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/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/app/page.tsx b/app/page.tsx index f776a0a..b67b19f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,8 @@ 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"; export const metadata: Metadata = { @@ -23,10 +26,20 @@ 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.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/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/landing/analytics-charts.tsx b/components/landing/analytics-charts.tsx new file mode 100644 index 0000000..a4d7e7a --- /dev/null +++ b/components/landing/analytics-charts.tsx @@ -0,0 +1,112 @@ +"use client"; + +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 { ChartTooltip } from "@/components/charts/tooltip"; +import { XAxis } from "@/components/charts/x-axis"; +import type { DailyTraffic, RankedCount } from "@/lib/landing-stats"; + +/** 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)} + +
+ ))} +
+ ); +} diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx new file mode 100644 index 0000000..d0f92b6 --- /dev/null +++ b/components/landing/landing-footer.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { ShaderBackdrop, TopScrim } from "@/components/landing/shader-backdrop"; +import { siteConfig } from "@/lib/seo"; + +const LICENSE_URL = `${siteConfig.github}/blob/main/LICENSE`; + +const legalLinks = [ + { label: "Sitemap", href: "/sitemap.xml" }, + { label: "robots.txt", href: "/robots.txt" }, + { label: "llms.txt", href: "/llms.txt" }, +]; + +const smallText = + "rounded-sm text-foreground/60 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + +/** 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 ( + // The same inset frame as the first screen. The gap is padding, not + // margin: a scroll container drops its last child's bottom margin. +
+ +
+ ); +} 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/sponsor-dialog.tsx b/components/landing/sponsor-dialog.tsx new file mode 100644 index 0000000..97ae240 --- /dev/null +++ b/components/landing/sponsor-dialog.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ArrowUpRightIcon, CheckIcon, MinusIcon, XLogoIcon } from "@phosphor-icons/react"; +import { type CSSProperties, type ReactNode, useState } from "react"; +import { SponsorShader } from "@/components/landing/sponsor-shader"; +import { + perks, + type SponsorTier, + sponsorTiers, + type TierId, +} from "@/components/landing/sponsor-tiers"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useAppTheme } from "@/hooks/use-app-theme"; +import { cn } from "@/lib/utils"; + +const CONTACT_URL = "https://x.com/radiumcoders"; + +/** The dialog's --popover, so the shader melts into it. */ +const POPOVER = { dark: "#171717", light: "#ffffff" }; + +const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); + +const cadenceShort = { month: "/mo", once: " once" } as const; +const cadenceLong = { month: "per month", once: "one-time" } as const; + +/** Tier-colored light from above, if the shader can't run. */ +function TierGlow({ tier, active }: { tier: SponsorTier; active: boolean }) { + return ( +
+ ); +} + +/** + * Sponsorship details, opened from an open slot on the landing. Starts on + * the slot's tier; the switcher shows what the other tiers add or drop. + */ +export function SponsorDialog({ + tier: initialTier, + visitors, + children, +}: { + tier: TierId; + /** Visitors over the last 30 days, when known. */ + visitors: number | null; + children: ReactNode; +}) { + const theme = useAppTheme(); + const [tierId, setTierId] = useState(initialTier); + const [shaderFailed, setShaderFailed] = useState(false); + const tier = sponsorTiers.find((t) => t.id === tierId) ?? sponsorTiers[0]; + + return ( + open && setTierId(initialTier)}> + {children} + + {/* Dithered glow in the tier's colors, fading out before the perks. The + layer clips itself to the dialog's corners: a masked WebGL canvas can + escape the parent's rounded overflow clip and paint square corners. */} +
+ {shaderFailed ? ( + sponsorTiers.map((option) => ( + + )) + ) : ( + { + console.warn("Sponsor shader disabled:", error.message); + setShaderFailed(true); + }} + className="opacity-75" + /> + )} + {/* Keeps the left-aligned heading legible; the glow stays vivid on the right. */} +
+
+ +
+
+

+ Sponsorship +

+ + Sponsor{" "} + Evil{" "} + Buttons + + + Put your logo in front of developers picking components for their next build + {visitors ? `, ${compact.format(visitors)} of them in the last 30 days` : ""}. + +
+ +
+ {sponsorTiers.map((option) => { + const selected = option.id === tierId; + return ( + + ); + })} +
+ +
+
+

+ + ${tier.price} + + {cadenceLong[tier.cadence]} +

+ + {tier.spots} + +
+

{tier.summary}

+ +
    + {perks.map((perk) => { + const detail = tier.perks[perk.id]; + return ( +
  • + {detail ? ( + + ) : ( + + )} + + {perk.label} + {detail ? ( + {detail} + ) : null} + +
  • + ); + })} +
+ + {tier.id === "sponsor" ? ( +

+ Shadcn Labs keeps its hero spot as our founding sponsor. They backed Evil + Buttons before there were tiers. +

+ ) : null} +
+
+ +
+

+ Perks are still taking shape. Ask for what you need. +

+ + + DM me about {tier.name} + + +
+ +
+ ); +} diff --git a/components/landing/sponsor-shader.tsx b/components/landing/sponsor-shader.tsx new file mode 100644 index 0000000..fedaef5 --- /dev/null +++ b/components/landing/sponsor-shader.tsx @@ -0,0 +1,578 @@ +/* + * Adapted from @isabruy · OpenShaders + * https://openshaders.com/@isabruy + * WebGL2 · dither + * + * Changes from the original: the field's hue, hue spread, chroma and + * lightness are uniforms instead of constants, so the sponsor dialog can + * recolor it per tier, and palette changes ease like theme changes do. + */ + +"use client"; + +import { type CSSProperties, useEffect, useRef } from "react"; + +/** OKLCH colour for the glow. Hues are in turns (0–1). */ +export type ShaderPalette = { + hue: number; + hueSpread: number; + chroma: number; + lightness: number; +}; + +const DEFAULT_PALETTE: ShaderPalette = { + hue: 0.81291616, + hueSpread: -0.283074647, + chroma: 0.0996806771, + lightness: 0.603072345, +}; + +const VERTEX_SHADER = `#version 300 es +void main() { + vec2 position = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0); +} +`; + +const FIELD_SHADER = `#version 300 es +precision highp float; + +uniform vec2 iResolution; +uniform float iTime; +uniform float uLightMode; +uniform vec3 uDarkBackground; +uniform vec3 uLightBackground; +uniform float uHue; +uniform float uHueSpread; +uniform float uChroma; +uniform float uLightness; +out vec4 fragColor; + +const float HUE_TRAVEL = 2.02707171; +const float COLOUR_CYCLE = 0.119931176; +const float THETA = 2.12858248; +const float SHEAR = 0.960009038; +const float SHRINK = 0.952544451; +const float LAYERS = 93.0; +const float WARP_FREQ_X = 0.459905267; +const float WARP_FREQ_Y = 2.82044196; +const float WARP_AMP_X = 0.107993826; +const float WARP_AMP_Y = 0.0339489467; +const float ASPECT_X = 2.4871676; +const float ASPECT_Y = 0.161353439; +const float OFFSET_X = 0.334827363; +const float OFFSET_Y = 0.00360822887; +const float TILT = -1.41846132; +const float ZOOM = 1.15413666; +const float CENTRE_X = -0.746459067; +const float CENTRE_Y = 0.0618864223; +const float GLOW_SIZE = 0.00257342239; +const float FALLOFF = 0.26105687; +const float VIGNETTE = 0.042046003; +const float FLOW_SPEED = 0.533952713; +const float FLOW_DIRECTION = 1.0; +const float BREATH_RATE = 0.59453541; +const float BREATH_AMOUNT = 0.0728159547; +const float PHASE = 63.8945999; +const float ECHO = 0.0; +const float ECHO_SHIFT = 0.123792708; +const float SOFTNESS = 0.00234925095; +const float LIGHT_SWING = 0.124245897; + +const float TAU = 6.28318530718; + +vec3 oklchToLinear(float L, float C, float h) { + float a = C * cos(h), b = C * sin(h); + float l_ = L + 0.3963377774 * a + 0.2158037573 * b; + float m_ = L - 0.1055613458 * a - 0.0638541728 * b; + float s_ = L - 0.0894841775 * a - 1.2914855480 * b; + vec3 lms = vec3(l_, m_, s_); + lms = lms * lms * lms; + return mat3(4.0767416621, -1.2684380046, -0.0041960863, + -3.3077115913, 2.6097574011, -0.7034186147, + 0.2309699292, -0.3413193965, 1.7076147010) * lms; +} + +float blueNoise(vec2 p, float frame) { + p += 5.588238 * mod(frame, 64.0); + return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y)); +} + +void main() { + vec2 R = iResolution.xy; + vec2 pos = (gl_FragCoord.xy - 0.5 * R) / R.y; + float t = iTime * FLOW_SPEED * FLOW_DIRECTION + PHASE; + float breath = (-sin(iTime * BREATH_RATE * 1.5) + sin(iTime * BREATH_RATE + 1.0)) * 0.25 + 0.5; + + vec2 u = (pos - vec2(CENTRE_X, CENTRE_Y)) * (ZOOM - breath * BREATH_AMOUNT); + float ct = cos(TILT), st = sin(TILT); + u = mat2(ct, st, -st, ct) * u; + + mat2 fold = mat2(cos(THETA), sin(THETA), -SHEAR, cos(THETA)); + + float hue0 = uHue * TAU; + float hue1 = hue0 + uHueSpread * TAU; + vec3 color = vec3(0.0); + + for (float i = 1.0; i <= 96.0; i += 1.0) { + if (i > LAYERS) break; + u.x += -sin(u.y * WARP_FREQ_X + t + i * 0.007) * WARP_AMP_X; + u.y += -sin(u.x * WARP_FREQ_Y - t + i * 0.02) * WARP_AMP_Y; + u = fold * u * SHRINK; + + vec2 q = u - vec2(OFFSET_X + breath * 0.1, OFFSET_Y); + vec2 s = vec2(q.x * ASPECT_X, q.y * ASPECT_Y); + float glow = GLOW_SIZE / (dot(s, s) + SOFTNESS); +#ifndef SKIP_ECHO + vec2 e = vec2((q.x - ECHO_SHIFT) * ASPECT_X, s.y); + glow += ECHO * GLOW_SIZE / (dot(e, e) + SOFTNESS); +#endif + glow *= 0.25 + breath * 0.4; + + float r = length(u); + float k = sin(i * COLOUR_CYCLE + t * 1.2 + r * HUE_TRAVEL) * 0.5 + 0.5; + vec3 tint = clamp(oklchToLinear(uLightness + LIGHT_SWING * k, uChroma * (0.75 + 0.35 * k), mix(hue0, hue1, k)), 0.0, 1.0); + color += glow * tint * exp2(-r * FALLOFF); + } + + vec3 x = max(color, 0.0); + color = (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14); + color = pow(clamp(color, 0.0, 1.0), vec3(0.85, 0.92, 0.98)); + + float edge = smoothstep(0.5, 1.6, length(pos)); + color *= 1.0 - edge * VIGNETTE; + + vec3 dark = uDarkBackground + color * (1.0 - uDarkBackground); + float strength = max(color.r, max(color.g, color.b)); + vec3 light = uLightBackground * (1.0 - strength) + color * 0.96; + color = mix(dark, light, uLightMode); + + color += (blueNoise(gl_FragCoord.xy, floor(iTime * 24.0)) - 0.5) / 255.0; + fragColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} +`; + +const RARITY_SHADER = `#version 300 es +precision highp float; + +uniform sampler2D tScene; +uniform vec2 iResolution; +uniform float iTime; +uniform float uLightMode; +uniform vec3 uDarkBackground; +uniform vec3 uLightBackground; +uniform float uPixelRatio; +out vec4 fragColor; + +const float uStrength = 1.25417173; +const float uScale = 0.863445401; +const float uSeed = 0.613922894; + +const float TAU = 6.28318530718; +const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722); + +vec3 toInk(vec3 c) { return mix(c - uDarkBackground, uLightBackground - c, uLightMode); } +vec3 fromInk(vec3 ink) { return mix(uDarkBackground + ink, uLightBackground - ink, uLightMode); } +vec3 sceneInk(vec2 uv) { return toInk(texture(tScene, clamp(uv, 0.0, 1.0)).rgb); } + +const mat4 BAYER = mat4( + 0.94118, 0.29412, 0.76471, 0.05882, + 0.47059, 0.70588, 0.23529, 0.52941, + 0.82353, 0.11765, 0.88235, 0.17647, + 0.35294, 0.58824, 0.41176, 0.64706 +); + +vec3 dither(vec2 frag) { + float cell = max(2.0, floor(uScale * 2.2 * uPixelRatio + 0.5)); + vec2 grid = floor(frag / cell); + vec3 soft = sceneInk(frag / iResolution); + vec3 ink = sceneInk((grid + 0.5) * cell / iResolution); + float level = dot(ink, LUMA); + float levels = 8.0; + ivec2 b = ivec2(mod(grid, 4.0)); + float v = pow(max(level, 0.0), 0.8) * levels + BAYER[b.x][b.y]; + float quantised = pow(floor(v) / levels, 1.25); + vec3 dithered = ink * (quantised / max(level, 1e-4)); + float presence = smoothstep(0.03, 0.14, level) * (0.38 + 0.2 * uStrength); + return mix(soft, dithered, presence); +} + +void main() { + vec2 frag = gl_FragCoord.xy; + vec3 ink = dither(frag); + vec3 color = fromInk(clamp(ink, 0.0, 1.0)); + fragColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} +`; + +export type ShaderTheme = "dark" | "light"; + +type ShaderOptions = { + theme?: ShaderTheme; + palette?: ShaderPalette; + background?: { dark?: string; light?: string }; + autoplay?: boolean; + signal?: AbortSignal; + onError?: (error: Error) => void; +}; + +type ShaderHandle = { + setTheme(theme: ShaderTheme): void; + setPalette(palette: ShaderPalette): void; + render(time: number): void; + destroy(): void; +}; + +export type SponsorShaderProps = { + theme?: ShaderTheme; + palette?: ShaderPalette; + background?: { dark?: string; light?: string }; + time?: number; + onError?: (error: Error) => void; + className?: string; + style?: CSSProperties; +}; + +export function SponsorShader({ + theme = "dark", + palette = DEFAULT_PALETTE, + background, + time, + onError, + className, + style, +}: SponsorShaderProps) { + const canvas = useRef(null); + const shader = useRef(null); + const latestTheme = useRef(theme); + const latestPalette = useRef(palette); + const latestTime = useRef(time); + const latestOnError = useRef(onError); + const dark = background?.dark ?? "#090909"; + const light = background?.light ?? "#ffffff"; + const animated = time === undefined; + + useEffect(() => { + latestTheme.current = theme; + shader.current?.setTheme(theme); + }, [theme]); + + useEffect(() => { + latestPalette.current = palette; + shader.current?.setPalette(palette); + }, [palette]); + + useEffect(() => { + latestTime.current = time; + if (time !== undefined) shader.current?.render(time); + }, [time]); + + useEffect(() => { + latestOnError.current = onError; + }, [onError]); + + useEffect(() => { + const element = canvas.current; + if (!element) return; + let handle: ShaderHandle | null = null; + const controller = new AbortController(); + const options: ShaderOptions = { + theme: latestTheme.current, + palette: latestPalette.current, + background: { dark, light }, + autoplay: animated, + signal: controller.signal, + onError: (error) => { + if (controller.signal.aborted) return; + if (latestOnError.current) latestOnError.current(error); + else console.error(error); + }, + }; + try { + handle = createShader(element, options); + shader.current = handle; + if (latestTime.current !== undefined) handle.render(latestTime.current); + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error(String(error))); + } + return () => { + controller.abort(); + handle?.destroy(); + shader.current = null; + }; + }, [dark, light, animated]); + + return ( +
); 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/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/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; 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 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",