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>
This commit is contained in:
Khalid Abdi
2026-06-12 19:22:07 +03:00
parent e2bcb2cfbc
commit 321a6298a4
81 changed files with 11538 additions and 67 deletions
+10
View File
@@ -0,0 +1,10 @@
import { InventoryView } from "@/components/pharmacy/inventory-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function InventoryPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<InventoryView />
</SidebarInset>
);
}
+43
View File
@@ -64,6 +64,22 @@
--animate-toast-error-odd: toast-error-odd 0.28s cubic-bezier(0.5, 1, 0.89, 1);
--animate-toast-error-even: toast-error-even 0.28s cubic-bezier(0.5, 1, 0.89, 1)
;
--color-chart-label: var(--chart-label);
--color-chart-ring-background: var(--chart-ring-background);
--color-chart-marker-foreground: var(--chart-marker-foreground);
--color-chart-marker-border: var(--chart-marker-border);
--color-chart-marker-background: var(--chart-marker-background);
--color-chart-tooltip-muted: var(--chart-tooltip-muted);
--color-chart-tooltip-foreground: var(--chart-tooltip-foreground);
--color-chart-tooltip-background: var(--chart-tooltip-background);
--chart-brush-border: var(--chart-brush-border);
--color-chart-grid: var(--chart-grid);
--color-chart-crosshair: var(--chart-crosshair);
--chart-line-secondary: var(--chart-line-secondary);
--chart-line-primary: var(--chart-line-primary);
--color-chart-foreground-muted: var(--chart-foreground-muted);
--color-chart-foreground: var(--chart-foreground);
--color-chart-background: var(--chart-background);
@keyframes toast-success-odd {
0% {
scale: 1;}
@@ -145,6 +161,22 @@
--success-foreground: var(--color-emerald-700);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-700);
--chart-background: oklch(1 0 0);
--chart-foreground: oklch(0.145 0.004 285);
--chart-foreground-muted: oklch(0.55 0.014 260);
--chart-line-primary: var(--chart-1);
--chart-line-secondary: var(--chart-2);
--chart-crosshair: oklch(0.4 0.1828 274.34);
--chart-grid: oklch(0.9 0 0);
--chart-brush-border: var(--chart-grid);
--chart-tooltip-background: oklch(0.21 0.006 285 / 0.8);
--chart-tooltip-foreground: oklch(0.985 0 0);
--chart-tooltip-muted: oklch(0.65 0.01 260);
--chart-marker-background: oklch(0.97 0.005 260);
--chart-marker-border: oklch(0.85 0.01 260);
--chart-marker-foreground: oklch(0.3 0.01 260);
--chart-ring-background: oklch(0.9 0.005 260 / 0.25);
--chart-label: oklch(0.45 0.01 260);
}
/* COSS neutral dark palette: near-black canvas, neutral accent, hairline borders. */
@@ -187,6 +219,17 @@
--success-foreground: var(--color-emerald-400);
--warning: var(--color-amber-500);
--warning-foreground: var(--color-amber-400);
--chart-background: oklch(0.145 0 0);
--chart-foreground: oklch(0.45 0 0);
--chart-foreground-muted: oklch(0.65 0.01 260);
--chart-crosshair: oklch(0.45 0 0);
--chart-grid: oklch(0.25 0 0);
--chart-brush-border: var(--chart-grid);
--chart-marker-background: oklch(0.25 0.01 260);
--chart-marker-border: oklch(0.4 0.01 260);
--chart-marker-foreground: oklch(0.9 0 0);
--chart-ring-background: oklch(0.35 0.01 260 / 0.25);
--chart-label: oklch(0.75 0.01 260);
}
@layer base {
+2 -1
View File
@@ -23,6 +23,7 @@
},
"registries": {
"@blocks-so": "https://blocks.so/r/{name}.json",
"@coss": "https://coss.com/ui/r/{name}.json"
"@coss": "https://coss.com/ui/r/{name}.json",
"@bklit": "https://ui.bklit.com/r/{name}.json"
}
}
+98 -14
View File
@@ -1,9 +1,17 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TrendCard } from "@/components/analysis/trend-card";
import { LiveHospitalChart } from "@/components/analysis/live-hospital-chart";
import { Area, AreaChart } from "@/components/charts/area-chart";
import { Bar } from "@/components/charts/bar";
import { BarChart } from "@/components/charts/bar-chart";
import { BarXAxis } from "@/components/charts/bar-x-axis";
import { BarYAxis } from "@/components/charts/bar-y-axis";
import { Grid } from "@/components/charts/grid";
import { ChartTooltip } from "@/components/charts/tooltip";
import { XAxis } from "@/components/charts/x-axis";
import { Card } from "@/components/ui/card";
import { type Analytics, getAnalytics } from "@/lib/analytics";
@@ -55,6 +63,28 @@ function Section({
);
}
// A titled card that frames a chart, used for the trend visualisations.
function ChartCard({
title,
total,
children,
}: {
title: string;
total: number;
children: ReactNode;
}) {
return (
<Card className="gap-3 p-4">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground text-sm">{title}</span>
<span className="font-semibold text-foreground text-xl tabular-nums">
{total}
</span>
</div>
{children}
</Card>
);
}
export function AnalysisView() {
const { t } = useTranslation();
@@ -76,6 +106,34 @@ export function AnalysisView() {
const n = (v: number | undefined) => String(v ?? 0);
// The area chart needs real Date x-values: synthesise one month per point,
// ending with the current month.
const monthData = useMemo(() => {
const points = data?.trends.patientsByMonth ?? [];
const now = new Date();
return points.map((p, i) => ({
date: new Date(
now.getFullYear(),
now.getMonth() - (points.length - 1 - i),
1,
),
patients: p.count,
}));
}, [data]);
// The bar chart is categorical (one bar per weekday).
const weekdayData = useMemo(
() =>
(data?.trends.appointmentsByWeekday ?? []).map((p) => ({
name: p.label,
appointments: p.count,
})),
[data],
);
const monthTotal = monthData.reduce((sum, p) => sum + p.patients, 0);
const weekdayTotal = weekdayData.reduce((sum, p) => sum + p.appointments, 0);
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div>
@@ -85,6 +143,23 @@ export function AnalysisView() {
<p className="text-muted-foreground text-sm">{t("analysis.subtitle")}</p>
</div>
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">
{t("analysis.live.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("analysis.live.subtitle")}
</p>
</div>
<Card className="gap-3 p-4">
<span className="text-muted-foreground text-sm">
{t("analysis.live.label")}
</span>
<LiveHospitalChart />
</Card>
</section>
<Section
columns={3}
description={t("analysis.patientVolume.description")}
@@ -114,20 +189,29 @@ export function AnalysisView() {
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TrendCard
description={t("analysis.charts.patientGrowthDescription")}
detailsLabel={t("analysis.charts.viewDetails")}
emptyLabel={t("analysis.charts.empty")}
points={data?.trends.patientsByMonth ?? []}
<ChartCard
title={t("analysis.charts.patientGrowthTitle")}
/>
<TrendCard
description={t("analysis.charts.weeklyAppointmentsDescription")}
detailsLabel={t("analysis.charts.viewDetails")}
emptyLabel={t("analysis.charts.empty")}
points={data?.trends.appointmentsByWeekday ?? []}
total={monthTotal}
>
<AreaChart aspectRatio="2 / 1" data={monthData}>
<Grid horizontal />
<Area dataKey="patients" fill="var(--chart-line-primary)" />
<XAxis tickMode="data" />
<ChartTooltip showDatePill={false} />
</AreaChart>
</ChartCard>
<ChartCard
title={t("analysis.charts.weeklyAppointmentsTitle")}
/>
total={weekdayTotal}
>
<BarChart aspectRatio="2 / 1" data={weekdayData}>
<Grid horizontal />
<Bar dataKey="appointments" fill="var(--chart-line-primary)" />
<BarXAxis />
<BarYAxis />
<ChartTooltip showDatePill={false} />
</BarChart>
</ChartCard>
</div>
</section>
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
LiveLineChart,
type LiveLinePoint,
} from "@/components/charts/live-line-chart";
import { LiveLine } from "@/components/charts/live-line";
import { LiveYAxis } from "@/components/charts/live-y-axis";
import { ChartTooltip } from "@/components/charts/tooltip";
// temetro has no real-time telemetry feed yet, so the "Live" panel simulates a
// hospital signal (patients currently in the building) as a bounded random walk
// that updates once a second. Swap `tick()` for a WebSocket/poll when a real
// feed exists — the chart contract (append { time, value }) stays the same.
const BASELINE = 48;
const MIN = 24;
const MAX = 90;
const WINDOW_SECONDS = 30;
export function LiveHospitalChart() {
const [data, setData] = useState<LiveLinePoint[]>([]);
const [value, setValue] = useState(BASELINE);
const valueRef = useRef(BASELINE);
useEffect(() => {
// Seed a short history so the line is drawn immediately on mount.
const now = Date.now() / 1000;
setData(
Array.from({ length: WINDOW_SECONDS }, (_, i) => ({
time: now - (WINDOW_SECONDS - i),
value: BASELINE,
})),
);
const id = setInterval(() => {
const drift = (Math.random() - 0.5) * 5;
const next = Math.max(MIN, Math.min(MAX, valueRef.current + drift));
valueRef.current = next;
setValue(next);
setData((prev) => [
...prev.slice(-500),
{ time: Date.now() / 1000, value: next },
]);
}, 1000);
return () => clearInterval(id);
}, []);
return (
<div className="h-56 w-full">
<LiveLineChart data={data} value={value} window={WINDOW_SECONDS}>
<LiveLine
dataKey="value"
formatValue={(v) => String(Math.round(v))}
momentumColors={{
up: "var(--color-emerald-500)",
down: "var(--color-red-500)",
flat: "var(--chart-line-primary)",
}}
/>
<LiveYAxis formatValue={(v) => String(Math.round(v))} position="left" />
<ChartTooltip showDatePill={false} />
</LiveLineChart>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Transition } from "motion/react";
/** Default clip-reveal easing for cartesian charts. */
export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)";
export const DEFAULT_ANIMATION_DURATION_MS = 1100;
/** Default enter transition — matches the original line chart reveal. */
export const DEFAULT_CHART_ENTER_TRANSITION: Transition = {
type: "tween",
duration: DEFAULT_ANIMATION_DURATION_MS / 1000,
ease: [0.85, 0, 0.15, 1],
};
/**
* Clip-path width reveal must use tween — spring does not reliably animate SVG width.
*/
export function clipRevealTransition(enterTransition?: Transition): Transition {
if (enterTransition?.type === "tween") {
return {
...enterTransition,
ease: enterTransition.ease ?? DEFAULT_CHART_ENTER_TRANSITION.ease,
};
}
const duration =
typeof enterTransition?.duration === "number"
? enterTransition.duration
: DEFAULT_ANIMATION_DURATION_MS / 1000;
return {
type: "tween",
duration,
ease: DEFAULT_CHART_ENTER_TRANSITION.ease,
};
}
@@ -0,0 +1,110 @@
"use client";
import { curveNatural } from "@visx/curve";
import { useMemo } from "react";
import { Area } from "./area";
import { AreaChart } from "./area-chart";
import type { Margin } from "./chart-context";
import {
DEFAULT_SKELETON_DATA_KEY,
DEFAULT_SKELETON_POINT_COUNT,
generateChartSkeletonData,
} from "./generate-chart-skeleton-data";
import { Grid } from "./grid";
const LOADING_DATA_KEY = DEFAULT_SKELETON_DATA_KEY;
const DEFAULT_LOADING_STROKE = "var(--foreground)";
const DEFAULT_LOADING_GRID_STROKE =
"color-mix(in oklch, var(--chart-grid) 50%, transparent)";
const DEFAULT_LOADING_GRID_SHIMMER_STROKE =
"color-mix(in oklch, var(--foreground) 68%, transparent)";
const DEFAULT_LOADING_STROKE_OPACITY = 0.5;
export interface AreaChartLoadingProps {
/** Chart margins */
margin?: Partial<Margin>;
/** Stroke color for the animated loading segment. */
stroke?: string;
/** Stroke opacity for the animated loading segment. Default: 0.5 */
strokeOpacity?: number;
/** Grid line stroke (color and opacity via color-mix or oklch alpha). */
gridStroke?: string;
/** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */
gridShimmerStroke?: string;
/** Animate a shimmer band across grid lines. Default: true */
gridShimmer?: boolean;
/** Shimmer band width in pixels. Default: 140 */
gridShimmerLength?: number;
/** Shimmer speed multiplier (higher = faster). Default: 1 */
gridShimmerSpeed?: number;
/** Match shimmer loop to the loading line pulse (cycle + inter-loop pause). */
gridShimmerSync?: boolean;
/** Centered shimmer label text. Default: "Loading" */
label?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
}
export function AreaChartLoading({
margin,
stroke = DEFAULT_LOADING_STROKE,
strokeOpacity = DEFAULT_LOADING_STROKE_OPACITY,
gridStroke = DEFAULT_LOADING_GRID_STROKE,
gridShimmerStroke = DEFAULT_LOADING_GRID_SHIMMER_STROKE,
gridShimmer = true,
gridShimmerLength,
gridShimmerSpeed,
gridShimmerSync = false,
label = "Loading",
aspectRatio = "2 / 1",
className = "",
}: AreaChartLoadingProps) {
const data = useMemo(
() =>
generateChartSkeletonData({
dataKey: DEFAULT_SKELETON_DATA_KEY,
pointCount: DEFAULT_SKELETON_POINT_COUNT,
}),
[]
);
return (
<AreaChart
animationDuration={0}
aspectRatio={aspectRatio}
className={className}
data={data}
loadingLabel={label}
margin={margin}
status="loading"
>
<Grid
horizontal
shimmer={gridShimmer}
shimmerLength={gridShimmerLength}
shimmerSpeed={gridShimmerSpeed}
shimmerStroke={gridShimmerStroke}
shimmerSync={gridShimmerSync}
stroke={gridStroke}
/>
<Area
curve={curveNatural}
dataKey={LOADING_DATA_KEY}
fadeEdges={false}
fill="transparent"
fillOpacity={0}
loading
loadingStroke={stroke}
loadingStrokeOpacity={strokeOpacity}
showHighlight={false}
showLine
stroke="transparent"
strokeWidth={2}
/>
</AreaChart>
);
}
export default AreaChartLoading;
+272
View File
@@ -0,0 +1,272 @@
"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;
@@ -0,0 +1,113 @@
import {
type FadeEdges,
fadeGradientStops,
resolveFadeSides,
viewportFadeGradientAttrs,
} from "./fade-edges";
interface AreaGradientDefsProps {
gradientId: string;
strokeGradientId: string;
edgeMaskId: string;
edgeGradientId: string;
fill: string;
fillOpacity: number;
gradientToOpacity: number;
/** 01: where the bottom stop sits (1 = full-height gradient). */
gradientSpan?: number;
resolvedStroke: string;
isPatternFill: boolean;
fadeEdges: FadeEdges;
innerWidth: number;
innerHeight: number;
}
export function AreaGradientDefs({
gradientId,
strokeGradientId,
edgeMaskId,
edgeGradientId,
fill,
fillOpacity,
gradientToOpacity,
gradientSpan = 1,
resolvedStroke,
isPatternFill,
fadeEdges,
innerWidth,
innerHeight,
}: AreaGradientDefsProps) {
const sides = resolveFadeSides(fadeEdges);
// Stroke gradient mirrors the area's edge fade so the line doesn't pop in
// past the faded fill. Skip emitting it when neither edge fades — the line
// can then paint a solid stroke instead of an unnecessary url(#...) ref.
const strokeStops = sides.any ? fadeGradientStops(sides) : null;
const showEdgeMask = sides.any && !isPatternFill;
const edgeStops = showEdgeMask ? fadeGradientStops(sides) : null;
const span = Math.min(1, Math.max(0.01, gradientSpan));
const midOffset = `${span * 100}%`;
return (
<defs>
{isPatternFill ? null : (
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop
offset="0%"
style={{ stopColor: fill, stopOpacity: fillOpacity }}
/>
<stop
offset={midOffset}
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
{span < 1 ? (
<stop
offset="100%"
style={{ stopColor: fill, stopOpacity: gradientToOpacity }}
/>
) : null}
</linearGradient>
)}
{strokeStops ? (
<linearGradient
id={strokeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{strokeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: resolvedStroke, stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
) : null}
{edgeStops ? (
<>
<linearGradient
id={edgeGradientId}
{...viewportFadeGradientAttrs(innerWidth)}
>
{edgeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: "white", stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
<mask id={edgeMaskId}>
<rect
fill={`url(#${edgeGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</>
) : null}
</defs>
);
}
+351
View File
@@ -0,0 +1,351 @@
"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;
+677
View File
@@ -0,0 +1,677 @@
"use client";
import { localPoint } from "@visx/event";
import { ParentSize } from "@visx/responsive";
import { scaleBand, scaleLinear } from "@visx/scale";
import type { Transition } from "motion/react";
import {
Children,
isValidElement,
memo,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { DEFAULT_ANIMATION_EASING } from "./animation";
import type { BarProps } from "./bar";
import {
ChartProvider,
type LineConfig,
type Margin,
type TooltipData,
} from "./chart-context";
import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
import { shortDateFmt } from "./chart-formatters";
import { type ChartPhase, DEFAULT_CHART_LIFECYCLE } from "./chart-phase";
import { useScheduledTooltip } from "./use-scheduled-tooltip";
import {
buildYScalesForLines,
getPrimaryYScale,
normalizeYAxisId,
wrapSingleYScale,
} from "./y-axis-scales";
export type BarOrientation = "vertical" | "horizontal";
export interface BarChartProps {
/** Data array - each item should have an x-axis key and numeric values */
data: Record<string, unknown>[];
/** Key in data for the categorical axis. Default: "name" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1100 */
animationDuration?: number;
/** CSS easing for bar grow transitions. */
animationEasing?: string;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers enter replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */
barGap?: number;
/** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */
barWidth?: number;
/** Bar chart orientation. Default: "vertical" */
orientation?: BarOrientation;
/** Whether to stack bars instead of grouping them. Default: false */
stacked?: boolean;
/** Gap between stacked bar segments in pixels. Default: 0 */
stackGap?: number;
/** Child components (Bar, Grid, ChartTooltip, etc.) */
children: ReactNode;
/** Reports reveal lifecycle for OG screenshots and loading orchestration. */
onPhaseChange?: (phase: ChartPhase) => void;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
// Extract bar configs from children synchronously
function extractBarConfigs(children: ReactNode): LineConfig[] {
const configs: LineConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const childType = child.type as {
displayName?: string;
name?: string;
__isBarDepthLayer?: boolean;
};
// Bar-depth surface layers (BarDepthBack/Front, BarPulse) carry a
// `dataKey` to pair with a Bar but are not series themselves — skip them
// so they don't inflate the series count and shrink the real bars.
if (childType.__isBarDepthLayer) {
return;
}
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
const props = child.props as BarProps | undefined;
const isBarComponent =
componentName === "Bar" ||
(props && typeof props.dataKey === "string" && props.dataKey.length > 0);
if (isBarComponent && props?.dataKey) {
// Use stroke for tooltip dot color if provided, otherwise fall back to fill
// This allows gradient/pattern fills to have a solid dot color
const dotColor =
props.stroke || props.fill || "var(--chart-line-primary)";
configs.push({
dataKey: props.dataKey,
stroke: dotColor,
strokeWidth: 0,
yAxisId: props.yAxisId,
});
}
});
return configs;
}
// Check if a component should render after the mouse overlay
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";
}
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing: string;
enterTransition?: Transition;
revealSignature?: string;
barGap: number;
barWidthProp?: number;
orientation: BarOrientation;
stacked: boolean;
stackGap: number;
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange?: (phase: ChartPhase) => void;
}
function ChartInner(props: ChartInnerProps) {
const { width, height } = props;
if (width < 10 || height < 10) {
return null;
}
return <ChartCore {...props} />;
}
const ChartCore = memo(function ChartCore({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing,
enterTransition,
revealSignature = "",
barGap,
barWidthProp,
orientation,
stacked,
stackGap,
children,
containerRef,
onPhaseChange,
}: ChartInnerProps) {
const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } =
useScheduledTooltip<TooltipData>();
const [isLoaded, setIsLoaded] = useState(false);
const [revealEpoch, setRevealEpoch] = useState(0);
const hoveredBarIndex = tooltipData?.index ?? null;
const isHorizontal = orientation === "horizontal";
// Extract bar configs synchronously from children
const lines = useMemo(() => extractBarConfigs(children), [children]);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Category accessor function - returns string for categorical scale
const categoryAccessor = useCallback(
(d: Record<string, unknown>): string => {
const value = d[xDataKey];
if (value instanceof Date) {
return shortDateFmt.format(value);
}
return String(value ?? "");
},
[xDataKey]
);
// For compatibility with ChartContext, provide a Date-based xAccessor
const xAccessorDate = useCallback(
(d: Record<string, unknown>): Date => {
const value = d[xDataKey];
if (value instanceof Date) {
return value;
}
return new Date();
},
[xDataKey]
);
// Category scale (band) - for the categorical axis
const categoryScale = useMemo(() => {
const domain = data.map((d) => categoryAccessor(d));
const range: [number, number] = isHorizontal
? [0, innerHeight]
: [0, innerWidth];
return scaleBand<string>({
range,
domain,
padding: barGap,
});
}, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]);
// Band width for bars - use prop if provided, otherwise use scale's bandwidth
const bandWidth = barWidthProp ?? categoryScale.bandwidth();
// Compute max value considering stacking
const maxValue = useMemo(() => {
if (stacked) {
// For stacked bars, sum all values at each data point
let max = 0;
for (const d of data) {
let sum = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
sum += value;
}
}
if (sum > max) {
max = sum;
}
}
return max || 100;
}
// For grouped bars, find max single value
let max = 0;
for (const line of lines) {
for (const d of data) {
const value = d[line.dataKey];
if (typeof value === "number" && value > max) {
max = value;
}
}
}
return max || 100;
}, [data, lines, stacked]);
// Value scale (linear) - for the value axis
const valueScale = useMemo(() => {
const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0];
return scaleLinear({
range,
domain: [0, maxValue * 1.1],
nice: true,
});
}, [innerWidth, innerHeight, maxValue, isHorizontal]);
const yScales = useMemo(() => {
if (isHorizontal) {
return wrapSingleYScale(valueScale);
}
return buildYScalesForLines({
lines,
data,
innerHeight,
resolveDomain: (dataKeys) => {
let max = 0;
for (const d of data) {
for (const key of dataKeys) {
const value = d[key];
if (typeof value === "number" && value > max) {
max = value;
}
}
}
return [0, (max || 100) * 1.1];
},
});
}, [data, innerHeight, isHorizontal, lines, valueScale]);
const primaryYScale = getPrimaryYScale(yScales, valueScale);
// Compute stack offsets for stacked bars
const stackOffsets = useMemo(() => {
if (!stacked) {
return undefined;
}
const offsets = new Map<number, Map<string, number>>();
for (let i = 0; i < data.length; i++) {
const d = data[i];
if (!d) {
continue;
}
const pointOffsets = new Map<string, number>();
let cumulative = 0;
for (const line of lines) {
pointOffsets.set(line.dataKey, cumulative);
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
}
}
offsets.set(i, pointOffsets);
}
return offsets;
}, [data, lines, stacked]);
// Column width for tooltip indicator
const columnWidth = useMemo(() => {
if (data.length < 1) {
return 0;
}
return isHorizontal ? innerHeight / data.length : innerWidth / data.length;
}, [innerWidth, innerHeight, data.length, isHorizontal]);
// Pre-compute labels for ticker animation
const dateLabels = useMemo(
() => data.map((d) => categoryAccessor(d)),
[data, categoryAccessor]
);
// Create a fake time scale for compatibility with ChartContext
const fakeTimeScale = useMemo(() => {
const now = Date.now();
const start = now - data.length * 24 * 60 * 60 * 1000;
const scale = {
...categoryScale,
domain: () => [new Date(start), new Date(now)],
range: () => [0, innerWidth] as [number, number],
invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)),
copy: () => scale,
};
return scale;
}, [categoryScale, innerWidth, data.length]);
// Animation timing — replay when motion settings change
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
useEffect(() => {
setRevealEpoch((n) => n + 1);
setIsLoaded(false);
const timer = setTimeout(() => {
setIsLoaded(true);
}, animationDuration);
return () => clearTimeout(timer);
}, [animationDuration, revealSignature]);
useEffect(() => {
onPhaseChange?.(isLoaded ? "ready" : "revealing");
}, [isLoaded, onPhaseChange]);
// Mouse move handler
const handleMouseMove = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const point = localPoint(event);
if (!point) {
return;
}
const pos = isHorizontal ? point.y - margin.top : point.x - margin.left;
// Find which band the mouse is over
const bandIndex = Math.floor(pos / columnWidth);
const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex));
const d = data[clampedIndex];
if (!d) {
return;
}
// Calculate positions for each bar
const yPositions: Record<string, number> = {};
const xPositions: Record<string, number> = {};
const barPos = categoryScale(categoryAccessor(d)) ?? 0;
if (isHorizontal) {
// Horizontal bars: dots at end of bar (x = value), centered vertically in band
const seriesCount = lines.length;
const groupGap = seriesCount > 1 ? 4 : 0;
const individualBarHeight =
seriesCount > 0
? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
: bandWidth;
if (stacked) {
// Stacked horizontal: all bars same y, x at cumulative end
let cumulative = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
xPositions[line.dataKey] = axisScale(cumulative) ?? 0;
yPositions[line.dataKey] = barPos + bandWidth / 2;
}
}
} else {
// Grouped horizontal: each bar at its own y position
lines.forEach((line, idx) => {
const value = d[line.dataKey];
if (typeof value === "number") {
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
xPositions[line.dataKey] = axisScale(value) ?? 0;
yPositions[line.dataKey] =
barPos +
idx * (individualBarHeight + groupGap) +
individualBarHeight / 2;
}
});
}
} else if (stacked) {
// Vertical stacked bars
let cumulative = 0;
let seriesIdx = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
const gapOffset = seriesIdx * stackGap;
yPositions[line.dataKey] = (axisScale(cumulative) ?? 0) - gapOffset;
seriesIdx++;
}
}
} else {
// Vertical grouped bars
const seriesCount = lines.length;
const groupGap = seriesCount > 1 ? 4 : 0;
const individualBarWidth =
seriesCount > 0
? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
: bandWidth;
lines.forEach((line, idx) => {
const value = d[line.dataKey];
if (typeof value === "number") {
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
yPositions[line.dataKey] = axisScale(value) ?? 0;
xPositions[line.dataKey] =
barPos +
idx * (individualBarWidth + groupGap) +
individualBarWidth / 2;
}
});
}
// Tooltip position: for horizontal, position at max bar end; for vertical, center of band
let tooltipX: number;
if (isHorizontal) {
// Position tooltip at the end of the longest bar
const maxX = Math.max(...Object.values(xPositions), 0);
tooltipX = maxX;
} else {
tooltipX = barPos + bandWidth / 2;
}
scheduleTooltip({
point: d,
index: clampedIndex,
x: tooltipX,
yPositions,
xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined,
});
},
[
categoryScale,
valueScale,
data,
lines,
margin.left,
margin.top,
categoryAccessor,
columnWidth,
bandWidth,
isHorizontal,
stacked,
stackGap,
scheduleTooltip,
yScales,
primaryYScale,
]
);
const handleMouseLeave = useCallback(() => {
clearTooltip();
}, [clearTooltip]);
const canInteract = isLoaded;
// Separate children into defs, pre-overlay, and post-overlay
const defsChildren: ReactElement[] = [];
const preOverlayChildren: ReactElement[] = [];
const postOverlayChildren: ReactElement[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (isGradientDefComponent(child)) {
defsChildren.push(child);
} else if (isPatternDefComponent(child)) {
preOverlayChildren.push(child);
} else if (isPostOverlayComponent(child)) {
postOverlayChildren.push(child);
} else {
preOverlayChildren.push(child);
}
});
const contextValue = {
...DEFAULT_CHART_LIFECYCLE,
data,
renderData: data,
xScale: fakeTimeScale as unknown as ReturnType<
typeof import("@visx/scale").scaleTime<number>
>,
yScale: isHorizontal ? valueScale : primaryYScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
isLoaded,
animationDuration,
animationEasing,
enterTransition,
revealEpoch,
xAccessor: xAccessorDate,
dateLabels,
// Bar-specific properties
barScale: categoryScale,
bandWidth,
hoveredBarIndex,
barXAccessor: categoryAccessor,
orientation,
stacked,
stackOffsets,
};
return (
<ChartProvider value={contextValue}>
<svg
aria-hidden="true"
className="overflow-visible"
height={height}
width={width}
>
{/* Gradient and pattern definitions */}
{defsChildren.length > 0 && <defs>{defsChildren}</defs>}
<rect fill="transparent" height={height} width={width} x={0} y={0} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: Chart interaction area */}
<g
onMouseLeave={canInteract ? handleMouseLeave : undefined}
onMouseMove={canInteract ? handleMouseMove : undefined}
style={{ cursor: canInteract ? "crosshair" : "default" }}
transform={`translate(${margin.left},${margin.top})`}
>
{/* Background rect for mouse event detection */}
<rect
fill="transparent"
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
{/* SVG children rendered before markers */}
{preOverlayChildren}
{/* Markers rendered last so they're on top for interaction */}
{postOverlayChildren}
</g>
</svg>
</ChartProvider>
);
});
export function BarChart({
data,
xDataKey = "name",
margin: marginProp,
animationDuration = 1100,
animationEasing = DEFAULT_ANIMATION_EASING,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
barGap = 0.2,
barWidth,
orientation = "vertical",
stacked = false,
stackGap = 0,
children,
onPhaseChange,
}: BarChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div
className={cn("relative w-full overflow-visible", className)}
ref={containerRef}
style={{ aspectRatio }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
barGap={barGap}
barWidthProp={barWidth}
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
margin={margin}
onPhaseChange={onPhaseChange}
orientation={orientation}
revealSignature={revealSignature}
stacked={stacked}
stackGap={stackGap}
width={width}
xDataKey={xDataKey}
>
{children}
</ChartInner>
)}
</ParentSize>
</div>
);
}
BarChart.displayName = "BarChart";
export default BarChart;
@@ -0,0 +1,34 @@
export const BAR_DEPTH_MAX_PX = 7;
/** The side parallelogram's back edge lifts by `depth * this ratio`, giving a
* subtle head-on perspective slope. */
export const BAR_DEPTH_PERSPECTIVE_RATIO = 0.45;
/**
* Maximum side-face depth in px for a chart, clamped so depth never spills past
* the gap between bars. `stepWidth` is d3-scaleBand's `step()` (bandwidth +
* gap); `bandWidth` is a single bar's width. Returns 0 for gapless/dense charts.
*/
export function barDepthMaxDepth(stepWidth: number, bandWidth: number): number {
const gap = Math.max(0, stepWidth - bandWidth);
return Math.min(bandWidth * 0.22, Math.max(0, gap - 1), BAR_DEPTH_MAX_PX);
}
/**
* Per-bar side-face depth + perspective rise.
*
* - `absOffset` ∈ [0, 1]: the bar's normalized distance from the chart's
* horizontal center. 0 = dead center (no depth); 1 = chart edge (full depth).
* - `naturalHeight`: the bar's pixel height. Depth is capped by it so a short
* bar's side never reads wider than the bar is tall.
* - `maxDepth`: from `barDepthMaxDepth`.
*/
export function barDepthAndRise(
absOffset: number,
naturalHeight: number,
maxDepth: number
): { depth: number; perspectiveRise: number } {
const offset = Math.min(1, Math.max(0, absOffset));
const cappedMaxDepth = Math.min(maxDepth, Math.max(0, naturalHeight));
const depth = offset * cappedMaxDepth;
return { depth, perspectiveRise: depth * BAR_DEPTH_PERSPECTIVE_RATIO };
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
export interface BarXAxisProps {
/** Width of the date ticker box for fade calculation. Default: 50 */
tickerHalfWidth?: number;
/** Whether to show all labels or skip some for dense data. Default: false */
showAllLabels?: boolean;
/** Maximum number of labels to show. Default: 12 */
maxLabels?: number;
}
interface BarXAxisLabelProps {
label: string;
x: number;
crosshairX: number | null;
isHovering: boolean;
tickerHalfWidth: number;
}
function BarXAxisLabel({
label,
x,
crosshairX,
isHovering,
tickerHalfWidth,
}: BarXAxisLabelProps) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
let opacity = 1;
if (isHovering && crosshairX !== null) {
const distance = Math.abs(x - crosshairX);
if (distance < tickerHalfWidth) {
opacity = 0;
} else if (distance < fadeRadius) {
opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
}
// Zero-width container approach for perfect centering
return (
<div
className="absolute"
style={{
left: x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
}}
>
<motion.span
animate={{ opacity }}
className={cn("whitespace-nowrap text-chart-label text-xs")}
initial={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{label}
</motion.span>
</div>
);
}
export function BarXAxis(props: BarXAxisProps) {
const { containerRef, barScale } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!barScale) {
return null;
}
return <BarXAxisInner {...props} container={container} />;
}
const BarXAxisInner = memo(function BarXAxisInner({
tickerHalfWidth = 50,
showAllLabels = false,
maxLabels = 12,
container,
}: BarXAxisProps & { container: HTMLDivElement }) {
const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } =
useChart();
// Generate labels for each bar
const labelsToShow = useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const allLabels = data.map((d) => {
const label = barXAccessor(d);
const bandX = barScale(label) ?? 0;
// Center the label under the bar group
const x = bandX + bandWidth / 2 + margin.left;
return { label, x };
});
// If showAllLabels is true or we have fewer than maxLabels, show all
if (showAllLabels || allLabels.length <= maxLabels) {
return allLabels;
}
// Otherwise, skip some labels to avoid crowding
const step = Math.ceil(allLabels.length / maxLabels);
return allLabels.filter((_, i) => i % step === 0);
}, [
barScale,
bandWidth,
barXAccessor,
data,
margin.left,
showAllLabels,
maxLabels,
]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => (
<BarXAxisLabel
crosshairX={crosshairX}
isHovering={isHovering}
key={`${item.label}-${item.x}`}
label={item.label}
tickerHalfWidth={tickerHalfWidth}
x={item.x}
/>
))}
</div>,
container
);
});
BarXAxis.displayName = "BarXAxis";
export default BarXAxis;
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
export interface BarYAxisProps {
/** Whether to show all labels or skip some for dense data. Default: true */
showAllLabels?: boolean;
/** Maximum number of labels to show. Default: 20 */
maxLabels?: number;
}
interface BarYAxisLabelProps {
label: string;
y: number;
bandHeight: number;
isHovered: boolean;
}
function BarYAxisLabel({
label,
y,
bandHeight,
isHovered,
}: BarYAxisLabelProps) {
return (
<div
className="absolute right-0 flex items-center justify-end pr-2"
style={{
top: y,
height: bandHeight,
}}
>
<motion.span
animate={{
opacity: isHovered ? 1 : 0.7,
color: isHovered
? "var(--foreground)"
: "var(--chart-label, var(--color-zinc-500))",
}}
className={cn("truncate whitespace-nowrap text-right text-xs")}
initial={{
opacity: 0.7,
color: "var(--chart-label, var(--color-zinc-500))",
}}
style={{ maxWidth: 70 }}
transition={{ duration: 0.15 }}
>
{label}
</motion.span>
</div>
);
}
export function BarYAxis(props: BarYAxisProps) {
const { containerRef, barScale } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!barScale) {
return null;
}
return <BarYAxisInner {...props} container={container} />;
}
const BarYAxisInner = memo(function BarYAxisInner({
showAllLabels = true,
maxLabels = 20,
container,
}: BarYAxisProps & { container: HTMLDivElement }) {
const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } =
useChart();
// Generate labels for each bar
const labelsToShow = useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const allLabels = data.map((d, i) => {
const label = barXAccessor(d);
const bandY = barScale(label) ?? 0;
// Center the label vertically within the band
const y = bandY + margin.top;
return { label, y, bandHeight: bandWidth, index: i };
});
// If showAllLabels is true or we have fewer than maxLabels, show all
if (showAllLabels || allLabels.length <= maxLabels) {
return allLabels;
}
// Otherwise, skip some labels to avoid crowding
const step = Math.ceil(allLabels.length / maxLabels);
return allLabels.filter((_, i) => i % step === 0);
}, [
barScale,
bandWidth,
barXAccessor,
data,
margin.top,
showAllLabels,
maxLabels,
]);
return createPortal(
<div
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: 0,
width: margin.left,
}}
>
{labelsToShow.map((item) => (
<BarYAxisLabel
bandHeight={item.bandHeight}
isHovered={hoveredBarIndex === item.index}
key={`${item.label}-${item.y}`}
label={item.label}
y={item.y}
/>
))}
</div>,
container
);
});
BarYAxis.displayName = "BarYAxis";
export default BarYAxis;
+466
View File
@@ -0,0 +1,466 @@
"use client";
import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useId, useMemo } from "react";
import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry";
import {
chartCssVars,
useChart,
useChartStable,
useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";
type ScaleBand<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
export type BarLineCap = "round" | "butt" | number;
export type BarAnimationType = "grow" | "fade";
// ── Bar-depth perspective trim ───────────────────────────────────────────
// Uses the SHARED geometry (`bar-depth-geometry.ts`) so a
// `<Bar perspective>` front face lines up exactly with
// `<BarDepthBack>`'s lid — the formula lives in one place for both.
/** perspectiveRise for a positive bar whose visual top sits at `topY`.
* Returns 0 for a dead-center bar or a dense chart (degenerate depth). */
function barDepthPerspectiveRise(
barScale: ScaleBand<string>,
bandWidth: number,
barXAccessor: (d: Record<string, unknown>) => string,
innerWidth: number,
datum: Record<string, unknown>,
topY: number,
baselineY: number
): number {
const centerX = innerWidth / 2;
if (centerX <= 0) {
return 0;
}
const step =
(barScale as unknown as { step?: () => number }).step?.() ?? bandWidth;
const maxDepth = barDepthMaxDepth(step, bandWidth);
const bandX = barScale(barXAccessor(datum)) ?? 0;
const cx = bandX + bandWidth / 2;
const absOffset = Math.min(1, Math.abs((cx - centerX) / centerX));
const naturalHeight = Math.abs(baselineY - topY);
return barDepthAndRise(absOffset, naturalHeight, maxDepth).perspectiveRise;
}
export interface BarProps {
/** Key in data to use for y values */
dataKey: string;
/** Y-scale group id for vertical bars (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
/** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */
fill?: string;
/** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */
stroke?: string;
/** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */
lineCap?: BarLineCap;
/** Whether to animate the bars. Default: true */
animate?: boolean;
/** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */
animationType?: BarAnimationType;
/** Opacity when not hovered (when another bar is hovered). Default: 0.3 */
fadedOpacity?: number;
/** Stagger delay between bars in seconds. Auto-calculated if not provided. */
staggerDelay?: number;
/** Gap between stacked bars in pixels. Default: 0 */
stackGap?: number;
/** Gap between grouped bars in pixels. Default: 4 */
groupGap?: number;
/** Shrink each positive bar's top by its perspective rise so the front face
* lines up with `<BarDepthBack>`'s lid (instead of the lid sitting above the
* front face). Pass `true` whenever the chart also renders the bar-depth 3D
* surfaces. Default: false */
perspective?: boolean;
/** Minimum rendered bar height in px (non-stacked, vertical). Floors short or
* zero-value bars so they stay visible. Pair with the same value on
* `<BarDepthProvider minBarHeight>` when using the 3D surfaces. Default: 0 */
minBarHeight?: number;
}
interface BarInnerProps extends BarProps {
barScale: ScaleBand<string>;
bandWidth: number;
barXAccessor: (d: Record<string, unknown>) => string;
}
interface AnimatedBarProps {
x: number;
y: number;
width: number;
height: number;
fill: string;
rx: number;
ry: number;
index: number;
isFaded: boolean;
animationType: BarAnimationType;
innerHeight: number;
fadedOpacity: number;
staggerDelay: number;
enterTransition?: Transition;
revealEpoch: number;
isHorizontal: boolean;
}
function AnimatedBar({
x,
y,
width,
height,
fill,
rx,
ry,
index,
isFaded,
animationType,
innerHeight,
fadedOpacity,
staggerDelay,
enterTransition,
revealEpoch,
isHorizontal,
}: AnimatedBarProps) {
const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay);
if (animationType === "fade") {
return (
<motion.rect
animate={{
opacity: isFaded ? fadedOpacity : 1,
filter: "blur(0px)",
}}
fill={fill}
height={height}
initial={{ opacity: 0, filter: "blur(2px)" }}
key={`fade-${index}-${revealEpoch}`}
rx={rx}
ry={ry}
transition={enterAnim}
width={width}
x={x}
y={y}
/>
);
}
const initial = isHorizontal
? { width: 0, height, x: 0, y }
: { width, height: 0, x, y: innerHeight };
const target = isHorizontal
? { width, height, x: 0, y }
: { width, height, x, y };
return (
<g
opacity={isFaded ? fadedOpacity : 1}
style={{ transition: "opacity 0.15s ease-in-out" }}
>
<motion.rect
animate={target}
fill={fill}
initial={initial}
key={`grow-${index}-${revealEpoch}`}
rx={rx}
ry={ry}
transition={enterAnim}
/>
</g>
);
}
const BarInner = memo(function BarInner({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
lineCap = "round",
animate = true,
animationType = "grow",
fadedOpacity = 0.3,
staggerDelay,
stackGap = 0,
groupGap = 4,
perspective = false,
minBarHeight = 0,
barScale,
bandWidth,
barXAccessor,
}: BarInnerProps) {
const {
data,
yScale: chartYScale,
innerHeight,
innerWidth,
isLoaded,
hoveredBarIndex,
lines,
orientation,
stacked,
stackOffsets,
animationDuration,
enterTransition,
revealEpoch = 0,
} = useChart();
// Calculate stagger delay automatically if not provided
// Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation
const totalAnimDuration = animationDuration || 1100;
const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread
const calculatedStaggerDelay =
staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
const uniqueId = useId();
const isHorizontal = orientation === "horizontal";
// Find the index of this bar series among all bar series
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const seriesIndex = useMemo(() => {
const idx = lines.findIndex((l) => l.dataKey === dataKey);
return idx >= 0 ? idx : 0;
}, [lines, dataKey]);
const seriesConfig = lines[seriesIndex];
const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId);
const isLegendDimmed =
legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;
const seriesCount = lines.length;
const isLastSeries = seriesIndex === seriesCount - 1;
// Calculate the width for each bar within a group (for non-stacked)
const barWidth = useMemo(() => {
if (!bandWidth || seriesCount === 0) {
return 0;
}
if (stacked) {
// Stacked bars use full band width
return bandWidth;
}
// Leave a gap between grouped bars (controlled by groupGap prop)
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
}, [bandWidth, seriesCount, stacked, groupGap]);
// Calculate corner radius based on lineCap. Perspective bars force a flat
// top (radius 0) so the 3D lid from `<BarDepthBack>` meets the bar with no
// gap — rounded corners would leave a wedge, so `perspective` overrides it.
const cornerRadius = useMemo(() => {
if (perspective) {
return 0;
}
if (typeof lineCap === "number") {
return lineCap;
}
if (lineCap === "round" && barWidth) {
return Math.min(barWidth / 2, 8);
}
return 0;
}, [lineCap, barWidth, perspective]);
return (
<g className={`bar-series-${uniqueId}`}>
{data.map((d, i) => {
const value = d[dataKey];
if (typeof value !== "number") {
return null;
}
const categoryValue = barXAccessor(d);
const bandPos = barScale(categoryValue) ?? 0;
let x: number;
let y: number;
let barHeight: number;
let barW: number;
const scale = isHorizontal ? chartYScale : valueScale;
if (isHorizontal) {
// Horizontal bars: category on y-axis, value on x-axis
const valuePos = scale(value) ?? 0;
barW = valuePos; // Width is the value position (grows from left)
barHeight = barWidth;
if (stacked && stackOffsets) {
const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
x = scale(offset) ?? 0;
barW = valuePos - x;
// Apply stack gap for horizontal: shift right and reduce width
const gapOffset = seriesIndex * stackGap;
x += gapOffset;
if (!isLastSeries && stackGap > 0) {
barW = Math.max(0, barW - stackGap);
}
} else {
x = 0;
// For grouped bars, offset y position
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
y = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
}
y = stacked
? bandPos
: bandPos +
seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));
} else {
// Vertical bars: category on x-axis, value on y-axis
const valuePos = scale(value) ?? 0;
barHeight = innerHeight - valuePos;
barW = barWidth;
if (stacked && stackOffsets) {
const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
const offsetY = scale(offset) ?? innerHeight;
// Apply stack gap: shift up and reduce height
const gapOffset = seriesIndex * stackGap;
y = offsetY - barHeight - gapOffset;
// Reduce height slightly for non-last bars to create visual gap
if (!isLastSeries && stackGap > 0) {
barHeight = Math.max(0, barHeight - stackGap);
}
} else {
y = valuePos;
// For grouped bars, offset x position
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
x = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
}
x = stacked
? bandPos
: bandPos +
seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));
// Minimum visible height — floor short/zero non-stacked bars so a
// zero-value data point still reads as a tiny bar instead of
// vanishing. Grows up from the baseline. Floored bars skip the
// perspective trim (sub-pixel on a 3px bar; keeps the front aligned
// with bar-depth, which also skips trim for floored bars).
let isFloored = false;
if (
!stacked &&
minBarHeight > 0 &&
value >= 0 &&
barHeight < minBarHeight
) {
const baselineY = scale(0) ?? innerHeight;
barHeight = minBarHeight;
y = baselineY - minBarHeight;
isFloored = true;
}
// Perspective trim — shrink the topmost positive bar's front-face
// top down by its perspective rise so it meets `<BarDepthBack>`'s
// lid back edge. Stacked: only the last (topmost) series; grouped or
// single: every positive bar. Clamped to `barHeight - 1` so very
// short bars keep a positive height (matches bar-depth's clamp).
if (
perspective &&
value > 0 &&
!isFloored &&
(!stacked || isLastSeries)
) {
const baselineY = scale(0) ?? innerHeight;
const rise = barDepthPerspectiveRise(
barScale,
bandWidth,
barXAccessor,
innerWidth,
d,
y,
baselineY
);
const trim = Math.min(rise, Math.max(0, barHeight - 1));
y += trim;
barHeight -= trim;
}
}
const isFaded =
(hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed;
// Use categoryValue as key since it's the unique identifier from data
const barKey = `bar-${dataKey}-${categoryValue}`;
// Apply rounded corners:
// - For non-stacked: always apply
// - For stacked with gap: apply to all bars
// - For stacked without gap: only apply to the last series
const applyRounding = !stacked || stackGap > 0 || isLastSeries;
const effectiveRx = applyRounding ? cornerRadius : 0;
const effectiveRy = applyRounding ? cornerRadius : 0;
if (animate && !isLoaded) {
return (
<AnimatedBar
animationType={animationType}
enterTransition={enterTransition}
fadedOpacity={fadedOpacity}
fill={fill}
height={barHeight}
index={i}
innerHeight={innerHeight}
isFaded={isFaded}
isHorizontal={isHorizontal}
key={barKey}
revealEpoch={revealEpoch}
rx={effectiveRx}
ry={effectiveRy}
staggerDelay={calculatedStaggerDelay}
width={barW}
x={x}
y={y}
/>
);
}
// Static bar after animation completes
return (
<rect
fill={fill}
height={barHeight}
key={barKey}
opacity={isFaded ? fadedOpacity : 1}
rx={effectiveRx}
ry={effectiveRy}
style={{
cursor: "default",
transition: "opacity 0.15s ease-in-out",
}}
width={barW}
x={x}
y={y}
/>
);
})}
</g>
);
});
export function Bar(props: BarProps) {
const { barScale, bandWidth, barXAccessor } = useChartStable();
if (!(barScale && bandWidth && barXAccessor)) {
console.warn("Bar component must be used within a BarChart");
return null;
}
return (
<BarInner
{...props}
bandWidth={bandWidth}
barScale={barScale}
barXAccessor={barXAccessor}
/>
);
}
Bar.displayName = "Bar";
export default Bar;
@@ -0,0 +1,23 @@
import { isValidElement, type ReactElement } from "react";
/** Marker on wrapper components whose single child should inherit clip classification. */
export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const;
export function isChartClipPassthrough(type: unknown): boolean {
return (
typeof type === "function" &&
(type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===
true
);
}
/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */
export function resolveChartChildElement(child: ReactElement): ReactElement {
if (isChartClipPassthrough(child.type)) {
const inner = (child.props as { children?: unknown }).children;
if (isValidElement(inner)) {
return resolveChartChildElement(inner);
}
}
return child;
}
@@ -0,0 +1,92 @@
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
export interface SpringConfig {
stiffness: number;
damping: number;
}
export interface ChartConfigValue {
/** Crosshair indicator, tooltip dot, date pill. */
tooltipSpring: SpringConfig;
/** Floating tooltip panel. */
tooltipBoxSpring: SpringConfig;
/** Line/area hover-highlight band (x + width). */
highlightSpring: SpringConfig;
}
export const DEFAULT_CHART_CONFIG: ChartConfigValue = {
tooltipSpring: { stiffness: 300, damping: 30 },
tooltipBoxSpring: { stiffness: 100, damping: 20 },
highlightSpring: { stiffness: 180, damping: 28 },
};
const ChartConfigContext = createContext<ChartConfigValue | null>(null);
export interface ChartConfigProviderProps {
value?: Partial<ChartConfigValue>;
children: ReactNode;
}
export function ChartConfigProvider({
value,
children,
}: ChartConfigProviderProps) {
const merged = useMemo<ChartConfigValue>(
() => ({
...DEFAULT_CHART_CONFIG,
...value,
}),
[value]
);
return (
<ChartConfigContext.Provider value={merged}>
{children}
</ChartConfigContext.Provider>
);
}
export function useChartConfig(): ChartConfigValue {
return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG;
}
const DEFAULT_TOOLTIP_BOX_DAMPING =
DEFAULT_CHART_CONFIG.tooltipBoxSpring.damping;
/** Maps a damping slider to the floating tooltip panel follow spring. `0` = instant. */
export function resolveTooltipBoxMotion(damping?: number): {
animate: boolean;
springConfig: SpringConfig;
} {
if (damping === 0) {
return {
animate: false,
springConfig: DEFAULT_CHART_CONFIG.tooltipBoxSpring,
};
}
const effectiveDamping = damping ?? DEFAULT_TOOLTIP_BOX_DAMPING;
let stiffness = DEFAULT_CHART_CONFIG.tooltipBoxSpring.stiffness;
if (effectiveDamping < DEFAULT_TOOLTIP_BOX_DAMPING) {
const t =
(DEFAULT_TOOLTIP_BOX_DAMPING - effectiveDamping) /
DEFAULT_TOOLTIP_BOX_DAMPING;
stiffness += t * 400;
} else if (effectiveDamping > DEFAULT_TOOLTIP_BOX_DAMPING) {
const t =
(effectiveDamping - DEFAULT_TOOLTIP_BOX_DAMPING) /
(100 - DEFAULT_TOOLTIP_BOX_DAMPING);
stiffness -= t * 85;
}
return {
animate: true,
springConfig: {
stiffness: Math.max(12, Math.round(stiffness)),
damping: effectiveDamping,
},
};
}
@@ -0,0 +1,419 @@
"use client";
import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale";
type ScaleLinear<Output, _Input = number> = ReturnType<
typeof scaleLinear<Output>
>;
type ScaleTime<Output, _Input = Date | number> = ReturnType<
typeof scaleTime<Output>
>;
type ScaleBand<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
import type { Transition } from "motion/react";
import {
createContext,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useContext,
useMemo,
} from "react";
import type { ChartPhase, ChartStatus } from "./chart-phase";
import type { ChartSelection } from "./use-chart-interaction";
import { DEFAULT_Y_AXIS_ID } from "./y-axis-scales";
import type { YDomain } from "./y-domain-utils";
// CSS variable references for theming
export const chartCssVars = {
background: "var(--chart-background)",
foreground: "var(--chart-foreground)",
foregroundMuted: "var(--chart-foreground-muted)",
label: "var(--chart-label)",
linePrimary: "var(--chart-line-primary)",
lineSecondary: "var(--chart-line-secondary)",
crosshair: "var(--chart-crosshair)",
grid: "var(--chart-grid)",
indicatorColor: "var(--chart-indicator-color)",
indicatorSecondaryColor: "var(--chart-indicator-secondary-color)",
markerBackground: "var(--chart-marker-background)",
markerBorder: "var(--chart-marker-border)",
markerForeground: "var(--chart-marker-foreground)",
badgeBackground: "var(--chart-marker-badge-background)",
badgeForeground: "var(--chart-marker-badge-foreground)",
segmentBackground: "var(--chart-segment-background)",
segmentLine: "var(--chart-segment-line)",
brushBorder: "var(--chart-brush-border)",
};
/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */
export const defaultScatterColors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
] as const;
export interface Margin {
top: number;
right: number;
bottom: number;
left: number;
}
export interface TooltipData {
/** The data point being hovered */
point: Record<string, unknown>;
/** Index in the data array */
index: number;
/** X position in pixels (relative to chart area) */
x: number;
/** Y positions for each line, keyed by dataKey */
yPositions: Record<string, number>;
/** X positions for each series (for grouped bars), keyed by dataKey */
xPositions?: Record<string, number>;
}
export interface LineConfig {
dataKey: string;
stroke: string;
strokeWidth: number;
/** Scale group id (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
}
/**
* Hover/selection state — every field here changes on mouse movement.
* Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …)
* can subscribe to the stable slice and skip re-rendering on every hover.
*/
export interface ChartHoverContextValue {
// Tooltip state
tooltipData: TooltipData | null;
setTooltipData: Dispatch<SetStateAction<TooltipData | null>>;
// Selection state (optional - only present when useChartInteraction is used)
/** Current drag/pinch selection range */
selection?: ChartSelection | null;
/** Clear the current selection */
clearSelection?: () => void;
// Bar chart hover (optional - only present in BarChart)
/** Index of currently hovered bar */
hoveredBarIndex?: number | null;
/** Setter for hovered bar index */
setHoveredBarIndex?: (index: number | null) => void;
// Candlestick hover (optional - only present in CandlestickChart)
/** Index of currently hovered candle */
hoveredCandleIndex?: number | null;
/** Setter for hovered candle index */
setHoveredCandleIndex?: (index: number | null) => void;
}
export interface ChartContextValue extends ChartHoverContextValue {
// Data
data: Record<string, unknown>[];
/** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */
renderData: Record<string, unknown>[];
// Scales
xScale: ScaleTime<number, number>;
/** Primary (left) y-scale — alias for `yScales[DEFAULT_Y_AXIS_ID]`. */
yScale: ScaleLinear<number, number>;
/** Per-axis y-scales keyed by `yAxisId`. */
yScales: Record<string, ScaleLinear<number, number>>;
// Dimensions
width: number;
height: number;
innerWidth: number;
innerHeight: number;
margin: Margin;
// Column width for spacing calculations
columnWidth: number;
// Container ref for portals
containerRef: RefObject<HTMLDivElement | null>;
// Line configurations (extracted from children)
lines: LineConfig[];
// Loading / lifecycle (LineChart status transitions)
chartPhase: ChartPhase;
chartStatus: ChartStatus;
/** Centered label while `chartPhase` shows loading chrome. */
loadingLabel?: string;
/** Y-domain tween duration when transitioning loading ↔ ready (ms). */
yDomainTweenDuration: number;
/** Niced y-domains per axis from skeleton data (placeholder). */
yDomainSkeletonByAxis: Record<string, YDomain>;
/** Niced y-domains per axis from the current target data. */
yDomainTargetByAxis: Record<string, YDomain>;
// Animation state
isLoaded: boolean;
animationDuration: number;
/** CSS easing for clip-reveal / line draw (cartesian charts). */
animationEasing?: string;
/** Motion enter transition (spring or tween) — drives clip reveal when spring. */
enterTransition?: Transition;
/** Increments when enter animation should replay. */
revealEpoch?: number;
/** Fired when a one-shot loading pulse (exit / enter) completes. */
notifyLoadingPulseComplete?: () => void;
// X accessor - how to get the x value from data points
xAccessor: (d: Record<string, unknown>) => Date;
// Pre-computed date labels for ticker animation
dateLabels: string[];
/** Active brush zoom range — when set, axis ticks align to visible data rows. */
xDomain?: [Date, Date];
/** Full dataset length when brush zoom is enabled (for zoom vs full-range detection). */
xDomainSlotCount?: number;
// Bar chart specific (optional - only present in BarChart)
/** Band scale for categorical x-axis (bar charts) */
barScale?: ScaleBand<string>;
/** Width of each bar band */
bandWidth?: number;
/** X accessor for bar charts (returns string instead of Date) */
barXAccessor?: (d: Record<string, unknown>) => string;
/** Bar chart orientation */
orientation?: "vertical" | "horizontal";
/** Whether bars are stacked */
stacked?: boolean;
/** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */
stackOffsets?: Map<number, Map<string, number>>;
// ComposedChart + SeriesBar (optional)
/** `SeriesBar` dataKeys in tree order, for grouped columns at each x */
composedBarDataKeys?: string[];
/** Target bar width in px (Recharts `barSize` style). */
composedBarSize?: number;
/** Max bar width in px (Recharts `maxBarSize`). */
composedMaxBarSize?: number;
/** Gap between grouped `SeriesBar` columns in px. */
composedBarGap?: number;
/** When true, `SeriesBar` segments stack in child order at each x. */
composedStacked?: boolean;
/** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */
composedStackOffsets?: Map<number, Map<string, number>>;
/** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */
composedStackGap?: number;
}
/**
* Stable slice of the chart context — everything that doesn't change on hover
* (data, scales, dimensions, animation state, layout config). Consumers that
* subscribe via `useChartStable()` skip re-renders on every mouse move.
*/
export type ChartStableContextValue = Omit<
ChartContextValue,
keyof ChartHoverContextValue
>;
const ChartStableContext = createContext<ChartStableContextValue | null>(null);
const ChartHoverContext = createContext<ChartHoverContextValue | null>(null);
/**
* Splits the merged `value` into a stable slice and a volatile hover slice,
* publishing each to its own context. Each slice is memoized on its own
* field identities, so changing `tooltipData` does not bust the stable
* slice — consumers of `useChartStable()` skip re-renders on hover.
*/
export function ChartProvider({
children,
value,
}: {
children: ReactNode;
value: ChartContextValue;
}) {
const stable = useMemo<ChartStableContextValue>(
() => ({
data: value.data,
renderData: value.renderData,
xScale: value.xScale,
yScale: value.yScale,
yScales: value.yScales,
width: value.width,
height: value.height,
innerWidth: value.innerWidth,
innerHeight: value.innerHeight,
margin: value.margin,
columnWidth: value.columnWidth,
containerRef: value.containerRef,
lines: value.lines,
chartPhase: value.chartPhase,
chartStatus: value.chartStatus,
loadingLabel: value.loadingLabel,
yDomainTweenDuration: value.yDomainTweenDuration,
yDomainSkeletonByAxis: value.yDomainSkeletonByAxis,
yDomainTargetByAxis: value.yDomainTargetByAxis,
isLoaded: value.isLoaded,
animationDuration: value.animationDuration,
animationEasing: value.animationEasing,
enterTransition: value.enterTransition,
revealEpoch: value.revealEpoch,
notifyLoadingPulseComplete: value.notifyLoadingPulseComplete,
xAccessor: value.xAccessor,
dateLabels: value.dateLabels,
xDomain: value.xDomain,
xDomainSlotCount: value.xDomainSlotCount,
barScale: value.barScale,
bandWidth: value.bandWidth,
barXAccessor: value.barXAccessor,
orientation: value.orientation,
stacked: value.stacked,
stackOffsets: value.stackOffsets,
composedBarDataKeys: value.composedBarDataKeys,
composedBarSize: value.composedBarSize,
composedMaxBarSize: value.composedMaxBarSize,
composedBarGap: value.composedBarGap,
composedStacked: value.composedStacked,
composedStackOffsets: value.composedStackOffsets,
composedStackGap: value.composedStackGap,
}),
[
value.data,
value.renderData,
value.xScale,
value.yScale,
value.yScales,
value.width,
value.height,
value.innerWidth,
value.innerHeight,
value.margin,
value.columnWidth,
value.containerRef,
value.lines,
value.chartPhase,
value.chartStatus,
value.loadingLabel,
value.yDomainTweenDuration,
value.yDomainSkeletonByAxis,
value.yDomainTargetByAxis,
value.isLoaded,
value.animationDuration,
value.animationEasing,
value.enterTransition,
value.revealEpoch,
value.notifyLoadingPulseComplete,
value.xAccessor,
value.dateLabels,
value.xDomain,
value.xDomainSlotCount,
value.barScale,
value.bandWidth,
value.barXAccessor,
value.orientation,
value.stacked,
value.stackOffsets,
value.composedBarDataKeys,
value.composedBarSize,
value.composedMaxBarSize,
value.composedBarGap,
value.composedStacked,
value.composedStackOffsets,
value.composedStackGap,
]
);
const hover = useMemo<ChartHoverContextValue>(
() => ({
tooltipData: value.tooltipData,
setTooltipData: value.setTooltipData,
selection: value.selection,
clearSelection: value.clearSelection,
hoveredBarIndex: value.hoveredBarIndex,
setHoveredBarIndex: value.setHoveredBarIndex,
hoveredCandleIndex: value.hoveredCandleIndex,
setHoveredCandleIndex: value.setHoveredCandleIndex,
}),
[
value.tooltipData,
value.setTooltipData,
value.selection,
value.clearSelection,
value.hoveredBarIndex,
value.setHoveredBarIndex,
value.hoveredCandleIndex,
value.setHoveredCandleIndex,
]
);
return (
<ChartStableContext.Provider value={stable}>
<ChartHoverContext.Provider value={hover}>
{children}
</ChartHoverContext.Provider>
</ChartStableContext.Provider>
);
}
/**
* Stable slice — data, scales, dimensions, animation state, layout config.
* Subscribers skip re-renders on hover (the hover slice lives in a separate
* context). Prefer this in cold consumers like axes, grid, pattern fills.
*/
export function useChartStable(): ChartStableContextValue {
const context = useContext(ChartStableContext);
if (!context) {
throw new Error(
"useChartStable must be used within a ChartProvider. " +
"Make sure your component is wrapped in <LineChart>, <AreaChart>, <BarChart>, or <ComposedChart>."
);
}
return context;
}
/** Y-scale for a series axis (`yAxisId` on Line / Area / YAxis). */
export function useYScale(
yAxisId?: string | number
): ScaleLinear<number, number> {
const { yScales, yScale } = useChartStable();
const id =
yAxisId == null || yAxisId === "" ? DEFAULT_Y_AXIS_ID : String(yAxisId);
return yScales[id] ?? yScale;
}
/**
* Hover slice — tooltipData, selection, hovered bar / candle indices.
* Subscribers re-render on every mouse move. Use only when the component
* actually reads hover state.
*/
export function useChartHover(): ChartHoverContextValue {
const context = useContext(ChartHoverContext);
if (!context) {
throw new Error(
"useChartHover must be used within a ChartProvider. " +
"Make sure your component is wrapped in <LineChart>, <AreaChart>, <BarChart>, or <ComposedChart>."
);
}
return context;
}
/**
* Merged stable + hover context. Convenient for components that need both,
* but re-renders on every hover (because hover changes). Prefer
* `useChartStable()` or `useChartHover()` for hot consumers that only need
* one slice.
*/
export function useChart(): ChartContextValue {
const stable = useChartStable();
const hover = useChartHover();
// Identity changes on every hover (hover is the volatile slice) — that's
// fine for consumers using this merged hook; they explicitly opted in to
// re-rendering on hover.
return { ...stable, ...hover };
}
export default ChartStableContext;
+72
View File
@@ -0,0 +1,72 @@
import {
Children,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
export function getChartChildComponentName(child: ReactElement): string {
const childType = child.type as { displayName?: string; name?: string };
return typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
}
const VISX_PATTERN_COMPONENT_NAMES = new Set([
"Lines",
"Circles",
"Waves",
"Hexagons",
"Path",
"Pattern",
]);
/** @visx/pattern default exports use short names (e.g. `Lines`); also match *Pattern* displayNames. */
export function isPatternDefComponent(child: ReactElement): boolean {
const name = getChartChildComponentName(child);
return name.includes("Pattern") || VISX_PATTERN_COMPONENT_NAMES.has(name);
}
export function isGradientDefComponent(child: ReactElement): boolean {
const name = getChartChildComponentName(child);
return (
name.includes("Gradient") ||
name === "LinearGradient" ||
name === "RadialGradient"
);
}
export function isChartDefsComponent(child: ReactElement): boolean {
return isPatternDefComponent(child) || isGradientDefComponent(child);
}
/** Split hoisted defs: @visx/pattern nodes already wrap `<defs>` and render at the svg root. */
export function partitionChartDefNodes(defNodes: ReactElement[]): {
patternDefNodes: ReactElement[];
gradientDefNodes: ReactElement[];
} {
const patternDefNodes: ReactElement[] = [];
const gradientDefNodes: ReactElement[] = [];
for (const node of defNodes) {
if (isPatternDefComponent(node)) {
patternDefNodes.push(node);
} else {
gradientDefNodes.push(node);
}
}
return { patternDefNodes, gradientDefNodes };
}
export function collectChartDefsChildren(children: ReactNode): ReactElement[] {
const defNodes: ReactElement[] = [];
Children.forEach(children, (child) => {
if (isValidElement(child) && isChartDefsComponent(child)) {
defNodes.push(child);
}
});
return defNodes;
}
@@ -0,0 +1,20 @@
export const shortDateFmt = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
export const weekdayDateFmt = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
export const hmsTimeFmt = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
// `Intl.NumberFormat.prototype.format` is a bound getter — safe to extract.
export const intFmt = new Intl.NumberFormat("en-US").format;
@@ -0,0 +1,44 @@
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
interface ChartLegendHoverContextValue {
hoveredIndex: number | null;
setHoveredIndex: (index: number | null) => void;
}
const ChartLegendHoverContext =
createContext<ChartLegendHoverContextValue | null>(null);
export function ChartLegendHoverProvider({
hoveredIndex,
onHoverChange,
children,
}: {
hoveredIndex: number | null;
onHoverChange: (index: number | null) => void;
children: ReactNode;
}) {
const value = useMemo(
() => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
[hoveredIndex, onHoverChange]
);
return (
<ChartLegendHoverContext.Provider value={value}>
{children}
</ChartLegendHoverContext.Provider>
);
}
export function useChartLegendHover(): ChartLegendHoverContextValue {
const context = useContext(ChartLegendHoverContext);
return (
context ?? {
hoveredIndex: null,
setHoveredIndex: () => {
/* noop outside ChartLegendHoverProvider */
},
}
);
}
@@ -0,0 +1,56 @@
"use client";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { ShimmeringText } from "@/components/shimmering-text";
import {
LINE_LOADING_PULSE_EASE,
LOADING_LABEL_EXIT_S,
LOADING_LABEL_EXIT_Y_PX,
} from "./line-loading-timing";
export interface ChartLoadingLabelProps {
/** Label shown centered over the chart. */
text?: string;
className?: string;
/** Animate down, fade, and blur during loading → ready handoff. */
exiting?: boolean;
}
export function ChartLoadingLabel({
text = "Loading",
className,
exiting = false,
}: ChartLoadingLabelProps) {
if (!text.trim()) {
return null;
}
return (
<motion.div
animate={{
y: exiting ? LOADING_LABEL_EXIT_Y_PX : 0,
opacity: exiting ? 0 : 1,
filter: exiting ? "blur(2px)" : "blur(0px)",
}}
aria-live="polite"
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center",
className
)}
initial={false}
role="status"
transition={{
duration: LOADING_LABEL_EXIT_S,
ease: [...LINE_LOADING_PULSE_EASE],
}}
>
<ShimmeringText
className="font-medium text-sm tracking-wide [--color:var(--muted-foreground)] [--shimmering-color:var(--foreground)]"
text={text}
/>
</motion.div>
);
}
export default ChartLoadingLabel;
+48
View File
@@ -0,0 +1,48 @@
export type ChartStatus = "loading" | "ready";
/**
* Internal visual lifecycle phase. Forward and reverse transitions add
* intermediate phases in later stack branches.
*/
export type ChartPhase =
| "loading"
| "exiting"
| "gridTweenReady"
| "revealing"
| "ready"
| "exitingReady"
| "gridTweenLoading"
| "revealingLoading";
export const DEFAULT_CHART_STATUS: ChartStatus = "ready";
/** Default Y-domain tween when transitioning loading ↔ ready (ms). */
export const DEFAULT_Y_DOMAIN_TWEEN_MS = 500;
/** Relative domain delta below which Y tween may be skipped (see plan). */
export const Y_DOMAIN_TWEEN_SKIP_THRESHOLD = 0.02;
/** Resting phase for a given status before transition orchestration runs. */
export function resolveRestingChartPhase(status: ChartStatus): ChartPhase {
return status === "loading" ? "loading" : "ready";
}
export function isChartInteractionPhase(phase: ChartPhase): boolean {
return phase === "ready";
}
export const DEFAULT_CHART_LIFECYCLE = {
chartPhase: "ready",
chartStatus: "ready",
loadingLabel: undefined,
yDomainTweenDuration: DEFAULT_Y_DOMAIN_TWEEN_MS,
yDomainSkeletonByAxis: { left: [0, 100] as [number, number] },
yDomainTargetByAxis: { left: [0, 100] as [number, number] },
} as const satisfies {
chartPhase: ChartPhase;
chartStatus: ChartStatus;
loadingLabel: undefined;
yDomainTweenDuration: number;
yDomainSkeletonByAxis: Record<string, [number, number]>;
yDomainTargetByAxis: Record<string, [number, number]>;
};
@@ -0,0 +1,92 @@
"use client";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { clipRevealTransition } from "./animation";
export type ChartRevealClipMode = "reveal" | "conceal";
export interface ChartRevealClipProps {
clipPathId: string;
height: number;
targetWidth: number;
enterTransition?: Transition;
/** Bumps when motion settings change to replay the reveal. */
revealEpoch: number;
/** Extra inset around the clip rect so edge glyphs are not cut off. */
padding?: number;
/** When false, clip stays at full width (no grow animation). */
animating?: boolean;
/** Reveal grows 0 → full; conceal shrinks full → 0 (ready → loading). */
mode?: ChartRevealClipMode;
/** Called when a conceal animation finishes. */
onComplete?: () => void;
}
/**
* Left-to-right clip reveal for cartesian series.
* Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center).
*/
export function ChartRevealClip({
clipPathId,
height,
targetWidth,
enterTransition,
revealEpoch,
padding = 0,
animating = true,
mode = "reveal",
onComplete,
}: ChartRevealClipProps) {
const transition = clipRevealTransition(enterTransition);
const paddedWidth = Math.max(0, targetWidth + padding * 2);
const paddedHeight = height + padding * 2;
if (!animating) {
return (
<clipPath id={clipPathId}>
<rect
height={paddedHeight}
width={paddedWidth}
x={-padding}
y={-padding}
/>
</clipPath>
);
}
if (mode === "conceal") {
// Mirror the LTR reveal: advance the clip's left edge rightward while width
// shrinks (same geometry as `LineLoadingPulseStroke` exit half-cycle).
const rightEdge = -padding + paddedWidth;
return (
<clipPath id={clipPathId}>
<motion.rect
animate={{ width: 0, x: rightEdge }}
height={paddedHeight}
initial={{ width: paddedWidth, x: -padding }}
key={`conceal-${revealEpoch}`}
onAnimationComplete={() => onComplete?.()}
transition={transition}
y={-padding}
/>
</clipPath>
);
}
return (
<clipPath id={clipPathId}>
<motion.rect
animate={{ width: paddedWidth }}
height={paddedHeight}
initial={{ width: 0 }}
key={`reveal-${revealEpoch}`}
transition={transition}
width={paddedWidth}
x={-padding}
y={-padding}
/>
</clipPath>
);
}
@@ -0,0 +1,75 @@
"use client";
import { useId } from "react";
export interface DashTailStrokeProps {
/** SVG path `d` for the full series (single curved path). */
pathD: string | null;
/** Total length of `pathD` in user units. */
pathLength: number;
/** Path length at which the dashed tail begins. */
dashStartLength: number;
/** X coordinate (chart inner space) where the tail clip begins. */
dashStartX: number;
innerWidth: number;
innerHeight: number;
/** Stroke paint — solid color or gradient url. */
stroke: string;
strokeWidth: number;
dashArray: string;
}
export function DashTailStroke({
pathD,
pathLength,
dashStartLength,
dashStartX,
innerWidth,
innerHeight,
stroke,
strokeWidth,
dashArray,
}: DashTailStrokeProps) {
const clipPathId = useId().replace(/:/g, "");
if (!pathD || pathLength <= 0 || dashStartLength >= pathLength) {
return null;
}
const pad = strokeWidth * 2;
const tailWidth = Math.max(0, innerWidth - dashStartX + pad);
return (
<>
<defs>
<clipPath id={clipPathId}>
<rect
height={innerHeight + pad}
width={tailWidth}
x={dashStartX - strokeWidth}
y={-strokeWidth}
/>
</clipPath>
</defs>
{/* Solid head — same curved path, gradient/fade preserved */}
<path
d={pathD}
fill="none"
stroke={stroke}
strokeDasharray={`${dashStartLength} ${Math.max(1, pathLength - dashStartLength)}`}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
{/* Dashed tail — clipped to x ≥ dashStartX so dashes follow the curve */}
<path
clipPath={`url(#${clipPathId})`}
d={pathD}
fill="none"
stroke={stroke}
strokeDasharray={dashArray}
strokeLinecap="round"
strokeWidth={strokeWidth}
/>
</>
);
}
@@ -0,0 +1,136 @@
export function decimateTimeSeries<T extends Record<string, unknown>>(
data: T[],
maxPoints: number,
valueKeys: string[] = []
): T[] {
const len = data.length;
if (maxPoints >= len || maxPoints < 3) {
return data;
}
const getY = (point: T, index: number): number => {
if (valueKeys.length === 0) {
for (const val of Object.values(point)) {
if (typeof val === "number") {
return val;
}
}
return index;
}
let sum = 0;
let count = 0;
for (const key of valueKeys) {
const val = point[key];
if (typeof val === "number") {
sum += val;
count++;
}
}
return count > 0 ? sum / count : index;
};
const sampled: T[] = [data[0] as T];
const bucketSize = (len - 2) / (maxPoints - 2);
let previousIndex = 0;
for (let i = 0; i < maxPoints - 2; i++) {
const rangeStart = Math.floor((i + 1) * bucketSize) + 1;
const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1);
const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1;
const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len);
const nextCount = Math.max(0, nextRangeEnd - nextRangeStart);
let avgX = len - 1;
let avgY = getY(data[len - 1] as T, len - 1);
if (nextCount > 0) {
avgX = 0;
avgY = 0;
for (let j = nextRangeStart; j < nextRangeEnd; j++) {
avgX += j;
avgY += getY(data[j] as T, j);
}
avgX /= nextCount;
avgY /= nextCount;
}
const pointA = data[previousIndex] as T;
const ax = previousIndex;
const ay = getY(pointA, previousIndex);
let maxArea = -1;
let maxIndex = rangeStart;
for (let j = rangeStart; j < rangeEnd; j++) {
const area =
Math.abs(
(ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay)
) * 0.5;
if (area > maxArea) {
maxArea = area;
maxIndex = j;
}
}
sampled.push(data[maxIndex] as T);
previousIndex = maxIndex;
}
sampled.push(data[len - 1] as T);
return sampled;
}
/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */
export function maxRenderPointsForWidth(innerWidth: number): number {
return Math.max(64, Math.ceil(innerWidth * 1.5));
}
/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */
export function decimateOhlcData<T extends Record<string, unknown>>(
data: T[],
maxPoints: number
): T[] {
const len = data.length;
if (maxPoints >= len || maxPoints < 2) {
return data;
}
const bucketSize = len / maxPoints;
const sampled: T[] = [];
for (let i = 0; i < maxPoints; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.min(len, Math.floor((i + 1) * bucketSize));
if (start >= end) {
continue;
}
const bucket = data.slice(start, end);
const first = bucket[0] as T;
const last = bucket.at(-1) as T;
let high = Number.NEGATIVE_INFINITY;
let low = Number.POSITIVE_INFINITY;
for (const row of bucket) {
const rowHigh = row.high;
const rowLow = row.low;
if (typeof rowHigh === "number" && rowHigh > high) {
high = rowHigh;
}
if (typeof rowLow === "number" && rowLow < low) {
low = rowLow;
}
}
sampled.push({
...last,
open: first.open,
high: Number.isFinite(high) ? high : last.high,
low: Number.isFinite(low) ? low : last.low,
close: last.close,
} as T);
}
return sampled;
}
+52
View File
@@ -0,0 +1,52 @@
export type FadeEdges = boolean | "left" | "right";
export interface FadeSides {
/** Whether the left edge should fade out. */
left: boolean;
/** Whether the right edge should fade out. */
right: boolean;
/** True if either side fades — use to gate gradient/mask defs. */
any: boolean;
}
export function resolveFadeSides(fade: FadeEdges): FadeSides {
if (fade === false) {
return { left: false, right: false, any: false };
}
if (fade === "left") {
return { left: true, right: false, any: true };
}
if (fade === "right") {
return { left: false, right: true, any: true };
}
return { left: true, right: true, any: true };
}
export interface FadeGradientStop {
offset: string;
opacity: number;
}
/**
* Stops for a horizontal fade gradient with opacity 0 at the faded side(s)
* and opacity 1 in the middle. Matches the historic 0/15/85/100 pattern.
*/
export function fadeGradientStops(sides: FadeSides): FadeGradientStop[] {
return [
{ offset: "0%", opacity: sides.left ? 0 : 1 },
{ offset: "15%", opacity: 1 },
{ offset: "85%", opacity: 1 },
{ offset: "100%", opacity: sides.right ? 0 : 1 },
];
}
/** Horizontal fade gradient pinned to the chart viewport (not the series path bounds). */
export function viewportFadeGradientAttrs(innerWidth: number) {
return {
gradientUnits: "userSpaceOnUse" as const,
x1: 0,
x2: innerWidth,
y1: 0,
y2: 0,
};
}
@@ -0,0 +1,43 @@
export function filterDataByXDomain(
data: Record<string, unknown>[],
xDomain: [Date, Date],
xAccessor: (d: Record<string, unknown>) => Date
): Record<string, unknown>[] {
const start = xDomain[0].getTime();
const end = xDomain[1].getTime();
const minTime = Math.min(start, end);
const maxTime = Math.max(start, end);
return data.filter((d) => {
const time = xAccessor(d).getTime();
return time >= minTime && time <= maxTime;
});
}
export function resolveDataXExtent(
data: Record<string, unknown>[],
xAccessor: (d: Record<string, unknown>) => Date
): [Date, Date] | null {
if (data.length === 0) {
return null;
}
let minTime = Number.POSITIVE_INFINITY;
let maxTime = Number.NEGATIVE_INFINITY;
for (const point of data) {
const time = xAccessor(point).getTime();
if (time < minTime) {
minTime = time;
}
if (time > maxTime) {
maxTime = time;
}
}
if (minTime === Number.POSITIVE_INFINITY) {
return null;
}
return [new Date(minTime), new Date(maxTime)];
}
@@ -0,0 +1,42 @@
const DEFAULT_SKELETON_DATA_KEY = "value";
const DEFAULT_SKELETON_POINT_COUNT = 7;
export interface GenerateChartSkeletonDataOptions {
/** Key used for y values in each row. Default: `"value"`. */
dataKey?: string;
/** Number of points. Default: 7. */
pointCount?: number;
/** Start date for the x axis. Default: 2025-01-01. */
baseDate?: Date;
}
/** Placeholder series used while `status="loading"` and data is empty. */
export function generateChartSkeletonData(
options: GenerateChartSkeletonDataOptions = {}
): Record<string, unknown>[] {
const dataKey = options.dataKey ?? DEFAULT_SKELETON_DATA_KEY;
const pointCount = options.pointCount ?? DEFAULT_SKELETON_POINT_COUNT;
const baseDate = options.baseDate ?? new Date("2025-01-01");
return Array.from({ length: pointCount }, (_, index) => {
const date = new Date(baseDate);
date.setDate(baseDate.getDate() + index);
return {
date,
[dataKey]: Math.round(110 + Math.sin(index * 1.15) * 36 + index * 9),
};
});
}
/** Skeleton rows that mirror target dates/count with lower magnitudes for Y tween. */
export function generateChartSkeletonFromTarget(
targetData: Record<string, unknown>[],
dataKey: string
): Record<string, unknown>[] {
return targetData.map((row, index) => ({
...row,
[dataKey]: Math.round(95 + Math.sin(index * 1.05) * 28 + index * 7),
}));
}
export { DEFAULT_SKELETON_DATA_KEY, DEFAULT_SKELETON_POINT_COUNT };
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { GridColumns, GridRows } from "@visx/grid";
import { motion } from "motion/react";
import { useId } from "react";
import { chartCssVars, useChartStable, useYScale } from "./chart-context";
import { useGridShimmer } from "./use-grid-shimmer";
import {
isLoadingChromePhase,
isLoadingGridChromePhase,
} from "./y-domain-utils";
const DEFAULT_SHIMMER_LENGTH_PX = 140;
const DEFAULT_SHIMMER_SPEED = 1;
const DEFAULT_SHIMMER_STROKE =
"color-mix(in oklch, var(--foreground) 68%, transparent)";
export interface GridProps {
/** Show horizontal grid lines. Default: true */
horizontal?: boolean;
/** Show vertical grid lines. Default: false */
vertical?: boolean;
/** Number of horizontal grid lines. Default: 5 */
numTicksRows?: number;
/** Number of vertical grid lines. Default: 10 */
numTicksColumns?: number;
/** Explicit tick values for horizontal grid lines. Overrides numTicksRows. */
rowTickValues?: number[];
/** Grid line stroke color. Default: var(--chart-grid) */
stroke?: string;
/** Grid stroke while loading chrome is active. Falls back to `stroke`. */
loadingStroke?: string;
/** Grid line stroke opacity. Default: 1 */
strokeOpacity?: number;
/** Grid line stroke width. Default: 1 */
strokeWidth?: number;
/** Grid line dash array. Default: "4,4" for dashed lines */
strokeDasharray?: string;
/** Horizontal row values rendered with alternate styling (e.g. zero baseline). */
highlightRowValues?: number[];
/** Stroke for highlighted rows. Default: var(--chart-foreground-muted) */
highlightRowStroke?: string;
/** Stroke opacity for highlighted rows. Default: 1 */
highlightRowStrokeOpacity?: number;
/** Stroke width for highlighted rows. Default: 1 */
highlightRowStrokeWidth?: number;
/** Dash array for highlighted rows. Default: solid line */
highlightRowStrokeDasharray?: string;
/** Enable horizontal fade effect on grid rows (fades at left/right). Default: true */
fadeHorizontal?: boolean;
/** Enable vertical fade effect on grid columns (fades at top/bottom). Default: false */
fadeVertical?: boolean;
/** Omit the first and last horizontal grid lines. Default: false */
hideHorizontalEdgeLines?: boolean;
/** Omit the first and last vertical grid lines. Default: false */
hideVerticalEdgeLines?: boolean;
/** Y-scale for horizontal grid lines. Default: primary (`"left"`) axis. */
yAxisId?: string | number;
/** Animate a shimmer band across horizontal grid lines. Default: false */
shimmer?: boolean;
/** Shimmer band stroke (color and opacity via color-mix or oklch alpha). */
shimmerStroke?: string;
/** Shimmer band width in pixels. Default: 140 */
shimmerLength?: number;
/** Shimmer speed multiplier (higher = faster). Default: 1 */
shimmerSpeed?: number;
/** Match loop timing to the loading line pulse (cycle + inter-loop pause). */
shimmerSync?: boolean;
}
function hideEdgeTicks<T>(ticks: T[], hideEdgeLines: boolean): T[] {
if (!hideEdgeLines || ticks.length <= 2) {
return ticks;
}
return ticks.slice(1, -1);
}
function resolveRowTickValues(options: {
hideHorizontalEdgeLines: boolean;
numTicksRows: number;
rowTickValues?: number[];
yScale: { ticks?: (count: number) => number[] };
}): number[] | undefined {
const { hideHorizontalEdgeLines, numTicksRows, rowTickValues, yScale } =
options;
const ticks =
rowTickValues ?? (yScale.ticks ? yScale.ticks(numTicksRows) : []);
const filtered = hideEdgeTicks(ticks, hideHorizontalEdgeLines);
if (filtered === ticks && !rowTickValues && !hideHorizontalEdgeLines) {
return undefined;
}
return filtered.length > 0 ? filtered : undefined;
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: grid fade masks and shimmer share one layer tree
export function Grid({
horizontal = true,
vertical = false,
numTicksRows = 5,
numTicksColumns = 10,
rowTickValues,
stroke = chartCssVars.grid,
loadingStroke,
strokeOpacity = 1,
strokeWidth = 1,
strokeDasharray = "4,4",
highlightRowValues,
highlightRowStroke = chartCssVars.foregroundMuted,
highlightRowStrokeOpacity = 1,
highlightRowStrokeWidth = 1,
highlightRowStrokeDasharray = "0",
fadeHorizontal = true,
fadeVertical = false,
hideHorizontalEdgeLines = false,
hideVerticalEdgeLines = false,
yAxisId,
shimmer = false,
shimmerStroke = DEFAULT_SHIMMER_STROKE,
shimmerLength = DEFAULT_SHIMMER_LENGTH_PX,
shimmerSpeed = DEFAULT_SHIMMER_SPEED,
shimmerSync = false,
}: GridProps) {
const { xScale, innerWidth, innerHeight, orientation, barScale, chartPhase } =
useChartStable();
const yScale = useYScale(yAxisId);
const shimmerActive = shimmer && isLoadingChromePhase(chartPhase);
const gridStroke =
isLoadingGridChromePhase(chartPhase) && loadingStroke != null
? loadingStroke
: stroke;
const { shimmerEnabled, shimmerTransform } = useGridShimmer({
innerWidth,
shimmer,
shimmerLength,
shimmerSpeed,
shimmerSync,
active: shimmerActive,
});
// For bar charts, determine which scale to use for grid lines
// Horizontal bar charts: vertical grid should use yScale (value scale)
// Vertical bar charts: horizontal grid uses yScale (value scale)
const isHorizontalBarChart = orientation === "horizontal" && barScale;
// For vertical grid lines in horizontal bar charts, use yScale (the value scale)
// For time-based charts, use xScale
const columnScale = isHorizontalBarChart ? yScale : xScale;
const rowTickValuesResolved = resolveRowTickValues({
hideHorizontalEdgeLines,
numTicksRows,
rowTickValues,
yScale,
});
const columnTickValuesResolved =
vertical &&
columnScale &&
typeof columnScale === "function" &&
hideVerticalEdgeLines
? (() => {
const ticks = columnScale.ticks?.(numTicksColumns) ?? [];
const filtered = hideEdgeTicks<number | Date>(ticks, true);
return filtered.length > 0 ? filtered : undefined;
})()
: undefined;
const uniqueId = useId();
// Horizontal fade mask (for grid rows - fades left/right)
const hMaskId = `grid-rows-fade-${uniqueId}`;
const hGradientId = `${hMaskId}-gradient`;
const shimmerGradientId = `grid-shimmer-${uniqueId}`;
// Vertical fade mask (for grid columns - fades top/bottom)
const vMaskId = `grid-cols-fade-${uniqueId}`;
const vGradientId = `${vMaskId}-gradient`;
return (
<g className="chart-grid">
{/* Gradient mask for horizontal grid lines - fades at left/right */}
{horizontal && (fadeHorizontal || shimmer) && (
<defs>
<linearGradient id={hGradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={hMaskId}>
<rect
fill={`url(#${hGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</defs>
)}
{horizontal && shimmerEnabled ? (
<defs>
<motion.linearGradient
gradientTransform={shimmerTransform}
gradientUnits="userSpaceOnUse"
id={shimmerGradientId}
x1={0}
x2={shimmerLength}
y1={0}
y2={0}
>
<stop offset="0%" stopColor={shimmerStroke} stopOpacity={0} />
<stop offset="35%" stopColor={shimmerStroke} stopOpacity={0.45} />
<stop offset="50%" stopColor={shimmerStroke} stopOpacity={1} />
<stop offset="65%" stopColor={shimmerStroke} stopOpacity={0.45} />
<stop offset="100%" stopColor={shimmerStroke} stopOpacity={0} />
</motion.linearGradient>
</defs>
) : null}
{/* Gradient mask for vertical grid lines - fades at top/bottom */}
{vertical && fadeVertical && (
<defs>
<linearGradient id={vGradientId} x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" style={{ stopColor: "white", stopOpacity: 0 }} />
<stop offset="10%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop offset="90%" style={{ stopColor: "white", stopOpacity: 1 }} />
<stop
offset="100%"
style={{ stopColor: "white", stopOpacity: 0 }}
/>
</linearGradient>
<mask id={vMaskId}>
<rect
fill={`url(#${vGradientId})`}
height={innerHeight}
width={innerWidth}
x="0"
y="0"
/>
</mask>
</defs>
)}
{horizontal && (
<g mask={fadeHorizontal || shimmer ? `url(#${hMaskId})` : undefined}>
<GridRows
numTicks={rowTickValuesResolved ? undefined : numTicksRows}
scale={yScale}
stroke={gridStroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
tickValues={rowTickValuesResolved}
width={innerWidth}
/>
{shimmerEnabled ? (
<GridRows
numTicks={rowTickValuesResolved ? undefined : numTicksRows}
scale={yScale}
stroke={`url(#${shimmerGradientId})`}
strokeDasharray={strokeDasharray}
strokeOpacity={1}
strokeWidth={strokeWidth}
tickValues={rowTickValuesResolved}
width={innerWidth}
/>
) : null}
</g>
)}
{horizontal && highlightRowValues && highlightRowValues.length > 0 ? (
<g className="chart-grid-highlight-rows">
{highlightRowValues.map((value) => {
const y = yScale(value);
if (y == null || !Number.isFinite(y)) {
return null;
}
return (
<line
key={value}
stroke={highlightRowStroke}
strokeDasharray={highlightRowStrokeDasharray}
strokeOpacity={highlightRowStrokeOpacity}
strokeWidth={highlightRowStrokeWidth}
x1={0}
x2={innerWidth}
y1={y}
y2={y}
/>
);
})}
</g>
) : null}
{vertical && columnScale && typeof columnScale === "function" && (
<g mask={fadeVertical ? `url(#${vMaskId})` : undefined}>
<GridColumns
height={innerHeight}
numTicks={columnTickValuesResolved ? undefined : numTicksColumns}
scale={columnScale}
stroke={stroke}
strokeDasharray={strokeDasharray}
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
tickValues={columnTickValuesResolved}
/>
</g>
)}
</g>
);
}
Grid.displayName = "Grid";
export default Grid;
@@ -0,0 +1,71 @@
import type { TooltipData } from "./chart-context";
import type { ChartSelection } from "./use-chart-interaction";
// Pure geometry for the hover-highlight band, split out from the hook so it can
// be unit-tested without React/motion (see __tests__).
//
// The band is the pixel x-range one data point either side of the hovered point:
// [ xScale(t(idx-1)), xScale(t(idx+1)) ]
// `<HighlightSegment>` then re-strokes the base path clipped to that band, so the
// highlight always traces the line itself. Selecting the band by data index
// assumes x is monotone along the path, which holds for a time series. On a curve
// that overshoots in x (curveNatural, curveBasis) a band edge can land a few
// pixels short, slightly narrowing the bright slice but never detaching it.
export interface SegmentBounds {
/** Left edge of the highlight band, in pixels. */
x: number;
/** Width of the highlight band, in pixels. */
width: number;
isActive: boolean;
}
export const INACTIVE_SEGMENT: SegmentBounds = {
x: 0,
width: 0,
isActive: false,
};
/**
* The highlight band `{x, width}` in pixel space, from the data + `xScale` plus
* the current hover/selection. Hover spans one data point either side of the dot
* (clamped to the ends); an active drag-selection uses the dragged pixel range
* directly and takes priority over hover.
*/
export function computeSegmentBounds(
data: Record<string, unknown>[],
xScale: (value: Date) => number | undefined,
xAccessor: (d: Record<string, unknown>) => Date,
tooltipData: Pick<TooltipData, "index"> | null | undefined,
selection:
| Pick<ChartSelection, "active" | "startX" | "endX">
| null
| undefined
): SegmentBounds {
if (data.length === 0) {
return INACTIVE_SEGMENT;
}
if (selection?.active) {
const x = Math.min(selection.startX, selection.endX);
const width = Math.abs(selection.endX - selection.startX);
return { x, width, isActive: true };
}
if (!tooltipData) {
return INACTIVE_SEGMENT;
}
const idx = tooltipData.index;
const startIdx = Math.max(0, idx - 1);
const endIdx = Math.min(data.length - 1, idx + 1);
const startPoint = data[startIdx];
const endPoint = data[endIdx];
if (!(startPoint && endPoint)) {
return INACTIVE_SEGMENT;
}
const startX = xScale(xAccessor(startPoint)) ?? 0;
const endX = xScale(xAccessor(endPoint)) ?? 0;
return { x: startX, width: Math.max(0, endX - startX), isActive: true };
}
@@ -0,0 +1,65 @@
"use client";
import { type MotionValue, motion } from "motion/react";
import { type RefObject, useId } from "react";
// Hover-highlight overlay: re-strokes the base path `d`, clipped to a vertical
// band whose x/width spring to track the hovered point, so only the segment
// around the dot shows brighter. The band comes from `useHighlightSegment`;
// because the bright stroke reuses the base `d`, it follows whatever curve is
// drawn (see `highlight-segment-bounds.ts` for the band-extent caveat).
export interface HighlightSegmentProps {
/** Ref to the rendered base stroke `<path>` — its `d` is re-used verbatim. */
pathRef: RefObject<SVGPathElement | null>;
/** Whether to render (caller gates on showHighlight + active + loaded). */
visible: boolean;
stroke: string;
strokeWidth: number;
/** Plot height — the clip band spans it fully. */
height: number;
/** Spring-eased left edge of the clip band (px). */
x: MotionValue<number>;
/** Spring-eased width of the clip band (px). */
width: MotionValue<number>;
}
export function HighlightSegment({
pathRef,
visible,
stroke,
strokeWidth,
height,
x,
width,
}: HighlightSegmentProps) {
const clipId = useId();
if (!(visible && pathRef.current)) {
return null;
}
return (
<>
<defs>
<clipPath id={clipId}>
<motion.rect height={height} width={width} x={x} y={0} />
</clipPath>
</defs>
<motion.path
animate={{ opacity: 1 }}
clipPath={`url(#${clipId})`}
d={pathRef.current.getAttribute("d") || ""}
exit={{ opacity: 0 }}
fill="none"
initial={{ opacity: 0 }}
stroke={stroke}
strokeLinecap="round"
strokeWidth={strokeWidth}
transition={{ duration: 0.4, ease: "easeInOut" }}
/>
</>
);
}
HighlightSegment.displayName = "HighlightSegment";
export default HighlightSegment;
@@ -0,0 +1,64 @@
export type IndicatorFadeEdges = "both" | "none" | "top" | "bottom";
export interface VerticalFadeSides {
top: boolean;
bottom: boolean;
any: boolean;
}
export function resolveVerticalFadeSides(
fade: IndicatorFadeEdges | boolean
): VerticalFadeSides {
if (fade === false || fade === "none") {
return { top: false, bottom: false, any: false };
}
if (fade === true || fade === "both") {
return { top: true, bottom: true, any: true };
}
if (fade === "top") {
return { top: true, bottom: false, any: true };
}
return { top: false, bottom: true, any: true };
}
export interface IndicatorFadeGradientStop {
offset: string;
opacity: number;
}
/** Opacity stops for the crosshair vertical gradient. */
export function indicatorFadeGradientStops(
sides: VerticalFadeSides,
fadeLengthPercent = 10
): IndicatorFadeGradientStop[] {
const fade = Math.min(40, Math.max(2, fadeLengthPercent));
const innerEnd = 100 - fade;
if (!sides.any) {
return [{ offset: "0%", opacity: 1 }];
}
if (sides.top && sides.bottom) {
return [
{ offset: "0%", opacity: 0 },
{ offset: `${fade}%`, opacity: 1 },
{ offset: "50%", opacity: 1 },
{ offset: `${innerEnd}%`, opacity: 1 },
{ offset: "100%", opacity: 0 },
];
}
if (sides.top) {
return [
{ offset: "0%", opacity: 0 },
{ offset: `${fade}%`, opacity: 1 },
{ offset: "100%", opacity: 1 },
];
}
return [
{ offset: "0%", opacity: 1 },
{ offset: `${innerEnd}%`, opacity: 1 },
{ offset: "100%", opacity: 0 },
];
}
@@ -0,0 +1,218 @@
"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;
@@ -0,0 +1,12 @@
export const LINE_LOADING_PULSE_CYCLE_S = 2.2;
/** Idle gap before the loading line pulse restarts (milliseconds). */
export const LINE_LOADING_LOOP_PAUSE_MS = 280;
/** Loading label exit on loading → ready (seconds). */
export const LOADING_LABEL_EXIT_S = 0.45;
/** Loading label drops this many pixels while exiting. */
export const LOADING_LABEL_EXIT_Y_PX = 30;
export const LINE_LOADING_PULSE_EASE = [0.85, 0, 0.15, 1] as const;
@@ -0,0 +1,657 @@
"use client";
import { localPoint } from "@visx/event";
import { ParentSize } from "@visx/responsive";
import { scaleLinear, scaleTime } from "@visx/scale";
import { bisector } from "d3-array";
import {
Children,
isValidElement,
memo,
type ReactNode,
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import {
ChartProvider,
type LineConfig,
type Margin,
type TooltipData,
} from "./chart-context";
import { hmsTimeFmt } from "./chart-formatters";
import { DEFAULT_CHART_LIFECYCLE } from "./chart-phase";
import type { LiveLineProps } from "./live-line";
import { wrapSingleYScale } from "./y-axis-scales";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface LiveLinePoint {
time: number;
value: number;
}
export interface LiveLineChartProps {
/** Streaming data — array of { time: unixSeconds, value } */
data: LiveLinePoint[];
/** Latest value (smoothly interpolated to) */
value: number;
/** Key used for the value field in context data. Default: "value" */
dataKey?: string;
/** Visible time window in seconds. Default: 30 */
window?: number;
/** Number of X-axis ticks (used to compute leading offset). Default: 5 */
numXTicks?: number;
/** Leading offset in X-tick units (0 = now at right edge). Default: 0 */
nowOffsetUnits?: number;
/** Tight Y-axis. Default: false */
exaggerate?: boolean;
/** Interpolation speed (01). Default: 0.08 */
lerpSpeed?: number;
/** Chart margins */
margin?: Partial<Margin>;
/** Freeze chart scrolling. Default: false */
paused?: boolean;
/** Child components (LiveLine, Grid, ChartTooltip, LiveXAxis, LiveYAxis, etc.) */
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const LERP_SPEED = 0.08;
const DEFAULT_MARGIN: Margin = { top: 24, right: 16, bottom: 32, left: 16 };
/** React commit interval for the live animation loop (~30fps). */
const LIVE_FRAME_COMMIT_MS = 32;
interface AnimFrame {
now: number;
yMin: number;
yMax: number;
displayValue: number;
}
function computeTargetRange(
data: LiveLinePoint[],
value: number,
exaggerate: boolean
) {
if (data.length === 0) {
return { yMin: 0, yMax: 100 };
}
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const d of data) {
if (d.value < min) {
min = d.value;
}
if (d.value > max) {
max = d.value;
}
}
if (value < min) {
min = value;
}
if (value > max) {
max = value;
}
const rawRange = max - min;
const paddingFactor = exaggerate ? 0.03 : 0.15;
const rangePad = rawRange * paddingFactor || (exaggerate ? 0.04 : 10);
return { yMin: min - rangePad, yMax: max + rangePad };
}
function nextAnimFrame(
prev: AnimFrame,
targetRange: { yMin: number; yMax: number },
targetValue: number,
speed: number,
isPaused: boolean
): AnimFrame {
const nextNow = isPaused ? prev.now : Date.now();
const nextYMin =
targetRange.yMin < prev.yMin
? targetRange.yMin
: prev.yMin + (targetRange.yMin - prev.yMin) * speed;
const nextYMax =
targetRange.yMax > prev.yMax
? targetRange.yMax
: prev.yMax + (targetRange.yMax - prev.yMax) * speed;
const nextValue =
prev.displayValue + (targetValue - prev.displayValue) * speed;
return {
now: nextNow,
yMin: nextYMin,
yMax: nextYMax,
displayValue: nextValue,
};
}
function interpolateAtTime(
points: LiveLinePoint[],
timeSec: number
): number | null {
if (points.length === 0) {
return null;
}
const firstPt = points[0] as LiveLinePoint;
const lastPt = points.at(-1) as LiveLinePoint;
if (timeSec <= firstPt.time) {
return firstPt.value;
}
if (timeSec >= lastPt.time) {
return lastPt.value;
}
let lo = 0;
let hi = points.length - 1;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
const midPt = points[mid];
if (midPt && midPt.time <= timeSec) {
lo = mid;
} else {
hi = mid;
}
}
const p1 = points[lo];
if (!p1) {
return null;
}
const p2 = points[hi];
if (!p2) {
return null;
}
const dt = p2.time - p1.time;
if (dt === 0) {
return p1.value;
}
const t = (timeSec - p1.time) / dt;
return p1.value + (p2.value - p1.value) * t;
}
const bisectTime = bisector<LiveLinePoint, number>((d) => d.time).left;
function extractLiveLineConfigs(children: ReactNode): LineConfig[] {
const configs: LineConfig[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
const childType = child.type as { displayName?: string; name?: string };
const name =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
const props = child.props as LiveLineProps | undefined;
if (
(name === "LiveLine" || (props && "dataKey" in props)) &&
props?.dataKey
) {
configs.push({
dataKey: props.dataKey,
stroke: props.stroke || "var(--chart-line-primary)",
strokeWidth: props.strokeWidth || 2,
});
}
});
return configs;
}
// ---------------------------------------------------------------------------
// Inner chart
// ---------------------------------------------------------------------------
function liveTooltipKey(
tooltip: TooltipData | null,
dataKey: string
): string | null {
if (!tooltip) {
return null;
}
return `${Math.round(tooltip.x)}:${Math.round(tooltip.yPositions[dataKey] ?? 0)}`;
}
function resolveLiveTooltip(
cursorX: number | null,
innerWidth: number,
innerHeight: number,
frame: AnimFrame,
leadingMs: number,
windowMs: number,
xTickUnitMs: number,
data: LiveLinePoint[],
dataKey: string
): TooltipData | null {
if (cursorX === null || innerWidth <= 0 || innerHeight <= 0) {
return null;
}
const domainEndMs = frame.now + leadingMs;
const xScaleNext = scaleTime({
domain: [new Date(domainEndMs - windowMs), new Date(domainEndMs)],
range: [0, innerWidth],
});
const yScaleNext = scaleLinear({
domain: [frame.yMin, frame.yMax],
range: [innerHeight, 0],
nice: true,
});
const timeMs = xScaleNext.invert(cursorX).getTime();
const timeSec = timeMs / 1000;
const visible = data.filter((p) => p.time >= (domainEndMs - windowMs) / 1000);
visible.push({ time: frame.now / 1000, value: frame.displayValue });
visible.push({
time: (frame.now + xTickUnitMs) / 1000,
value: frame.displayValue,
});
const val = interpolateAtTime(visible, timeSec);
if (val === null) {
return null;
}
return {
point: { date: new Date(timeMs), [dataKey]: val },
index: 0,
x: cursorX,
yPositions: { [dataKey]: yScaleNext(val) ?? 0 },
};
}
function shouldCommitLiveUpdates(
now: number,
lastFrameCommit: number,
tooltipKey: string | null,
lastTooltipKey: string | null
): { commitFrame: boolean; commitTooltip: boolean } {
const commitFrame = now - lastFrameCommit >= LIVE_FRAME_COMMIT_MS;
const commitTooltip = tooltipKey !== lastTooltipKey;
return { commitFrame, commitTooltip };
}
interface InnerProps {
data: LiveLinePoint[];
value: number;
dataKey: string;
windowSecs: number;
numXTicks: number;
nowOffsetUnits: number;
exaggerate: boolean;
lerpSpeed: number;
margin: Margin;
paused: boolean;
width: number;
height: number;
containerRef: React.RefObject<HTMLDivElement | null>;
children: ReactNode;
}
function LiveLineChartInner(props: InnerProps) {
const { width, height, margin } = props;
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
if (innerWidth <= 0 || innerHeight <= 0) {
return null;
}
return <LiveLineChartCore {...props} />;
}
const LiveLineChartCore = memo(function LiveLineChartCore({
data,
value,
dataKey,
windowSecs,
numXTicks,
nowOffsetUnits,
exaggerate,
lerpSpeed,
margin,
paused,
width,
height,
containerRef,
children,
}: InnerProps) {
const windowMs = windowSecs * 1000;
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// ---- Animation state ----
const animRef = useRef<AnimFrame>({
now: Date.now(),
yMin: 0,
yMax: 100,
displayValue: value,
});
const [frame, setFrame] = useState<AnimFrame>({
now: Date.now(),
yMin: 0,
yMax: 100,
displayValue: value,
});
const pausedRef = useRef(paused);
const dataRef = useRef(data);
const dataKeyRef = useRef(dataKey);
dataRef.current = data;
dataKeyRef.current = dataKey;
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
const targetRange = useMemo(
() => computeTargetRange(data, value, exaggerate),
[data, value, exaggerate]
);
const lines = useMemo(() => extractLiveLineConfigs(children), [children]);
// Leading offset (used in rAF for tooltip)
const xTickUnitMs = windowMs / (numXTicks - 1);
const leadingMs = nowOffsetUnits * xTickUnitMs;
// ---- rAF loop: update frame and tooltip in one place to avoid effect→setState loops ----
const cursorXRef = useRef<number | null>(null);
const [tooltipData, setTooltipData] = useState<TooltipData | null>(null);
const lastFrameCommitRef = useRef(0);
const lastTooltipKeyRef = useRef<string | null>(null);
useEffect(() => {
let raf: number;
const tick = () => {
const next = nextAnimFrame(
animRef.current,
targetRange,
value,
lerpSpeed,
pausedRef.current
);
animRef.current = next;
const nextTooltip = resolveLiveTooltip(
cursorXRef.current,
innerWidth,
innerHeight,
next,
leadingMs,
windowMs,
xTickUnitMs,
dataRef.current,
dataKeyRef.current
);
const now = performance.now();
const tooltipKey = liveTooltipKey(nextTooltip, dataKeyRef.current);
const { commitFrame, commitTooltip } = shouldCommitLiveUpdates(
now,
lastFrameCommitRef.current,
tooltipKey,
lastTooltipKeyRef.current
);
if (!(commitFrame || commitTooltip)) {
raf = requestAnimationFrame(tick);
return;
}
if (commitFrame) {
lastFrameCommitRef.current = now;
}
if (commitTooltip) {
lastTooltipKeyRef.current = tooltipKey;
}
startTransition(() => {
if (commitFrame) {
setFrame(next);
}
if (commitTooltip) {
setTooltipData(nextTooltip);
}
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [
targetRange,
value,
lerpSpeed,
leadingMs,
windowMs,
xTickUnitMs,
innerWidth,
innerHeight,
]);
const domainEndMs = frame.now + leadingMs;
// ---- Scales ----
const xScale = useMemo(
() =>
scaleTime({
domain: [new Date(domainEndMs - windowMs), new Date(domainEndMs)],
range: [0, innerWidth],
}),
[domainEndMs, windowMs, innerWidth]
);
const yScale = useMemo(
() =>
scaleLinear({
domain: [frame.yMin, frame.yMax],
range: [innerHeight, 0],
nice: true,
}),
[frame.yMin, frame.yMax, innerHeight]
);
// ---- Build context-compatible data ----
// Convert LiveLinePoint[] to Record<string, unknown>[] with 2 virtual points:
// 1. At "now" — the live tip where the dot sits
// 2. At "now + 1 unit" — a queued point that the line fades into
const contextData = useMemo(() => {
const windowStart = domainEndMs - windowMs;
let startIdx = bisectTime(data, windowStart / 1000, 0);
if (startIdx > 0) {
startIdx--;
}
const sliced = data.slice(startIdx);
const records: Record<string, unknown>[] = sliced.map((p) => ({
date: new Date(p.time * 1000),
[dataKey]: p.value,
}));
// Virtual point 1: the "now" position (where the live dot sits)
records.push({
date: new Date(frame.now),
[dataKey]: frame.displayValue,
});
// Virtual point 2: queued ahead (the line extends and fades into this)
records.push({
date: new Date(frame.now + xTickUnitMs),
[dataKey]: frame.displayValue,
});
return records;
}, [
data,
frame.now,
frame.displayValue,
domainEndMs,
windowMs,
dataKey,
xTickUnitMs,
]);
// ---- X accessor ----
const xAccessor = useCallback(
(d: Record<string, unknown>): Date =>
d.date instanceof Date ? d.date : new Date(d.date as number),
[]
);
const handleMouseMove = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const coords = localPoint(event);
if (!coords) {
return;
}
const x = coords.x - margin.left;
cursorXRef.current = x >= 0 && x <= innerWidth ? x : null;
},
[margin.left, innerWidth]
);
const handleMouseLeave = useCallback(() => {
cursorXRef.current = null;
lastTooltipKeyRef.current = null;
setTooltipData(null);
}, []);
// Date labels (for ChartTooltip's DateTicker — not used in live but needed for context)
const dateLabels = useMemo(
() => contextData.map((d) => hmsTimeFmt.format(xAccessor(d))),
[contextData, xAccessor]
);
const columnWidth = useMemo(() => {
if (contextData.length < 2) {
return 0;
}
return innerWidth / (contextData.length - 1);
}, [innerWidth, contextData.length]);
const contextValue = useMemo(
() => ({
...DEFAULT_CHART_LIFECYCLE,
data: contextData,
renderData: contextData,
xScale,
yScale,
yScales: wrapSingleYScale(yScale),
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
isLoaded: true,
animationDuration: 0,
xAccessor,
dateLabels,
}),
[
contextData,
xScale,
yScale,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
containerRef,
lines,
xAccessor,
dateLabels,
]
);
return (
<ChartProvider value={contextValue}>
<svg
aria-hidden="true"
className="overflow-visible"
height={height}
width={width}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: SVG group for mouse tracking */}
<g
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
style={{ cursor: "crosshair" }}
transform={`translate(${margin.left},${margin.top})`}
>
<rect
fill="transparent"
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
{children}
</g>
</svg>
</ChartProvider>
);
});
// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------
export function LiveLineChart({
data,
value,
dataKey = "value",
window: windowSecs = 30,
numXTicks = 5,
nowOffsetUnits = 0,
exaggerate = false,
lerpSpeed = LERP_SPEED,
margin: marginProp,
paused = false,
children,
className,
style,
}: LiveLineChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div
className={cn("relative w-full", className)}
ref={containerRef}
style={{ height: 300, touchAction: "none", ...style }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<LiveLineChartInner
containerRef={containerRef}
data={data}
dataKey={dataKey}
exaggerate={exaggerate}
height={height}
lerpSpeed={lerpSpeed}
margin={margin}
nowOffsetUnits={nowOffsetUnits}
numXTicks={numXTicks}
paused={paused}
value={value}
width={width}
windowSecs={windowSecs}
>
{children}
</LiveLineChartInner>
)}
</ParentSize>
</div>
);
}
export default LiveLineChart;
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { curveMonotoneX } from "@visx/curve";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
import { AreaClosed, LinePath } from "@visx/shape";
import { motion } from "motion/react";
import { useCallback, useId, useMemo } from "react";
import { chartCssVars, useChart } from "./chart-context";
export type Momentum = "up" | "down" | "flat";
export interface MomentumColors {
up: string;
down: string;
flat: string;
}
export function detectMomentum(
data: Record<string, unknown>[],
dataKey: string,
lookback = 20
): Momentum {
if (data.length < 5) {
return "flat";
}
const start = Math.max(0, data.length - lookback);
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (let i = start; i < data.length; i++) {
const v = data[i]?.[dataKey];
if (typeof v === "number") {
if (v < min) {
min = v;
}
if (v > max) {
max = v;
}
}
}
const range = max - min;
if (range === 0) {
return "flat";
}
const tailStart = Math.max(start, data.length - 5);
const first = (data[tailStart]?.[dataKey] as number) ?? 0;
const last = (data.at(-1)?.[dataKey] as number) ?? 0;
const delta = last - first;
const threshold = range * 0.12;
if (delta > threshold) {
return "up";
}
if (delta < -threshold) {
return "down";
}
return "flat";
}
export interface LiveLineProps {
/** Key in data to use for y values */
dataKey: string;
/** Stroke color. Default: var(--chart-line-primary) */
stroke?: string;
/** Stroke width. Default: 2 */
strokeWidth?: number;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** Show gradient fill under the curve. Default: true */
fill?: boolean;
/** Show pulsing live dot at the right edge. Default: true */
pulse?: boolean;
/** Radius of the live dot. Default: 4 */
dotSize?: number;
/** Show value badge pill at the live tip. Default: true */
badge?: boolean;
/** Value label formatter for the badge */
formatValue?: (v: number) => string;
/**
* When set, the line/fill color changes based on momentum direction.
* Overrides `stroke` for the line and fill (dot always uses momentum colors).
*/
momentumColors?: MomentumColors;
}
LiveLine.displayName = "LiveLine";
export function LiveLine({
dataKey,
stroke = chartCssVars.linePrimary,
strokeWidth = 2,
curve = curveMonotoneX,
fill = true,
pulse = true,
dotSize = 4,
badge = true,
formatValue = (v: number) => v.toFixed(2),
momentumColors,
}: LiveLineProps) {
const {
data,
xScale,
yScale,
innerWidth,
innerHeight,
xAccessor,
lines,
tooltipData,
} = useChart();
const isScrubbing = tooltipData !== null;
const uid = useId();
const gradientId = `live-line-grad-${uid}`;
const areaGradientId = `live-area-grad-${uid}`;
const fadeId = `live-fade-${uid}`;
const fadeMaskId = `live-fade-mask-${uid}`;
const getX = useCallback(
(d: Record<string, unknown>) => xScale(xAccessor(d)) ?? 0,
[xScale, xAccessor]
);
const getY = useCallback(
(d: Record<string, unknown>) => {
const v = d[dataKey];
return typeof v === "number" ? (yScale(v) ?? 0) : 0;
},
[dataKey, yScale]
);
// The second-to-last point is the "now" position (live tip).
// The last point is the queued future point for the fade-out zone.
const nowPoint = data.length >= 2 ? data.at(-2) : data.at(-1);
const liveValue =
nowPoint && typeof nowPoint[dataKey] === "number"
? (nowPoint[dataKey] as number)
: 0;
const liveDotX = nowPoint ? (xScale(xAccessor(nowPoint)) ?? 0) : innerWidth;
const liveDotY = yScale(liveValue) ?? 0;
const momentum = useMemo(
() => detectMomentum(data, dataKey),
[data, dataKey]
);
const defaultMomentumColors: MomentumColors = {
up: "var(--chart-1)",
down: "var(--chart-5)",
flat: stroke,
};
const dotMomentumColors = momentumColors ?? defaultMomentumColors;
const dotColor = dotMomentumColors[momentum];
// Find the line config for this dataKey to get the resolved stroke
const lineConfig = lines.find((l) => l.dataKey === dataKey);
const baseStroke = lineConfig?.stroke ?? stroke;
const resolvedStroke = momentumColors ? momentumColors[momentum] : baseStroke;
return (
<>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={resolvedStroke} stopOpacity={1} />
<stop offset="100%" stopColor={resolvedStroke} stopOpacity={0.6} />
</linearGradient>
<linearGradient id={areaGradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={resolvedStroke} stopOpacity={0.1} />
<stop offset="100%" stopColor={resolvedStroke} stopOpacity={0} />
</linearGradient>
<linearGradient id={fadeId} x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="white" stopOpacity={0} />
<stop offset="4%" stopColor="white" stopOpacity={1} />
{liveDotX < innerWidth - 1 ? (
<>
<stop
offset={`${(liveDotX / innerWidth) * 100}%`}
stopColor="white"
stopOpacity={1}
/>
<stop offset="100%" stopColor="white" stopOpacity={0} />
</>
) : (
<stop offset="100%" stopColor="white" stopOpacity={1} />
)}
</linearGradient>
<mask id={fadeMaskId}>
<rect
fill={`url(#${fadeId})`}
height={innerHeight + 40}
width={innerWidth}
x={0}
y={-20}
/>
</mask>
</defs>
{/* Area fill */}
{fill && data.length > 1 && (
<g mask={`url(#${fadeMaskId})`}>
<AreaClosed
curve={curve}
data={data}
fill={`url(#${areaGradientId})`}
strokeWidth={0}
x={getX}
y={getY}
yScale={yScale}
/>
</g>
)}
{/* Line */}
{data.length > 1 && (
<g mask={`url(#${fadeMaskId})`}>
<LinePath
curve={curve}
data={data}
stroke={`url(#${gradientId})`}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={strokeWidth}
x={getX}
y={getY}
/>
</g>
)}
{/* Dashed horizontal line at current value */}
<line
opacity={0.25}
stroke={resolvedStroke}
strokeDasharray="4,4"
strokeWidth={1}
x1={0}
x2={innerWidth}
y1={liveDotY}
y2={liveDotY}
/>
{/* Live indicator (dot + badge) — dims when crosshair is active */}
<motion.g
animate={{ opacity: isScrubbing ? 0.25 : 1 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{/* Pulsing dot */}
<g>
{pulse && (
<circle
cx={liveDotX}
cy={liveDotY}
fill="none"
opacity={0.4}
r={dotSize * 2}
stroke={dotColor}
strokeWidth={1.5}
>
<animate
attributeName="r"
dur="1.5s"
from={String(dotSize)}
repeatCount="indefinite"
to={String(dotSize * 3.5)}
/>
<animate
attributeName="opacity"
dur="1.5s"
from="0.5"
repeatCount="indefinite"
to="0"
/>
</circle>
)}
<circle
cx={liveDotX}
cy={liveDotY}
fill={dotColor}
opacity={0.1}
r={dotSize + 2}
/>
<circle
cx={liveDotX}
cy={liveDotY}
fill={dotColor}
r={dotSize}
stroke={chartCssVars.background}
strokeWidth={2}
/>
</g>
{/* Badge — use popover vars so text is never white-on-white */}
{badge && (
<g transform={`translate(${liveDotX + 12},${liveDotY})`}>
<rect
fill="var(--popover)"
height={24}
opacity={0.95}
rx={6}
width={formatValue(liveValue).length * 7.5 + 16}
x={0}
y={-12}
/>
<text
fill="var(--popover-foreground)"
fontFamily="SF Mono, Menlo, Monaco, monospace"
fontSize={11}
fontWeight={500}
x={8}
y={4}
>
{formatValue(liveValue)}
</text>
</g>
)}
</motion.g>
</>
);
}
export default LiveLine;
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { motion, useSpring } from "motion/react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useChart, useChartStable } from "./chart-context";
import { hmsTimeFmt } from "./chart-formatters";
const TICKER_HALF_WIDTH = 50;
const FADE_BUFFER = 20;
const crosshairSpringConfig = { stiffness: 300, damping: 30 };
function labelFadeOpacity(
labelX: number,
crosshairX: number | null,
isHovering: boolean
): number {
if (!isHovering || crosshairX === null) {
return 1;
}
const distance = Math.abs(labelX - crosshairX);
if (distance < TICKER_HALF_WIDTH) {
return 0;
}
if (distance < TICKER_HALF_WIDTH + FADE_BUFFER) {
return (distance - TICKER_HALF_WIDTH) / FADE_BUFFER;
}
return 1;
}
export interface LiveXAxisProps {
/** Number of time labels. Default: 5 */
numTicks?: number;
/** Time formatter. Default: HH:MM:SS */
formatTime?: (t: number) => string;
}
const defaultFormatTime = (t: number) => hmsTimeFmt.format(new Date(t));
export function LiveXAxis(props: LiveXAxisProps) {
const { containerRef } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return <LiveXAxisInner {...props} container={container} />;
}
const LiveXAxisInner = memo(function LiveXAxisInner({
numTicks = 5,
formatTime = defaultFormatTime,
container,
}: LiveXAxisProps & { container: HTMLDivElement }) {
const { xScale, margin, tooltipData } = useChart();
const domain = xScale.domain();
const startMs = domain[0]?.getTime() ?? 0;
const endMs = domain[1]?.getTime() ?? 0;
const labels = useMemo(() => {
const step = (endMs - startMs) / (numTicks - 1);
return Array.from({ length: numTicks }, (_, i) => {
const t = startMs + i * step;
const x = (xScale(new Date(t)) ?? 0) + margin.left;
return { x, label: formatTime(t), stableKey: i };
});
}, [startMs, endMs, numTicks, xScale, margin.left, formatTime]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
// Time pill label
const pillLabel = useMemo(() => {
if (!tooltipData) {
return null;
}
const timeMs = xScale.invert(tooltipData.x).getTime();
return formatTime(timeMs);
}, [tooltipData, xScale, formatTime]);
// Spring-animated pill position — matches TooltipIndicator's spring config
// so the pill and crosshair line move in lockstep
const pillX = tooltipData ? tooltipData.x + margin.left : 0;
const animatedPillX = useSpring(pillX, crosshairSpringConfig);
const springRef = useRef(animatedPillX);
springRef.current = animatedPillX;
useEffect(() => {
springRef.current.set(pillX);
}, [pillX]);
return createPortal(
<div className="pointer-events-none absolute inset-0">
{/* Time labels */}
{labels.map((l) => (
<div
className="absolute"
key={l.stableKey}
style={{
left: l.x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
}}
>
<motion.span
animate={{
opacity: labelFadeOpacity(l.x, crosshairX, isHovering),
}}
className="whitespace-nowrap text-chart-label text-xs"
transition={{ duration: 0.15, ease: "easeOut" }}
>
{l.label}
</motion.span>
</div>
))}
{/* Time pill at crosshair — spring-animated to match crosshair line */}
{isHovering && pillLabel && (
<motion.div
className="absolute z-50"
style={{
left: animatedPillX,
x: "-50%",
bottom: 4,
}}
>
<div className="overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
<span className="whitespace-nowrap font-medium text-sm">
{pillLabel}
</span>
</div>
</motion.div>
)}
</div>,
container
);
});
LiveXAxis.displayName = "LiveXAxis";
export default LiveXAxis;
+229
View File
@@ -0,0 +1,229 @@
"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;
@@ -0,0 +1,58 @@
import type { Transition } from "motion/react";
import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation";
export function transitionWithDelay(
transition: Transition | undefined,
delaySeconds: number,
fallback: Transition = DEFAULT_CHART_ENTER_TRANSITION
): Transition {
const base = transition ?? fallback;
return { ...base, delay: delaySeconds };
}
export interface SpringOptions {
stiffness: number;
damping: number;
mass?: number;
}
export function springOptionsFromTransition(
transition?: Transition,
fallback: SpringOptions = { stiffness: 60, damping: 20 }
): SpringOptions {
if (!transition) {
return fallback;
}
if (transition.type === "spring") {
const bounce =
typeof transition.bounce === "number" ? transition.bounce : undefined;
const baseStiffness =
typeof transition.stiffness === "number"
? transition.stiffness
: fallback.stiffness;
const baseDamping =
typeof transition.damping === "number"
? transition.damping
: fallback.damping;
return {
stiffness:
bounce == null
? baseStiffness
: Math.min(400, Math.max(80, baseStiffness * (1 + bounce * 0.35))),
damping:
bounce == null
? baseDamping
: Math.max(8, baseDamping * (1 - bounce * 0.25)),
mass:
typeof transition.mass === "number" ? transition.mass : fallback.mass,
};
}
const duration =
"duration" in transition && typeof transition.duration === "number"
? transition.duration
: 0.8;
return {
stiffness: Math.min(500, Math.max(40, 280 / duration)),
damping: Math.min(40, Math.max(12, 18 + duration * 4)),
};
}
@@ -0,0 +1,88 @@
import { type RefObject, useEffect, useState } from "react";
export function findPathLengthAtX(
path: SVGPathElement | null,
pathLength: number,
targetX: number
): number {
if (!path || pathLength === 0) {
return 0;
}
let low = 0;
let high = pathLength;
const tolerance = 0.5;
while (high - low > tolerance) {
const mid = (low + high) / 2;
const point = path.getPointAtLength(mid);
if (point.x < targetX) {
low = mid;
} else {
high = mid;
}
}
return (low + high) / 2;
}
interface PathStrokeMetrics {
pathD: string | null;
pathLength: number;
}
const EMPTY_METRICS: PathStrokeMetrics = { pathD: null, pathLength: 0 };
/**
* Caller passes the references that drive the rendered path (renderData,
* innerWidth, etc.) as `deps`. A stringified summary like
* `${renderData.length}:${innerWidth}` is *not* safe here — same-length
* in-place mutations of `renderData` keep the summary identical, so the
* effect would never re-fire and `pathD`/`pathLength` would stay frozen on
* the previous geometry (the area fill repaints from `renderData` directly
* and would diverge from the stroke).
*/
export function usePathStrokeMetrics(
pathRef: RefObject<SVGPathElement | null>,
deps: readonly unknown[]
): PathStrokeMetrics {
const [metrics, setMetrics] = useState<PathStrokeMetrics>(EMPTY_METRICS);
useEffect(() => {
const path = pathRef.current;
if (!path) {
return;
}
const d = path.getAttribute("d");
const len = d ? path.getTotalLength() : 0;
setMetrics((prev) =>
prev.pathD === d && prev.pathLength === len
? prev
: { pathD: d, pathLength: len }
);
}, deps);
return metrics;
}
export function resolveDashTailBounds(
dashFromIndex: number | undefined,
dataLength: number
): boolean {
return (
dashFromIndex != null &&
dashFromIndex >= 0 &&
dashFromIndex < dataLength - 1
);
}
export function resolveDashStartX(
data: Record<string, unknown>[],
dashFromIndex: number,
xScale: (value: Date | number) => number | undefined,
xAccessor: (datum: Record<string, unknown>) => Date | number
): number {
const dashFromPoint = data[dashFromIndex];
if (!dashFromPoint) {
return 0;
}
return xScale(xAccessor(dashFromPoint)) ?? 0;
}
@@ -0,0 +1,49 @@
"use client";
import { curveMonotoneX } from "@visx/curve";
import { AreaClosed } from "@visx/shape";
import { useChartStable } from "./chart-context";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
export interface PatternAreaProps {
/** Key in data to use for y values */
dataKey: string;
/** Fill color or pattern URL (e.g. `url(#pattern-id)`) */
fill: string;
/** Curve function. Default: curveMonotoneX */
curve?: CurveFactory;
/** @deprecated Pattern fill is not clip-revealed; only the stroke `Area` animates. */
animate?: boolean;
}
/**
* Filled area using an SVG pattern (`url(#id)`).
* Pair with `PatternLines` in `AreaChart` children and an `Area` with `fillOpacity={0}` for the stroke line.
*/
export function PatternArea({
dataKey,
fill,
curve = curveMonotoneX,
}: PatternAreaProps) {
const { renderData, xScale, yScale, xAccessor } = useChartStable();
return (
<AreaClosed
curve={curve}
data={renderData}
fill={fill}
x={(d) => xScale(xAccessor(d)) ?? 0}
y={(d) => {
const v = d[dataKey];
return typeof v === "number" ? (yScale(v) ?? 0) : 0;
}}
yScale={yScale}
/>
);
}
PatternArea.displayName = "PatternArea";
export default PatternArea;
@@ -0,0 +1,61 @@
export function computeSeriesBarWidth(input: {
innerWidth: number;
dataLength: number;
columnWidth: number;
seriesCount: number;
composedBarSize?: number;
composedMaxBarSize?: number;
composedBarGap?: number;
stacked?: boolean;
}): number {
const {
innerWidth,
dataLength,
columnWidth,
seriesCount,
composedBarSize,
composedMaxBarSize,
composedBarGap = 4,
stacked = false,
} = input;
const gap = composedBarGap;
const groupCount = stacked ? 1 : Math.max(1, seriesCount);
let slot = columnWidth;
if (slot <= 0) {
slot = dataLength < 2 ? innerWidth : innerWidth / (dataLength - 1);
}
let width =
composedBarSize ??
Math.min(slot * 0.88, composedMaxBarSize ?? Number.POSITIVE_INFINITY);
if (composedMaxBarSize != null) {
width = Math.min(width, composedMaxBarSize);
}
if (groupCount > 1) {
const maxGroup = slot * 0.92;
const needed = groupCount * width + (groupCount - 1) * gap;
if (needed > maxGroup && maxGroup > 0) {
width = Math.max(4, (maxGroup - (groupCount - 1) * gap) / groupCount);
}
}
return Math.max(2, width);
}
/** Half-width of the bar group at each x — used to pad reveal clips. */
export function computeSeriesBarRevealClipPadding(input: {
barWidth: number;
seriesCount: number;
gap?: number;
stacked?: boolean;
}): number {
const { barWidth, seriesCount, gap = 4, stacked = false } = input;
if (stacked || seriesCount <= 1) {
return Math.ceil(barWidth / 2);
}
const groupWidth = seriesCount * barWidth + (seriesCount - 1) * gap;
return Math.ceil(groupWidth / 2);
}
@@ -0,0 +1,82 @@
"use client";
import { memo, useMemo } from "react";
import { DashTailStroke } from "./dash-tail-stroke";
import { resolveDashStartX, resolveDashTailBounds } from "./path-stroke-utils";
interface SeriesDashTailOverlayProps {
dashFromIndex?: number;
dashArray: string;
data: Record<string, unknown>[];
pathD: string | null;
pathLength: number;
innerWidth: number;
innerHeight: number;
stroke: string;
strokeWidth: number;
xScale: (value: Date | number) => number | undefined;
xAccessor: (datum: Record<string, unknown>) => Date | number;
}
function SeriesDashTailOverlayImpl({
dashFromIndex,
dashArray,
data,
pathD,
pathLength,
innerWidth,
innerHeight,
stroke,
strokeWidth,
xScale,
xAccessor,
}: SeriesDashTailOverlayProps) {
const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length);
const dashStartX = useMemo(() => {
if (!hasDashTail || dashFromIndex == null) {
return 0;
}
return resolveDashStartX(data, dashFromIndex, xScale, xAccessor);
}, [hasDashTail, dashFromIndex, data, xScale, xAccessor]);
// Linear (index-based) approximation of the path length at `dashFromIndex`.
// The accurate version (`findPathLengthAtX` binary search via
// `getPointAtLength`) is exact but cost ~40 ms per series on a 365-point
// bezier — for charts with ~10 series that synchronously blocks the main
// thread for ~400 ms on the post-measurement re-render, swallowing the first
// second of the entrance animation.
//
// For evenly-spaced time-series data — the standard case — this is exact at
// flat regions of the curve and only differs by a pixel or two where the
// curve has steep y-variation, which is imperceptible at the dash boundary.
const dashStartLength = useMemo(() => {
if (!hasDashTail || dashFromIndex == null || pathLength <= 0) {
return 0;
}
return (dashFromIndex / Math.max(1, data.length - 1)) * pathLength;
}, [hasDashTail, dashFromIndex, data.length, pathLength]);
if (!hasDashTail || dashFromIndex == null || pathLength <= 0) {
return null;
}
return (
<DashTailStroke
dashArray={dashArray}
dashStartLength={dashStartLength}
dashStartX={dashStartX}
innerHeight={innerHeight}
innerWidth={innerWidth}
pathD={pathD}
pathLength={pathLength}
stroke={stroke}
strokeWidth={strokeWidth}
/>
);
}
// All props originate from the chart's stable context slice (data, xScale,
// xAccessor, …) or are mount-stable strings (gradient `url(#…)` ids). Shallow
// compare lets us skip the path-length binary search on every cursor move.
export const SeriesDashTailOverlay = memo(SeriesDashTailOverlayImpl);
@@ -0,0 +1,49 @@
"use client";
import type { RefObject } from "react";
import { useChartStable } from "./chart-context";
import { HighlightSegment } from "./highlight-segment";
import { useHighlightSegment } from "./use-highlight-segment";
interface SeriesHighlightLayerProps {
/** Caller already gated `showHighlight && showLine`; this just routes through. */
enabled: boolean;
height: number;
pathRef: RefObject<SVGPathElement | null>;
stroke: string;
strokeWidth: number;
}
/**
* Self-contained hover-highlight band over a series stroke.
*
* Owns the `useHighlightSegment` subscription (which reads both stable + hover
* context) so the parent <Area> / <Line> can stay on the stable slice. This
* component still re-renders on hover — that's the price of driving the
* highlight band — but it's a tiny leaf so the cost is bounded to itself.
*/
export function SeriesHighlightLayer({
enabled,
height,
pathRef,
stroke,
strokeWidth,
}: SeriesHighlightLayerProps) {
const { isLoaded } = useChartStable();
const { xSpring, widthSpring, isActive } = useHighlightSegment({ enabled });
return (
<HighlightSegment
height={height}
pathRef={pathRef}
stroke={stroke}
strokeWidth={strokeWidth}
visible={enabled && isActive && isLoaded}
width={widthSpring}
x={xSpring}
/>
);
}
SeriesHighlightLayer.displayName = "SeriesHighlightLayer";
export default SeriesHighlightLayer;
@@ -0,0 +1,60 @@
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { useChartHover } from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
interface SeriesHoverDimProps {
/** Skip the dim entirely. */
enabled?: boolean;
/** Opacity to fade to while the chart is being hovered. */
dimOpacity?: number;
/** Tween duration in seconds. */
durationSec?: number;
/** Series index for multi-series legend hover dimming. */
seriesIndex?: number;
/** Stable chart visuals — area fill, stroke line, dashed tail, etc. */
children: ReactNode;
}
/**
* Wraps stable series visuals with a hover-driven opacity animation.
*
* The wrapper subscribes to chart hover state internally so the parent (Area /
* Line) can stay on the stable context slice. Children come in as a React prop:
* because the parent is not re-rendering on hover, the children element
* reference stays identical and React skips re-rendering them when this
* wrapper re-renders. That keeps expensive subtrees (`SeriesDashTailOverlay`
* and its `getPointAtLength` binary search) quiescent on cursor motion.
*/
export function SeriesHoverDim({
enabled = true,
dimOpacity = 0.5,
durationSec = 0.4,
seriesIndex,
children,
}: SeriesHoverDimProps) {
const { tooltipData, selection } = useChartHover();
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const isChartHovering = tooltipData !== null || selection?.active === true;
const isLegendDimmed =
legendHoveredIndex !== null &&
seriesIndex !== undefined &&
legendHoveredIndex !== seriesIndex;
const opacity =
enabled && (isChartHovering || isLegendDimmed) ? dimOpacity : 1;
return (
<motion.g
animate={{ opacity }}
initial={{ opacity: 1 }}
transition={{ duration: durationSec, ease: "easeInOut" }}
>
{children}
</motion.g>
);
}
SeriesHoverDim.displayName = "SeriesHoverDim";
export default SeriesHoverDim;
@@ -0,0 +1,294 @@
"use client";
import { type ReactNode, useCallback, useMemo } from "react";
import { clipRevealTransition } from "./animation";
import {
defaultScatterColors,
useChartHover,
useChartStable,
useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import {
getSeriesMarkerVisualExtent,
SeriesPointMarker,
type SeriesPointMarkerStyle,
StaticSeriesPointMarker,
} from "./series-point-marker";
export interface SeriesMarkersProps extends SeriesPointMarkerStyle {
dataKey: string;
/** Marker fill color. Defaults to series stroke or chart palette color. */
fill?: string;
/** Whether to animate markers with clip reveal. Default: true */
animate?: boolean;
}
interface PointAt {
index: number;
cx: number;
cy: number;
revealDelay: number;
}
interface MarkerStyle {
fill: string;
stroke: string;
strokeWidth: number;
ringGap: number;
outlineWidth: number;
outlineColor?: string;
radius: number;
}
export function SeriesMarkers({
dataKey,
fill,
stroke,
strokeWidth = 2,
ringGap = 2,
outlineWidth = 0,
outlineColor,
radius = 5,
animate = true,
fadeOnHover = true,
inactiveOpacity = 0.5,
inactiveBlur = 2,
enterBlur = 2,
showActiveHighlight = true,
}: SeriesMarkersProps) {
// Stable slice only. Hover-driven dim + active-highlight live in the inner
// <SeriesMarkersDimWrapper> / <SeriesMarkersActiveHighlight> components, so
// mouse motion does not re-render the full point grid.
const {
data,
xScale,
innerWidth,
enterTransition,
animationDuration,
revealEpoch,
isLoaded,
xAccessor,
lines,
} = useChartStable();
const seriesIndex = useMemo(() => {
const index = lines.findIndex((line) => line.dataKey === dataKey);
return index >= 0 ? index : 0;
}, [lines, dataKey]);
const seriesConfig = lines[seriesIndex];
const yScale = useYScale(seriesConfig?.yAxisId);
const seriesColor =
defaultScatterColors[seriesIndex % defaultScatterColors.length] ??
defaultScatterColors[0];
const resolvedFill = fill ?? seriesConfig?.stroke ?? seriesColor;
const resolvedStroke = stroke ?? resolvedFill;
const visualExtent = useMemo(
() =>
getSeriesMarkerVisualExtent({
radius,
strokeWidth,
ringGap,
outlineWidth,
showActiveHighlight,
}),
[radius, strokeWidth, ringGap, outlineWidth, showActiveHighlight]
);
const revealDurationSec =
clipRevealTransition(enterTransition).duration ?? animationDuration / 1000;
const enterDuration = 0.5;
const isRevealing = animate && !isLoaded;
const getY = useCallback(
(d: Record<string, unknown>) => {
const value = d[dataKey];
return typeof value === "number" ? (yScale(value) ?? 0) : null;
},
[dataKey, yScale]
);
const points = useMemo<PointAt[]>(
() =>
data.flatMap((d, index) => {
const cy = getY(d);
if (cy === null) {
return [];
}
const cx = xScale(xAccessor(d)) ?? 0;
const leadingEdge = Math.max(0, cx - visualExtent);
const revealDelay =
innerWidth > 0 && isRevealing
? (leadingEdge / innerWidth) * revealDurationSec
: 0;
return [{ index, cx, cy, revealDelay }];
}),
[
data,
getY,
xScale,
xAccessor,
innerWidth,
isRevealing,
revealDurationSec,
visualExtent,
]
);
// Memo so the inner <SeriesMarkersActiveHighlight> sees a stable prop and
// can be cheaply re-rendered on hover without re-creating the spread.
const markerStyle = useMemo<MarkerStyle>(
() => ({
fill: resolvedFill,
stroke: resolvedStroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
}),
[
resolvedFill,
resolvedStroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
]
);
if (isRevealing) {
return (
<g>
{points.map((point) => (
<SeriesPointMarker
cx={point.cx}
cy={point.cy}
dataKey={dataKey}
enterBlur={enterBlur}
enterDuration={enterDuration}
index={point.index}
key={`${dataKey}-${point.index}`}
revealDelay={point.revealDelay}
revealEpoch={revealEpoch ?? 0}
{...markerStyle}
/>
))}
</g>
);
}
// Stable base layer — its children come from the parent and stay
// referentially identical when the dim wrapper re-renders for hover.
const baseMarkers = points.map((point) => (
<StaticSeriesPointMarker
cx={point.cx}
cy={point.cy}
key={`${dataKey}-${point.index}`}
{...markerStyle}
/>
));
const activeScale = showActiveHighlight ? 1.35 : 1;
return (
<g>
<SeriesMarkersDimWrapper
enabled={fadeOnHover}
inactiveBlur={inactiveBlur}
inactiveOpacity={inactiveOpacity}
seriesIndex={seriesIndex}
>
{baseMarkers}
</SeriesMarkersDimWrapper>
<SeriesMarkersActiveHighlight
activeScale={activeScale}
enabled={fadeOnHover}
markerStyle={markerStyle}
points={points}
/>
</g>
);
}
SeriesMarkers.displayName = "SeriesMarkers";
interface SeriesMarkersDimWrapperProps {
enabled: boolean;
inactiveOpacity: number;
inactiveBlur: number;
seriesIndex: number;
children: ReactNode;
}
/**
* Wraps the stable point grid with hover-driven opacity + blur. Subscribes to
* hover internally so the grid (passed as `children`) keeps a stable reference
* and React skips reconciling it when this wrapper re-renders.
*/
function SeriesMarkersDimWrapper({
enabled,
inactiveOpacity,
inactiveBlur,
seriesIndex,
children,
}: SeriesMarkersDimWrapperProps) {
const { tooltipData } = useChartHover();
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const isLegendDimmed =
legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;
const dimBase = enabled && (tooltipData !== null || isLegendDimmed);
return (
<g
opacity={dimBase ? inactiveOpacity : 1}
style={{
transition: "opacity 0.15s ease-in-out, filter 0.15s ease-in-out",
filter:
dimBase && inactiveBlur > 0 ? `blur(${inactiveBlur}px)` : "none",
}}
>
{children}
</g>
);
}
interface SeriesMarkersActiveHighlightProps {
enabled: boolean;
points: PointAt[];
markerStyle: MarkerStyle;
activeScale: number;
}
/**
* Renders the scaled "active" marker on top of the base grid. Subscribes to
* hover internally; the parent doesn't re-render on cursor motion.
*/
function SeriesMarkersActiveHighlight({
enabled,
points,
markerStyle,
activeScale,
}: SeriesMarkersActiveHighlightProps) {
const { tooltipData } = useChartHover();
if (!enabled || tooltipData === null) {
return null;
}
const activePoint = points.find((point) => point.index === tooltipData.index);
if (!activePoint) {
return null;
}
return (
<StaticSeriesPointMarker
cx={activePoint.cx}
cy={activePoint.cy}
scale={activeScale}
{...markerStyle}
/>
);
}
export default SeriesMarkers;
@@ -0,0 +1,209 @@
"use client";
import type { Variants } from "motion/react";
import { motion } from "motion/react";
import { memo } from "react";
import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation";
export interface SeriesPointMarkerStyle {
/** Fill color for the inner circle */
fill?: string;
/** Outer ring stroke color. Default: same as `fill` */
stroke?: string;
/** Outer ring stroke width in px. Default: 2. Set to 0 to disable. */
strokeWidth?: number;
/** Gap between the inner fill and outer ring in px. Default: 2 */
ringGap?: number;
/** Optional outer outline beyond the ring. Default: 0 */
outlineWidth?: number;
/** Outer outline color. Default: same as `stroke` */
outlineColor?: string;
/** Point radius in px. Default: 5 */
radius?: number;
/** Dim non-active points while hovering. Default: true */
fadeOnHover?: boolean;
/** Opacity for non-hovered points when `fadeOnHover` is true. Default: 0.5 */
inactiveOpacity?: number;
/**
* Blur in px for non-hovered points when `fadeOnHover` is true.
* Applied once on the dimmed layer (not per dot) for performance. Default: 2
*/
inactiveBlur?: number;
/** Initial blur in px during enter animation. Default: 2 */
enterBlur?: number;
/** Enlarge the active point while hovering. Default: true */
showActiveHighlight?: boolean;
}
interface MarkerCirclesProps {
fill?: string;
stroke?: string;
strokeWidth: number;
ringGap: number;
outlineWidth: number;
outlineColor?: string;
radius: number;
}
function MarkerCircles({
fill,
stroke,
strokeWidth,
ringGap,
outlineWidth,
outlineColor,
radius,
}: MarkerCirclesProps) {
const resolvedStroke = stroke ?? fill ?? "currentColor";
const resolvedOutlineColor = outlineColor ?? resolvedStroke;
const ringOuter = strokeWidth > 0 ? radius + ringGap + strokeWidth : radius;
const outlineRadius = outlineWidth > 0 ? ringOuter + outlineWidth / 2 : 0;
return (
<>
{outlineWidth > 0 ? (
<circle
cx={0}
cy={0}
fill="none"
r={outlineRadius}
stroke={resolvedOutlineColor}
strokeWidth={outlineWidth}
/>
) : null}
<circle cx={0} cy={0} fill={fill} r={radius} />
{strokeWidth > 0 ? (
<circle
cx={0}
cy={0}
fill="none"
r={radius + ringGap + strokeWidth / 2}
stroke={resolvedStroke}
strokeWidth={strokeWidth}
/>
) : null}
</>
);
}
export interface StaticSeriesPointMarkerProps extends SeriesPointMarkerStyle {
cx: number;
cy: number;
scale?: number;
}
export const StaticSeriesPointMarker = memo(function StaticSeriesPointMarker({
cx,
cy,
scale = 1,
fill,
stroke,
strokeWidth = 2,
ringGap = 2,
outlineWidth = 0,
outlineColor,
radius = 5,
}: StaticSeriesPointMarkerProps) {
return (
<g transform={`translate(${cx}, ${cy}) scale(${scale})`}>
<MarkerCircles
fill={fill}
outlineColor={outlineColor}
outlineWidth={outlineWidth}
radius={radius}
ringGap={ringGap}
stroke={stroke}
strokeWidth={strokeWidth}
/>
</g>
);
});
export interface SeriesPointMarkerProps extends SeriesPointMarkerStyle {
dataKey: string;
index: number;
cx: number;
cy: number;
revealDelay: number;
revealEpoch: number;
enterDuration: number;
}
/** Motion enter marker — used only while the chart reveal is running. */
export function SeriesPointMarker({
dataKey,
index,
cx,
cy,
enterBlur = 2,
revealDelay,
revealEpoch,
enterDuration,
fill,
stroke,
strokeWidth = 2,
ringGap = 2,
outlineWidth = 0,
outlineColor,
radius = 5,
}: SeriesPointMarkerProps) {
const variants: Variants = {
hidden: {
opacity: 0,
filter: `blur(${enterBlur}px)`,
scale: 1,
},
visible: {
opacity: 1,
filter: "blur(0px)",
scale: 1,
transition: {
delay: revealDelay,
duration: enterDuration,
ease: DEFAULT_CHART_ENTER_TRANSITION.ease,
},
},
};
return (
<g transform={`translate(${cx}, ${cy})`}>
<motion.g
animate="visible"
initial="hidden"
key={`${dataKey}-${index}-${revealEpoch}`}
variants={variants}
>
<MarkerCircles
fill={fill}
outlineColor={outlineColor}
outlineWidth={outlineWidth}
radius={radius}
ringGap={ringGap}
stroke={stroke}
strokeWidth={strokeWidth}
/>
</motion.g>
</g>
);
}
export function getSeriesMarkerVisualExtent(
style: Pick<
SeriesPointMarkerStyle,
| "radius"
| "strokeWidth"
| "ringGap"
| "outlineWidth"
| "showActiveHighlight"
>
): number {
const radius = style.radius ?? 5;
const strokeWidth = style.strokeWidth ?? 2;
const ringGap = style.ringGap ?? 2;
const outlineWidth = style.outlineWidth ?? 0;
const showActiveHighlight = style.showActiveHighlight ?? true;
const ring = strokeWidth > 0 ? ringGap + strokeWidth : 0;
const outline = outlineWidth > 0 ? outlineWidth : 0;
const highlightPad = showActiveHighlight ? radius * 0.35 : 0;
return radius + ring + outline + highlightPad + 2;
}
@@ -0,0 +1,22 @@
"use client";
import { createContext, type ReactNode, useContext } from "react";
const StaticChartPreviewContext = createContext(false);
/** Disables cartesian reveal clip-path for static docs previews. */
export function StaticChartPreviewProvider({
children,
}: {
children: ReactNode;
}) {
return (
<StaticChartPreviewContext.Provider value={true}>
{children}
</StaticChartPreviewContext.Provider>
);
}
export function useStaticChartPreview() {
return useContext(StaticChartPreviewContext);
}
@@ -0,0 +1,627 @@
"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>
);
});
@@ -0,0 +1,389 @@
"use client";
import { motion, useSpring } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
resolveTooltipBoxMotion,
type SpringConfig,
useChartConfig,
} from "../chart-config-context";
import {
chartCssVars,
type LineConfig,
useChart,
useChartStable,
} from "../chart-context";
import { weekdayDateFmt } from "../chart-formatters";
import type { IndicatorFadeEdges } from "../indicator-fade";
import { DateTicker } from "./date-ticker";
import { TooltipBox } from "./tooltip-box";
import { TooltipContent, type TooltipRow } from "./tooltip-content";
import { TooltipDot } from "./tooltip-dot";
import { TooltipIndicator } from "./tooltip-indicator";
export interface ChartTooltipProps {
/** Whether to show the date pill at bottom. Default: true */
showDatePill?: boolean;
/** Whether to show the vertical crosshair line. Default: true */
showCrosshair?: boolean;
/** Whether to show dots on the lines. Default: true */
showDots?: boolean;
/**
* Color for the crosshair/indicator line. When a function, receives the hovered point
* (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair.
*/
indicatorColor?: string | ((point: Record<string, unknown>) => string);
/** Custom content renderer for the tooltip box */
content?: (props: {
point: Record<string, unknown>;
index: number;
}) => React.ReactNode;
/** Custom row renderer - return array of TooltipRow */
rows?: (point: Record<string, unknown>) => TooltipRow[];
/**
* Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors.
* When a function, receives the hovered point and line config.
*/
dotColor?:
| string
| ((point: Record<string, unknown>, line: LineConfig) => string);
/** Additional content to show below rows (e.g., markers) */
children?: React.ReactNode;
/** Custom class name */
className?: string;
/** Per-chart override for the crosshair / dot / date-pill spring. */
springConfig?: SpringConfig;
/**
* When `true`, the floating panel uses the crosshair spring and stays in sync.
* Default `false` — panel follow uses `damping` (`20`).
*/
matchCrosshair?: boolean;
/**
* Spring damping for the floating tooltip panel when `matchCrosshair` is `false`.
* `0` disables spring motion (instant). Default: `20`.
*/
damping?: number;
/** SVG stroke dash pattern for the crosshair. Omit for solid. */
indicatorDasharray?: string;
/** Vertical crosshair fade: `both`, `top`, `bottom`, or `none` (solid). Default: `both`. */
indicatorFadeEdges?: IndicatorFadeEdges;
/** Crosshair fade zone size (% of height). Default: `10`. */
indicatorFadeLength?: number;
/** Per-chart override for the floating-panel spring. */
boxSpringConfig?: SpringConfig;
/** Inline styles for the tooltip panel (background, blur, etc.). */
panelStyle?: React.CSSProperties;
}
interface ChartTooltipInnerProps extends ChartTooltipProps {
container: HTMLElement;
}
const ChartTooltipInner = memo(function ChartTooltipInner({
showDatePill = true,
showCrosshair = true,
showDots = true,
indicatorColor: indicatorColorProp,
content,
rows: rowsRenderer,
dotColor: dotColorProp,
children,
className = "",
container,
springConfig,
matchCrosshair = false,
damping,
indicatorDasharray,
indicatorFadeEdges,
indicatorFadeLength,
boxSpringConfig,
panelStyle,
}: ChartTooltipInnerProps) {
const {
tooltipData,
width,
height,
innerHeight,
margin,
columnWidth,
lines,
xAccessor,
dateLabels,
containerRef,
orientation,
barXAccessor,
} = useChart();
const { tooltipSpring } = useChartConfig();
const isHorizontal = orientation === "horizontal";
const discreteInteraction = dateLabels.length > 60;
const boxMotion = useMemo(() => {
if (boxSpringConfig) {
return {
animate: !discreteInteraction,
springConfig: boxSpringConfig,
};
}
if (matchCrosshair) {
return {
animate: !discreteInteraction,
springConfig: springConfig ?? tooltipSpring,
};
}
return resolveTooltipBoxMotion(damping);
}, [
boxSpringConfig,
damping,
discreteInteraction,
matchCrosshair,
springConfig,
tooltipSpring,
]);
const visible = tooltipData !== null;
const x = tooltipData?.x ?? 0;
const xWithMargin = x + margin.left;
// For horizontal charts, get the y position from the first line's yPosition (center of bar)
const firstLineDataKey = lines[0]?.dataKey;
const firstLineY = firstLineDataKey
? (tooltipData?.yPositions[firstLineDataKey] ?? 0)
: 0;
const yWithMargin = firstLineY + margin.top;
const tooltipRows = useMemo(() => {
if (!tooltipData) {
return [];
}
if (rowsRenderer) {
return rowsRenderer(tooltipData.point);
}
// Default: generate rows from registered lines
return lines.map((line) => ({
color: line.stroke,
label: line.dataKey,
value: (tooltipData.point[line.dataKey] as number) ?? 0,
}));
}, [tooltipData, lines, rowsRenderer]);
const resolveDotColor = useMemo(() => {
return (line: LineConfig, index: number): string => {
if (rowsRenderer && tooltipRows[index]?.color) {
return tooltipRows[index].color;
}
if (dotColorProp != null) {
if (typeof dotColorProp === "function" && tooltipData) {
return dotColorProp(tooltipData.point, line);
}
if (typeof dotColorProp === "string") {
return dotColorProp;
}
}
return line.stroke;
};
}, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]);
// Resolve indicator color (static or from hovered point)
const indicatorColor = useMemo(() => {
if (indicatorColorProp == null) {
return chartCssVars.crosshair;
}
if (typeof indicatorColorProp === "function") {
return tooltipData
? indicatorColorProp(tooltipData.point)
: chartCssVars.crosshair;
}
return indicatorColorProp;
}, [indicatorColorProp, tooltipData]);
// Title from date or category
const title = useMemo(() => {
if (!tooltipData) {
return undefined;
}
// For bar charts (horizontal or vertical), use the category name
if (barXAccessor) {
return barXAccessor(tooltipData.point);
}
// For line/area charts, use the date
return weekdayDateFmt.format(xAccessor(tooltipData.point));
}, [tooltipData, barXAccessor, xAccessor]);
const tooltipContent = (
<>
{/* Crosshair indicator - rendered as SVG overlay */}
{showCrosshair && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
<TooltipIndicator
animate={!discreteInteraction}
colorEdge={indicatorColor}
colorMid={indicatorColor}
columnWidth={columnWidth}
fadeEdges={
indicatorDasharray ? "none" : (indicatorFadeEdges ?? "both")
}
fadeLength={indicatorFadeLength}
height={innerHeight}
springConfig={springConfig}
strokeDasharray={indicatorDasharray}
visible={visible}
width="line"
x={x}
/>
</g>
</svg>
)}
{/* Dots on bars/lines - show for vertical charts only */}
{showDots && visible && !isHorizontal && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
{lines.map((line, index) => (
<TooltipDot
color={resolveDotColor(line, index)}
key={line.dataKey}
springConfig={springConfig}
strokeColor={chartCssVars.background}
visible={visible}
x={tooltipData?.xPositions?.[line.dataKey] ?? x}
y={tooltipData?.yPositions[line.dataKey] ?? 0}
/>
))}
</g>
</svg>
)}
{/* Tooltip Box */}
<TooltipBox
animate={boxMotion.animate}
className={className}
containerHeight={height}
containerRef={containerRef}
containerWidth={width}
panelStyle={panelStyle}
springConfig={boxMotion.springConfig}
top={isHorizontal ? undefined : margin.top}
visible={visible}
x={xWithMargin}
y={isHorizontal ? yWithMargin : margin.top}
>
{content && tooltipData
? content({
point: tooltipData.point,
index: tooltipData.index,
})
: !content && (
<TooltipContent rows={tooltipRows} title={title}>
{children}
</TooltipContent>
)}
</TooltipBox>
{/* Date/Category Ticker - only show for vertical charts */}
<DatePillTracker
currentIndex={tooltipData?.index ?? 0}
discreteInteraction={discreteInteraction}
enabled={showDatePill && !isHorizontal}
labels={dateLabels}
springConfig={springConfig}
visible={visible}
xWithMargin={xWithMargin}
/>
</>
);
return createPortal(tooltipContent, container);
});
export function ChartTooltip(props: ChartTooltipProps) {
const { containerRef } = useChartStable();
const [mounted, setMounted] = useState(false);
// Only render portals on client side after mount
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return <ChartTooltipInner {...props} container={container} />;
}
ChartTooltip.displayName = "ChartTooltip";
interface DatePillTrackerProps {
enabled: boolean;
visible: boolean;
labels: string[];
currentIndex: number;
xWithMargin: number;
discreteInteraction: boolean;
springConfig?: SpringConfig;
}
// Inner-only-on-visible so `useSpring` initializes at the real cursor x
// instead of `margin.left` on first hover.
function DatePillTracker(props: DatePillTrackerProps) {
if (!(props.enabled && props.visible && props.labels.length > 0)) {
return null;
}
return <DatePillTrackerInner {...props} />;
}
function DatePillTrackerInner({
labels,
currentIndex,
xWithMargin,
discreteInteraction,
springConfig,
visible,
}: DatePillTrackerProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const animatedX = useSpring(xWithMargin, effectiveSpring);
if (!discreteInteraction) {
animatedX.set(xWithMargin);
}
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
useEffect(() => {
animatedX.set(xWithMargin);
}, [animatedX, visible]);
return (
<motion.div
className="pointer-events-none absolute z-50"
style={{
left: discreteInteraction ? xWithMargin : animatedX,
transform: "translateX(-50%)",
bottom: 4,
}}
>
<DateTicker
currentIndex={currentIndex}
labels={labels}
visible={visible}
/>
</motion.div>
);
}
export default ChartTooltip;
@@ -0,0 +1,150 @@
"use client";
import { motion, useSpring } from "motion/react";
import { memo, useMemo, useRef } from "react";
const TICKER_ITEM_HEIGHT = 24;
/** Full scroll stacks are skipped above this count — single label + instant updates. */
const COMPACT_TICKER_THRESHOLD = 60;
export interface DateTickerProps {
currentIndex: number;
labels: string[];
visible: boolean;
}
const DateTickerCompact = memo(function DateTickerCompact({
currentIndex,
labels,
}: Omit<DateTickerProps, "visible">) {
const label = labels[currentIndex] ?? labels[0] ?? "";
return (
<div className="overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
<div className="flex h-6 items-center justify-center">
<span className="whitespace-nowrap font-medium text-sm">{label}</span>
</div>
</div>
);
});
const DateTickerInner = memo(function DateTickerInner({
currentIndex,
labels,
}: Omit<DateTickerProps, "visible">) {
// Parse labels into month and day parts
const parsedLabels = useMemo(() => {
return labels.map((label, index) => {
const parts = label.split(" ");
const month = parts[0] || "";
const day = parts[1] || "";
return { month, day, full: label, key: `${label}::${index}` };
});
}, [labels]);
// Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index
const monthSegments = useMemo(() => {
const segments: { month: string; key: string; startIndex: number }[] = [];
parsedLabels.forEach((label, index) => {
const prev = segments.at(-1);
if (!prev || prev.month !== label.month) {
segments.push({
month: label.month,
key: `${label.month}-${index}`,
startIndex: index,
});
}
});
return segments;
}, [parsedLabels]);
// Index into monthSegments for the current data point
const currentMonthIndex = useMemo(() => {
if (currentIndex < 0 || currentIndex >= parsedLabels.length) {
return 0;
}
for (let i = monthSegments.length - 1; i >= 0; i--) {
const segment = monthSegments[i];
if (segment && segment.startIndex <= currentIndex) {
return i;
}
}
return 0;
}, [currentIndex, parsedLabels.length, monthSegments]);
// Track previous month index
const prevMonthIndexRef = useRef(-1);
// Animated Y offsets
const dayY = useSpring(0, { stiffness: 400, damping: 35 });
const monthY = useSpring(0, { stiffness: 400, damping: 35 });
dayY.set(-currentIndex * TICKER_ITEM_HEIGHT);
if (currentMonthIndex >= 0) {
const isFirstRender = prevMonthIndexRef.current === -1;
const monthChanged = prevMonthIndexRef.current !== currentMonthIndex;
if (isFirstRender || monthChanged) {
monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT);
prevMonthIndexRef.current = currentMonthIndex;
}
}
return (
<div className="overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
<div className="relative h-6 overflow-hidden">
<div className="flex items-center justify-center gap-1">
{/* Month stack */}
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: monthY }}>
{monthSegments.map((segment) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={segment.key}
>
<span className="whitespace-nowrap font-medium text-sm">
{segment.month}
</span>
</div>
))}
</motion.div>
</div>
{/* Day stack */}
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: dayY }}>
{parsedLabels.map((label) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={label.key}
>
<span className="whitespace-nowrap font-medium text-sm">
{label.day}
</span>
</div>
))}
</motion.div>
</div>
</div>
</div>
</div>
);
});
export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) {
if (!visible || labels.length === 0) {
return null;
}
if (labels.length > COMPACT_TICKER_THRESHOLD) {
return <DateTickerCompact currentIndex={currentIndex} labels={labels} />;
}
return <DateTickerInner currentIndex={currentIndex} labels={labels} />;
}
DateTicker.displayName = "DateTicker";
export default DateTicker;
@@ -0,0 +1,14 @@
export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip";
export { DateTicker, type DateTickerProps } from "./date-ticker";
export { TooltipBox, type TooltipBoxProps } from "./tooltip-box";
export {
TooltipContent,
type TooltipContentProps,
type TooltipRow,
} from "./tooltip-content";
export { TooltipDot, type TooltipDotProps } from "./tooltip-dot";
export {
type IndicatorWidth,
TooltipIndicator,
type TooltipIndicatorProps,
} from "./tooltip-indicator";
@@ -0,0 +1,195 @@
"use client";
import { motion, useSpring } from "motion/react";
import type { RefObject } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
export interface TooltipBoxProps {
/** X position in pixels (relative to container) */
x: number;
/** Y position in pixels (relative to container) */
y: number;
/** Whether the tooltip is visible */
visible: boolean;
/** Container ref for portal rendering */
containerRef: RefObject<HTMLDivElement | null>;
/** Container width for flip detection */
containerWidth: number;
/** Container height for bounds clamping */
containerHeight: number;
/** Offset from the target position */
offset?: number;
/** Custom class name */
className?: string;
/** Tooltip content */
children: React.ReactNode;
/** Override left position (bypasses internal calculation) */
left?: number | ReturnType<typeof useSpring>;
/** Override top position (bypasses internal calculation) */
top?: number | ReturnType<typeof useSpring>;
/** Force flip direction (for custom positioning) */
flipped?: boolean;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */
springConfig?: SpringConfig;
/** Animate panel position with a spring. Default: true */
animate?: boolean;
/** Inline styles for the inner tooltip panel. */
panelStyle?: React.CSSProperties;
}
// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y
// instead of (0, 0) on first hover.
export function TooltipBox(props: TooltipBoxProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = props.containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!props.visible) {
return null;
}
return <TooltipBoxInner {...props} container={container} />;
}
function TooltipBoxInner({
x,
y,
containerWidth,
containerHeight,
offset = 16,
className = "",
children,
left: leftOverride,
top: topOverride,
flipped: flippedOverride,
springConfig,
animate = true,
panelStyle,
container,
}: Omit<TooltipBoxProps, "visible" | "containerRef"> & {
container: HTMLElement;
}) {
const { tooltipBoxSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipBoxSpring;
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipWidthRef = useRef(180);
const tooltipHeightRef = useRef(80);
const [staticPosition, setStaticPosition] = useState({ left: x, top: y });
const tw = tooltipWidthRef.current;
const th = tooltipHeightRef.current;
const shouldFlipX = x + tw + offset > containerWidth;
const targetX = shouldFlipX ? x - offset - tw : x + offset;
const targetY = Math.max(
offset,
Math.min(y - th / 2, containerHeight - th - offset)
);
const animatedLeft = useSpring(targetX, effectiveSpring);
const animatedTop = useSpring(targetY, effectiveSpring);
if (animate && leftOverride === undefined) {
animatedLeft.set(targetX);
}
if (animate && topOverride === undefined) {
animatedTop.set(targetY);
}
useLayoutEffect(() => {
if (!tooltipRef.current) {
return;
}
const el = tooltipRef.current;
const w = el.offsetWidth;
const h = el.offsetHeight;
if (w > 0) {
tooltipWidthRef.current = w;
}
if (h > 0) {
tooltipHeightRef.current = h;
}
const w2 = tooltipWidthRef.current;
const h2 = tooltipHeightRef.current;
const flip = x + w2 + offset > containerWidth;
const tx = flip ? x - offset - w2 : x + offset;
const ty = Math.max(
offset,
Math.min(y - h2 / 2, containerHeight - h2 - offset)
);
if (!animate) {
setStaticPosition({ left: tx, top: ty });
return;
}
if (leftOverride === undefined) {
animatedLeft.set(tx);
}
if (topOverride === undefined) {
animatedTop.set(ty);
}
}, [
x,
y,
containerWidth,
containerHeight,
offset,
leftOverride,
topOverride,
animate,
animatedLeft,
animatedTop,
]);
const prevFlipRef = useRef(shouldFlipX);
const [flipKey, setFlipKey] = useState(0);
useEffect(() => {
if (prevFlipRef.current !== shouldFlipX) {
setFlipKey((k) => k + 1);
prevFlipRef.current = shouldFlipX;
}
}, [shouldFlipX]);
const finalLeft = animate
? (leftOverride ?? animatedLeft)
: staticPosition.left;
const finalTop = animate ? (topOverride ?? animatedTop) : staticPosition.top;
const isFlipped = flippedOverride ?? shouldFlipX;
const transformOrigin = isFlipped ? "right top" : "left top";
return createPortal(
<motion.div
animate={{ opacity: 1 }}
className={cn("pointer-events-none absolute z-50", className)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
ref={tooltipRef}
style={{ left: finalLeft, top: finalTop }}
transition={{ duration: 0.1 }}
>
<motion.div
animate={{ scale: 1, opacity: 1, x: 0 }}
className="min-w-[140px] overflow-hidden rounded-lg bg-chart-tooltip-background text-chart-tooltip-foreground shadow-lg backdrop-blur-md"
initial={{ scale: 0.85, opacity: 0, x: isFlipped ? 20 : -20 }}
key={flipKey}
style={{ transformOrigin, ...panelStyle }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{children}
</motion.div>
</motion.div>,
container
);
}
TooltipBox.displayName = "TooltipBox";
export default TooltipBox;
@@ -0,0 +1,62 @@
"use client";
import type { ReactNode } from "react";
import { intFmt } from "../chart-formatters";
export interface TooltipRow {
color: string;
label: string;
value: string | number;
}
export interface TooltipContentProps {
title?: string;
rows: TooltipRow[];
/** Optional additional content (e.g., markers) */
children?: ReactNode;
}
export function TooltipContent({ title, rows, children }: TooltipContentProps) {
return (
<div className="overflow-hidden">
<div className="px-3 py-2.5">
{title && (
<div className="mb-2 font-medium text-chart-tooltip-foreground text-xs">
{title}
</div>
)}
<div className="space-y-1.5">
{rows.map((row) => (
<div
className="flex items-center justify-between gap-4"
key={`${row.label}-${row.color}`}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="text-chart-tooltip-muted text-sm">
{row.label}
</span>
</div>
<span className="font-medium text-chart-tooltip-foreground text-sm tabular-nums">
{typeof row.value === "number" ? intFmt(row.value) : row.value}
</span>
</div>
))}
</div>
{children && (
<div className="mt-2 transition-opacity duration-200 ease-out">
{children}
</div>
)}
</div>
</div>
);
}
TooltipContent.displayName = "TooltipContent";
export default TooltipContent;
@@ -0,0 +1,73 @@
"use client";
import { motion, useSpring } from "motion/react";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
import { chartCssVars } from "../chart-context";
export interface TooltipDotProps {
x: number;
y: number;
visible: boolean;
color: string;
size?: number;
strokeColor?: string;
strokeWidth?: number;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
springConfig?: SpringConfig;
/** Animate position with a spring. Default: true */
animate?: boolean;
}
export function TooltipDot({
x,
y,
visible,
color,
size = 5,
strokeColor = chartCssVars.background,
strokeWidth = 2,
springConfig,
animate = true,
}: TooltipDotProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const animatedX = useSpring(x, effectiveSpring);
const animatedY = useSpring(y, effectiveSpring);
if (animate) {
animatedX.set(x);
animatedY.set(y);
}
if (!visible) {
return null;
}
if (!animate) {
return (
<circle
cx={x}
cy={y}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
return (
<motion.circle
cx={animatedX}
cy={animatedY}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
TooltipDot.displayName = "TooltipDot";
export default TooltipDot;
@@ -0,0 +1,211 @@
"use client";
import { motion, useSpring } from "motion/react";
import { useEffect } from "react";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
import { chartCssVars } from "../chart-context";
import {
type IndicatorFadeEdges,
indicatorFadeGradientStops,
resolveVerticalFadeSides,
} from "../indicator-fade";
export type IndicatorWidth =
| number // Pixel width
| "line" // 1px line (default)
| "thin" // 2px
| "medium" // 4px
| "thick"; // 8px
export interface TooltipIndicatorProps {
/** X position in pixels (center of the indicator) */
x: number;
/** Height of the indicator */
height: number;
/** Whether the indicator is visible */
visible: boolean;
/**
* Width of the indicator - number (pixels) or preset.
* Ignored if `span` is provided.
*/
width?: IndicatorWidth;
/**
* Number of columns/days to span, with current point centered.
* Requires `columnWidth` to be set.
*/
span?: number;
/** Width of a single column/day in pixels. Required when using `span`. */
columnWidth?: number;
/** Primary color at edges (10% and 90%) */
colorEdge?: string;
/** Secondary color at center (50%) */
colorMid?: string;
/** Vertical fade: both ends, top, bottom, or none (solid). */
fadeEdges?: IndicatorFadeEdges | boolean;
/** Fade zone size as a percentage of indicator height. Default: 10 */
fadeLength?: number;
/** Animate position with a spring. Default: true */
animate?: boolean;
/** Unique ID for the gradient */
gradientId?: string;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
springConfig?: SpringConfig;
/** SVG stroke dash pattern. When set, renders a dashed stroke instead of a solid fill. */
strokeDasharray?: string;
}
function resolveWidth(width: IndicatorWidth): number {
if (typeof width === "number") {
return width;
}
switch (width) {
case "line":
return 1;
case "thin":
return 2;
case "medium":
return 4;
case "thick":
return 8;
default:
return 1;
}
}
// Inner-only-on-visible so `useSpring` initializes at the real cursor x
// instead of 0 on first hover.
export function TooltipIndicator(props: TooltipIndicatorProps) {
if (!props.visible) {
return null;
}
return <TooltipIndicatorInner {...props} />;
}
function TooltipIndicatorInner({
x,
visible,
height,
width = "line",
span,
columnWidth,
colorEdge = chartCssVars.crosshair,
colorMid = chartCssVars.crosshair,
fadeEdges = "both",
fadeLength = 10,
animate = true,
gradientId = "tooltip-indicator-gradient",
springConfig,
strokeDasharray,
}: TooltipIndicatorProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const pixelWidth =
span !== undefined && columnWidth !== undefined
? span * columnWidth
: resolveWidth(width);
const rectX = x - pixelWidth / 2;
const lineX = x;
const animatedX = useSpring(rectX, effectiveSpring);
const animatedLineX = useSpring(lineX, effectiveSpring);
if (animate) {
animatedX.set(rectX);
animatedLineX.set(lineX);
}
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
useEffect(() => {
animatedX.set(rectX);
animatedLineX.set(lineX);
}, [animatedLineX, animatedX, lineX, rectX, visible]);
const indicatorFill = colorMid || colorEdge;
const fadeSides = resolveVerticalFadeSides(fadeEdges);
const dashed = Boolean(strokeDasharray);
if (dashed) {
const strokeWidth = Math.max(1, pixelWidth);
return animate ? (
<motion.line
stroke={indicatorFill}
strokeDasharray={strokeDasharray}
strokeWidth={strokeWidth}
x1={animatedLineX}
x2={animatedLineX}
y1={0}
y2={height}
/>
) : (
<line
stroke={indicatorFill}
strokeDasharray={strokeDasharray}
strokeWidth={strokeWidth}
x1={lineX}
x2={lineX}
y1={0}
y2={height}
/>
);
}
if (!fadeSides.any) {
return animate ? (
<motion.rect
fill={indicatorFill}
height={height}
width={pixelWidth}
x={animatedX}
y={0}
/>
) : (
<rect
fill={indicatorFill}
height={height}
width={pixelWidth}
x={rectX}
y={0}
/>
);
}
const fadeStops = indicatorFadeGradientStops(fadeSides, fadeLength);
return (
<g>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
{fadeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: indicatorFill, stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
</defs>
{animate ? (
<motion.rect
fill={`url(#${gradientId})`}
height={height}
width={pixelWidth}
x={animatedX}
y={0}
/>
) : (
<rect
fill={`url(#${gradientId})`}
height={height}
width={pixelWidth}
x={rectX}
y={0}
/>
)}
</g>
);
}
TooltipIndicator.displayName = "TooltipIndicator";
export default TooltipIndicator;
@@ -0,0 +1,242 @@
"use client";
import { animate, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import type { ChartPhase } from "./chart-phase";
import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing";
import {
domainsEqual,
isYDomainTweenPhase,
resolveAnimatedYDestinationDomains,
shouldTweenYDomain,
type YDomain,
} from "./y-domain-utils";
function lerpDomain(from: YDomain, to: YDomain, progress: number): YDomain {
return [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
];
}
function snapDomains(
domains: Record<string, YDomain>,
setAnimatedByAxis: (domains: Record<string, YDomain>) => void,
animatedRef: { current: Record<string, YDomain> }
) {
if (domainsEqual(animatedRef.current, domains)) {
return;
}
setAnimatedByAxis(domains);
animatedRef.current = domains;
}
function tweenDomains({
destination,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled,
}: {
destination: Record<string, YDomain>;
durationMs: number;
enabled: boolean;
reducedMotion: boolean | null;
animatedRef: { current: Record<string, YDomain> };
setAnimatedByAxis: (domains: Record<string, YDomain>) => void;
onSettled?: () => void;
}) {
if (domainsEqual(animatedRef.current, destination)) {
onSettled?.();
return;
}
if (!enabled || reducedMotion) {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
return;
}
const axisIds = Object.keys(destination);
const fromSnapshot = animatedRef.current;
let needsTween = false;
for (const axisId of axisIds) {
const from =
fromSnapshot[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain);
const to = destination[axisId] ?? from;
if (shouldTweenYDomain(from, to)) {
needsTween = true;
break;
}
}
if (!needsTween) {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
return;
}
const fromByAxis: Record<string, YDomain> = {};
for (const axisId of axisIds) {
fromByAxis[axisId] = fromSnapshot[axisId] ??
destination[axisId] ?? [0, 100];
}
const control = animate(0, 1, {
duration: durationMs / 1000,
ease: [...LINE_LOADING_PULSE_EASE],
onUpdate: (progress) => {
const next: Record<string, YDomain> = {};
for (const axisId of axisIds) {
const from =
fromByAxis[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain);
const to = destination[axisId] ?? from;
next[axisId] = shouldTweenYDomain(from, to)
? lerpDomain(from, to, progress)
: to;
}
animatedRef.current = next;
setAnimatedByAxis(next);
},
onComplete: () => {
snapDomains(destination, setAnimatedByAxis, animatedRef);
onSettled?.();
},
});
return control;
}
export interface UseAnimatedYDomainsOptions {
enabled: boolean;
durationMs: number;
chartPhase: ChartPhase;
skeletonByAxis: Record<string, YDomain>;
targetByAxis: Record<string, YDomain>;
onSettled?: () => void;
/** When true, tweens y-domains on target changes while the chart is in the ready phase (e.g. brush zoom). */
tweenOnTargetChange?: boolean;
}
export function useAnimatedYDomains({
enabled,
durationMs,
chartPhase,
skeletonByAxis,
targetByAxis,
onSettled,
tweenOnTargetChange = false,
}: UseAnimatedYDomainsOptions): Record<string, YDomain> {
const reducedMotion = useReducedMotion();
const destinationByAxis = resolveAnimatedYDestinationDomains(
chartPhase,
skeletonByAxis,
targetByAxis
);
const destinationRef = useRef(destinationByAxis);
destinationRef.current = destinationByAxis;
const skeletonRef = useRef(skeletonByAxis);
skeletonRef.current = skeletonByAxis;
const targetRef = useRef(targetByAxis);
targetRef.current = targetByAxis;
const [animatedByAxis, setAnimatedByAxis] = useState(destinationByAxis);
const animatedRef = useRef(animatedByAxis);
const prevPhaseRef = useRef(chartPhase);
const onSettledRef = useRef(onSettled);
onSettledRef.current = onSettled;
useEffect(() => {
animatedRef.current = animatedByAxis;
}, [animatedByAxis]);
useEffect(() => {
if (prevPhaseRef.current === chartPhase) {
return;
}
prevPhaseRef.current = chartPhase;
const settle = () => {
onSettledRef.current?.();
};
// Keep grid spacing frozen while the series exits the viewport.
if (chartPhase === "exiting") {
snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "exitingReady") {
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "loading") {
snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (chartPhase === "revealing" || chartPhase === "ready") {
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
return;
}
if (!isYDomainTweenPhase(chartPhase)) {
return;
}
const control = tweenDomains({
destination: destinationRef.current,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled: settle,
});
return () => control?.stop();
}, [chartPhase, durationMs, enabled, reducedMotion]);
const targetSignature = JSON.stringify(targetByAxis);
const prevTargetSignatureRef = useRef(targetSignature);
useEffect(() => {
const inLivePhase = chartPhase === "ready" || chartPhase === "revealing";
if (!inLivePhase) {
prevTargetSignatureRef.current = targetSignature;
return;
}
if (prevTargetSignatureRef.current === targetSignature) {
return;
}
prevTargetSignatureRef.current = targetSignature;
if (tweenOnTargetChange && chartPhase === "ready") {
const control = tweenDomains({
destination: targetRef.current,
durationMs,
enabled,
reducedMotion,
animatedRef,
setAnimatedByAxis,
onSettled: () => onSettledRef.current?.(),
});
return () => control?.stop();
}
snapDomains(targetRef.current, setAnimatedByAxis, animatedRef);
}, [
chartPhase,
durationMs,
enabled,
reducedMotion,
targetSignature,
tweenOnTargetChange,
]);
return animatedByAxis;
}
@@ -0,0 +1,353 @@
"use client";
import { localPoint } from "@visx/event";
import type { scaleLinear, scaleTime } from "@visx/scale";
import { useCallback, useEffect, useRef, useState } from "react";
import type { LineConfig, Margin, TooltipData } from "./chart-context";
import { useScheduledTooltip } from "./use-scheduled-tooltip";
import { normalizeYAxisId } from "./y-axis-scales";
type ScaleTime = ReturnType<typeof scaleTime<number>>;
type ScaleLinear = ReturnType<typeof scaleLinear<number>>;
export interface ChartSelection {
startX: number;
endX: number;
startIndex: number;
endIndex: number;
active: boolean;
}
interface UseChartInteractionParams {
xScale: ScaleTime;
yScale: ScaleLinear;
yScales: Record<string, ScaleLinear>;
data: Record<string, unknown>[];
lines: LineConfig[];
margin: Margin;
xAccessor: (d: Record<string, unknown>) => Date;
bisectDate: (
data: Record<string, unknown>[],
date: Date,
lo: number
) => number;
canInteract: boolean;
}
interface ChartInteractionResult {
tooltipData: TooltipData | null;
setTooltipData: React.Dispatch<React.SetStateAction<TooltipData | null>>;
selection: ChartSelection | null;
clearSelection: () => void;
interactionHandlers: {
onMouseMove?: (event: React.MouseEvent<SVGGElement>) => void;
onMouseLeave?: () => void;
onMouseDown?: (event: React.MouseEvent<SVGGElement>) => void;
onMouseUp?: () => void;
onTouchStart?: (event: React.TouchEvent<SVGGElement>) => void;
onTouchMove?: (event: React.TouchEvent<SVGGElement>) => void;
onTouchEnd?: () => void;
};
interactionStyle: React.CSSProperties;
}
export function useChartInteraction({
xScale,
yScale,
yScales,
data,
lines,
margin,
xAccessor,
bisectDate,
canInteract,
}: UseChartInteractionParams): ChartInteractionResult {
const [selection, setSelection] = useState<ChartSelection | null>(null);
const {
tooltipData,
setTooltipData,
scheduleTooltip,
clearTooltip,
resetTooltipDedupe,
} = useScheduledTooltip<TooltipData>();
const isDraggingRef = useRef(false);
const dragStartXRef = useRef<number>(0);
const lastHoveredXRef = useRef<number | null>(null);
const resolveTooltipFromX = useCallback(
(pixelX: number): TooltipData | null => {
const x0 = xScale.invert(pixelX);
const index = bisectDate(data, x0, 1);
const d0 = data[index - 1];
const d1 = data[index];
if (!d0) {
return null;
}
let d = d0;
let finalIndex = index - 1;
if (d1) {
const d0Time = xAccessor(d0).getTime();
const d1Time = xAccessor(d1).getTime();
if (x0.getTime() - d0Time > d1Time - x0.getTime()) {
d = d1;
finalIndex = index;
}
}
const yPositions: Record<string, number> = {};
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
const axisScale = yScales[normalizeYAxisId(line.yAxisId)] ?? yScale;
yPositions[line.dataKey] = axisScale(value) ?? 0;
}
}
return {
point: d,
index: finalIndex,
x: xScale(xAccessor(d)) ?? 0,
yPositions,
};
},
[xScale, yScale, yScales, data, lines, xAccessor, bisectDate]
);
const resolveIndexFromX = useCallback(
(pixelX: number): number => {
const x0 = xScale.invert(pixelX);
const index = bisectDate(data, x0, 1);
const d0 = data[index - 1];
const d1 = data[index];
if (!d0) {
return 0;
}
if (d1) {
const d0Time = xAccessor(d0).getTime();
const d1Time = xAccessor(d1).getTime();
if (x0.getTime() - d0Time > d1Time - x0.getTime()) {
return index;
}
}
return index - 1;
},
[xScale, data, xAccessor, bisectDate]
);
const getChartX = useCallback(
(
event: React.MouseEvent<SVGGElement> | React.TouchEvent<SVGGElement>,
touchIndex = 0
): number | null => {
let point: { x: number; y: number } | null = null;
if ("touches" in event) {
const touch = event.touches[touchIndex];
if (!touch) {
return null;
}
const svg = event.currentTarget.ownerSVGElement;
if (!svg) {
return null;
}
point = localPoint(svg, touch as unknown as MouseEvent);
} else {
point = localPoint(event);
}
if (!point) {
return null;
}
return point.x - margin.left;
},
[margin.left]
);
const handleMouseMove = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const chartX = getChartX(event);
if (chartX === null) {
return;
}
if (isDraggingRef.current) {
const startX = Math.min(dragStartXRef.current, chartX);
const endX = Math.max(dragStartXRef.current, chartX);
setSelection({
startX,
endX,
startIndex: resolveIndexFromX(startX),
endIndex: resolveIndexFromX(endX),
active: true,
});
return;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(tooltip);
}
},
[getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip]
);
const handleMouseLeave = useCallback(() => {
lastHoveredXRef.current = null;
clearTooltip();
if (isDraggingRef.current) {
isDraggingRef.current = false;
}
setSelection(null);
}, [clearTooltip]);
const handleMouseDown = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const chartX = getChartX(event);
if (chartX === null) {
return;
}
isDraggingRef.current = true;
dragStartXRef.current = chartX;
clearTooltip();
setSelection(null);
},
[getChartX, clearTooltip]
);
const handleMouseUp = useCallback(() => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
}
setSelection(null);
}, []);
const handleTouchStart = useCallback(
(event: React.TouchEvent<SVGGElement>) => {
if (event.touches.length === 1) {
event.preventDefault();
const chartX = getChartX(event, 0);
if (chartX === null) {
return;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(tooltip);
}
} else if (event.touches.length === 2) {
event.preventDefault();
resetTooltipDedupe();
clearTooltip();
const x0 = getChartX(event, 0);
const x1 = getChartX(event, 1);
if (x0 === null || x1 === null) {
return;
}
const startX = Math.min(x0, x1);
const endX = Math.max(x0, x1);
setSelection({
startX,
endX,
startIndex: resolveIndexFromX(startX),
endIndex: resolveIndexFromX(endX),
active: true,
});
}
},
[
getChartX,
resolveTooltipFromX,
resolveIndexFromX,
scheduleTooltip,
resetTooltipDedupe,
clearTooltip,
]
);
const handleTouchMove = useCallback(
(event: React.TouchEvent<SVGGElement>) => {
if (event.touches.length === 1) {
event.preventDefault();
const chartX = getChartX(event, 0);
if (chartX === null) {
return;
}
lastHoveredXRef.current = chartX;
const tooltip = resolveTooltipFromX(chartX);
if (tooltip) {
scheduleTooltip(tooltip);
}
} else if (event.touches.length === 2) {
event.preventDefault();
const x0 = getChartX(event, 0);
const x1 = getChartX(event, 1);
if (x0 === null || x1 === null) {
return;
}
const startX = Math.min(x0, x1);
const endX = Math.max(x0, x1);
setSelection({
startX,
endX,
startIndex: resolveIndexFromX(startX),
endIndex: resolveIndexFromX(endX),
active: true,
});
}
},
[getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip]
);
const handleTouchEnd = useCallback(() => {
clearTooltip();
setSelection(null);
}, [clearTooltip]);
const clearSelection = useCallback(() => {
setSelection(null);
}, []);
// Re-anchor tooltip/crosshair when x-scale or visible data changes (e.g. brush zoom commit).
useEffect(() => {
if (!canInteract || lastHoveredXRef.current === null) {
return;
}
const tooltip = resolveTooltipFromX(lastHoveredXRef.current);
if (tooltip) {
// Bypass index-only dedupe so x re-snaps when xScale changes after brush zoom.
setTooltipData(tooltip);
return;
}
clearTooltip();
}, [canInteract, clearTooltip, resolveTooltipFromX, setTooltipData]);
const interactionHandlers = canInteract
? {
onMouseMove: handleMouseMove,
onMouseLeave: handleMouseLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
}
: {};
const interactionStyle: React.CSSProperties = {
cursor: canInteract ? "crosshair" : "default",
touchAction: "none",
};
return {
tooltipData,
setTooltipData,
selection,
clearSelection,
interactionHandlers,
interactionStyle,
};
}
@@ -0,0 +1,183 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type ChartPhase,
type ChartStatus,
resolveRestingChartPhase,
} from "./chart-phase";
export interface UseChartPhaseOrchestratorOptions {
chartStatus: ChartStatus;
targetData: Record<string, unknown>[];
skeletonData: Record<string, unknown>[];
animationDuration: number;
yDomainTweenDuration: number;
/** Signature of motion URL state — replays clip reveal in Studio. */
revealSignature?: string;
/** Skip mount/signature enter reveal (static docs previews). */
skipEnterReveal?: boolean;
}
export function useChartPhaseOrchestrator({
chartStatus,
targetData,
skeletonData,
animationDuration,
yDomainTweenDuration,
revealSignature = "",
skipEnterReveal = false,
}: UseChartPhaseOrchestratorOptions) {
const [chartPhase, setChartPhase] = useState<ChartPhase>(() =>
resolveRestingChartPhase(chartStatus)
);
const [plotData, setPlotData] = useState<Record<string, unknown>[]>(() =>
chartStatus === "loading" ? skeletonData : targetData
);
const [revealEpoch, setRevealEpoch] = useState(0);
const [concealEpoch, setConcealEpoch] = useState(0);
const [isLoaded, setIsLoaded] = useState(() => chartStatus === "ready");
const prevStatusRef = useRef(chartStatus);
const phaseRef = useRef(chartPhase);
phaseRef.current = chartPhase;
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: status transition branches for animation durations
useEffect(() => {
const prevStatus = prevStatusRef.current;
if (prevStatus === chartStatus) {
return;
}
prevStatusRef.current = chartStatus;
if (chartStatus === "ready" && prevStatus === "loading") {
setIsLoaded(false);
if (animationDuration <= 0) {
if (yDomainTweenDuration <= 0) {
setPlotData(targetData);
setChartPhase("revealing");
} else {
setChartPhase("gridTweenReady");
}
} else {
setChartPhase("exiting");
}
return;
}
if (chartStatus === "loading" && prevStatus === "ready") {
setIsLoaded(false);
if (animationDuration <= 0) {
if (yDomainTweenDuration <= 0) {
setPlotData(skeletonData);
setChartPhase("loading");
} else {
setChartPhase("gridTweenLoading");
}
} else {
setConcealEpoch((epoch) => epoch + 1);
setChartPhase("exitingReady");
}
}
}, [
animationDuration,
chartStatus,
skeletonData,
targetData,
yDomainTweenDuration,
]);
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature replays enter
useEffect(() => {
if (skipEnterReveal) {
return;
}
if (chartStatus !== "ready") {
return;
}
if (phaseRef.current !== "ready") {
return;
}
setChartPhase("revealing");
setIsLoaded(false);
}, [animationDuration, chartStatus, revealSignature, skipEnterReveal]);
useEffect(() => {
switch (chartPhase) {
case "loading":
if (chartStatus === "loading") {
setPlotData(skeletonData);
}
break;
case "exiting":
setPlotData(skeletonData);
break;
case "exitingReady":
case "gridTweenLoading":
case "gridTweenReady":
case "revealing":
case "ready":
setPlotData(targetData);
break;
default:
break;
}
}, [chartPhase, chartStatus, skeletonData, targetData]);
/** Loading pulse exit finished — tween grid to ready spacing next. */
const notifyLoadingPulseComplete = useCallback(() => {
if (phaseRef.current !== "exiting") {
return;
}
setChartPhase("gridTweenReady");
}, []);
/** Ready series conceal finished — tween grid to loading spacing next. */
const notifyRevealConcealComplete = useCallback(() => {
if (phaseRef.current !== "exitingReady") {
return;
}
setChartPhase("gridTweenLoading");
}, []);
/** Grid tween finished — enter the next resting phase. */
const notifyYDomainTweenComplete = useCallback(() => {
if (phaseRef.current === "gridTweenLoading") {
setChartPhase("loading");
return;
}
if (phaseRef.current === "gridTweenReady") {
setChartPhase("revealing");
}
}, []);
useEffect(() => {
if (chartPhase !== "revealing") {
return;
}
setRevealEpoch((epoch) => epoch + 1);
if (animationDuration <= 0) {
setChartPhase("ready");
setIsLoaded(true);
return;
}
const timer = window.setTimeout(() => {
setChartPhase("ready");
setIsLoaded(true);
}, animationDuration);
return () => window.clearTimeout(timer);
}, [animationDuration, chartPhase]);
return {
chartPhase,
plotData,
revealEpoch,
concealEpoch,
isLoaded,
notifyLoadingPulseComplete,
notifyRevealConcealComplete,
notifyYDomainTweenComplete,
};
}
@@ -0,0 +1,28 @@
"use client";
import type { MotionValue } from "motion/react";
import { useEffect, useState } from "react";
/**
* Returns true once a mount-progress MotionValue reaches 1.
* Use to swap animated MotionValue-driven props for static values after
* enter completes — drops per-frame subscriptions during pan/hover.
*/
export function useEnterComplete(mountProgress: MotionValue<number>): boolean {
const [complete, setComplete] = useState(() => mountProgress.get() >= 1);
useEffect(() => {
if (mountProgress.get() >= 1) {
setComplete(true);
return;
}
return mountProgress.on("change", (value) => {
if (value >= 1) {
setComplete(true);
}
});
}, [mountProgress]);
return complete;
}
@@ -0,0 +1,111 @@
"use client";
import {
animate,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { useEffect } from "react";
import {
LINE_LOADING_LOOP_PAUSE_MS,
LINE_LOADING_PULSE_CYCLE_S,
LINE_LOADING_PULSE_EASE,
} from "./line-loading-timing";
export interface UseGridShimmerOptions {
innerWidth: number;
shimmer: boolean;
shimmerLength: number;
shimmerSpeed: number;
shimmerSync: boolean;
/** When false, shimmer animation is paused (e.g. during exit transition). */
active: boolean;
/** Run a single synced sweep (loading → ready handoff). */
oneShot?: boolean;
}
export function useGridShimmer({
innerWidth,
shimmer,
shimmerLength,
shimmerSpeed,
shimmerSync,
active,
oneShot = false,
}: UseGridShimmerOptions) {
const progress = useMotionValue(0);
const reducedMotion = useReducedMotion();
const shimmerCycleS =
LINE_LOADING_PULSE_CYCLE_S / Math.max(shimmerSpeed, 0.1);
const shimmerEnabled =
active && shimmer && reducedMotion !== true && innerWidth > 0;
useEffect(() => {
if (!shimmerEnabled) {
return;
}
let cancelled = false;
let timeoutId: number | undefined;
let controls: ReturnType<typeof animate> | undefined;
const runSyncedCycle = () => {
if (cancelled) {
return;
}
progress.set(0);
controls = animate(progress, 1, {
duration: shimmerCycleS,
ease: [...LINE_LOADING_PULSE_EASE],
onComplete: () => {
if (cancelled) {
return;
}
timeoutId = window.setTimeout(
runSyncedCycle,
LINE_LOADING_LOOP_PAUSE_MS
);
},
});
};
if (shimmerSync && oneShot) {
progress.set(0);
controls = animate(progress, 1, {
duration: shimmerCycleS / 2,
ease: [...LINE_LOADING_PULSE_EASE],
});
return () => controls?.stop();
}
if (shimmerSync) {
runSyncedCycle();
return () => {
cancelled = true;
controls?.stop();
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}
progress.set(0);
controls = animate(progress, 1, {
duration: shimmerCycleS,
repeat: Number.POSITIVE_INFINITY,
ease: [...LINE_LOADING_PULSE_EASE],
});
return () => controls?.stop();
}, [oneShot, progress, shimmerCycleS, shimmerEnabled, shimmerSync]);
const shimmerX = useTransform(
progress,
(value) => -shimmerLength + value * (innerWidth + shimmerLength * 2)
);
const shimmerTransform = useTransform(shimmerX, (x) => `translate(${x}, 0)`);
return { shimmerEnabled, shimmerTransform };
}
@@ -0,0 +1,61 @@
"use client";
import { useSpring } from "motion/react";
import { useMemo, useRef } from "react";
import { useChartConfig } from "./chart-config-context";
import { useChartHover, useChartStable } from "./chart-context";
import {
computeSegmentBounds,
INACTIVE_SEGMENT,
} from "./highlight-segment-bounds";
// Hover-highlight band for `line.tsx` and `area.tsx`. Computes the segment
// bounds and springs its x/width; `<HighlightSegment>` renders the clipped
// re-stroke. Spring tuning comes from `ChartConfigProvider.highlightSpring`.
// Stable + hover slices are read separately so callers can see the exact
// subscription surface (anything calling this hook will re-render on hover).
export interface HighlightSegmentResult {
xSpring: ReturnType<typeof useSpring>;
widthSpring: ReturnType<typeof useSpring>;
isActive: boolean;
}
/**
* @param enabled set false when there is no stroke to highlight (e.g. an area
* with `showLine={false}`); defaults true.
*/
export function useHighlightSegment({
enabled = true,
}: {
enabled?: boolean;
} = {}): HighlightSegmentResult {
const { data, xScale, xAccessor } = useChartStable();
const { tooltipData, selection } = useChartHover();
const { highlightSpring } = useChartConfig();
const bounds = useMemo(
() =>
enabled
? computeSegmentBounds(data, xScale, xAccessor, tooltipData, selection)
: INACTIVE_SEGMENT,
[enabled, data, xScale, xAccessor, tooltipData, selection]
);
const xSpring = useSpring(0, highlightSpring);
const widthSpring = useSpring(0, highlightSpring);
// Jump on inactive→active so the band appears at the hovered point instead
// of sliding in from x=0; ease on subsequent moves.
const wasActive = useRef(false);
if (bounds.isActive && !wasActive.current) {
xSpring.jump(bounds.x);
widthSpring.jump(bounds.width);
} else {
xSpring.set(bounds.x);
widthSpring.set(bounds.width);
}
wasActive.current = bounds.isActive;
return { xSpring, widthSpring, isActive: bounds.isActive };
}
@@ -0,0 +1,29 @@
"use client";
import { animate, type Transition, useMotionValue } from "motion/react";
import { useEffect, useRef } from "react";
import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation";
/** Drives 0→1 enter progress using the studio motion transition (spring or tween). */
export function useMountProgress(
enterTransition: Transition | undefined,
delaySeconds: number,
replayKey: number | string
) {
const progress = useMotionValue(0);
const transitionRef = useRef(enterTransition);
transitionRef.current = enterTransition;
// replayKey intentionally retriggers enter when motion settings change
// biome-ignore lint/correctness/useExhaustiveDependencies: replayKey
useEffect(() => {
progress.set(0);
const controls = animate(progress, 1, {
...(transitionRef.current ?? DEFAULT_CHART_ENTER_TRANSITION),
delay: delaySeconds,
});
return () => controls.stop();
}, [delaySeconds, replayKey, progress]);
return progress;
}
@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export interface ScheduledTooltipControls<T> {
tooltipData: T | null;
setTooltipData: React.Dispatch<React.SetStateAction<T | null>>;
scheduleTooltip: (tooltip: T, dedupeKey?: string) => void;
clearTooltip: () => void;
resetTooltipDedupe: () => void;
}
function defaultDedupeKey<T>(tooltip: T): string {
if (
typeof tooltip === "object" &&
tooltip !== null &&
"index" in tooltip &&
typeof (tooltip as { index: unknown }).index === "number"
) {
const { index, x } = tooltip as { index: number; x?: number };
if (typeof x === "number") {
return `${index}:${Math.round(x)}`;
}
return String(index);
}
return JSON.stringify(tooltip);
}
export function useScheduledTooltip<T>(): ScheduledTooltipControls<T> {
const [tooltipData, setTooltipData] = useState<T | null>(null);
const lastKeyRef = useRef<string | null>(null);
const pendingRef = useRef<T | null>(null);
const rafRef = useRef<number | null>(null);
const pendingKeyRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
const commitTooltip = useCallback((tooltip: T, dedupeKey: string) => {
if (dedupeKey === lastKeyRef.current) {
return;
}
lastKeyRef.current = dedupeKey;
setTooltipData(tooltip);
}, []);
const scheduleTooltip = useCallback(
(tooltip: T, dedupeKey?: string) => {
const key = dedupeKey ?? defaultDedupeKey(tooltip);
pendingRef.current = tooltip;
pendingKeyRef.current = key;
if (key === lastKeyRef.current) {
return;
}
if (rafRef.current !== null) {
return;
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const next = pendingRef.current;
const nextKey = pendingKeyRef.current;
if (next && nextKey) {
commitTooltip(next, nextKey);
}
});
},
[commitTooltip]
);
const clearTooltip = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
pendingRef.current = null;
pendingKeyRef.current = null;
lastKeyRef.current = null;
setTooltipData(null);
}, []);
const resetTooltipDedupe = useCallback(() => {
lastKeyRef.current = null;
}, []);
return {
tooltipData,
setTooltipData,
scheduleTooltip,
clearTooltip,
resetTooltipDedupe,
};
}
+561
View File
@@ -0,0 +1,561 @@
"use client";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
import { shortDateFmt } from "./chart-formatters";
import { DEFAULT_Y_DOMAIN_TWEEN_MS } from "./chart-phase";
import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing";
const X_AXIS_POSITION_TWEEN_MS = DEFAULT_Y_DOMAIN_TWEEN_MS;
export interface XAxisProps {
/** Number of ticks to show (including first and last). Default: 5. Used when `tickMode` is `"domain"`. */
numTicks?: number;
/** Width of the date ticker box for fade calculation. Default: 50 */
tickerHalfWidth?: number;
/**
* `"domain"` — evenly spaced ticks across the time domain (default).
* `"data"` — one label per data row at its x value (better with sparse or monthly bars).
*/
tickMode?: "domain" | "data";
}
interface AxisTick {
date: Date;
x: number;
label: string;
}
interface XAxisLabelProps {
label: string;
x: number;
crosshairX: number | null;
hoveredLabel: string | null;
isHovering: boolean;
tickerHalfWidth: number;
animatePosition: boolean;
}
function XAxisLabel({
label,
x,
crosshairX,
hoveredLabel,
isHovering,
tickerHalfWidth,
animatePosition,
}: XAxisLabelProps) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
let opacity = 1;
if (isHovering && crosshairX !== null) {
const distance = Math.abs(x - crosshairX);
if (distance < tickerHalfWidth) {
opacity = 0;
} else if (hoveredLabel && label === hoveredLabel) {
opacity = 0;
} else if (distance < fadeRadius) {
opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
}
return (
<div
className="absolute"
style={{
left: x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
transition: animatePosition
? `left ${X_AXIS_POSITION_TWEEN_MS}ms cubic-bezier(${LINE_LOADING_PULSE_EASE.join(", ")})`
: undefined,
}}
>
<span
className={cn("whitespace-nowrap text-chart-label text-xs")}
style={{
opacity,
transition: "opacity 0.4s ease-in-out",
}}
>
{label}
</span>
</div>
);
}
const MAX_GAP_LAYOUTS = 400;
function binomial(n: number, k: number): number {
if (k < 0 || k > n) {
return 0;
}
let result = 1;
for (let i = 0; i < k; i++) {
result = (result * (n - i)) / (i + 1);
}
return result;
}
/** All ways to split `span` into `parts` positive integer gaps. */
function composePositiveSum(sum: number, parts: number): number[][] {
if (parts === 1) {
return sum >= 1 ? [[sum]] : [];
}
const layouts: number[][] = [];
for (let gap = 1; gap <= sum - (parts - 1); gap++) {
for (const tail of composePositiveSum(sum - gap, parts - 1)) {
layouts.push([gap, ...tail]);
}
}
return layouts;
}
function gapsToIndices(gaps: number[]): number[] {
const indices = [0];
let position = 0;
for (const gap of gaps) {
position += gap;
indices.push(position);
}
return indices;
}
function indicesForTickCount(length: number, tickCount: number): number[] {
const span = length - 1;
if (span <= 0) {
return [0];
}
const rawIndices = Array.from({ length: tickCount }, (_, index) =>
Math.round((index / (tickCount - 1)) * span)
);
const indices = [...new Set(rawIndices)].sort((a, b) => a - b);
if (indices[0] !== 0) {
indices.unshift(0);
}
if (indices.at(-1) !== span) {
indices.push(span);
}
return [...new Set(indices)].sort((a, b) => a - b);
}
function allIndexLayouts(length: number, tickCount: number): number[][] {
const span = length - 1;
if (span <= 0) {
return [[0]];
}
const gapCount = tickCount - 1;
if (gapCount <= 0) {
return [[0]];
}
const layoutCount = binomial(span - 1, gapCount - 1);
if (layoutCount > MAX_GAP_LAYOUTS) {
return [indicesForTickCount(length, tickCount)];
}
return composePositiveSum(span, gapCount).map(gapsToIndices);
}
function dedupeIndicesByLabel(
indices: number[],
data: Record<string, unknown>[],
dateLabels: string[],
xAccessor: (d: Record<string, unknown>) => Date
): number[] {
const seenLabels = new Set<string>();
const deduped: number[] = [];
for (const index of indices) {
const point = data[index];
if (!point) {
continue;
}
const label = dateLabels[index] ?? shortDateFmt.format(xAccessor(point));
if (seenLabels.has(label)) {
continue;
}
seenLabels.add(label);
deduped.push(index);
}
return deduped;
}
interface TickLayoutScore {
score: number;
symmetryPenalty: number;
countDistance: number;
/** 0 = smallest gap at end, 1 = at start, 2 = in the middle */
edgePreference: number;
}
function indexGaps(indices: number[]): number[] {
const gaps: number[] = [];
for (let i = 1; i < indices.length; i++) {
const current = indices[i];
const previous = indices[i - 1];
if (current == null || previous == null) {
continue;
}
gaps.push(current - previous);
}
return gaps;
}
function smallestGapEdgePreference(indices: number[]): number {
const gaps = indexGaps(indices);
const smallestGap = Math.min(...gaps);
const smallestGapIndex = gaps.indexOf(smallestGap);
if (smallestGapIndex === gaps.length - 1) {
return 0;
}
if (smallestGapIndex === 0) {
return 1;
}
return 2;
}
function scoreTickLayout(
indices: number[],
resolveXPx: (index: number) => number,
targetCount: number
): TickLayoutScore {
if (indices.length < 2) {
return {
score: Number.POSITIVE_INFINITY,
symmetryPenalty: Number.POSITIVE_INFINITY,
countDistance: Number.POSITIVE_INFINITY,
edgePreference: Number.POSITIVE_INFINITY,
};
}
const pixelGaps: number[] = [];
for (let i = 1; i < indices.length; i++) {
const current = indices[i];
const previous = indices[i - 1];
if (current == null || previous == null) {
continue;
}
pixelGaps.push(resolveXPx(current) - resolveXPx(previous));
}
const minGap = Math.min(...pixelGaps);
const maxGap = Math.max(...pixelGaps);
const meanGap =
pixelGaps.reduce((sum, gap) => sum + gap, 0) / pixelGaps.length;
const spreadRatio =
meanGap > 0 ? (maxGap - minGap) / meanGap : maxGap - minGap;
const countDistance = Math.abs(indices.length - targetCount);
const gaps = indexGaps(indices);
const smallestGap = Math.min(...gaps);
const smallestGapIndex = gaps.indexOf(smallestGap);
const interiorPenalty =
smallestGapIndex > 0 && smallestGapIndex < gaps.length - 1 ? 0.08 : 0;
const symmetryPenalty =
gaps.reduce((penalty, gap, index) => {
return penalty + Math.abs(gap - (gaps.at(-1 - index) ?? gap));
}, 0) / gaps.length;
return {
score:
spreadRatio +
0.1 * countDistance +
interiorPenalty +
symmetryPenalty * 0.02,
symmetryPenalty,
countDistance,
edgePreference: smallestGapEdgePreference(indices),
};
}
function isBetterTickLayout(
next: TickLayoutScore,
best: TickLayoutScore,
nextCountDistance: number,
bestCountDistance: number
): boolean {
if (next.score < best.score - 1e-6) {
return true;
}
if (Math.abs(next.score - best.score) > 1e-6) {
return false;
}
if (nextCountDistance < bestCountDistance) {
return true;
}
if (nextCountDistance > bestCountDistance) {
return false;
}
if (next.symmetryPenalty < best.symmetryPenalty - 1e-6) {
return true;
}
if (next.symmetryPenalty > best.symmetryPenalty + 1e-6) {
return false;
}
return next.edgePreference < best.edgePreference;
}
/**
* Picks tick indices with the most even on-screen spacing. Tries
* `targetCount ± 1` and evaluates every gap layout when feasible.
*/
export function selectEvenlySpacedIndices(
length: number,
targetCount: number,
options?: {
data?: Record<string, unknown>[];
dateLabels?: string[];
xAccessor?: (d: Record<string, unknown>) => Date;
resolveXPx?: (index: number) => number;
}
): number[] {
if (length <= 0) {
return [];
}
if (length === 1) {
return [0];
}
if (length <= targetCount) {
return Array.from({ length }, (_, index) => index);
}
const resolveXPx = options?.resolveXPx ?? ((index: number) => index);
const minCount = Math.max(2, targetCount - 1);
const maxCount = Math.min(length, targetCount + 1);
let bestIndices = indicesForTickCount(length, targetCount);
let bestScore = scoreTickLayout(bestIndices, resolveXPx, targetCount);
let bestCountDistance = bestScore.countDistance;
for (let tickCount = minCount; tickCount <= maxCount; tickCount++) {
for (const rawIndices of allIndexLayouts(length, tickCount)) {
const indices =
options?.data && options.dateLabels && options.xAccessor
? dedupeIndicesByLabel(
rawIndices,
options.data,
options.dateLabels,
options.xAccessor
)
: rawIndices;
if (indices.length < 2) {
continue;
}
const layoutScore = scoreTickLayout(indices, resolveXPx, targetCount);
const countDistance = Math.abs(indices.length - targetCount);
if (
isBetterTickLayout(
layoutScore,
bestScore,
countDistance,
bestCountDistance
)
) {
bestIndices = indices;
bestScore = layoutScore;
bestCountDistance = countDistance;
}
}
}
return bestIndices;
}
function buildDataAlignedTicks({
data,
dateLabels,
marginLeft,
targetTickCount,
xAccessor,
xScale,
}: {
data: Record<string, unknown>[];
dateLabels: string[];
marginLeft: number;
targetTickCount: number;
xAccessor: (d: Record<string, unknown>) => Date;
xScale: (date: Date) => number | undefined;
}): AxisTick[] {
const seenLabels = new Set<string>();
const ticks: AxisTick[] = [];
const resolveXPx = (index: number) => {
const point = data[index];
if (!point) {
return index;
}
return xScale(xAccessor(point)) ?? 0;
};
for (const index of selectEvenlySpacedIndices(data.length, targetTickCount, {
data,
dateLabels,
resolveXPx,
xAccessor,
})) {
const point = data[index];
if (!point) {
continue;
}
const date = xAccessor(point);
const label = dateLabels[index] ?? shortDateFmt.format(date);
if (seenLabels.has(label)) {
continue;
}
seenLabels.add(label);
ticks.push({
date,
label,
x: (xScale(date) ?? 0) + marginLeft,
});
}
return ticks;
}
function buildDomainTicks({
marginLeft,
numTicks,
xScale,
}: {
marginLeft: number;
numTicks: number;
xScale: {
domain: () => Date[];
(date: Date): number | undefined;
};
}): AxisTick[] {
const domain = xScale.domain();
const startDate = domain[0];
const endDate = domain[1];
if (!(startDate && endDate)) {
return [];
}
const startTime = startDate.getTime();
const endTime = endDate.getTime();
const timeRange = endTime - startTime;
const tickCount = Math.max(2, numTicks);
const seenLabels = new Set<string>();
const ticks: AxisTick[] = [];
for (let i = 0; i < tickCount; i++) {
const t = i / (tickCount - 1);
const date = new Date(startTime + t * timeRange);
const label = shortDateFmt.format(date);
if (seenLabels.has(label)) {
continue;
}
seenLabels.add(label);
ticks.push({
date,
label,
x: (xScale(date) ?? 0) + marginLeft,
});
}
return ticks;
}
export function XAxis(props: XAxisProps) {
const { containerRef } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return <XAxisInner {...props} container={container} />;
}
const XAxisInner = memo(function XAxisInner({
numTicks = 5,
tickerHalfWidth = 50,
tickMode = "domain",
container,
}: XAxisProps & { container: HTMLDivElement }) {
const { xScale, margin, tooltipData, data, xAccessor, dateLabels, xDomain } =
useChart();
const labelsToShow = useMemo(() => {
// Brush (any extent): snap ticks to data rows with even index spacing.
if (tickMode === "data" || xDomain != null) {
return buildDataAlignedTicks({
data,
dateLabels,
marginLeft: margin.left,
targetTickCount: numTicks,
xAccessor,
xScale,
});
}
return buildDomainTicks({
marginLeft: margin.left,
numTicks,
xScale,
});
}, [
tickMode,
xDomain,
data,
dateLabels,
xAccessor,
xScale,
margin.left,
numTicks,
]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
const hoveredLabel =
isHovering && tooltipData
? (dateLabels[tooltipData.index] ??
shortDateFmt.format(xAccessor(tooltipData.point)))
: null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => (
<XAxisLabel
animatePosition={xDomain == null}
crosshairX={crosshairX}
hoveredLabel={hoveredLabel}
isHovering={isHovering}
key={`${item.date.getTime()}-${item.x}`}
label={item.label}
tickerHalfWidth={tickerHalfWidth}
x={item.x}
/>
))}
</div>,
container
);
});
XAxis.displayName = "XAxis";
export default XAxis;
+115
View File
@@ -0,0 +1,115 @@
import { scaleLinear } from "@visx/scale";
import type { LineConfig } from "./chart-context";
/** Default axis id when `yAxisId` is omitted (Recharts-style `0` / primary left axis). */
export const DEFAULT_Y_AXIS_ID = "left";
export type YAxisOrientation = "left" | "right";
export function normalizeYAxisId(id?: string | number): string {
if (id == null || id === "") {
return DEFAULT_Y_AXIS_ID;
}
return String(id);
}
export function groupLinesByYAxisId(
lines: LineConfig[]
): Map<string, LineConfig[]> {
const groups = new Map<string, LineConfig[]>();
for (const line of lines) {
const axisId = normalizeYAxisId(line.yAxisId);
const bucket = groups.get(axisId) ?? [];
bucket.push(line);
groups.set(axisId, bucket);
}
return groups;
}
type YScale = ReturnType<typeof scaleLinear<number>>;
export function getPrimaryYScale(
yScales: Record<string, YScale>,
fallback: YScale
): YScale {
const primary = yScales[DEFAULT_Y_AXIS_ID];
if (primary) {
return primary;
}
const first = Object.values(yScales)[0];
return first ?? fallback;
}
export function buildYScalesForLines({
lines,
innerHeight,
resolveDomain,
}: {
lines: LineConfig[];
/** Passed by callers; domain is resolved via `resolveDomain`. */
data?: Record<string, unknown>[];
innerHeight: number;
resolveDomain: (dataKeys: string[]) => [number, number];
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId, axisLines] of groups) {
const dataKeys = axisLines.map((line) => line.dataKey);
const domain = resolveDomain(dataKeys);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
nice: true,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: [0, 100],
nice: true,
});
}
return scales;
}
/** Build y-scales from pre-computed (already nice'd) domain endpoints. */
export function buildYScalesFromDomains({
lines,
innerHeight,
domainsByAxis,
}: {
lines: LineConfig[];
innerHeight: number;
domainsByAxis: Record<string, [number, number]>;
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId] of groups) {
const domain =
domainsByAxis[axisId] ??
domainsByAxis[DEFAULT_Y_AXIS_ID] ??
([0, 100] as [number, number]);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: domainsByAxis[DEFAULT_Y_AXIS_ID] ?? [0, 100],
});
}
return scales;
}
/** Single-axis charts (bar, scatter, candlestick, live line). */
export function wrapSingleYScale(yScale: YScale): Record<string, YScale> {
return { [DEFAULT_Y_AXIS_ID]: yScale };
}
@@ -0,0 +1,25 @@
export const Y_AXIS_DEFAULT_TICK_COUNT = 5;
/** Minimum valid `numTicks` for `scale.ticks()` — values ≤ 0 yield no ticks. */
export const Y_AXIS_MIN_TICK_COUNT = 1;
/**
* Upper bound for the tick count hint. D3 may return more "nice" ticks above ~10;
* keeping the hint in a modest range avoids overcrowded axes.
*/
export const Y_AXIS_MAX_TICK_COUNT = 10;
/** Clamps a user `numTicks` value to a valid d3 tick-count hint. */
export function resolveYAxisTickCount(numTicks?: number): number {
if (numTicks == null || !Number.isFinite(numTicks)) {
return Y_AXIS_DEFAULT_TICK_COUNT;
}
const rounded = Math.round(numTicks);
if (rounded < Y_AXIS_MIN_TICK_COUNT) {
return Y_AXIS_MIN_TICK_COUNT;
}
if (rounded > Y_AXIS_MAX_TICK_COUNT) {
return Y_AXIS_MAX_TICK_COUNT;
}
return rounded;
}
@@ -0,0 +1,124 @@
import { scaleLinear } from "@visx/scale";
import type { LineConfig } from "./chart-context";
import { type ChartPhase, Y_DOMAIN_TWEEN_SKIP_THRESHOLD } from "./chart-phase";
import { groupLinesByYAxisId, normalizeYAxisId } from "./y-axis-scales";
export type YDomain = [number, number];
/** Apply visx `nice()` to raw domain endpoints for stable grid ticks. */
export function niceYDomain(domain: YDomain): YDomain {
const scale = scaleLinear({ domain, range: [0, 1], nice: true });
const niceDomain = scale.domain();
return [niceDomain[0] ?? domain[0], niceDomain[1] ?? domain[1]];
}
/**
* Skip Y tween when both endpoints move less than the threshold relative to span.
* When in doubt callers should tween — beauty wins over micro-optimization.
*/
export function shouldTweenYDomain(from: YDomain, to: YDomain): boolean {
const span = Math.max(
Math.abs(to[1] - to[0]),
Math.abs(from[1] - from[0]),
1
);
const deltaMin = Math.abs(to[0] - from[0]) / span;
const deltaMax = Math.abs(to[1] - from[1]) / span;
return (
deltaMin >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD ||
deltaMax >= Y_DOMAIN_TWEEN_SKIP_THRESHOLD
);
}
/** Phases where the chart shows loading chrome (shimmer, pulse, label). */
export function isLoadingChromePhase(phase: ChartPhase): boolean {
return phase === "loading" || phase === "revealingLoading";
}
/** Phases where grid lines use loading stroke styling (muted / dashed chrome). */
export function isLoadingGridChromePhase(phase: ChartPhase): boolean {
return (
phase === "loading" || phase === "exiting" || phase === "gridTweenLoading"
);
}
/** Phases where Y-domain tween runs after the series has exited. */
export function isYDomainTweenPhase(phase: ChartPhase): boolean {
return phase === "gridTweenLoading" || phase === "gridTweenReady";
}
export function resolveAnimatedYDestinationDomains(
chartPhase: ChartPhase,
skeletonByAxis: Record<string, YDomain>,
targetByAxis: Record<string, YDomain>
): Record<string, YDomain> {
switch (chartPhase) {
case "loading":
case "exiting":
case "gridTweenLoading":
return skeletonByAxis;
case "exitingReady":
case "gridTweenReady":
case "revealing":
case "ready":
return targetByAxis;
default:
return targetByAxis;
}
}
export function computeYDomainsByAxis({
lines,
resolveDomain,
}: {
lines: LineConfig[];
resolveDomain: (dataKeys: string[]) => YDomain;
}): Record<string, YDomain> {
const groups = groupLinesByYAxisId(lines);
const domains: Record<string, YDomain> = {};
for (const [axisId, axisLines] of groups) {
const dataKeys = axisLines.map((line) => line.dataKey);
domains[normalizeYAxisId(axisId)] = niceYDomain(resolveDomain(dataKeys));
}
if (!domains.left) {
domains.left = niceYDomain([0, 100]);
}
return domains;
}
/** Merge domain maps, normalizing axis ids to strings. */
export function mergeYDomainRecords(
...records: Record<string, YDomain>[]
): Record<string, YDomain> {
const merged: Record<string, YDomain> = {};
for (const record of records) {
for (const [axisId, domain] of Object.entries(record)) {
merged[normalizeYAxisId(axisId)] = domain;
}
}
return merged;
}
export function domainsEqual(
left: Record<string, YDomain>,
right: Record<string, YDomain>
): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const axisId of leftKeys) {
const from = left[axisId];
const to = right[axisId];
if (!(from && to) || from[0] !== to[0] || from[1] !== to[1]) {
return false;
}
}
return true;
}
+229 -50
View File
@@ -3,6 +3,7 @@
import { Check, FlaskConical, Plus, Search } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
useEffect,
useMemo,
@@ -24,7 +25,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
import {
type Lab,
type LabFlag,
type Patient,
appendLabs,
@@ -41,8 +45,18 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
low: "outline",
};
const flagVariant: Record<LabFlag, "secondary" | "warning" | "destructive"> = {
normal: "secondary",
low: "warning",
high: "warning",
critical: "destructive",
};
const LAB_FLAGS: LabFlag[] = ["normal", "low", "high", "critical"];
// A patient + one of their lab results, used by the "Recent results" feed.
type RecentResult = { patient: Patient; lab: Lab };
// Patient dates are stored as formatted strings (e.g. "Jun 02, 2026") — match
// the format used by the patient form.
const today = () =>
@@ -52,6 +66,23 @@ const today = () =>
year: "numeric",
});
// Parse a stored date string to a timestamp for sorting; unknown formats sort
// last (0).
const ts = (s: string): number => {
const t = Date.parse(s);
return Number.isNaN(t) ? 0 : t;
};
// Flatten every patient's labs into a recent-results feed, newest first.
function buildRecent(patients: Patient[]): RecentResult[] {
const all: RecentResult[] = [];
for (const patient of patients) {
for (const lab of patient.labs) all.push({ patient, lab });
}
all.sort((a, b) => ts(b.lab.takenAt) - ts(a.lab.takenAt));
return all.slice(0, 12);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
@@ -102,39 +133,70 @@ function AddResultDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
patients: Patient[];
onAdded: () => void;
onAdded: (patient: Patient, lab: Lab) => void;
}) {
const { t } = useTranslation();
const [patient, setPatient] = useState<Patient | null>(null);
const [patientQuery, setPatientQuery] = useState("");
// Highlighted index in the patient match list, for arrow-key navigation.
const [activeIndex, setActiveIndex] = useState(0);
const [name, setName] = useState("");
const [value, setValue] = useState("");
const [flag, setFlag] = useState<LabFlag>("normal");
const [takenAt, setTakenAt] = useState(today());
// Advanced mode reveals a free-form reference-range field for analyses that
// aren't in the catalog (the test field already accepts any text).
const [advanced, setAdvanced] = useState(false);
const [refRange, setRefRange] = useState("");
const [saving, setSaving] = useState(false);
const reset = () => {
setPatient(null);
setPatientQuery("");
setActiveIndex(0);
setName("");
setValue("");
setFlag("normal");
setTakenAt(today());
setAdvanced(false);
setRefRange("");
setSaving(false);
};
const search = patientQuery.trim().toLowerCase();
// Only surface patients once the user has typed something — never the full
// roster on an empty field.
const matches = useMemo(() => {
const base = search
? patients.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.fileNumber.includes(search),
)
: patients;
return base.slice(0, 6);
if (!search) return [];
return patients
.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.fileNumber.includes(search),
)
.slice(0, 6);
}, [patients, search]);
// Keyboard navigation for the patient list: ↑/↓ to move, Enter to select.
const onPatientKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (matches.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => Math.min(i + 1, matches.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (event.key === "Enter") {
// Don't submit the form — pick the highlighted patient instead.
event.preventDefault();
const picked = matches[activeIndex];
if (picked) setPatient(picked);
}
};
// Unit hint for the value field once a catalogued analysis is chosen.
const unitHint = LAB_ANALYSIS_UNITS[name.trim()];
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!patient) {
@@ -151,23 +213,27 @@ function AddResultDialog({
);
return;
}
const finalValue =
advanced && refRange.trim()
? `${value.trim()} (ref ${refRange.trim()})`
: value.trim();
const lab: Lab = {
name: name.trim(),
value: finalValue,
flag,
takenAt: takenAt.trim() || today(),
};
setSaving(true);
try {
await appendLabs(patient.fileNumber, [
{
name: name.trim(),
value: value.trim(),
flag,
takenAt: takenAt.trim() || today(),
},
]);
await appendLabs(patient.fileNumber, [lab]);
notify.success(
t("lab.addResult.addedTitle"),
t("lab.addResult.addedBody", { test: name.trim(), name: patient.name }),
t("lab.addResult.addedBody", { test: lab.name, name: patient.name }),
);
const added = patient;
reset();
onOpenChange(false);
onAdded();
onAdded(added, lab);
} catch {
setSaving(false);
notify.error(
@@ -223,51 +289,85 @@ function AddResultDialog({
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
aria-activedescendant={
matches[activeIndex]
? `patient-opt-${matches[activeIndex].fileNumber}`
: undefined
}
autoFocus
className="pl-9"
onChange={(event) => setPatientQuery(event.target.value)}
onChange={(event) => {
setPatientQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={onPatientKeyDown}
placeholder={t("lab.addResult.patientPlaceholder")}
value={patientQuery}
/>
</div>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{matches.map((p) => (
<button
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => setPatient(p)}
type="button"
>
<Avatar className="size-8">
<AvatarFallback>{p.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 truncate text-sm">{p.name}</span>
<span className="ms-auto text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))}
{matches.length === 0 && (
<p className="px-2 py-3 text-muted-foreground text-sm">
{t("lab.addResult.noPatients")}
</p>
)}
</div>
{search.length > 0 && (
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{matches.map((p, index) => (
<button
className={cn(
"flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors",
index === activeIndex
? "bg-accent"
: "hover:bg-accent",
)}
id={`patient-opt-${p.fileNumber}`}
key={p.fileNumber}
onClick={() => setPatient(p)}
onMouseMove={() => setActiveIndex(index)}
type="button"
>
<Avatar className="size-8">
<AvatarFallback>{p.initials}</AvatarFallback>
</Avatar>
<span className="min-w-0 truncate text-sm">
{p.name}
</span>
<span className="ms-auto text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))}
{matches.length === 0 && (
<p className="px-2 py-3 text-muted-foreground text-sm">
{t("lab.addResult.noPatients")}
</p>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={t("lab.addResult.test")}>
<Input
list="lab-analyses-list"
onChange={(event) => setName(event.target.value)}
placeholder={t("lab.addResult.testPlaceholder")}
value={name}
/>
<datalist id="lab-analyses-list">
{LAB_ANALYSES.map((a) => (
<option key={a.name} value={a.name}>
{a.group}
</option>
))}
</datalist>
</Field>
<Field label={t("lab.addResult.value")}>
<Input
onChange={(event) => setValue(event.target.value)}
placeholder={t("lab.addResult.valuePlaceholder")}
placeholder={
unitHint
? t("lab.addResult.valueUnitPlaceholder", {
unit: unitHint,
})
: t("lab.addResult.valuePlaceholder")
}
value={value}
/>
</Field>
@@ -293,6 +393,28 @@ function AddResultDialog({
/>
</Field>
</div>
<label className="flex items-center justify-between gap-3 rounded-2xl border bg-card/30 px-3 py-2">
<span className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{t("lab.addResult.advanced")}
</span>
<span className="text-muted-foreground text-xs">
{t("lab.addResult.advancedHint")}
</span>
</span>
<Switch checked={advanced} onCheckedChange={setAdvanced} />
</label>
{advanced && (
<Field label={t("lab.addResult.refRange")}>
<Input
onChange={(event) => setRefRange(event.target.value)}
placeholder={t("lab.addResult.refRangePlaceholder")}
value={refRange}
/>
</Field>
)}
</DialogPanel>
<DialogFooter>
@@ -309,12 +431,13 @@ function AddResultDialog({
);
}
// The lab department home: the lab's task queue plus the "add result" flow
// for submitting a patient's analyses to their record.
// The lab department home: the lab's task queue, an "add result" flow for
// submitting a patient's analyses, and a feed of recently recorded results.
export function LabView() {
const { t } = useTranslation();
const [tasks, setTasks] = useState<Task[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [recent, setRecent] = useState<RecentResult[]>([]);
const [addOpen, setAddOpen] = useState(false);
useEffect(() => {
@@ -328,7 +451,10 @@ export function LabView() {
});
listPatients()
.then((data) => {
if (active) setPatients(data);
if (active) {
setPatients(data);
setRecent(buildRecent(data));
}
})
.catch(() => {
/* same */
@@ -345,6 +471,17 @@ export function LabView() {
[tasks],
);
// After a result is submitted: show it instantly at the top of the feed, and
// refresh patient records in the background so it survives a reload.
const handleAdded = (patient: Patient, lab: Lab) => {
setRecent((prev) => [{ patient, lab }, ...prev].slice(0, 12));
listPatients()
.then((data) => setPatients(data))
.catch(() => {
/* keep the optimistic feed if the refresh fails */
});
};
// Optimistically flip done, then persist; roll back on failure.
const toggle = async (id: string) => {
const current = tasks.find((task) => task.id === id);
@@ -442,10 +579,52 @@ export function LabView() {
</div>
</section>
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">
{t("lab.recent.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("lab.recent.description")}
</p>
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{recent.length === 0 ? (
<p className="p-6 text-center text-muted-foreground text-sm">
{t("lab.recent.empty")}
</p>
) : (
recent.map(({ patient, lab }, index) => (
<div
className="flex items-center gap-3 px-4 py-3"
key={`${patient.fileNumber}-${lab.name}-${lab.takenAt}-${index}`}
>
<Avatar className="size-8">
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{lab.name}
<span className="font-normal text-muted-foreground">
{" "}
· {lab.value}
</span>
</span>
<span className="truncate text-muted-foreground text-xs">
{patient.name} · #{patient.fileNumber} · {lab.takenAt}
</span>
</div>
<Badge className="shrink-0" variant={flagVariant[lab.flag]}>
{t(`patientCard.labFlag.${lab.flag}`)}
</Badge>
</div>
))
)}
</div>
</section>
<AddResultDialog
onAdded={() => {
/* the patient record is reloaded on next lookup; nothing to refresh here */
}}
onAdded={handleAdded}
onOpenChange={setAddOpen}
open={addOpen}
patients={patients}
@@ -0,0 +1,182 @@
"use client";
import { AlertTriangle, Boxes, PackageX, Pill, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
type Availability,
type InventoryItem,
availabilityOf,
listInventory,
} from "@/lib/inventory";
const availabilityVariant: Record<
Availability,
"success" | "warning" | "destructive"
> = {
"in-stock": "success",
low: "warning",
out: "destructive",
};
function Kpi({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: typeof Pill;
}) {
return (
<Card className="flex-row items-center gap-3 p-4">
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-muted-foreground text-xs">{label}</span>
<span className="font-semibold text-foreground text-lg tracking-tight">
{value}
</span>
</div>
</Card>
);
}
function ItemRow({ item }: { item: InventoryItem }) {
const { t } = useTranslation();
const availability = availabilityOf(item);
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
return (
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
<Pill className="size-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{item.name}
{descriptor && (
<span className="font-normal text-muted-foreground">
{" "}
· {descriptor}
</span>
)}
</span>
<span className="truncate text-muted-foreground text-xs">
{t("inventory.stock", {
count: item.stockQuantity,
unit: item.unit || "units",
})}
{item.reorderThreshold > 0 &&
` · ${t("inventory.reorderAt", { count: item.reorderThreshold })}`}
{item.location && ` · ${t("inventory.location", { location: item.location })}`}
</span>
</div>
<Badge className="shrink-0" variant={availabilityVariant[availability]}>
{t(`inventory.availability.${availability}`)}
</Badge>
</div>
);
}
// The pharmacy inventory page: a searchable view of the clinic's medication
// stock with at-a-glance availability. Pharmacy holds inventory read/write;
// this view is read/search only (stock edits are a later step).
export function InventoryView() {
const { t } = useTranslation();
const [items, setItems] = useState<InventoryItem[]>([]);
const [query, setQuery] = useState("");
useEffect(() => {
let active = true;
listInventory()
.then((data) => {
if (active) setItems(data);
})
.catch(() => {
/* api-client redirects on 401; otherwise leave the list empty */
});
return () => {
active = false;
};
}, []);
const search = query.trim().toLowerCase();
const results = useMemo(() => {
const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
if (!search) return sorted;
return sorted.filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.form.toLowerCase().includes(search) ||
item.strength.toLowerCase().includes(search) ||
item.location.toLowerCase().includes(search),
);
}, [items, search]);
const lowCount = items.filter((i) => availabilityOf(i) === "low").length;
const outCount = items.filter((i) => availabilityOf(i) === "out").length;
const kpis = [
{ label: t("inventory.kpi.total"), value: String(items.length), icon: Boxes },
{ label: t("inventory.kpi.low"), value: String(lowCount), icon: AlertTriangle },
{ label: t("inventory.kpi.out"), value: String(outCount), icon: PackageX },
];
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">
{t("inventory.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("inventory.subtitle")}
</p>
</div>
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("inventory.searchPlaceholder")}
value={query}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{kpis.map((k) => (
<Kpi key={k.label} {...k} />
))}
</div>
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">
{t("inventory.list.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("inventory.list.description")}
</p>
</div>
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{results.map((item) => (
<ItemRow item={item} key={item.id} />
))}
{results.length === 0 && (
<p className="p-6 text-center text-muted-foreground text-sm">
{search
? t("inventory.list.noMatches")
: t("inventory.list.empty")}
</p>
)}
</div>
</section>
</div>
);
}
@@ -155,12 +155,17 @@ export function PharmacyView() {
}, []);
const now = new Date();
// Start of today, so a course ending today still counts as expiring (not
// already elapsed by a few hours).
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const soon = new Date(now);
soon.setDate(soon.getDate() + 7);
const isExpiringSoon = (rx: Prescription) => {
if (rx.status !== "active") return false;
const end = expiresAt(rx);
return end != null && end <= soon;
// Only courses ending in the next 7 days — not ones that already elapsed.
return end != null && end >= startOfToday && end <= soon;
};
const active = useMemo(
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { type ComponentProps, useCallback } from "react";
import { cn } from "@/lib/utils";
export type ShimmeringTextProps = Omit<
ComponentProps<typeof motion.span>,
"children"
> & {
/** The text to render with the shimmering effect. */
text: string;
/**
* Duration in seconds for one shimmer cycle.
* @defaultValue 1
*/
duration?: number;
/**
* Whether the shimmer animation is paused.
* @defaultValue false
*/
isStopped?: boolean;
};
export function ShimmeringText({
text,
duration = 1,
isStopped = false,
className,
...props
}: ShimmeringTextProps) {
const reducedMotion = useReducedMotion();
const stopped = isStopped || reducedMotion === true;
const createCharVariants = useCallback(
(charIndex: number): Variants => ({
running: {
color: ["var(--color)", "var(--shimmering-color)", "var(--color)"],
transition: {
duration,
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
repeatDelay: text.length * 0.05,
delay: (charIndex * duration) / text.length,
ease: "easeInOut",
},
},
stopped: {
color: "var(--color)",
transition: {
duration: duration * 0.5,
ease: "easeOut",
},
},
}),
[duration, text.length]
);
return (
<motion.span
className={cn(
"inline-flex select-none items-center leading-none",
"[--color:var(--muted-foreground)] [--shimmering-color:var(--foreground)]",
className
)}
{...props}
>
{text.split("").map((char, index) => (
<motion.span
animate={stopped ? "stopped" : "running"}
aria-hidden
className="inline-block whitespace-pre leading-none"
initial="stopped"
// biome-ignore lint/suspicious/noArrayIndexKey: static label text, order never changes
key={index}
variants={createCharVariants(index)}
>
{char}
</motion.span>
))}
<span className="sr-only">{text}</span>
</motion.span>
);
}
+6
View File
@@ -13,6 +13,7 @@ export const statements = {
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
} as const;
@@ -27,6 +28,7 @@ export const owner = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -36,6 +38,7 @@ export const admin = ac.newRole({
patient: ["read", "write", "delete"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -45,6 +48,7 @@ export const member = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -56,6 +60,7 @@ export const doctor = ac.newRole({
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
inventory: ["read", "write", "delete"],
task: ["read", "write", "delete"],
lab: ["read", "write"],
});
@@ -75,6 +80,7 @@ export const pharmacy = ac.newRole({
patient: ["read"],
appointment: ["read"],
prescription: ["read", "write"],
inventory: ["read", "write"],
task: ["read", "write"],
});
+41 -1
View File
@@ -115,6 +115,7 @@
"prescriptions": "Prescriptions",
"analysis": "Analysis",
"pharmacy": "Pharmacy",
"inventory": "Inventory",
"lab": "Lab",
"notes": "Notes",
"messages": "Messages",
@@ -338,6 +339,30 @@
"completeFailedTitle": "Couldn't update prescription",
"completeFailedBody": "Please try again."
},
"inventory": {
"title": "Inventory",
"subtitle": "Search the pharmacy's medication stock and check availability.",
"searchPlaceholder": "Search medications",
"kpi": {
"total": "Medications in stock",
"low": "Low stock",
"out": "Out of stock"
},
"list": {
"title": "Medications",
"description": "Current stock levels across the pharmacy. Items at or below their reorder point are flagged.",
"empty": "No medications in inventory yet.",
"noMatches": "No medications match your search."
},
"stock": "{{count}} {{unit}}",
"reorderAt": "Reorder at {{count}}",
"location": "Shelf {{location}}",
"availability": {
"in-stock": "In stock",
"low": "Low stock",
"out": "Out of stock"
}
},
"lab": {
"title": "Lab",
"subtitle": "The lab's work queue and analysis results.",
@@ -358,8 +383,13 @@
"testPlaceholder": "e.g. Hemoglobin",
"value": "Value",
"valuePlaceholder": "e.g. 14.2 g/dL",
"valueUnitPlaceholder": "e.g. value in {{unit}}",
"flag": "Flag",
"takenAt": "Date",
"advanced": "Advanced",
"advancedHint": "Record a custom analysis with a reference range.",
"refRange": "Reference range",
"refRangePlaceholder": "e.g. 13.017.0 g/dL",
"cancel": "Cancel",
"submit": "Add result",
"needPatientTitle": "Pick a patient",
@@ -371,6 +401,11 @@
"failedTitle": "Couldn't add the result",
"failedBody": "Please try again."
},
"recent": {
"title": "Recent results",
"description": "Analyses recorded on patient records, newest first.",
"empty": "No results recorded yet."
},
"toast": {
"updateFailedTitle": "Couldn't update the task",
"updateFailedBody": "Please try again."
@@ -472,6 +507,11 @@
"analysis": {
"title": "Analysis",
"subtitle": "Clinic performance at a glance, computed from your clinic's data.",
"live": {
"title": "Live",
"subtitle": "Patients currently in the building, updating in real time.",
"label": "In the building now"
},
"patientVolume": {
"title": "Patient volume",
"description": "New, active and total patients",
@@ -501,7 +541,7 @@
},
"charts": {
"title": "Trends",
"subtitle": "Tap a card for the full breakdown.",
"subtitle": "Patient growth and weekly appointment volume.",
"viewDetails": "Tap for details",
"patientGrowthTitle": "New patients",
"patientGrowthDescription": "New patients per month over the last 6 months",
+66
View File
@@ -0,0 +1,66 @@
import { apiFetch } from "@/lib/api-client";
// A pharmacy stock item. Mirrors the backend `src/types/inventory.ts`. Scoped to
// the active clinic. `expiresAt` is an ISO YYYY-MM-DD date (or null).
export type InventoryItem = {
id: string;
name: string;
form: string;
strength: string;
unit: string;
stockQuantity: number;
reorderThreshold: number;
location: string;
expiresAt: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
};
// The fields the inventory dialog collects.
export type InventoryInput = {
name: string;
form?: string;
strength?: string;
unit?: string;
stockQuantity?: number;
reorderThreshold?: number;
location?: string;
expiresAt?: string | null;
notes?: string | null;
};
export type Availability = "in-stock" | "low" | "out";
// Derived stock status: out at zero, low at/under the reorder threshold,
// otherwise in stock. Computed on the client (not persisted).
export function availabilityOf(item: InventoryItem): Availability {
if (item.stockQuantity <= 0) return "out";
if (item.stockQuantity <= item.reorderThreshold) return "low";
return "in-stock";
}
export function listInventory(): Promise<InventoryItem[]> {
return apiFetch<InventoryItem[]>("/api/inventory");
}
export function createInventory(input: InventoryInput): Promise<InventoryItem> {
return apiFetch<InventoryItem>("/api/inventory", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateInventory(
id: string,
input: InventoryInput,
): Promise<InventoryItem> {
return apiFetch<InventoryItem>(`/api/inventory/${id}`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function deleteInventory(id: string): Promise<void> {
return apiFetch<void>(`/api/inventory/${id}`, { method: "DELETE" });
}
+65
View File
@@ -0,0 +1,65 @@
// A curated catalog of common laboratory analyses, grouped by panel, used to
// power the "Test" combobox in the lab add-result dialog. Users can still type
// any analysis not listed here (the field accepts free text) — this is just a
// fast-path for the routine ones, with typical reporting units as hints.
//
// Compiled from common clinical lab panels (CBC, BMP/CMP, lipid, thyroid,
// coagulation, inflammatory markers, iron studies, urinalysis). Sources:
// HealthLabs, LevelUpRN, Fullscript, Frederick Health.
export type LabAnalysis = { name: string; unit?: string; group: string };
export const LAB_ANALYSES: LabAnalysis[] = [
// Complete Blood Count (CBC)
{ name: "Hemoglobin", unit: "g/dL", group: "Complete Blood Count" },
{ name: "Hematocrit", unit: "%", group: "Complete Blood Count" },
{ name: "White Blood Cell Count", unit: "10³/µL", group: "Complete Blood Count" },
{ name: "Red Blood Cell Count", unit: "10⁶/µL", group: "Complete Blood Count" },
{ name: "Platelet Count", unit: "10³/µL", group: "Complete Blood Count" },
{ name: "Mean Corpuscular Volume (MCV)", unit: "fL", group: "Complete Blood Count" },
// Basic / Comprehensive Metabolic Panel
{ name: "Sodium", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Potassium", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Chloride", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Bicarbonate (CO₂)", unit: "mmol/L", group: "Metabolic Panel" },
{ name: "Blood Urea Nitrogen (BUN)", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Creatinine", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Glucose", unit: "mg/dL", group: "Metabolic Panel" },
{ name: "Calcium", unit: "mg/dL", group: "Metabolic Panel" },
// Liver Function Tests
{ name: "Alanine Aminotransferase (ALT)", unit: "U/L", group: "Liver Function" },
{ name: "Aspartate Aminotransferase (AST)", unit: "U/L", group: "Liver Function" },
{ name: "Alkaline Phosphatase (ALP)", unit: "U/L", group: "Liver Function" },
{ name: "Total Bilirubin", unit: "mg/dL", group: "Liver Function" },
{ name: "Albumin", unit: "g/dL", group: "Liver Function" },
{ name: "Total Protein", unit: "g/dL", group: "Liver Function" },
// Lipid Panel
{ name: "Total Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "LDL Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "HDL Cholesterol", unit: "mg/dL", group: "Lipid Panel" },
{ name: "Triglycerides", unit: "mg/dL", group: "Lipid Panel" },
// Thyroid
{ name: "Thyroid Stimulating Hormone (TSH)", unit: "mIU/L", group: "Thyroid" },
{ name: "Free T4", unit: "ng/dL", group: "Thyroid" },
{ name: "Free T3", unit: "pg/mL", group: "Thyroid" },
// Diabetes / Inflammatory
{ name: "Hemoglobin A1c", unit: "%", group: "Diabetes & Inflammation" },
{ name: "C-Reactive Protein (CRP)", unit: "mg/L", group: "Diabetes & Inflammation" },
{ name: "Erythrocyte Sedimentation Rate (ESR)", unit: "mm/hr", group: "Diabetes & Inflammation" },
// Coagulation
{ name: "Prothrombin Time (PT)", unit: "s", group: "Coagulation" },
{ name: "International Normalized Ratio (INR)", group: "Coagulation" },
{ name: "Partial Thromboplastin Time (PTT)", unit: "s", group: "Coagulation" },
// Iron / Vitamins
{ name: "Ferritin", unit: "ng/mL", group: "Iron & Vitamins" },
{ name: "Iron", unit: "µg/dL", group: "Iron & Vitamins" },
{ name: "Vitamin D, 25-Hydroxy", unit: "ng/mL", group: "Iron & Vitamins" },
{ name: "Vitamin B12", unit: "pg/mL", group: "Iron & Vitamins" },
// Urinalysis
{ name: "Urinalysis", group: "Urinalysis" },
{ name: "Urine Protein", unit: "mg/dL", group: "Urinalysis" },
];
// Quick lookup of an analysis's unit by name (for prefilling the value hint).
export const LAB_ANALYSIS_UNITS: Record<string, string> = Object.fromEntries(
LAB_ANALYSES.filter((a) => a.unit).map((a) => [a.name, a.unit as string]),
);
+14
View File
@@ -1,5 +1,6 @@
import {
BarChart3,
Boxes,
CalendarClock,
Cross,
FlaskConical,
@@ -90,6 +91,19 @@ export const navItems: NavItem[] = [
icon: Cross,
link: "/pharmacy",
access: "pharmacy",
subs: [
{
id: "pharmacy-dispensing",
labelKey: "nav.pharmacy",
link: "/pharmacy",
},
{
id: "inventory",
labelKey: "nav.inventory",
icon: Boxes,
link: "/inventory",
},
],
},
{
id: "lab",
+1
View File
@@ -142,6 +142,7 @@ const ROUTE_AREAS: Record<string, AccessArea> = {
"/notes": "clinical",
"/activity": "clinical",
"/pharmacy": "pharmacy",
"/inventory": "pharmacy",
"/lab": "lab",
};
+319
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.5.0",
"@number-flow/react": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.28.6",
"@streamdown/cjk": "^1.0.3",
@@ -20,6 +21,14 @@
"@tiptap/pm": "^3.25.0",
"@tiptap/react": "^3.25.0",
"@tiptap/starter-kit": "^3.25.0",
"@visx/curve": "^4.0.1-alpha.0",
"@visx/event": "^4.0.1-alpha.0",
"@visx/gradient": "^4.0.1-alpha.0",
"@visx/grid": "^4.0.1-alpha.0",
"@visx/pattern": "^4.0.1-alpha.0",
"@visx/responsive": "^4.0.1-alpha.0",
"@visx/scale": "^4.0.1-alpha.0",
"@visx/shape": "^4.0.1-alpha.0",
"@xyflow/react": "^12.10.2",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
@@ -27,6 +36,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-array": "^3.2.4",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.40.0",
"i18next": "^26.3.1",
@@ -2213,6 +2223,20 @@
"node": ">=12.4.0"
}
},
"node_modules/@number-flow/react": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.0.tgz",
"integrity": "sha512-77Yfc9+zkV2UDSP8phhZzxJGuwxi/Tt1TikmipL+1r3e9GFKEYDZ1XwInj67NoSt3OnOB0KLvvcl3lfPZgBHVQ==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4",
"number-flow": "0.6.0"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@open-draft/deferred-promise": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz",
@@ -4132,6 +4156,12 @@
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -4854,6 +4884,268 @@
"node": ">= 20"
}
},
"node_modules/@visx/curve": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz",
"integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==",
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/event": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz",
"integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/point": "4.0.1-alpha.0"
}
},
"node_modules/@visx/gradient": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/gradient/-/gradient-4.0.1-alpha.0.tgz",
"integrity": "sha512-HxkxLdThV/gPaulw+t7/ESo7AtqIBq9IlHIkHi7lQqXe0Yl+x8rpM3KYlYqEZjtihD9CeEaVJjBbdY7fQb54IA==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/grid": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz",
"integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/point": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/group": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz",
"integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/pattern": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/pattern/-/pattern-4.0.1-alpha.0.tgz",
"integrity": "sha512-KCqHpjtHdu95uqamc5PIYG55Xp0w+7/Zz63k4eeZErwK1o0PN2rZiBiuodNtENL8NqIU2b5AP+XI/xXaUH0M4Q==",
"license": "MIT",
"dependencies": {
"@types/react": "*",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/point": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz",
"integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==",
"license": "MIT"
},
"node_modules/@visx/responsive": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz",
"integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==",
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/scale": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz",
"integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==",
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/shape": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz",
"integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==",
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/vendor": "4.0.0-alpha.0",
"classnames": "^2.3.1",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/vendor": {
"version": "4.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz",
"integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==",
"license": "MIT and ISC",
"dependencies": {
"@types/d3-array": "3.0.3",
"@types/d3-color": "3.1.0",
"@types/d3-delaunay": "6.0.1",
"@types/d3-format": "3.0.1",
"@types/d3-geo": "3.1.0",
"@types/d3-interpolate": "3.0.1",
"@types/d3-path": "3.1.1",
"@types/d3-scale": "4.0.2",
"@types/d3-shape": "3.1.7",
"@types/d3-time": "3.0.0",
"@types/d3-time-format": "2.1.0",
"d3-array": "3.2.1",
"d3-color": "3.1.0",
"d3-delaunay": "6.0.2",
"d3-format": "3.1.0",
"d3-geo": "3.1.0",
"d3-interpolate": "3.0.1",
"d3-path": "3.1.0",
"d3-scale": "4.0.2",
"d3-shape": "3.2.0",
"d3-time": "3.1.0",
"d3-time-format": "4.1.0",
"internmap": "2.0.3"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-time-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-delaunay": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
@@ -5763,6 +6055,12 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -7617,6 +7915,12 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -10182,6 +10486,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -11807,6 +12117,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/number-flow": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.0.tgz",
"integrity": "sha512-K8flNq2Wqus53vjp/btVo3qXFkagF8dIdYavreBfE7hlvFFG/b1HMGEH6nZL+mlrJ+4lbLP9OmPv3t2rmRkpSQ==",
"license": "MIT",
"dependencies": {
"esm-env": "^1.1.4"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+10
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@number-flow/react": "^0.6.0",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.28.6",
"@streamdown/cjk": "^1.0.3",
@@ -21,6 +22,14 @@
"@tiptap/pm": "^3.25.0",
"@tiptap/react": "^3.25.0",
"@tiptap/starter-kit": "^3.25.0",
"@visx/curve": "^4.0.1-alpha.0",
"@visx/event": "^4.0.1-alpha.0",
"@visx/gradient": "^4.0.1-alpha.0",
"@visx/grid": "^4.0.1-alpha.0",
"@visx/pattern": "^4.0.1-alpha.0",
"@visx/responsive": "^4.0.1-alpha.0",
"@visx/scale": "^4.0.1-alpha.0",
"@visx/shape": "^4.0.1-alpha.0",
"@xyflow/react": "^12.10.2",
"ai": "^6.0.193",
"ansi-to-react": "^6.2.6",
@@ -28,6 +37,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-array": "^3.2.4",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.40.0",
"i18next": "^26.3.1",