| 1 | "use client" |
| 2 | |
| 3 | import type { ScaleLinear } from "d3-scale" |
| 4 | import { createContext, use |
| 5 | import type { CommonChart |
| 6 | import type { BloomInput |
| 7 | import type { DitherColor, |
| 8 | import { seedOfColor } |
| 9 | import { |
| 10 | buildBandScale, |
| 11 | buildXScale, |
| 12 | buildYScale, |
| 13 | computeBands, |
| 14 | indexAtBand, |
| 15 | nearestIndex, |
| 16 | type StackType, |
| 17 | } from "./scales" |
| 18 | import type { Dimensions |
| 19 | |
| 20 | /** Which chart root a part is composed under - drives the boundary guards. */ |
| 21 | export type ChartType |
| 22 | |
| 23 | export type ChartConfig |
| 24 | |
| 25 | export type Margins |
| 26 | top: number |
| 27 | right: number |
| 28 | bottom: number |
| 29 | left: number |
| 30 | } |
| 31 | |
| 32 | type Row |
| 33 | |
| 34 | export type AreaVariant |
| 35 | export type StrokeVariant |
| 36 | export type SeriesKind |
| 37 | |
| 38 | /** What each series part (<Area />, <Line />, <Bar />) registers so the canvas |
| 39 | * knows which series to paint and how. */ |
| 40 | export type SeriesSpec |
| 41 | dataKey: string |
| 42 | kind: SeriesKind |
| 43 | variant: AreaVariant |
| 44 | strokeVariant: StrokeVariant |
| 45 | } |
| 46 | |
| 47 | export type ChartContextValue |
| 48 | chartType: ChartType |
| 49 | config: ChartConfig |
| 50 | configKeys: string |
| 51 | data: Row |
| 52 | dataLength: number |
| 53 | stackType: StackType |
| 54 | |
| 55 | margins: Margins |
| 56 | plot: { |
| 57 | ready: boolean |
| 58 | |
| 59 | xCenter: (index |
| 60 | bandwidth: number |
| 61 | indexAtX: (px |
| 62 | // Bar geometry in plot px - one source of truth for the canvas + click rects. |
| 63 | barSlot: ( |
| 64 | index: number |
| 65 | seriesIndex: number |
| 66 | seriesCount: number |
| 67 | ) => |
| 68 | y: ScaleLinear |
| 69 | bands: Record |
| 70 | max: number |
| 71 | |
| 72 | // Interaction state, shared by every part. |
| 73 | selectedDataKey: string |
| 74 | selectDataKey: (key |
| 75 | /** Legend-hover spotlight - dims every series but this one while set. */ |
| 76 | focusDataKey: string |
| 77 | setFocusDataKey: (key |
| 78 | hoverIndex: number |
| 79 | setHoverIndex: (index |
| 80 | markerIndex: number |
| 81 | cursorX: number |
| 82 | setCursorX: (px |
| 83 | isMouseInChart: boolean |
| 84 | setMouseInChart: (over |
| 85 | hovered: boolean |
| 86 | bloom: BloomInput |
| 87 | bloomOnHover: boolean |
| 88 | |
| 89 | // Series register themselves so the canvas knows what (and how) to paint. |
| 90 | seriesSpecs: Record |
| 91 | registerSeries: (spec |
| 92 | unregisterSeries: (dataKey |
| 93 | |
| 94 | // Entrance animation (prop-driven). `revision` bumps when the data changes or |
| 95 | // the replay token advances, so the canvas can re-play its entrance. |
| 96 | animate: boolean |
| 97 | animationDuration: number |
| 98 | revision: number |
| 99 | entranceDone: boolean |
| 100 | markEntranceDone: () |
| 101 | |
| 102 | // Helpers. |
| 103 | seedOf: (key |
| 104 | common: CommonChart |
| 105 | } |
| 106 | |
| 107 | const ChartContext = createContext |
| 108 | |
| 109 | const ROOT_OF: Record |
| 110 | area: "<AreaChart />", |
| 111 | bar: "<BarChart />", |
| 112 | line: "<LineChart />", |
| 113 | pie: "<PieChart />", |
| 114 | radar: "<RadarChart />", |
| 115 | } |
| 116 | |
| 117 | /** Generic accessor for internal layers (canvas/overlay) that work for any root. */ |
| 118 | export function useChart() |
| 119 | const ctx = |
| 120 | if (!ctx) |
| 121 | throw new Error( |
| 122 | "Chart parts must be used within a chart root (e.g. <AreaChart />)." |
| 123 | ) |
| 124 | } |
| 125 | return ctx |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Boundary guard for a composable part. Throws a precise error when used outside |
| 130 | * a root, or inside the wrong chart type - e.g. `<Bar />` placed in an area |
| 131 | * chart. `kind` omitted means the part works under any root (grid, axes, …). |
| 132 | */ |
| 133 | export function useChartPart( |
| 134 | part: string, |
| 135 | kind?: ChartType |
| 136 | ): ChartContextValue { |
| 137 | const ctx = |
| 138 | if (!ctx) |
| 139 | const where = |
| 140 | ? ROOT_OF[Array. |
| 141 | : "a chart root" |
| 142 | throw new Error( |
| 143 | } |
| 144 | if (kind) { |
| 145 | const allowed = |
| 146 | if (!allowed.includes |
| 147 | throw new Error( |
| 148 | `<${part} /> is not valid inside ${ |
| 149 | .map((k) |
| 150 | .join(" or ") |
| 151 | ) |
| 152 | } |
| 153 | } |
| 154 | return ctx |
| 155 | } |
| 156 | |
| 157 | export { ChartContext } |
| 158 | |
| 159 | /** A counter that advances whenever `data` changes identity or `token` advances |
| 160 | * - drives entrance replays without remounting. Uses the adjust-state-during- |
| 161 | * render pattern (https://react.dev/reference/react/useState) instead of a ref: |
| 162 | * the revision is derived purely from render inputs, so it stays consistent |
| 163 | * across the memoized values below rather than lagging a render behind. */ |
| 164 | export function useRevision(data |
| 165 | const [prev, |
| 166 | if (prev.data |
| 167 | const next = |
| 168 | setPrev(next) |
| 169 | return next.revision |
| 170 | } |
| 171 | return prev.revision |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Builds the shared context value: resolves the plot rect from the measured |
| 176 | * size minus margins, computes the x/y scales and the per-series stack bands, |
| 177 | * and owns the selection + hover state every part reads. |
| 178 | */ |
| 179 | export function useChartController({ |
| 180 | chartType, |
| 181 | data, |
| 182 | config, |
| 183 | stackType, |
| 184 | dimensions, |
| 185 | margins, |
| 186 | animate = true, |
| 187 | animationDuration = 900, |
| 188 | replayToken = 0, |
| 189 | markerIndex = null, |
| 190 | hovered = false, |
| 191 | bloom = "off" |
| 192 | bloomOnHover = false, |
| 193 | defaultSelectedDataKey = null, |
| 194 | onSelectionChange, |
| 195 | }: { |
| 196 | chartType: ChartType |
| 197 | data: Row |
| 198 | config: ChartConfig |
| 199 | stackType: StackType |
| 200 | dimensions: Dimensions |
| 201 | margins: Margins |
| 202 | animate?: boolean |
| 203 | animationDuration?: number |
| 204 | replayToken?: number |
| 205 | markerIndex?: number |
| 206 | hovered?: boolean |
| 207 | bloom?: BloomInput |
| 208 | bloomOnHover?: boolean |
| 209 | defaultSelectedDataKey?: string |
| 210 | onSelectionChange?: ( |
| 211 | }): ChartContextValue { |
| 212 | // This object becomes the ChartContext value, so its identity - and the |
| 213 | // identity of every function/object it carries - must stay stable across |
| 214 | // renders that don't change the underlying inputs. Otherwise every consumer |
| 215 | // (axes, legend, tooltip, dots) re-renders on every parent render. So the |
| 216 | // expensive derivations, the exposed callbacks, and the returned value are |
| 217 | // memoized below; only cheap scalars (bandwidth, ready, plot sizes) are left |
| 218 | // bare, since they're just recomputed reads, not identities anyone depends on. |
| 219 | |
| 220 | // Memoized: configKeys is the dep that drives `bands`, `common` and the |
| 221 | // canvas `targets` memo - a fresh array each render would bust all of them. |
| 222 | const configKeys = |
| 223 | const revision = |
| 224 | |
| 225 | const [selectedDataKey, |
| 226 | defaultSelectedDataKey |
| 227 | ) |
| 228 | const [focusDataKey, |
| 229 | const [hoverIndex, |
| 230 | const [cursorX, |
| 231 | const [isMouseInChart, |
| 232 | const [seriesSpecs, |
| 233 | |
| 234 | // useCallback because the series effects in area.tsx/bar.tsx list these as |
| 235 | // deps - without stable identities the unregister/register effect re-fires |
| 236 | // every render and its setState pair loops ("Maximum update depth exceeded"). |
| 237 | const registerSeries = |
| 238 | setSeriesSpecs((prev) |
| 239 | const cur = |
| 240 | return cur && |
| 241 | cur.kind === |
| 242 | cur.variant === |
| 243 | cur.strokeVariant === |
| 244 | ? prev |
| 245 | : { ...prev |
| 246 | }) |
| 247 | }, []) |
| 248 | const unregisterSeries = |
| 249 | setSeriesSpecs((prev) |
| 250 | if (!(dataKey |
| 251 | const next = |
| 252 | delete next[dataKey] |
| 253 | return next |
| 254 | }) |
| 255 | }, []) |
| 256 | |
| 257 | // Stable so the memoized value keeps its identity; only re-created when the |
| 258 | // caller's selection handler does. |
| 259 | const selectDataKey = |
| 260 | (key: string |
| 261 | setSelectedDataKey(key) |
| 262 | onSelectionChange?.(key) |
| 263 | }, |
| 264 | [onSelectionChange] |
| 265 | ) |
| 266 | |
| 267 | // The root spreads `{ ...DEFAULT_MARGINS, ...marginsProp }` fresh every |
| 268 | // render, so `margins` never keeps its identity. Pin one off the four numbers |
| 269 | // so it doesn't, on its own, invalidate the value or the plot geometry. |
| 270 | const { top: |
| 271 | const stableMargins = |
| 272 | () => ({ |
| 273 | [mTop, mRight, |
| 274 | ) |
| 275 | |
| 276 | const plotWidth = |
| 277 | const plotHeight = |
| 278 | const ready = |
| 279 | |
| 280 | // The entrance gate flips true when the canvas reveal completes (via |
| 281 | // `markEntranceDone`) so DOM markers fade in with the fill, and re-arms on |
| 282 | // each replay. Adjust-state-during-render instead of an effect, so the reset |
| 283 | // lands in the same render as the revision bump. |
| 284 | const [entrance, |
| 285 | if (entrance.revision |
| 286 | setEntrance({ revision, |
| 287 | } |
| 288 | const entranceDone = |
| 289 | // Stable across renders at the same revision; the canvas holds this in a ref. |
| 290 | const markEntranceDone = |
| 291 | () => setEntrance( |
| 292 | [revision] |
| 293 | ) |
| 294 | |
| 295 | // Memoized: the priciest derivation in the render path - it walks every |
| 296 | // row × series to build the stack bands. Hover/cursor state changes must not |
| 297 | // recompute it, only a real data/series/stack change. |
| 298 | const { bands, |
| 299 | () => computeBands( |
| 300 | [data, configKeys, |
| 301 | ) |
| 302 | |
| 303 | const isBar = |
| 304 | // The d3 scale factories are memoized so `y` keeps a stable identity: the |
| 305 | // canvas `targets` memo (cartesian-canvas / bar-canvas) deps on ctx.y, and |
| 306 | // xCenter/indexAtX/barSlot below close over these. |
| 307 | const xPoint = |
| 308 | () => buildXScale( |
| 309 | [data.length, plotWidth |
| 310 | ) |
| 311 | const xBand = |
| 312 | () => buildBandScale( |
| 313 | [data.length, plotWidth |
| 314 | ) |
| 315 | const bandwidth = |
| 316 | const xCenter = |
| 317 | (i: number |
| 318 | isBar ? (xBand |
| 319 | [isBar, xBand, |
| 320 | ) |
| 321 | const indexAtX = |
| 322 | (px: number |
| 323 | isBar |
| 324 | ? indexAtBand(px, |
| 325 | : nearestIndex(px, |
| 326 | [isBar, data. |
| 327 | ) |
| 328 | const stacked = |
| 329 | const barSlot = |
| 330 | (i: number |
| 331 | const center = |
| 332 | if (stacked) { |
| 333 | const w = |
| 334 | return { x: |
| 335 | } |
| 336 | const slot = |
| 337 | return { |
| 338 | x: center - |
| 339 | width: slot * |
| 340 | } |
| 341 | }, |
| 342 | [xCenter, stacked, |
| 343 | ) |
| 344 | const y = |
| 345 | |
| 346 | // Stable so `common` and the value stay stable; re-created only on config. |
| 347 | const seedOf = |
| 348 | (key: string |
| 349 | [config] |
| 350 | ) |
| 351 | |
| 352 | // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip), |
| 353 | // so it needs its own stable identity independent of the parent value. |
| 354 | const common: |
| 355 | names: configKeys, |
| 356 | labelOf: (n) |
| 357 | seedOf, |
| 358 | selectedDataKey, |
| 359 | selectDataKey, |
| 360 | focusDataKey, |
| 361 | setFocusDataKey, |
| 362 | hoverIndex, |
| 363 | ready, |
| 364 | tooltipLeft: Math.max |
| 365 | // Follow the highest hovered node so the card rides the data path, but |
| 366 | // keep enough headroom that the upward-lifted card never clips the top. |
| 367 | tooltipTop: (() => |
| 368 | const floor = |
| 369 | if (hoverIndex == |
| 370 | let minY = |
| 371 | for (const key |
| 372 | const b = |
| 373 | if (b) minY |
| 374 | } |
| 375 | if (!Number.isFinite |
| 376 | return Math.max |
| 377 | })(), |
| 378 | heading: (i, |
| 379 | labelKey ? String( |
| 380 | itemsAt: (i) |
| 381 | configKeys.map((name |
| 382 | const raw = |
| 383 | return { |
| 384 | name, |
| 385 | label: config[name |
| 386 | value: typeof raw |
| 387 | seed: seedOf(name |
| 388 | dimmed: (() => |
| 389 | const emphasis = |
| 390 | return emphasis !== |
| 391 | })(), |
| 392 | } |
| 393 | }), |
| 394 | }), [ |
| 395 | configKeys, |
| 396 | config, |
| 397 | seedOf, |
| 398 | selectedDataKey, |
| 399 | selectDataKey, |
| 400 | focusDataKey, |
| 401 | setFocusDataKey, |
| 402 | hoverIndex, |
| 403 | ready, |
| 404 | plotWidth, |
| 405 | mLeft, |
| 406 | mTop, |
| 407 | cursorX, |
| 408 | bands, |
| 409 | y, |
| 410 | data, |
| 411 | ]) |
| 412 | |
| 413 | // Memoized: this is the ChartContext value. A fresh object here would |
| 414 | // re-render every consumer on every parent render - the whole reason the |
| 415 | // pieces above are stabilized. Rebuilds only when a listed input changes |
| 416 | // (which is exactly when a consumer needs the update). The useState setters |
| 417 | // are listed but never change identity, so they never trigger a rebuild. |
| 418 | return useMemo<ChartContextValue |
| 419 | () => ({ |
| 420 | chartType, |
| 421 | config, |
| 422 | configKeys, |
| 423 | data, |
| 424 | dataLength: data.length, |
| 425 | stackType, |
| 426 | margins: stableMargins, |
| 427 | plot: { width: plotWidth |
| 428 | ready, |
| 429 | xCenter, |
| 430 | bandwidth, |
| 431 | indexAtX, |
| 432 | barSlot, |
| 433 | y, |
| 434 | bands, |
| 435 | max, |
| 436 | selectedDataKey, |
| 437 | selectDataKey, |
| 438 | focusDataKey, |
| 439 | setFocusDataKey, |
| 440 | hoverIndex, |
| 441 | setHoverIndex, |
| 442 | markerIndex, |
| 443 | cursorX, |
| 444 | setCursorX, |
| 445 | isMouseInChart, |
| 446 | setMouseInChart, |
| 447 | hovered, |
| 448 | bloom, |
| 449 | bloomOnHover, |
| 450 | seriesSpecs, |
| 451 | registerSeries, |
| 452 | unregisterSeries, |
| 453 | animate, |
| 454 | animationDuration, |
| 455 | revision, |
| 456 | entranceDone, |
| 457 | markEntranceDone, |
| 458 | seedOf, |
| 459 | common, |
| 460 | }), |
| 461 | [ |
| 462 | chartType, |
| 463 | config, |
| 464 | configKeys, |
| 465 | data, |
| 466 | stackType, |
| 467 | stableMargins, |
| 468 | plotWidth, |
| 469 | plotHeight, |
| 470 | ready, |
| 471 | xCenter, |
| 472 | bandwidth, |
| 473 | indexAtX, |
| 474 | barSlot, |
| 475 | y, |
| 476 | bands, |
| 477 | max, |
| 478 | selectedDataKey, |
| 479 | selectDataKey, |
| 480 | focusDataKey, |
| 481 | setFocusDataKey, |
| 482 | hoverIndex, |
| 483 | setHoverIndex, |
| 484 | markerIndex, |
| 485 | cursorX, |
| 486 | setCursorX, |
| 487 | isMouseInChart, |
| 488 | setMouseInChart, |
| 489 | hovered, |
| 490 | bloom, |
| 491 | bloomOnHover, |
| 492 | seriesSpecs, |
| 493 | registerSeries, |
| 494 | unregisterSeries, |
| 495 | animate, |
| 496 | animationDuration, |
| 497 | revision, |
| 498 | entranceDone, |
| 499 | markEntranceDone, |
| 500 | seedOf, |
| 501 | common, |
| 502 | ] |
| 503 | ) |
| 504 | } |