From 5108ce0b2a0b6473fb848c0ea0de57beda7e6957 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:35:27 +0530 Subject: [PATCH 01/16] Add the bklit-ui agent skill for chart work Installed with `npx skills add bklit/bklit-ui --skill bklit-ui`. Only the consumer skill is taken; the package's other skills are for bklit's own monorepo. --- .claude/skills/bklit-ui/SKILL.md | 136 ++++++++++++++++++ .claude/skills/bklit-ui/rules/animation.md | 42 ++++++ .claude/skills/bklit-ui/rules/composition.md | 57 ++++++++ .claude/skills/bklit-ui/rules/installation.md | 74 ++++++++++ .claude/skills/bklit-ui/rules/theming.md | 55 +++++++ .claude/skills/bklit-ui/rules/tooltips.md | 71 +++++++++ skills-lock.json | 6 + 7 files changed, 441 insertions(+) create mode 100644 .claude/skills/bklit-ui/SKILL.md create mode 100644 .claude/skills/bklit-ui/rules/animation.md create mode 100644 .claude/skills/bklit-ui/rules/composition.md create mode 100644 .claude/skills/bklit-ui/rules/installation.md create mode 100644 .claude/skills/bklit-ui/rules/theming.md create mode 100644 .claude/skills/bklit-ui/rules/tooltips.md diff --git a/.claude/skills/bklit-ui/SKILL.md b/.claude/skills/bklit-ui/SKILL.md new file mode 100644 index 0000000..cd8ee94 --- /dev/null +++ b/.claude/skills/bklit-ui/SKILL.md @@ -0,0 +1,136 @@ +--- +name: bklit-ui +description: > + Bklit UI charts and data visualization for any project using the @bklit + shadcn registry. Install, compose, theme, and animate charts correctly. + Triggers when working with @bklitui/ui/charts, @bklit components, data + visualization, dashboards, or chart theming. Also invoke manually for + chart tasks. +allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) +--- + +# Bklit UI + +Composable chart components for React, distributed via the `@bklit` shadcn registry. Charts are installed as source into the user's project. + +> **IMPORTANT:** Run shadcn CLI commands with the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. + +## Current Project Context + +```json +!`npx shadcn@latest info --json` +``` + +Use the JSON above for framework, aliases, Tailwind version, installed components, and resolved paths. Confirm the `@bklit` registry is configured before adding charts. + +## Principles + +1. **Install before inventing.** Use `npx shadcn@latest add @bklit/` — charts are registry components, not hand-rolled SVG. +2. **Compose, don't flatten.** Root chart → `Grid` → series → axes → `ChartTooltip`. See [composition.md](./rules/composition.md). +3. **Theme with tokens.** Use `chartCssVars` and `--chart-*` variables — never hardcode one-off colors. See [theming.md](./rules/theming.md). +4. **Read the doc page first.** Each chart has props, data shape, and examples at `https://ui.bklit.com/docs/components/`. +5. **Browse variants.** Gallery: `https://ui.bklit.com/charts/` — Studio: `https://ui.bklit.com/studio?chart=`. + +## Critical Rules + +These rules are **always enforced**. Each links to Incorrect/Correct examples. + +### Composition → [composition.md](./rules/composition.md) + +- **Series and axes live inside the root chart** — `LineChart`, `BarChart`, `AreaChart`, etc. +- **One root per chart** — use `ComposedChart` for mixed series types. +- **Grid before series** so lines/bars render above grid lines. +- **`ChartTooltip` as a chart child** — required for crosshair and hover context. + +### Theming → [theming.md](./rules/theming.md) + +- **Use `chartCssVars`** from `@bklitui/ui/charts` instead of raw `"var(--chart-…)"` strings. +- **Series palette:** `--chart-1` … `--chart-5` for multi-series charts. +- **Tooltip surfaces:** `bg-popover text-popover-foreground` — avoids white-on-white in light mode. + +### Animation → [animation.md](./rules/animation.md) + +- **Default duration ~1100ms** for cartesian enter animations unless the doc specifies otherwise. +- **Replay:** change `revealSignature` or remount with a new `key`. +- **Live charts:** use `paused` on `LiveLineChart` to debug without stopping the rAF loop manually. + +### Tooltips → [tooltips.md](./rules/tooltips.md) + +- **Custom content via `ChartTooltip` `content` prop** or children patterns from docs. +- **`indicatorColor` function** for candlestick / dynamic crosshair colors. +- **Custom indicators:** use `useChart()` — do not track mouse globally outside chart context. + +### Installation → [installation.md](./rules/installation.md) + +- **Require `@bklit` registry** in `components.json`. +- **Install:** `npx shadcn@latest add @bklit/`. +- **Let the CLI install peer dependencies** — do not pin `@visx/*` / `motion` manually unless resolving a conflict. + +## Chart Catalog + +| Slug | Use when | Install | Docs | Gallery | +|------|----------|---------|------|---------| +| `area-chart` | Trends with filled regions under lines | `@bklit/area-chart` | [/docs/components/area-chart](https://ui.bklit.com/docs/components/area-chart) | [/charts/area-chart](https://ui.bklit.com/charts/area-chart) | +| `bar-chart` | Category comparisons, stacked or grouped bars | `@bklit/bar-chart` | [/docs/components/bar-chart](https://ui.bklit.com/docs/components/bar-chart) | [/charts/bar-chart](https://ui.bklit.com/charts/bar-chart) | +| `line-chart` | Time series, multi-line trends, markers | `@bklit/line-chart` | [/docs/components/line-chart](https://ui.bklit.com/docs/components/line-chart) | [/charts/line-chart](https://ui.bklit.com/charts/line-chart) | +| `live-line-chart` | Streaming / real-time data | `@bklit/live-line-chart` | [/docs/components/live-line-chart](https://ui.bklit.com/docs/components/live-line-chart) | [/charts/live-line-chart](https://ui.bklit.com/charts/live-line-chart) | +| `composed-chart` | Mixed bar + line (or similar) on one axis | `@bklit/composed-chart` | [/docs/components/composed-chart](https://ui.bklit.com/docs/components/composed-chart) | [/charts/composed-chart](https://ui.bklit.com/charts/composed-chart) | +| `scatter-chart` | Correlation, distribution, bubble sizing | `@bklit/scatter-chart` | [/docs/components/scatter-chart](https://ui.bklit.com/docs/components/scatter-chart) | [/charts/scatter-chart](https://ui.bklit.com/charts/scatter-chart) | +| `candlestick-chart` | OHLC financial data, brushes | `@bklit/candlestick-chart` | [/docs/components/candlestick-chart](https://ui.bklit.com/docs/components/candlestick-chart) | [/charts/candlestick-chart](https://ui.bklit.com/charts/candlestick-chart) | +| `pie-chart` | Part-to-whole slices | `@bklit/pie-chart` | [/docs/components/pie-chart](https://ui.bklit.com/docs/components/pie-chart) | [/charts/pie-chart](https://ui.bklit.com/charts/pie-chart) | +| `ring-chart` | Donut / ring KPIs | `@bklit/ring-chart` | [/docs/components/ring-chart](https://ui.bklit.com/docs/components/ring-chart) | [/charts/ring-chart](https://ui.bklit.com/charts/ring-chart) | +| `radar-chart` | Multi-axis comparison | `@bklit/radar-chart` | [/docs/components/radar-chart](https://ui.bklit.com/docs/components/radar-chart) | [/charts/radar-chart](https://ui.bklit.com/charts/radar-chart) | +| `gauge-chart` | Single-value KPI dial | `@bklit/gauge-chart` | [/docs/components/gauge-chart](https://ui.bklit.com/docs/components/gauge-chart) | [/charts/gauge-chart](https://ui.bklit.com/charts/gauge-chart) | +| `funnel-chart` | Stage conversion funnels | `@bklit/funnel-chart` | [/docs/components/funnel-chart](https://ui.bklit.com/docs/components/funnel-chart) | [/charts/funnel-chart](https://ui.bklit.com/charts/funnel-chart) | +| `sankey-chart` | Flow between nodes | `@bklit/sankey-chart` | [/docs/components/sankey-chart](https://ui.bklit.com/docs/components/sankey-chart) | [/charts/sankey-chart](https://ui.bklit.com/charts/sankey-chart) | +| `choropleth-chart` | Geo regions colored by value | `@bklit/choropleth-chart` | [/docs/components/choropleth-chart](https://ui.bklit.com/docs/components/choropleth-chart) | [/charts/choropleth-chart](https://ui.bklit.com/charts/choropleth-chart) | + +## Workflow + +1. Run `npx shadcn@latest info --json` — verify `@bklit` registry and aliases. +2. Pick a chart from the catalog (or ask the user what story the data tells). +3. Open the doc URL for data shape and props. +4. If not installed: `npx shadcn@latest add @bklit/`. +5. Compose with grid, series, axes, tooltip — apply theming tokens. +6. Point the user to the gallery or Studio URL for variant inspiration. + +## Quick Reference + +```bash +# Project info +npx shadcn@latest info --json + +# Add a chart +npx shadcn@latest add @bklit/line-chart + +# Search registries (if configured) +npx shadcn@latest search @bklit +``` + +```tsx +import { LineChart, Line, Grid, XAxis, ChartTooltip, chartCssVars } from "@bklitui/ui/charts"; + + + + + + + +``` + +## Utility docs + +- Theming: https://ui.bklit.com/docs/theming +- Grid: https://ui.bklit.com/docs/utility/grid +- Legend: https://ui.bklit.com/docs/utility/legend +- Tooltip: https://ui.bklit.com/docs/utility/tooltip +- Custom indicator: https://ui.bklit.com/docs/utility/custom-indicator +- useChart: https://ui.bklit.com/docs/utility/use-chart + +## Detailed References + +- [composition.md](./rules/composition.md) +- [theming.md](./rules/theming.md) +- [animation.md](./rules/animation.md) +- [tooltips.md](./rules/tooltips.md) +- [installation.md](./rules/installation.md) diff --git a/.claude/skills/bklit-ui/rules/animation.md b/.claude/skills/bklit-ui/rules/animation.md new file mode 100644 index 0000000..964488c --- /dev/null +++ b/.claude/skills/bklit-ui/rules/animation.md @@ -0,0 +1,42 @@ +# Animation + +## Defaults + +Most cartesian charts default to ~1100ms enter animation. Bar charts often use staggered reveals (~1.2s total) with easing `cubic-bezier(0.85, 0, 0.15, 1)`. + +## Replay enter animations + +Pass a changing `revealSignature` (or remount the chart with a new `key`) to replay mount animations after prop changes. + +### Correct + +```tsx +const [replayKey, setReplayKey] = useState(0); + + + {/* ... */} + + + +``` + +## Live charts + +- `LiveLineChart` runs its own animation loop — use `paused` to freeze updates for debugging. +- Avoid nesting heavy state updates inside the rAF path; pass stable props where possible. + +## Reduced motion + +Respect `prefers-reduced-motion` when adding custom animation wrappers around charts. Bklit charts handle reduced motion internally for enter transitions. + +## Performance + +- Prefer CSS/SVG-friendly props over re-creating large data arrays every frame. +- For streaming data, append points and trim the window instead of replacing the full array when possible. + +## Inspiration + +Browse animated variants: https://ui.bklit.com/charts/ +Tune motion interactively: https://ui.bklit.com/studio?chart= diff --git a/.claude/skills/bklit-ui/rules/composition.md b/.claude/skills/bklit-ui/rules/composition.md new file mode 100644 index 0000000..ccc9eaa --- /dev/null +++ b/.claude/skills/bklit-ui/rules/composition.md @@ -0,0 +1,57 @@ +# Chart Composition + +## Root + children + +Charts use a composable API. Always wrap series, axes, and overlays inside the root chart component. + +### Incorrect + +```tsx +
+ + +
+``` + +### Correct + +```tsx + + + + + + +``` + +## Axes and grid + +- Put `Grid` before series so lines render on top. +- Use `XAxis` / `YAxis` as siblings inside the root chart — not outside the chart context. +- For live streaming charts, use `LiveXAxis` and `LiveYAxis` inside `LiveLineChart`. + +## Tooltips and markers + +- `ChartTooltip` must be a child of the root chart so it can read hover state. +- Custom tooltip content receives chart context — prefer `useChart()` when building custom indicators. +- `ChartMarkers` and marker content render inside `ChartTooltip` when showing event callouts. + +## Multi-series charts + +- One `Line` / `Bar` / `Area` child per `dataKey`. +- Use `--chart-1` … `--chart-5` or explicit stroke/fill props for series differentiation. +- For combined line + bar, use `ComposedChart` instead of nesting unrelated roots. + +## Data shape + +- Cartesian charts: array of objects; set `xDataKey` on the root (default `"date"`). +- OHLC: use `CandlestickChart` with `{ date, open, high, low, close }`. +- Live line: `{ time, value }` points with a separate `value` prop on the root. +- Sankey / funnel / pie: follow each chart’s doc for node/link or slice shape. + +## Docs + +- Composition patterns: https://ui.bklit.com/docs/components/line-chart +- `useChart` hook: https://ui.bklit.com/docs/utility/use-chart +- Grid: https://ui.bklit.com/docs/utility/grid +- Legend: https://ui.bklit.com/docs/utility/legend diff --git a/.claude/skills/bklit-ui/rules/installation.md b/.claude/skills/bklit-ui/rules/installation.md new file mode 100644 index 0000000..b8afac0 --- /dev/null +++ b/.claude/skills/bklit-ui/rules/installation.md @@ -0,0 +1,74 @@ +# Installation + +## Prerequisites + +Bklit UI is a shadcn registry. Initialize shadcn in the project first: + +```bash +npx shadcn@latest init +``` + +## Registry namespace + +Add the `@bklit` namespace to `components.json` if it is not already present: + +```json +{ + "registries": { + "@bklit": "https://ui.bklit.com/r/{name}.json" + } +} +``` + +New projects scaffolded with Bklit often include this automatically. + +## Install a chart + +```bash +npx shadcn@latest add @bklit/line-chart +``` + +Replace `line-chart` with any chart slug from the catalog. The CLI copies source into your components directory and installs peer dependencies. + +## Verify project context + +```bash +npx shadcn@latest info --json +``` + +Use the JSON to confirm framework, aliases, Tailwind version, and which `@bklit` components are already installed. + +## Import path + +After install, import from the charts entry (path may vary by alias — check `info --json`): + +```tsx +import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@/components/ui/line-chart"; +// or from package re-exports when using the npm package directly: +import { LineChart, Line, Grid, XAxis, ChartTooltip } from "@bklitui/ui/charts"; +``` + +Prefer the import path your project’s shadcn install actually generated. + +## Common peer dependencies + +| Chart | Typical dependencies | +|-------|---------------------| +| line-chart, area-chart | `@visx/curve`, `@visx/shape`, `motion` | +| bar-chart | `@visx/gradient`, `@visx/pattern`, `@visx/shape`, `motion` | +| candlestick-chart | `@visx/scale`, `@visx/shape`, `@visx/responsive`, `d3-array`, `motion` | +| live-line-chart | `@visx/curve`, `@visx/scale`, `@visx/shape`, `@visx/responsive`, `@visx/event`, `d3-array`, `motion` | +| choropleth-chart | `@visx/geo`, `@visx/responsive`, `@visx/zoom`, `d3-geo`, `topojson-client`, `motion` | +| sankey-chart | `@visx/gradient`, `@visx/pattern`, `@visx/responsive`, `@visx/sankey`, `motion` | +| scatter-chart | `d3-scale`, `d3-array`, `motion`, `react-use-measure` | +| pie-chart, ring-chart | `@visx/responsive`, `@visx/shape`, `motion` | +| radar-chart | `@visx/responsive`, `d3-shape`, `motion` | +| funnel-chart | `motion` | +| gauge-chart | `@visx/responsive`, `motion` | + +The CLI installs these when adding a chart — do not guess versions; let `shadcn add` resolve them. + +## Docs + +- Installation: https://ui.bklit.com/docs/installation +- Per-chart install tabs: https://ui.bklit.com/docs/components/ diff --git a/.claude/skills/bklit-ui/rules/theming.md b/.claude/skills/bklit-ui/rules/theming.md new file mode 100644 index 0000000..6bbf8b5 --- /dev/null +++ b/.claude/skills/bklit-ui/rules/theming.md @@ -0,0 +1,55 @@ +# Theming + +## Use chartCssVars + +Prefer the typed `chartCssVars` export over raw CSS variable strings. + +### Incorrect + +```tsx + + +``` + +### Correct + +```tsx +import { chartCssVars } from "@bklitui/ui/charts"; + + + +``` + +## Series colors + +- Multi-series: `var(--chart-1)` through `var(--chart-5)` or theme tokens. +- Line charts: `var(--chart-line-primary)` / `var(--chart-line-secondary)` for default strokes. +- Do not hardcode hex colors unless the design explicitly requires brand colors outside the theme. + +## Tooltip and badge surfaces + +Tooltip boxes and live value badges should use shadcn popover tokens so text stays readable in light and dark mode: + +```tsx +// Tooltip content / badges +className="bg-popover text-popover-foreground" +``` + +## Dark mode + +Override chart variables in `:root` and `.dark` — do not sprinkle `dark:` on individual chart SVG elements. + +```css +:root { + --chart-background: oklch(1 0 0); + --chart-grid: oklch(0.9 0 0); +} +.dark { + --chart-background: oklch(0.145 0.004 285); + --chart-grid: oklch(0.25 0 0); +} +``` + +## Docs + +- Full theming guide: https://ui.bklit.com/docs/theming diff --git a/.claude/skills/bklit-ui/rules/tooltips.md b/.claude/skills/bklit-ui/rules/tooltips.md new file mode 100644 index 0000000..3de29ca --- /dev/null +++ b/.claude/skills/bklit-ui/rules/tooltips.md @@ -0,0 +1,71 @@ +# Tooltips + +## Default tooltip + +Use `ChartTooltip` as a child of the root chart. Defaults include crosshair, dots, and date pill where applicable. + +```tsx + + + + + +``` + +## Custom content + +```tsx + ( +
+
{formattedDate}
+
{point.users as number} users
+
+ )} +/> +``` + +## indicatorColor (candlestick and crosshair) + +Match the crosshair to the hovered datum — e.g. green/red candles: + +```tsx + + (point.close as number) >= (point.open as number) + ? "var(--chart-1)" + : "var(--chart-3)" + } + showDots={false} +/> +``` + +## Custom indicators + +For advanced crosshair/markers, use `useChart()` and the patterns in the custom indicator docs. + +### Incorrect + +```tsx +// Reading mouse position manually outside chart context +const [x, setX] = useState(0); +useEffect(() => { + window.addEventListener("mousemove", ...); +}, []); +``` + +### Correct + +```tsx +import { useChart } from "@bklitui/ui/charts"; + +function CustomIndicator() { + const { tooltipData } = useChart(); + // render from tooltipData +} +``` + +## Docs + +- ChartTooltip: https://ui.bklit.com/docs/utility/tooltip +- Custom indicators: https://ui.bklit.com/docs/utility/custom-indicator diff --git a/skills-lock.json b/skills-lock.json index 7a87193..2e818d8 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,12 @@ { "version": 1, "skills": { + "bklit-ui": { + "source": "bklit/bklit-ui", + "sourceType": "github", + "skillPath": "skills/bklit-ui/SKILL.md", + "computedHash": "ace89f8b9a6d5d8f0367307f886cb9654065356db537df7b57b3d849aa503534" + }, "grill-me": { "source": "mattpocock/skills", "sourceType": "github", From 3721633db8ec987455a1fd662c35467b44fcb76d Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:35:27 +0530 Subject: [PATCH 02/16] Install Bklit UI's area, bar and live line charts Adds the @bklit registry (now served from bklit.com) and pulls the three charts in as source under components/charts. Two fixes on top of the CLI output: its theme tokens landed as var(----chart-*) and three of them referenced themselves, and ChartLoadingLabel imported shimmering-text from a path that doesn't exist. The d3 type packages it needs are added as dev dependencies. The vendored chart code trips the React Compiler lint rules, and `shadcn add --overwrite` would undo any local fixes, so ESLint skips it. --- app/globals.css | 55 ++ components.json | 3 +- components/charts/animation.ts | 36 + components/charts/area-chart-loading.tsx | 116 +++ components/charts/area-chart.tsx | 272 +++++++ components/charts/area-gradient-defs.tsx | 113 +++ components/charts/area.tsx | 377 +++++++++ components/charts/bar-chart-loading.tsx | 38 + components/charts/bar-chart.tsx | 733 ++++++++++++++++++ components/charts/bar-depth-geometry.ts | 34 + components/charts/bar-squares-layout.ts | 99 +++ components/charts/bar-squares.tsx | 635 +++++++++++++++ components/charts/bar-x-axis.tsx | 153 ++++ components/charts/bar-y-axis.tsx | 142 ++++ components/charts/bar.tsx | 466 +++++++++++ components/charts/chart-child-passthrough.ts | 117 +++ components/charts/chart-config-context.tsx | 92 +++ components/charts/chart-context.tsx | 428 ++++++++++ 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 ++ components/charts/chart-phase.ts | 52 ++ components/charts/chart-reveal-clip.tsx | 92 +++ components/charts/dash-tail-stroke.tsx | 75 ++ components/charts/decimate-time-series.ts | 136 ++++ components/charts/fade-edges.ts | 52 ++ components/charts/filter-data-by-x-domain.ts | 59 ++ .../charts/generate-chart-skeleton-data.ts | 42 + components/charts/grid.tsx | 317 ++++++++ components/charts/highlight-segment-bounds.ts | 71 ++ components/charts/highlight-segment.tsx | 65 ++ 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 | 688 ++++++++++++++++ components/charts/live-line.tsx | 322 ++++++++ components/charts/live-x-axis.tsx | 151 ++++ components/charts/live-y-axis.tsx | 229 ++++++ components/charts/loading-sweep.tsx | 488 ++++++++++++ components/charts/motion-utils.ts | 58 ++ components/charts/path-stroke-utils.ts | 88 +++ components/charts/pattern-area.tsx | 49 ++ components/charts/pattern-preset.tsx | 186 +++++ components/charts/projection-config.ts | 129 +++ components/charts/projection-utils.ts | 386 +++++++++ components/charts/reference-area-config.ts | 68 ++ .../reference-area-registration-context.tsx | 16 + components/charts/series-bar-layout.ts | 61 ++ .../charts/series-dash-tail-overlay.tsx | 82 ++ components/charts/series-highlight-layer.tsx | 49 ++ components/charts/series-hover-dim.tsx | 60 ++ components/charts/series-markers.tsx | 294 +++++++ components/charts/series-path-utils.ts | 108 +++ components/charts/series-point-marker.tsx | 209 +++++ .../charts/static-chart-preview-context.tsx | 22 + components/charts/time-series-chart-shell.tsx | 704 +++++++++++++++++ components/charts/tooltip/chart-tooltip.tsx | 436 +++++++++++ components/charts/tooltip/date-ticker.tsx | 150 ++++ components/charts/tooltip/index.ts | 14 + components/charts/tooltip/tooltip-box.tsx | 235 ++++++ components/charts/tooltip/tooltip-content.tsx | 62 ++ components/charts/tooltip/tooltip-dot.tsx | 176 +++++ .../charts/tooltip/tooltip-indicator.tsx | 211 +++++ components/charts/use-animated-series-path.ts | 165 ++++ components/charts/use-animated-y-domains.ts | 242 ++++++ components/charts/use-chart-interaction.ts | 352 +++++++++ .../charts/use-chart-phase-orchestrator.ts | 183 +++++ components/charts/use-enter-complete.ts | 28 + components/charts/use-grid-shimmer.ts | 111 +++ components/charts/use-highlight-segment.ts | 61 ++ components/charts/use-mount-progress.ts | 29 + components/charts/use-scheduled-tooltip.ts | 97 +++ components/charts/visx-pattern.tsx | 33 + components/charts/x-axis.tsx | 667 ++++++++++++++++ components/charts/y-axis-scales.ts | 115 +++ components/charts/y-axis-ticks.ts | 25 + components/charts/y-domain-utils.ts | 131 ++++ components/shimmering-text.tsx | 90 +++ eslint.config.mjs | 4 + package.json | 12 + pnpm-lock.yaml | 376 +++++++++ 82 files changed, 13807 insertions(+), 1 deletion(-) create mode 100644 components/charts/animation.ts create mode 100644 components/charts/area-chart-loading.tsx create mode 100644 components/charts/area-chart.tsx create mode 100644 components/charts/area-gradient-defs.tsx create mode 100644 components/charts/area.tsx create mode 100644 components/charts/bar-chart-loading.tsx create mode 100644 components/charts/bar-chart.tsx create mode 100644 components/charts/bar-depth-geometry.ts create mode 100644 components/charts/bar-squares-layout.ts create mode 100644 components/charts/bar-squares.tsx create mode 100644 components/charts/bar-x-axis.tsx create mode 100644 components/charts/bar-y-axis.tsx create mode 100644 components/charts/bar.tsx create mode 100644 components/charts/chart-child-passthrough.ts create mode 100644 components/charts/chart-config-context.tsx create mode 100644 components/charts/chart-context.tsx create mode 100644 components/charts/chart-defs.ts create mode 100644 components/charts/chart-formatters.ts create mode 100644 components/charts/chart-legend-hover.tsx create mode 100644 components/charts/chart-loading-label.tsx create mode 100644 components/charts/chart-phase.ts create mode 100644 components/charts/chart-reveal-clip.tsx create mode 100644 components/charts/dash-tail-stroke.tsx create mode 100644 components/charts/decimate-time-series.ts create mode 100644 components/charts/fade-edges.ts create mode 100644 components/charts/filter-data-by-x-domain.ts create mode 100644 components/charts/generate-chart-skeleton-data.ts create mode 100644 components/charts/grid.tsx create mode 100644 components/charts/highlight-segment-bounds.ts create mode 100644 components/charts/highlight-segment.tsx create mode 100644 components/charts/indicator-fade.ts create mode 100644 components/charts/line-loading-pulse.tsx create mode 100644 components/charts/line-loading-timing.ts create mode 100644 components/charts/live-line-chart.tsx create mode 100644 components/charts/live-line.tsx create mode 100644 components/charts/live-x-axis.tsx create mode 100644 components/charts/live-y-axis.tsx create mode 100644 components/charts/loading-sweep.tsx create mode 100644 components/charts/motion-utils.ts create mode 100644 components/charts/path-stroke-utils.ts create mode 100644 components/charts/pattern-area.tsx create mode 100644 components/charts/pattern-preset.tsx create mode 100644 components/charts/projection-config.ts create mode 100644 components/charts/projection-utils.ts create mode 100644 components/charts/reference-area-config.ts create mode 100644 components/charts/reference-area-registration-context.tsx create mode 100644 components/charts/series-bar-layout.ts create mode 100644 components/charts/series-dash-tail-overlay.tsx create mode 100644 components/charts/series-highlight-layer.tsx create mode 100644 components/charts/series-hover-dim.tsx create mode 100644 components/charts/series-markers.tsx create mode 100644 components/charts/series-path-utils.ts create mode 100644 components/charts/series-point-marker.tsx create mode 100644 components/charts/static-chart-preview-context.tsx create mode 100644 components/charts/time-series-chart-shell.tsx create mode 100644 components/charts/tooltip/chart-tooltip.tsx create mode 100644 components/charts/tooltip/date-ticker.tsx create mode 100644 components/charts/tooltip/index.ts create mode 100644 components/charts/tooltip/tooltip-box.tsx create mode 100644 components/charts/tooltip/tooltip-content.tsx create mode 100644 components/charts/tooltip/tooltip-dot.tsx create mode 100644 components/charts/tooltip/tooltip-indicator.tsx create mode 100644 components/charts/use-animated-series-path.ts create mode 100644 components/charts/use-animated-y-domains.ts create mode 100644 components/charts/use-chart-interaction.ts create mode 100644 components/charts/use-chart-phase-orchestrator.ts create mode 100644 components/charts/use-enter-complete.ts create mode 100644 components/charts/use-grid-shimmer.ts create mode 100644 components/charts/use-highlight-segment.ts create mode 100644 components/charts/use-mount-progress.ts create mode 100644 components/charts/use-scheduled-tooltip.ts create mode 100644 components/charts/visx-pattern.tsx create mode 100644 components/charts/x-axis.tsx create mode 100644 components/charts/y-axis-scales.ts create mode 100644 components/charts/y-axis-ticks.ts create mode 100644 components/charts/y-domain-utils.ts create mode 100644 components/shimmering-text.tsx diff --git a/app/globals.css b/app/globals.css index 8a2b6c7..0c7dc28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -50,6 +50,24 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --color-chart-scale-pattern-color: var(--chart-scale-pattern-color); + --color-chart-scale-05: var(--chart-scale-05); + --color-chart-scale-04: var(--chart-scale-04); + --color-chart-scale-03: var(--chart-scale-03); + --color-chart-scale-02: var(--chart-scale-02); + --color-chart-scale-01: var(--chart-scale-01); + --color-chart-label: var(--chart-label); + --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); + --color-chart-grid: var(--chart-grid); + --color-chart-crosshair: var(--chart-crosshair); + --color-chart-foreground-muted: var(--chart-foreground-muted); + --color-chart-foreground: var(--chart-foreground); + --color-chart-background: var(--chart-background); } :root { @@ -89,6 +107,27 @@ --chrome: oklch(0.967 0 0); /* The red of the logo's eyes. */ --brand: oklch(0.6 0.23 29); + --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-label: oklch(0.45 0.01 260); + --chart-scale-01: oklch(0.98 0.003 106); + --chart-scale-02: oklch(0.92 0.008 106); + --chart-scale-03: oklch(0.82 0.015 106); + --chart-scale-04: oklch(0.68 0.02 106); + --chart-scale-05: oklch(0.55 0.025 106); + --chart-scale-pattern-color: oklch(0.96 0.005 106); } .dark { @@ -125,6 +164,22 @@ --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); --brand: oklch(0.66 0.23 32); + --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-label: oklch(0.75 0.01 260); + --chart-scale-01: oklch(0.28 0.03 270); + --chart-scale-02: oklch(0.38 0.04 270); + --chart-scale-03: oklch(0.48 0.05 270); + --chart-scale-04: oklch(0.58 0.06 270); + --chart-scale-05: oklch(0.68 0.07 270); + --chart-scale-pattern-color: oklch(0.145 0 0); } @layer base { diff --git a/components.json b/components.json index 27e35d2..917bfbc 100644 --- a/components.json +++ b/components.json @@ -25,6 +25,7 @@ "@evilbuttons": "http://localhost:3000/r/{name}.json", "@xcn": "https://ui.radiumcoders.com/r/xcn/{name}.json", "@react-bits": "https://reactbits.dev/r/{name}.json", - "@ogimagecn": "https://ogimagecn.vercel.app/r/{name}.json" + "@ogimagecn": "https://ogimagecn.vercel.app/r/{name}.json", + "@bklit": "https://bklit.com/r/{name}.json" } } diff --git a/components/charts/animation.ts b/components/charts/animation.ts new file mode 100644 index 0000000..0b5569b --- /dev/null +++ b/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/components/charts/area-chart-loading.tsx b/components/charts/area-chart-loading.tsx new file mode 100644 index 0000000..0e90530 --- /dev/null +++ b/components/charts/area-chart-loading.tsx @@ -0,0 +1,116 @@ +"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 type { LoadingStyle } from "./chart-phase"; +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; + /** Loading animation: `"pulse"` (default traveling pulse) or `"sweep"` (a + * diagonal shimmer across the skeleton area). Default: `"pulse"`. */ + loadingStyle?: LoadingStyle; + /** 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, + loadingStyle = "pulse", + 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/components/charts/area-chart.tsx b/components/charts/area-chart.tsx new file mode 100644 index 0000000..51b871e --- /dev/null +++ b/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/components/charts/area-gradient-defs.tsx b/components/charts/area-gradient-defs.tsx new file mode 100644 index 0000000..cb142cb --- /dev/null +++ b/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/components/charts/area.tsx b/components/charts/area.tsx new file mode 100644 index 0000000..1439a7d --- /dev/null +++ b/components/charts/area.tsx @@ -0,0 +1,377 @@ +"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, LoadingStyle } 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 { LineLoadingSweep } from "./loading-sweep"; +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; + /** + * Loading animation while the chart is in loading status: the default + * traveling `"pulse"`, or a diagonal `"sweep"` shimmer across the skeleton + * area. Default: `"pulse"`. + */ + loadingStyle?: LoadingStyle; +} + +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, + loadingStyle = "pulse", +}: 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} + + ); + + // Sweep style owns all loading modes (loop + the exit/enter transitions), + // drawing its own silhouette; the pulse covers the default style. + const sweepLoading = + showLoadingPulse && innerWidth > 0 && loadingStyle === "sweep"; + const pulseLoading = showLoadingPulse && innerWidth > 0 && !sweepLoading; + + return ( + <> + + + + {useViewportEdgeFade ? ( + {seriesLayers} + ) : ( + seriesLayers + )} + + + {/* Highlight segment on hover — isolated hover subscriber. */} + + + {showMarkers && showSeriesContent ? ( + + ) : null} + + {sweepLoading ? ( + + ) : null} + {pulseLoading && pathD ? ( + + ) : null} + + ); +} + +Area.displayName = "Area"; + +export default Area; diff --git a/components/charts/bar-chart-loading.tsx b/components/charts/bar-chart-loading.tsx new file mode 100644 index 0000000..8258273 --- /dev/null +++ b/components/charts/bar-chart-loading.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { BarChart } from "./bar-chart"; +import type { Margin } from "./chart-context"; + +const EMPTY_DATA: Record[] = []; + +export interface BarChartLoadingProps { + /** Chart margins. */ + margin?: Partial; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container. */ + className?: string; +} + +/** + * Turnkey loading skeleton for bar charts, a thin shortcut for + * ``. Renders shimmer-swept placeholder bars while + * data is fetching; swap in a real `` once it resolves. + */ +export function BarChartLoading({ + margin, + aspectRatio = "2 / 1", + className = "", +}: BarChartLoadingProps) { + return ( + + ); +} + +export default BarChartLoading; diff --git a/components/charts/bar-chart.tsx b/components/charts/bar-chart.tsx new file mode 100644 index 0000000..ccf7ca1 --- /dev/null +++ b/components/charts/bar-chart.tsx @@ -0,0 +1,733 @@ +"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 { + 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 { topSquareCenterY } from "./bar-squares-layout"; +import { + forEachChartChild, + isChartClipPassthrough, + isClipExcludedComponent, + isPostOverlayComponent, + isUnderlayComponent, + renderKeyedChartLayers, + resolveChartChildElement, +} from "./chart-child-passthrough"; +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, + type ChartStatus, + DEFAULT_CHART_LIFECYCLE, + resolveRestingChartPhase, +} from "./chart-phase"; +import { BarLoadingSkeleton } from "./loading-sweep"; +import { extractReferenceAreaConfigs } from "./reference-area-config"; +import { useScheduledTooltip } from "./use-scheduled-tooltip"; +import { + buildYScalesForLines, + getPrimaryYScale, + normalizeYAxisId, + wrapSingleYScale, +} from "./y-axis-scales"; + +/** Skeleton bars to show when `status="loading"` and `data` is empty. */ +const FALLBACK_LOADING_BARS = 12; + +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; + /** When set, tooltip Y positions snap to the top square center (shape variant). */ + squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean }; + /** Child components (Bar, Grid, ChartTooltip, etc.). Optional — omit for a + * pure `status="loading"` skeleton. */ + children?: ReactNode; + /** Reports reveal lifecycle for OG screenshots and loading orchestration. */ + onPhaseChange?: (phase: ChartPhase) => void; + /** Fetch / display status. When `"loading"`, a shimmer skeleton replaces the + * bars (no chart data required). Default: `"ready"`. */ + status?: ChartStatus; +} + +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[] = []; + + forEachChartChild(children, (child) => { + 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" || + componentName === "BarSquares" || + (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; +} + +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; + squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean }; + children: ReactNode; + containerRef: React.RefObject; + onPhaseChange?: (phase: ChartPhase) => void; + status: ChartStatus; +} + +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, + squareSnap, + children, + containerRef, + onPhaseChange, + status, +}: 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); + // While loading, hold the skeleton (no reveal, no interaction). When + // status flips to "ready" this effect re-runs and plays the grow reveal. + if (status === "loading") { + return; + } + const staggerMs = data.length > 1 ? animationDuration * 0.4 : 0; + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration + staggerMs); + return () => clearTimeout(timer); + }, [animationDuration, revealSignature, status]); + + 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; + const baselineY = axisScale(0) ?? innerHeight; + const valueY = axisScale(value) ?? 0; + const barLengthPx = baselineY - valueY; + + if (squareSnap && !isHorizontal && value > 0) { + yPositions[line.dataKey] = topSquareCenterY({ + baselineY, + barLengthPx, + squareSize: individualBarWidth, + gap: squareSnap.squareGap, + fit: squareSnap.fit, + }); + } else { + yPositions[line.dataKey] = valueY; + } + + 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, + squareSnap, + innerHeight, + ] + ); + + const handleMouseLeave = useCallback(() => { + clearTooltip(); + }, [clearTooltip]); + + const canInteract = isLoaded; + + // Separate children into defs, pre-overlay, and post-overlay + const defsChildren: ReactElement[] = []; + const clipExcludedChildren: ReactElement[] = []; + const underlayChildren: ReactElement[] = []; + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + forEachChartChild(children, (child) => { + const resolvedChild = resolveChartChildElement(child); + + if (isGradientDefComponent(child)) { + defsChildren.push(child); + } else if (isPatternDefComponent(child)) { + preOverlayChildren.push(child); + } else if (isPostOverlayComponent(resolvedChild)) { + postOverlayChildren.push(resolvedChild); + } else if (isClipExcludedComponent(resolvedChild)) { + clipExcludedChildren.push( + isChartClipPassthrough(child.type) ? resolvedChild : child + ); + } else if (isUnderlayComponent(resolvedChild)) { + underlayChildren.push(resolvedChild); + } else { + preOverlayChildren.push(child); + } + }); + + const referenceAreas = useMemo( + () => extractReferenceAreaConfigs(children), + [children] + ); + + const contextValue = { + ...DEFAULT_CHART_LIFECYCLE, + chartPhase: resolveRestingChartPhase(status), + chartStatus: status, + 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, + referenceAreas, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor: xAccessorDate, + dateLabels, + // Bar-specific properties + barScale: categoryScale, + bandWidth, + hoveredBarIndex, + barXAccessor: categoryAccessor, + orientation, + stacked, + stackOffsets, + squareSnap, + }; + + 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, + squareSnap, + children, + onPhaseChange, + status = "ready", +}: BarChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +BarChart.displayName = "BarChart"; + +export default BarChart; diff --git a/components/charts/bar-depth-geometry.ts b/components/charts/bar-depth-geometry.ts new file mode 100644 index 0000000..d00743b --- /dev/null +++ b/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/components/charts/bar-squares-layout.ts b/components/charts/bar-squares-layout.ts new file mode 100644 index 0000000..9561551 --- /dev/null +++ b/components/charts/bar-squares-layout.ts @@ -0,0 +1,99 @@ +export interface SquareColumnLayout { + /** Number of squares in the column */ + count: number; + /** Top-left Y of each square, bottom square first (relative to bar top at 0) */ + positions: number[]; + /** Quantized column height in pixels */ + columnHeight: number; + squareSize: number; + /** Effective gap between squares (may differ when fit mode redistributes) */ + gap: number; +} + +export interface SquareColumnInput { + /** Raw bar length in pixels (baseline − value) */ + barLengthPx: number; + /** Square width/height — typically equals bar width */ + squareSize: number; + /** Gap between stacked squares in pixels */ + gap: number; + /** When true, redistribute gap so column height matches barLengthPx exactly */ + fit?: boolean; +} + +/** Quantize bar length into a stack of square cells. */ +export function computeSquareColumn({ + barLengthPx, + squareSize, + gap, + fit = false, +}: SquareColumnInput): SquareColumnLayout { + if (barLengthPx <= 0 || squareSize <= 0) { + return { count: 0, positions: [], columnHeight: 0, squareSize, gap }; + } + + if (fit) { + const count = Math.max( + 1, + Math.floor((barLengthPx + gap) / (squareSize + gap)) + ); + const effectiveGap = + count > 1 + ? Math.max(0, (barLengthPx - count * squareSize) / (count - 1)) + : 0; + const step = squareSize + effectiveGap; + const columnHeight = barLengthPx; + const positions: number[] = []; + + for (let i = 0; i < count; i++) { + positions.push(columnHeight - squareSize - i * step); + } + + return { + count, + positions, + columnHeight, + squareSize, + gap: effectiveGap, + }; + } + + const step = squareSize + gap; + const count = Math.max(1, Math.round(barLengthPx / step)); + const columnHeight = count * squareSize + Math.max(0, count - 1) * gap; + + const positions: number[] = []; + for (let i = 0; i < count; i++) { + const offsetFromBottom = i * step; + positions.push(columnHeight - squareSize - offsetFromBottom); + } + + return { count, positions, columnHeight, squareSize, gap }; +} + +/** Y center of the topmost square in a vertical column. */ +export function topSquareCenterY({ + baselineY, + barLengthPx, + squareSize, + gap, + fit = false, +}: SquareColumnInput & { baselineY: number }): number { + const { + count, + squareSize: size, + columnHeight, + } = computeSquareColumn({ + barLengthPx, + squareSize, + gap, + fit, + }); + + if (count === 0) { + return baselineY; + } + + const topY = baselineY - columnHeight; + return topY + size / 2; +} diff --git a/components/charts/bar-squares.tsx b/components/charts/bar-squares.tsx new file mode 100644 index 0000000..225fb16 --- /dev/null +++ b/components/charts/bar-squares.tsx @@ -0,0 +1,635 @@ +"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 { computeSquareColumn } from "./bar-squares-layout"; +import { + chartCssVars, + useChart, + useChartStable, + useYScale, +} from "./chart-context"; +import { useChartLegendHover } from "./chart-legend-hover"; +import { transitionWithDelay } from "./motion-utils"; +import { type PatternPresetId, renderPatternPreset } from "./pattern-preset"; + +type ScaleBand = ReturnType< + typeof scaleBand +>; + +export interface GradientStop { + offset: number; + color: string; +} + +export interface BarSquaresProps { + dataKey: string; + yAxisId?: string | number; + /** Fill color, gradient url, or pattern url. Default: var(--chart-line-primary) */ + fill?: string; + /** Tooltip dot / ring stroke color when fill is gradient/pattern */ + stroke?: string; + /** Gap between stacked squares in pixels. Default: 3 */ + squareGap?: number; + /** Corner radius as a fraction of square size (0 = flat, 0.5 = circle). Default: 0.25 */ + squareRadius?: number; + /** Redistribute gap so columns fit bar height exactly */ + squareFit?: boolean; + /** Apply bar-spanning gradient from gradientStops */ + useGradient?: boolean; + gradientStops?: GradientStop[]; + /** Pattern preset when fill is a pattern (for gradient tinting) */ + patternPreset?: PatternPresetId; + animate?: boolean; + fadedOpacity?: number; + staggerDelay?: number; + groupGap?: number; +} + +export interface BarColumnTrackProps { + /** Fill color or pattern url. Default: var(--chart-grid) */ + fill?: string; + opacity?: number; + squareGap?: number; + /** Corner radius fraction (matches squares). Default: 0.25 */ + squareRadius?: number; + groupGap?: number; + squareFit?: boolean; + staggerDelay?: number; +} + +interface BarSquaresInnerProps extends BarSquaresProps { + barScale: ScaleBand; + bandWidth: number; + barXAccessor: (d: Record) => string; +} + +interface SquareColumnProps { + x: number; + baselineY: number; + barLengthPx: number; + squareSize: number; + squareGap: number; + squareRadius: number; + squareFit: boolean; + fill: string; + useGradient: boolean; + gradientStops: GradientStop[]; + patternPreset?: PatternPresetId; + index: number; + isFaded: boolean; + fadedOpacity: number; + animate: boolean; + staggerDelay: number; + animationDuration: number; + enterTransition?: Transition; + revealEpoch: number; +} + +function isPatternFill(fill: string): boolean { + return fill.startsWith("url("); +} + +/** Delay between stacked squares within one column (bottom → top). */ +function squareCascadeStepSeconds( + enterTransition: Transition | undefined, + animationDurationMs: number, + squareCount: number +): number { + if (squareCount <= 1) { + return 0; + } + const durationMs = + enterTransition?.type === "tween" && + typeof enterTransition.duration === "number" + ? enterTransition.duration * 1000 + : animationDurationMs; + const cascadeSpreadMs = durationMs * 0.4; + return cascadeSpreadMs / 1000 / (squareCount - 1); +} + +function cascadeColumnTransition( + enterTransition: Transition | undefined, + animationDurationMs: number, + columnIndex: number, + columnStaggerDelay: number, + squareCount: number +): Transition { + const cascadeStep = squareCascadeStepSeconds( + enterTransition, + animationDurationMs, + squareCount + ); + const base = transitionWithDelay( + enterTransition, + columnIndex * columnStaggerDelay + ); + if (squareCount <= 1 || base.type !== "tween") { + return base; + } + const baseDuration = + typeof base.duration === "number" + ? base.duration + : animationDurationMs / 1000; + return { + ...base, + duration: baseDuration + cascadeStep * (squareCount - 1), + }; +} + +function SquareColumn({ + x, + baselineY, + barLengthPx, + squareSize, + squareGap, + squareRadius, + squareFit, + fill, + useGradient, + gradientStops, + patternPreset, + index, + isFaded, + fadedOpacity, + animate, + staggerDelay, + animationDuration, + enterTransition, + revealEpoch, +}: SquareColumnProps) { + const layout = useMemo( + () => + computeSquareColumn({ + barLengthPx, + squareSize, + gap: squareGap, + fit: squareFit, + }), + [barLengthPx, squareSize, squareGap, squareFit] + ); + + const rx = squareSize * squareRadius; + const columnTop = baselineY - layout.columnHeight; + const gradientId = `bar-squares-gradient-${index}-${revealEpoch}`; + const patternFill = isPatternFill(fill); + const patternId = `bar-squares-pattern-${index}-${revealEpoch}`; + + const effectiveFill = useMemo(() => { + if (useGradient) { + if (patternFill && patternPreset && patternPreset !== "none") { + return `url(#${patternId})`; + } + return `url(#${gradientId})`; + } + return fill; + }, [useGradient, patternFill, patternPreset, fill, gradientId, patternId]); + + const cascadeStep = squareCascadeStepSeconds( + enterTransition, + animationDuration, + layout.count + ); + const squareOpacity = isFaded ? fadedOpacity : 1; + + const gradientPatternNode = + useGradient && patternFill && patternPreset && patternPreset !== "none" + ? renderPatternPreset(patternPreset, patternId, { + color: `url(#${gradientId})`, + }) + : null; + + const gradientDefs = useGradient ? ( + + + {gradientStops.map((stop) => ( + + ))} + + {gradientPatternNode} + + ) : null; + + const squares = layout.positions.map((relY, squareIndex) => { + const y = columnTop + relY; + const bottomY = y + squareSize; + const key = `sq-${index}-${squareIndex}-${revealEpoch}`; + + if (!animate) { + return ( + + ); + } + + return ( + + ); + }); + + return ( + <> + {gradientDefs} + {squares} + + ); +} + +const BarSquaresInner = memo(function BarSquaresInner({ + dataKey, + yAxisId, + fill = chartCssVars.linePrimary, + squareGap = 3, + squareRadius = 0.25, + squareFit = false, + useGradient = false, + gradientStops = [], + patternPreset, + animate = true, + fadedOpacity = 0.3, + staggerDelay, + groupGap = 4, + barScale, + bandWidth, + barXAccessor, +}: BarSquaresInnerProps) { + const { + data, + innerHeight, + hoveredBarIndex, + lines, + orientation, + stacked, + animationDuration, + enterTransition, + revealEpoch = 0, + } = useChart(); + + const { hoveredIndex: legendHoveredIndex } = useChartLegendHover(); + const uniqueId = useId(); + + const isHorizontal = orientation === "horizontal"; + const isUnsupported = isHorizontal || stacked; + + 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 squareSize = useMemo(() => { + if (!bandWidth || seriesCount === 0) { + return 0; + } + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount; + }, [bandWidth, seriesCount, groupGap]); + + const totalAnimDuration = animationDuration || 1100; + const staggerSpread = totalAnimDuration * 0.4; + const calculatedStaggerDelay = + staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0); + + const baselineY = valueScale(0) ?? innerHeight; + const stops = + gradientStops.length >= 2 + ? gradientStops + : [ + { offset: 0, color: fill }, + { offset: 100, color: fill }, + ]; + + if (isUnsupported) { + return null; + } + + return ( + + {data.map((d, i) => { + const value = d[dataKey]; + if (typeof value !== "number" || value <= 0) { + return null; + } + + const categoryValue = barXAccessor(d); + const bandPos = barScale(categoryValue) ?? 0; + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap); + + const valuePos = valueScale(value) ?? 0; + const barLengthPx = baselineY - valuePos; + + const isFaded = + (hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed; + + return ( + + ); + })} + + ); +}); + +export function BarSquares(props: BarSquaresProps) { + const { barScale, bandWidth, barXAccessor } = useChartStable(); + + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("BarSquares must be used within a BarChart"); + return null; + } + + return ( + + ); +} + +BarSquares.displayName = "BarSquares"; + +const BarColumnTrackInner = memo(function BarColumnTrackInner({ + fill = chartCssVars.grid, + opacity = 0.3, + squareGap = 3, + squareRadius = 0.25, + squareFit = false, + groupGap = 4, + staggerDelay, + barScale, + bandWidth, + barXAccessor, +}: BarColumnTrackProps & { + barScale: ScaleBand; + bandWidth: number; + barXAccessor: (d: Record) => string; +}) { + const { + data, + lines, + orientation, + stacked, + hoveredBarIndex, + animationDuration, + enterTransition, + revealEpoch = 0, + } = useChart(); + const uniqueId = useId(); + + const isHorizontal = orientation === "horizontal"; + const isUnsupported = isHorizontal || stacked; + const seriesCount = lines.length; + + const squareSize = useMemo(() => { + if (!bandWidth || seriesCount === 0) { + return 0; + } + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount; + }, [bandWidth, seriesCount, groupGap]); + + const totalAnimDuration = animationDuration || 1100; + const staggerSpread = totalAnimDuration * 0.4; + const calculatedStaggerDelay = + staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0); + + if (isUnsupported) { + return null; + } + + const rx = squareSize * squareRadius; + const effectiveOpacity = hoveredBarIndex === null ? opacity : 0; + + return ( + + {data.map((d, i) => { + const categoryValue = barXAccessor(d); + const bandPos = barScale(categoryValue) ?? 0; + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + + return lines.map((line, seriesIndex) => ( + + )); + })} + + ); +}); + +function TrackColumn({ + d, + dataKey, + yAxisId, + bandPos, + seriesIndex, + effectiveGroupGap, + squareSize, + squareGap, + squareFit, + fill, + rx, + effectiveOpacity, + index, + staggerDelay, + animate, + enterTransition, + revealEpoch, +}: { + d: Record; + dataKey: string; + yAxisId?: string | number; + bandPos: number; + seriesIndex: number; + effectiveGroupGap: number; + squareSize: number; + squareGap: number; + squareFit: boolean; + fill: string; + rx: number; + effectiveOpacity: number; + index: number; + staggerDelay: number; + animate: boolean; + enterTransition?: Transition; + revealEpoch: number; +}) { + const { innerHeight, animationDuration: chartAnimationDuration } = useChart(); + const valueScale = useYScale(yAxisId); + const value = d[dataKey]; + + if (typeof value !== "number" || value <= 0) { + return null; + } + + const baselineY = valueScale(0) ?? innerHeight; + const valuePos = valueScale(value) ?? 0; + const barLengthPx = baselineY - valuePos; + const layout = computeSquareColumn({ + barLengthPx, + squareSize, + gap: squareGap, + fit: squareFit, + }); + const columnTop = baselineY - layout.columnHeight; + const trackHeight = Math.max(0, columnTop); + + if (trackHeight <= 0 && !animate) { + return null; + } + + const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap); + const enterAnim = cascadeColumnTransition( + enterTransition, + chartAnimationDuration || 1100, + index, + staggerDelay, + layout.count + ); + const animatedHeight = trackHeight > 0 ? trackHeight : 0; + + if (animate) { + return ( + + ); + } + + if (trackHeight <= 0) { + return null; + } + + return ( + + ); +} + +export function BarColumnTrack(props: BarColumnTrackProps) { + const { barScale, bandWidth, barXAccessor } = useChartStable(); + + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("BarColumnTrack must be used within a BarChart"); + return null; + } + + return ( + + ); +} + +BarColumnTrack.displayName = "BarColumnTrack"; + +export default BarSquares; diff --git a/components/charts/bar-x-axis.tsx b/components/charts/bar-x-axis.tsx new file mode 100644 index 0000000..2fbfa5f --- /dev/null +++ b/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/components/charts/bar-y-axis.tsx b/components/charts/bar-y-axis.tsx new file mode 100644 index 0000000..95400e5 --- /dev/null +++ b/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/components/charts/bar.tsx b/components/charts/bar.tsx new file mode 100644 index 0000000..6f3d815 --- /dev/null +++ b/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/components/charts/chart-child-passthrough.ts b/components/charts/chart-child-passthrough.ts new file mode 100644 index 0000000..d7a775c --- /dev/null +++ b/components/charts/chart-child-passthrough.ts @@ -0,0 +1,117 @@ +import { + Children, + cloneElement, + Fragment, + isValidElement, + type ReactElement, + type ReactNode, +} 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; +} + +/** Walk chart children, flattening React fragments (studio often groups layers in `<>...`). */ +export function forEachChartChild( + children: ReactNode, + callback: (child: ReactElement, index: number) => void +) { + let index = 0; + const visit = (nodes: ReactNode) => { + Children.forEach(nodes, (child) => { + if (!isValidElement(child)) { + return; + } + if (child.type === Fragment) { + visit((child.props as { children?: ReactNode }).children); + return; + } + callback(child, index); + index += 1; + }); + }; + visit(children); +} + +const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([ + "Background", + "Grid", + "XAxis", + "YAxis", + "BarXAxis", + "BarYAxis", + "LiveXAxis", + "LiveYAxis", +]); + +const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea", "BarColumnTrack"]); + +/** 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; + __isPostOverlay?: boolean; + }; + + if (childType.__isChartMarkers || childType.__isPostOverlay) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return ( + componentName === "ChartMarkers" || + componentName === "MarkerGroup" || + componentName === "ChartBrush" + ); +} + +/** Renders above grid/axes but below series; excluded from grow-clip reveal. */ +export function isUnderlayComponent(child: ReactElement): boolean { + const childType = child.type as { displayName?: string; name?: string }; + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + return UNDERLAY_COMPONENT_NAMES.has(componentName); +} + +/** 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); +} + +/** SVG layer lists from chart shells need stable keys when rendered as arrays. */ +export function renderKeyedChartLayers(children: ReactElement[]) { + return children.map((child, index) => + cloneElement(child, { key: child.key ?? `chart-layer-${index}` }) + ); +} diff --git a/components/charts/chart-config-context.tsx b/components/charts/chart-config-context.tsx new file mode 100644 index 0000000..2faf405 --- /dev/null +++ b/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/components/charts/chart-context.tsx b/components/charts/chart-context.tsx new file mode 100644 index 0000000..a3ded04 --- /dev/null +++ b/components/charts/chart-context.tsx @@ -0,0 +1,428 @@ +"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 { ReferenceAreaConfig } from "./reference-area-config"; +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)", + tooltipBackground: "var(--chart-tooltip-background)", +}; + +/** 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[]; + + /** {@link ReferenceArea} bands — drives y-axis label colors in range. */ + referenceAreas: ReferenceAreaConfig[]; + + // 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>; + /** Squares variant — snap tooltip to top square and size ring dots. */ + squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean }; + + // 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, + referenceAreas: value.referenceAreas, + 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.referenceAreas, + 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/components/charts/chart-defs.ts b/components/charts/chart-defs.ts new file mode 100644 index 0000000..6dd941a --- /dev/null +++ b/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/components/charts/chart-formatters.ts b/components/charts/chart-formatters.ts new file mode 100644 index 0000000..d8b0070 --- /dev/null +++ b/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/components/charts/chart-legend-hover.tsx b/components/charts/chart-legend-hover.tsx new file mode 100644 index 0000000..b95fc71 --- /dev/null +++ b/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/components/charts/chart-loading-label.tsx b/components/charts/chart-loading-label.tsx new file mode 100644 index 0000000..d38078e --- /dev/null +++ b/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/components/charts/chart-phase.ts b/components/charts/chart-phase.ts new file mode 100644 index 0000000..0385624 --- /dev/null +++ b/components/charts/chart-phase.ts @@ -0,0 +1,52 @@ +export type ChartStatus = "loading" | "ready"; + +/** Loading animation style: the default traveling pulse, or a diagonal + * shimmer that sweeps across the skeleton. */ +export type LoadingStyle = "pulse" | "sweep"; + +/** + * 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/components/charts/chart-reveal-clip.tsx b/components/charts/chart-reveal-clip.tsx new file mode 100644 index 0000000..ee4161a --- /dev/null +++ b/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/components/charts/dash-tail-stroke.tsx b/components/charts/dash-tail-stroke.tsx new file mode 100644 index 0000000..becbca5 --- /dev/null +++ b/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/components/charts/decimate-time-series.ts b/components/charts/decimate-time-series.ts new file mode 100644 index 0000000..7749a0c --- /dev/null +++ b/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/components/charts/fade-edges.ts b/components/charts/fade-edges.ts new file mode 100644 index 0000000..084aec2 --- /dev/null +++ b/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/components/charts/filter-data-by-x-domain.ts b/components/charts/filter-data-by-x-domain.ts new file mode 100644 index 0000000..6c72279 --- /dev/null +++ b/components/charts/filter-data-by-x-domain.ts @@ -0,0 +1,59 @@ +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)]; +} + +/** Brush track extent — optionally extends past the last data row (e.g. projections). */ +export function resolveBrushTrackXExtent( + data: Record[], + xAccessor: (d: Record) => Date, + xExtentMax?: Date +): [Date, Date] | null { + const extent = resolveDataXExtent(data, xAccessor); + if (!extent) { + return null; + } + if (!xExtentMax || xExtentMax.getTime() <= extent[1].getTime()) { + return extent; + } + return [extent[0], xExtentMax]; +} diff --git a/components/charts/generate-chart-skeleton-data.ts b/components/charts/generate-chart-skeleton-data.ts new file mode 100644 index 0000000..b838c5d --- /dev/null +++ b/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/components/charts/grid.tsx b/components/charts/grid.tsx new file mode 100644 index 0000000..c030c38 --- /dev/null +++ b/components/charts/grid.tsx @@ -0,0 +1,317 @@ +"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`; + const horizontalFadeMask = fadeHorizontal ? `url(#${hMaskId})` : undefined; + + return ( + + {/* Gradient mask for horizontal grid lines - fades at left/right */} + {horizontal && fadeHorizontal && ( + + + + + + + + + + + + )} + + {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/components/charts/highlight-segment-bounds.ts b/components/charts/highlight-segment-bounds.ts new file mode 100644 index 0000000..19e66a9 --- /dev/null +++ b/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/components/charts/highlight-segment.tsx b/components/charts/highlight-segment.tsx new file mode 100644 index 0000000..aa6295a --- /dev/null +++ b/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/components/charts/indicator-fade.ts b/components/charts/indicator-fade.ts new file mode 100644 index 0000000..e939211 --- /dev/null +++ b/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/components/charts/line-loading-pulse.tsx b/components/charts/line-loading-pulse.tsx new file mode 100644 index 0000000..31296c2 --- /dev/null +++ b/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/components/charts/line-loading-timing.ts b/components/charts/line-loading-timing.ts new file mode 100644 index 0000000..726505d --- /dev/null +++ b/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/components/charts/live-line-chart.tsx b/components/charts/live-line-chart.tsx new file mode 100644 index 0000000..8f2830b --- /dev/null +++ b/components/charts/live-line-chart.tsx @@ -0,0 +1,688 @@ +"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 ReactElement, + type ReactNode, + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import { + isClipExcludedComponent, + isUnderlayComponent, +} from "./chart-child-passthrough"; +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 { extractReferenceAreaConfigs } from "./reference-area-config"; +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 clipExcludedChildren: ReactElement[] = []; + const underlayChildren: ReactElement[] = []; + const seriesChildren: ReactElement[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + if (isClipExcludedComponent(child)) { + clipExcludedChildren.push(child); + } else if (isUnderlayComponent(child)) { + underlayChildren.push(child); + } else { + seriesChildren.push(child); + } + }); + + const referenceAreas = useMemo( + () => extractReferenceAreaConfigs(children), + [children] + ); + + 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, + referenceAreas, + isLoaded: true, + animationDuration: 0, + xAccessor, + dateLabels, + }), + [ + contextData, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + containerRef, + lines, + referenceAreas, + 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/components/charts/live-line.tsx b/components/charts/live-line.tsx new file mode 100644 index 0000000..35e3160 --- /dev/null +++ b/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/components/charts/live-x-axis.tsx b/components/charts/live-x-axis.tsx new file mode 100644 index 0000000..af768d2 --- /dev/null +++ b/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/components/charts/live-y-axis.tsx b/components/charts/live-y-axis.tsx new file mode 100644 index 0000000..ca089ae --- /dev/null +++ b/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/components/charts/loading-sweep.tsx b/components/charts/loading-sweep.tsx new file mode 100644 index 0000000..e1c988a --- /dev/null +++ b/components/charts/loading-sweep.tsx @@ -0,0 +1,488 @@ +"use client"; + +import { scaleLinear } from "@visx/scale"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { motion, useReducedMotion } from "motion/react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; +import { chartCssVars, useChartStable } from "./chart-context"; +import { + LINE_LOADING_PULSE_EASE, + LOADING_LABEL_EXIT_S, +} from "./line-loading-timing"; + +/** + * Shared "sweep" loading visuals. A soft diagonal shimmer band travels across a + * self-contained skeleton silhouette on a loop, painted via an SVG mask. The + * silhouette re-randomizes between passes (held steady during a pass, re-rolled + * once the band clears the right edge) so it reads as live and still loading, + * without warping mid-sweep. Used as the `loadingStyle="sweep"` alternative to + * the traveling pulse on `` and ``, and as the skeleton for + * ``. + */ + +// CurveFactory type - simplified version compatible with visx +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +/** One shimmer sweep, in seconds. */ +const DEFAULT_SWEEP_DURATION_S = 2; +/** Sweep travel in objectBoundingBox space: off the left edge to off the right. */ +const SWEEP_START_X = -1; +const SWEEP_END_X = 2; +/** Diagonal tilt of the shimmer band, in degrees. */ +const SWEEP_ANGLE_DEG = 25; +const HEIGHT_MIN_PCT = 20; +const HEIGHT_MAX_PCT = 80; +const DEFAULT_POINT_COUNT = 14; +const BAR_CORNER_RADIUS = 2; +const DEFAULT_BAR_COUNT = 12; +const DEFAULT_FILL = "var(--foreground)"; +const DEFAULT_BAR_FILL_OPACITY = 0.45; +const LINE_STROKE_OPACITY = 0.55; +const AREA_FILL_TOP_OPACITY = 0.18; +const AREA_FILL_BOTTOM_OPACITY = 0.02; +/** Bar width as a fraction of its band (the rest is the inter-bar gap). */ +const DEFAULT_BAR_FRACTION = 0.7; + +// ─── Pure, SSR-safe helpers ────────────────────────────────────────────── +// Heights come from a deterministic hash of (index, seed), never +// `Math.random()`, so the first server render and first client render agree +// (no Next.js hydration mismatch). Re-randomizing only bumps the numeric seed +// on the client, after a sweep completes. + +/** Cheap deterministic hash to a fractional part in [0, 1). */ +function hashFract(n: number): number { + const x = Math.sin(n) * 43_758.5453; + return x - Math.floor(x); +} + +/** Deterministic heights (percentages of the available height) for a seed. */ +export function getSkeletonHeights( + count: number, + seed = 0, + min = HEIGHT_MIN_PCT, + max = HEIGHT_MAX_PCT +): number[] { + const range = max - min; + return Array.from( + { length: count }, + (_, i) => min + Math.floor(hashFract((i + 1) * 12.9898 + seed) * range) + ); +} + +/** Deterministic up/down (±1) per bar for the "center" baseline. */ +function getSkeletonSigns(count: number, seed = 0): number[] { + return Array.from({ length: count }, (_, i) => + hashFract((i + 1) * 78.233 + seed) < 0.5 ? -1 : 1 + ); +} + +/** Bell-curve opacity stops (sin squared) for the shimmer band's soft edges. */ +function generateEasedGradientStops( + steps = 17, + minOpacity = 0.05, + maxOpacity = 0.9 +) { + return Array.from({ length: steps }, (_, i) => { + const t = i / (steps - 1); + const eased = Math.sin(t * Math.PI) ** 2; + const opacity = minOpacity + eased * (maxOpacity - minOpacity); + return { + offset: `${(t * 100).toFixed(0)}%`, + opacity: Number(opacity.toFixed(3)), + }; + }); +} + +// ─── Shared mask defs (`${chartId}-mask` is the mask id) ───────────────────── + +function LoadingSweepMask({ + chartId, + width, + height, + durationSeconds, + onSweepComplete, +}: { + chartId: string; + width: number; + height: number; + durationSeconds: number; + onSweepComplete: () => void; +}) { + const gradientStops = useMemo(() => generateEasedGradientStops(), []); + const lastXRef = useRef(SWEEP_START_X); + + const handleUpdate = useCallback( + (latest: { x?: number }) => { + const xValue = typeof latest.x === "number" ? latest.x : SWEEP_START_X; + // Re-roll once the band has cleared the visible area (crossed past 1), + // so the silhouette never changes shape under the user's eye. + if (xValue >= 1 && lastXRef.current < 1) { + onSweepComplete(); + } + lastXRef.current = xValue; + }, + [onSweepComplete] + ); + + return ( + <> + + {gradientStops.map(({ offset, opacity }) => ( + + ))} + + + + + + + + + ); +} + +// ─── Line / Area loading sweep (its own re-randomizing silhouette) ─────────── + +export interface LineLoadingSweepProps { + /** Curve factory from the host `` / ``, so the silhouette matches + * the chart's interpolation (step, smooth, linear, …). */ + curve: CurveFactory; + /** Fill the silhouette as an area (for ``); otherwise stroke only. */ + withArea?: boolean; + /** Loading phase: `"loop"` (steady), `"exit"` (loading → ready), or `"enter"` + * (ready → loading). Exit/enter fade the silhouette and then signal the chart + * to continue its reveal. Default: `"loop"`. */ + mode?: "loop" | "exit" | "enter"; + /** Fired when an exit/enter transition finishes, to advance the chart phase. */ + onTransitionComplete?: () => void; + stroke?: string; + strokeOpacity?: number; + strokeWidth?: number; + pointCount?: number; + durationSeconds?: number; +} + +/** + * Renders a placeholder line/area silhouette (its own, not the chart's skeleton) + * with the shimmer sweeping across it. The silhouette re-randomizes between + * passes. Reads inner dimensions from chart context. + */ +export function LineLoadingSweep({ + curve, + withArea = false, + mode = "loop", + onTransitionComplete, + stroke = chartCssVars.foreground, + strokeOpacity = LINE_STROKE_OPACITY, + strokeWidth = 2, + pointCount = DEFAULT_POINT_COUNT, + durationSeconds = DEFAULT_SWEEP_DURATION_S, +}: LineLoadingSweepProps) { + const { innerWidth, innerHeight } = useChartStable(); + const reduceMotion = useReducedMotion(); + const reactId = useId(); + const chartId = `line-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; + const isLoop = mode === "loop"; + + const [tick, setTick] = useState(0); + // Re-randomize only while looping; hold the silhouette steady through a + // transition so it fades out (or in) as one piece. + const onSweepComplete = useCallback(() => { + if (isLoop) { + setTick((prev) => prev + 1); + } + }, [isLoop]); + const heights = useMemo( + () => getSkeletonHeights(pointCount, tick), + [pointCount, tick] + ); + + // With reduced motion there is no fade to await, so signal the handoff + // immediately or the phase machine would stall mid-transition. + useEffect(() => { + if (reduceMotion && !isLoop) { + onTransitionComplete?.(); + } + }, [reduceMotion, isLoop, onTransitionComplete]); + + if (innerWidth <= 0 || innerHeight <= 0 || heights.length < 2) { + return null; + } + + const xScale = scaleLinear({ + domain: [0, heights.length - 1], + range: [0, innerWidth], + }); + const yScale = scaleLinear({ domain: [0, 100], range: [innerHeight, 0] }); + const points = heights.map((value, index) => ({ index, value })); + const getX = (d: { index: number }) => xScale(d.index); + const getY = (d: { value: number }) => yScale(d.value); + + const silhouette = ( + <> + {withArea ? ( + + ) : null} + + + ); + + const areaGradient = withArea ? ( + + + + + ) : null; + + if (reduceMotion) { + return ( + <> + {areaGradient ? {areaGradient} : null} + {silhouette} + + ); + } + + const maskUrl = `url(#${chartId}-mask)`; + const defs = ( + + {areaGradient} + + + ); + + if (isLoop) { + return ( + <> + {defs} + {silhouette} + + ); + } + + // Transition: fade the swept silhouette out (exit) or in (enter), then hand + // off to the chart so it can reveal the real series. + return ( + <> + {defs} + + {silhouette} + + + ); +} + +LineLoadingSweep.displayName = "LineLoadingSweep"; + +// ─── Bar loading skeleton (seeded bars under the sweep, inner coords) ───────── + +function SkeletonBars({ + heights, + signs, + innerWidth, + innerHeight, + baseline, + barFraction, + fill, + fillOpacity, +}: { + heights: number[]; + signs: number[]; + innerWidth: number; + innerHeight: number; + baseline: "bottom" | "center"; + barFraction: number; + fill: string; + fillOpacity: number; +}) { + const bandWidth = innerWidth / heights.length; + const barW = bandWidth * barFraction; + const xOffset = (bandWidth * (1 - barFraction)) / 2; + const isCenter = baseline === "center"; + const baselineY = isCenter ? innerHeight / 2 : innerHeight; + const halfBarH = isCenter ? innerHeight / 2 : innerHeight; + + return ( + <> + {heights.map((value, i) => { + const sign = isCenter ? (signs[i] ?? 1) : 1; + const barH = Math.max(1, halfBarH * (value / 100)); + const x = i * bandWidth + xOffset; + const y = sign === 1 ? baselineY - barH : baselineY; + return ( + + ); + })} + + ); +} + +export interface BarLoadingSkeletonProps { + innerWidth: number; + innerHeight: number; + /** Number of skeleton bars. Default: 12 */ + barCount?: number; + /** Bar fill color. Default: `var(--foreground)` */ + fill?: string; + /** Bar fill opacity. Default: 0.45 */ + fillOpacity?: number; + /** Bars rise from the bottom or diverge from the vertical center. Default: `"bottom"` */ + baseline?: "bottom" | "center"; + /** Bar width as a fraction of its band (0–1). Default: 0.7 */ + barFraction?: number; + /** One shimmer sweep, in seconds. Default: 2 */ + durationSeconds?: number; +} + +/** + * Skeleton bars masked by the shimmer sweep, re-randomizing between passes. + * Rendered in the chart's inner coordinate space (origin at the inner top-left), + * so a `BarChart` drops it inside its margin-translated group. + */ +export function BarLoadingSkeleton({ + innerWidth, + innerHeight, + barCount = DEFAULT_BAR_COUNT, + fill = DEFAULT_FILL, + fillOpacity = DEFAULT_BAR_FILL_OPACITY, + baseline = "bottom", + barFraction = DEFAULT_BAR_FRACTION, + durationSeconds = DEFAULT_SWEEP_DURATION_S, +}: BarLoadingSkeletonProps) { + const reduceMotion = useReducedMotion(); + const reactId = useId(); + const chartId = `bar-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`; + const [tick, setTick] = useState(0); + const onSweepComplete = useCallback(() => setTick((prev) => prev + 1), []); + const heights = useMemo( + () => getSkeletonHeights(barCount, tick), + [barCount, tick] + ); + const signs = useMemo( + () => getSkeletonSigns(barCount, tick), + [barCount, tick] + ); + + if (innerWidth <= 0 || innerHeight <= 0) { + return null; + } + + const bars = ( + + ); + + if (reduceMotion) { + return bars; + } + + return ( + <> + + + + {bars} + + ); +} + +BarLoadingSkeleton.displayName = "BarLoadingSkeleton"; diff --git a/components/charts/motion-utils.ts b/components/charts/motion-utils.ts new file mode 100644 index 0000000..77a79fe --- /dev/null +++ b/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/components/charts/path-stroke-utils.ts b/components/charts/path-stroke-utils.ts new file mode 100644 index 0000000..7deefe5 --- /dev/null +++ b/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/components/charts/pattern-area.tsx b/components/charts/pattern-area.tsx new file mode 100644 index 0000000..749e4e8 --- /dev/null +++ b/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/components/charts/pattern-preset.tsx b/components/charts/pattern-preset.tsx new file mode 100644 index 0000000..55cc9f2 --- /dev/null +++ b/components/charts/pattern-preset.tsx @@ -0,0 +1,186 @@ +"use client"; + +import type { ReactNode } from "react"; +import { PatternCircles, PatternLines } from "./visx-pattern"; + +export const PATTERN_PRESET_IDS = [ + "none", + "diagonal", + "horizontal", + "vertical", + "cross", + "dots", + "circles", + "accent", +] as const; + +export type PatternPresetId = (typeof PATTERN_PRESET_IDS)[number]; + +export interface PatternPresetOptions { + color?: string; + scale?: number; + strokeWidth?: number; + radius?: number; + complement?: boolean; + fill?: string; + /** Dot grid only — when false, render hollow dots (stroke only). Default: true */ + dotFill?: boolean; + tileBackground?: string; +} + +/** Presets rendered with @visx/pattern `PatternCircles`. */ +export function isCirclePattern(preset: PatternPresetId): boolean { + return preset === "circles" || preset === "dots"; +} + +/** @deprecated Use `isCirclePattern`. */ +export function isCirclesPattern(preset: PatternPresetId): boolean { + return isCirclePattern(preset); +} + +export function patternPresetTileSize( + preset: PatternPresetId, + scale = 1 +): { width: number; height: number; strokeWidth: number } { + let base = { width: 6, height: 6, strokeWidth: 1 }; + if (preset === "dots") { + base = { width: 10, height: 10, strokeWidth: 0 }; + } else if (preset === "cross") { + base = { width: 8, height: 8, strokeWidth: 1 }; + } else if (preset === "circles") { + base = { width: 6, height: 6, strokeWidth: 1 }; + } + + return { + width: base.width * scale, + height: base.height * scale, + strokeWidth: base.strokeWidth * scale, + }; +} + +function renderPatternCircles( + preset: "dots" | "circles", + _id: string, + color: string, + common: { + id: string; + height: number; + width: number; + strokeWidth: number; + background?: string; + }, + options: PatternPresetOptions, + scale: number +) { + const isDotGrid = preset === "dots"; + const radius = + options.radius ?? (isDotGrid ? Math.max(0.5, 1.5 * scale) : 2 * scale); + const dotFillEnabled = options.dotFill !== false; + + if (isDotGrid) { + const dotFill = dotFillEnabled ? options.fill || color : undefined; + return ( + + ); + } + + return ( + + ); +} + +/** Renders a @visx/pattern definition node for the given preset. */ +export function renderPatternPreset( + preset: PatternPresetId, + id: string, + options: PatternPresetOptions = {} +): ReactNode { + if (preset === "none") { + return null; + } + + const color = options.color ?? "var(--chart-1)"; + const scale = options.scale ?? 1; + const tile = patternPresetTileSize(preset, scale); + const common = { + id, + height: tile.height, + width: tile.width, + strokeWidth: tile.strokeWidth, + ...(options.tileBackground ? { background: options.tileBackground } : {}), + }; + + if (preset === "dots" || preset === "circles") { + return renderPatternCircles(preset, id, color, common, options, scale); + } + + const strokeWidth = options.strokeWidth ?? tile.strokeWidth; + + switch (preset) { + case "diagonal": + return ( + + ); + case "horizontal": + return ( + + ); + case "vertical": + return ( + + ); + case "cross": + return ( + + ); + case "accent": + return ( + + ); + default: + return null; + } +} diff --git a/components/charts/projection-config.ts b/components/charts/projection-config.ts new file mode 100644 index 0000000..657dbd8 --- /dev/null +++ b/components/charts/projection-config.ts @@ -0,0 +1,129 @@ +import { + Children, + Fragment, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { isChartClipPassthrough } from "./chart-child-passthrough"; +import type { ProjectionPoint } from "./projection-utils"; +import { + projectionDateExtents, + projectionValueExtents, +} from "./projection-utils"; +import { normalizeYAxisId } from "./y-axis-scales"; + +export interface ProjectionLineConfig { + yAxisId: string; + data: ProjectionPoint[]; +} + +interface ProjectionLineConfigProps { + data?: ProjectionPoint[]; + yAxisId?: string | number; +} + +function getChildComponentName(child: ReactElement) { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +function isProjectionLineElement(child: ReactElement): boolean { + return getChildComponentName(child) === "ProjectionLine"; +} + +function normalizeProjectionData( + data: ProjectionPoint[] | undefined +): ProjectionPoint[] { + if (!data?.length) { + return []; + } + return data.map((point) => ({ + date: point.date instanceof Date ? point.date : new Date(point.date), + value: point.value, + })); +} + +/** Collect {@link ProjectionLine} props from chart children for domain extension. */ +export function extractProjectionLineConfigs( + children: ReactNode +): ProjectionLineConfig[] { + const configs: ProjectionLineConfig[] = []; + + const visit = (node: ReactNode) => { + Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + if (child.type === Fragment) { + visit((child.props as { children?: ReactNode }).children); + return; + } + + if (isProjectionLineElement(child)) { + const props = child.props as ProjectionLineConfigProps | undefined; + const data = normalizeProjectionData(props?.data); + if (data.length >= 2) { + configs.push({ + yAxisId: normalizeYAxisId(props?.yAxisId), + data, + }); + } + return; + } + + if (isChartClipPassthrough(child.type)) { + visit((child.props as { children?: ReactNode }).children); + return; + } + + const childProps = child.props as { children?: ReactNode } | undefined; + if (childProps?.children) { + visit(childProps.children); + } + }); + }; + + visit(children); + return configs; +} + +export function mergeProjectionYDomain( + domain: [number, number], + configs: ProjectionLineConfig[], + yAxisId: string +): [number, number] { + const paths = configs + .filter((config) => config.yAxisId === yAxisId) + .map((config) => config.data); + const extents = projectionValueExtents(paths); + if (!extents) { + return domain; + } + + const [min, max] = domain; + const nextMin = Math.min(min, extents.minValue); + const nextMax = Math.max(max, extents.maxValue); + + if (nextMin >= 0 && min >= 0) { + return [0, nextMax <= 0 ? 100 : nextMax * 1.1]; + } + + const padding = (nextMax - nextMin) * 0.05 || 1; + return [nextMin - padding, nextMax + padding]; +} + +export function mergeProjectionXDomainMax( + maxTime: number, + configs: ProjectionLineConfig[] +): number { + const paths = configs.map((config) => config.data); + const extents = projectionDateExtents(paths); + if (!extents) { + return maxTime; + } + return Math.max(maxTime, extents.maxTime); +} diff --git a/components/charts/projection-utils.ts b/components/charts/projection-utils.ts new file mode 100644 index 0000000..ce611ee --- /dev/null +++ b/components/charts/projection-utils.ts @@ -0,0 +1,386 @@ +export type ProjectionMode = "auto" | "target" | "manual"; +export type ProjectionAutoMethod = "linearRegression" | "lastSegment"; +/** How the projection segment is drawn between anchor and horizon. */ +export type ProjectionCurveKind = "linear" | "bezier"; +/** @deprecated Stepped density removed — projections always anchor → horizon. */ +export type ProjectionPathDensity = "stepped" | "endpoints"; + +export interface ProjectionPoint { + date: Date; + value: number; +} + +export interface BuildProjectionPathOptions { + sourceData: Record[]; + seriesKey: string; + xDataKey?: string; + mode: ProjectionMode; + autoMethod?: ProjectionAutoMethod; + /** Auto mode: stepped points per interval, or anchor + end only. Default: stepped */ + pathDensity?: ProjectionPathDensity; + /** Index in sourceData where projection anchors (default: last point). */ + startIndex?: number; + /** How many future points to generate (matches source cadence). */ + horizonPoints?: number; + /** Target Y at the final projected date (target mode). */ + endValue?: number; + /** Full manual path — anchor + future points (manual mode). */ + points?: ProjectionPoint[]; +} + +function readDate(row: Record, xDataKey: string): Date | null { + const raw = row[xDataKey]; + if (raw instanceof Date && !Number.isNaN(raw.getTime())) { + return raw; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; + } + if (typeof raw === "string") { + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; + } + return null; +} + +function readValue( + row: Record, + seriesKey: string +): number | null { + const raw = row[seriesKey]; + return typeof raw === "number" && Number.isFinite(raw) ? raw : null; +} + +function resolveStartIndex( + sourceData: Record[], + startIndex: number | undefined +): number { + if (startIndex == null || !Number.isFinite(startIndex)) { + return Math.max(0, sourceData.length - 1); + } + return Math.min(Math.max(0, Math.floor(startIndex)), sourceData.length - 1); +} + +function intervalFromAdjacentRows( + sourceData: Record[], + xDataKey: string, + startIndex: number +): number | null { + if (startIndex < 1) { + return null; + } + const prevRow = sourceData[startIndex - 1]; + const currentRow = sourceData[startIndex]; + const prev = prevRow ? readDate(prevRow, xDataKey) : null; + const current = currentRow ? readDate(currentRow, xDataKey) : null; + if (!(prev && current)) { + return null; + } + const delta = current.getTime() - prev.getTime(); + return delta > 0 ? delta : null; +} + +function intervalFromSeriesSpan( + sourceData: Record[], + xDataKey: string +): number | null { + if (sourceData.length < 2) { + return null; + } + const firstRow = sourceData[0]; + const lastRow = sourceData.at(-1); + const first = firstRow ? readDate(firstRow, xDataKey) : null; + const last = lastRow ? readDate(lastRow, xDataKey) : null; + if (!(first && last)) { + return null; + } + const span = last.getTime() - first.getTime(); + return span > 0 ? span / (sourceData.length - 1) : null; +} + +function resolveIntervalMs( + sourceData: Record[], + xDataKey: string, + startIndex: number +): number { + return ( + intervalFromAdjacentRows(sourceData, xDataKey, startIndex) ?? + intervalFromSeriesSpan(sourceData, xDataKey) ?? + 86_400_000 + ); +} + +function linearRegressionSlope(points: { t: number; y: number }[]): number { + if (points.length < 2) { + return 0; + } + const n = points.length; + let sumT = 0; + let sumY = 0; + let sumTY = 0; + let sumTT = 0; + for (const { t, y } of points) { + sumT += t; + sumY += y; + sumTY += t * y; + sumTT += t * t; + } + const denom = n * sumTT - sumT * sumT; + if (Math.abs(denom) < 1e-12) { + return 0; + } + return (n * sumTY - sumT * sumY) / denom; +} + +function buildAutoFutureValues(options: { + anchorTime: number; + anchorValue: number; + autoMethod: ProjectionAutoMethod; + historyPoints: { t: number; y: number }[]; + horizonPoints: number; + intervalMs: number; + pathDensity: ProjectionPathDensity; +}): ProjectionPoint[] { + const { + anchorTime, + anchorValue, + autoMethod, + historyPoints, + horizonPoints, + intervalMs, + pathDensity, + } = options; + + const slope = + autoMethod === "lastSegment" && historyPoints.length >= 2 + ? (() => { + const prev = historyPoints.at(-2); + const last = historyPoints.at(-1); + if (!(prev && last)) { + return 0; + } + const dt = last.t - prev.t; + return dt === 0 ? 0 : (last.y - prev.y) / dt; + })() + : linearRegressionSlope(historyPoints); + + if (pathDensity === "endpoints") { + const endTime = anchorTime + intervalMs * horizonPoints; + const endValue = anchorValue + slope * intervalMs * horizonPoints; + return [ + { date: new Date(anchorTime), value: anchorValue }, + { date: new Date(endTime), value: endValue }, + ]; + } + + const result: ProjectionPoint[] = [ + { date: new Date(anchorTime), value: anchorValue }, + ]; + + for (let i = 1; i <= horizonPoints; i++) { + const t = anchorTime + intervalMs * i; + const value = anchorValue + slope * intervalMs * i; + result.push({ date: new Date(t), value }); + } + + return result; +} + +/** Slope (value change per ms) at the projection anchor from the last data segment. */ +export function computeProjectionAnchorTangentSlope( + sourceData: Record[], + seriesKey: string, + xDataKey = "date", + startIndexProp?: number +): number { + if (sourceData.length < 2) { + return 0; + } + const startIndex = resolveStartIndex(sourceData, startIndexProp); + const historyPoints: { t: number; y: number }[] = []; + for (let i = 0; i <= startIndex; i++) { + const row = sourceData[i]; + if (!row) { + continue; + } + const date = readDate(row, xDataKey); + const value = readValue(row, seriesKey); + if (date && value != null) { + historyPoints.push({ t: date.getTime(), y: value }); + } + } + if (historyPoints.length < 2) { + return 0; + } + const prev = historyPoints.at(-2); + const last = historyPoints.at(-1); + if (!(prev && last)) { + return 0; + } + const dt = last.t - prev.t; + return dt === 0 ? 0 : (last.y - prev.y) / dt; +} + +/** Cubic bezier with horizontal tangents at start and end (price-target S-curve). */ +export function buildHorizontalTangentBezierPath( + x0: number, + y0: number, + x1: number, + y1: number, + /** How far control points sit along the x span (0–0.5). Default: 0.45 */ + tension = 0.45 +): string { + const dx = x1 - x0; + if (Math.abs(dx) < 1e-6) { + return `M ${x0},${y0} L ${x1},${y1}`; + } + const t = Math.min(0.5, Math.max(0.05, tension)); + const c1x = x0 + dx * t; + const c2x = x1 - dx * t; + return `M ${x0},${y0} C ${c1x},${y0} ${c2x},${y1} ${x1},${y1}`; +} + +function buildTargetPath(options: { + anchorTime: number; + anchorValue: number; + endValue: number; + horizonPoints: number; + intervalMs: number; +}): ProjectionPoint[] { + const { anchorTime, anchorValue, endValue, horizonPoints, intervalMs } = + options; + const endTime = anchorTime + intervalMs * horizonPoints; + return [ + { date: new Date(anchorTime), value: anchorValue }, + { date: new Date(endTime), value: endValue }, + ]; +} + +/** Build a projection path from historical chart data or explicit points. */ +export function buildProjectionPath( + options: BuildProjectionPathOptions +): ProjectionPoint[] { + const { + sourceData, + seriesKey, + xDataKey = "date", + mode, + autoMethod = "linearRegression", + pathDensity = "endpoints", + startIndex: startIndexProp, + horizonPoints = 6, + endValue, + points, + } = options; + + if (mode === "manual" && points && points.length >= 2) { + return points.map((point) => ({ + date: new Date(point.date), + value: point.value, + })); + } + + if (sourceData.length === 0) { + return []; + } + + const startIndex = resolveStartIndex(sourceData, startIndexProp); + const anchorRow = sourceData[startIndex]; + if (!anchorRow) { + return []; + } + + const anchorDate = readDate(anchorRow, xDataKey); + const anchorValue = readValue(anchorRow, seriesKey); + if (!anchorDate || anchorValue == null) { + return []; + } + + const intervalMs = resolveIntervalMs(sourceData, xDataKey, startIndex); + const anchorTime = anchorDate.getTime(); + + const historyPoints: { t: number; y: number }[] = []; + for (let i = 0; i <= startIndex; i++) { + const row = sourceData[i]; + if (!row) { + continue; + } + const date = readDate(row, xDataKey); + const value = readValue(row, seriesKey); + if (date && value != null) { + historyPoints.push({ t: date.getTime(), y: value }); + } + } + + if (mode === "target" && endValue != null && Number.isFinite(endValue)) { + return buildTargetPath({ + anchorTime, + anchorValue, + endValue, + horizonPoints, + intervalMs, + }); + } + + return buildAutoFutureValues({ + anchorTime, + anchorValue, + autoMethod, + historyPoints, + horizonPoints, + intervalMs, + pathDensity, + }); +} + +/** Collect numeric Y extents from projection point arrays. */ +export function projectionValueExtents( + paths: ProjectionPoint[][] +): { minValue: number; maxValue: number } | null { + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + + for (const path of paths) { + for (const point of path) { + if (point.value < minValue) { + minValue = point.value; + } + if (point.value > maxValue) { + maxValue = point.value; + } + } + } + + if (minValue === Number.POSITIVE_INFINITY) { + return null; + } + + return { minValue, maxValue }; +} + +/** Collect date extents from projection point arrays. */ +export function projectionDateExtents( + paths: ProjectionPoint[][] +): { minTime: number; maxTime: number } | null { + let minTime = Number.POSITIVE_INFINITY; + let maxTime = Number.NEGATIVE_INFINITY; + + for (const path of paths) { + for (const point of path) { + const time = point.date.getTime(); + if (time < minTime) { + minTime = time; + } + if (time > maxTime) { + maxTime = time; + } + } + } + + if (minTime === Number.POSITIVE_INFINITY) { + return null; + } + + return { minTime, maxTime }; +} diff --git a/components/charts/reference-area-config.ts b/components/charts/reference-area-config.ts new file mode 100644 index 0000000..f9c0692 --- /dev/null +++ b/components/charts/reference-area-config.ts @@ -0,0 +1,68 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; +import { normalizeYAxisId } from "./y-axis-scales"; + +export interface ReferenceAreaConfig { + yAxisId: string; + y1?: number; + y2?: number; + axisLabelColor?: string; +} + +interface ReferenceAreaConfigProps { + yAxisId?: string | number; + y1?: number; + y2?: number; + axisLabelColor?: string; +} + +function getChildComponentName(child: ReactElement) { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +function isReferenceAreaElement(child: ReactElement): boolean { + return getChildComponentName(child) === "ReferenceArea"; +} + +/** Collect {@link ReferenceArea} props from chart children for axis label styling. */ +export function extractReferenceAreaConfigs( + children: ReactNode +): ReferenceAreaConfig[] { + const configs: ReferenceAreaConfig[] = []; + + const visit = (node: ReactNode) => { + Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + if (isReferenceAreaElement(child)) { + const props = child.props as ReferenceAreaConfigProps | undefined; + if (props) { + configs.push({ + yAxisId: normalizeYAxisId(props.yAxisId), + y1: props.y1, + y2: props.y2, + axisLabelColor: props.axisLabelColor, + }); + } + return; + } + + const childProps = child.props as { children?: ReactNode } | undefined; + if (childProps?.children) { + visit(childProps.children); + } + }); + }; + + visit(children); + return configs; +} diff --git a/components/charts/reference-area-registration-context.tsx b/components/charts/reference-area-registration-context.tsx new file mode 100644 index 0000000..09f52cf --- /dev/null +++ b/components/charts/reference-area-registration-context.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { ReferenceAreaConfig } from "./reference-area-config"; + +export interface ReferenceAreaRegistrationContextValue { + registerReferenceArea: (id: string, config: ReferenceAreaConfig) => void; + unregisterReferenceArea: (id: string) => void; +} + +export const ReferenceAreaRegistrationContext = + createContext(null); + +export function useReferenceAreaRegistration(): ReferenceAreaRegistrationContextValue | null { + return useContext(ReferenceAreaRegistrationContext); +} diff --git a/components/charts/series-bar-layout.ts b/components/charts/series-bar-layout.ts new file mode 100644 index 0000000..ed2249b --- /dev/null +++ b/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/components/charts/series-dash-tail-overlay.tsx b/components/charts/series-dash-tail-overlay.tsx new file mode 100644 index 0000000..514e2d7 --- /dev/null +++ b/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/components/charts/series-highlight-layer.tsx b/components/charts/series-highlight-layer.tsx new file mode 100644 index 0000000..c2dbb3c --- /dev/null +++ b/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/components/charts/series-hover-dim.tsx b/components/charts/series-hover-dim.tsx new file mode 100644 index 0000000..696ffbc --- /dev/null +++ b/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/components/charts/series-markers.tsx b/components/charts/series-markers.tsx new file mode 100644 index 0000000..a3b2e96 --- /dev/null +++ b/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/components/charts/series-path-utils.ts b/components/charts/series-path-utils.ts new file mode 100644 index 0000000..6a05b8c --- /dev/null +++ b/components/charts/series-path-utils.ts @@ -0,0 +1,108 @@ +import { line as d3Line } from "d3-shape"; + +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +export interface SeriesPathPoint { + x: number; + y: number; + key: string; +} + +export function computeSeriesPathPoints( + data: Record[], + xAccessor: (datum: Record) => Date, + xScale: (value: Date) => number | undefined, + yScale: (value: number) => number | undefined, + dataKey: string +): SeriesPathPoint[] { + return data.map((datum, index) => { + const xValue = xAccessor(datum); + const yValue = datum[dataKey]; + return { + x: xScale(xValue) ?? 0, + y: typeof yValue === "number" ? (yScale(yValue) ?? 0) : 0, + key: String(xValue.getTime?.() ?? index), + }; + }); +} + +export function interpolateSeriesPathPoints( + from: SeriesPathPoint[], + to: SeriesPathPoint[], + progress: number +): SeriesPathPoint[] { + if (progress >= 1) { + return to; + } + if (progress <= 0) { + return from.length > 0 ? from : to; + } + + const fromByKey = new Map(from.map((point) => [point.key, point])); + + return to.map((target, index) => { + const source = fromByKey.get(target.key); + if (source) { + return { + key: target.key, + x: source.x + (target.x - source.x) * progress, + y: source.y + (target.y - source.y) * progress, + }; + } + + const previousTarget = index > 0 ? to[index - 1] : undefined; + const previousSource = previousTarget + ? fromByKey.get(previousTarget.key) + : undefined; + const nextTarget = index < to.length - 1 ? to[index + 1] : undefined; + const nextSource = nextTarget ? fromByKey.get(nextTarget.key) : undefined; + const anchor = previousSource ?? nextSource ?? from[0] ?? target; + + return { + key: target.key, + x: anchor.x + (target.x - anchor.x) * progress, + y: anchor.y + (target.y - anchor.y) * progress, + }; + }); +} + +export function seriesPathFromPoints( + points: SeriesPathPoint[], + curve: CurveFactory +): string { + if (points.length === 0) { + return ""; + } + + const generator = d3Line() + .x((point) => point.x) + .y((point) => point.y) + .curve(curve); + + return generator(points) ?? ""; +} + +export function seriesPathTransitionSignature({ + renderData, + xAccessor, + dataKey, + innerWidth, + xDomainMin, + xDomainMax, +}: { + renderData: Record[]; + xAccessor: (datum: Record) => Date; + dataKey: string; + innerWidth: number; + xDomainMin: number; + xDomainMax: number; +}): string { + const values = renderData.map((datum) => { + const xValue = xAccessor(datum); + const yValue = datum[dataKey]; + return `${xValue.getTime()}:${typeof yValue === "number" ? yValue : ""}`; + }); + + return `${innerWidth}|${xDomainMin}|${xDomainMax}|${values.join(",")}`; +} diff --git a/components/charts/series-point-marker.tsx b/components/charts/series-point-marker.tsx new file mode 100644 index 0000000..d352bd3 --- /dev/null +++ b/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/components/charts/static-chart-preview-context.tsx b/components/charts/static-chart-preview-context.tsx new file mode 100644 index 0000000..0930ad1 --- /dev/null +++ b/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/components/charts/time-series-chart-shell.tsx b/components/charts/time-series-chart-shell.tsx new file mode 100644 index 0000000..3773599 --- /dev/null +++ b/components/charts/time-series-chart-shell.tsx @@ -0,0 +1,704 @@ +"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, + useId, + useMemo, + useState, +} from "react"; +import { + DEFAULT_ANIMATION_EASING, + DEFAULT_CHART_ENTER_TRANSITION, +} from "./animation"; +import { + isClipExcludedComponent, + isPostOverlayComponent, + isUnderlayComponent, + 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 { + extractProjectionLineConfigs, + mergeProjectionXDomainMax, + mergeProjectionYDomain, +} from "./projection-config"; +import { + extractReferenceAreaConfigs, + type ReferenceAreaConfig, +} from "./reference-area-config"; +import { ReferenceAreaRegistrationContext } from "./reference-area-registration-context"; +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]; +} + +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[]; + /** + * Base SVG clipPath id for grow animation. Uniquified per chart instance so + * multiple charts on one page do not share a clipPath (#226). + */ + 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) { + // Unique per instance — shared clipPath ids crop later charts (#226). + const uniqueClipPathId = `${clipPathId}-${useId().replace(/:/g, "")}`; + 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 projectionConfigs = useMemo( + () => extractProjectionLineConfigs(children), + [children] + ); + + const xScale = useMemo(() => { + const minTime = xDomain + ? xDomain[0].getTime() + : (extent(plotData, (d) => xAccessor(d).getTime())[0] ?? 0); + let maxTime = xDomain + ? xDomain[1].getTime() + : (extent(plotData, (d) => xAccessor(d).getTime())[1] ?? minTime); + // Brush defines the viewport — projection horizon is included via brush + // track extent, not by extending past the selection on the main chart. + if (!xDomain) { + maxTime = mergeProjectionXDomainMax(maxTime, projectionConfigs); + } + + return scaleTime({ + range: [0, innerWidth], + domain: [minTime, maxTime], + }); + }, [innerWidth, plotData, projectionConfigs, 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(() => { + const base = computeYDomainsByAxis({ + lines, + resolveDomain: (dataKeys) => + resolveYDomain(xDomain ? visiblePlotData : data, dataKeys), + }); + if (projectionConfigs.length === 0) { + return base; + } + const merged: Record = { ...base }; + for (const axisId of Object.keys(base)) { + merged[axisId] = mergeProjectionYDomain( + base[axisId] ?? [0, 100], + projectionConfigs, + axisId + ); + } + for (const config of projectionConfigs) { + if (!merged[config.yAxisId]) { + merged[config.yAxisId] = mergeProjectionYDomain( + [0, 100], + projectionConfigs, + config.yAxisId + ); + } + } + return merged; + }, [ + data, + lines, + projectionConfigs, + resolveYDomain, + visiblePlotData, + xDomain, + ]); + + const animatedYDomainsByAxis = useAnimatedYDomains({ + chartPhase, + durationMs: yDomainTweenDuration, + enabled: yDomainTween, + onSettled: notifyYDomainTweenComplete, + skeletonByAxis: yDomainSkeletonByAxis, + targetByAxis: yDomainTargetByAxis, + tweenOnTargetChange: + yDomainTween || (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 underlayChildren: 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 if (isUnderlayComponent(resolvedChild)) { + underlayChildren.push(resolvedChild); + } else { + preOverlayChildren.push(resolvedChild); + } + }); + + const [registeredReferenceAreas, setRegisteredReferenceAreas] = useState( + () => new Map() + ); + + const registerReferenceArea = useCallback( + (id: string, config: ReferenceAreaConfig) => { + setRegisteredReferenceAreas((prev) => { + const existing = prev.get(id); + if ( + existing && + existing.yAxisId === config.yAxisId && + existing.y1 === config.y1 && + existing.y2 === config.y2 && + existing.axisLabelColor === config.axisLabelColor + ) { + return prev; + } + const next = new Map(prev); + next.set(id, config); + return next; + }); + }, + [] + ); + + const unregisterReferenceArea = useCallback((id: string) => { + setRegisteredReferenceAreas((prev) => { + if (!prev.has(id)) { + return prev; + } + const next = new Map(prev); + next.delete(id); + return next; + }); + }, []); + + const referenceAreaRegistration = useMemo( + () => ({ registerReferenceArea, unregisterReferenceArea }), + [registerReferenceArea, unregisterReferenceArea] + ); + + const referenceAreas = useMemo(() => { + const extracted = extractReferenceAreaConfigs(children); + const registered = [...registeredReferenceAreas.values()]; + if (registered.length === 0) { + return extracted; + } + if (extracted.length === 0) { + return registered; + } + return [...extracted, ...registered]; + }, [children, registeredReferenceAreas]); + + const contextValue = useMemo( + () => ({ + data: visiblePlotData, + renderData, + xScale, + yScale, + yScales, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + referenceAreas, + 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, + referenceAreas, + 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/components/charts/tooltip/chart-tooltip.tsx b/components/charts/tooltip/chart-tooltip.tsx new file mode 100644 index 0000000..f71d093 --- /dev/null +++ b/components/charts/tooltip/chart-tooltip.tsx @@ -0,0 +1,436 @@ +"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; + /** Dot style: filled circle or transparent ring. Default: "dot" */ + dotVariant?: "dot" | "ring"; + /** Dot / ring radius in pixels. Default: 5 */ + dotSize?: number; + /** Ring corner radius as a fraction of side length (0 = square, 0.5 = circle). */ + dotRadiusFraction?: number; + /** Multiplier applied to the computed dot / ring pixel radius. Default: 1 */ + dotScale?: number; + /** Ring stroke width in pixels. Default: 1.5 for ring variant */ + dotStrokeWidth?: number; + /** + * 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; + /** + * Tooltip panel background color (CSS variable or color value). + * Default: `var(--chart-tooltip-background)`. + */ + backgroundColor?: string; +} + +interface ChartTooltipInnerProps extends ChartTooltipProps { + container: HTMLElement; +} + +const ChartTooltipInner = memo(function ChartTooltipInner({ + showDatePill = true, + showCrosshair = true, + showDots = true, + dotVariant = "dot", + dotSize = 5, + dotRadiusFraction, + dotScale = 1, + dotStrokeWidth, + indicatorColor: indicatorColorProp, + content, + rows: rowsRenderer, + dotColor: dotColorProp, + children, + className = "", + container, + springConfig, + matchCrosshair = false, + damping, + indicatorDasharray, + indicatorFadeEdges, + indicatorFadeLength, + boxSpringConfig, + panelStyle, + backgroundColor, +}: ChartTooltipInnerProps) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor, + bandWidth, + squareSnap, + } = useChart(); + const { tooltipSpring } = useChartConfig(); + + const isHorizontal = orientation === "horizontal"; + const discreteInteraction = dateLabels.length > 60; + + const resolvedDotSize = useMemo(() => { + if (dotVariant !== "ring" || !bandWidth || lines.length === 0) { + return dotSize * dotScale; + } + const seriesCount = lines.length; + const gap = squareSnap?.groupGap ?? (seriesCount > 1 ? 4 : 0); + const squareSize = (bandWidth - gap * (seriesCount - 1)) / seriesCount; + return (squareSize / 2) * dotScale; + }, [ + bandWidth, + dotScale, + dotSize, + dotVariant, + lines.length, + squareSnap?.groupGap, + ]); + 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/components/charts/tooltip/date-ticker.tsx b/components/charts/tooltip/date-ticker.tsx new file mode 100644 index 0000000..d4a99e0 --- /dev/null +++ b/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/components/charts/tooltip/index.ts b/components/charts/tooltip/index.ts new file mode 100644 index 0000000..5b4dafc --- /dev/null +++ b/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/components/charts/tooltip/tooltip-box.tsx b/components/charts/tooltip/tooltip-box.tsx new file mode 100644 index 0000000..0aeac95 --- /dev/null +++ b/components/charts/tooltip/tooltip-box.tsx @@ -0,0 +1,235 @@ +"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"; +import { chartCssVars } from "../chart-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; + /** Fade/scale the panel on show. Default: true */ + entrance?: boolean; + /** Inline styles for the inner tooltip panel. */ + panelStyle?: React.CSSProperties; + /** + * Tooltip panel background color (CSS variable or color value). + * Default: `var(--chart-tooltip-background)`. + */ + backgroundColor?: string; +} + +// 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, + entrance = true, + panelStyle, + backgroundColor = chartCssVars.tooltipBackground, + 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"; + + const panelClassName = cn( + "min-w-[140px] overflow-hidden rounded-lg text-chart-tooltip-foreground shadow-lg", + panelStyle?.backgroundColor === undefined && + backgroundColor === chartCssVars.tooltipBackground && + "bg-chart-tooltip-background", + panelStyle?.backdropFilter === undefined && "backdrop-blur-md" + ); + const panelStyleResolved = { + transformOrigin, + ...(panelStyle?.backgroundColor === undefined && { + backgroundColor, + }), + ...panelStyle, + }; + + if (!entrance) { + return createPortal( +
+
+ {children} +
+
, + container + ); + } + + return createPortal( + + + {children} + + , + container + ); +} + +TooltipBox.displayName = "TooltipBox"; + +export default TooltipBox; diff --git a/components/charts/tooltip/tooltip-content.tsx b/components/charts/tooltip/tooltip-content.tsx new file mode 100644 index 0000000..dc849b9 --- /dev/null +++ b/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/components/charts/tooltip/tooltip-dot.tsx b/components/charts/tooltip/tooltip-dot.tsx new file mode 100644 index 0000000..1de0296 --- /dev/null +++ b/components/charts/tooltip/tooltip-dot.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { motion, useSpring, useTransform } 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; + /** Half of width/height for dots; half-extent for ring squares. Default: 5 */ + size?: number; + strokeColor?: string; + strokeWidth?: number; + /** Dot fill or transparent ring around the hovered mark. Default: "dot" */ + variant?: "dot" | "ring"; + /** + * Ring corner radius as a fraction of side length (0 = square, 0.5 = circle). + * Same semantics as bar square radius. + */ + cornerRadiusFraction?: number; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig; + /** Animate position with a spring. Default: true */ + animate?: boolean; +} + +function ringCornerRadius( + halfExtent: number, + cornerRadiusFraction: number +): number { + const side = halfExtent * 2; + return side * Math.max(0, Math.min(0.5, cornerRadiusFraction)); +} + +function AnimatedRingDot({ + x, + y, + halfExtent, + cornerRadiusFraction, + fill, + stroke, + strokeWidth, + springConfig, +}: { + x: number; + y: number; + halfExtent: number; + cornerRadiusFraction: number; + fill: string; + stroke: string; + strokeWidth: number; + springConfig?: SpringConfig; +}) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(x, effectiveSpring); + const animatedY = useSpring(y, effectiveSpring); + const side = halfExtent * 2; + const rx = ringCornerRadius(halfExtent, cornerRadiusFraction); + const rectX = useTransform(animatedX, (value) => value - halfExtent); + const rectY = useTransform(animatedY, (value) => value - halfExtent); + + animatedX.set(x); + animatedY.set(y); + + return ( + + ); +} + +export function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, + variant = "dot", + cornerRadiusFraction = 0.25, + springConfig, + animate = true, +}: TooltipDotProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(x, effectiveSpring); + const animatedY = useSpring(y, effectiveSpring); + + const isRing = variant === "ring"; + const fill = isRing ? "transparent" : color; + const stroke = isRing ? color : strokeColor; + const effectiveStrokeWidth = isRing ? (strokeWidth ?? 1.5) : strokeWidth; + + if (animate && !isRing) { + animatedX.set(x); + animatedY.set(y); + } + + if (!visible) { + return null; + } + + if (isRing) { + if (animate) { + return ( + + ); + } + + const side = size * 2; + const rx = ringCornerRadius(size, cornerRadiusFraction); + + return ( + + ); + } + + if (!animate) { + return ( + + ); + } + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +export default TooltipDot; diff --git a/components/charts/tooltip/tooltip-indicator.tsx b/components/charts/tooltip/tooltip-indicator.tsx new file mode 100644 index 0000000..ee32d10 --- /dev/null +++ b/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/components/charts/use-animated-series-path.ts b/components/charts/use-animated-series-path.ts new file mode 100644 index 0000000..1a874be --- /dev/null +++ b/components/charts/use-animated-series-path.ts @@ -0,0 +1,165 @@ +"use client"; + +import { animate, useReducedMotion } from "motion/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing"; +import { + computeSeriesPathPoints, + interpolateSeriesPathPoints, + type SeriesPathPoint, + seriesPathFromPoints, + seriesPathTransitionSignature, +} from "./series-path-utils"; + +// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type +type CurveFactory = any; + +export interface UseAnimatedSeriesPathOptions { + renderData: Record[]; + xAccessor: (datum: Record) => Date; + xScale: (value: Date) => number | undefined; + yScale: (value: number) => number | undefined; + dataKey: string; + curve: CurveFactory; + chartPhase: string; + durationMs: number; + innerWidth: number; + enabled: boolean; +} + +export function useAnimatedSeriesPath({ + renderData, + xAccessor, + xScale, + yScale, + dataKey, + curve, + chartPhase, + durationMs, + innerWidth, + enabled, +}: UseAnimatedSeriesPathOptions) { + const reducedMotion = useReducedMotion(); + const [animatedPoints, setAnimatedPoints] = useState< + SeriesPathPoint[] | null + >(null); + const displayedPointsRef = useRef(null); + const animatingRef = useRef(false); + + const xScaleDomain = useMemo(() => { + const scaleWithDomain = xScale as { domain?: () => [Date, Date] }; + return scaleWithDomain.domain?.() ?? [new Date(0), new Date(0)]; + }, [xScale]); + + const transitionSignature = useMemo( + () => + seriesPathTransitionSignature({ + renderData, + xAccessor, + dataKey, + innerWidth, + xDomainMin: xScaleDomain[0]?.getTime?.() ?? 0, + xDomainMax: xScaleDomain[1]?.getTime?.() ?? 0, + }), + [renderData, xAccessor, dataKey, innerWidth, xScaleDomain] + ); + + const targetPoints = useMemo( + () => + computeSeriesPathPoints(renderData, xAccessor, xScale, yScale, dataKey), + [renderData, xAccessor, xScale, yScale, dataKey] + ); + + const prevTransitionSignatureRef = useRef(transitionSignature); + + useEffect(() => { + if (!animatingRef.current) { + displayedPointsRef.current = targetPoints; + } + }, [targetPoints]); + + useEffect(() => { + const shouldAnimate = + enabled && + !reducedMotion && + chartPhase === "ready" && + durationMs > 0 && + renderData.length > 0; + + if (!shouldAnimate) { + animatingRef.current = false; + setAnimatedPoints(null); + displayedPointsRef.current = targetPoints; + prevTransitionSignatureRef.current = transitionSignature; + return; + } + + if (prevTransitionSignatureRef.current === transitionSignature) { + return; + } + prevTransitionSignatureRef.current = transitionSignature; + + const fromPoints = displayedPointsRef.current ?? targetPoints; + if (fromPoints.length === 0) { + displayedPointsRef.current = targetPoints; + return; + } + + animatingRef.current = true; + const fromSnapshot = fromPoints; + + const control = animate(0, 1, { + duration: durationMs / 1000, + ease: [...LINE_LOADING_PULSE_EASE], + onUpdate: (progress) => { + const currentTarget = computeSeriesPathPoints( + renderData, + xAccessor, + xScale, + yScale, + dataKey + ); + const next = interpolateSeriesPathPoints( + fromSnapshot, + currentTarget, + progress + ); + displayedPointsRef.current = next; + setAnimatedPoints(next); + }, + onComplete: () => { + animatingRef.current = false; + displayedPointsRef.current = targetPoints; + setAnimatedPoints(null); + }, + }); + + return () => { + control.stop(); + animatingRef.current = false; + }; + }, [ + transitionSignature, + chartPhase, + durationMs, + enabled, + reducedMotion, + renderData, + xAccessor, + xScale, + yScale, + dataKey, + targetPoints, + ]); + + const activePoints = animatedPoints ?? targetPoints; + const pathD = useMemo( + () => seriesPathFromPoints(activePoints, curve), + [activePoints, curve] + ); + + return { + pathD, + isPathAnimating: animatedPoints != null, + }; +} diff --git a/components/charts/use-animated-y-domains.ts b/components/charts/use-animated-y-domains.ts new file mode 100644 index 0000000..d4b94d2 --- /dev/null +++ b/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/components/charts/use-chart-interaction.ts b/components/charts/use-chart-interaction.ts new file mode 100644 index 0000000..503dd31 --- /dev/null +++ b/components/charts/use-chart-interaction.ts @@ -0,0 +1,352 @@ +"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) { + scheduleTooltip(tooltip, `${tooltip.index}:${Math.round(tooltip.x)}`); + return; + } + clearTooltip(); + }, [canInteract, clearTooltip, resolveTooltipFromX, scheduleTooltip]); + + 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/components/charts/use-chart-phase-orchestrator.ts b/components/charts/use-chart-phase-orchestrator.ts new file mode 100644 index 0000000..413c334 --- /dev/null +++ b/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/components/charts/use-enter-complete.ts b/components/charts/use-enter-complete.ts new file mode 100644 index 0000000..e16026e --- /dev/null +++ b/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/components/charts/use-grid-shimmer.ts b/components/charts/use-grid-shimmer.ts new file mode 100644 index 0000000..22e9046 --- /dev/null +++ b/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/components/charts/use-highlight-segment.ts b/components/charts/use-highlight-segment.ts new file mode 100644 index 0000000..f5cb8bc --- /dev/null +++ b/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/components/charts/use-mount-progress.ts b/components/charts/use-mount-progress.ts new file mode 100644 index 0000000..cf7bf64 --- /dev/null +++ b/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/components/charts/use-scheduled-tooltip.ts b/components/charts/use-scheduled-tooltip.ts new file mode 100644 index 0000000..fb4e947 --- /dev/null +++ b/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/components/charts/visx-pattern.tsx b/components/charts/visx-pattern.tsx new file mode 100644 index 0000000..a9770bd --- /dev/null +++ b/components/charts/visx-pattern.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { + PatternCircles as VisxPatternCircles, + PatternHexagons as VisxPatternHexagons, + PatternLines as VisxPatternLines, + PatternWaves as VisxPatternWaves, +} from "@visx/pattern"; +import type { ComponentProps } from "react"; + +export function PatternLines(props: ComponentProps) { + return ; +} +PatternLines.displayName = "PatternLines"; + +export function PatternCircles( + props: ComponentProps +) { + return ; +} +PatternCircles.displayName = "PatternCircles"; + +export function PatternWaves(props: ComponentProps) { + return ; +} +PatternWaves.displayName = "PatternWaves"; + +export function PatternHexagons( + props: ComponentProps +) { + return ; +} +PatternHexagons.displayName = "PatternHexagons"; diff --git a/components/charts/x-axis.tsx b/components/charts/x-axis.tsx new file mode 100644 index 0000000..9690ca6 --- /dev/null +++ b/components/charts/x-axis.tsx @@ -0,0 +1,667 @@ +"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. */ + numTicks?: number; + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number; + /** + * `"data"` — tick labels snap to data rows so crosshair and tooltip stay aligned (default). + * `"domain"` — evenly spaced ticks across the time domain (may not align with hover). + */ + 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; +} + +function domainExtendsPastData( + data: Record[], + xAccessor: (d: Record) => Date, + xScale: { domain: () => Date[] } +): boolean { + if (data.length === 0) { + return false; + } + const domainEnd = xScale.domain()[1]; + const lastPoint = data.at(-1); + if (!(domainEnd && lastPoint)) { + return false; + } + return domainEnd.getTime() > xAccessor(lastPoint).getTime(); +} + +/** Domain ticks for the projection tail when brush keeps data-aligned labels. */ +function appendProjectionTailTicks( + ticks: AxisTick[], + data: Record[], + xAccessor: (d: Record) => Date, + xScale: { + domain: () => Date[]; + (date: Date): number | undefined; + }, + marginLeft: number, + maxExtraTicks: number +): AxisTick[] { + if (data.length === 0 || maxExtraTicks <= 0) { + return ticks; + } + + const lastPoint = data.at(-1); + const domainEnd = xScale.domain()[1]; + if (!(lastPoint && domainEnd)) { + return ticks; + } + + const lastDate = xAccessor(lastPoint); + const startTime = lastDate.getTime(); + const endTime = domainEnd.getTime(); + if (endTime <= startTime) { + return ticks; + } + + const seenLabels = new Set(ticks.map((tick) => tick.label)); + const extras: AxisTick[] = []; + const extraCount = Math.min(maxExtraTicks, 3); + + for (let i = 1; i <= extraCount; i++) { + const date = new Date( + startTime + (i / (extraCount + 1)) * (endTime - startTime) + ); + const label = shortDateFmt.format(date); + if (seenLabels.has(label)) { + continue; + } + seenLabels.add(label); + extras.push({ + date, + label, + x: (xScale(date) ?? 0) + marginLeft, + }); + } + + const endLabel = shortDateFmt.format(domainEnd); + if (!seenLabels.has(endLabel)) { + extras.push({ + date: domainEnd, + label: endLabel, + x: (xScale(domainEnd) ?? 0) + marginLeft, + }); + } + + if (extras.length === 0) { + return ticks; + } + + return [...ticks, ...extras].sort((a, b) => a.x - b.x); +} + +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 = "data", + container, +}: XAxisProps & { container: HTMLDivElement }) { + const { xScale, margin, tooltipData, data, xAccessor, dateLabels, xDomain } = + useChart(); + + const labelsToShow = useMemo(() => { + const projectionExtendsScale = + tickMode === "data" && domainExtendsPastData(data, xAccessor, xScale); + + if (tickMode === "domain") { + return buildDomainTicks({ + marginLeft: margin.left, + numTicks, + xScale, + }); + } + + // No brush: evenly spaced ticks across the full domain (data + projection). + if (projectionExtendsScale && xDomain == null) { + return buildDomainTicks({ + marginLeft: margin.left, + numTicks, + xScale, + }); + } + + const dataTicks = buildDataAlignedTicks({ + data, + dateLabels, + marginLeft: margin.left, + targetTickCount: numTicks, + xAccessor, + xScale, + }); + + // Brush: keep data-aligned ticks, add labels only in the projection tail. + if (projectionExtendsScale && xDomain != null) { + return appendProjectionTailTicks( + dataTicks, + data, + xAccessor, + xScale, + margin.left, + Math.max(1, numTicks - dataTicks.length + 1) + ); + } + + return dataTicks; + }, [ + 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/components/charts/y-axis-scales.ts b/components/charts/y-axis-scales.ts new file mode 100644 index 0000000..73c4473 --- /dev/null +++ b/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/components/charts/y-axis-ticks.ts b/components/charts/y-axis-ticks.ts new file mode 100644 index 0000000..55c700d --- /dev/null +++ b/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/components/charts/y-domain-utils.ts b/components/charts/y-domain-utils.ts new file mode 100644 index 0000000..e7d5ee6 --- /dev/null +++ b/components/charts/y-domain-utils.ts @@ -0,0 +1,131 @@ +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"; +} + +/** Phases where {@link ReferenceArea} bands are shown (fade in/out on transitions). */ +export function isReferenceAreaVisiblePhase(phase: ChartPhase): boolean { + return ( + phase === "ready" || phase === "revealing" || 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/components/shimmering-text.tsx b/components/shimmering-text.tsx new file mode 100644 index 0000000..1ddd79a --- /dev/null +++ b/components/shimmering-text.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { motion, useReducedMotion, type Variants } from "motion/react"; +import { type ComponentProps, useCallback } from "react"; +import { cn } from "@/lib/utils"; + +export type ShimmeringTextProps = Omit< + ComponentProps, + "children" +> & { + /** The text to render with the shimmering effect. */ + text: string; + /** + * Duration in seconds for one shimmer cycle. + * @defaultValue 1 + */ + duration?: number; + /** + * Pause the shimmer (e.g. when the hero leaves the viewport). + * @defaultValue false + */ + paused?: boolean; + /** + * Legacy alias for `paused`. + * @defaultValue false + */ + isStopped?: boolean; +}; + +export function ShimmeringText({ + text, + duration = 1, + isStopped = false, + paused = false, + className, + ...props +}: ShimmeringTextProps) { + const reducedMotion = useReducedMotion(); + const stopped = isStopped || paused || reducedMotion === true; + + const createCharVariants = useCallback( + (charIndex: number): Variants => ({ + running: { + color: ["var(--color)", "var(--shimmering-color)", "var(--color)"], + transition: { + duration, + repeat: Number.POSITIVE_INFINITY, + repeatType: "loop", + repeatDelay: text.length * 0.05, + delay: (charIndex * duration) / text.length, + ease: "easeInOut", + }, + }, + stopped: { + color: "var(--color)", + transition: { + duration: duration * 0.5, + ease: "easeOut", + }, + }, + }), + [duration, text.length] + ); + + return ( + + {text.split("").map((char, index) => ( + + {char} + + ))} + {text} + + ); +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 848a584..1201782 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,6 +77,10 @@ const eslintConfig = defineConfig([ "next-env.d.ts", // Generated by fumadocs-mdx during development. ".source/**", + // Bklit UI chart source, vendored from the @bklit registry and refreshed + // with `shadcn add --overwrite`, so local lint fixes wouldn't survive. + "components/charts/**", + "components/shimmering-text.tsx", ]), ]); diff --git a/package.json b/package.json index 0f880f7..04c0853 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,20 @@ "@types/mdx": "^2.0.14", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/gradient": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/pattern": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "d3-array": "^3.2.4", + "d3-shape": "^3.2.0", "dialkit": "^2.0.2", "fumadocs-core": "^16.15.14", "fumadocs-mdx": "^15.4.5", @@ -43,6 +53,8 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/canvas-confetti": "^1.9.0", + "@types/d3-array": "^3.2.2", + "@types/d3-shape": "^3.2.0", "@types/matter-js": "^0.20.2", "@types/node": "^26.6.2", "@types/react": "^19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10188e1..4188cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,30 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@16.3.6(@babel/core@7.29.7)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + '@visx/curve': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/event': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/gradient': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.3.0) + '@visx/grid': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.3.0) + '@visx/pattern': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.3.0) + '@visx/responsive': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/shape': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.3.0) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -32,6 +56,12 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + d3-array: + specifier: ^3.2.4 + version: 3.2.4 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 dialkit: specifier: ^2.0.2 version: 2.0.2(motion@13.4.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -90,6 +120,12 @@ importers: '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 + '@types/d3-shape': + specifier: ^3.2.0 + version: 3.2.0 '@types/matter-js': specifier: ^0.20.2 version: 0.20.2 @@ -1643,6 +1679,45 @@ packages: '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/d3-array@3.0.3': + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.0': + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + + '@types/d3-delaunay@6.0.1': + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + + '@types/d3-format@3.0.1': + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.1': + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.2': + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time-format@2.1.0': + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + + '@types/d3-time@3.0.0': + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -1655,6 +1730,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -1664,6 +1742,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/matter-js@0.20.2': resolution: {integrity: sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==} @@ -2057,6 +2138,51 @@ packages: vue-router: optional: true + '@visx/curve@4.0.1-alpha.0': + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + + '@visx/event@4.0.1-alpha.0': + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + + '@visx/gradient@4.0.1-alpha.0': + resolution: {integrity: sha512-HxkxLdThV/gPaulw+t7/ESo7AtqIBq9IlHIkHi7lQqXe0Yl+x8rpM3KYlYqEZjtihD9CeEaVJjBbdY7fQb54IA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/grid@4.0.1-alpha.0': + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/group@4.0.1-alpha.0': + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/pattern@4.0.1-alpha.0': + resolution: {integrity: sha512-KCqHpjtHdu95uqamc5PIYG55Xp0w+7/Zz63k4eeZErwK1o0PN2rZiBiuodNtENL8NqIU2b5AP+XI/xXaUH0M4Q==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/point@4.0.1-alpha.0': + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + + '@visx/responsive@4.0.1-alpha.0': + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/scale@4.0.1-alpha.0': + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + + '@visx/shape@4.0.1-alpha.0': + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + + '@visx/vendor@4.0.0-alpha.0': + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@yuku-analyzer/binding-android-arm64@0.10.2': resolution: {integrity: sha512-xt4Vy3RTBw1NfszBlf4A0Rr6ZjA/th2Pj9XlWFLQHW9A1qOCtxNCoIWSzn/INtnZLMB4jO2gWdEtSaHUtK73wA==} cpu: [arm64] @@ -2342,6 +2468,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2443,6 +2572,54 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2521,6 +2698,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3244,6 +3424,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip-address@10.7.2: resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} engines: {node: '>= 12'} @@ -3614,6 +3798,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -4314,6 +4501,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -6368,6 +6558,42 @@ snapshots: '@types/canvas-confetti@1.9.0': {} + '@types/d3-array@3.0.3': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.0': {} + + '@types/d3-delaunay@6.0.1': {} + + '@types/d3-format@3.0.1': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.1': + dependencies: + '@types/d3-color': 3.1.0 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.2': + dependencies: + '@types/d3-time': 3.0.0 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@2.1.0': {} + + '@types/d3-time@3.0.0': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6380,6 +6606,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -6388,6 +6616,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/lodash@4.17.25': {} + '@types/matter-js@0.20.2': {} '@types/mdast@4.0.4': @@ -6653,6 +6883,94 @@ snapshots: next: 16.3.6(@babel/core@7.29.7)(@types/node@26.6.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 + '@visx/curve@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/event@4.0.1-alpha.0': + dependencies: + '@types/react': 19.3.0 + '@visx/point': 4.0.1-alpha.0 + + '@visx/gradient@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + react: 19.3.0 + + '@visx/grid@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.3.0) + classnames: 2.5.1 + react: 19.3.0 + + '@visx/group@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + classnames: 2.5.1 + react: 19.3.0 + + '@visx/pattern@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + classnames: 2.5.1 + react: 19.3.0 + + '@visx/point@4.0.1-alpha.0': {} + + '@visx/responsive@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.3.0 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/scale@4.0.1-alpha.0': + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + + '@visx/shape@4.0.1-alpha.0(react@19.3.0)': + dependencies: + '@types/lodash': 4.17.25 + '@types/react': 19.3.0 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.3.0) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.3.0 + + '@visx/vendor@4.0.0-alpha.0': + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + '@yuku-analyzer/binding-android-arm64@0.10.2': optional: true @@ -6925,6 +7243,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -7012,6 +7332,52 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.1: + dependencies: + internmap: 2.0.3 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.2: + dependencies: + delaunator: 5.1.0 + + d3-format@3.1.0: {} + + d3-geo@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + damerau-levenshtein@1.0.8: {} data-view-buffer@1.0.2: @@ -7077,6 +7443,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + depd@2.0.0: {} dequal@2.0.3: {} @@ -8035,6 +8405,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + ip-address@10.7.2: {} ipaddr.js@1.9.1: {} @@ -8343,6 +8715,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash@4.18.1: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -9428,6 +9802,8 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.3: {} + router@2.2.0: dependencies: debug: 4.4.3 From 4d417fd92e1e02084f1312932df4ac25200a9600 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:35:27 +0530 Subject: [PATCH 03/16] Bring back the landing stats section with live Tracwell charts The stats section returns below the first screen with four charts fed by Tracwell's MCP server: - Visitors online, drawn as a live line chart. Counts unique visitors seen in the trailing five minutes, sampled each minute over the last half hour from Tracwell's event stream. The card polls /api/live-visitors every 20s while it's on screen, and the route is cached for 15s so viewers share reads. - Visitors per day over 30 days, as an area chart with today's partial day dashed and the peak day labelled. - Top referrers and most-copied components, as horizontal bars with names and counts drawn beside them (BarYAxis caps labels at 70px). The 30-day numbers now refresh every 10 minutes instead of hourly. --- app/api/live-visitors/route.ts | 17 ++ app/page.tsx | 15 +- components/landing/analytics-charts.tsx | 243 ++++++++++++++++++++++++ components/landing/stats-section.tsx | 115 ++++++++++- lib/landing-stats.ts | 79 ++++++-- lib/live-visitors.ts | 112 +++++++++++ lib/tracwell-mcp.ts | 3 + 7 files changed, 568 insertions(+), 16 deletions(-) create mode 100644 app/api/live-visitors/route.ts create mode 100644 components/landing/analytics-charts.tsx create mode 100644 lib/live-visitors.ts diff --git a/app/api/live-visitors/route.ts b/app/api/live-visitors/route.ts new file mode 100644 index 0000000..d0047ef --- /dev/null +++ b/app/api/live-visitors/route.ts @@ -0,0 +1,17 @@ +import { fetchLiveVisitors } from "@/lib/live-visitors"; + +// Cached and refreshed at most every 15s, however many people are watching, +// so Tracwell sees a steady trickle of reads instead of one per visitor. +export const dynamic = "force-static"; +export const revalidate = 15; + +export async function GET() { + const key = process.env.TRACWELL_MCP_KEY; + if (!key) return Response.json({ live: null }); + try { + return Response.json({ live: await fetchLiveVisitors(key) }); + } catch (error) { + console.warn("Live visitors unavailable:", error); + return Response.json({ live: null }); + } +} diff --git a/app/page.tsx b/app/page.tsx index f776a0a..54f06a6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; import { LandingPage } from "@/components/landing/landing-page"; +import { StatsSection } from "@/components/landing/stats-section"; +import { getLandingStats } from "@/lib/landing-stats"; import { source } from "@/lib/source"; export const metadata: Metadata = { @@ -23,10 +25,19 @@ export const metadata: Metadata = { }, }; -export default function HomePage() { +// The 30-day stats refresh every 10 minutes; live visitors poll on their own. +export const revalidate = 600; + +export default async function HomePage() { const categories = Object.fromEntries( source.getPages().map((page) => [page.url, page.data.category]), ); - return ; + const stats = await getLandingStats(); + + return ( + + + + ); } \ No newline at end of file diff --git a/components/landing/analytics-charts.tsx b/components/landing/analytics-charts.tsx new file mode 100644 index 0000000..56ec28f --- /dev/null +++ b/components/landing/analytics-charts.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Area, AreaChart } from "@/components/charts/area-chart"; +import { Bar } from "@/components/charts/bar"; +import { BarChart } from "@/components/charts/bar-chart"; +import { Grid } from "@/components/charts/grid"; +import { LiveLine } from "@/components/charts/live-line"; +import { LiveLineChart } from "@/components/charts/live-line-chart"; +import { LiveXAxis } from "@/components/charts/live-x-axis"; +import { ChartTooltip } from "@/components/charts/tooltip"; +import { XAxis } from "@/components/charts/x-axis"; +import type { DailyTraffic, RankedCount } from "@/lib/landing-stats"; +import type { LiveVisitors } from "@/lib/live-visitors"; + +/** Every series is one entity, so every mark wears the one brand accent. */ +const SERIES = "var(--brand)"; +const MUTED = "var(--muted-foreground)"; + +const count = new Intl.NumberFormat("en"); +const compactCount = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); + +/** Solid hairlines: a dashed grid reads as a threshold. */ +function HairlineGrid() { + return ; +} + +export function DailyVisitorsChart({ daily }: { daily: DailyTraffic[] }) { + // Local midnight, so the axis names the same day in every timezone. + const data = daily.map(({ date, visitors, views }) => { + const [year, month, day] = date.split("-").map(Number); + return { date: new Date(year, month - 1, day), visitors, views }; + }); + + return ( + + + {/* Today is still counting, so its segment is dashed. */} + + + [ + { color: SERIES, label: "Visitors", value: count.format(point.visitors as number) }, + { color: MUTED, label: "Page views", value: count.format(point.views as number) }, + ]} + /> + + ); +} + +const ROW = 36; +const BAR_GAP = 0.45; +const LABEL_WIDTH = 124; +const VALUE_WIDTH = 52; +const BAR_MARGIN = { top: 4, right: VALUE_WIDTH, bottom: 4, left: LABEL_WIDTH }; + +/** + * Horizontal bars with names and counts drawn as HTML beside them. BarYAxis + * caps labels at 70px, too narrow for component names, so rows are placed + * at the band centers the chart's scaleBand computes. + */ +export function RankedBarChart({ rows, label }: { rows: RankedCount[]; label: string }) { + const step = (rows.length * ROW) / (rows.length + BAR_GAP); + const center = (index: number) => BAR_MARGIN.top + step * (index + (1 + BAR_GAP) / 2); + + return ( +
+ + + [ + { color: SERIES, label, value: count.format(point.count as number) }, + ]} + /> + + {rows.map((row, index) => ( +
+ + {row.name} + + + {compactCount.format(row.count)} + +
+ ))} +
+ ); +} + +const POLL_MS = 20 * 1000; +const clock = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "2-digit" }); +// Module-level so the live chart's per-frame memos stay stable. +const formatClock = (ms: number) => clock.format(ms); +const formatOnline = (value: number) => `${Math.round(value)}`; + +/** True while the element is on screen and the tab is in front. */ +function useWatched() { + const ref = useRef(null); + const [onScreen, setOnScreen] = useState(false); + const [tabVisible, setTabVisible] = useState(true); + + useEffect(() => { + const element = ref.current; + if (!element) return; + const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting)); + observer.observe(element); + const onVisibility = () => setTabVisible(document.visibilityState === "visible"); + document.addEventListener("visibilitychange", onVisibility); + return () => { + observer.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + return [ref, onScreen && tabVisible] as const; +} + +/** + * Visitors online right now, polled from /api/live-visitors while on screen. + * The route caches for 15s, so every viewer shares the same few reads. + */ +export function LiveVisitorsCard() { + const [ref, watched] = useWatched(); + const [live, setLive] = useState(null); + const [status, setStatus] = useState<"loading" | "ready" | "unavailable">("loading"); + + useEffect(() => { + if (!watched) return; + let timer: number | undefined; + let cancelled = false; + + async function poll() { + try { + const response = await fetch("/api/live-visitors", { cache: "no-store" }); + const { live: next } = (await response.json()) as { live: LiveVisitors | null }; + if (cancelled) return; + if (next) { + setLive(next); + setStatus("ready"); + } else { + setStatus((current) => (current === "ready" ? current : "unavailable")); + } + } catch { + // A missed poll keeps the last reading; the next one tries again. + } + if (!cancelled) timer = window.setTimeout(poll, POLL_MS); + } + + poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [watched]); + + return ( +
+
+ + {status === "ready" ? ( + + ) : null} + + + Live, last 30 minutes +
+ +
+ + {live ? count.format(live.online) : "–"} + + + {live?.online === 1 ? "visitor" : "visitors"} online + +
+

+ {status === "unavailable" + ? "Live numbers are taking a break." + : live + ? `${count.format(live.views)} page views in the last 30 minutes` + : "Counting who's here…"} +

+ +
+ {live && live.points.length > 1 ? ( + + + + [ + { color: SERIES, label: "Online", value: formatOnline(point.value as number) }, + ]} + /> + + ) : null} +
+
+ ); +} diff --git a/components/landing/stats-section.tsx b/components/landing/stats-section.tsx index e91bee3..4e68b23 100644 --- a/components/landing/stats-section.tsx +++ b/components/landing/stats-section.tsx @@ -1,7 +1,13 @@ import { ArrowUpRightIcon, GithubLogoIcon } from "@phosphor-icons/react/dist/ssr"; +import type { ReactNode } from "react"; +import { + DailyVisitorsChart, + LiveVisitorsCard, + RankedBarChart, +} from "@/components/landing/analytics-charts"; import { showcase } from "@/components/landing/showcase"; import { TracwellCard } from "@/components/tracwell-card"; -import type { LandingStats } from "@/lib/landing-stats"; +import type { LandingStats, RankedCount } from "@/lib/landing-stats"; import { siteConfig } from "@/lib/seo"; import { cn } from "@/lib/utils"; @@ -19,6 +25,77 @@ const lgColumns: Record = { 4: "lg:grid-cols-4", }; +function ChartCard({ + title, + caption, + aside, + className, + children, +}: { + title?: string; + caption?: string; + /** A direct label for the one value worth reading off the chart. */ + aside?: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( +
+ {title ? ( +
+ + {title} + {caption ? ( + {caption} + ) : null} + + {aside} +
+ ) : null} + {children} +
+ ); +} + +const dayLabel = new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +function PeakDay({ daily }: { daily: LandingStats["daily"] }) { + const peak = daily.reduce((best, day) => (day.visitors > best.visitors ? day : best)); + return ( + + Peak{" "} + {compact.format(peak.visitors)} + {dayLabel.format(new Date(peak.date))} + + ); +} + +/** The numbers behind a bar chart, for screen readers. */ +function RankedTable({ rows, label }: { rows: RankedCount[]; label: string }) { + return ( + + + + + + + + + {rows.map((row) => ( + + + + + ))} + +
Name{label}
{row.name}{row.count}
+ ); +} + export function StatsSection({ stats }: { stats: LandingStats }) { // Numbers that couldn't be fetched are left out rather than shown as zero. const tiles: Stat[] = []; @@ -91,6 +168,42 @@ export function StatsSection({ stats }: { stats: LandingStats }) { ))} +
+ {stats.daily.length > 1 ? ( + } + className="lg:col-span-2" + > + + + ) : null} + 1 ? undefined : "lg:col-span-3"}> + + +
+ + {stats.sources.length > 0 || stats.mostCopied.length > 0 ? ( +
+ {stats.sources.length > 0 ? ( + + + + + ) : null} + {stats.mostCopied.length > 0 ? ( + + + + + ) : null} +
+ ) : null} +
diff --git a/lib/landing-stats.ts b/lib/landing-stats.ts index a1b722e..5a55287 100644 --- a/lib/landing-stats.ts +++ b/lib/landing-stats.ts @@ -1,9 +1,15 @@ import { unstable_cache } from "next/cache"; +import { showcase } from "@/components/landing/showcase"; import { siteConfig } from "@/lib/seo"; -import { createTracwellMcp } from "@/lib/tracwell-mcp"; +import { createTracwellMcp, TRACWELL_PROJECT_ID } from "@/lib/tracwell-mcp"; /** Seconds a fetched number is served before it's refreshed. */ -const REVALIDATE = 60 * 60; +const REVALIDATE = 10 * 60; + +/** One UTC day of traffic. `date` is `YYYY-MM-DD` so it survives the cache. */ +export type DailyTraffic = { date: string; visitors: number; views: number }; + +export type RankedCount = { name: string; count: number }; export type LandingStats = { stars: number | null; @@ -11,6 +17,22 @@ export type LandingStats = { visitors: number | null; /** `install_command_copied` events over the last 30 days. */ installsCopied: number | null; + /** Visitors and views per day over the last 30 days, oldest first. */ + daily: DailyTraffic[]; + /** Referrers by visitors over the last 30 days, largest first. */ + sources: RankedCount[]; + /** Components by install commands copied over the last 30 days. */ + mostCopied: RankedCount[]; +}; + +type TracwellStats = Omit; + +const EMPTY_TRACWELL: TracwellStats = { + visitors: null, + installsCopied: null, + daily: [], + sources: [], + mostCopied: [], }; async function fetchStars(): Promise { @@ -28,9 +50,6 @@ async function fetchStars(): Promise { return stargazers_count ?? null; } -/** Tracwell's id for this site (not a secret; the MCP key is). */ -const TRACWELL_PROJECT_ID = "888fa603-1018-468a-8e07-6275b95d2226"; - /** The last 30 days, today included, as UTC `YYYY-MM-DD` dates. */ function last30Days() { const day = 24 * 60 * 60 * 1000; @@ -38,9 +57,19 @@ function last30Days() { return { from: iso(Date.now() - 29 * day), to: iso(Date.now()) }; } -type Overview = { metrics?: { visitors?: { value?: number } } }; +type Breakdown = { value: string; count: number; share: number }; + +type Overview = { + metrics?: { visitors?: { value?: number } }; + trend?: { date: string; visitors: number; views: number }[]; + breakdowns?: { sources?: Breakdown[] }; +}; + type EventDetail = { - breakdowns?: { pages?: { count: number; share: number }[] }; + breakdowns?: { + pages?: Breakdown[]; + properties?: { key: string; values: { value: string; count: number }[] }[]; + }; }; /** @@ -53,11 +82,22 @@ function eventTotal({ breakdowns }: EventDetail) { return top.share > 0 ? Math.round((top.count * 100) / top.share) : top.count; } -/** Reads visitors and install copies over Tracwell's MCP server. */ +const componentNames = new Map(showcase.map((entry) => [entry.registryName, entry.name])); + +/** Copies per component, keyed by the `registry_name` the copy button sends. */ +function copiesByComponent({ breakdowns }: EventDetail): RankedCount[] { + const values = breakdowns?.properties?.find((p) => p.key === "registry_name")?.values ?? []; + return values.map(({ value, count }) => ({ + name: componentNames.get(value) ?? value, + count, + })); +} + +/** Reads the 30-day numbers and breakdowns over Tracwell's MCP server. */ const fetchTracwell = unstable_cache( - async (): Promise> => { + async (): Promise => { const key = process.env.TRACWELL_MCP_KEY; - if (!key) return { visitors: null, installsCopied: null }; + if (!key) return EMPTY_TRACWELL; const mcp = await createTracwellMcp(key); const range = { projectId: TRACWELL_PROJECT_ID, ...last30Days() }; const [overview, installs] = await Promise.all([ @@ -67,9 +107,22 @@ const fetchTracwell = unstable_cache( eventName: "install_command_copied", }), ]); + const traffic = overview.structuredContent as Overview; + const copies = installs.structuredContent as EventDetail; return { - visitors: (overview.structuredContent as Overview)?.metrics?.visitors?.value ?? null, - installsCopied: eventTotal(installs.structuredContent as EventDetail), + visitors: traffic?.metrics?.visitors?.value ?? null, + installsCopied: eventTotal(copies), + daily: (traffic?.trend ?? []).map(({ date, visitors, views }) => ({ + date, + visitors, + views, + })), + // "Others" lumps the long tail together, so it isn't a source to rank. + sources: (traffic?.breakdowns?.sources ?? []) + .filter((source) => source.value !== "Others") + .slice(0, 6) + .map(({ value, count }) => ({ name: value, count })), + mostCopied: copiesByComponent(copies).slice(0, 6), }; }, ["landing-tracwell-stats"], @@ -82,7 +135,7 @@ export async function getLandingStats(): Promise { fetchStars().catch(() => null), fetchTracwell().catch((error: unknown) => { console.warn("Tracwell stats unavailable:", error); - return { visitors: null, installsCopied: null }; + return EMPTY_TRACWELL; }), ]); return { stars, ...tracwell }; diff --git a/lib/live-visitors.ts b/lib/live-visitors.ts new file mode 100644 index 0000000..8071152 --- /dev/null +++ b/lib/live-visitors.ts @@ -0,0 +1,112 @@ +import { createTracwellMcp, TRACWELL_PROJECT_ID } from "@/lib/tracwell-mcp"; + +const MINUTE = 60 * 1000; +/** How far back the live chart looks. */ +const WINDOW = 30 * MINUTE; +/** A visitor counts as online for this long after their last event. */ +const ONLINE_FOR = 5 * MINUTE; +/** Stream pages are capped at 100 events; this bounds a traffic spike. */ +const PAGE_SIZE = 100; +const MAX_PAGES = 10; + +export type LivePoint = { time: number; value: number }; + +export type LiveVisitors = { + /** Unique visitors seen in the last five minutes. */ + online: number; + /** Page views in the last 30 minutes. */ + views: number; + /** Visitors online at each minute of the window, in Unix seconds. */ + points: LivePoint[]; +}; + +type StreamEvent = { id: string; kind: string; actor: string; receivedTime: string }; +type Stream = { events?: StreamEvent[]; completeness?: { eventsHasMore?: boolean } }; +type Mcp = Awaited>; + +/** Tracwell's stream reads within one UTC day, so a window over midnight is split. */ +function dayWindows(from: number, to: number) { + const windows: { date: string; fromTime: string; toTime: string }[] = []; + for (let start = from; start < to; ) { + const midnight = new Date(start).setUTCHours(24, 0, 0, 0); + const end = Math.min(midnight, to); + windows.push({ + date: new Date(start).toISOString().slice(0, 10), + fromTime: new Date(start).toISOString(), + toTime: new Date(end).toISOString(), + }); + start = end; + } + return windows; +} + +/** Events newest first; pages backwards by moving `toTime` to the oldest seen. */ +async function readStream(mcp: Mcp, window: ReturnType[number]) { + const events: StreamEvent[] = []; + let toTime = window.toTime; + let complete = false; + for (let page = 0; page < MAX_PAGES && !complete; page++) { + const result = await mcp.callTool("analytics_events", { + projectId: TRACWELL_PROJECT_ID, + date: window.date, + view: "stream", + limit: PAGE_SIZE, + excludeBots: true, + fromTime: window.fromTime, + toTime, + }); + const { events: batch = [], completeness } = result.structuredContent as Stream; + events.push(...batch); + const oldest = batch.at(-1); + complete = !completeness?.eventsHasMore || !oldest; + if (oldest) toTime = oldest.receivedTime; + } + return { events, complete }; +} + +/** Reads the last half hour of events and turns them into visitors online. */ +export async function fetchLiveVisitors(key: string): Promise { + const now = Date.now(); + // Points sit on whole minutes so they don't shift between refreshes. + const lastMinute = Math.floor(now / MINUTE) * MINUTE; + const from = lastMinute - WINDOW - ONLINE_FOR; + + const mcp = await createTracwellMcp(key); + // Windows run newest first, so a capped read loses the oldest events only. + const reads = []; + for (const window of dayWindows(from, now).reverse()) { + const read = await readStream(mcp, window); + reads.push(read); + if (!read.complete) break; + } + + const seen = new Set(); + const events = reads + .flatMap((read) => read.events) + .filter((event) => !seen.has(event.id) && seen.add(event.id)) + .map((event) => ({ ...event, time: Date.parse(event.receivedTime) })); + + // After a capped read, only minutes with a full lookback are reported. + const truncated = reads.some((read) => !read.complete); + const coveredFrom = truncated ? Math.min(...events.map((event) => event.time)) : from; + + const onlineAt = (time: number) => + new Set( + events + .filter((event) => event.time > time - ONLINE_FOR && event.time <= time) + .map((event) => event.actor), + ).size; + + const times: number[] = []; + for (let time = lastMinute - WINDOW; time <= lastMinute; time += MINUTE) { + if (time - ONLINE_FOR >= coveredFrom) times.push(time); + } + if (now > lastMinute) times.push(now); + + return { + online: onlineAt(now), + views: events.filter((event) => event.kind === "pageview" && event.time > now - WINDOW) + .length, + points: times.map((time) => ({ time: time / 1000, value: onlineAt(time) })), + }; +} diff --git a/lib/tracwell-mcp.ts b/lib/tracwell-mcp.ts index 65cccad..da57d6f 100644 --- a/lib/tracwell-mcp.ts +++ b/lib/tracwell-mcp.ts @@ -7,6 +7,9 @@ const ENDPOINT = "https://tracwell.app/mcp"; const PROTOCOL_VERSION = "2025-06-18"; +/** Tracwell's id for this site (not a secret; the MCP key is). */ +export const TRACWELL_PROJECT_ID = "888fa603-1018-468a-8e07-6275b95d2226"; + type JsonRpcResponse = { id?: number; result?: unknown; From 4f8b113866f516a30b2c9b068cc74720aa7a5042 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:37:48 +0530 Subject: [PATCH 04/16] Drop the Analytics by Tracwell credit from the landing stats --- components/landing/stats-section.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/components/landing/stats-section.tsx b/components/landing/stats-section.tsx index 4e68b23..e312aee 100644 --- a/components/landing/stats-section.tsx +++ b/components/landing/stats-section.tsx @@ -6,7 +6,6 @@ import { RankedBarChart, } from "@/components/landing/analytics-charts"; import { showcase } from "@/components/landing/showcase"; -import { TracwellCard } from "@/components/tracwell-card"; import type { LandingStats, RankedCount } from "@/lib/landing-stats"; import { siteConfig } from "@/lib/seo"; import { cn } from "@/lib/utils"; @@ -203,10 +202,6 @@ export function StatsSection({ stats }: { stats: LandingStats }) { ) : null} ) : null} - -
- -
); From ed2fc9dabb2c380d120624b48795bd8822d06e77 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:42:35 +0530 Subject: [PATCH 05/16] Add a sponsors section to the landing Below the stats: Shadcn Labs as the sponsor, Tracwell as the analytics platform sponsor, and an open "Your logo here" slot. The open slot and "Become a sponsor" link to GitHub Sponsors. TracwellCard now exports its logo and a UTM link helper so both can share them. --- app/page.tsx | 2 + components/landing/sponsors-section.tsx | 107 ++++++++++++++++++++++++ components/tracwell-card.tsx | 8 +- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 components/landing/sponsors-section.tsx diff --git a/app/page.tsx b/app/page.tsx index 54f06a6..6533963 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { LandingPage } from "@/components/landing/landing-page"; +import { SponsorsSection } from "@/components/landing/sponsors-section"; import { StatsSection } from "@/components/landing/stats-section"; import { getLandingStats } from "@/lib/landing-stats"; import { source } from "@/lib/source"; @@ -38,6 +39,7 @@ export default async function HomePage() { return ( + ); } \ No newline at end of file diff --git a/components/landing/sponsors-section.tsx b/components/landing/sponsors-section.tsx new file mode 100644 index 0000000..cdb9ddd --- /dev/null +++ b/components/landing/sponsors-section.tsx @@ -0,0 +1,107 @@ +import { ArrowRightIcon, PlusIcon } from "@phosphor-icons/react/dist/ssr"; +import type { ReactNode } from "react"; +import { SHADCN_LABS_URL, ShadcnLabsLogotype } from "@/components/shadcn-labs"; +import { TracwellLogo, tracwellUrl } from "@/components/tracwell-card"; +import { cn } from "@/lib/utils"; + +const SPONSOR_URL = "https://github.com/sponsors/radiumcoders"; + +const card = + "group flex flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-foreground/[0.025] transition-colors hover:border-foreground/15 hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +function TierLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function SponsorCard({ + href, + label, + role, + className, + children, +}: { + href: string; + /** Accessible name, since the logo inside is decorative. */ + label: string; + role?: string; + className?: string; + children: ReactNode; +}) { + return ( + + {children} + {role ? ( + + {role} + + ) : null} + + ); +} + +export function SponsorsSection() { + return ( +
+
+

+ Evil Buttons is backed and supported by the finest +

+ + Sponsor + + + + + Platform sponsors +
+ + + + Tracwell + + + + + Your logo here + +
+ + +
+
+ ); +} diff --git a/components/tracwell-card.tsx b/components/tracwell-card.tsx index 7627afc..4db30e4 100644 --- a/components/tracwell-card.tsx +++ b/components/tracwell-card.tsx @@ -1,6 +1,10 @@ import { ArrowUpRightIcon } from "@phosphor-icons/react/dist/ssr"; -function TracwellLogo({ className }: { className?: string }) { +/** UTM-tagged so the visit shows up under evilbuttons in Tracwell's analytics. */ +export const tracwellUrl = (campaign: string) => + `https://tracwell.app/?utm_source=evilbuttons&utm_medium=referral&utm_campaign=${campaign}`; + +export function TracwellLogo({ className }: { className?: string }) { return ( @@ -22,7 +26,7 @@ function TracwellLogo({ className }: { className?: string }) { export function TracwellCard() { return ( Date: Wed, 30 Sep 2026 15:44:38 +0530 Subject: [PATCH 06/16] Shrink the Shadcn Labs sponsor card to a platform-card footprint The full-width banner with a large logo overstated the sponsorship. It's now a centered card the size of a platform card, with a 16px logotype. --- components/landing/sponsors-section.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/landing/sponsors-section.tsx b/components/landing/sponsors-section.tsx index cdb9ddd..d8c5c94 100644 --- a/components/landing/sponsors-section.tsx +++ b/components/landing/sponsors-section.tsx @@ -59,8 +59,13 @@ export function SponsorsSection() { Sponsor - - + {/* Same footprint as a platform card, centered, rather than a full-width banner. */} + + Platform sponsors From 0b00c7fcf14f8940469f68c90ecc83febc553d6a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:50:04 +0530 Subject: [PATCH 07/16] Add a shader-backed footer to the landing The footer mirrors the first screen: the same inset rounded frame over the evil shader and top scrim, so the page opens and closes on the red smoke. A nav row (Home, Docs, Sponsor, GitHub, X) sits above an oversized "Evil Buttons." wordmark, with copyright, sitemap, robots.txt, llms.txt and an Apache 2.0 badge along the bottom. ShaderBackdrop and TopScrim move to their own module to be shared, and the scrim's gradient id now comes from useId so two can coexist. --- components/landing/landing-footer.tsx | 138 ++++++++++++++++++++++++ components/landing/landing-page.tsx | 60 +---------- components/landing/shader-backdrop.tsx | 62 +++++++++++ components/landing/sponsors-section.tsx | 112 ------------------- components/tracwell-card.tsx | 8 +- 5 files changed, 206 insertions(+), 174 deletions(-) create mode 100644 components/landing/landing-footer.tsx create mode 100644 components/landing/shader-backdrop.tsx delete mode 100644 components/landing/sponsors-section.tsx diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx new file mode 100644 index 0000000..246145f --- /dev/null +++ b/components/landing/landing-footer.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { GithubLogo, XLogo } from "@phosphor-icons/react"; +import Image from "next/image"; +import Link from "next/link"; +import { ShaderBackdrop, TopScrim } from "@/components/landing/shader-backdrop"; +import { siteConfig } from "@/lib/seo"; + +const X_URL = "https://x.com/radiumcoders"; +const SPONSOR_URL = "https://github.com/sponsors/radiumcoders"; +const LICENSE_URL = `${siteConfig.github}/blob/main/LICENSE`; + +const navLinks = [ + { label: "Home", href: "/" }, + { label: "Docs", href: "/docs" }, + { label: "Sponsor", href: SPONSOR_URL }, +]; + +const legalLinks = [ + { label: "Sitemap", href: "/sitemap.xml" }, + { label: "robots.txt", href: "/robots.txt" }, + { label: "llms.txt", href: "/llms.txt" }, +]; + +const navText = + "rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; +const smallText = + "rounded-sm text-foreground/60 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +function NavLink({ href, className, children }: { href: string; className: string; children: React.ReactNode }) { + if (href.startsWith("/") && !href.includes(".")) { + return ( + + {children} + + ); + } + const external = href.startsWith("http"); + return ( + + {children} + + ); +} + +/** Two-tone chip in the spirit of a license seal. */ +function LicenseBadge() { + return ( + + Open source + + Apache 2.0 + + + ); +} + +export function LandingFooter() { + const year = new Date().getFullYear(); + + return ( +
+
+ + +
+ +
+
+ + + Evil Buttons + + +
+ +

+ Evil + Buttons. +

+ +
+

+ Evil Buttons © {year} + · + + Built by{" "} + + {siteConfig.author.name} + + +

+
+ {legalLinks.map((link, index) => ( + + {index > 0 ? · : null} + + {link.label} + + + ))} + + + +
+
+
+
+ ); +} diff --git a/components/landing/landing-page.tsx b/components/landing/landing-page.tsx index 37d82d9..b0ef91d 100644 --- a/components/landing/landing-page.tsx +++ b/components/landing/landing-page.tsx @@ -8,8 +8,9 @@ import { } from "@phosphor-icons/react"; import Image from "next/image"; import Link from "next/link"; -import { type ReactNode, useState } from "react"; -import { EvilShader } from "@/components/landing/evil-shader"; +import type { ReactNode } from "react"; +import { LandingFooter } from "@/components/landing/landing-footer"; +import { ShaderBackdrop, TopScrim } from "@/components/landing/shader-backdrop"; import { ShowcaseCarousel } from "@/components/landing/showcase-carousel"; import { SHADCN_LABS_URL, @@ -99,60 +100,6 @@ function BackedBy() { ); } -function ShaderBackdrop() { - const theme = useAppTheme(); - const [failed, setFailed] = useState(false); - - if (failed) return null; - - // Falls back to the CSS .landing-bg underneath when WebGL2 is unavailable. - return ( - { - console.warn("Landing shader disabled:", error.message); - setFailed(true); - }} - className="pointer-events-none absolute inset-0 rounded-[inherit]" - /> - ); -} - -// Eased stops so the fade has no visible band where it ends. -const scrimStops = [ - [0, 0.92], - [0.2, 0.8], - [0.4, 0.58], - [0.6, 0.32], - [0.8, 0.1], - [1, 0], -] as const; - -/** Darkens the top of the shader so the navbar and headline stay legible. */ -function TopScrim() { - return ( - - ); -} - export function LandingPage({ categories, children }: LandingPageProps) { return ( // never scrolls, so the page is its own scroll container. @@ -209,6 +156,7 @@ export function LandingPage({ categories, children }: LandingPageProps) { {children} + ); } diff --git a/components/landing/shader-backdrop.tsx b/components/landing/shader-backdrop.tsx new file mode 100644 index 0000000..40b580e --- /dev/null +++ b/components/landing/shader-backdrop.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useId, useState } from "react"; +import { EvilShader } from "@/components/landing/evil-shader"; +import { useAppTheme } from "@/hooks/use-app-theme"; + +export function ShaderBackdrop() { + const theme = useAppTheme(); + const [failed, setFailed] = useState(false); + + if (failed) return null; + + // Falls back to the CSS .landing-bg underneath when WebGL2 is unavailable. + return ( + { + console.warn("Landing shader disabled:", error.message); + setFailed(true); + }} + className="pointer-events-none absolute inset-0 rounded-[inherit]" + /> + ); +} + +// Eased stops so the fade has no visible band where it ends. +const scrimStops = [ + [0, 0.92], + [0.2, 0.8], + [0.4, 0.58], + [0.6, 0.32], + [0.8, 0.1], + [1, 0], +] as const; + +/** Darkens the top of the shader so the navbar and headline stay legible. */ +export function TopScrim() { + // Unique per instance: the hero and the footer both draw one. + const gradientId = useId(); + + return ( + + ); +} diff --git a/components/landing/sponsors-section.tsx b/components/landing/sponsors-section.tsx deleted file mode 100644 index d8c5c94..0000000 --- a/components/landing/sponsors-section.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { ArrowRightIcon, PlusIcon } from "@phosphor-icons/react/dist/ssr"; -import type { ReactNode } from "react"; -import { SHADCN_LABS_URL, ShadcnLabsLogotype } from "@/components/shadcn-labs"; -import { TracwellLogo, tracwellUrl } from "@/components/tracwell-card"; -import { cn } from "@/lib/utils"; - -const SPONSOR_URL = "https://github.com/sponsors/radiumcoders"; - -const card = - "group flex flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-foreground/[0.025] transition-colors hover:border-foreground/15 hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; - -function TierLabel({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} - -function SponsorCard({ - href, - label, - role, - className, - children, -}: { - href: string; - /** Accessible name, since the logo inside is decorative. */ - label: string; - role?: string; - className?: string; - children: ReactNode; -}) { - return ( - - {children} - {role ? ( - - {role} - - ) : null} - - ); -} - -export function SponsorsSection() { - return ( -
-
-

- Evil Buttons is backed and supported by the finest -

- - Sponsor - {/* Same footprint as a platform card, centered, rather than a full-width banner. */} - - - - - Platform sponsors -
- - - - Tracwell - - - - - Your logo here - -
- - -
-
- ); -} diff --git a/components/tracwell-card.tsx b/components/tracwell-card.tsx index 4db30e4..7627afc 100644 --- a/components/tracwell-card.tsx +++ b/components/tracwell-card.tsx @@ -1,10 +1,6 @@ import { ArrowUpRightIcon } from "@phosphor-icons/react/dist/ssr"; -/** UTM-tagged so the visit shows up under evilbuttons in Tracwell's analytics. */ -export const tracwellUrl = (campaign: string) => - `https://tracwell.app/?utm_source=evilbuttons&utm_medium=referral&utm_campaign=${campaign}`; - -export function TracwellLogo({ className }: { className?: string }) { +function TracwellLogo({ className }: { className?: string }) { return ( @@ -26,7 +22,7 @@ export function TracwellLogo({ className }: { className?: string }) { export function TracwellCard() { return ( Date: Wed, 30 Sep 2026 15:50:17 +0530 Subject: [PATCH 08/16] Remove the landing sponsors section The component and its TracwellCard export went out with the previous commit; this drops the leftover render and import from the home page. --- app/page.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 6533963..54f06a6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; import { LandingPage } from "@/components/landing/landing-page"; -import { SponsorsSection } from "@/components/landing/sponsors-section"; import { StatsSection } from "@/components/landing/stats-section"; import { getLandingStats } from "@/lib/landing-stats"; import { source } from "@/lib/source"; @@ -39,7 +38,6 @@ export default async function HomePage() { return ( - ); } \ No newline at end of file From 8a5467dfe91ba96a5036079f86df60159470608a Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:54:08 +0530 Subject: [PATCH 09/16] Frame the landing footer like the first screen The footer is now a screen-tall inset frame with the same even gap on every side as the hero. The gap moves from margin to padding, since the scroll container dropped the footer's bottom margin and pinned its edge to the window. The hairline above the nav row goes, and the row takes the hero navbar's 64px height. --- components/landing/landing-footer.tsx | 122 +++++++++++++------------- 1 file changed, 63 insertions(+), 59 deletions(-) diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx index 246145f..245a4a2 100644 --- a/components/landing/landing-footer.tsx +++ b/components/landing/landing-footer.tsx @@ -68,71 +68,75 @@ export function LandingFooter() { const year = new Date().getFullYear(); return ( -
-
- - -
- -
-
- - - Evil Buttons - - + // The same inset, screen-tall frame as the first screen. The gap is padding, + // not margin: a scroll container drops its last child's bottom margin. +
+
+
+ +
-

- Evil - Buttons. -

- -
-

- Evil Buttons © {year} - · - - Built by{" "} - - {siteConfig.author.name} - - -

-
- {legalLinks.map((link, index) => ( - - {index > 0 ? · : null} - +
+
+ + + Evil Buttons + + +
+ +

+ Evil + Buttons. +

+ +
+

+ Evil Buttons © {year} + · + + Built by{" "} + + {siteConfig.author.name} + + +

+
+ {legalLinks.map((link, index) => ( + + {index > 0 ? · : null} + + {link.label} + + + ))} + + - ))} - - - +
-
-
+ +
); } From 70f413642508b4c170cd004f8625b9c04a9744a7 Mon Sep 17 00:00:00 2001 From: radiumcoders Date: Wed, 30 Sep 2026 15:56:15 +0530 Subject: [PATCH 10/16] Drop the footer's nav row and restore its height The footer is back to its 30rem / 38rem minimum instead of a full screen, and it no longer repeats the navbar. The padded bottom gap stays. --- components/landing/landing-footer.tsx | 67 ++------------------------- 1 file changed, 5 insertions(+), 62 deletions(-) diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx index 245a4a2..d0f92b6 100644 --- a/components/landing/landing-footer.tsx +++ b/components/landing/landing-footer.tsx @@ -1,52 +1,19 @@ "use client"; -import { GithubLogo, XLogo } from "@phosphor-icons/react"; -import Image from "next/image"; -import Link from "next/link"; import { ShaderBackdrop, TopScrim } from "@/components/landing/shader-backdrop"; import { siteConfig } from "@/lib/seo"; -const X_URL = "https://x.com/radiumcoders"; -const SPONSOR_URL = "https://github.com/sponsors/radiumcoders"; const LICENSE_URL = `${siteConfig.github}/blob/main/LICENSE`; -const navLinks = [ - { label: "Home", href: "/" }, - { label: "Docs", href: "/docs" }, - { label: "Sponsor", href: SPONSOR_URL }, -]; - const legalLinks = [ { label: "Sitemap", href: "/sitemap.xml" }, { label: "robots.txt", href: "/robots.txt" }, { label: "llms.txt", href: "/llms.txt" }, ]; -const navText = - "rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; const smallText = "rounded-sm text-foreground/60 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; -function NavLink({ href, className, children }: { href: string; className: string; children: React.ReactNode }) { - if (href.startsWith("/") && !href.includes(".")) { - return ( - - {children} - - ); - } - const external = href.startsWith("http"); - return ( - - {children} - - ); -} - /** Two-tone chip in the spirit of a license seal. */ function LicenseBadge() { return ( @@ -68,40 +35,16 @@ export function LandingFooter() { const year = new Date().getFullYear(); return ( - // The same inset, screen-tall frame as the first screen. The gap is padding, - // not margin: a scroll container drops its last child's bottom margin. + // The same inset frame as the first screen. The gap is padding, not + // margin: a scroll container drops its last child's bottom margin.
-