Files
temetro/frontend/components/charts/live-y-axis.tsx
T
Khalid Abdi 321a6298a4 frontend: pharmacy inventory, lab add-result & analysis charts
- 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>
2026-06-12 19:22:07 +03:00

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;