mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
/** 0–1: 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (0–1). `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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
/** Nice’d y-domains per axis from skeleton data (placeholder). */
|
||||
yDomainSkeletonByAxis: Record<string, YDomain>;
|
||||
/** Nice’d 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 (0–1). 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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.0–17.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",
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -142,6 +142,7 @@ const ROUTE_AREAS: Record<string, AccessArea> = {
|
||||
"/notes": "clinical",
|
||||
"/activity": "clinical",
|
||||
"/pharmacy": "pharmacy",
|
||||
"/inventory": "pharmacy",
|
||||
"/lab": "lab",
|
||||
};
|
||||
|
||||
|
||||
Generated
+319
@@ -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,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",
|
||||
|
||||
Reference in New Issue
Block a user