Files
temetro/frontend/components/charts/line-loading-pulse.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

219 lines
5.3 KiB
TypeScript

"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect, useId } from "react";
import { chartCssVars, useChartStable } from "./chart-context";
import type { ChartPhase } from "./chart-phase";
import {
fadeGradientStops,
resolveFadeSides,
viewportFadeGradientAttrs,
} from "./fade-edges";
import {
LINE_LOADING_PULSE_CYCLE_S,
LINE_LOADING_PULSE_EASE,
} from "./line-loading-timing";
const CLIP_PADDING = 10;
export type LineLoadingPulseMode = "loop" | "exit" | "enter";
export function resolveLineLoadingPulseMode(
phase: ChartPhase
): LineLoadingPulseMode | null {
switch (phase) {
case "loading":
return "loop";
case "exiting":
return "exit";
case "revealingLoading":
return "enter";
default:
return null;
}
}
export interface LineLoadingPulseStrokeProps {
pathD: string;
mode?: LineLoadingPulseMode;
/** Bumps to restart loop cycles without remounting the stroke. */
loopEpoch?: number;
stroke?: string;
/** Stroke opacity for the animated segment. Default: 0.5 */
strokeOpacity?: number;
strokeWidth?: number;
onCycleComplete?: () => void;
}
function useGrowExitClip(
innerWidth: number,
mode: LineLoadingPulseMode,
loopEpoch: number,
onComplete?: () => void
) {
const progress = useMotionValue(0);
const paddedFullWidth = innerWidth + CLIP_PADDING * 2;
const rightEdge = innerWidth + CLIP_PADDING;
const clipWidth = useTransform(progress, (p) => {
if (p <= 0.5) {
return (p / 0.5) * paddedFullWidth;
}
const shrink = (p - 0.5) / 0.5;
return (1 - shrink) * paddedFullWidth;
});
const clipX = useTransform(progress, (p) => {
if (p <= 0.5) {
return -CLIP_PADDING;
}
const shrink = (p - 0.5) / 0.5;
return rightEdge - (1 - shrink) * paddedFullWidth;
});
// biome-ignore lint/correctness/useExhaustiveDependencies: loopEpoch restarts pulse when orchestrator advances
useEffect(() => {
if (innerWidth <= 0) {
return;
}
const halfCycleS = LINE_LOADING_PULSE_CYCLE_S / 2;
let cancelled = false;
let controls: ReturnType<typeof animate> | undefined;
const finish = () => {
if (!cancelled) {
onComplete?.();
}
};
const runShrink = (from: number) => {
const shrinkDuration = halfCycleS * ((1 - from) / 0.5);
controls = animate(progress, 1, {
duration: Math.max(shrinkDuration, 0.01),
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
};
if (mode === "loop") {
progress.set(0);
controls = animate(progress, 1, {
duration: LINE_LOADING_PULSE_CYCLE_S,
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
return () => {
cancelled = true;
controls?.stop();
};
}
if (mode === "exit") {
const current = progress.get();
if (current < 0.5) {
const growDuration = halfCycleS * ((0.5 - current) / 0.5);
controls = animate(progress, 0.5, {
duration: Math.max(growDuration, 0.01),
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: () => {
if (!cancelled) {
runShrink(0.5);
}
},
});
} else {
runShrink(current);
}
return () => {
cancelled = true;
controls?.stop();
};
}
if (mode === "enter") {
progress.set(0);
controls = animate(progress, 0.5, {
duration: halfCycleS,
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: finish,
});
return () => {
cancelled = true;
controls?.stop();
};
}
}, [innerWidth, loopEpoch, mode, onComplete, progress]);
return { clipX, clipWidth };
}
export function LineLoadingPulseStroke({
pathD,
mode = "loop",
loopEpoch = 0,
stroke = chartCssVars.foreground,
strokeOpacity = 0.5,
strokeWidth = 2.5,
onCycleComplete,
}: LineLoadingPulseStrokeProps) {
const { innerWidth, innerHeight } = useChartStable();
const reactId = useId();
const clipPathId = `line-loading-clip-${reactId}`;
const gradientId = `line-loading-gradient-${reactId}`;
const fadeStops = fadeGradientStops(resolveFadeSides(true));
const clipHeight = innerHeight + CLIP_PADDING * 2;
const { clipX, clipWidth } = useGrowExitClip(
innerWidth,
mode,
loopEpoch,
onCycleComplete
);
if (innerWidth <= 0) {
return null;
}
return (
<>
<defs>
<clipPath id={clipPathId}>
<motion.rect
height={clipHeight}
style={{ width: clipWidth, x: clipX }}
y={-CLIP_PADDING}
/>
</clipPath>
<linearGradient
id={gradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{fadeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
stopColor={stroke}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
</defs>
<path
clipPath={`url(#${clipPathId})`}
d={pathD}
fill="none"
opacity={strokeOpacity}
stroke={`url(#${gradientId})`}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</>
);
}
LineLoadingPulseStroke.displayName = "LineLoadingPulseStroke";
export default LineLoadingPulseStroke;