mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 19:46:53 +00:00
321a6298a4
- Pharmacy: the sidebar entry now expands into Pharmacy + a new Inventory page (searchable medication stock with derived in-stock/low/out availability, backed by /api/inventory). Fix the dispensing-queue "Expiring" badge so it only flags courses ending within the next 7 days, not ones already elapsed. - Lab "Add analysis result": the patient picker no longer lists patients until you type and supports arrow-key + Enter selection; the test field offers a catalog of common analyses with an Advanced option (custom analysis + ref range); submitted results now show immediately in a Recent results feed. - Analysis: add a Live panel (real-time line chart) and replace the flat trend sparklines with bklit-ui area + bar charts (installed from the @bklit shadcn registry; chart CSS variables wired into the theme for light and dark). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
6.7 KiB
TypeScript
230 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useChartStable } from "./chart-context";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interval picker (inspired by liveline's pickInterval)
|
|
// Finds a "nice" step size that keeps labels ~minGap pixels apart.
|
|
// Uses hysteresis: keeps the previous interval if it still fits, preventing
|
|
// jittery step changes when the range oscillates near a boundary.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function pickNiceInterval(
|
|
valRange: number,
|
|
chartHeight: number,
|
|
minGap: number,
|
|
prevInterval: number
|
|
): number {
|
|
if (valRange <= 0 || chartHeight <= 0) {
|
|
return 1;
|
|
}
|
|
const pxPerUnit = chartHeight / valRange;
|
|
|
|
// Keep previous interval if it still produces reasonable spacing
|
|
if (prevInterval > 0) {
|
|
const px = prevInterval * pxPerUnit;
|
|
if (px >= minGap * 0.5 && px <= minGap * 3) {
|
|
return prevInterval;
|
|
}
|
|
}
|
|
|
|
// Try multiple divisor sequences to find the best nice step
|
|
const divisorSets = [
|
|
[2, 2.5, 2],
|
|
[2, 2, 2.5],
|
|
[2.5, 2, 2],
|
|
];
|
|
let best = Number.POSITIVE_INFINITY;
|
|
for (const divs of divisorSets) {
|
|
let span = 10 ** Math.ceil(Math.log10(valRange));
|
|
let i = 0;
|
|
let d = divs[i % 3] ?? 2;
|
|
while ((span / d) * pxPerUnit >= minGap) {
|
|
span /= d;
|
|
i++;
|
|
d = divs[i % 3] ?? 2;
|
|
}
|
|
if (span < best) {
|
|
best = span;
|
|
}
|
|
}
|
|
return best === Number.POSITIVE_INFINITY ? valRange / 5 : best;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Edge fade: labels near the top/bottom of the chart area fade out
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EDGE_FADE_PX = 28;
|
|
|
|
function edgeOpacity(y: number, chartHeight: number): number {
|
|
const fromEdge = Math.min(y, chartHeight - y);
|
|
if (fromEdge >= EDGE_FADE_PX) {
|
|
return 1;
|
|
}
|
|
if (fromEdge <= 0) {
|
|
return 0;
|
|
}
|
|
return fromEdge / EDGE_FADE_PX;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface LiveYAxisProps {
|
|
/** Minimum pixel gap between labels. Default: 36 */
|
|
minGap?: number;
|
|
/** Position. Default: "left" */
|
|
position?: "left" | "right";
|
|
/** Value formatter */
|
|
formatValue?: (v: number) => string;
|
|
/** Allow decimal tick values. Default: true */
|
|
allowDecimals?: boolean;
|
|
}
|
|
|
|
const tickSpring = { type: "spring" as const, stiffness: 180, damping: 24 };
|
|
|
|
export function LiveYAxis(props: LiveYAxisProps) {
|
|
const { containerRef } = useChartStable();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const container = containerRef.current;
|
|
if (!(mounted && container)) {
|
|
return null;
|
|
}
|
|
|
|
return <LiveYAxisInner {...props} container={container} />;
|
|
}
|
|
|
|
const LiveYAxisInner = memo(function LiveYAxisInner({
|
|
minGap = 36,
|
|
position = "left",
|
|
formatValue = (v: number) => v.toFixed(2),
|
|
allowDecimals = true,
|
|
container,
|
|
}: LiveYAxisProps & { container: HTMLDivElement }) {
|
|
const { yScale, margin, innerHeight } = useChartStable();
|
|
const intervalRef = useRef(0);
|
|
|
|
const domain = yScale.domain() as [number, number];
|
|
const minVal = domain[0];
|
|
const maxVal = domain[1];
|
|
const valRange = maxVal - minVal;
|
|
|
|
// Pick a nice interval with hysteresis
|
|
const interval = useMemo(() => {
|
|
const next = pickNiceInterval(
|
|
valRange,
|
|
innerHeight,
|
|
minGap,
|
|
intervalRef.current
|
|
);
|
|
intervalRef.current = next;
|
|
return next;
|
|
}, [valRange, innerHeight, minGap]);
|
|
|
|
// Stabilize the tick VALUE set: only recompute which ticks exist when the
|
|
// domain crosses an interval boundary. We quantize min/max to interval
|
|
// boundaries so the set doesn't change on every sub-pixel lerp frame.
|
|
const quantizedMin = interval > 0 ? Math.floor(minVal / interval) : 0;
|
|
const quantizedMax = interval > 0 ? Math.ceil(maxVal / interval) : 0;
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: quantized values are intentional coarse-grained deps for stability
|
|
const stableTickValues = useMemo(() => {
|
|
if (interval <= 0 || valRange <= 0) {
|
|
return [];
|
|
}
|
|
const expandedMin = minVal - interval * 0.5;
|
|
const expandedMax = maxVal + interval * 0.5;
|
|
const first = Math.ceil(expandedMin / interval) * interval;
|
|
const values: number[] = [];
|
|
for (let v = first; v <= expandedMax; v += interval) {
|
|
const rounded = Math.round(v * 1e10) / 1e10;
|
|
const isDecimal = !Number.isInteger(rounded);
|
|
if (isDecimal && !allowDecimals) {
|
|
continue;
|
|
}
|
|
values.push(rounded);
|
|
}
|
|
return values;
|
|
}, [
|
|
quantizedMin,
|
|
quantizedMax,
|
|
interval,
|
|
minVal,
|
|
maxVal,
|
|
valRange,
|
|
allowDecimals,
|
|
]);
|
|
|
|
// Pixel positions update every frame for smooth movement
|
|
const tickData = useMemo(
|
|
() =>
|
|
stableTickValues
|
|
.map((value) => {
|
|
const y = yScale(value) ?? 0;
|
|
return {
|
|
value,
|
|
y,
|
|
label: formatValue(value),
|
|
key: value.toPrecision(10),
|
|
edgeAlpha: edgeOpacity(y, innerHeight),
|
|
};
|
|
})
|
|
.filter((t) => t.y >= -10 && t.y <= innerHeight + 10),
|
|
[stableTickValues, yScale, innerHeight, formatValue]
|
|
);
|
|
|
|
const isLeft = position === "left";
|
|
|
|
return createPortal(
|
|
<div className="pointer-events-none absolute inset-0">
|
|
<div
|
|
className="absolute overflow-hidden"
|
|
style={{
|
|
top: margin.top,
|
|
height: innerHeight,
|
|
...(isLeft
|
|
? { left: 0, width: margin.left }
|
|
: { right: 0, width: margin.right }),
|
|
}}
|
|
>
|
|
<AnimatePresence initial={false}>
|
|
{tickData.map((tick) => (
|
|
<motion.div
|
|
animate={{ opacity: tick.edgeAlpha, y: tick.y }}
|
|
className="absolute w-full"
|
|
exit={{ opacity: 0 }}
|
|
initial={{ opacity: 0, y: tick.y }}
|
|
key={tick.key}
|
|
style={{
|
|
...(isLeft
|
|
? { right: 0, paddingRight: 8, textAlign: "right" }
|
|
: { left: 0, paddingLeft: 8, textAlign: "left" }),
|
|
}}
|
|
transition={tickSpring}
|
|
>
|
|
<span className="whitespace-nowrap font-mono text-chart-label text-xs">
|
|
{tick.label}
|
|
</span>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>,
|
|
container
|
|
);
|
|
});
|
|
|
|
LiveYAxis.displayName = "LiveYAxis";
|
|
|
|
export default LiveYAxis;
|