@hexclave/dashboard-ui-components 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/components/alert.js +4 -4
  2. package/dist/components/alert.js.map +1 -1
  3. package/dist/components/badge.js +1 -1
  4. package/dist/components/badge.js.map +1 -1
  5. package/dist/components/button.d.ts +4 -4
  6. package/dist/components/button.js +3 -3
  7. package/dist/components/button.js.map +1 -1
  8. package/dist/components/data-grid/data-grid-toolbar.js +5 -5
  9. package/dist/components/data-grid/data-grid-toolbar.js.map +1 -1
  10. package/dist/components/data-grid/data-grid.d.ts.map +1 -1
  11. package/dist/components/data-grid/data-grid.js +11 -5
  12. package/dist/components/data-grid/data-grid.js.map +1 -1
  13. package/dist/components/data-grid/data-grid.test.js +34 -1
  14. package/dist/components/data-grid/data-grid.test.js.map +1 -1
  15. package/dist/components/dialog.d.ts +6 -2
  16. package/dist/components/dialog.d.ts.map +1 -1
  17. package/dist/components/dialog.js +14 -10
  18. package/dist/components/dialog.js.map +1 -1
  19. package/dist/components/input.js +12 -9
  20. package/dist/components/input.js.map +1 -1
  21. package/dist/components/pill-toggle.js +3 -3
  22. package/dist/components/pill-toggle.js.map +1 -1
  23. package/dist/components/tabs.js +2 -2
  24. package/dist/components/tabs.js.map +1 -1
  25. package/dist/dashboard-ui-components.global.js +15639 -17400
  26. package/dist/dashboard-ui-components.global.js.map +4 -4
  27. package/dist/esm/components/alert.js +4 -4
  28. package/dist/esm/components/alert.js.map +1 -1
  29. package/dist/esm/components/badge.js +1 -1
  30. package/dist/esm/components/badge.js.map +1 -1
  31. package/dist/esm/components/button.d.ts +4 -4
  32. package/dist/esm/components/button.js +3 -3
  33. package/dist/esm/components/button.js.map +1 -1
  34. package/dist/esm/components/data-grid/data-grid-toolbar.js +5 -5
  35. package/dist/esm/components/data-grid/data-grid-toolbar.js.map +1 -1
  36. package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
  37. package/dist/esm/components/data-grid/data-grid.js +11 -5
  38. package/dist/esm/components/data-grid/data-grid.js.map +1 -1
  39. package/dist/esm/components/data-grid/data-grid.test.js +34 -1
  40. package/dist/esm/components/data-grid/data-grid.test.js.map +1 -1
  41. package/dist/esm/components/dialog.d.ts +6 -2
  42. package/dist/esm/components/dialog.d.ts.map +1 -1
  43. package/dist/esm/components/dialog.js +13 -11
  44. package/dist/esm/components/dialog.js.map +1 -1
  45. package/dist/esm/components/input.js +12 -9
  46. package/dist/esm/components/input.js.map +1 -1
  47. package/dist/esm/components/pill-toggle.js +4 -4
  48. package/dist/esm/components/pill-toggle.js.map +1 -1
  49. package/dist/esm/components/tabs.js +2 -2
  50. package/dist/esm/components/tabs.js.map +1 -1
  51. package/dist/esm/index.d.ts +2 -2
  52. package/dist/esm/index.js +2 -2
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +12 -0
  55. package/package.json +4 -3
  56. package/src/components/alert.tsx +120 -0
  57. package/src/components/analytics-chart/analytics-chart-pie.tsx +369 -0
  58. package/src/components/analytics-chart/analytics-chart.tsx +1585 -0
  59. package/src/components/analytics-chart/default-analytics-chart-tooltip.tsx +265 -0
  60. package/src/components/analytics-chart/format.ts +101 -0
  61. package/src/components/analytics-chart/index.ts +75 -0
  62. package/src/components/analytics-chart/palette.ts +68 -0
  63. package/src/components/analytics-chart/render-data-series.tsx +169 -0
  64. package/src/components/analytics-chart/state.ts +165 -0
  65. package/src/components/analytics-chart/strings.ts +72 -0
  66. package/src/components/analytics-chart/types.ts +220 -0
  67. package/src/components/badge.tsx +108 -0
  68. package/src/components/button.tsx +104 -0
  69. package/src/components/card.tsx +263 -0
  70. package/src/components/chart-card.tsx +101 -0
  71. package/src/components/chart-container.tsx +155 -0
  72. package/src/components/chart-legend.tsx +65 -0
  73. package/src/components/chart-theme.tsx +52 -0
  74. package/src/components/chart-tooltip.tsx +165 -0
  75. package/src/components/cursor-blast-effect.tsx +334 -0
  76. package/src/components/data-grid/data-grid-sizing.ts +51 -0
  77. package/src/components/data-grid/data-grid-toolbar.tsx +373 -0
  78. package/src/components/data-grid/data-grid.test.tsx +407 -0
  79. package/src/components/data-grid/data-grid.tsx +1231 -0
  80. package/src/components/data-grid/index.ts +64 -0
  81. package/src/components/data-grid/state.ts +235 -0
  82. package/src/components/data-grid/strings.ts +45 -0
  83. package/src/components/data-grid/types.ts +401 -0
  84. package/src/components/data-grid/use-data-source.ts +413 -0
  85. package/src/components/data-grid/use-url-state.test.tsx +88 -0
  86. package/src/components/data-grid/use-url-state.ts +298 -0
  87. package/src/components/dialog.tsx +218 -0
  88. package/src/components/edit-mode.tsx +17 -0
  89. package/src/components/empty-state.tsx +64 -0
  90. package/src/components/input.tsx +97 -0
  91. package/src/components/metric-card.tsx +142 -0
  92. package/src/components/pill-toggle.tsx +159 -0
  93. package/src/components/progress-bar.tsx +82 -0
  94. package/src/components/separator.tsx +36 -0
  95. package/src/components/skeleton.tsx +30 -0
  96. package/src/components/table.tsx +113 -0
  97. package/src/components/tabs.tsx +217 -0
  98. package/src/index.ts +71 -0
@@ -0,0 +1,1585 @@
1
+ "use client";
2
+
3
+ import { cn } from "@hexclave/ui";
4
+ import { DesignButton } from "../button";
5
+ import {
6
+ type DesignChartConfig,
7
+ DesignChartContainer,
8
+ } from "../chart-container";
9
+ import {
10
+ CartesianGrid,
11
+ ComposedChart,
12
+ ReferenceArea,
13
+ ReferenceLine,
14
+ XAxis,
15
+ YAxis,
16
+ } from "recharts";
17
+ import {
18
+ FlagIcon,
19
+ MagnifyingGlassMinusIcon,
20
+ MagnifyingGlassPlusIcon,
21
+ XIcon,
22
+ } from "@phosphor-icons/react";
23
+ import {
24
+ type CSSProperties,
25
+ type ReactNode,
26
+ useCallback,
27
+ useEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ } from "react";
32
+ import { AnalyticsChartPie } from "./analytics-chart-pie";
33
+ import {
34
+ DefaultAnalyticsChartTooltip,
35
+ type AnalyticsChartTooltipContext,
36
+ type AnalyticsChartTooltipLayerView,
37
+ type AnalyticsChartTooltipSegmentRow,
38
+ } from "./default-analytics-chart-tooltip";
39
+ import { formatDelta, formatValue } from "./format";
40
+ import {
41
+ buildRampColors,
42
+ resolveAnalyticsChartPalette,
43
+ } from "./palette";
44
+ import { renderDataSeries } from "./render-data-series";
45
+ import {
46
+ computeLocalInProgressIdx,
47
+ EMPTY_MATRIX,
48
+ EMPTY_SERIES,
49
+ findAnnotationsLayer,
50
+ findCompareLayer,
51
+ findPrimaryLayer,
52
+ isAnalyticsChartDataLayer,
53
+ isTimeseriesState,
54
+ resolveDataLayerStyle,
55
+ STROKE_DASHARRAY,
56
+ type ResolvedDataLayerStyle,
57
+ } from "./state";
58
+ import { resolveAnalyticsChartStrings } from "./strings";
59
+ import type {
60
+ AnalyticsChartDelta,
61
+ AnalyticsChartPalette,
62
+ AnalyticsChartPieProps,
63
+ AnalyticsChartSeries,
64
+ AnalyticsChartState,
65
+ AnalyticsChartTimeseriesState,
66
+ Annotation,
67
+ FormatKind,
68
+ Point,
69
+ } from "./types";
70
+ import { cssIdent, pointValue } from "./types";
71
+ import type { AnalyticsChartStrings } from "./strings";
72
+
73
+ /** Mirrors Recharts' internal `Margin` shape (not exported from their typings). */
74
+ export type Margin = {
75
+ top?: number,
76
+ right?: number,
77
+ bottom?: number,
78
+ left?: number,
79
+ };
80
+
81
+ /**
82
+ * Props for {@link AnalyticsChart}.
83
+ *
84
+ * ## HOW TO REFERENCE THIS COMPONENT
85
+ *
86
+ * **In the custom dashboard sandbox** (AI-generated dashboard code): every
87
+ * export lives on the global `DashboardUI` object. Use
88
+ * `DashboardUI.AnalyticsChart`, `DashboardUI.ANALYTICS_CHART_DEFAULT_STATE`,
89
+ * `DashboardUI.pointValue`, etc. **Never** use bare identifiers like
90
+ * `<AnalyticsChart />` inside the sandbox — there is no module system and
91
+ * nothing is destructured into scope. Types (`AnalyticsChartState`,
92
+ * `Point`, …) don't exist at runtime anyway, so just drop the type
93
+ * annotations in sandbox code.
94
+ *
95
+ * **In a regular TypeScript app** (anywhere importing `@hexclave/dashboard-ui-components`
96
+ * directly): `import { AnalyticsChart, ANALYTICS_CHART_DEFAULT_STATE } from
97
+ * "@hexclave/dashboard-ui-components"` and use the bare name. Drop the
98
+ * `DashboardUI.` prefix from the examples below when doing so.
99
+ *
100
+ * ## Data shape in 30 seconds
101
+ *
102
+ * - `data` is `Point[]`. Each `Point` is `{ ts: number, values: Record<string, number> }`.
103
+ * `ts` is a Unix millisecond timestamp; `values` is keyed by **layer id**.
104
+ * - `state` is fully controlled. Start from
105
+ * `DashboardUI.ANALYTICS_CHART_DEFAULT_STATE` (which ships with a
106
+ * `"primary"` + `"compare"` + `"annotations"` layer set) and override
107
+ * what you need. Do **not** hand-build the layer array from scratch.
108
+ * - For a breakdown (e.g. signups by region), add `segments` (a `number[][]`
109
+ * with one row per `data` point) and `segmentSeries` (the category labels)
110
+ * to the primary layer. Rows of `segments` should sum to the point's layer
111
+ * value. Same for the compare layer if you want a compared breakdown.
112
+ *
113
+ * ## **SCALE YOUR DATA BEFORE PUTTING IT ON A POINT** (critical)
114
+ *
115
+ * The chart renders every visible layer on a **single shared y-axis**. If
116
+ * two layers are on different orders of magnitude (e.g. revenue in cents
117
+ * `1_200_000` and sign-ups `450`), the smaller series collapses to a flat
118
+ * line at the bottom and the chart looks broken. You **must** normalize
119
+ * both metrics into the same range before building `data`. Rules of thumb:
120
+ *
121
+ * - **Cents → dollars / units**: divide money amounts by 100 (or 1000 for
122
+ * large currencies). Use a `valueFormatter` to render the original unit
123
+ * in tooltips so the UX still reads as "$12,543".
124
+ * - **Counts vs rates**: if one layer is a count (e.g. requests) and the
125
+ * other is a ratio (e.g. error rate 0.02), multiply the ratio by the
126
+ * count's scale (or by `max(counts)`) so both sit in the same band.
127
+ * - **Very different counts** (e.g. page views `120_000` vs sign-ups `430`):
128
+ * either divide the large metric (`views / 100`) or promote the small
129
+ * one to a rate (`signups / views * 1000`). Note the transformation in
130
+ * the layer `label` ("Sign-ups per 1k views") so it's honest.
131
+ * - **Pick the target range from the layer with the most natural scale**
132
+ * — usually the metric the user actually cares about — and normalize
133
+ * everything else into it. Don't normalize by fighting Recharts with
134
+ * `yDomain` hacks; do it in the data.
135
+ *
136
+ * If the two metrics truly can't share an axis (e.g. latency ms vs error
137
+ * count), render them as **two separate `AnalyticsChart` instances** stacked
138
+ * in the layout instead of jamming them into one chart.
139
+ *
140
+ * ## Example 1 — simplest possible: one area layer, no compare
141
+ *
142
+ * ```jsx
143
+ * // Sandbox dashboard code — everything prefixed with DashboardUI.*
144
+ * function Dashboard() {
145
+ * const data = [
146
+ * { ts: Date.UTC(2026, 2, 1), values: { primary: 420 } },
147
+ * { ts: Date.UTC(2026, 2, 2), values: { primary: 512 } },
148
+ * { ts: Date.UTC(2026, 2, 3), values: { primary: 604 } },
149
+ * // ...one row per time bucket
150
+ * ];
151
+ *
152
+ * // Start from defaults, hide the compare layer so it's a single series.
153
+ * const [state, setState] = React.useState({
154
+ * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE,
155
+ * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) =>
156
+ * l.kind === "compare" ? { ...l, visible: false } : l,
157
+ * ),
158
+ * });
159
+ *
160
+ * return (
161
+ * <DashboardUI.DesignChartCard title="Sign-ups" description="Last 30 days">
162
+ * <DashboardUI.AnalyticsChart
163
+ * data={data}
164
+ * state={state}
165
+ * onChange={setState}
166
+ * />
167
+ * </DashboardUI.DesignChartCard>
168
+ * );
169
+ * }
170
+ * ```
171
+ *
172
+ * ## Example 2 — current vs previous period (compare)
173
+ *
174
+ * Each point carries both layer values under their layer ids. The default
175
+ * state's `"primary"` and `"compare"` layers are already visible, so no
176
+ * state customization is needed.
177
+ *
178
+ * ```jsx
179
+ * function Dashboard() {
180
+ * const data = rows.map((r) => ({
181
+ * ts: r.bucketTs,
182
+ * values: {
183
+ * primary: r.signupsThisPeriod, // keyed by layer id "primary"
184
+ * compare: r.signupsLastPeriod, // keyed by layer id "compare"
185
+ * },
186
+ * }));
187
+ *
188
+ * const [state, setState] = React.useState(
189
+ * DashboardUI.ANALYTICS_CHART_DEFAULT_STATE,
190
+ * );
191
+ *
192
+ * return (
193
+ * <DashboardUI.AnalyticsChart
194
+ * data={data}
195
+ * state={state}
196
+ * onChange={setState}
197
+ * />
198
+ * );
199
+ * }
200
+ * ```
201
+ *
202
+ * ## Example 3 — stacked bar with region breakdown (segmented)
203
+ *
204
+ * ```jsx
205
+ * function Dashboard() {
206
+ * const regions = [
207
+ * { key: "us", label: "United States" },
208
+ * { key: "eu", label: "European Union" },
209
+ * { key: "asia", label: "Asia-Pacific" },
210
+ * ];
211
+ *
212
+ * // Row index matches `data` index; column index matches `regions`.
213
+ * // Each row MUST sum to data[i].values.primary.
214
+ * const segments = [
215
+ * [210, 140, 70], // day 0 → total 420
216
+ * [250, 170, 92], // day 1 → total 512
217
+ * [300, 200, 104], // day 2 → total 604
218
+ * ];
219
+ *
220
+ * const data = [
221
+ * { ts: Date.UTC(2026, 2, 1), values: { primary: 420 } },
222
+ * { ts: Date.UTC(2026, 2, 2), values: { primary: 512 } },
223
+ * { ts: Date.UTC(2026, 2, 3), values: { primary: 604 } },
224
+ * ];
225
+ *
226
+ * const [state, setState] = React.useState({
227
+ * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE,
228
+ * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => {
229
+ * if (l.kind === "primary") {
230
+ * return {
231
+ * ...l,
232
+ * type: "bar", // switch from area → stacked bars
233
+ * segmented: true,
234
+ * segments,
235
+ * segmentSeries: regions,
236
+ * };
237
+ * }
238
+ * if (l.kind === "compare") {
239
+ * return { ...l, visible: false };
240
+ * }
241
+ * return l;
242
+ * }),
243
+ * });
244
+ *
245
+ * return (
246
+ * <DashboardUI.AnalyticsChart
247
+ * data={data}
248
+ * state={state}
249
+ * onChange={setState}
250
+ * />
251
+ * );
252
+ * }
253
+ * ```
254
+ *
255
+ * ## Example 4 — mixing display types (e.g. revenue bars + signups area)
256
+ *
257
+ * Use two layers. The "primary" layer holds one metric; reuse the "compare"
258
+ * layer slot for the second metric by overriding its `id`, `label`, and
259
+ * `type`. Then key both values in each `Point`.
260
+ *
261
+ * **IMPORTANT**: the two metrics share a single y-axis, so scale them into
262
+ * the same range before putting them on the point. A common trick is to
263
+ * pass a `valueFormatter` that reports each layer's number with its own
264
+ * unit so tooltips still read correctly.
265
+ *
266
+ * ```jsx
267
+ * function Dashboard() {
268
+ * // Sign-ups are already in range; revenue cents would dwarf them, so
269
+ * // we normalize revenue (cents → dollars) onto the same scale.
270
+ * const data = rows.map((r) => ({
271
+ * ts: r.bucketTs,
272
+ * values: {
273
+ * revenue: r.revenueCents / 100,
274
+ * signups: r.signups,
275
+ * },
276
+ * }));
277
+ *
278
+ * const [state, setState] = React.useState({
279
+ * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE,
280
+ * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => {
281
+ * if (l.kind === "primary") {
282
+ * return { ...l, id: "revenue", label: "Revenue", type: "bar" };
283
+ * }
284
+ * if (l.kind === "compare") {
285
+ * return {
286
+ * ...l,
287
+ * id: "signups",
288
+ * label: "Sign-ups",
289
+ * type: "area",
290
+ * visible: true,
291
+ * };
292
+ * }
293
+ * return l;
294
+ * }),
295
+ * });
296
+ *
297
+ * // Per-layer formatter: `kind` lets you branch per-axis vs per-layer
298
+ * // using the layer id passed in via the tooltip context. For most
299
+ * // cases formatting by raw value is enough.
300
+ * const valueFormatter = (value, kind) => {
301
+ * if (kind.type === "currency") return `$${value.toFixed(0)}`;
302
+ * return value.toLocaleString();
303
+ * };
304
+ *
305
+ * return (
306
+ * <DashboardUI.AnalyticsChart
307
+ * data={data}
308
+ * state={state}
309
+ * onChange={setState}
310
+ * valueFormatter={valueFormatter}
311
+ * />
312
+ * );
313
+ * }
314
+ * ```
315
+ *
316
+ * ## Example 5 — segmented sign-ups stacked with a revenue line (mix + segment)
317
+ *
318
+ * Combines Example 3 and Example 4: primary layer is revenue as a line
319
+ * (un-segmented), compare layer is sign-ups as a stacked bar (segmented
320
+ * by region). Remember: row sums of the compare segments must equal
321
+ * `point.values.signups`, and both metrics share one y-axis.
322
+ *
323
+ * ```jsx
324
+ * function Dashboard() {
325
+ * const regions = [
326
+ * { key: "us", label: "United States" },
327
+ * { key: "eu", label: "European Union" },
328
+ * { key: "asia", label: "Asia-Pacific" },
329
+ * ];
330
+ *
331
+ * // Normalize revenue to the same order of magnitude as sign-ups.
332
+ * const data = rows.map((r) => ({
333
+ * ts: r.bucketTs,
334
+ * values: {
335
+ * revenue: r.revenueCents / 100,
336
+ * signups: r.signupsTotal,
337
+ * },
338
+ * }));
339
+ *
340
+ * // Row index matches `data` index. Each row sums to signupsTotal.
341
+ * const signupSegments = rows.map((r) => [
342
+ * r.signupsUs,
343
+ * r.signupsEu,
344
+ * r.signupsAsia,
345
+ * ]);
346
+ *
347
+ * const [state, setState] = React.useState({
348
+ * ...DashboardUI.ANALYTICS_CHART_DEFAULT_STATE,
349
+ * layers: DashboardUI.ANALYTICS_CHART_DEFAULT_STATE.layers.map((l) => {
350
+ * if (l.kind === "primary") {
351
+ * return { ...l, id: "revenue", label: "Revenue", type: "line" };
352
+ * }
353
+ * if (l.kind === "compare") {
354
+ * return {
355
+ * ...l,
356
+ * id: "signups",
357
+ * label: "Sign-ups",
358
+ * type: "bar",
359
+ * visible: true,
360
+ * segmented: true,
361
+ * segments: signupSegments,
362
+ * segmentSeries: regions,
363
+ * };
364
+ * }
365
+ * return l;
366
+ * }),
367
+ * });
368
+ *
369
+ * return (
370
+ * <DashboardUI.AnalyticsChart
371
+ * data={data}
372
+ * state={state}
373
+ * onChange={setState}
374
+ * />
375
+ * );
376
+ * }
377
+ * ```
378
+ */
379
+ export type AnalyticsChartProps = {
380
+ /** Time-series points — each point carries `values` keyed by layer id.
381
+ * See {@link AnalyticsChartProps} for full data-shape examples. */
382
+ data: Point[],
383
+ /** Annotations. Fully prop-driven; the consumer owns the array. */
384
+ annotations?: Annotation[],
385
+ /** Fully-controlled state + dispatch. The chart reads every config and
386
+ * persistent-interaction slice from `state` and mutates it through
387
+ * `onChange`. Ephemeral interaction state (hover, brush, pin, draft) is
388
+ * managed internally and surfaces only through the state callbacks. */
389
+ state: AnalyticsChartState,
390
+ onChange: React.Dispatch<React.SetStateAction<AnalyticsChartState>>,
391
+ /** Fired when the user submits the in-chart annotation form. The consumer
392
+ * is expected to append to its own annotations array. */
393
+ onAnnotationCreate?: (annotation: Annotation) => void,
394
+ /** Override any user-visible copy. Shallow-merges over the defaults. */
395
+ strings?: Partial<AnalyticsChartStrings>,
396
+ /** Override segment color ramps. Each ramp is either procedural
397
+ * (hue + sat + lightness range) or explicit (concrete color lists). */
398
+ palette?: Partial<AnalyticsChartPalette>,
399
+ /** Render slot for the tooltip body. Receives a prepared context with
400
+ * the active point, primary/compare layer views, pre-bound formatters,
401
+ * and resolved strings. Defaults to `DefaultAnalyticsChartTooltip`. */
402
+ renderTooltip?: (ctx: AnalyticsChartTooltipContext) => ReactNode,
403
+ /** Recharts plot margins. Also drives overlay positioning math so the
404
+ * crosshair, tooltip anchor, brush popup, and flag markers line up with
405
+ * the actual plot area. Defaults to `{ top: 16, right: 24, bottom: 8, left: 12 }`. */
406
+ plotMargin?: Margin,
407
+ /** Y-axis reserved width in pixels. Defaults to 48. */
408
+ yAxisWidth?: number,
409
+ /** Fractional headroom added to the y-axis top. Defaults to 0.1. */
410
+ yDomainPadding?: number,
411
+ /** Grouped pie configuration. Each field has a sensible default. */
412
+ pie?: AnalyticsChartPieProps,
413
+ /** Custom number formatter. Receives the raw value and the kind to format
414
+ * with — the same function is invoked for both x-axis and y-axis values. */
415
+ valueFormatter?: (value: number, kind: FormatKind) => string,
416
+ };
417
+
418
+ type RechartsMouseState = {
419
+ activeTooltipIndex?: number,
420
+ isTooltipActive?: boolean,
421
+ };
422
+
423
+ const FALLBACK_PRIMARY_STYLE: ResolvedDataLayerStyle = {
424
+ color: "#2563eb",
425
+ type: "area",
426
+ strokeStyle: "solid",
427
+ fillOpacity: 0,
428
+ };
429
+ const FALLBACK_COMPARE_STYLE: ResolvedDataLayerStyle = {
430
+ color: "#f59e0b",
431
+ type: "line",
432
+ strokeStyle: "dashed",
433
+ fillOpacity: 0,
434
+ };
435
+ const FALLBACK_ANNOTATION_COLOR = "#f59e0b";
436
+
437
+ function buildTooltipLayerView(args: {
438
+ show: boolean,
439
+ layer: { id: string, label: string } | undefined,
440
+ color: string,
441
+ segmented: boolean,
442
+ segmentSeries: readonly AnalyticsChartSeries[],
443
+ segmentRows: readonly (readonly number[])[],
444
+ segmentTotals: readonly number[],
445
+ segmentColorsLight: readonly string[],
446
+ segmentColorsDark: readonly string[],
447
+ activeIndex: number,
448
+ activePoint: Point,
449
+ fallbackLabel?: string,
450
+ }): AnalyticsChartTooltipLayerView | null {
451
+ const {
452
+ show,
453
+ layer,
454
+ color,
455
+ segmented,
456
+ segmentSeries,
457
+ segmentRows,
458
+ segmentTotals,
459
+ segmentColorsLight,
460
+ segmentColorsDark,
461
+ activeIndex,
462
+ activePoint,
463
+ fallbackLabel,
464
+ } = args;
465
+ if (!show || !layer) return null;
466
+ const segments: AnalyticsChartTooltipSegmentRow[] = segmented
467
+ ? segmentSeries.map((s, sIdx) => ({
468
+ key: s.key,
469
+ label: s.label,
470
+ value: segmentRows[activeIndex]?.[sIdx] ?? 0,
471
+ color: segmentColorsLight[sIdx],
472
+ colorDark: segmentColorsDark[sIdx],
473
+ }))
474
+ : [];
475
+ return {
476
+ id: layer.id,
477
+ label: layer.label || fallbackLabel || "",
478
+ color,
479
+ colorDark: color,
480
+ total: segmented
481
+ ? (segmentTotals[activeIndex] ?? 0)
482
+ : pointValue(activePoint, layer.id),
483
+ segmented,
484
+ segments,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Preferred chart for all time-series: area, line, bar, compare layers,
490
+ * segmented stacks, tooltips, zoom, and annotations. Wrap in
491
+ * `DesignChartCard` for the title/description chrome. Only fall back to
492
+ * raw Recharts for non-time-series visuals (static rankings etc.).
493
+ *
494
+ * ## Data shape
495
+ *
496
+ * `data` is `Point[]`, where `Point = { ts: number, values: Record<string, number> }`.
497
+ * `ts` is a Unix milliseconds timestamp. `values` maps layer id → numeric value
498
+ * at that bucket. Example:
499
+ *
500
+ * ```ts
501
+ * { ts: 1743465600000, values: { primary: 420 } }
502
+ * { ts: 1743465600000, values: { primary: 420, compare: 380 } } // with compare layer
503
+ * ```
504
+ *
505
+ * ## State is fully controlled — start from ANALYTICS_CHART_DEFAULT_STATE
506
+ *
507
+ * The default state ships with three pre-configured layers: `"primary"`,
508
+ * `"compare"`, and `"annotations"`. ALWAYS spread from
509
+ * `ANALYTICS_CHART_DEFAULT_STATE` and map over `layers` to override. Do NOT
510
+ * hand-build the layer array from scratch — you will miss fields and crash.
511
+ *
512
+ * ```ts
513
+ * // Default state shape (for reference — spread from the constant, don't copy):
514
+ * {
515
+ * view: "timeseries",
516
+ * layers: [
517
+ * { id: "primary", kind: "primary", label: "Current", visible: true, color: "#2563eb",
518
+ * segmented: false, type: "area", strokeStyle: "solid", fillOpacity: 0.22, inProgressFromIndex: null },
519
+ * { id: "compare", kind: "compare", label: "Previous period", visible: true, color: "#f59e0b",
520
+ * segmented: false, type: "line", strokeStyle: "dashed", inProgressFromIndex: null },
521
+ * { id: "annotations", kind: "annotations", label: "Annotations", visible: true, color: "#f59e0b" },
522
+ * ],
523
+ * xFormatKind: { type: "datetime", style: "short" },
524
+ * yFormatKind: { type: "short" },
525
+ * showGrid: true, showXAxis: true, showYAxis: true,
526
+ * zoomRange: null, pinnedIndex: null,
527
+ * }
528
+ * ```
529
+ *
530
+ * ## onChange — CRITICAL, get this right
531
+ *
532
+ * `onChange` fires with an `AnalyticsChartState` object — NOT your custom
533
+ * wrapper. If you store chart data and state together, `onChange` MUST only
534
+ * update the state part. Keep data and state in SEPARATE hooks:
535
+ *
536
+ * ```tsx
537
+ * // WRONG — overwrites your data with a bare state object, crashes on next render:
538
+ * const [combined, setCombined] = React.useState({ data: [], state: ANALYTICS_CHART_DEFAULT_STATE });
539
+ * <AnalyticsChart data={combined.data} state={combined.state} onChange={setCombined} />
540
+ *
541
+ * // RIGHT — two hooks:
542
+ * const [data, setData] = React.useState([]);
543
+ * const [chartState, setChartState] = React.useState({ ...ANALYTICS_CHART_DEFAULT_STATE });
544
+ * <AnalyticsChart data={data} state={chartState} onChange={setChartState} />
545
+ * ```
546
+ *
547
+ * NEVER pass a setter that manages a combined `{ data, state }` object directly to `onChange`.
548
+ *
549
+ * ## Common patterns
550
+ *
551
+ * ### 1. Simplest — one area layer, no compare
552
+ *
553
+ * ```tsx
554
+ * const data = rows.map(r => ({ ts: r.bucketTs, values: { primary: r.count } }));
555
+ * const [state, setState] = React.useState({
556
+ * ...ANALYTICS_CHART_DEFAULT_STATE,
557
+ * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l =>
558
+ * l.kind === "compare" ? { ...l, visible: false } : l
559
+ * ),
560
+ * });
561
+ * <DesignChartCard title="Signups" description="Last 30 days">
562
+ * <AnalyticsChart data={data} state={state} onChange={setState} />
563
+ * </DesignChartCard>
564
+ * ```
565
+ *
566
+ * ### 2. Current vs previous period (compare)
567
+ *
568
+ * ```tsx
569
+ * const data = rows.map(r => ({
570
+ * ts: r.bucketTs,
571
+ * values: { primary: r.thisPeriod, compare: r.lastPeriod },
572
+ * }));
573
+ * const [state, setState] = React.useState(ANALYTICS_CHART_DEFAULT_STATE);
574
+ * <AnalyticsChart data={data} state={state} onChange={setState} />
575
+ * ```
576
+ *
577
+ * ### 3. Stacked bar with breakdown (segmented)
578
+ *
579
+ * ```tsx
580
+ * const regions = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }];
581
+ * const segments = rows.map(r => [r.signupsUs, r.signupsEu]); // MUST sum to primary value per row
582
+ * const [state, setState] = React.useState({
583
+ * ...ANALYTICS_CHART_DEFAULT_STATE,
584
+ * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => {
585
+ * if (l.kind === "primary") return { ...l, type: "bar", segmented: true, segments, segmentSeries: regions };
586
+ * if (l.kind === "compare") return { ...l, visible: false };
587
+ * return l;
588
+ * }),
589
+ * });
590
+ * <AnalyticsChart data={data} state={state} onChange={setState} />
591
+ * ```
592
+ *
593
+ * ### 4. Two metrics on one chart (revenue bars + signups area)
594
+ *
595
+ * ```tsx
596
+ * // IMPORTANT: metrics share one y-axis, so normalize into the same range.
597
+ * const data = rows.map(r => ({
598
+ * ts: r.bucketTs,
599
+ * values: { revenue: r.revenueCents / 100, signups: r.signups },
600
+ * }));
601
+ * const [state, setState] = React.useState({
602
+ * ...ANALYTICS_CHART_DEFAULT_STATE,
603
+ * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => {
604
+ * if (l.kind === "primary") return { ...l, id: "revenue", label: "Revenue", type: "bar" };
605
+ * if (l.kind === "compare") return { ...l, id: "signups", label: "Sign-ups", type: "area", visible: true };
606
+ * return l;
607
+ * }),
608
+ * });
609
+ * <AnalyticsChart data={data} state={state} onChange={setState} />
610
+ * ```
611
+ *
612
+ * ### 5. Pie view (distribution / breakdown, non-time-series)
613
+ *
614
+ * Pie needs one data point, `segments` with one row, and `segmentSeries` with labels:
615
+ *
616
+ * ```tsx
617
+ * const categories = [{ key: "verified", label: "Verified" }, { key: "unverified", label: "Unverified" }, { key: "anonymous", label: "Anonymous" }];
618
+ * const total = verified + unverified + anonymous;
619
+ * const data = [{ ts: 0, values: { primary: total } }];
620
+ * const segments = [[verified, unverified, anonymous]]; // one row; values sum to total
621
+ * const [state, setState] = React.useState({
622
+ * ...ANALYTICS_CHART_DEFAULT_STATE,
623
+ * view: "pie",
624
+ * layers: ANALYTICS_CHART_DEFAULT_STATE.layers.map(l => {
625
+ * if (l.kind === "primary") return { ...l, segmented: true, segments, segmentSeries: categories };
626
+ * if (l.kind === "compare") return { ...l, visible: false };
627
+ * return l;
628
+ * }),
629
+ * });
630
+ * <AnalyticsChart data={data} state={state} onChange={setState} />
631
+ * ```
632
+ *
633
+ * ## Segment data contract (MUST follow when segmented: true)
634
+ *
635
+ * `segments` is a 2D array: `segments[dayIndex][categoryIndex] = number`.
636
+ *
637
+ * - Outer length MUST equal `data.length` (one row per Point).
638
+ * - Inner length MUST equal `segmentSeries.length` (one value per category).
639
+ * - Each row MUST sum to `data[dayIndex].values[layerId]` (the layer's total for that day).
640
+ * - `segmentSeries` defines the category labels, in the SAME order as segment columns.
641
+ *
642
+ * Example: if `segmentSeries = [{ key: "us", label: "US" }, { key: "eu", label: "EU" }]`
643
+ * and `data[0].values.primary = 420`, then `segments[0]` must be `[usValue, euValue]`
644
+ * where `usValue + euValue === 420`. If rows don't sum to the layer total, stacked bars
645
+ * will render incorrectly (gaps or overflow).
646
+ *
647
+ * ## Palette
648
+ *
649
+ * AnalyticsChart auto-generates segment colors (blue shades for primary, amber for
650
+ * compare). You do NOT need to pass a palette prop — it just works. Segment keys
651
+ * can be any string; the component sanitizes them for CSS purposes internally.
652
+ *
653
+ * ## Layer quick reference
654
+ *
655
+ * - Layer `type` options: `"area" | "line" | "bar"`
656
+ * - Layer `kind` values: `"primary" | "compare" | "annotations"`
657
+ * - To hide a layer: `{ ...l, visible: false }`
658
+ * - To switch chart type: `{ ...l, type: "bar" }` (or `"line"`, `"area"`)
659
+ * - To rename a layer: `{ ...l, id: "myMetric", label: "My Metric" }`
660
+ *
661
+ * ## Formatting (xFormatKind / yFormatKind on state)
662
+ *
663
+ * - `{ type: "numeric" }` — plain number
664
+ * - `{ type: "short" }` — abbreviated (1.2K, 3.4M) — good default for y-axis
665
+ * - `{ type: "currency", currency: "USD", divisor: 100 }` — for cents → dollars
666
+ * - `{ type: "percent", source: "fraction" }` — for 0..1 → "45.2%"
667
+ * - `{ type: "datetime", style: "short" }` — good default for x-axis timestamps
668
+ *
669
+ * Set these on state:
670
+ * `{ ...ANALYTICS_CHART_DEFAULT_STATE, yFormatKind: { type: "currency", currency: "USD" } }`
671
+ *
672
+ * ## Scale warning
673
+ *
674
+ * All visible layers share ONE y-axis. If magnitudes differ wildly (e.g. revenue
675
+ * cents vs signup count), normalize the data BEFORE building Points. If
676
+ * normalization is impossible, use two separate `AnalyticsChart` instances stacked
677
+ * vertically.
678
+ */
679
+ export function AnalyticsChart({
680
+ data: fullData,
681
+ annotations: fullAnnotations = [],
682
+ state,
683
+ onChange,
684
+ onAnnotationCreate,
685
+ strings: stringsOverride,
686
+ palette: paletteOverride,
687
+ renderTooltip,
688
+ plotMargin,
689
+ yAxisWidth = 48,
690
+ yDomainPadding = 0.1,
691
+ pie,
692
+ valueFormatter,
693
+ }: AnalyticsChartProps) {
694
+ const resolvedPlotMargin = useMemo<Required<Margin>>(
695
+ () => ({
696
+ top: plotMargin?.top ?? 16,
697
+ right: plotMargin?.right ?? 24,
698
+ bottom: plotMargin?.bottom ?? 8,
699
+ left: plotMargin?.left ?? 12,
700
+ }),
701
+ [plotMargin],
702
+ );
703
+ const fmtValue = valueFormatter ?? formatValue;
704
+ const strings = useMemo(
705
+ () => resolveAnalyticsChartStrings(stringsOverride),
706
+ [stringsOverride],
707
+ );
708
+ const palette = useMemo(
709
+ () => resolveAnalyticsChartPalette(paletteOverride),
710
+ [paletteOverride],
711
+ );
712
+
713
+ const renderTooltipFn = useMemo(
714
+ () => renderTooltip ?? ((ctx: AnalyticsChartTooltipContext) => <DefaultAnalyticsChartTooltip ctx={ctx} />),
715
+ [renderTooltip],
716
+ );
717
+
718
+ const { xFormatKind, yFormatKind, layers } = state;
719
+ const timeseries = isTimeseriesState(state) ? state : null;
720
+ const showGrid = timeseries?.showGrid ?? false;
721
+ const showXAxis = timeseries?.showXAxis ?? false;
722
+ const showYAxis = timeseries?.showYAxis ?? false;
723
+ const zoomRange = timeseries?.zoomRange ?? null;
724
+ const pinnedIndex = timeseries?.pinnedIndex ?? null;
725
+
726
+ const primaryLayer = findPrimaryLayer(layers);
727
+ const compareLayer = findCompareLayer(layers);
728
+ const annotationsLayer = findAnnotationsLayer(layers);
729
+ const showPrimary = primaryLayer?.visible ?? false;
730
+ const showCompare = compareLayer?.visible ?? false;
731
+ const showAnnotationsLayer = annotationsLayer?.visible ?? false;
732
+
733
+ const primaryStyle = primaryLayer ? resolveDataLayerStyle(primaryLayer) : FALLBACK_PRIMARY_STYLE;
734
+ const compareStyle = compareLayer ? resolveDataLayerStyle(compareLayer) : FALLBACK_COMPARE_STYLE;
735
+ const primaryColor = primaryStyle.color;
736
+ const compareColor = compareStyle.color;
737
+ const annotationColor = annotationsLayer?.color ?? FALLBACK_ANNOTATION_COLOR;
738
+ const primaryStroke = STROKE_DASHARRAY[primaryStyle.strokeStyle];
739
+ const compareStroke = STROKE_DASHARRAY[compareStyle.strokeStyle];
740
+ const primaryFillOpacity = primaryStyle.fillOpacity;
741
+ const compareFillOpacity = compareStyle.fillOpacity;
742
+
743
+ const setTimeseriesField = useCallback(
744
+ <K extends keyof AnalyticsChartTimeseriesState>(
745
+ key: K,
746
+ value: AnalyticsChartTimeseriesState[K],
747
+ ) => {
748
+ onChange((prev) => {
749
+ if (prev.view !== "timeseries") return prev;
750
+ return { ...prev, [key]: value };
751
+ });
752
+ },
753
+ [onChange],
754
+ );
755
+
756
+ const wrapperRef = useRef<HTMLDivElement>(null);
757
+ const [hoverIndex, setHoverIndex] = useState<number | null>(null);
758
+ const [committedRange, setCommittedRange] = useState<[number, number] | null>(null);
759
+ const [annotationDraft, setAnnotationDraft] = useState<string | null>(null);
760
+ const [dragAnchor, setDragAnchor] = useState<number | null>(null);
761
+ const [brushStart, setBrushStart] = useState<number | null>(null);
762
+ const [brushEnd, setBrushEnd] = useState<number | null>(null);
763
+ const [pieHoverKey, setPieHoverKey] = useState<string | null>(null);
764
+
765
+ const activeIndex = pinnedIndex ?? hoverIndex;
766
+
767
+ const primarySegmentSeries = useMemo<readonly AnalyticsChartSeries[]>(
768
+ () => primaryLayer?.segmentSeries ?? EMPTY_SERIES,
769
+ [primaryLayer?.segmentSeries],
770
+ );
771
+ const compareSegmentSeries = useMemo<readonly AnalyticsChartSeries[]>(
772
+ () => compareLayer?.segmentSeries ?? EMPTY_SERIES,
773
+ [compareLayer?.segmentSeries],
774
+ );
775
+ const primaryFullSegments = useMemo<readonly (readonly number[])[]>(
776
+ () => primaryLayer?.segments ?? EMPTY_MATRIX,
777
+ [primaryLayer?.segments],
778
+ );
779
+ const compareFullSegments = useMemo<readonly (readonly number[])[]>(
780
+ () => compareLayer?.segments ?? EMPTY_MATRIX,
781
+ [compareLayer?.segments],
782
+ );
783
+
784
+ const primarySegmented =
785
+ (primaryLayer?.segmented ?? false)
786
+ && showPrimary
787
+ && primarySegmentSeries.length > 0
788
+ && primaryFullSegments.length > 0;
789
+ const compareSegmented =
790
+ (compareLayer?.segmented ?? false)
791
+ && showCompare
792
+ && compareSegmentSeries.length > 0
793
+ && compareFullSegments.length > 0;
794
+
795
+ const visibleStart = zoomRange ? zoomRange[0] : 0;
796
+ const visibleEnd = zoomRange ? zoomRange[1] : fullData.length - 1;
797
+
798
+ const data = useMemo(
799
+ () => fullData.slice(visibleStart, visibleEnd + 1),
800
+ [fullData, visibleStart, visibleEnd],
801
+ );
802
+ const primarySegments = useMemo(
803
+ () => primaryFullSegments.slice(visibleStart, visibleEnd + 1),
804
+ [primaryFullSegments, visibleStart, visibleEnd],
805
+ );
806
+ const compareSegments = useMemo(
807
+ () => compareFullSegments.slice(visibleStart, visibleEnd + 1),
808
+ [compareFullSegments, visibleStart, visibleEnd],
809
+ );
810
+ const primarySegmentTotals = useMemo(
811
+ () => primarySegments.map((row) => row.reduce((a, b) => a + b, 0)),
812
+ [primarySegments],
813
+ );
814
+ const compareSegmentTotals = useMemo(
815
+ () => compareSegments.map((row) => row.reduce((a, b) => a + b, 0)),
816
+ [compareSegments],
817
+ );
818
+
819
+ const yDomainMax = useMemo(() => {
820
+ const dataLayerIds = layers.filter(isAnalyticsChartDataLayer).map((l) => l.id);
821
+ const layerMaxes = dataLayerIds.map((id) =>
822
+ data.reduce((m, p) => Math.max(m, pointValue(p, id)), 0),
823
+ );
824
+ const primaryStackMax = primarySegmentTotals.reduce((m, v) => Math.max(m, v), 0);
825
+ const compareStackMax = compareSegmentTotals.reduce((m, v) => Math.max(m, v), 0);
826
+ const rawMax = Math.max(0, ...layerMaxes, primaryStackMax, compareStackMax);
827
+ return Math.ceil(rawMax * (1 + yDomainPadding));
828
+ }, [data, layers, primarySegmentTotals, compareSegmentTotals, yDomainPadding]);
829
+
830
+ const segmentColors = useMemo(() => {
831
+ return {
832
+ primary: {
833
+ light: buildRampColors(palette.primary, primarySegmentSeries.length, "light"),
834
+ dark: buildRampColors(palette.primary, primarySegmentSeries.length, "dark"),
835
+ },
836
+ compare: {
837
+ light: buildRampColors(palette.compare, compareSegmentSeries.length, "light"),
838
+ dark: buildRampColors(palette.compare, compareSegmentSeries.length, "dark"),
839
+ },
840
+ };
841
+ }, [primarySegmentSeries.length, compareSegmentSeries.length, palette]);
842
+
843
+ const aggregatedPrimarySegments = useMemo(
844
+ () =>
845
+ primarySegmentSeries.map((_, sIdx) =>
846
+ primarySegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0),
847
+ ),
848
+ [primarySegmentSeries, primarySegments],
849
+ );
850
+ const aggregatedCompareSegments = useMemo(
851
+ () =>
852
+ compareSegmentSeries.map((_, sIdx) =>
853
+ compareSegments.reduce((acc, row) => acc + (row[sIdx] ?? 0), 0),
854
+ ),
855
+ [compareSegmentSeries, compareSegments],
856
+ );
857
+ const aggregatedPrimaryTotal = useMemo(
858
+ () => aggregatedPrimarySegments.reduce((a, b) => a + b, 0),
859
+ [aggregatedPrimarySegments],
860
+ );
861
+ const aggregatedCompareTotal = useMemo(
862
+ () => aggregatedCompareSegments.reduce((a, b) => a + b, 0),
863
+ [aggregatedCompareSegments],
864
+ );
865
+
866
+ const annotations = useMemo(() => {
867
+ return fullAnnotations
868
+ .filter((a) => a.index >= visibleStart && a.index <= visibleEnd)
869
+ .map((a) => ({ ...a, index: a.index - visibleStart }));
870
+ }, [fullAnnotations, visibleStart, visibleEnd]);
871
+
872
+ const brushing = brushStart != null;
873
+ const N = data.length;
874
+
875
+ const primaryKey = primaryLayer?.id ?? "__analytics_primary";
876
+ const compareKey = compareLayer?.id ?? "__analytics_compare";
877
+ // Segment keys must be valid CSS `<ident>` tokens — colons break `--color-${key}` declarations.
878
+ const primarySolidKey = `${primaryKey}_solid`;
879
+ const primaryDashedKey = `${primaryKey}_dashed`;
880
+ const compareSolidKey = `${compareKey}_solid`;
881
+ const compareDashedKey = `${compareKey}_dashed`;
882
+ const primarySegKey = useCallback(
883
+ (segKey: string) => `${primaryKey}_seg_${cssIdent(segKey)}`,
884
+ [primaryKey],
885
+ );
886
+ const compareSegKey = useCallback(
887
+ (segKey: string) => `${compareKey}_seg_${cssIdent(segKey)}`,
888
+ [compareKey],
889
+ );
890
+
891
+ const primaryInProgressLocalIdx = computeLocalInProgressIdx(
892
+ primaryLayer?.inProgressFromIndex,
893
+ visibleStart,
894
+ visibleEnd,
895
+ );
896
+ const compareInProgressLocalIdx = computeLocalInProgressIdx(
897
+ compareLayer?.inProgressFromIndex,
898
+ visibleStart,
899
+ visibleEnd,
900
+ );
901
+ const primaryHasInProgress =
902
+ primaryInProgressLocalIdx != null
903
+ && !primarySegmented
904
+ && (primaryStyle.type === "line" || primaryStyle.type === "area");
905
+ const compareHasInProgress =
906
+ compareInProgressLocalIdx != null
907
+ && !compareSegmented
908
+ && (compareStyle.type === "line" || compareStyle.type === "area");
909
+
910
+ const chartData = useMemo(() => {
911
+ return data.map((point, i) => {
912
+ const row: Record<string, number | string | null> = {
913
+ index: i,
914
+ ts: point.ts,
915
+ };
916
+ for (const [k, v] of Object.entries(point.values)) {
917
+ row[k] = v;
918
+ }
919
+ if (primaryLayer && primaryHasInProgress) {
920
+ const k = primaryInProgressLocalIdx as number;
921
+ const v = pointValue(point, primaryLayer.id);
922
+ row[primarySolidKey] = i < k ? v : null;
923
+ row[primaryDashedKey] = i >= k - 1 ? v : null;
924
+ }
925
+ if (compareLayer && compareHasInProgress) {
926
+ const k = compareInProgressLocalIdx as number;
927
+ const v = pointValue(point, compareLayer.id);
928
+ row[compareSolidKey] = i < k ? v : null;
929
+ row[compareDashedKey] = i >= k - 1 ? v : null;
930
+ }
931
+ if (primarySegmented) {
932
+ primarySegmentSeries.forEach((s, sIdx) => {
933
+ row[primarySegKey(s.key)] = primarySegments[i]?.[sIdx] ?? 0;
934
+ });
935
+ }
936
+ if (compareSegmented) {
937
+ compareSegmentSeries.forEach((s, sIdx) => {
938
+ row[compareSegKey(s.key)] = compareSegments[i]?.[sIdx] ?? 0;
939
+ });
940
+ }
941
+ return row;
942
+ });
943
+ }, [
944
+ data,
945
+ primaryLayer,
946
+ compareLayer,
947
+ primarySolidKey,
948
+ primaryDashedKey,
949
+ compareSolidKey,
950
+ compareDashedKey,
951
+ primarySegKey,
952
+ compareSegKey,
953
+ primarySegments,
954
+ compareSegments,
955
+ primarySegmentSeries,
956
+ compareSegmentSeries,
957
+ primarySegmented,
958
+ compareSegmented,
959
+ primaryHasInProgress,
960
+ compareHasInProgress,
961
+ primaryInProgressLocalIdx,
962
+ compareInProgressLocalIdx,
963
+ ]);
964
+
965
+ const chartConfig = useMemo<DesignChartConfig>(() => {
966
+ const primaryLabel = primaryLayer?.label ?? "";
967
+ const compareLabel = compareLayer?.label ?? "";
968
+ const config: DesignChartConfig = {};
969
+ if (primaryLayer) {
970
+ config[primaryLayer.id] = { label: primaryLabel, color: primaryColor };
971
+ if (primaryHasInProgress) {
972
+ config[primarySolidKey] = { label: primaryLabel, color: primaryColor };
973
+ config[primaryDashedKey] = { label: primaryLabel, color: primaryColor };
974
+ }
975
+ }
976
+ if (compareLayer) {
977
+ config[compareLayer.id] = { label: compareLabel, color: compareColor };
978
+ if (compareHasInProgress) {
979
+ config[compareSolidKey] = { label: compareLabel, color: compareColor };
980
+ config[compareDashedKey] = { label: compareLabel, color: compareColor };
981
+ }
982
+ }
983
+ if (primarySegmented) {
984
+ primarySegmentSeries.forEach((s, i) => {
985
+ config[primarySegKey(s.key)] = {
986
+ label: s.label,
987
+ theme: {
988
+ light: segmentColors.primary.light[i],
989
+ dark: segmentColors.primary.dark[i],
990
+ },
991
+ };
992
+ });
993
+ }
994
+ if (compareSegmented) {
995
+ compareSegmentSeries.forEach((s, i) => {
996
+ config[compareSegKey(s.key)] = {
997
+ label: s.label,
998
+ theme: {
999
+ light: segmentColors.compare.light[i],
1000
+ dark: segmentColors.compare.dark[i],
1001
+ },
1002
+ };
1003
+ });
1004
+ }
1005
+ return config;
1006
+ }, [
1007
+ primaryLayer,
1008
+ compareLayer,
1009
+ primarySolidKey,
1010
+ primaryDashedKey,
1011
+ compareSolidKey,
1012
+ compareDashedKey,
1013
+ primarySegKey,
1014
+ compareSegKey,
1015
+ primaryColor,
1016
+ compareColor,
1017
+ primaryHasInProgress,
1018
+ compareHasInProgress,
1019
+ primarySegmented,
1020
+ compareSegmented,
1021
+ primarySegmentSeries,
1022
+ compareSegmentSeries,
1023
+ segmentColors,
1024
+ ]);
1025
+
1026
+ const handleChartMouseMove = useCallback(
1027
+ (rechartsState: RechartsMouseState) => {
1028
+ const i = rechartsState.activeTooltipIndex;
1029
+ if (typeof i !== "number") return;
1030
+ setHoverIndex(i);
1031
+ if (dragAnchor != null && (brushStart != null || i !== dragAnchor)) {
1032
+ if (brushStart == null) setBrushStart(dragAnchor);
1033
+ setBrushEnd(i);
1034
+ }
1035
+ },
1036
+ [dragAnchor, brushStart],
1037
+ );
1038
+ const handleChartMouseDown = useCallback(
1039
+ (rechartsState: RechartsMouseState, e: React.MouseEvent) => {
1040
+ if (e.button !== 0) return;
1041
+ const i = rechartsState.activeTooltipIndex;
1042
+ if (typeof i !== "number") return;
1043
+ e.preventDefault();
1044
+ setDragAnchor(i);
1045
+ setAnnotationDraft(null);
1046
+ },
1047
+ [],
1048
+ );
1049
+ const handleChartMouseUp = useCallback(
1050
+ (_: RechartsMouseState, e: React.MouseEvent) => {
1051
+ e.stopPropagation();
1052
+ if (brushStart != null && brushEnd != null) {
1053
+ const lo = Math.min(brushStart, brushEnd);
1054
+ const hi = Math.max(brushStart, brushEnd);
1055
+ setBrushStart(null);
1056
+ setBrushEnd(null);
1057
+ setDragAnchor(null);
1058
+ if (hi - lo >= 1) setCommittedRange([lo, hi]);
1059
+ return;
1060
+ }
1061
+ setDragAnchor(null);
1062
+ if (pinnedIndex != null) {
1063
+ setTimeseriesField("pinnedIndex", null);
1064
+ } else if (hoverIndex != null) {
1065
+ setTimeseriesField("pinnedIndex", hoverIndex);
1066
+ }
1067
+ },
1068
+ [
1069
+ brushStart,
1070
+ brushEnd,
1071
+ hoverIndex,
1072
+ pinnedIndex,
1073
+ setTimeseriesField,
1074
+ ],
1075
+ );
1076
+ const handleChartMouseLeave = useCallback(() => {
1077
+ if (!brushing) setHoverIndex(null);
1078
+ }, [brushing]);
1079
+
1080
+ const handleKeyDown = useCallback(
1081
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
1082
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
1083
+ e.preventDefault();
1084
+ setHoverIndex((cur) => {
1085
+ const base = cur ?? pinnedIndex ?? 0;
1086
+ return e.key === "ArrowRight"
1087
+ ? Math.min(N - 1, base + 1)
1088
+ : Math.max(0, base - 1);
1089
+ });
1090
+ return;
1091
+ }
1092
+ if (e.key === "Home") {
1093
+ e.preventDefault();
1094
+ setHoverIndex(0);
1095
+ return;
1096
+ }
1097
+ if (e.key === "End") {
1098
+ e.preventDefault();
1099
+ setHoverIndex(N - 1);
1100
+ return;
1101
+ }
1102
+ if (e.key === "Enter" || e.key === " ") {
1103
+ e.preventDefault();
1104
+ if (pinnedIndex != null) {
1105
+ setTimeseriesField("pinnedIndex", null);
1106
+ } else if (hoverIndex != null) {
1107
+ setTimeseriesField("pinnedIndex", hoverIndex);
1108
+ }
1109
+ }
1110
+ },
1111
+ [N, hoverIndex, pinnedIndex, setTimeseriesField],
1112
+ );
1113
+
1114
+ useEffect(() => {
1115
+ if (pinnedIndex == null) return;
1116
+ const onKey = (e: KeyboardEvent) => {
1117
+ if (e.key === "Escape") setTimeseriesField("pinnedIndex", null);
1118
+ };
1119
+ const onDown = (e: MouseEvent) => {
1120
+ if (!wrapperRef.current) return;
1121
+ if (!wrapperRef.current.contains(e.target as Node)) setTimeseriesField("pinnedIndex", null);
1122
+ };
1123
+ window.addEventListener("keydown", onKey);
1124
+ window.addEventListener("mousedown", onDown);
1125
+ return () => {
1126
+ window.removeEventListener("keydown", onKey);
1127
+ window.removeEventListener("mousedown", onDown);
1128
+ };
1129
+ }, [pinnedIndex, setTimeseriesField]);
1130
+
1131
+ const plotInset = resolvedPlotMargin.left + resolvedPlotMargin.right;
1132
+ const indexToCss = useCallback(
1133
+ (i: number): string => {
1134
+ if (N <= 1) return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * 0.5)`;
1135
+ const t = Math.max(0, Math.min(1, i / (N - 1)));
1136
+ return `calc(${resolvedPlotMargin.left}px + (100% - ${plotInset}px) * ${t})`;
1137
+ },
1138
+ [N, resolvedPlotMargin.left, plotInset],
1139
+ );
1140
+ const tooltipXPct = activeIndex != null
1141
+ ? N <= 1
1142
+ ? 50
1143
+ : (activeIndex / (N - 1)) * 100
1144
+ : 0;
1145
+ const shouldFlip = tooltipXPct > 68;
1146
+
1147
+ const activePoint = activeIndex != null ? data[activeIndex] : null;
1148
+
1149
+ const chartAriaLabel = primaryLayer?.label || "Chart";
1150
+
1151
+ if (state.view === "pie") {
1152
+ return (
1153
+ <AnalyticsChartPie
1154
+ wrapperRef={wrapperRef}
1155
+ primarySegmentSeries={primarySegmentSeries}
1156
+ compareSegmentSeries={compareSegmentSeries}
1157
+ aggregatedPrimarySegments={aggregatedPrimarySegments}
1158
+ aggregatedCompareSegments={aggregatedCompareSegments}
1159
+ aggregatedPrimaryTotal={aggregatedPrimaryTotal}
1160
+ aggregatedCompareTotal={aggregatedCompareTotal}
1161
+ segmentColors={segmentColors}
1162
+ showPrimary={showPrimary}
1163
+ showCompare={showCompare}
1164
+ xFormatKind={xFormatKind}
1165
+ yFormatKind={yFormatKind}
1166
+ hoverKey={pieHoverKey}
1167
+ setHoverKey={setPieHoverKey}
1168
+ zoomRange={zoomRange}
1169
+ onResetZoom={() => {
1170
+ onChange((prev) => ({ ...prev, zoomRange: null, pinnedIndex: null }));
1171
+ setCommittedRange(null);
1172
+ setAnnotationDraft(null);
1173
+ setHoverIndex(null);
1174
+ }}
1175
+ visibleStart={visibleStart}
1176
+ visibleEnd={visibleEnd}
1177
+ fullData={fullData}
1178
+ strings={strings}
1179
+ fmtValue={fmtValue}
1180
+ pie={pie}
1181
+ />
1182
+ );
1183
+ }
1184
+
1185
+ return (
1186
+ <div
1187
+ ref={wrapperRef}
1188
+ className={cn(
1189
+ "relative w-full select-none rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:focus-visible:ring-blue-400/40",
1190
+ brushing && "[&_.recharts-wrapper]:cursor-ew-resize",
1191
+ !brushing && "[&_.recharts-wrapper]:cursor-crosshair",
1192
+ )}
1193
+ onClick={(e) => {
1194
+ e.stopPropagation();
1195
+ }}
1196
+ onKeyDown={handleKeyDown}
1197
+ onFocus={() => {
1198
+ if (hoverIndex == null) setHoverIndex(pinnedIndex ?? Math.floor(N / 2));
1199
+ }}
1200
+ tabIndex={0}
1201
+ role="img"
1202
+ aria-label={`${chartAriaLabel} over the visible ${data.length}-day range. Use arrow keys to move the cursor, Enter to pin, Escape to release. Click and drag to select a range.`}
1203
+ >
1204
+ <DesignChartContainer
1205
+ config={chartConfig}
1206
+ className="aspect-auto h-[260px] w-full sm:h-[320px]"
1207
+ >
1208
+ <ComposedChart
1209
+ data={chartData}
1210
+ margin={resolvedPlotMargin}
1211
+ onMouseMove={handleChartMouseMove}
1212
+ onMouseDown={handleChartMouseDown}
1213
+ onMouseUp={handleChartMouseUp}
1214
+ onMouseLeave={handleChartMouseLeave}
1215
+ >
1216
+ {showGrid && <CartesianGrid vertical={false} strokeDasharray="3 3" />}
1217
+ {showXAxis && (
1218
+ <XAxis
1219
+ dataKey="index"
1220
+ tickLine={false}
1221
+ axisLine={false}
1222
+ tickMargin={10}
1223
+ minTickGap={32}
1224
+ tickFormatter={(value: number | string) => {
1225
+ const idx = Number(value);
1226
+ if (idx < 0 || idx >= data.length) return "";
1227
+ return fmtValue(data[idx]!.ts, xFormatKind);
1228
+ }}
1229
+ />
1230
+ )}
1231
+ {showYAxis && (
1232
+ <YAxis
1233
+ tickLine={false}
1234
+ axisLine={false}
1235
+ tickMargin={8}
1236
+ width={yAxisWidth}
1237
+ domain={[0, yDomainMax]}
1238
+ allowDataOverflow={false}
1239
+ tickFormatter={(value: number | string) =>
1240
+ fmtValue(Math.round(Number(value)), yFormatKind)
1241
+ }
1242
+ />
1243
+ )}
1244
+
1245
+ {showAnnotationsLayer
1246
+ && annotations.map((a) => (
1247
+ <ReferenceLine
1248
+ key={a.index}
1249
+ x={a.index}
1250
+ stroke={annotationColor}
1251
+ strokeDasharray="3 4"
1252
+ strokeOpacity={0.55}
1253
+ ifOverflow="visible"
1254
+ />
1255
+ ))}
1256
+
1257
+ {committedRange && (
1258
+ <ReferenceArea
1259
+ x1={committedRange[0]}
1260
+ x2={committedRange[1]}
1261
+ fill={primaryColor}
1262
+ fillOpacity={0.07}
1263
+ stroke={primaryColor}
1264
+ strokeOpacity={0.45}
1265
+ strokeDasharray="3 3"
1266
+ ifOverflow="visible"
1267
+ />
1268
+ )}
1269
+
1270
+ {brushStart != null && brushEnd != null && (
1271
+ <ReferenceArea
1272
+ x1={Math.min(brushStart, brushEnd)}
1273
+ x2={Math.max(brushStart, brushEnd)}
1274
+ fill={primaryColor}
1275
+ fillOpacity={0.15}
1276
+ stroke={primaryColor}
1277
+ strokeOpacity={0.55}
1278
+ ifOverflow="visible"
1279
+ />
1280
+ )}
1281
+
1282
+ {showPrimary && primaryLayer && renderDataSeries({
1283
+ layer: primaryLayer,
1284
+ segmented: primarySegmented,
1285
+ segmentSeries: primarySegmentSeries,
1286
+ segKey: primarySegKey,
1287
+ stackId: "primary-segments",
1288
+ strokeDasharray: primaryStroke,
1289
+ segmentedStrokeDasharray: undefined,
1290
+ fillOpacity: primaryFillOpacity,
1291
+ segmentedFillOpacity: 0.78,
1292
+ strokeWidth: 2,
1293
+ segmentedStrokeWidth: 0.75,
1294
+ inProgressKeys: primaryHasInProgress
1295
+ ? { solid: primarySolidKey, dashed: primaryDashedKey }
1296
+ : null,
1297
+ })}
1298
+
1299
+ {showCompare && compareLayer && renderDataSeries({
1300
+ layer: compareLayer,
1301
+ segmented: compareSegmented,
1302
+ segmentSeries: compareSegmentSeries,
1303
+ segKey: compareSegKey,
1304
+ stackId: "compare-segments",
1305
+ strokeDasharray: compareStroke,
1306
+ segmentedStrokeDasharray: compareStroke,
1307
+ fillOpacity: compareFillOpacity,
1308
+ segmentedFillOpacity: 0.6,
1309
+ baseOpacity: compareSegmented ? 0.9 : 1,
1310
+ strokeWidth: 1.5,
1311
+ segmentedStrokeWidth: 0.75,
1312
+ inProgressKeys: compareHasInProgress
1313
+ ? { solid: compareSolidKey, dashed: compareDashedKey }
1314
+ : null,
1315
+ })}
1316
+ </ComposedChart>
1317
+ </DesignChartContainer>
1318
+
1319
+ {activeIndex != null && activePoint && !brushing && (
1320
+ <div
1321
+ className="pointer-events-none absolute inset-y-4 z-10 w-px border-l border-dashed border-foreground/30"
1322
+ style={{ left: indexToCss(activeIndex) }}
1323
+ />
1324
+ )}
1325
+
1326
+ {brushStart != null && brushEnd != null && (() => {
1327
+ const lo = Math.min(brushStart, brushEnd);
1328
+ const hi = Math.max(brushStart, brushEnd);
1329
+ const days = hi - lo + 1;
1330
+ return (
1331
+ <div
1332
+ className="pointer-events-none absolute -top-1 z-30 -translate-x-1/2 -translate-y-full rounded-lg border border-blue-500/30 bg-background/95 px-3 py-1.5 text-[11px] shadow-lg backdrop-blur-xl dark:border-blue-400/30"
1333
+ style={{ left: indexToCss((lo + hi) / 2) }}
1334
+ >
1335
+ <div className="flex items-center gap-2">
1336
+ <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
1337
+ {strings.rangeLabel}
1338
+ </span>
1339
+ <span className="font-mono tabular-nums text-foreground">
1340
+ {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)}
1341
+ </span>
1342
+ <span className="font-mono text-[10px] tabular-nums text-muted-foreground">
1343
+ · {strings.daysShort(days)}
1344
+ </span>
1345
+ </div>
1346
+ </div>
1347
+ );
1348
+ })()}
1349
+
1350
+ {zoomRange && (
1351
+ <div className="absolute right-2 top-2 z-20">
1352
+ <button
1353
+ type="button"
1354
+ onClick={() => {
1355
+ onChange((prev) => ({ ...prev, zoomRange: null, pinnedIndex: null }));
1356
+ setCommittedRange(null);
1357
+ setAnnotationDraft(null);
1358
+ setHoverIndex(null);
1359
+ }}
1360
+ className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider text-blue-600 ring-1 ring-blue-500/30 transition-colors duration-150 hover:bg-blue-500/15 hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 dark:text-blue-300 dark:ring-blue-400/30"
1361
+ >
1362
+ <MagnifyingGlassMinusIcon weight="bold" className="size-3" aria-hidden="true" />
1363
+ <span>{strings.resetZoom}</span>
1364
+ </button>
1365
+ </div>
1366
+ )}
1367
+
1368
+ {committedRange && !brushing && (() => {
1369
+ const [lo, hi] = committedRange;
1370
+ const center = (lo + hi) / 2;
1371
+ const centerPct = N <= 1 ? 50 : (center / (N - 1)) * 100;
1372
+ const snapLeft = centerPct < 22;
1373
+ const snapRight = centerPct > 78;
1374
+ const anchorStyle: CSSProperties = snapLeft
1375
+ ? { left: "8px" }
1376
+ : snapRight
1377
+ ? { right: "8px" }
1378
+ : { left: indexToCss(center), transform: "translateX(-50%)" };
1379
+ const days = hi - lo + 1;
1380
+ const draft = annotationDraft;
1381
+ return (
1382
+ <div className="absolute top-2 z-30" style={anchorStyle}>
1383
+ <div className="flex items-center gap-1 rounded-full border border-foreground/10 bg-background/95 py-1 pl-2 pr-1 shadow-[0_10px_28px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:shadow-[0_10px_28px_rgba(0,0,0,0.55)]">
1384
+ {draft == null ? (
1385
+ <>
1386
+ <span className="flex items-center gap-1.5 whitespace-nowrap px-1 font-mono text-[10px] tabular-nums text-muted-foreground">
1387
+ <span className="text-foreground">
1388
+ {fmtValue(data[lo]!.ts, xFormatKind)} – {fmtValue(data[hi]!.ts, xFormatKind)}
1389
+ </span>
1390
+ <span className="text-foreground/30">·</span>
1391
+ <span>{strings.daysShort(days)}</span>
1392
+ </span>
1393
+ <div className="h-4 w-px bg-foreground/10" aria-hidden="true" />
1394
+ <DesignButton
1395
+ size="sm"
1396
+ variant="ghost"
1397
+ className="h-7 gap-1.5 px-2 text-[11px]"
1398
+ onClick={() => {
1399
+ onChange((prev) => ({
1400
+ ...prev,
1401
+ zoomRange: [visibleStart + lo, visibleStart + hi],
1402
+ pinnedIndex: null,
1403
+ }));
1404
+ setCommittedRange(null);
1405
+ setAnnotationDraft(null);
1406
+ setHoverIndex(null);
1407
+ }}
1408
+ >
1409
+ <MagnifyingGlassPlusIcon weight="bold" className="size-3.5" aria-hidden="true" />
1410
+ {strings.zoomIn}
1411
+ </DesignButton>
1412
+ <DesignButton
1413
+ size="sm"
1414
+ variant="ghost"
1415
+ className="h-7 gap-1.5 px-2 text-[11px]"
1416
+ onClick={() => setAnnotationDraft("")}
1417
+ >
1418
+ <FlagIcon weight="bold" className="size-3.5" aria-hidden="true" />
1419
+ {strings.annotate}
1420
+ </DesignButton>
1421
+ <DesignButton
1422
+ size="sm"
1423
+ variant="ghost"
1424
+ className="h-7 w-7 p-0"
1425
+ aria-label={strings.clearSelection}
1426
+ onClick={() => setCommittedRange(null)}
1427
+ >
1428
+ <XIcon weight="bold" className="size-3.5" aria-hidden="true" />
1429
+ </DesignButton>
1430
+ </>
1431
+ ) : (
1432
+ <form
1433
+ className="flex items-center gap-1 px-1"
1434
+ onSubmit={(e) => {
1435
+ e.preventDefault();
1436
+ const label = draft.trim();
1437
+ if (label.length === 0) return;
1438
+ const mid = Math.round((lo + hi) / 2);
1439
+ onAnnotationCreate?.({
1440
+ index: visibleStart + mid,
1441
+ label,
1442
+ description: label,
1443
+ });
1444
+ setCommittedRange(null);
1445
+ setAnnotationDraft(null);
1446
+ }}
1447
+ >
1448
+ <FlagIcon
1449
+ weight="bold"
1450
+ className="size-3.5 text-amber-500 dark:text-amber-400"
1451
+ aria-hidden="true"
1452
+ />
1453
+ <input
1454
+ autoFocus
1455
+ type="text"
1456
+ value={draft}
1457
+ onChange={(e) => setAnnotationDraft(e.target.value)}
1458
+ onKeyDown={(e) => {
1459
+ if (e.key === "Escape") {
1460
+ e.preventDefault();
1461
+ setAnnotationDraft(null);
1462
+ }
1463
+ }}
1464
+ maxLength={40}
1465
+ placeholder={strings.annotationPlaceholder}
1466
+ aria-label={strings.annotationLabelAria}
1467
+ className="w-44 bg-transparent px-1 py-0.5 font-mono text-[11px] text-foreground placeholder:text-muted-foreground/60 focus-visible:outline-none"
1468
+ />
1469
+ <DesignButton
1470
+ type="submit"
1471
+ size="sm"
1472
+ variant="default"
1473
+ className="h-7 px-2.5 text-[11px]"
1474
+ disabled={draft.trim().length === 0}
1475
+ >
1476
+ {strings.save}
1477
+ </DesignButton>
1478
+ <DesignButton
1479
+ type="button"
1480
+ size="sm"
1481
+ variant="ghost"
1482
+ className="h-7 px-2 text-[11px]"
1483
+ onClick={() => setAnnotationDraft(null)}
1484
+ >
1485
+ {strings.cancel}
1486
+ </DesignButton>
1487
+ </form>
1488
+ )}
1489
+ </div>
1490
+ </div>
1491
+ );
1492
+ })()}
1493
+
1494
+ {showAnnotationsLayer && (
1495
+ <div className="pointer-events-none absolute inset-x-0 top-1 h-0">
1496
+ {annotations.map((a) => {
1497
+ return (
1498
+ <div
1499
+ key={a.index}
1500
+ className="absolute -translate-x-1/2"
1501
+ style={{ left: indexToCss(a.index) }}
1502
+ >
1503
+ <button
1504
+ type="button"
1505
+ aria-label={a.description}
1506
+ className="peer pointer-events-auto inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-1.5 py-[1px] font-mono text-[9px] font-semibold uppercase tracking-wider text-amber-700 ring-1 ring-amber-500/30 dark:text-amber-300 dark:bg-amber-500/20 dark:ring-amber-500/40 transition-colors duration-150 hover:bg-amber-500/25 hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50"
1507
+ >
1508
+ <FlagIcon weight="fill" className="size-2.5" aria-hidden="true" />
1509
+ {a.label}
1510
+ </button>
1511
+ <span
1512
+ role="tooltip"
1513
+ className="pointer-events-none absolute left-1/2 top-full z-30 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-[10px] font-normal text-background shadow-md peer-hover:block peer-focus-visible:block"
1514
+ >
1515
+ {a.description}
1516
+ </span>
1517
+ </div>
1518
+ );
1519
+ })}
1520
+ </div>
1521
+ )}
1522
+
1523
+ {activeIndex != null && activePoint && (() => {
1524
+ const primaryView = buildTooltipLayerView({
1525
+ show: showPrimary,
1526
+ layer: primaryLayer,
1527
+ color: primaryColor,
1528
+ segmented: primarySegmented,
1529
+ segmentSeries: primarySegmentSeries,
1530
+ segmentRows: primarySegments,
1531
+ segmentTotals: primarySegmentTotals,
1532
+ segmentColorsLight: segmentColors.primary.light,
1533
+ segmentColorsDark: segmentColors.primary.dark,
1534
+ activeIndex,
1535
+ activePoint,
1536
+ fallbackLabel: "Chart",
1537
+ });
1538
+ const compareView = buildTooltipLayerView({
1539
+ show: showCompare,
1540
+ layer: compareLayer,
1541
+ color: compareColor,
1542
+ segmented: compareSegmented,
1543
+ segmentSeries: compareSegmentSeries,
1544
+ segmentRows: compareSegments,
1545
+ segmentTotals: compareSegmentTotals,
1546
+ segmentColorsLight: segmentColors.compare.light,
1547
+ segmentColorsDark: segmentColors.compare.dark,
1548
+ activeIndex,
1549
+ activePoint,
1550
+ });
1551
+ const delta: AnalyticsChartDelta | null = primaryView && compareView
1552
+ ? formatDelta(primaryView.total, compareView.total)
1553
+ : null;
1554
+ const ctx: AnalyticsChartTooltipContext = {
1555
+ activeIndex,
1556
+ point: activePoint,
1557
+ isPinned: pinnedIndex != null,
1558
+ primary: primaryView,
1559
+ compare: compareView,
1560
+ delta,
1561
+ formatValue: (v) => fmtValue(v, yFormatKind),
1562
+ formatDate: (ts) => fmtValue(ts, xFormatKind),
1563
+ strings,
1564
+ };
1565
+ return (
1566
+ <div
1567
+ className={cn(
1568
+ "pointer-events-none absolute top-10 z-20",
1569
+ "transition-[transform,opacity] duration-150",
1570
+ )}
1571
+ style={{
1572
+ left: indexToCss(activeIndex),
1573
+ transform: shouldFlip
1574
+ ? "translateX(calc(-100% - 16px))"
1575
+ : "translateX(16px)",
1576
+ }}
1577
+ >
1578
+ {renderTooltipFn(ctx)}
1579
+ </div>
1580
+ );
1581
+ })()}
1582
+ </div>
1583
+ );
1584
+ }
1585
+