mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 18:49:29 +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>
628 lines
17 KiB
TypeScript
628 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { scaleLinear, scaleTime } from "@visx/scale";
|
|
import { bisector, extent } from "d3-array";
|
|
import type { Transition } from "motion/react";
|
|
import {
|
|
Children,
|
|
cloneElement,
|
|
isValidElement,
|
|
memo,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
} from "react";
|
|
import {
|
|
DEFAULT_ANIMATION_EASING,
|
|
DEFAULT_CHART_ENTER_TRANSITION,
|
|
} from "./animation";
|
|
import { resolveChartChildElement } from "./chart-child-passthrough";
|
|
import { ChartProvider, type LineConfig, type Margin } from "./chart-context";
|
|
import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
|
|
import { shortDateFmt } from "./chart-formatters";
|
|
import {
|
|
type ChartPhase,
|
|
type ChartStatus,
|
|
DEFAULT_CHART_STATUS,
|
|
DEFAULT_Y_DOMAIN_TWEEN_MS,
|
|
isChartInteractionPhase,
|
|
} from "./chart-phase";
|
|
import { ChartRevealClip } from "./chart-reveal-clip";
|
|
import {
|
|
decimateTimeSeries,
|
|
maxRenderPointsForWidth,
|
|
} from "./decimate-time-series";
|
|
import { filterDataByXDomain } from "./filter-data-by-x-domain";
|
|
import {
|
|
generateChartSkeletonData,
|
|
generateChartSkeletonFromTarget,
|
|
} from "./generate-chart-skeleton-data";
|
|
import {
|
|
computeSeriesBarRevealClipPadding,
|
|
computeSeriesBarWidth,
|
|
} from "./series-bar-layout";
|
|
import { useStaticChartPreview } from "./static-chart-preview-context";
|
|
import { useAnimatedYDomains } from "./use-animated-y-domains";
|
|
import { useChartInteraction } from "./use-chart-interaction";
|
|
import { useChartPhaseOrchestrator } from "./use-chart-phase-orchestrator";
|
|
import {
|
|
buildYScalesFromDomains,
|
|
DEFAULT_Y_AXIS_ID,
|
|
getPrimaryYScale,
|
|
groupLinesByYAxisId,
|
|
} from "./y-axis-scales";
|
|
import { computeYDomainsByAxis } from "./y-domain-utils";
|
|
|
|
function collectNumericExtents(
|
|
data: Record<string, unknown>[],
|
|
dataKeys: string[]
|
|
) {
|
|
let minValue = Number.POSITIVE_INFINITY;
|
|
let maxValue = Number.NEGATIVE_INFINITY;
|
|
|
|
for (const d of data) {
|
|
for (const key of dataKeys) {
|
|
const value = d[key];
|
|
if (typeof value === "number") {
|
|
if (value < minValue) {
|
|
minValue = value;
|
|
}
|
|
if (value > maxValue) {
|
|
maxValue = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (minValue === Number.POSITIVE_INFINITY) {
|
|
return { minValue: 0, maxValue: 100 };
|
|
}
|
|
|
|
return { minValue, maxValue };
|
|
}
|
|
|
|
function resolveTimeSeriesYDomain(
|
|
data: Record<string, unknown>[],
|
|
dataKeys: string[],
|
|
yScaleDomainMax: number | undefined
|
|
): [number, number] {
|
|
if (yScaleDomainMax != null && yScaleDomainMax > 0) {
|
|
return [0, yScaleDomainMax * 1.1];
|
|
}
|
|
|
|
const { minValue, maxValue } = collectNumericExtents(data, dataKeys);
|
|
|
|
if (minValue >= 0) {
|
|
const top = maxValue <= 0 ? 100 : maxValue * 1.1;
|
|
return [0, top];
|
|
}
|
|
|
|
const padding = (maxValue - minValue) * 0.05 || 1;
|
|
return [minValue - padding, maxValue + padding];
|
|
}
|
|
|
|
/** Markers render after the interaction overlay so they stay clickable. */
|
|
export function isPostOverlayComponent(child: ReactElement): boolean {
|
|
const childType = child.type as {
|
|
displayName?: string;
|
|
name?: string;
|
|
__isChartMarkers?: boolean;
|
|
};
|
|
|
|
if (childType.__isChartMarkers) {
|
|
return true;
|
|
}
|
|
|
|
const componentName =
|
|
typeof child.type === "function"
|
|
? childType.displayName || childType.name || ""
|
|
: "";
|
|
|
|
return (
|
|
componentName === "ChartMarkers" ||
|
|
componentName === "MarkerGroup" ||
|
|
componentName === "ChartBrush"
|
|
);
|
|
}
|
|
|
|
const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([
|
|
"Background",
|
|
"Grid",
|
|
"XAxis",
|
|
"YAxis",
|
|
"BarXAxis",
|
|
"BarYAxis",
|
|
"LiveXAxis",
|
|
"LiveYAxis",
|
|
]);
|
|
|
|
/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */
|
|
export function isClipExcludedComponent(child: ReactElement): boolean {
|
|
const childType = child.type as { displayName?: string; name?: string };
|
|
const componentName =
|
|
typeof child.type === "function"
|
|
? childType.displayName || childType.name || ""
|
|
: "";
|
|
return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName);
|
|
}
|
|
|
|
function ensureChildKey(child: ReactElement, index: number): ReactElement {
|
|
if (child.key != null) {
|
|
return child;
|
|
}
|
|
return cloneElement(child, { key: `chart-child-${index}` });
|
|
}
|
|
|
|
export interface TimeSeriesChartInnerProps {
|
|
width: number;
|
|
height: number;
|
|
data: Record<string, unknown>[];
|
|
xDataKey: string;
|
|
margin: Margin;
|
|
animationDuration: number;
|
|
animationEasing?: string;
|
|
enterTransition?: Transition;
|
|
/** Signature of motion URL state — triggers reveal replay when it changes. */
|
|
revealSignature?: string;
|
|
children: ReactNode;
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
/** Series keys driving y-domain and tooltip (Line / Area / SeriesBar configs). */
|
|
lines: LineConfig[];
|
|
/** SVG clipPath id for grow animation. */
|
|
clipPathId: string;
|
|
/** Optional ComposedChart bar layout (forwarded into context). */
|
|
composedBarDataKeys?: string[];
|
|
composedBarSize?: number;
|
|
composedMaxBarSize?: number;
|
|
composedBarGap?: number;
|
|
composedStacked?: boolean;
|
|
composedStackOffsets?: Map<number, Map<string, number>>;
|
|
composedStackGap?: number;
|
|
/** When set, drives the y-axis max instead of scanning `lines` (e.g. stacked bar totals). */
|
|
yScaleDomainMax?: number;
|
|
/** Loading vs ready — drives chart phase until transition orchestration lands. */
|
|
chartStatus?: ChartStatus;
|
|
loadingLabel?: string;
|
|
/** Animate y-domain on status / data transitions. Default: true */
|
|
yDomainTween?: boolean;
|
|
yDomainTweenDuration?: number;
|
|
/** Visible x-domain for brush zoom. When set, y-domain and series use data in this range. */
|
|
xDomain?: [Date, Date];
|
|
/** Full dataset length for x-scale padding when `xDomain` is set. */
|
|
xDomainSlotCount?: number;
|
|
/** Tween y-domain when the visible x-range changes during the ready phase. */
|
|
tweenYDomainOnXDomainChange?: boolean;
|
|
onPhaseChange?: (phase: ChartPhase) => void;
|
|
}
|
|
|
|
export function TimeSeriesChartInner(props: TimeSeriesChartInnerProps) {
|
|
const { width, height } = props;
|
|
if (width < 10 || height < 10) {
|
|
return null;
|
|
}
|
|
return <TimeSeriesChartCore {...props} />;
|
|
}
|
|
|
|
const TimeSeriesChartCore = memo(function TimeSeriesChartCore({
|
|
width,
|
|
height,
|
|
data,
|
|
xDataKey,
|
|
margin,
|
|
animationDuration,
|
|
animationEasing = DEFAULT_ANIMATION_EASING,
|
|
enterTransition,
|
|
revealSignature = "",
|
|
children,
|
|
containerRef,
|
|
lines,
|
|
clipPathId,
|
|
composedBarDataKeys,
|
|
composedBarSize,
|
|
composedMaxBarSize,
|
|
composedBarGap,
|
|
composedStacked,
|
|
composedStackOffsets,
|
|
composedStackGap,
|
|
yScaleDomainMax,
|
|
chartStatus = DEFAULT_CHART_STATUS,
|
|
loadingLabel,
|
|
yDomainTween = true,
|
|
yDomainTweenDuration = DEFAULT_Y_DOMAIN_TWEEN_MS,
|
|
xDomain,
|
|
xDomainSlotCount,
|
|
tweenYDomainOnXDomainChange = false,
|
|
onPhaseChange,
|
|
}: TimeSeriesChartInnerProps) {
|
|
const staticPreview = useStaticChartPreview();
|
|
const innerWidth = width - margin.left - margin.right;
|
|
const innerHeight = height - margin.top - margin.bottom;
|
|
|
|
const resolveYDomain = useCallback(
|
|
(sourceData: Record<string, unknown>[], dataKeys: string[]) => {
|
|
const axisGroups = groupLinesByYAxisId(lines);
|
|
const usesDefaultOnly =
|
|
axisGroups.size === 1 && axisGroups.has(DEFAULT_Y_AXIS_ID);
|
|
const domainMax =
|
|
usesDefaultOnly && yScaleDomainMax != null
|
|
? yScaleDomainMax
|
|
: undefined;
|
|
return resolveTimeSeriesYDomain(sourceData, dataKeys, domainMax);
|
|
},
|
|
[lines, yScaleDomainMax]
|
|
);
|
|
|
|
const skeletonData = useMemo(() => {
|
|
const primaryKey = lines[0]?.dataKey ?? "value";
|
|
if (data.length === 0) {
|
|
return generateChartSkeletonData({ dataKey: primaryKey });
|
|
}
|
|
return generateChartSkeletonFromTarget(data, primaryKey);
|
|
}, [data, lines]);
|
|
|
|
const {
|
|
chartPhase,
|
|
plotData,
|
|
revealEpoch,
|
|
concealEpoch,
|
|
isLoaded,
|
|
notifyLoadingPulseComplete,
|
|
notifyRevealConcealComplete,
|
|
notifyYDomainTweenComplete,
|
|
} = useChartPhaseOrchestrator({
|
|
animationDuration,
|
|
chartStatus,
|
|
revealSignature,
|
|
skeletonData,
|
|
skipEnterReveal: staticPreview,
|
|
targetData: data,
|
|
yDomainTweenDuration,
|
|
});
|
|
|
|
useEffect(() => {
|
|
onPhaseChange?.(chartPhase);
|
|
}, [chartPhase, onPhaseChange]);
|
|
|
|
const xAccessor = useCallback(
|
|
(d: Record<string, unknown>): Date => {
|
|
const value = d[xDataKey];
|
|
return value instanceof Date ? value : new Date(value as string | number);
|
|
},
|
|
[xDataKey]
|
|
);
|
|
|
|
const bisectDate = useMemo(
|
|
() => bisector<Record<string, unknown>, Date>((d) => xAccessor(d)).left,
|
|
[xAccessor]
|
|
);
|
|
|
|
const visiblePlotData = useMemo(() => {
|
|
if (!xDomain) {
|
|
return plotData;
|
|
}
|
|
return filterDataByXDomain(plotData, xDomain, xAccessor);
|
|
}, [plotData, xDomain, xAccessor]);
|
|
|
|
const xScale = useMemo(() => {
|
|
const minTime = xDomain
|
|
? xDomain[0].getTime()
|
|
: (extent(plotData, (d) => xAccessor(d).getTime())[0] ?? 0);
|
|
const maxTime = xDomain
|
|
? xDomain[1].getTime()
|
|
: (extent(plotData, (d) => xAccessor(d).getTime())[1] ?? minTime);
|
|
|
|
return scaleTime({
|
|
range: [0, innerWidth],
|
|
domain: [minTime, maxTime],
|
|
});
|
|
}, [innerWidth, plotData, xAccessor, xDomain]);
|
|
|
|
// When brushing, keep the full series for path rendering so edge fades stay
|
|
// anchored to the viewport while the line pans through them. Y-domain and
|
|
// interaction still use the filtered visible slice.
|
|
const seriesSourceData = xDomain ? plotData : visiblePlotData;
|
|
|
|
const renderData = useMemo(() => {
|
|
const valueKeys = lines.map((line) => line.dataKey);
|
|
return decimateTimeSeries(
|
|
seriesSourceData,
|
|
maxRenderPointsForWidth(innerWidth),
|
|
valueKeys
|
|
);
|
|
}, [seriesSourceData, innerWidth, lines]);
|
|
|
|
const columnWidth = useMemo(() => {
|
|
const slotCount =
|
|
xDomain && xDomainSlotCount != null
|
|
? xDomainSlotCount
|
|
: visiblePlotData.length;
|
|
if (slotCount < 2) {
|
|
return 0;
|
|
}
|
|
return innerWidth / (slotCount - 1);
|
|
}, [innerWidth, visiblePlotData.length, xDomain, xDomainSlotCount]);
|
|
|
|
const yDomainSkeletonByAxis = useMemo(
|
|
() =>
|
|
computeYDomainsByAxis({
|
|
lines,
|
|
resolveDomain: (dataKeys) => resolveYDomain(skeletonData, dataKeys),
|
|
}),
|
|
[lines, resolveYDomain, skeletonData]
|
|
);
|
|
|
|
const yDomainTargetByAxis = useMemo(
|
|
() =>
|
|
computeYDomainsByAxis({
|
|
lines,
|
|
resolveDomain: (dataKeys) =>
|
|
resolveYDomain(xDomain ? visiblePlotData : data, dataKeys),
|
|
}),
|
|
[data, lines, resolveYDomain, visiblePlotData, xDomain]
|
|
);
|
|
|
|
const animatedYDomainsByAxis = useAnimatedYDomains({
|
|
chartPhase,
|
|
durationMs: yDomainTweenDuration,
|
|
enabled: yDomainTween,
|
|
onSettled: notifyYDomainTweenComplete,
|
|
skeletonByAxis: yDomainSkeletonByAxis,
|
|
targetByAxis: yDomainTargetByAxis,
|
|
tweenOnTargetChange: tweenYDomainOnXDomainChange && xDomain != null,
|
|
});
|
|
|
|
const yDomainsForScales = animatedYDomainsByAxis;
|
|
|
|
const yScales = useMemo(
|
|
() =>
|
|
buildYScalesFromDomains({
|
|
domainsByAxis: yDomainsForScales,
|
|
innerHeight,
|
|
lines,
|
|
}),
|
|
[yDomainsForScales, innerHeight, lines]
|
|
);
|
|
|
|
const yScale = getPrimaryYScale(
|
|
yScales,
|
|
scaleLinear({ range: [innerHeight, 0], domain: [0, 100], nice: true })
|
|
);
|
|
|
|
const dateLabels = useMemo(
|
|
() => visiblePlotData.map((d) => shortDateFmt.format(xAccessor(d))),
|
|
[visiblePlotData, xAccessor]
|
|
);
|
|
|
|
const canInteract = isLoaded && isChartInteractionPhase(chartPhase);
|
|
|
|
const {
|
|
tooltipData,
|
|
setTooltipData,
|
|
selection,
|
|
clearSelection,
|
|
interactionHandlers,
|
|
interactionStyle,
|
|
} = useChartInteraction({
|
|
bisectDate,
|
|
canInteract,
|
|
data: visiblePlotData,
|
|
lines,
|
|
margin,
|
|
xAccessor,
|
|
xScale,
|
|
yScale,
|
|
yScales,
|
|
});
|
|
|
|
const defsChildren: ReactElement[] = [];
|
|
const clipExcludedChildren: ReactElement[] = [];
|
|
const preOverlayChildren: ReactElement[] = [];
|
|
const postOverlayChildren: ReactElement[] = [];
|
|
|
|
Children.forEach(children, (child, index) => {
|
|
if (!isValidElement(child)) {
|
|
return;
|
|
}
|
|
|
|
const keyedChild = ensureChildKey(child, index);
|
|
const resolvedChild = resolveChartChildElement(keyedChild);
|
|
|
|
if (isGradientDefComponent(resolvedChild)) {
|
|
defsChildren.push(resolvedChild);
|
|
} else if (isPatternDefComponent(resolvedChild)) {
|
|
preOverlayChildren.push(resolvedChild);
|
|
} else if (isPostOverlayComponent(resolvedChild)) {
|
|
postOverlayChildren.push(resolvedChild);
|
|
} else if (isClipExcludedComponent(resolvedChild)) {
|
|
clipExcludedChildren.push(resolvedChild);
|
|
} else {
|
|
preOverlayChildren.push(resolvedChild);
|
|
}
|
|
});
|
|
|
|
const contextValue = useMemo(
|
|
() => ({
|
|
data: visiblePlotData,
|
|
renderData,
|
|
xScale,
|
|
yScale,
|
|
yScales,
|
|
width,
|
|
height,
|
|
innerWidth,
|
|
innerHeight,
|
|
margin,
|
|
columnWidth,
|
|
tooltipData,
|
|
setTooltipData,
|
|
containerRef,
|
|
lines,
|
|
chartPhase,
|
|
chartStatus,
|
|
loadingLabel,
|
|
yDomainTweenDuration,
|
|
yDomainSkeletonByAxis,
|
|
yDomainTargetByAxis,
|
|
isLoaded,
|
|
animationDuration,
|
|
animationEasing,
|
|
enterTransition,
|
|
revealEpoch,
|
|
notifyLoadingPulseComplete,
|
|
xAccessor,
|
|
dateLabels,
|
|
xDomain,
|
|
xDomainSlotCount,
|
|
selection,
|
|
clearSelection,
|
|
composedBarDataKeys,
|
|
composedBarSize,
|
|
composedMaxBarSize,
|
|
composedBarGap,
|
|
composedStacked,
|
|
composedStackOffsets,
|
|
composedStackGap,
|
|
}),
|
|
[
|
|
visiblePlotData,
|
|
renderData,
|
|
xScale,
|
|
yScale,
|
|
yScales,
|
|
width,
|
|
height,
|
|
innerWidth,
|
|
innerHeight,
|
|
margin,
|
|
columnWidth,
|
|
tooltipData,
|
|
setTooltipData,
|
|
containerRef,
|
|
lines,
|
|
chartPhase,
|
|
chartStatus,
|
|
loadingLabel,
|
|
yDomainTweenDuration,
|
|
yDomainSkeletonByAxis,
|
|
yDomainTargetByAxis,
|
|
isLoaded,
|
|
animationDuration,
|
|
animationEasing,
|
|
enterTransition,
|
|
revealEpoch,
|
|
notifyLoadingPulseComplete,
|
|
xAccessor,
|
|
dateLabels,
|
|
xDomain,
|
|
xDomainSlotCount,
|
|
selection,
|
|
clearSelection,
|
|
composedBarDataKeys,
|
|
composedBarSize,
|
|
composedMaxBarSize,
|
|
composedBarGap,
|
|
composedStacked,
|
|
composedStackOffsets,
|
|
composedStackGap,
|
|
]
|
|
);
|
|
|
|
const useClipReveal =
|
|
!staticPreview &&
|
|
renderData.length > 1 &&
|
|
innerWidth > 0 &&
|
|
animationDuration > 0;
|
|
const isRevealAnimating = chartPhase === "revealing";
|
|
const isRevealConcealing =
|
|
chartPhase === "exitingReady" && animationDuration > 0;
|
|
|
|
const effectiveEnterTransition: Transition =
|
|
enterTransition ??
|
|
({
|
|
...DEFAULT_CHART_ENTER_TRANSITION,
|
|
duration: animationDuration / 1000,
|
|
} satisfies Transition);
|
|
|
|
const revealClipPadding = useMemo(() => {
|
|
if (!composedBarDataKeys?.length) {
|
|
return 0;
|
|
}
|
|
const barWidth = computeSeriesBarWidth({
|
|
columnWidth,
|
|
composedBarGap,
|
|
composedBarSize,
|
|
composedMaxBarSize,
|
|
dataLength: plotData.length,
|
|
innerWidth,
|
|
seriesCount: composedBarDataKeys.length,
|
|
stacked: composedStacked,
|
|
});
|
|
return computeSeriesBarRevealClipPadding({
|
|
barWidth,
|
|
gap: composedBarGap,
|
|
seriesCount: composedBarDataKeys.length,
|
|
stacked: composedStacked,
|
|
});
|
|
}, [
|
|
columnWidth,
|
|
composedBarDataKeys,
|
|
composedBarGap,
|
|
composedBarSize,
|
|
composedMaxBarSize,
|
|
composedStacked,
|
|
innerWidth,
|
|
plotData.length,
|
|
]);
|
|
|
|
return (
|
|
<ChartProvider value={contextValue}>
|
|
<svg aria-hidden="true" height={height} width={width}>
|
|
<defs>
|
|
{defsChildren}
|
|
{useClipReveal ? (
|
|
<ChartRevealClip
|
|
animating={isRevealAnimating || isRevealConcealing}
|
|
clipPathId={clipPathId}
|
|
enterTransition={effectiveEnterTransition}
|
|
height={innerHeight + 20}
|
|
mode={isRevealConcealing ? "conceal" : "reveal"}
|
|
onComplete={
|
|
isRevealConcealing ? notifyRevealConcealComplete : undefined
|
|
}
|
|
padding={revealClipPadding}
|
|
revealEpoch={isRevealConcealing ? concealEpoch : revealEpoch}
|
|
targetWidth={innerWidth}
|
|
/>
|
|
) : null}
|
|
</defs>
|
|
|
|
<rect fill="transparent" height={height} width={width} x={0} y={0} />
|
|
|
|
<g
|
|
{...interactionHandlers}
|
|
style={interactionStyle}
|
|
transform={`translate(${margin.left},${margin.top})`}
|
|
>
|
|
<rect
|
|
fill="transparent"
|
|
height={innerHeight}
|
|
width={innerWidth}
|
|
x={0}
|
|
y={0}
|
|
/>
|
|
|
|
{clipExcludedChildren}
|
|
{useClipReveal ? (
|
|
<g clipPath={`url(#${clipPathId})`}>{preOverlayChildren}</g>
|
|
) : (
|
|
preOverlayChildren
|
|
)}
|
|
{postOverlayChildren}
|
|
</g>
|
|
</svg>
|
|
</ChartProvider>
|
|
);
|
|
});
|