From 321a6298a49972ebb18eae98e6ce5ca2fdd19bff Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 12 Jun 2026 19:22:07 +0300 Subject: [PATCH] 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 --- frontend/app/(app)/inventory/page.tsx | 10 + frontend/app/globals.css | 43 ++ frontend/components.json | 3 +- .../components/analysis/analysis-view.tsx | 112 ++- .../analysis/live-hospital-chart.tsx | 67 ++ frontend/components/charts/animation.ts | 36 + .../components/charts/area-chart-loading.tsx | 110 +++ frontend/components/charts/area-chart.tsx | 272 +++++++ .../components/charts/area-gradient-defs.tsx | 113 +++ frontend/components/charts/area.tsx | 351 +++++++++ frontend/components/charts/bar-chart.tsx | 677 ++++++++++++++++++ .../components/charts/bar-depth-geometry.ts | 34 + frontend/components/charts/bar-x-axis.tsx | 153 ++++ frontend/components/charts/bar-y-axis.tsx | 142 ++++ frontend/components/charts/bar.tsx | 466 ++++++++++++ .../charts/chart-child-passthrough.ts | 23 + .../charts/chart-config-context.tsx | 92 +++ frontend/components/charts/chart-context.tsx | 419 +++++++++++ frontend/components/charts/chart-defs.ts | 72 ++ .../components/charts/chart-formatters.ts | 20 + .../components/charts/chart-legend-hover.tsx | 44 ++ .../components/charts/chart-loading-label.tsx | 56 ++ frontend/components/charts/chart-phase.ts | 48 ++ .../components/charts/chart-reveal-clip.tsx | 92 +++ .../components/charts/dash-tail-stroke.tsx | 75 ++ .../components/charts/decimate-time-series.ts | 136 ++++ frontend/components/charts/fade-edges.ts | 52 ++ .../charts/filter-data-by-x-domain.ts | 43 ++ .../charts/generate-chart-skeleton-data.ts | 42 ++ frontend/components/charts/grid.tsx | 316 ++++++++ .../charts/highlight-segment-bounds.ts | 71 ++ .../components/charts/highlight-segment.tsx | 65 ++ frontend/components/charts/indicator-fade.ts | 64 ++ .../components/charts/line-loading-pulse.tsx | 218 ++++++ .../components/charts/line-loading-timing.ts | 12 + .../components/charts/live-line-chart.tsx | 657 +++++++++++++++++ frontend/components/charts/live-line.tsx | 322 +++++++++ frontend/components/charts/live-x-axis.tsx | 151 ++++ frontend/components/charts/live-y-axis.tsx | 229 ++++++ frontend/components/charts/motion-utils.ts | 58 ++ .../components/charts/path-stroke-utils.ts | 88 +++ frontend/components/charts/pattern-area.tsx | 49 ++ .../components/charts/series-bar-layout.ts | 61 ++ .../charts/series-dash-tail-overlay.tsx | 82 +++ .../charts/series-highlight-layer.tsx | 49 ++ .../components/charts/series-hover-dim.tsx | 60 ++ frontend/components/charts/series-markers.tsx | 294 ++++++++ .../components/charts/series-point-marker.tsx | 209 ++++++ .../charts/static-chart-preview-context.tsx | 22 + .../charts/time-series-chart-shell.tsx | 627 ++++++++++++++++ .../charts/tooltip/chart-tooltip.tsx | 389 ++++++++++ .../components/charts/tooltip/date-ticker.tsx | 150 ++++ frontend/components/charts/tooltip/index.ts | 14 + .../components/charts/tooltip/tooltip-box.tsx | 195 +++++ .../charts/tooltip/tooltip-content.tsx | 62 ++ .../components/charts/tooltip/tooltip-dot.tsx | 73 ++ .../charts/tooltip/tooltip-indicator.tsx | 211 ++++++ .../charts/use-animated-y-domains.ts | 242 +++++++ .../charts/use-chart-interaction.ts | 353 +++++++++ .../charts/use-chart-phase-orchestrator.ts | 183 +++++ .../components/charts/use-enter-complete.ts | 28 + .../components/charts/use-grid-shimmer.ts | 111 +++ .../charts/use-highlight-segment.ts | 61 ++ .../components/charts/use-mount-progress.ts | 29 + .../charts/use-scheduled-tooltip.ts | 97 +++ frontend/components/charts/x-axis.tsx | 561 +++++++++++++++ frontend/components/charts/y-axis-scales.ts | 115 +++ frontend/components/charts/y-axis-ticks.ts | 25 + frontend/components/charts/y-domain-utils.ts | 124 ++++ frontend/components/lab/lab-view.tsx | 279 ++++++-- .../components/pharmacy/inventory-view.tsx | 182 +++++ .../components/pharmacy/pharmacy-view.tsx | 7 +- frontend/components/shimmering-text.tsx | 84 +++ frontend/lib/access.ts | 6 + frontend/lib/i18n/locales/en/translation.json | 42 +- frontend/lib/inventory.ts | 66 ++ frontend/lib/lab-analyses.ts | 65 ++ frontend/lib/nav.ts | 14 + frontend/lib/roles.ts | 1 + frontend/package-lock.json | 319 +++++++++ frontend/package.json | 10 + 81 files changed, 11538 insertions(+), 67 deletions(-) create mode 100644 frontend/app/(app)/inventory/page.tsx create mode 100644 frontend/components/analysis/live-hospital-chart.tsx create mode 100644 frontend/components/charts/animation.ts create mode 100644 frontend/components/charts/area-chart-loading.tsx create mode 100644 frontend/components/charts/area-chart.tsx create mode 100644 frontend/components/charts/area-gradient-defs.tsx create mode 100644 frontend/components/charts/area.tsx create mode 100644 frontend/components/charts/bar-chart.tsx create mode 100644 frontend/components/charts/bar-depth-geometry.ts create mode 100644 frontend/components/charts/bar-x-axis.tsx create mode 100644 frontend/components/charts/bar-y-axis.tsx create mode 100644 frontend/components/charts/bar.tsx create mode 100644 frontend/components/charts/chart-child-passthrough.ts create mode 100644 frontend/components/charts/chart-config-context.tsx create mode 100644 frontend/components/charts/chart-context.tsx create mode 100644 frontend/components/charts/chart-defs.ts create mode 100644 frontend/components/charts/chart-formatters.ts create mode 100644 frontend/components/charts/chart-legend-hover.tsx create mode 100644 frontend/components/charts/chart-loading-label.tsx create mode 100644 frontend/components/charts/chart-phase.ts create mode 100644 frontend/components/charts/chart-reveal-clip.tsx create mode 100644 frontend/components/charts/dash-tail-stroke.tsx create mode 100644 frontend/components/charts/decimate-time-series.ts create mode 100644 frontend/components/charts/fade-edges.ts create mode 100644 frontend/components/charts/filter-data-by-x-domain.ts create mode 100644 frontend/components/charts/generate-chart-skeleton-data.ts create mode 100644 frontend/components/charts/grid.tsx create mode 100644 frontend/components/charts/highlight-segment-bounds.ts create mode 100644 frontend/components/charts/highlight-segment.tsx create mode 100644 frontend/components/charts/indicator-fade.ts create mode 100644 frontend/components/charts/line-loading-pulse.tsx create mode 100644 frontend/components/charts/line-loading-timing.ts create mode 100644 frontend/components/charts/live-line-chart.tsx create mode 100644 frontend/components/charts/live-line.tsx create mode 100644 frontend/components/charts/live-x-axis.tsx create mode 100644 frontend/components/charts/live-y-axis.tsx create mode 100644 frontend/components/charts/motion-utils.ts create mode 100644 frontend/components/charts/path-stroke-utils.ts create mode 100644 frontend/components/charts/pattern-area.tsx create mode 100644 frontend/components/charts/series-bar-layout.ts create mode 100644 frontend/components/charts/series-dash-tail-overlay.tsx create mode 100644 frontend/components/charts/series-highlight-layer.tsx create mode 100644 frontend/components/charts/series-hover-dim.tsx create mode 100644 frontend/components/charts/series-markers.tsx create mode 100644 frontend/components/charts/series-point-marker.tsx create mode 100644 frontend/components/charts/static-chart-preview-context.tsx create mode 100644 frontend/components/charts/time-series-chart-shell.tsx create mode 100644 frontend/components/charts/tooltip/chart-tooltip.tsx create mode 100644 frontend/components/charts/tooltip/date-ticker.tsx create mode 100644 frontend/components/charts/tooltip/index.ts create mode 100644 frontend/components/charts/tooltip/tooltip-box.tsx create mode 100644 frontend/components/charts/tooltip/tooltip-content.tsx create mode 100644 frontend/components/charts/tooltip/tooltip-dot.tsx create mode 100644 frontend/components/charts/tooltip/tooltip-indicator.tsx create mode 100644 frontend/components/charts/use-animated-y-domains.ts create mode 100644 frontend/components/charts/use-chart-interaction.ts create mode 100644 frontend/components/charts/use-chart-phase-orchestrator.ts create mode 100644 frontend/components/charts/use-enter-complete.ts create mode 100644 frontend/components/charts/use-grid-shimmer.ts create mode 100644 frontend/components/charts/use-highlight-segment.ts create mode 100644 frontend/components/charts/use-mount-progress.ts create mode 100644 frontend/components/charts/use-scheduled-tooltip.ts create mode 100644 frontend/components/charts/x-axis.tsx create mode 100644 frontend/components/charts/y-axis-scales.ts create mode 100644 frontend/components/charts/y-axis-ticks.ts create mode 100644 frontend/components/charts/y-domain-utils.ts create mode 100644 frontend/components/pharmacy/inventory-view.tsx create mode 100644 frontend/components/shimmering-text.tsx create mode 100644 frontend/lib/inventory.ts create mode 100644 frontend/lib/lab-analyses.ts diff --git a/frontend/app/(app)/inventory/page.tsx b/frontend/app/(app)/inventory/page.tsx new file mode 100644 index 0000000..1ee75fb --- /dev/null +++ b/frontend/app/(app)/inventory/page.tsx @@ -0,0 +1,10 @@ +import { InventoryView } from "@/components/pharmacy/inventory-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function InventoryPage() { + return ( + + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 82ec521..4cecfdd 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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 { diff --git a/frontend/components.json b/frontend/components.json index 9521df1..e73582c 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -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" } } diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index 8f7499e..a502141 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -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 ( + +
+ {title} + + {total} + +
+ {children} +
+ ); +} 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 (
@@ -85,6 +143,23 @@ export function AnalysisView() {

{t("analysis.subtitle")}

+
+
+

+ {t("analysis.live.title")} +

+

+ {t("analysis.live.subtitle")} +

+
+ + + {t("analysis.live.label")} + + + +
+
- - + + + + + + + + + total={weekdayTotal} + > + + + + + + + +
diff --git a/frontend/components/analysis/live-hospital-chart.tsx b/frontend/components/analysis/live-hospital-chart.tsx new file mode 100644 index 0000000..fc6ca9d --- /dev/null +++ b/frontend/components/analysis/live-hospital-chart.tsx @@ -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([]); + 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 ( +
+ + String(Math.round(v))} + momentumColors={{ + up: "var(--color-emerald-500)", + down: "var(--color-red-500)", + flat: "var(--chart-line-primary)", + }} + /> + String(Math.round(v))} position="left" /> + + +
+ ); +} diff --git a/frontend/components/charts/animation.ts b/frontend/components/charts/animation.ts new file mode 100644 index 0000000..0b5569b --- /dev/null +++ b/frontend/components/charts/animation.ts @@ -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, + }; +} diff --git a/frontend/components/charts/area-chart-loading.tsx b/frontend/components/charts/area-chart-loading.tsx new file mode 100644 index 0000000..1294bc7 --- /dev/null +++ b/frontend/components/charts/area-chart-loading.tsx @@ -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; + /** 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 ( + + + + + ); +} + +export default AreaChartLoading; diff --git a/frontend/components/charts/area-chart.tsx b/frontend/components/charts/area-chart.tsx new file mode 100644 index 0000000..51b871e --- /dev/null +++ b/frontend/components/charts/area-chart.tsx @@ -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[]; + /** Key in data for the x-axis (date). Default: "date" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** 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[]; + 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; + 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 ( + + {children} + + ); +} + +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(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + const [chartPhase, setChartPhase] = useState(() => + 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 ( +
+ + {({ width, height }) => ( + + {children} + + )} + + {showLoadingLabel ? ( + + ) : null} +
+ ); +} + +export { Area, type AreaProps } from "./area"; + +export default AreaChart; diff --git a/frontend/components/charts/area-gradient-defs.tsx b/frontend/components/charts/area-gradient-defs.tsx new file mode 100644 index 0000000..cb142cb --- /dev/null +++ b/frontend/components/charts/area-gradient-defs.tsx @@ -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 ( + + {isPatternFill ? null : ( + + + + {span < 1 ? ( + + ) : null} + + )} + + {strokeStops ? ( + + {strokeStops.map((stop) => ( + + ))} + + ) : null} + + {edgeStops ? ( + <> + + {edgeStops.map((stop) => ( + + ))} + + + + + + ) : null} + + ); +} diff --git a/frontend/components/charts/area.tsx b/frontend/components/charts/area.tsx new file mode 100644 index 0000000..9f71b87 --- /dev/null +++ b/frontend/components/charts/area.tsx @@ -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 `` and + // `` so this component (and its expensive + // 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 + // `` 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(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) => { + 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 ? ( + xScale(xAccessor(d)) ?? 0} + y={getY} + yScale={yScale} + /> + ) : null} + + {shouldMeasurePath ? ( + <> + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + {showSeriesStroke ? ( + + ) : null} + + ) : null} + + ); + + return ( + <> + + + + {useViewportEdgeFade ? ( + {seriesLayers} + ) : ( + seriesLayers + )} + + + {/* Highlight segment on hover — isolated hover subscriber. */} + + + {showMarkers && showSeriesContent ? ( + + ) : null} + + {showLoadingPulse && pathD && innerWidth > 0 ? ( + + ) : null} + + ); +} + +Area.displayName = "Area"; + +export default Area; diff --git a/frontend/components/charts/bar-chart.tsx b/frontend/components/charts/bar-chart.tsx new file mode 100644 index 0000000..c550557 --- /dev/null +++ b/frontend/components/charts/bar-chart.tsx @@ -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[]; + /** Key in data for the categorical axis. Default: "name" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** 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[]; + 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; + onPhaseChange?: (phase: ChartPhase) => void; +} + +function ChartInner(props: ChartInnerProps) { + const { width, height } = props; + if (width < 10 || height < 10) { + return null; + } + return ; +} + +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(); + 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 => { + 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): 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({ + 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>(); + for (let i = 0; i < data.length; i++) { + const d = data[i]; + if (!d) { + continue; + } + const pointOffsets = new Map(); + 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) => { + 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 = {}; + const xPositions: Record = {}; + 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 + >, + 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 ( + + + + ); +}); + +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(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +BarChart.displayName = "BarChart"; + +export default BarChart; diff --git a/frontend/components/charts/bar-depth-geometry.ts b/frontend/components/charts/bar-depth-geometry.ts new file mode 100644 index 0000000..d00743b --- /dev/null +++ b/frontend/components/charts/bar-depth-geometry.ts @@ -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 }; +} diff --git a/frontend/components/charts/bar-x-axis.tsx b/frontend/components/charts/bar-x-axis.tsx new file mode 100644 index 0000000..2fbfa5f --- /dev/null +++ b/frontend/components/charts/bar-x-axis.tsx @@ -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 ( +
+ + {label} + +
+ ); +} + +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 ; +} + +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( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +BarXAxis.displayName = "BarXAxis"; + +export default BarXAxis; diff --git a/frontend/components/charts/bar-y-axis.tsx b/frontend/components/charts/bar-y-axis.tsx new file mode 100644 index 0000000..95400e5 --- /dev/null +++ b/frontend/components/charts/bar-y-axis.tsx @@ -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 ( +
+ + {label} + +
+ ); +} + +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 ; +} + +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( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +BarYAxis.displayName = "BarYAxis"; + +export default BarYAxis; diff --git a/frontend/components/charts/bar.tsx b/frontend/components/charts/bar.tsx new file mode 100644 index 0000000..6f3d815 --- /dev/null +++ b/frontend/components/charts/bar.tsx @@ -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 = ReturnType< + typeof scaleBand +>; + +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 +// `` front face lines up exactly with +// ``'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, + bandWidth: number, + barXAccessor: (d: Record) => string, + innerWidth: number, + datum: Record, + 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 ``'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 + * `` when using the 3D surfaces. Default: 0 */ + minBarHeight?: number; +} + +interface BarInnerProps extends BarProps { + barScale: ScaleBand; + bandWidth: number; + barXAccessor: (d: Record) => 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 ( + + ); + } + + 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 ( + + + + ); +} + +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 `` 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 ( + + {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 ``'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 ( + + ); + } + + // Static bar after animation completes + return ( + + ); + })} + + ); +}); + +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 ( + + ); +} + +Bar.displayName = "Bar"; + +export default Bar; diff --git a/frontend/components/charts/chart-child-passthrough.ts b/frontend/components/charts/chart-child-passthrough.ts new file mode 100644 index 0000000..deed3e0 --- /dev/null +++ b/frontend/components/charts/chart-child-passthrough.ts @@ -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; +} diff --git a/frontend/components/charts/chart-config-context.tsx b/frontend/components/charts/chart-config-context.tsx new file mode 100644 index 0000000..2faf405 --- /dev/null +++ b/frontend/components/charts/chart-config-context.tsx @@ -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(null); + +export interface ChartConfigProviderProps { + value?: Partial; + children: ReactNode; +} + +export function ChartConfigProvider({ + value, + children, +}: ChartConfigProviderProps) { + const merged = useMemo( + () => ({ + ...DEFAULT_CHART_CONFIG, + ...value, + }), + [value] + ); + + return ( + + {children} + + ); +} + +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, + }, + }; +} diff --git a/frontend/components/charts/chart-context.tsx b/frontend/components/charts/chart-context.tsx new file mode 100644 index 0000000..b95ad5a --- /dev/null +++ b/frontend/components/charts/chart-context.tsx @@ -0,0 +1,419 @@ +"use client"; + +import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale"; + +type ScaleLinear = ReturnType< + typeof scaleLinear +>; +type ScaleTime = ReturnType< + typeof scaleTime +>; +type ScaleBand = ReturnType< + typeof scaleBand +>; + +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; + /** 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; + /** X positions for each series (for grouped bars), keyed by dataKey */ + xPositions?: Record; +} + +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>; + + // 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[]; + /** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */ + renderData: Record[]; + + // Scales + xScale: ScaleTime; + /** Primary (left) y-scale — alias for `yScales[DEFAULT_Y_AXIS_ID]`. */ + yScale: ScaleLinear; + /** Per-axis y-scales keyed by `yAxisId`. */ + yScales: Record>; + + // Dimensions + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + + // Column width for spacing calculations + columnWidth: number; + + // Container ref for portals + containerRef: RefObject; + + // 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; + /** Nice’d y-domains per axis from the current target data. */ + yDomainTargetByAxis: Record; + + // 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) => 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; + /** Width of each bar band */ + bandWidth?: number; + /** X accessor for bar charts (returns string instead of Date) */ + barXAccessor?: (d: Record) => 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>; + + // 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>; + /** 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(null); +const ChartHoverContext = createContext(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( + () => ({ + 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( + () => ({ + 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 ( + + + {children} + + + ); +} + +/** + * 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 , , , or ." + ); + } + return context; +} + +/** Y-scale for a series axis (`yAxisId` on Line / Area / YAxis). */ +export function useYScale( + yAxisId?: string | number +): ScaleLinear { + 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 , , , or ." + ); + } + 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; diff --git a/frontend/components/charts/chart-defs.ts b/frontend/components/charts/chart-defs.ts new file mode 100644 index 0000000..6dd941a --- /dev/null +++ b/frontend/components/charts/chart-defs.ts @@ -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 `` 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; +} diff --git a/frontend/components/charts/chart-formatters.ts b/frontend/components/charts/chart-formatters.ts new file mode 100644 index 0000000..d8b0070 --- /dev/null +++ b/frontend/components/charts/chart-formatters.ts @@ -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; diff --git a/frontend/components/charts/chart-legend-hover.tsx b/frontend/components/charts/chart-legend-hover.tsx new file mode 100644 index 0000000..b95fc71 --- /dev/null +++ b/frontend/components/charts/chart-legend-hover.tsx @@ -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(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 ( + + {children} + + ); +} + +export function useChartLegendHover(): ChartLegendHoverContextValue { + const context = useContext(ChartLegendHoverContext); + return ( + context ?? { + hoveredIndex: null, + setHoveredIndex: () => { + /* noop outside ChartLegendHoverProvider */ + }, + } + ); +} diff --git a/frontend/components/charts/chart-loading-label.tsx b/frontend/components/charts/chart-loading-label.tsx new file mode 100644 index 0000000..d38078e --- /dev/null +++ b/frontend/components/charts/chart-loading-label.tsx @@ -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 ( + + + + ); +} + +export default ChartLoadingLabel; diff --git a/frontend/components/charts/chart-phase.ts b/frontend/components/charts/chart-phase.ts new file mode 100644 index 0000000..2ff6807 --- /dev/null +++ b/frontend/components/charts/chart-phase.ts @@ -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; + yDomainTargetByAxis: Record; +}; diff --git a/frontend/components/charts/chart-reveal-clip.tsx b/frontend/components/charts/chart-reveal-clip.tsx new file mode 100644 index 0000000..ee4161a --- /dev/null +++ b/frontend/components/charts/chart-reveal-clip.tsx @@ -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 ( + + + + ); + } + + 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 ( + + onComplete?.()} + transition={transition} + y={-padding} + /> + + ); + } + + return ( + + + + ); +} diff --git a/frontend/components/charts/dash-tail-stroke.tsx b/frontend/components/charts/dash-tail-stroke.tsx new file mode 100644 index 0000000..becbca5 --- /dev/null +++ b/frontend/components/charts/dash-tail-stroke.tsx @@ -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 ( + <> + + + + + + {/* Solid head — same curved path, gradient/fade preserved */} + + {/* Dashed tail — clipped to x ≥ dashStartX so dashes follow the curve */} + + + ); +} diff --git a/frontend/components/charts/decimate-time-series.ts b/frontend/components/charts/decimate-time-series.ts new file mode 100644 index 0000000..7749a0c --- /dev/null +++ b/frontend/components/charts/decimate-time-series.ts @@ -0,0 +1,136 @@ +export function decimateTimeSeries>( + 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>( + 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; +} diff --git a/frontend/components/charts/fade-edges.ts b/frontend/components/charts/fade-edges.ts new file mode 100644 index 0000000..084aec2 --- /dev/null +++ b/frontend/components/charts/fade-edges.ts @@ -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, + }; +} diff --git a/frontend/components/charts/filter-data-by-x-domain.ts b/frontend/components/charts/filter-data-by-x-domain.ts new file mode 100644 index 0000000..b6d04e7 --- /dev/null +++ b/frontend/components/charts/filter-data-by-x-domain.ts @@ -0,0 +1,43 @@ +export function filterDataByXDomain( + data: Record[], + xDomain: [Date, Date], + xAccessor: (d: Record) => Date +): Record[] { + 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[], + xAccessor: (d: Record) => 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)]; +} diff --git a/frontend/components/charts/generate-chart-skeleton-data.ts b/frontend/components/charts/generate-chart-skeleton-data.ts new file mode 100644 index 0000000..b838c5d --- /dev/null +++ b/frontend/components/charts/generate-chart-skeleton-data.ts @@ -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[] { + 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[], + dataKey: string +): Record[] { + 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 }; diff --git a/frontend/components/charts/grid.tsx b/frontend/components/charts/grid.tsx new file mode 100644 index 0000000..61b42f1 --- /dev/null +++ b/frontend/components/charts/grid.tsx @@ -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(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(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 ( + + {/* Gradient mask for horizontal grid lines - fades at left/right */} + {horizontal && (fadeHorizontal || shimmer) && ( + + + + + + + + + + + + )} + + {horizontal && shimmerEnabled ? ( + + + + + + + + + + ) : null} + + {/* Gradient mask for vertical grid lines - fades at top/bottom */} + {vertical && fadeVertical && ( + + + + + + + + + + + + )} + + {horizontal && ( + + + {shimmerEnabled ? ( + + ) : null} + + )} + {horizontal && highlightRowValues && highlightRowValues.length > 0 ? ( + + {highlightRowValues.map((value) => { + const y = yScale(value); + if (y == null || !Number.isFinite(y)) { + return null; + } + + return ( + + ); + })} + + ) : null} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +export default Grid; diff --git a/frontend/components/charts/highlight-segment-bounds.ts b/frontend/components/charts/highlight-segment-bounds.ts new file mode 100644 index 0000000..19e66a9 --- /dev/null +++ b/frontend/components/charts/highlight-segment-bounds.ts @@ -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)) ] +// `` 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[], + xScale: (value: Date) => number | undefined, + xAccessor: (d: Record) => Date, + tooltipData: Pick | null | undefined, + selection: + | Pick + | 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 }; +} diff --git a/frontend/components/charts/highlight-segment.tsx b/frontend/components/charts/highlight-segment.tsx new file mode 100644 index 0000000..aa6295a --- /dev/null +++ b/frontend/components/charts/highlight-segment.tsx @@ -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 `` — its `d` is re-used verbatim. */ + pathRef: RefObject; + /** 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; + /** Spring-eased width of the clip band (px). */ + width: MotionValue; +} + +export function HighlightSegment({ + pathRef, + visible, + stroke, + strokeWidth, + height, + x, + width, +}: HighlightSegmentProps) { + const clipId = useId(); + if (!(visible && pathRef.current)) { + return null; + } + return ( + <> + + + + + + + + ); +} + +HighlightSegment.displayName = "HighlightSegment"; + +export default HighlightSegment; diff --git a/frontend/components/charts/indicator-fade.ts b/frontend/components/charts/indicator-fade.ts new file mode 100644 index 0000000..e939211 --- /dev/null +++ b/frontend/components/charts/indicator-fade.ts @@ -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 }, + ]; +} diff --git a/frontend/components/charts/line-loading-pulse.tsx b/frontend/components/charts/line-loading-pulse.tsx new file mode 100644 index 0000000..31296c2 --- /dev/null +++ b/frontend/components/charts/line-loading-pulse.tsx @@ -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 | 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 ( + <> + + + + + + {fadeStops.map((stop) => ( + + ))} + + + + + ); +} + +LineLoadingPulseStroke.displayName = "LineLoadingPulseStroke"; + +export default LineLoadingPulseStroke; diff --git a/frontend/components/charts/line-loading-timing.ts b/frontend/components/charts/line-loading-timing.ts new file mode 100644 index 0000000..726505d --- /dev/null +++ b/frontend/components/charts/line-loading-timing.ts @@ -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; diff --git a/frontend/components/charts/live-line-chart.tsx b/frontend/components/charts/live-line-chart.tsx new file mode 100644 index 0000000..889b6ac --- /dev/null +++ b/frontend/components/charts/live-line-chart.tsx @@ -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; + /** 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((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; + 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 ; +} + +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({ + now: Date.now(), + yMin: 0, + yMax: 100, + displayValue: value, + }); + const [frame, setFrame] = useState({ + 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(null); + const [tooltipData, setTooltipData] = useState(null); + const lastFrameCommitRef = useRef(0); + const lastTooltipKeyRef = useRef(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[] 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[] = 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): Date => + d.date instanceof Date ? d.date : new Date(d.date as number), + [] + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + 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 ( + + + + ); +}); + +// --------------------------------------------------------------------------- +// 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(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +export default LiveLineChart; diff --git a/frontend/components/charts/live-line.tsx b/frontend/components/charts/live-line.tsx new file mode 100644 index 0000000..35e3160 --- /dev/null +++ b/frontend/components/charts/live-line.tsx @@ -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[], + 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) => xScale(xAccessor(d)) ?? 0, + [xScale, xAccessor] + ); + + const getY = useCallback( + (d: Record) => { + 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 ( + <> + + + + + + + + + + + + + {liveDotX < innerWidth - 1 ? ( + <> + + + + ) : ( + + )} + + + + + + + {/* Area fill */} + {fill && data.length > 1 && ( + + + + )} + + {/* Line */} + {data.length > 1 && ( + + + + )} + + {/* Dashed horizontal line at current value */} + + + {/* Live indicator (dot + badge) — dims when crosshair is active */} + + {/* Pulsing dot */} + + {pulse && ( + + + + + )} + + + + + {/* Badge — use popover vars so text is never white-on-white */} + {badge && ( + + + + {formatValue(liveValue)} + + + )} + + + ); +} + +export default LiveLine; diff --git a/frontend/components/charts/live-x-axis.tsx b/frontend/components/charts/live-x-axis.tsx new file mode 100644 index 0000000..af768d2 --- /dev/null +++ b/frontend/components/charts/live-x-axis.tsx @@ -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 ; +} + +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( +
+ {/* Time labels */} + {labels.map((l) => ( +
+ + {l.label} + +
+ ))} + + {/* Time pill at crosshair — spring-animated to match crosshair line */} + {isHovering && pillLabel && ( + +
+ + {pillLabel} + +
+
+ )} +
, + container + ); +}); + +LiveXAxis.displayName = "LiveXAxis"; + +export default LiveXAxis; diff --git a/frontend/components/charts/live-y-axis.tsx b/frontend/components/charts/live-y-axis.tsx new file mode 100644 index 0000000..ca089ae --- /dev/null +++ b/frontend/components/charts/live-y-axis.tsx @@ -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 ; +} + +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( +
+
+ + {tickData.map((tick) => ( + + + {tick.label} + + + ))} + +
+
, + container + ); +}); + +LiveYAxis.displayName = "LiveYAxis"; + +export default LiveYAxis; diff --git a/frontend/components/charts/motion-utils.ts b/frontend/components/charts/motion-utils.ts new file mode 100644 index 0000000..77a79fe --- /dev/null +++ b/frontend/components/charts/motion-utils.ts @@ -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)), + }; +} diff --git a/frontend/components/charts/path-stroke-utils.ts b/frontend/components/charts/path-stroke-utils.ts new file mode 100644 index 0000000..7deefe5 --- /dev/null +++ b/frontend/components/charts/path-stroke-utils.ts @@ -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, + deps: readonly unknown[] +): PathStrokeMetrics { + const [metrics, setMetrics] = useState(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[], + dashFromIndex: number, + xScale: (value: Date | number) => number | undefined, + xAccessor: (datum: Record) => Date | number +): number { + const dashFromPoint = data[dashFromIndex]; + if (!dashFromPoint) { + return 0; + } + return xScale(xAccessor(dashFromPoint)) ?? 0; +} diff --git a/frontend/components/charts/pattern-area.tsx b/frontend/components/charts/pattern-area.tsx new file mode 100644 index 0000000..749e4e8 --- /dev/null +++ b/frontend/components/charts/pattern-area.tsx @@ -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 ( + 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; diff --git a/frontend/components/charts/series-bar-layout.ts b/frontend/components/charts/series-bar-layout.ts new file mode 100644 index 0000000..ed2249b --- /dev/null +++ b/frontend/components/charts/series-bar-layout.ts @@ -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); +} diff --git a/frontend/components/charts/series-dash-tail-overlay.tsx b/frontend/components/charts/series-dash-tail-overlay.tsx new file mode 100644 index 0000000..514e2d7 --- /dev/null +++ b/frontend/components/charts/series-dash-tail-overlay.tsx @@ -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[]; + pathD: string | null; + pathLength: number; + innerWidth: number; + innerHeight: number; + stroke: string; + strokeWidth: number; + xScale: (value: Date | number) => number | undefined; + xAccessor: (datum: Record) => 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 ( + + ); +} + +// 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); diff --git a/frontend/components/charts/series-highlight-layer.tsx b/frontend/components/charts/series-highlight-layer.tsx new file mode 100644 index 0000000..c2dbb3c --- /dev/null +++ b/frontend/components/charts/series-highlight-layer.tsx @@ -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; + 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 / 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 ( + + ); +} + +SeriesHighlightLayer.displayName = "SeriesHighlightLayer"; + +export default SeriesHighlightLayer; diff --git a/frontend/components/charts/series-hover-dim.tsx b/frontend/components/charts/series-hover-dim.tsx new file mode 100644 index 0000000..696ffbc --- /dev/null +++ b/frontend/components/charts/series-hover-dim.tsx @@ -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 ( + + {children} + + ); +} + +SeriesHoverDim.displayName = "SeriesHoverDim"; + +export default SeriesHoverDim; diff --git a/frontend/components/charts/series-markers.tsx b/frontend/components/charts/series-markers.tsx new file mode 100644 index 0000000..a3b2e96 --- /dev/null +++ b/frontend/components/charts/series-markers.tsx @@ -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 + // / 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) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : null; + }, + [dataKey, yScale] + ); + + const points = useMemo( + () => + 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 sees a stable prop and + // can be cheaply re-rendered on hover without re-creating the spread. + const markerStyle = useMemo( + () => ({ + fill: resolvedFill, + stroke: resolvedStroke, + strokeWidth, + ringGap, + outlineWidth, + outlineColor, + radius, + }), + [ + resolvedFill, + resolvedStroke, + strokeWidth, + ringGap, + outlineWidth, + outlineColor, + radius, + ] + ); + + if (isRevealing) { + return ( + + {points.map((point) => ( + + ))} + + ); + } + + // 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) => ( + + )); + const activeScale = showActiveHighlight ? 1.35 : 1; + + return ( + + + {baseMarkers} + + + + ); +} + +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 ( + 0 ? `blur(${inactiveBlur}px)` : "none", + }} + > + {children} + + ); +} + +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 ( + + ); +} + +export default SeriesMarkers; diff --git a/frontend/components/charts/series-point-marker.tsx b/frontend/components/charts/series-point-marker.tsx new file mode 100644 index 0000000..d352bd3 --- /dev/null +++ b/frontend/components/charts/series-point-marker.tsx @@ -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 ? ( + + ) : null} + + {strokeWidth > 0 ? ( + + ) : 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 ( + + + + ); +}); + +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 ( + + + + + + ); +} + +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; +} diff --git a/frontend/components/charts/static-chart-preview-context.tsx b/frontend/components/charts/static-chart-preview-context.tsx new file mode 100644 index 0000000..0930ad1 --- /dev/null +++ b/frontend/components/charts/static-chart-preview-context.tsx @@ -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 ( + + {children} + + ); +} + +export function useStaticChartPreview() { + return useContext(StaticChartPreviewContext); +} diff --git a/frontend/components/charts/time-series-chart-shell.tsx b/frontend/components/charts/time-series-chart-shell.tsx new file mode 100644 index 0000000..77f513b --- /dev/null +++ b/frontend/components/charts/time-series-chart-shell.tsx @@ -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[], + 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[], + 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[]; + 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; + /** 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>; + 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 ; +} + +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[], 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): Date => { + const value = d[xDataKey]; + return value instanceof Date ? value : new Date(value as string | number); + }, + [xDataKey] + ); + + const bisectDate = useMemo( + () => bisector, 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 ( + + + + ); +}); diff --git a/frontend/components/charts/tooltip/chart-tooltip.tsx b/frontend/components/charts/tooltip/chart-tooltip.tsx new file mode 100644 index 0000000..ddfb89a --- /dev/null +++ b/frontend/components/charts/tooltip/chart-tooltip.tsx @@ -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); + /** Custom content renderer for the tooltip box */ + content?: (props: { + point: Record; + index: number; + }) => React.ReactNode; + /** Custom row renderer - return array of TooltipRow */ + rows?: (point: Record) => 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, 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 && ( + + )} + + {/* Dots on bars/lines - show for vertical charts only */} + {showDots && visible && !isHorizontal && ( + + )} + + {/* Tooltip Box */} + + {content && tooltipData + ? content({ + point: tooltipData.point, + index: tooltipData.index, + }) + : !content && ( + + {children} + + )} + + + {/* Date/Category Ticker - only show for vertical charts */} + + + ); + + 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 ; +} + +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 ; +} + +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 ( + + + + ); +} + +export default ChartTooltip; diff --git a/frontend/components/charts/tooltip/date-ticker.tsx b/frontend/components/charts/tooltip/date-ticker.tsx new file mode 100644 index 0000000..d4a99e0 --- /dev/null +++ b/frontend/components/charts/tooltip/date-ticker.tsx @@ -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) { + const label = labels[currentIndex] ?? labels[0] ?? ""; + + return ( +
+
+ {label} +
+
+ ); +}); + +const DateTickerInner = memo(function DateTickerInner({ + currentIndex, + labels, +}: Omit) { + // 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 ( +
+
+
+ {/* Month stack */} +
+ + {monthSegments.map((segment) => ( +
+ + {segment.month} + +
+ ))} +
+
+ + {/* Day stack */} +
+ + {parsedLabels.map((label) => ( +
+ + {label.day} + +
+ ))} +
+
+
+
+
+ ); +}); + +export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + if (!visible || labels.length === 0) { + return null; + } + + if (labels.length > COMPACT_TICKER_THRESHOLD) { + return ; + } + + return ; +} + +DateTicker.displayName = "DateTicker"; + +export default DateTicker; diff --git a/frontend/components/charts/tooltip/index.ts b/frontend/components/charts/tooltip/index.ts new file mode 100644 index 0000000..5b4dafc --- /dev/null +++ b/frontend/components/charts/tooltip/index.ts @@ -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"; diff --git a/frontend/components/charts/tooltip/tooltip-box.tsx b/frontend/components/charts/tooltip/tooltip-box.tsx new file mode 100644 index 0000000..6e36e05 --- /dev/null +++ b/frontend/components/charts/tooltip/tooltip-box.tsx @@ -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; + /** 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; + /** Override top position (bypasses internal calculation) */ + top?: number | ReturnType; + /** 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 ; +} + +function TooltipBoxInner({ + x, + y, + containerWidth, + containerHeight, + offset = 16, + className = "", + children, + left: leftOverride, + top: topOverride, + flipped: flippedOverride, + springConfig, + animate = true, + panelStyle, + container, +}: Omit & { + container: HTMLElement; +}) { + const { tooltipBoxSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipBoxSpring; + + const tooltipRef = useRef(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( + + + {children} + + , + container + ); +} + +TooltipBox.displayName = "TooltipBox"; + +export default TooltipBox; diff --git a/frontend/components/charts/tooltip/tooltip-content.tsx b/frontend/components/charts/tooltip/tooltip-content.tsx new file mode 100644 index 0000000..7e07119 --- /dev/null +++ b/frontend/components/charts/tooltip/tooltip-content.tsx @@ -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 ( +
+
+ {title && ( +
+ {title} +
+ )} +
+ {rows.map((row) => ( +
+
+ + + {row.label} + +
+ + {typeof row.value === "number" ? intFmt(row.value) : row.value} + +
+ ))} +
+ + {children && ( +
+ {children} +
+ )} +
+
+ ); +} + +TooltipContent.displayName = "TooltipContent"; + +export default TooltipContent; diff --git a/frontend/components/charts/tooltip/tooltip-dot.tsx b/frontend/components/charts/tooltip/tooltip-dot.tsx new file mode 100644 index 0000000..5b8024e --- /dev/null +++ b/frontend/components/charts/tooltip/tooltip-dot.tsx @@ -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 ( + + ); + } + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +export default TooltipDot; diff --git a/frontend/components/charts/tooltip/tooltip-indicator.tsx b/frontend/components/charts/tooltip/tooltip-indicator.tsx new file mode 100644 index 0000000..ee32d10 --- /dev/null +++ b/frontend/components/charts/tooltip/tooltip-indicator.tsx @@ -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 ; +} + +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 ? ( + + ) : ( + + ); + } + + if (!fadeSides.any) { + return animate ? ( + + ) : ( + + ); + } + + const fadeStops = indicatorFadeGradientStops(fadeSides, fadeLength); + + return ( + + + + {fadeStops.map((stop) => ( + + ))} + + + {animate ? ( + + ) : ( + + )} + + ); +} + +TooltipIndicator.displayName = "TooltipIndicator"; + +export default TooltipIndicator; diff --git a/frontend/components/charts/use-animated-y-domains.ts b/frontend/components/charts/use-animated-y-domains.ts new file mode 100644 index 0000000..d4b94d2 --- /dev/null +++ b/frontend/components/charts/use-animated-y-domains.ts @@ -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, + setAnimatedByAxis: (domains: Record) => void, + animatedRef: { current: Record } +) { + if (domainsEqual(animatedRef.current, domains)) { + return; + } + setAnimatedByAxis(domains); + animatedRef.current = domains; +} + +function tweenDomains({ + destination, + durationMs, + enabled, + reducedMotion, + animatedRef, + setAnimatedByAxis, + onSettled, +}: { + destination: Record; + durationMs: number; + enabled: boolean; + reducedMotion: boolean | null; + animatedRef: { current: Record }; + setAnimatedByAxis: (domains: Record) => 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 = {}; + 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 = {}; + 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; + targetByAxis: Record; + 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 { + 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; +} diff --git a/frontend/components/charts/use-chart-interaction.ts b/frontend/components/charts/use-chart-interaction.ts new file mode 100644 index 0000000..cc74704 --- /dev/null +++ b/frontend/components/charts/use-chart-interaction.ts @@ -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>; +type ScaleLinear = ReturnType>; + +export interface ChartSelection { + startX: number; + endX: number; + startIndex: number; + endIndex: number; + active: boolean; +} + +interface UseChartInteractionParams { + xScale: ScaleTime; + yScale: ScaleLinear; + yScales: Record; + data: Record[]; + lines: LineConfig[]; + margin: Margin; + xAccessor: (d: Record) => Date; + bisectDate: ( + data: Record[], + date: Date, + lo: number + ) => number; + canInteract: boolean; +} + +interface ChartInteractionResult { + tooltipData: TooltipData | null; + setTooltipData: React.Dispatch>; + selection: ChartSelection | null; + clearSelection: () => void; + interactionHandlers: { + onMouseMove?: (event: React.MouseEvent) => void; + onMouseLeave?: () => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: () => void; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchMove?: (event: React.TouchEvent) => void; + onTouchEnd?: () => void; + }; + interactionStyle: React.CSSProperties; +} + +export function useChartInteraction({ + xScale, + yScale, + yScales, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, +}: UseChartInteractionParams): ChartInteractionResult { + const [selection, setSelection] = useState(null); + const { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe, + } = useScheduledTooltip(); + + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + const lastHoveredXRef = useRef(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 = {}; + 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 | React.TouchEvent, + 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) => { + 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) => { + 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) => { + 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) => { + 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, + }; +} diff --git a/frontend/components/charts/use-chart-phase-orchestrator.ts b/frontend/components/charts/use-chart-phase-orchestrator.ts new file mode 100644 index 0000000..413c334 --- /dev/null +++ b/frontend/components/charts/use-chart-phase-orchestrator.ts @@ -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[]; + skeletonData: Record[]; + 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(() => + resolveRestingChartPhase(chartStatus) + ); + const [plotData, setPlotData] = useState[]>(() => + 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, + }; +} diff --git a/frontend/components/charts/use-enter-complete.ts b/frontend/components/charts/use-enter-complete.ts new file mode 100644 index 0000000..e16026e --- /dev/null +++ b/frontend/components/charts/use-enter-complete.ts @@ -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): 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; +} diff --git a/frontend/components/charts/use-grid-shimmer.ts b/frontend/components/charts/use-grid-shimmer.ts new file mode 100644 index 0000000..22e9046 --- /dev/null +++ b/frontend/components/charts/use-grid-shimmer.ts @@ -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 | 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 }; +} diff --git a/frontend/components/charts/use-highlight-segment.ts b/frontend/components/charts/use-highlight-segment.ts new file mode 100644 index 0000000..f5cb8bc --- /dev/null +++ b/frontend/components/charts/use-highlight-segment.ts @@ -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; `` 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; + widthSpring: ReturnType; + 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 }; +} diff --git a/frontend/components/charts/use-mount-progress.ts b/frontend/components/charts/use-mount-progress.ts new file mode 100644 index 0000000..cf7bf64 --- /dev/null +++ b/frontend/components/charts/use-mount-progress.ts @@ -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; +} diff --git a/frontend/components/charts/use-scheduled-tooltip.ts b/frontend/components/charts/use-scheduled-tooltip.ts new file mode 100644 index 0000000..fb4e947 --- /dev/null +++ b/frontend/components/charts/use-scheduled-tooltip.ts @@ -0,0 +1,97 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ScheduledTooltipControls { + tooltipData: T | null; + setTooltipData: React.Dispatch>; + scheduleTooltip: (tooltip: T, dedupeKey?: string) => void; + clearTooltip: () => void; + resetTooltipDedupe: () => void; +} + +function defaultDedupeKey(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(): ScheduledTooltipControls { + const [tooltipData, setTooltipData] = useState(null); + const lastKeyRef = useRef(null); + const pendingRef = useRef(null); + const rafRef = useRef(null); + const pendingKeyRef = useRef(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, + }; +} diff --git a/frontend/components/charts/x-axis.tsx b/frontend/components/charts/x-axis.tsx new file mode 100644 index 0000000..807a084 --- /dev/null +++ b/frontend/components/charts/x-axis.tsx @@ -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 ( +
+ + {label} + +
+ ); +} + +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[], + dateLabels: string[], + xAccessor: (d: Record) => Date +): number[] { + const seenLabels = new Set(); + 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[]; + dateLabels?: string[]; + xAccessor?: (d: Record) => 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[]; + dateLabels: string[]; + marginLeft: number; + targetTickCount: number; + xAccessor: (d: Record) => Date; + xScale: (date: Date) => number | undefined; +}): AxisTick[] { + const seenLabels = new Set(); + 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(); + 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 ; +} + +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( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container + ); +}); + +XAxis.displayName = "XAxis"; + +export default XAxis; diff --git a/frontend/components/charts/y-axis-scales.ts b/frontend/components/charts/y-axis-scales.ts new file mode 100644 index 0000000..73c4473 --- /dev/null +++ b/frontend/components/charts/y-axis-scales.ts @@ -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 { + const groups = new Map(); + 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>; + +export function getPrimaryYScale( + yScales: Record, + 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[]; + innerHeight: number; + resolveDomain: (dataKeys: string[]) => [number, number]; +}): Record { + const groups = groupLinesByYAxisId(lines); + const scales: Record = {}; + + 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; +}): Record { + const groups = groupLinesByYAxisId(lines); + const scales: Record = {}; + + 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 { + return { [DEFAULT_Y_AXIS_ID]: yScale }; +} diff --git a/frontend/components/charts/y-axis-ticks.ts b/frontend/components/charts/y-axis-ticks.ts new file mode 100644 index 0000000..55c700d --- /dev/null +++ b/frontend/components/charts/y-axis-ticks.ts @@ -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; +} diff --git a/frontend/components/charts/y-domain-utils.ts b/frontend/components/charts/y-domain-utils.ts new file mode 100644 index 0000000..77aba7f --- /dev/null +++ b/frontend/components/charts/y-domain-utils.ts @@ -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, + targetByAxis: Record +): Record { + 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 { + const groups = groupLinesByYAxisId(lines); + const domains: Record = {}; + + 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[] +): Record { + const merged: Record = {}; + for (const record of records) { + for (const [axisId, domain] of Object.entries(record)) { + merged[normalizeYAxisId(axisId)] = domain; + } + } + return merged; +} + +export function domainsEqual( + left: Record, + right: Record +): 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; +} diff --git a/frontend/components/lab/lab-view.tsx b/frontend/components/lab/lab-view.tsx index 6e2d48d..3af1fee 100644 --- a/frontend/components/lab/lab-view.tsx +++ b/frontend/components/lab/lab-view.tsx @@ -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 low: "outline", }; +const flagVariant: Record = { + 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 (