Files
temetro/frontend/components/charts/area-chart.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

273 lines
7.7 KiB
TypeScript

"use client";
import { ParentSize } from "@visx/responsive";
import type { Transition } from "motion/react";
import {
Children,
type CSSProperties,
isValidElement,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { Area, type AreaProps } from "./area";
import type { LineConfig, Margin } from "./chart-context";
import { ChartLoadingLabel } from "./chart-loading-label";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_STATUS,
DEFAULT_Y_DOMAIN_TWEEN_MS,
resolveRestingChartPhase,
} from "./chart-phase";
import { PatternArea } from "./pattern-area";
import { TimeSeriesChartInner } from "./time-series-chart-shell";
export interface AreaChartProps {
/** Data array - each item should have a date field and numeric values */
data: Record<string, unknown>[];
/** Key in data for the x-axis (date). Default: "date" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1100 */
animationDuration?: number;
/** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */
animationEasing?: string;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers reveal replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Loading vs ready — drives chart phase and loading chrome. Default: `"ready"`. */
status?: ChartStatus;
/** Centered shimmer label while loading. */
loadingLabel?: string;
/** Animate y-domain over this duration (ms) on status transitions. Default: 500. */
yDomainTweenDuration?: number;
/** Animate y-domain when status or target domain changes. Default: true */
yDomainTween?: boolean;
/** Visible x-domain for brush zoom. */
xDomain?: [Date, Date];
/** Full dataset length for x-scale padding when `xDomain` is set. */
xDomainSlotCount?: number;
/** Tween y-domain when brush changes the visible x-range. Default: false */
tweenYDomainOnXDomainChange?: boolean;
/** Inline container styles (e.g. fixed height for brush strip). */
style?: CSSProperties;
/** Fires when the internal chart phase changes (e.g. OG capture readiness). */
onPhaseChange?: (phase: ChartPhase) => void;
/** Child components (Area, Grid, ChartTooltip, etc.) */
children: ReactNode;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
function extractAreaConfigs(children: ReactNode): LineConfig[] {
const configs: LineConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const childType = child.type as {
displayName?: string;
name?: string;
};
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
const props = child.props as AreaProps | undefined;
const isPatternArea =
componentName === "PatternArea" || child.type === PatternArea;
const isAreaComponent =
componentName === "Area" ||
child.type === Area ||
(props &&
typeof props.dataKey === "string" &&
props.dataKey.length > 0 &&
!isPatternArea);
if (isAreaComponent && props?.dataKey) {
configs.push({
dataKey: props.dataKey,
stroke: props.stroke || props.fill || "var(--chart-line-primary)",
strokeWidth: props.strokeWidth || 2,
yAxisId: props.yAxisId,
});
}
});
return configs;
}
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing?: string;
enterTransition?: Transition;
revealSignature?: string;
chartStatus: ChartStatus;
loadingLabel?: string;
yDomainTweenDuration: number;
yDomainTween: boolean;
xDomain?: [Date, Date];
xDomainSlotCount?: number;
tweenYDomainOnXDomainChange?: boolean;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange: (phase: ChartPhase) => void;
}
function ChartInner({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing,
enterTransition,
revealSignature,
chartStatus,
loadingLabel,
yDomainTweenDuration,
yDomainTween,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange,
children,
containerRef,
onPhaseChange,
}: ChartInnerProps) {
const lines = useMemo(() => extractAreaConfigs(children), [children]);
return (
<TimeSeriesChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={chartStatus}
clipPathId="chart-area-grow-clip"
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
lines={lines}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={onPhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</TimeSeriesChartInner>
);
}
export function AreaChart({
data,
xDataKey = "date",
margin: marginProp,
animationDuration = 1100,
animationEasing,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
status = DEFAULT_CHART_STATUS,
loadingLabel,
yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
yDomainTween = true,
xDomain,
xDomainSlotCount,
tweenYDomainOnXDomainChange = false,
style,
onPhaseChange,
children,
}: AreaChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(status)
);
const handlePhaseChange = useCallback(
(phase: ChartPhase) => {
setChartPhase(phase);
onPhaseChange?.(phase);
},
[onPhaseChange]
);
const showLoadingLabel = Boolean(
loadingLabel?.trim() &&
(chartPhase === "loading" ||
chartPhase === "exiting" ||
chartPhase === "gridTweenReady" ||
chartPhase === "revealingLoading")
);
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ aspectRatio, touchAction: "none", ...style }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
chartStatus={status}
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
loadingLabel={loadingLabel}
margin={margin}
onPhaseChange={handlePhaseChange}
revealSignature={revealSignature}
tweenYDomainOnXDomainChange={tweenYDomainOnXDomainChange}
width={width}
xDataKey={xDataKey}
xDomain={xDomain}
xDomainSlotCount={xDomainSlotCount}
yDomainTween={yDomainTween}
yDomainTweenDuration={yDomainTweenDuration}
>
{children}
</ChartInner>
)}
</ParentSize>
{showLoadingLabel ? (
<ChartLoadingLabel
exiting={chartPhase !== "loading"}
text={loadingLabel}
/>
) : null}
</div>
);
}
export { Area, type AreaProps } from "./area";
export default AreaChart;