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

352 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { curveMonotoneX } from "@visx/curve";
import { AreaClosed, LinePath } from "@visx/shape";
// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AreaGradientDefs } from "./area-gradient-defs";
import { chartCssVars, useChartStable, useYScale } from "./chart-context";
import type { ChartPhase } from "./chart-phase";
import { type FadeEdges, resolveFadeSides } from "./fade-edges";
import {
type LineLoadingPulseMode,
LineLoadingPulseStroke,
resolveLineLoadingPulseMode,
} from "./line-loading-pulse";
import { LINE_LOADING_LOOP_PAUSE_MS } from "./line-loading-timing";
import {
resolveDashTailBounds,
usePathStrokeMetrics,
} from "./path-stroke-utils";
import { SeriesDashTailOverlay } from "./series-dash-tail-overlay";
import { SeriesHighlightLayer } from "./series-highlight-layer";
import { SeriesHoverDim } from "./series-hover-dim";
import { SeriesMarkers } from "./series-markers";
import type { SeriesPointMarkerStyle } from "./series-point-marker";
export interface AreaProps {
/** Key in data to use for y values */
dataKey: string;
/** Y-scale group id (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
/** Fill color for the area gradient start. Default: var(--chart-line-primary) */
fill?: string;
/** Fill opacity at the top of the area. Default: 0.4 */
fillOpacity?: number;
/** Stroke color for the line. Default: same as fill */
stroke?: string;
/** Stroke width. Default: 2 */
strokeWidth?: number;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** Whether to animate the area. Default: true */
animate?: boolean;
/** Whether to show the stroke line. Default: true */
showLine?: boolean;
/** Whether to show highlight segment on hover. Default: true */
showHighlight?: boolean;
/** Gradient opacity at bottom (0 = fully transparent). Default: 0 */
gradientToOpacity?: number;
/**
* Vertical extent of the fill gradient (01). `1` fades across the full
* height; lower values compress the gradient toward the top.
*/
gradientSpan?: number;
/**
* Fade the area fill (and stroke) toward transparent at the chart edges.
* - `true` fades both edges, `false` disables the fade entirely.
* - `"left"` / `"right"` fades only that side — useful when the opposite
* edge butts up against another element you don't want to fade into.
* Default: false
*/
fadeEdges?: FadeEdges;
/** Render scatter-style circle markers at each data point. Default: false */
showMarkers?: boolean;
/** Marker styling (same options as Scatter). */
markers?: SeriesPointMarkerStyle;
/**
* Data index from which the line stroke becomes dashed (inclusive).
* Useful for projecting incomplete periods, e.g. dashed from yesterday through today.
*/
dashFromIndex?: number;
/** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */
dashArray?: string;
/** Pulse stroke color while chart is loading. Default: var(--foreground) */
loadingStroke?: string;
/** Pulse stroke opacity while chart is loading. Default: 0.5 */
loadingStrokeOpacity?: number;
/**
* Show the loading pulse overlay. Default: follows chart loading phase.
* Set `false` to disable even during loading.
*/
loading?: boolean;
/** Override pulse animation mode (loop / exit / enter). */
loadingPulseMode?: LineLoadingPulseMode;
}
function useAreaLoadingPulseState(
chartPhase: ChartPhase,
loading: boolean | undefined,
loadingPulseMode: LineLoadingPulseMode | undefined,
notifyLoadingPulseComplete?: () => void
) {
const phasePulseMode = resolveLineLoadingPulseMode(chartPhase);
const pulseMode =
loading === false
? null
: (loadingPulseMode ?? (loading === true ? "loop" : phasePulseMode));
const showLoadingPulse = pulseMode != null;
const showSeriesContent =
chartPhase === "revealing" ||
chartPhase === "ready" ||
chartPhase === "exitingReady";
const [pulseEpoch, setPulseEpoch] = useState(0);
const handleLoadingPulseComplete = useCallback(() => {
if (pulseMode === "loop") {
window.setTimeout(() => {
setPulseEpoch((epoch) => epoch + 1);
}, LINE_LOADING_LOOP_PAUSE_MS);
return;
}
notifyLoadingPulseComplete?.();
}, [notifyLoadingPulseComplete, pulseMode]);
return {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: mirrors Line series layout (fill, stroke, dash, markers, pulse)
export function Area({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
fillOpacity = 0.4,
stroke,
strokeWidth = 2,
curve = curveMonotoneX,
animate = true,
showLine = true,
showHighlight = true,
gradientToOpacity = 0,
gradientSpan = 1,
fadeEdges = false,
showMarkers = false,
markers,
dashFromIndex,
dashArray = "6,4",
loading,
loadingStroke = chartCssVars.foreground,
loadingStrokeOpacity = 0.5,
loadingPulseMode,
}: AreaProps) {
// Stable slice only: hover state lives inside `<SeriesHoverDim>` and
// `<SeriesHighlightLayer>` so this component (and its expensive
// <SeriesDashTailOverlay> child) does not re-render on cursor motion.
// The reveal-clip is now a single shared clipPath at the chart-shell
// level (`time-series-chart-shell.tsx`); we no longer render a per-area
// `<ChartRevealClip>` or read `revealEpoch` here.
const {
data,
renderData,
xScale,
innerHeight,
innerWidth,
xAccessor,
lines,
chartPhase,
notifyLoadingPulseComplete,
} = useChartStable();
const yScale = useYScale(yAxisId);
const {
handleLoadingPulseComplete,
pulseMode,
pulseEpoch,
showLoadingPulse,
showSeriesContent,
} = useAreaLoadingPulseState(
chartPhase,
loading,
loadingPulseMode,
notifyLoadingPulseComplete
);
const seriesIndex = useMemo(() => {
const index = lines.findIndex((line) => line.dataKey === dataKey);
return index >= 0 ? index : 0;
}, [lines, dataKey]);
const pathRef = useRef<SVGPathElement>(null);
const { pathLength, pathD } = usePathStrokeMetrics(pathRef, [
renderData,
innerWidth,
dashFromIndex,
showLine,
showSeriesContent,
showLoadingPulse,
]);
// Unique IDs for this area
const uniqueId = useId();
const gradientId = `area-gradient-${dataKey}-${uniqueId}`;
const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`;
const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`;
const edgeGradientId = `${edgeMaskId}-gradient`;
const isPatternFill = fill.startsWith("url(");
const showAreaFill = isPatternFill || fillOpacity > 0;
const areaFill = isPatternFill ? fill : `url(#${gradientId})`;
// Resolved stroke color (defaults to fill; pattern URLs need a real color)
const resolvedStroke =
stroke || (isPatternFill ? chartCssVars.linePrimary : fill);
const getY = useCallback(
(d: Record<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : 0;
},
[dataKey, yScale]
);
const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
// The stroke gradient is only emitted when at least one edge fades, so fall
// back to the resolved solid color otherwise — avoids an invalid url(#...).
const fadeSides = resolveFadeSides(fadeEdges);
const useViewportEdgeFade = fadeSides.any && !isPatternFill;
let strokePaint = resolvedStroke;
if (!useViewportEdgeFade && fadeSides.any) {
strokePaint = `url(#${strokeGradientId})`;
}
const highlightEnabled =
showHighlight && showLine && !showLoadingPulse && showSeriesContent;
const showSeriesStroke = showSeriesContent && showLine;
let visibleStroke = "transparent";
if (showSeriesStroke && !hasDashTail) {
visibleStroke = strokePaint;
}
const shouldMeasurePath = showLine && (showSeriesContent || showLoadingPulse);
const seriesLayers = (
<>
{showSeriesContent && showAreaFill ? (
<AreaClosed
curve={curve}
data={renderData}
fill={areaFill}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
yScale={yScale}
/>
) : null}
{shouldMeasurePath ? (
<>
<LinePath
curve={curve}
data={renderData}
innerRef={pathRef}
stroke={visibleStroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={getY}
/>
{showSeriesStroke ? (
<SeriesDashTailOverlay
dashArray={dashArray}
dashFromIndex={dashFromIndex}
data={data}
innerHeight={innerHeight}
innerWidth={innerWidth}
pathD={pathD}
pathLength={pathLength}
stroke={strokePaint}
strokeWidth={strokeWidth}
xAccessor={xAccessor}
xScale={xScale}
/>
) : null}
</>
) : null}
</>
);
return (
<>
<AreaGradientDefs
edgeGradientId={edgeGradientId}
edgeMaskId={edgeMaskId}
fadeEdges={fadeEdges}
fill={fill}
fillOpacity={fillOpacity}
gradientId={gradientId}
gradientSpan={gradientSpan}
gradientToOpacity={gradientToOpacity}
innerHeight={innerHeight}
innerWidth={innerWidth}
isPatternFill={isPatternFill}
resolvedStroke={resolvedStroke}
strokeGradientId={strokeGradientId}
/>
<SeriesHoverDim
dimOpacity={0.6}
enabled={showHighlight}
seriesIndex={seriesIndex}
>
{useViewportEdgeFade ? (
<g mask={`url(#${edgeMaskId})`}>{seriesLayers}</g>
) : (
seriesLayers
)}
</SeriesHoverDim>
{/* Highlight segment on hover — isolated hover subscriber. */}
<SeriesHighlightLayer
enabled={highlightEnabled}
height={innerHeight}
pathRef={pathRef}
stroke={resolvedStroke}
strokeWidth={strokeWidth}
/>
{showMarkers && showSeriesContent ? (
<SeriesMarkers
animate={animate}
dataKey={dataKey}
{...markers}
fill={markers?.fill ?? resolvedStroke}
stroke={markers?.stroke ?? markers?.fill ?? resolvedStroke}
/>
) : null}
{showLoadingPulse && pathD && innerWidth > 0 ? (
<LineLoadingPulseStroke
key="loading-pulse"
loopEpoch={pulseEpoch}
mode={pulseMode ?? undefined}
onCycleComplete={handleLoadingPulseComplete}
pathD={pathD}
stroke={loadingStroke}
strokeOpacity={loadingStrokeOpacity}
strokeWidth={strokeWidth}
/>
) : null}
</>
);
}
Area.displayName = "Area";
export default Area;