@funnest/charts-react 0.1.1 → 0.2.1

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.
package/dist/index.js CHANGED
@@ -72,6 +72,7 @@ import {
72
72
  bandScale,
73
73
  extent,
74
74
  linearScale,
75
+ logScale,
75
76
  maxLength,
76
77
  nearestIndex,
77
78
  niceDomain,
@@ -92,6 +93,7 @@ import {
92
93
 
93
94
  // src/viz/context.ts
94
95
  import { createContext as createContext2, useContext as useContext2 } from "react";
96
+ var nearScrub = (ctx, x, within = 56) => ctx.scrubIndex !== null && Math.abs(ctx.x(ctx.scrubIndex) - x) < within;
95
97
  var CartesianContext = createContext2(null);
96
98
  function useCartesianChart() {
97
99
  const ctx = useContext2(CartesianContext);
@@ -104,8 +106,11 @@ var VAR = {
104
106
  grid: "var(--fnv-grid, color-mix(in srgb, CanvasText 9%, transparent))",
105
107
  line: "var(--fnv-line, color-mix(in srgb, CanvasText 32%, transparent))",
106
108
  bg: "var(--fnv-bg, Canvas)",
107
- font: "var(--fnv-font, inherit)"
109
+ font: "var(--fnv-font, inherit)",
110
+ accentRing: "color-mix(in srgb, var(--fnv-accent, #6C4DFF) 55%, transparent)"
108
111
  };
112
+ var DIM_OPACITY = 0.22;
113
+ var dashFor = (style, width) => style === "dashed" ? `${width * 3} ${width * 2.5}` : style === "dotted" ? `0.1 ${width * 2.6}` : void 0;
109
114
  var numberFormat = new Intl.NumberFormat(void 0, { maximumFractionDigits: 2 });
110
115
  var compactFormat = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 2 });
111
116
  var formatNumber = (v) => Math.abs(v) >= 1e5 ? compactFormat.format(v) : numberFormat.format(v);
@@ -113,22 +118,36 @@ function seriesY(ctx, s, i) {
113
118
  const stacked = ctx.stacked?.get(s.id);
114
119
  if (stacked) {
115
120
  const raw = s.data[i];
116
- return raw === null || raw === void 0 ? null : ctx.y(stacked.y1[i]);
121
+ return raw === null || raw === void 0 ? null : s.scale(stacked.y1[i]);
117
122
  }
118
123
  const v = s.data[i];
119
- return v === null || v === void 0 || !Number.isFinite(v) ? null : ctx.y(v);
124
+ return v === null || v === void 0 || !Number.isFinite(v) ? null : s.scale(v);
120
125
  }
126
+ var scaleFor = (ctx, axisId) => (axisId ? ctx.yScales[axisId] : void 0) ?? ctx.y;
121
127
 
122
128
  // src/viz/legend.tsx
123
129
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
124
- function Legend({ items, onItemClick, onItemHover, shape = "circle", className, style }) {
125
- const swatch = (color) => shape === "line" ? { width: 12, height: 3, borderRadius: 2, background: color } : { width: 8, height: 8, borderRadius: shape === "circle" ? "50%" : 2, background: color };
130
+ function Legend({ items, onItemClick, onItemHover, shape = "circle", direction = "row", justify = "flex-start", className, style }) {
131
+ const swatch = (color) => {
132
+ switch (shape) {
133
+ case "line":
134
+ return { width: 12, height: 3, borderRadius: 2, background: color };
135
+ case "pill":
136
+ return { width: 14, height: 6, borderRadius: 3, background: color };
137
+ case "square":
138
+ return { width: 8, height: 8, borderRadius: 1, background: color };
139
+ case "squircle":
140
+ return { width: 9, height: 9, borderRadius: 3, background: color };
141
+ default:
142
+ return { width: 8, height: 8, borderRadius: "50%", background: color };
143
+ }
144
+ };
126
145
  return /* @__PURE__ */ jsx2(
127
146
  "div",
128
147
  {
129
148
  className,
130
149
  role: onItemClick ? "group" : "list",
131
- style: { display: "flex", flexWrap: "wrap", gap: "4px 14px", padding: "4px 0", fontSize: 12, color: VAR.text, fontFamily: VAR.font, ...style },
150
+ style: { display: "flex", flexDirection: direction, flexWrap: direction === "row" ? "wrap" : "nowrap", justifyContent: justify, gap: direction === "row" ? "4px 14px" : 8, padding: "4px 0", fontSize: 12, color: VAR.text, fontFamily: VAR.font, ...style },
132
151
  children: items.map((item) => {
133
152
  const content = /* @__PURE__ */ jsxs(Fragment, { children: [
134
153
  /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", style: { flex: "none", ...swatch(item.color) } }),
@@ -157,6 +176,7 @@ function Legend({ items, onItemClick, onItemHover, shape = "circle", className,
157
176
  // src/viz/cartesian-chart.tsx
158
177
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
159
178
  var DEFAULT_HEIGHT = 240;
179
+ var DEFAULT_AXIS = "default";
160
180
  function resolveInset(inset) {
161
181
  const d = typeof inset === "number" ? inset : 8;
162
182
  const o = typeof inset === "object" ? inset : {};
@@ -196,6 +216,7 @@ function useElementWidth(fixed) {
196
216
  function prefersReducedMotion() {
197
217
  return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
198
218
  }
219
+ var normalizeLegend = (legend) => legend === true ? { position: "top" } : legend === false || legend === void 0 ? null : typeof legend === "string" ? { position: legend } : legend;
199
220
  function CartesianChart({
200
221
  series,
201
222
  width: fixedWidth,
@@ -206,9 +227,11 @@ function CartesianChart({
206
227
  stacked = false,
207
228
  enableScrubbing = false,
208
229
  onScrubberPositionChange,
230
+ getScrubberAccessibilityLabel,
209
231
  animate = true,
210
232
  legend = false,
211
233
  legendValueFormatter,
234
+ emptyState,
212
235
  palette,
213
236
  className,
214
237
  style,
@@ -218,11 +241,30 @@ function CartesianChart({
218
241
  const id = `fnv${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
219
242
  const [wrapRef, width] = useElementWidth(fixedWidth);
220
243
  const [scrubIndex, setScrubIndex] = useState2(null);
244
+ const [focused, setFocused] = useState2(false);
221
245
  const [motion, setMotion] = useState2(false);
246
+ const [hidden, setHidden] = useState2([]);
247
+ const [emphasis, setEmphasis] = useState2(null);
248
+ const [dimFuture, setDimFuture] = useState2(false);
222
249
  useEffect2(() => setMotion(animate && !prefersReducedMotion()), [animate]);
223
- const resolved = useMemo(
224
- () => series.map((s, index) => ({ ...s, index, color: s.color ?? seriesColor(index, palette) })),
225
- [series, palette]
250
+ const legendCfg = normalizeLegend(legend);
251
+ const axes = useMemo(() => {
252
+ const list = Array.isArray(yAxis) ? yAxis : [yAxis ?? {}];
253
+ return (list.length ? list : [{}]).map((a, i) => ({ ...a, id: a.id ?? (i === 0 ? DEFAULT_AXIS : `axis${i}`) }));
254
+ }, [yAxis]);
255
+ const firstAxis = axes[0].id;
256
+ const all = useMemo(
257
+ () => series.map((s, index) => {
258
+ const hasRange = s.data.some((v) => Array.isArray(v));
259
+ const high = s.data.map((v) => Array.isArray(v) ? Math.max(v[0], v[1]) : v);
260
+ const low = hasRange ? s.data.map((v) => Array.isArray(v) ? Math.min(v[0], v[1]) : v) : null;
261
+ return { ...s, data: high, low, index, color: s.color ?? seriesColor(index, palette), dimmed: false, axisId: s.yAxisId ?? firstAxis };
262
+ }),
263
+ [series, palette, firstAxis]
264
+ );
265
+ const visible = useMemo(
266
+ () => all.filter((s) => !hidden.includes(s.id)).map((s) => ({ ...s, dimmed: emphasis !== null && emphasis !== s.id })),
267
+ [all, hidden, emphasis]
226
268
  );
227
269
  const axisSpace = useMemo(() => {
228
270
  const acc = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -236,26 +278,33 @@ function CartesianChart({
236
278
  const right = Math.max(left, width - ins.right - axisSpace.right);
237
279
  const bottom = Math.max(top, height - ins.bottom - axisSpace.bottom);
238
280
  const plot = { left, top, right, bottom, width: right - left, height: bottom - top };
239
- const length = Math.max(maxLength(resolved), xAxis?.data?.length ?? 0);
281
+ const length = Math.max(maxLength(all), xAxis?.data?.length ?? 0);
240
282
  const isBand = xAxis?.scaleType === "band";
241
283
  const band = isBand ? bandScale(length, [left, right], xAxis?.bandPadding ?? 0.25) : null;
242
284
  const xLinear = linearScale([0, Math.max(1, length - 1)], [left, right]);
243
285
  const x = band ? (i) => band.center(i) : (i) => length <= 1 ? (left + right) / 2 : xLinear(i);
244
- let stackedMap = null;
245
- let [min, max] = extent(resolved) ?? [0, 1];
246
- if (stacked) {
247
- const st = stack(resolved);
248
- stackedMap = new Map(st.map((s) => [s.id, s]));
249
- [min, max] = stackedExtent(st);
250
- }
251
- if (yAxis?.includeZero ?? isBand) {
252
- min = Math.min(min, 0);
253
- max = Math.max(max, 0);
286
+ let stackedMap = stacked ? /* @__PURE__ */ new Map() : null;
287
+ const yScales = {};
288
+ for (const axis of axes) {
289
+ const on = all.filter((s) => s.axisId === axis.id);
290
+ let [min, max] = extent([...on, ...on.flatMap((s) => s.low ? [{ id: `${s.id}~low`, data: s.low }] : [])]) ?? [0, 1];
291
+ if (stacked) {
292
+ for (const st of stack(visible.filter((s) => s.axisId === axis.id))) stackedMap.set(st.id, st);
293
+ if (on.length) [min, max] = stackedExtent(stack(on));
294
+ }
295
+ const log = axis.scaleType === "log";
296
+ if (!log && (axis.includeZero ?? isBand)) {
297
+ min = Math.min(min, 0);
298
+ max = Math.max(max, 0);
299
+ }
300
+ if (axis.nice && !log) [min, max] = niceDomain(min, max, Math.max(2, Math.round(plot.height / 50)));
301
+ if (axis.domain?.min !== void 0) min = axis.domain.min;
302
+ if (axis.domain?.max !== void 0) max = axis.domain.max;
303
+ yScales[axis.id] = log ? logScale([Math.max(min, Number.MIN_VALUE), max], [bottom, top]) : linearScale([min, max], [bottom, top]);
254
304
  }
255
- if (yAxis?.nice) [min, max] = niceDomain(min, max, Math.max(2, Math.round(plot.height / 50)));
256
- if (yAxis?.domain?.min !== void 0) min = yAxis.domain.min;
257
- if (yAxis?.domain?.max !== void 0) max = yAxis.domain.max;
258
- const y = linearScale([min, max], [bottom, top]);
305
+ if (stackedMap && stackedMap.size === 0) stackedMap = null;
306
+ const y = yScales[firstAxis];
307
+ const resolved = visible.map(({ axisId, ...s }) => ({ ...s, scale: yScales[axisId] ?? y }));
259
308
  const xData = xAxis?.data;
260
309
  const xLabel = (i) => {
261
310
  const v = xData?.[i];
@@ -272,13 +321,17 @@ function CartesianChart({
272
321
  x,
273
322
  band,
274
323
  y,
324
+ yScales,
275
325
  stacked: stackedMap,
276
326
  xLabel,
277
327
  xData,
278
328
  scrubIndex,
279
- animate: motion
329
+ focused,
330
+ animate: motion,
331
+ futureMask: dimFuture && !band && scrubIndex !== null && scrubIndex < length ? `url(#${id}-future)` : void 0,
332
+ setDimFuture
280
333
  };
281
- }, [id, width, height, inset, axisSpace, resolved, xAxis, yAxis, stacked, scrubIndex, motion]);
334
+ }, [id, width, height, inset, axisSpace, all, visible, axes, firstAxis, xAxis, stacked, scrubIndex, focused, motion, dimFuture]);
282
335
  const xsRef = useRef2([]);
283
336
  xsRef.current = useMemo(() => Array.from({ length: ctx.length }, (_, i) => ctx.x(i)), [ctx.length, ctx.x]);
284
337
  const updateScrub = useCallback(
@@ -291,51 +344,116 @@ function CartesianChart({
291
344
  [onScrubberPositionChange]
292
345
  );
293
346
  const onPointerMove = (e) => {
294
- if (!enableScrubbing) return;
295
347
  const rect = e.currentTarget.getBoundingClientRect();
296
348
  const px = e.clientX - rect.left;
297
349
  const i = ctx.band ? ctx.band.invert(px) : nearestIndex(xsRef.current, px);
298
350
  updateScrub(i < 0 ? null : i);
299
351
  };
300
- const onPointerLeave = () => enableScrubbing && updateScrub(null);
301
- const legendPos = legend === true ? "top" : legend === false ? null : legend;
302
- const legendEl = legendPos && /* @__PURE__ */ jsx3(
352
+ const onPointerLeave = () => {
353
+ if (!focused) updateScrub(null);
354
+ };
355
+ const onKeyDown = (e) => {
356
+ const last = ctx.length - 1;
357
+ if (last < 0) return;
358
+ const step = e.shiftKey ? 10 : 1;
359
+ const cur = scrubIndex ?? last;
360
+ let next;
361
+ if (e.key === "ArrowLeft") next = Math.max(0, (scrubIndex === null ? last + 1 : cur) - step);
362
+ else if (e.key === "ArrowRight") next = Math.min(last, (scrubIndex === null ? -1 : cur) + step);
363
+ else if (e.key === "Home") next = 0;
364
+ else if (e.key === "End") next = last;
365
+ else if (e.key === "Escape") next = null;
366
+ if (next === void 0) return;
367
+ e.preventDefault();
368
+ updateScrub(next);
369
+ };
370
+ const hasData = all.some((s) => s.data.some((v) => v !== null && Number.isFinite(v)));
371
+ const a11yText = (i) => getScrubberAccessibilityLabel ? getScrubberAccessibilityLabel(i) : [ctx.xLabel(i), ...ctx.series.map((s) => `${s.label ?? s.id} ${s.data[i] === null || s.data[i] === void 0 ? "\u2014" : formatNumber(s.data[i])}`)].join(", ");
372
+ const legendEl = legendCfg && /* @__PURE__ */ jsx3(
303
373
  Legend,
304
374
  {
305
- items: resolved.map((s) => {
375
+ direction: legendCfg.position === "left" || legendCfg.position === "right" ? "column" : "row",
376
+ ...legendCfg.shape ? { shape: legendCfg.shape } : {},
377
+ items: all.map((s) => {
306
378
  const v = scrubIndex === null ? null : s.data[scrubIndex];
307
379
  return {
308
380
  id: s.id,
309
381
  label: s.label ?? s.id,
310
382
  color: s.color,
383
+ inactive: hidden.includes(s.id) || emphasis !== null && emphasis !== s.id,
311
384
  value: v === null || v === void 0 ? void 0 : legendValueFormatter ? legendValueFormatter(v, s.id) : formatNumber(v)
312
385
  };
313
- })
386
+ }),
387
+ ...legendCfg.interactive ? {
388
+ onItemClick: (sid) => setHidden((h) => h.includes(sid) ? h.filter((x) => x !== sid) : [...h, sid]),
389
+ onItemHover: (sid) => setEmphasis(sid && !hidden.includes(sid) ? sid : null)
390
+ } : {}
391
+ }
392
+ );
393
+ const side = legendCfg?.position === "left" || legendCfg?.position === "right";
394
+ const svg = /* @__PURE__ */ jsxs2(
395
+ "svg",
396
+ {
397
+ width: width || "100%",
398
+ height,
399
+ role: "img",
400
+ "aria-label": ariaLabel,
401
+ tabIndex: enableScrubbing && hasData ? 0 : void 0,
402
+ style: {
403
+ display: "block",
404
+ overflow: "visible",
405
+ touchAction: enableScrubbing ? "pan-y" : void 0,
406
+ userSelect: "none",
407
+ outline: "none",
408
+ borderRadius: 6,
409
+ boxShadow: focused ? `0 0 0 2px ${VAR.accentRing}` : void 0
410
+ },
411
+ onPointerMove: enableScrubbing ? onPointerMove : void 0,
412
+ onPointerDown: enableScrubbing ? onPointerMove : void 0,
413
+ onPointerLeave: enableScrubbing ? onPointerLeave : void 0,
414
+ onPointerCancel: enableScrubbing ? onPointerLeave : void 0,
415
+ onKeyDown: enableScrubbing ? onKeyDown : void 0,
416
+ onFocus: enableScrubbing ? () => setFocused(true) : void 0,
417
+ onBlur: enableScrubbing ? () => {
418
+ setFocused(false);
419
+ updateScrub(null);
420
+ } : void 0,
421
+ children: [
422
+ ctx.futureMask && // Fade everything right of the scrubber to 35% (luminance mask: works on any background).
423
+ /* @__PURE__ */ jsxs2("mask", { id: `${id}-future`, maskUnits: "userSpaceOnUse", x: -width, y: -height, width: width * 3, height: height * 3, children: [
424
+ /* @__PURE__ */ jsx3("rect", { x: -width, y: -height, width: width + ctx.x(scrubIndex), height: height * 3, fill: "#fff" }),
425
+ /* @__PURE__ */ jsx3("rect", { x: ctx.x(scrubIndex), y: -height, width: width * 2, height: height * 3, fill: "#fff", fillOpacity: 0.35 })
426
+ ] }),
427
+ width > 0 && hasData && /* @__PURE__ */ jsx3(CartesianContext.Provider, { value: ctx, children }),
428
+ width > 0 && !hasData && /* @__PURE__ */ jsx3("text", { x: width / 2, y: height / 2, textAnchor: "middle", dominantBaseline: "central", fontSize: 13, style: { fill: VAR.text, fontFamily: VAR.font }, children: typeof emptyState === "string" || emptyState === void 0 ? emptyState ?? "" : null })
429
+ ]
430
+ }
431
+ );
432
+ return /* @__PURE__ */ jsxs2(
433
+ "div",
434
+ {
435
+ ref: wrapRef,
436
+ className,
437
+ style: {
438
+ position: "relative",
439
+ width: fixedWidth ?? "100%",
440
+ ...side ? { display: "flex", alignItems: "center", gap: 16, flexDirection: legendCfg?.position === "left" ? "row-reverse" : "row" } : {},
441
+ ...style
442
+ },
443
+ children: [
444
+ legendCfg?.position === "top" || legendCfg && !legendCfg.position ? legendEl : null,
445
+ side ? /* @__PURE__ */ jsx3("div", { style: { flex: 1, minWidth: 0 }, children: svg }) : svg,
446
+ side && legendEl,
447
+ legendCfg?.position === "bottom" && legendEl,
448
+ !hasData && emptyState !== void 0 && typeof emptyState !== "string" && /* @__PURE__ */ jsx3("div", { style: { position: "absolute", inset: 0, display: "grid", placeItems: "center" }, children: emptyState }),
449
+ enableScrubbing && /* @__PURE__ */ jsx3("span", { "aria-live": "polite", style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }, children: focused && scrubIndex !== null ? a11yText(scrubIndex) : "" })
450
+ ]
314
451
  }
315
452
  );
316
- return /* @__PURE__ */ jsxs2("div", { ref: wrapRef, className, style: { position: "relative", width: fixedWidth ?? "100%", ...style }, children: [
317
- legendPos === "top" && legendEl,
318
- /* @__PURE__ */ jsx3(
319
- "svg",
320
- {
321
- width: width || "100%",
322
- height,
323
- role: "img",
324
- "aria-label": ariaLabel,
325
- style: { display: "block", overflow: "visible", touchAction: enableScrubbing ? "pan-y" : void 0, userSelect: "none" },
326
- onPointerMove: enableScrubbing ? onPointerMove : void 0,
327
- onPointerDown: enableScrubbing ? onPointerMove : void 0,
328
- onPointerLeave: enableScrubbing ? onPointerLeave : void 0,
329
- onPointerCancel: enableScrubbing ? onPointerLeave : void 0,
330
- children: width > 0 && /* @__PURE__ */ jsx3(CartesianContext.Provider, { value: ctx, children })
331
- }
332
- ),
333
- legendPos === "bottom" && legendEl
334
- ] });
335
453
  }
336
454
 
337
455
  // src/viz/axes.tsx
338
- import { sampleIndices, ticks as niceTicks } from "@funnest/charts-viz";
456
+ import { logTicks, sampleIndices, ticks as niceTicks } from "@funnest/charts-viz";
339
457
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
340
458
  function XAxis({
341
459
  position = "bottom",
@@ -373,14 +491,17 @@ function YAxis({
373
491
  width = 48,
374
492
  ticks,
375
493
  tickLabelFormatter = formatNumber,
494
+ axisId,
376
495
  showGrid = true,
377
496
  showLine = false,
378
497
  minTickSpacing = 32
379
498
  }) {
380
499
  const ctx = useCartesianChart();
381
- const { plot, y } = ctx;
500
+ const { plot } = ctx;
501
+ const y = scaleFor(ctx, axisId);
382
502
  const [d0, d1] = y.domain;
383
- const values = (ticks ?? niceTicks(d0, d1, Math.max(2, Math.floor(plot.height / minTickSpacing)))).filter(
503
+ const count = Math.max(2, Math.floor(plot.height / minTickSpacing));
504
+ const values = (ticks ?? (y.kind === "log" ? logTicks(d0, d1, count) : niceTicks(d0, d1, count))).filter(
384
505
  (v) => v >= Math.min(d0, d1) - 1e-9 && v <= Math.max(d0, d1) + 1e-9
385
506
  );
386
507
  const right = position === "right";
@@ -396,114 +517,230 @@ YAxis.axisSpace = (p) => ({ side: p.position ?? "right", size: p.width ?? 48 });
396
517
 
397
518
  // src/viz/line.tsx
398
519
  import { areaPath, linePath, segments } from "@funnest/charts-viz";
399
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
520
+ import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
400
521
  function seriesRuns(ctx, s, connectNulls = false) {
401
522
  const st = ctx.stacked?.get(s.id);
402
- const top = (i, v) => ctx.y(st ? st.y1[i] : v);
403
- const bottom = (i) => st ? ctx.y(st.y0[i]) : null;
523
+ const y = s.scale;
524
+ const top = (i, v) => y(st ? st.y1[i] : v);
525
+ const bottom = (i) => st ? y(st.y0[i]) : s.low ? y(s.low[i] ?? s.data[i]) : null;
404
526
  const runs = connectNulls ? [{ start: 0, values: s.data.map((v, i) => v === null ? null : i).filter((i) => i !== null) }] : segments(s.data).map((r) => ({ start: r.start, values: r.values.map((_, k) => r.start + k) }));
405
527
  return runs.filter((r) => r.values.length > 0).map((r) => {
406
528
  const idx = r.values;
407
529
  const upper = idx.map((i) => ({ x: ctx.x(i), y: top(i, s.data[i]) }));
408
- const lower = st ? idx.map((i) => ({ x: ctx.x(i), y: bottom(i) })) : null;
530
+ const lower = st || s.low ? idx.map((i) => ({ x: ctx.x(i), y: bottom(i) })) : null;
409
531
  return { upper, lower };
410
532
  });
411
533
  }
412
- function baselineY(ctx) {
413
- const [d0, d1] = ctx.y.domain;
534
+ function baselineY(ctx, scale, baseline) {
535
+ const { top, bottom } = ctx.plot;
536
+ if (baseline !== void 0) return Math.min(bottom, Math.max(top, scale(baseline)));
537
+ const [d0, d1] = scale.domain;
414
538
  const lo = Math.min(d0, d1);
415
539
  const hi = Math.max(d0, d1);
416
- return lo <= 0 && hi >= 0 ? ctx.y(0) : ctx.plot.bottom;
540
+ return scale.kind === "linear" && lo <= 0 && hi >= 0 ? scale(0) : bottom;
541
+ }
542
+ function SplitClips({ id, y, ctx }) {
543
+ const { left, right, top, bottom } = ctx.plot;
544
+ const pad = 20;
545
+ return /* @__PURE__ */ jsxs4("defs", { children: [
546
+ /* @__PURE__ */ jsx5("clipPath", { id: `${id}-above`, children: /* @__PURE__ */ jsx5("rect", { x: left - pad, y: top - pad, width: right - left + pad * 2, height: Math.max(0, y - top + pad) }) }),
547
+ /* @__PURE__ */ jsx5("clipPath", { id: `${id}-below`, children: /* @__PURE__ */ jsx5("rect", { x: left - pad, y, width: right - left + pad * 2, height: Math.max(0, bottom - y + pad) }) })
548
+ ] });
417
549
  }
418
- function Area({ seriesId, curve = "monotone", fill = "gradient", opacity, connectNulls }) {
550
+ function Area({ seriesId, curve = "monotone", fill = "gradient", opacity, connectNulls, baseline, splitColors, gradientStops }) {
419
551
  const ctx = useCartesianChart();
420
552
  const s = ctx.getSeries(seriesId);
421
553
  if (!s) return null;
422
554
  const gid = `${ctx.id}-area-${s.index}`;
423
- const base = baselineY(ctx);
424
- const alpha = opacity ?? (fill === "gradient" ? 0.24 : 0.16);
425
- return /* @__PURE__ */ jsxs4("g", { children: [
426
- fill === "gradient" && /* @__PURE__ */ jsx5("defs", { children: /* @__PURE__ */ jsxs4("linearGradient", { id: gid, x1: "0", y1: ctx.plot.top, x2: "0", y2: base, gradientUnits: "userSpaceOnUse", children: [
427
- /* @__PURE__ */ jsx5("stop", { offset: "0", stopOpacity: alpha, style: { stopColor: s.color } }),
428
- /* @__PURE__ */ jsx5("stop", { offset: "1", stopOpacity: 0, style: { stopColor: s.color } })
429
- ] }) }),
430
- seriesRuns(ctx, s, connectNulls).map((run, k) => /* @__PURE__ */ jsx5(
431
- "path",
432
- {
433
- d: areaPath(run.upper, run.lower ?? base, curve),
434
- fillOpacity: fill === "solid" ? alpha : void 0,
435
- stroke: "none",
436
- style: { fill: fill === "gradient" ? `url(#${gid})` : s.color },
437
- children: ctx.animate && /* @__PURE__ */ jsx5("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.6s", fill: "freeze" })
438
- },
439
- k
440
- ))
441
- ] });
555
+ const base = baselineY(ctx, s.scale, baseline);
556
+ const alpha = opacity ?? (fill === "gradient" ? 0.24 : fill === "dotted" ? 0.5 : 0.16);
557
+ const runs = seriesRuns(ctx, s, connectNulls);
558
+ const dim = s.dimmed ? DIM_OPACITY : 1;
559
+ const paint = (key, color, from, clip) => {
560
+ const fillId = `${gid}-paint-${key}`;
561
+ const stops = gradientStops ?? [
562
+ { offset: 0, opacity: alpha },
563
+ { offset: 1, opacity: 0 }
564
+ ];
565
+ return /* @__PURE__ */ jsxs4("g", { clipPath: clip ? `url(#${clip})` : void 0, opacity: dim, children: [
566
+ /* @__PURE__ */ jsxs4("defs", { children: [
567
+ fill === "gradient" && /* @__PURE__ */ jsx5("linearGradient", { id: fillId, x1: "0", y1: from, x2: "0", y2: base, gradientUnits: "userSpaceOnUse", children: stops.map((st, i) => /* @__PURE__ */ jsx5("stop", { offset: st.offset, stopOpacity: st.opacity, style: { stopColor: st.color ?? color } }, i)) }),
568
+ fill === "dotted" && /* @__PURE__ */ jsx5("pattern", { id: fillId, width: "6", height: "6", patternUnits: "userSpaceOnUse", children: /* @__PURE__ */ jsx5("circle", { cx: "1.5", cy: "1.5", r: "1.1", style: { fill: color } }) })
569
+ ] }),
570
+ runs.map((run, k) => /* @__PURE__ */ jsx5(
571
+ "path",
572
+ {
573
+ d: areaPath(run.upper, run.lower ?? base, curve),
574
+ fillOpacity: fill === "gradient" ? void 0 : alpha,
575
+ stroke: "none",
576
+ style: { fill: fill === "solid" ? color : `url(#${fillId})` },
577
+ children: ctx.animate && /* @__PURE__ */ jsx5("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.6s", fill: "freeze" })
578
+ },
579
+ k
580
+ ))
581
+ ] }, key);
582
+ };
583
+ if (splitColors && baseline !== void 0 && !run0IsStacked(ctx, s)) {
584
+ return /* @__PURE__ */ jsxs4("g", { mask: ctx.futureMask, children: [
585
+ /* @__PURE__ */ jsx5(SplitClips, { id: gid, y: base, ctx }),
586
+ paint("above", splitColors.above, ctx.plot.top, `${gid}-above`),
587
+ paint("below", splitColors.below, ctx.plot.bottom, `${gid}-below`)
588
+ ] });
589
+ }
590
+ return /* @__PURE__ */ jsx5("g", { mask: ctx.futureMask, children: paint("fill", s.color, ctx.plot.top) });
442
591
  }
592
+ var run0IsStacked = (ctx, s) => !!ctx.stacked?.get(s.id);
443
593
  function Line({
444
594
  seriesId,
445
595
  curve = "monotone",
446
596
  strokeWidth = 2,
597
+ lineStyle,
447
598
  dash,
448
599
  connectNulls,
449
600
  area = "none",
450
601
  areaOpacity,
451
- opacity
602
+ opacity,
603
+ points,
604
+ baseline,
605
+ splitColors
452
606
  }) {
453
607
  const ctx = useCartesianChart();
454
608
  const s = ctx.getSeries(seriesId);
455
609
  if (!s) return null;
456
- const drawIn = ctx.animate && !dash;
610
+ const style = lineStyle ?? s.lineStyle ?? "solid";
611
+ const dashArray = dash ?? dashFor(style, strokeWidth);
612
+ const drawIn = ctx.animate && !dashArray;
613
+ const runs = seriesRuns(ctx, s, connectNulls);
614
+ const split = splitColors && baseline !== void 0;
615
+ const gid = `${ctx.id}-line-${s.index}`;
616
+ const baseY = baselineY(ctx, s.scale, baseline);
617
+ const strokeOpacity = (opacity ?? 1) * (s.dimmed ? DIM_OPACITY : 1);
618
+ const strokes = (color, clip) => /* @__PURE__ */ jsx5("g", { clipPath: clip ? `url(#${clip})` : void 0, mask: ctx.futureMask, children: runs.map((run, k) => /* @__PURE__ */ jsx5(
619
+ "path",
620
+ {
621
+ d: linePath(run.upper, curve),
622
+ fill: "none",
623
+ strokeWidth,
624
+ strokeLinecap: "round",
625
+ strokeLinejoin: "round",
626
+ strokeDasharray: dashArray ?? (drawIn ? "1" : void 0),
627
+ strokeOpacity,
628
+ pathLength: drawIn ? 1 : void 0,
629
+ style: { stroke: color },
630
+ children: drawIn && /* @__PURE__ */ jsx5("animate", { attributeName: "stroke-dashoffset", from: "1", to: "0", dur: "0.8s", fill: "freeze", calcMode: "spline", keySplines: "0.3 0 0.2 1", keyTimes: "0;1" })
631
+ },
632
+ k
633
+ )) });
634
+ const dots = [];
635
+ if (points) {
636
+ const st = ctx.stacked?.get(s.id);
637
+ s.data.forEach((v, i) => {
638
+ if (v === null || v === void 0 || !Number.isFinite(v)) return;
639
+ const spec = points === true ? true : points(i, v);
640
+ if (!spec) return;
641
+ const p = spec === true ? {} : spec;
642
+ const x = ctx.x(i);
643
+ const y = s.scale(st ? st.y1[i] : v);
644
+ const color = p.color ?? (split ? v >= baseline ? splitColors.above : splitColors.below : s.color);
645
+ const r = p.radius ?? Math.max(3, strokeWidth + 1);
646
+ dots.push(
647
+ /* @__PURE__ */ jsxs4("g", { opacity: s.dimmed ? DIM_OPACITY : 1, children: [
648
+ /* @__PURE__ */ jsx5("circle", { cx: x, cy: y, r, strokeWidth: 2, style: { fill: color, stroke: VAR.bg } }),
649
+ p.label && /* @__PURE__ */ jsx5(
650
+ "text",
651
+ {
652
+ opacity: nearScrub(ctx, x) ? 0 : 1,
653
+ x,
654
+ y: p.labelPosition === "bottom" ? y + r + 13 : y - r - 6,
655
+ textAnchor: "middle",
656
+ fontSize: 11,
657
+ fontWeight: 600,
658
+ strokeWidth: 3,
659
+ strokeLinejoin: "round",
660
+ paintOrder: "stroke",
661
+ style: { fill: color, stroke: VAR.bg },
662
+ children: p.label
663
+ }
664
+ )
665
+ ] }, i)
666
+ );
667
+ });
668
+ }
457
669
  return /* @__PURE__ */ jsxs4("g", { children: [
458
- area !== "none" && /* @__PURE__ */ jsx5(Area, { seriesId, curve, fill: area, connectNulls: connectNulls ?? false, ...areaOpacity === void 0 ? {} : { opacity: areaOpacity } }),
459
- seriesRuns(ctx, s, connectNulls).map((run, k) => /* @__PURE__ */ jsx5(
460
- "path",
670
+ area !== "none" && /* @__PURE__ */ jsx5(
671
+ Area,
461
672
  {
462
- d: linePath(run.upper, curve),
463
- fill: "none",
464
- strokeWidth,
465
- strokeLinecap: "round",
466
- strokeLinejoin: "round",
467
- strokeDasharray: dash ?? (drawIn ? "1" : void 0),
468
- strokeOpacity: opacity,
469
- pathLength: drawIn ? 1 : void 0,
470
- style: { stroke: s.color },
471
- children: drawIn && /* @__PURE__ */ jsx5("animate", { attributeName: "stroke-dashoffset", from: "1", to: "0", dur: "0.8s", fill: "freeze", calcMode: "spline", keySplines: "0.3 0 0.2 1", keyTimes: "0;1" })
472
- },
473
- k
474
- ))
673
+ seriesId,
674
+ curve,
675
+ fill: area,
676
+ connectNulls: connectNulls ?? false,
677
+ ...areaOpacity === void 0 ? {} : { opacity: areaOpacity },
678
+ ...baseline === void 0 ? {} : { baseline },
679
+ ...splitColors ? { splitColors } : {}
680
+ }
681
+ ),
682
+ split ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
683
+ /* @__PURE__ */ jsx5(SplitClips, { id: gid, y: baseY, ctx }),
684
+ strokes(splitColors.above, `${gid}-above`),
685
+ strokes(splitColors.below, `${gid}-below`)
686
+ ] }) : strokes(s.color),
687
+ dots
475
688
  ] });
476
689
  }
477
690
 
478
691
  // src/viz/bar.tsx
479
692
  import { barPath } from "@funnest/charts-viz";
480
693
  import { jsx as jsx6 } from "react/jsx-runtime";
481
- function BarPlot({ seriesIds, radius = 3, groupGap = 2, getBarColor, highlightScrubbed = true }) {
694
+ function BarPlot({
695
+ seriesIds,
696
+ radius = 3,
697
+ roundBaseline = false,
698
+ groupGap = 2,
699
+ stackGap = 0,
700
+ minSize = 0,
701
+ getBarColor,
702
+ highlightScrubbed = true
703
+ }) {
482
704
  const ctx = useCartesianChart();
483
705
  const band = ctx.band;
484
706
  if (!band) return null;
485
707
  const list = seriesIds ? ctx.series.filter((s) => seriesIds.includes(s.id)) : ctx.series;
486
708
  const groups = ctx.stacked ? 1 : Math.max(1, list.length);
487
709
  const barW = Math.max(1, (band.bandwidth - groupGap * (groups - 1)) / groups);
488
- const zero = ctx.y(Math.min(Math.max(0, Math.min(...ctx.y.domain)), Math.max(...ctx.y.domain)));
710
+ const zeroOf = (scale) => {
711
+ const [d0, d1] = scale.domain;
712
+ return scale(Math.min(Math.max(0, Math.min(d0, d1)), Math.max(d0, d1)));
713
+ };
489
714
  const dim = highlightScrubbed && ctx.scrubIndex !== null;
490
715
  return /* @__PURE__ */ jsx6("g", { children: list.map((s, k) => {
491
716
  const st = ctx.stacked?.get(s.id);
717
+ const y = s.scale;
718
+ const zero = zeroOf(y);
492
719
  const offset = ctx.stacked ? 0 : k * (barW + groupGap);
493
720
  const isTopOfStack = (i, v) => !ctx.stacked || !list.slice(k + 1).some((o) => {
494
721
  const ov = o.data[i];
495
722
  return ov !== null && ov !== void 0 && ov !== 0 && Math.sign(ov) === Math.sign(v);
496
723
  });
497
- return /* @__PURE__ */ jsx6("g", { children: s.data.map((v, i) => {
498
- if (v === null || v === void 0 || !Number.isFinite(v)) return null;
724
+ return /* @__PURE__ */ jsx6("g", { opacity: s.dimmed ? DIM_OPACITY : 1, children: s.data.map((v, i) => {
725
+ if (v === null || v === void 0 || !Number.isFinite(v) || v === 0) return null;
499
726
  const x = band(i) + offset;
500
- const y0 = st ? ctx.y(st.y0[i]) : zero;
501
- const y1 = st ? ctx.y(st.y1[i]) : ctx.y(v);
727
+ const low = s.low?.[i];
728
+ let y0 = st ? y(st.y0[i]) : low !== null && low !== void 0 && s.low ? y(low) : zero;
729
+ let y1 = st ? y(st.y1[i]) : y(v);
730
+ const dir = Math.sign(y1 - y0) || -1;
731
+ const top = isTopOfStack(i, v);
732
+ const atBase = Math.abs(y0 - zero) <= 0.5 || !!s.low;
733
+ if (st && stackGap > 0) {
734
+ if (!atBase) y0 += dir * stackGap / 2;
735
+ if (!top) y1 -= dir * stackGap / 2;
736
+ }
737
+ if (minSize > 0 && Math.abs(y1 - y0) < minSize) y1 = y0 + dir * minSize;
502
738
  const color = getBarColor?.(v, i, s.id) ?? s.color;
739
+ const r = top ? radius : st && stackGap > 0 ? Math.min(radius, 2) : 0;
503
740
  return /* @__PURE__ */ jsx6(
504
741
  "path",
505
742
  {
506
- d: barPath(x, barW, y0, y1, isTopOfStack(i, v) ? radius : 0),
743
+ d: barPath(x, barW, y0, y1, s.low ? radius : r, roundBaseline && atBase || !!s.low),
507
744
  fillOpacity: dim && ctx.scrubIndex !== i ? 0.45 : 1,
508
745
  style: { fill: color },
509
746
  children: ctx.animate && /* @__PURE__ */ jsx6("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.5s", fill: "freeze" })
@@ -514,116 +751,360 @@ function BarPlot({ seriesIds, radius = 3, groupGap = 2, getBarColor, highlightSc
514
751
  }) });
515
752
  }
516
753
 
754
+ // src/viz/horizontal-bar.tsx
755
+ import { bandScale as bandScale2, barPath as barPath2, extent as extent2, linearScale as linearScale2, seriesColor as seriesColor2, stack as stack2, stackedExtent as stackedExtent2, ticks as niceTicks2 } from "@funnest/charts-viz";
756
+ import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState3 } from "react";
757
+ import { Fragment as Fragment3, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
758
+ var textWidth = (t, size = 12) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? size : size * 0.6), 0);
759
+ function HorizontalBarChart({
760
+ series,
761
+ categories,
762
+ width: fixedWidth,
763
+ height,
764
+ stacked = false,
765
+ radius = 3,
766
+ groupGap = 2,
767
+ stackGap = 0,
768
+ bandPadding = 0.3,
769
+ getBarColor,
770
+ showValueAxis = true,
771
+ showCategoryAxis = true,
772
+ valueFormatter = formatNumber,
773
+ enableScrubbing = true,
774
+ legend = false,
775
+ palette,
776
+ className,
777
+ style,
778
+ "aria-label": ariaLabel
779
+ }) {
780
+ const wrapRef = useRef3(null);
781
+ const [measured, setMeasured] = useState3(fixedWidth ?? 0);
782
+ const [hover, setHover] = useState3(null);
783
+ useEffect3(() => {
784
+ if (fixedWidth !== void 0) return setMeasured(fixedWidth);
785
+ const el = wrapRef.current;
786
+ if (!el || typeof ResizeObserver === "undefined") return;
787
+ const ro = new ResizeObserver(([e]) => e && setMeasured(Math.round(e.contentRect.width)));
788
+ ro.observe(el);
789
+ setMeasured(Math.round(el.getBoundingClientRect().width));
790
+ return () => ro.disconnect();
791
+ }, [fixedWidth]);
792
+ const resolved = useMemo2(
793
+ () => series.map((s, index) => ({
794
+ ...s,
795
+ color: s.color ?? seriesColor2(index, palette),
796
+ high: s.data.map((v) => Array.isArray(v) ? Math.max(v[0], v[1]) : v),
797
+ low: s.data.some((v) => Array.isArray(v)) ? s.data.map((v) => Array.isArray(v) ? Math.min(v[0], v[1]) : v) : null
798
+ })),
799
+ [series, palette]
800
+ );
801
+ const rows = Math.max(categories?.length ?? 0, ...resolved.map((s) => s.high.length));
802
+ const h = height ?? rows * 34 + (showValueAxis ? 26 : 8);
803
+ const w = measured;
804
+ const labelW = showCategoryAxis ? Math.min(140, Math.max(0, ...(categories ?? []).map((c) => textWidth(c))) + 12) : 0;
805
+ const left = labelW + 4;
806
+ const right = Math.max(left + 10, w - 12);
807
+ const top = 4;
808
+ const bottom = h - (showValueAxis ? 24 : 4);
809
+ const stackedMap = stacked ? new Map(stack2(resolved.map((s) => ({ id: s.id, data: s.high }))).map((st) => [st.id, st])) : null;
810
+ let [min, max] = stacked ? stackedExtent2([...stackedMap.values()]) : extent2([...resolved.map((s) => ({ id: s.id, data: s.high })), ...resolved.flatMap((s) => s.low ? [{ id: `${s.id}~`, data: s.low }] : [])]) ?? [0, 1];
811
+ min = Math.min(min, 0);
812
+ max = Math.max(max, 0);
813
+ const xs = linearScale2([min, max], [left, right]);
814
+ const band = bandScale2(rows, [top, bottom], bandPadding);
815
+ const zero = xs(0);
816
+ const groups = stacked ? 1 : Math.max(1, resolved.length);
817
+ const barH = Math.max(1, (band.bandwidth - groupGap * (groups - 1)) / groups);
818
+ const tickValues = niceTicks2(min, max, Math.max(2, Math.floor((right - left) / 80)));
819
+ const onMove = (e) => {
820
+ const r = e.currentTarget.getBoundingClientRect();
821
+ setHover(band.invert(e.clientY - r.top));
822
+ };
823
+ const tipRows = hover === null ? [] : resolved.flatMap((s) => {
824
+ const v = s.high[hover];
825
+ if (v === null || v === void 0) return [];
826
+ const lo = s.low?.[hover];
827
+ return [{ s, color: getBarColor?.(v, hover, s.id) ?? s.color, text: lo !== null && lo !== void 0 && lo !== v ? `${valueFormatter(lo)} \u2013 ${valueFormatter(v)}` : valueFormatter(v) }];
828
+ });
829
+ const tipW = Math.max(0, ...tipRows.map((r) => textWidth(r.text) + textWidth(r.s.label ?? r.s.id) + 30)) + 8;
830
+ const tipH = tipRows.length * 18 + 14;
831
+ const rowY = hover === null ? 0 : band.center(hover);
832
+ const tipY = Math.min(Math.max(top, rowY - tipH / 2), bottom - tipH);
833
+ return /* @__PURE__ */ jsxs5("div", { ref: wrapRef, className, style: { position: "relative", width: fixedWidth ?? "100%", fontFamily: VAR.font, ...style }, children: [
834
+ legend && /* @__PURE__ */ jsx7(
835
+ Legend,
836
+ {
837
+ ...typeof legend === "object" && legend.shape ? { shape: legend.shape } : {},
838
+ items: resolved.map((s) => ({ id: s.id, label: s.label ?? s.id, color: s.color }))
839
+ }
840
+ ),
841
+ /* @__PURE__ */ jsx7(
842
+ "svg",
843
+ {
844
+ width: w || "100%",
845
+ height: h,
846
+ role: "img",
847
+ "aria-label": ariaLabel,
848
+ style: { display: "block", overflow: "visible", userSelect: "none" },
849
+ onPointerMove: enableScrubbing ? onMove : void 0,
850
+ onPointerLeave: enableScrubbing ? () => setHover(null) : void 0,
851
+ children: w > 0 && /* @__PURE__ */ jsxs5(Fragment3, { children: [
852
+ tickValues.map((t) => /* @__PURE__ */ jsx7("line", { x1: xs(t), x2: xs(t), y1: top, y2: bottom, shapeRendering: "crispEdges", style: { stroke: VAR.grid } }, `g${t}`)),
853
+ min < 0 && /* @__PURE__ */ jsx7("line", { x1: zero, x2: zero, y1: top, y2: bottom, shapeRendering: "crispEdges", style: { stroke: VAR.line } }),
854
+ hover !== null && /* @__PURE__ */ jsx7("rect", { x: left - 4, y: band.center(hover) - band.step / 2, width: right - left + 8, height: band.step, rx: 6, style: { fill: VAR.grid } }),
855
+ resolved.map((s, k) => {
856
+ const st = stackedMap?.get(s.id);
857
+ return /* @__PURE__ */ jsx7("g", { children: s.high.map((v, i) => {
858
+ if (v === null || v === void 0 || !Number.isFinite(v) || v === 0) return null;
859
+ const yTop = band(i) + (stacked ? 0 : k * (barH + groupGap));
860
+ const lo = s.low?.[i];
861
+ let x0 = st ? xs(st.y0[i]) : lo !== null && lo !== void 0 && s.low ? xs(lo) : zero;
862
+ let x1 = st ? xs(st.y1[i]) : xs(v);
863
+ const isTop = !stacked || !resolved.slice(k + 1).some((o) => (o.high[i] ?? 0) !== 0 && Math.sign(o.high[i]) === Math.sign(v));
864
+ const dir = Math.sign(x1 - x0) || 1;
865
+ if (st && stackGap > 0) {
866
+ if (Math.abs(x0 - zero) > 0.5) x0 += dir * stackGap / 2;
867
+ if (!isTop) x1 -= dir * stackGap / 2;
868
+ }
869
+ const color = getBarColor?.(v, i, s.id) ?? s.color;
870
+ const d = barPath2(yTop, barH, x0, x1, isTop || s.low ? radius : 0, !!s.low);
871
+ return /* @__PURE__ */ jsx7(
872
+ "path",
873
+ {
874
+ d,
875
+ transform: `matrix(0 1 1 0 0 0)`,
876
+ opacity: hover !== null && hover !== i ? DIM_OPACITY * 2 : 1,
877
+ style: { fill: color }
878
+ },
879
+ i
880
+ );
881
+ }) }, s.id);
882
+ }),
883
+ showCategoryAxis && (categories ?? []).map((c, i) => /* @__PURE__ */ jsx7("text", { x: labelW, y: band.center(i), textAnchor: "end", dominantBaseline: "central", fontSize: 12, style: { fill: hover === i ? VAR.textStrong : VAR.text, fontWeight: hover === i ? 700 : 500 }, children: c }, c + i)),
884
+ showValueAxis && tickValues.map((t) => /* @__PURE__ */ jsx7("text", { x: xs(t), y: h - 6, textAnchor: "middle", fontSize: 11, style: { fill: VAR.text }, children: valueFormatter(t) }, t)),
885
+ tipRows.length > 0 && /* @__PURE__ */ jsxs5("g", { transform: `translate(${Math.min(right - tipW, Math.max(left, xs(max) - tipW))},${tipY})`, pointerEvents: "none", children: [
886
+ /* @__PURE__ */ jsx7("rect", { width: tipW, height: tipH, rx: 8, strokeWidth: 1, style: { fill: VAR.bg, stroke: VAR.grid } }),
887
+ tipRows.map((r, k) => /* @__PURE__ */ jsxs5("g", { transform: `translate(10,${7 + 9 + k * 18})`, children: [
888
+ /* @__PURE__ */ jsx7("circle", { cx: 3, cy: 0, r: 3, style: { fill: r.color } }),
889
+ /* @__PURE__ */ jsx7("text", { x: 12, dominantBaseline: "central", fontSize: 12, style: { fill: VAR.text }, children: r.s.label ?? r.s.id }),
890
+ /* @__PURE__ */ jsx7("text", { x: tipW - 20, textAnchor: "end", dominantBaseline: "central", fontSize: 12, fontWeight: 700, style: { fill: VAR.textStrong, fontVariantNumeric: "tabular-nums" }, children: r.text })
891
+ ] }, r.s.id))
892
+ ] })
893
+ ] })
894
+ }
895
+ )
896
+ ] });
897
+ }
898
+
517
899
  // src/viz/scrubber.tsx
518
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
900
+ import { useEffect as useEffect4 } from "react";
901
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
519
902
  var TIP_FONT = 12;
520
903
  var ROW = 18;
521
904
  var TIP_PAD = 7;
522
- var textWidth = (t) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? TIP_FONT : TIP_FONT * 0.6), 0);
523
- function Scrubber({ label, seriesIds, dimFuture = true, showValues = false, valueFormatter = formatNumber }) {
905
+ var textWidth2 = (t) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? TIP_FONT : TIP_FONT * 0.6), 0);
906
+ function Scrubber({
907
+ label,
908
+ seriesIds,
909
+ dimFuture = true,
910
+ hideOverlay = false,
911
+ idlePulse = false,
912
+ beaconLabels = false,
913
+ beaconColor,
914
+ showValues = false,
915
+ valueFormatter = formatNumber
916
+ }) {
524
917
  const ctx = useCartesianChart();
525
918
  const i = ctx.scrubIndex;
526
- if (i === null || i >= ctx.length) return null;
919
+ const { setDimFuture } = ctx;
920
+ const fade = dimFuture && !hideOverlay;
921
+ useEffect4(() => {
922
+ setDimFuture(fade);
923
+ return () => setDimFuture(false);
924
+ }, [fade, setDimFuture]);
925
+ const pick = seriesIds ? ctx.series.filter((s) => seriesIds.includes(s.id)) : ctx.series;
926
+ const colorOf = (s, v) => beaconColor?.(v, s.id) ?? s.color;
927
+ if (i === null || i >= ctx.length) {
928
+ if (!idlePulse || ctx.band) return null;
929
+ return /* @__PURE__ */ jsx8("g", { pointerEvents: "none", children: pick.map((s) => {
930
+ let last = s.data.length - 1;
931
+ while (last >= 0 && (s.data[last] === null || s.data[last] === void 0)) last--;
932
+ if (last < 0) return null;
933
+ const y = seriesY(ctx, s, last);
934
+ if (y === null) return null;
935
+ const x2 = ctx.x(last);
936
+ const color = colorOf(s, s.data[last]);
937
+ return /* @__PURE__ */ jsxs6("g", { children: [
938
+ /* @__PURE__ */ jsxs6("circle", { cx: x2, cy: y, r: 4, fillOpacity: 0.35, style: { fill: color }, children: [
939
+ /* @__PURE__ */ jsx8("animate", { attributeName: "r", values: "4;13", dur: "1.6s", repeatCount: "indefinite" }),
940
+ /* @__PURE__ */ jsx8("animate", { attributeName: "fill-opacity", values: "0.35;0", dur: "1.6s", repeatCount: "indefinite" })
941
+ ] }),
942
+ /* @__PURE__ */ jsx8("circle", { cx: x2, cy: y, r: 4, strokeWidth: 2, style: { fill: color, stroke: VAR.bg } })
943
+ ] }, s.id);
944
+ }) });
945
+ }
527
946
  const { plot } = ctx;
528
947
  const x = ctx.x(i);
529
948
  const text = label ? label(i) : ctx.xLabel(i);
530
- const list = seriesIds ? ctx.series.filter((s) => seriesIds.includes(s.id)) : ctx.series;
949
+ const list = pick;
531
950
  const textX = Math.min(Math.max(x, plot.left + 30), plot.right - 30);
532
951
  const rows = showValues ? list.flatMap((s) => {
533
952
  const v = s.data[i];
534
- return v === null || v === void 0 || !Number.isFinite(v) ? [] : [{ s, text: valueFormatter(v, s.id) }];
953
+ if (v === null || v === void 0 || !Number.isFinite(v)) return [];
954
+ const lo = s.low?.[i];
955
+ const text2 = lo !== null && lo !== void 0 && lo !== v ? `${valueFormatter(lo, s.id)} \u2013 ${valueFormatter(v, s.id)}` : valueFormatter(v, s.id);
956
+ return [{ s, text: text2 }];
535
957
  }) : [];
536
- const tipW = Math.max(0, ...rows.map((r) => textWidth(r.text) + (rows.length > 1 || r.s.label ? textWidth(r.s.label ?? "") + 26 : 18))) + 16;
958
+ const tipW = Math.max(0, ...rows.map((r) => textWidth2(r.text) + (rows.length > 1 || r.s.label ? textWidth2(r.s.label ?? "") + 26 : 18))) + 16;
537
959
  const tipH = rows.length * ROW + TIP_PAD * 2;
538
960
  const tipRight = x + 12 + tipW <= plot.right;
539
961
  const tipX = tipRight ? x + 12 : x - 12 - tipW;
540
962
  const tipY = plot.top + 4;
541
- return /* @__PURE__ */ jsxs5("g", { pointerEvents: "none", children: [
542
- dimFuture && !ctx.band && /* @__PURE__ */ jsx7("rect", { x, y: plot.top, width: Math.max(0, plot.right - x + 2), height: plot.height, fillOpacity: 0.55, style: { fill: VAR.bg } }),
543
- /* @__PURE__ */ jsx7("line", { x1: x, x2: x, y1: plot.top, y2: plot.bottom, strokeWidth: 1, shapeRendering: "crispEdges", style: { stroke: VAR.line } }),
544
- text && /* @__PURE__ */ jsx7("text", { x: textX, y: Math.max(11, plot.top - 2), textAnchor: "middle", fontSize: 11, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill: VAR.textStrong }, children: text }),
963
+ return /* @__PURE__ */ jsxs6("g", { pointerEvents: "none", children: [
964
+ /* @__PURE__ */ jsx8("line", { x1: x, x2: x, y1: plot.top, y2: plot.bottom, strokeWidth: 1, shapeRendering: "crispEdges", style: { stroke: VAR.line } }),
965
+ text && /* @__PURE__ */ jsx8("text", { x: textX, y: Math.max(11, plot.top - 2), textAnchor: "middle", fontSize: 11, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill: VAR.textStrong }, children: text }),
545
966
  !ctx.band && list.map((s) => {
546
967
  const y = seriesY(ctx, s, i);
547
968
  if (y === null) return null;
548
- return /* @__PURE__ */ jsxs5("g", { children: [
549
- /* @__PURE__ */ jsx7("circle", { cx: x, cy: y, r: 7, fillOpacity: 0.18, style: { fill: s.color } }),
550
- /* @__PURE__ */ jsx7("circle", { cx: x, cy: y, r: 4, strokeWidth: 2, style: { fill: s.color, stroke: VAR.bg } })
969
+ const v = s.data[i];
970
+ const color = colorOf(s, v);
971
+ const text2 = beaconLabels ? typeof beaconLabels === "function" ? beaconLabels(v, s.id) : valueFormatter(v, s.id) : "";
972
+ const right = x + 12 + textWidth2(text2) <= plot.right;
973
+ return /* @__PURE__ */ jsxs6("g", { children: [
974
+ /* @__PURE__ */ jsx8("circle", { cx: x, cy: y, r: 7, fillOpacity: 0.18, style: { fill: color } }),
975
+ /* @__PURE__ */ jsx8("circle", { cx: x, cy: y, r: 4, strokeWidth: 2, style: { fill: color, stroke: VAR.bg } }),
976
+ text2 && /* @__PURE__ */ jsx8(
977
+ "text",
978
+ {
979
+ x: right ? x + 10 : x - 10,
980
+ y,
981
+ textAnchor: right ? "start" : "end",
982
+ dominantBaseline: "central",
983
+ fontSize: TIP_FONT,
984
+ fontWeight: 700,
985
+ strokeWidth: 3,
986
+ strokeLinejoin: "round",
987
+ paintOrder: "stroke",
988
+ style: { fill: color, stroke: VAR.bg, fontVariantNumeric: "tabular-nums" },
989
+ children: text2
990
+ }
991
+ )
551
992
  ] }, s.id);
552
993
  }),
553
- rows.length > 0 && /* @__PURE__ */ jsxs5("g", { transform: `translate(${tipX},${tipY})`, children: [
554
- /* @__PURE__ */ jsx7("rect", { width: tipW, height: tipH, rx: 8, style: { fill: VAR.bg, stroke: VAR.grid }, strokeWidth: 1 }),
555
- rows.map((r, k) => /* @__PURE__ */ jsxs5("g", { transform: `translate(10,${TIP_PAD + ROW / 2 + k * ROW})`, children: [
556
- /* @__PURE__ */ jsx7("circle", { cx: 3, cy: 0, r: 3, style: { fill: r.s.color } }),
557
- (rows.length > 1 || r.s.label) && /* @__PURE__ */ jsx7("text", { x: 12, dominantBaseline: "central", fontSize: TIP_FONT, style: { fill: VAR.text }, children: r.s.label ?? r.s.id }),
558
- /* @__PURE__ */ jsx7("text", { x: tipW - 20, textAnchor: "end", dominantBaseline: "central", fontSize: TIP_FONT, fontWeight: 700, style: { fill: VAR.textStrong, fontVariantNumeric: "tabular-nums" }, children: r.text })
994
+ rows.length > 0 && /* @__PURE__ */ jsxs6("g", { transform: `translate(${tipX},${tipY})`, children: [
995
+ /* @__PURE__ */ jsx8("rect", { width: tipW, height: tipH, rx: 8, style: { fill: VAR.bg, stroke: VAR.grid }, strokeWidth: 1 }),
996
+ rows.map((r, k) => /* @__PURE__ */ jsxs6("g", { transform: `translate(10,${TIP_PAD + ROW / 2 + k * ROW})`, children: [
997
+ /* @__PURE__ */ jsx8("circle", { cx: 3, cy: 0, r: 3, style: { fill: r.s.color } }),
998
+ (rows.length > 1 || r.s.label) && /* @__PURE__ */ jsx8("text", { x: 12, dominantBaseline: "central", fontSize: TIP_FONT, style: { fill: VAR.text }, children: r.s.label ?? r.s.id }),
999
+ /* @__PURE__ */ jsx8("text", { x: tipW - 20, textAnchor: "end", dominantBaseline: "central", fontSize: TIP_FONT, fontWeight: 700, style: { fill: VAR.textStrong, fontVariantNumeric: "tabular-nums" }, children: r.text })
559
1000
  ] }, r.s.id))
560
1001
  ] })
561
1002
  ] });
562
1003
  }
563
1004
 
564
1005
  // src/viz/annotations.tsx
565
- import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
566
- function ReferenceLine({ dataY, dataX, label, labelPosition, color = VAR.line, dash = "4 4", strokeWidth = 1 }) {
1006
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1007
+ var LABEL_FONT = 11;
1008
+ var labelWidth = (t) => [...t].reduce((w, ch) => w + (ch.charCodeAt(0) > 11904 ? LABEL_FONT : LABEL_FONT * 0.62), 0);
1009
+ function LineLabel(props) {
1010
+ const { x, y, text, anchor, color, pill } = props;
1011
+ const textColor = color === VAR.line ? VAR.text : color;
1012
+ if (!pill) {
1013
+ return /* @__PURE__ */ jsx9("text", { x, y, textAnchor: anchor, dominantBaseline: "central", fontSize: LABEL_FONT, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill: textColor }, children: text });
1014
+ }
1015
+ const w = labelWidth(text) + 14;
1016
+ const left = anchor === "start" ? x : anchor === "end" ? x - w : x - w / 2;
1017
+ return /* @__PURE__ */ jsxs7("g", { children: [
1018
+ /* @__PURE__ */ jsx9("rect", { x: left, y: y - 10, width: w, height: 20, rx: 10, style: { fill: color === VAR.line ? VAR.textStrong : color } }),
1019
+ /* @__PURE__ */ jsx9("text", { x: left + w / 2, y, textAnchor: "middle", dominantBaseline: "central", fontSize: LABEL_FONT, fontWeight: 700, style: { fill: VAR.bg }, children: text })
1020
+ ] });
1021
+ }
1022
+ function ReferenceLine({
1023
+ dataY,
1024
+ dataX,
1025
+ yAxisId,
1026
+ label,
1027
+ labelPosition,
1028
+ labelAlign = "above",
1029
+ labelStyle = "text",
1030
+ labelDx = 0,
1031
+ labelDy = 0,
1032
+ color = VAR.line,
1033
+ dash = "4 4",
1034
+ strokeWidth = 1
1035
+ }) {
567
1036
  const ctx = useCartesianChart();
568
1037
  const { plot } = ctx;
1038
+ const pill = labelStyle === "pill";
569
1039
  if (dataY !== void 0) {
570
- const y = ctx.y(dataY);
1040
+ const y = scaleFor(ctx, yAxisId)(dataY);
571
1041
  if (y < plot.top - 0.5 || y > plot.bottom + 0.5) return null;
572
1042
  const pos = labelPosition ?? "right";
573
1043
  const tx = pos === "left" ? plot.left + 4 : pos === "center" ? (plot.left + plot.right) / 2 : plot.right - 4;
574
1044
  const anchor = pos === "left" ? "start" : pos === "center" ? "middle" : "end";
575
- return /* @__PURE__ */ jsxs6("g", { pointerEvents: "none", children: [
576
- /* @__PURE__ */ jsx8("line", { x1: plot.left, x2: plot.right, y1: y, y2: y, strokeWidth, strokeDasharray: dash || void 0, shapeRendering: "crispEdges", style: { stroke: color } }),
577
- label !== void 0 && /* @__PURE__ */ jsx8("text", { x: tx, y: y - 5, textAnchor: anchor, fontSize: 11, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill: color === VAR.line ? VAR.text : color }, children: label })
1045
+ const off = pill ? 13 : 9;
1046
+ const ly = labelAlign === "center" ? y : labelAlign === "below" ? y + off : y - off;
1047
+ return /* @__PURE__ */ jsxs7("g", { pointerEvents: "none", children: [
1048
+ /* @__PURE__ */ jsx9("line", { x1: plot.left, x2: plot.right, y1: y, y2: y, strokeWidth, strokeDasharray: dash || void 0, shapeRendering: "crispEdges", style: { stroke: color } }),
1049
+ label !== void 0 && /* @__PURE__ */ jsx9(LineLabel, { x: tx + labelDx, y: ly + labelDy, text: label, anchor, color, pill })
578
1050
  ] });
579
1051
  }
580
1052
  if (dataX !== void 0) {
581
1053
  const x = ctx.x(dataX);
582
1054
  const pos = labelPosition ?? "top";
583
- return /* @__PURE__ */ jsxs6("g", { pointerEvents: "none", children: [
584
- /* @__PURE__ */ jsx8("line", { x1: x, x2: x, y1: plot.top, y2: plot.bottom, strokeWidth, strokeDasharray: dash || void 0, shapeRendering: "crispEdges", style: { stroke: color } }),
585
- label !== void 0 && /* @__PURE__ */ jsx8(
586
- "text",
1055
+ return /* @__PURE__ */ jsxs7("g", { pointerEvents: "none", children: [
1056
+ /* @__PURE__ */ jsx9("line", { x1: x, x2: x, y1: plot.top, y2: plot.bottom, strokeWidth, strokeDasharray: dash || void 0, shapeRendering: "crispEdges", style: { stroke: color } }),
1057
+ label !== void 0 && /* @__PURE__ */ jsx9(
1058
+ LineLabel,
587
1059
  {
588
- x: x + 4,
589
- y: pos === "bottom" ? plot.bottom - 4 : plot.top + 11,
590
- fontSize: 11,
591
- fontWeight: 600,
592
- strokeWidth: 3,
593
- strokeLinejoin: "round",
594
- paintOrder: "stroke",
595
- style: { stroke: VAR.bg, fill: color === VAR.line ? VAR.text : color },
596
- children: label
1060
+ x: x + (pill ? 0 : 4) + labelDx,
1061
+ y: (pos === "bottom" ? plot.bottom - 10 : plot.top + 10) + labelDy,
1062
+ text: label,
1063
+ anchor: pill ? "middle" : "start",
1064
+ color,
1065
+ pill
597
1066
  }
598
1067
  )
599
1068
  ] });
600
1069
  }
601
1070
  return null;
602
1071
  }
603
- function Point({ dataX, dataY, seriesId, color, radius = 4, label, labelPosition = "top", pulse = false, onClick }) {
1072
+ function Point({ dataX, dataY, seriesId, yAxisId, color, radius = 4, label, labelPosition = "top", pulse = false, onClick }) {
604
1073
  const ctx = useCartesianChart();
605
1074
  const s = seriesId ? ctx.getSeries(seriesId) : void 0;
606
1075
  const value = dataY ?? (s ? s.data[dataX] : void 0);
607
1076
  if (value === null || value === void 0 || !Number.isFinite(value)) return null;
608
1077
  const st = s ? ctx.stacked?.get(s.id) : void 0;
609
1078
  const x = ctx.x(dataX);
610
- const y = ctx.y(st && dataY === void 0 ? st.y1[dataX] : value);
1079
+ const y = (s?.scale ?? scaleFor(ctx, yAxisId))(st && dataY === void 0 ? st.y1[dataX] : value);
611
1080
  const fill = color ?? s?.color ?? ctx.series[0]?.color ?? "currentColor";
612
1081
  const gap = radius + 6;
613
1082
  const [lx, ly, anchor] = labelPosition === "bottom" ? [x, y + gap + 8, "middle"] : labelPosition === "left" ? [x - gap, y + 4, "end"] : labelPosition === "right" ? [x + gap, y + 4, "start"] : [x, y - gap, "middle"];
614
- return /* @__PURE__ */ jsxs6("g", { style: onClick ? { cursor: "pointer" } : void 0, onClick, pointerEvents: onClick ? "auto" : "none", children: [
615
- pulse && /* @__PURE__ */ jsxs6("circle", { cx: x, cy: y, r: radius, fillOpacity: 0.35, style: { fill }, children: [
616
- /* @__PURE__ */ jsx8("animate", { attributeName: "r", values: `${radius};${radius * 3.2}`, dur: "1.6s", repeatCount: "indefinite" }),
617
- /* @__PURE__ */ jsx8("animate", { attributeName: "fill-opacity", values: "0.35;0", dur: "1.6s", repeatCount: "indefinite" })
1083
+ return /* @__PURE__ */ jsxs7("g", { style: onClick ? { cursor: "pointer" } : void 0, onClick, pointerEvents: onClick ? "auto" : "none", children: [
1084
+ pulse && /* @__PURE__ */ jsxs7("circle", { cx: x, cy: y, r: radius, fillOpacity: 0.35, style: { fill }, children: [
1085
+ /* @__PURE__ */ jsx9("animate", { attributeName: "r", values: `${radius};${radius * 3.2}`, dur: "1.6s", repeatCount: "indefinite" }),
1086
+ /* @__PURE__ */ jsx9("animate", { attributeName: "fill-opacity", values: "0.35;0", dur: "1.6s", repeatCount: "indefinite" })
618
1087
  ] }),
619
- /* @__PURE__ */ jsx8("circle", { cx: x, cy: y, r: radius, strokeWidth: 2, style: { fill, stroke: VAR.bg } }),
620
- label !== void 0 && /* @__PURE__ */ jsx8("text", { x: lx, y: ly, textAnchor: anchor, fontSize: 11, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill }, children: label })
1088
+ /* @__PURE__ */ jsx9("circle", { cx: x, cy: y, r: radius, strokeWidth: 2, style: { fill, stroke: VAR.bg } }),
1089
+ label !== void 0 && /* @__PURE__ */ jsx9("text", { opacity: nearScrub(ctx, x) ? 0 : 1, x: lx, y: ly, textAnchor: anchor, fontSize: 11, fontWeight: 600, strokeWidth: 3, strokeLinejoin: "round", paintOrder: "stroke", style: { stroke: VAR.bg, fill }, children: label })
621
1090
  ] });
622
1091
  }
623
1092
 
624
1093
  // src/viz/period-selector.tsx
625
- import { useLayoutEffect, useRef as useRef3, useState as useState3 } from "react";
626
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1094
+ import { useLayoutEffect, useRef as useRef4, useState as useState4 } from "react";
1095
+ import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
1096
+ function LiveTabLabel({ label, color = "var(--fnv-live, #F04452)" }) {
1097
+ return /* @__PURE__ */ jsxs8("span", { style: { display: "inline-flex", alignItems: "center", gap: 6 }, children: [
1098
+ /* @__PURE__ */ jsxs8("span", { "aria-hidden": "true", style: { position: "relative", width: 7, height: 7 }, children: [
1099
+ /* @__PURE__ */ jsx10("span", { style: { position: "absolute", inset: 0, borderRadius: "50%", background: color } }),
1100
+ /* @__PURE__ */ jsx10("svg", { width: "7", height: "7", viewBox: "0 0 7 7", style: { position: "absolute", inset: 0, overflow: "visible" }, children: /* @__PURE__ */ jsxs8("circle", { cx: "3.5", cy: "3.5", r: "3.5", style: { fill: color }, fillOpacity: 0.45, children: [
1101
+ /* @__PURE__ */ jsx10("animate", { attributeName: "r", values: "3.5;8", dur: "1.6s", repeatCount: "indefinite" }),
1102
+ /* @__PURE__ */ jsx10("animate", { attributeName: "fill-opacity", values: "0.45;0", dur: "1.6s", repeatCount: "indefinite" })
1103
+ ] }) })
1104
+ ] }),
1105
+ label
1106
+ ] });
1107
+ }
627
1108
  var DEFAULT_PERIODS = [
628
1109
  { id: "1D", label: "1\uC77C" },
629
1110
  { id: "1W", label: "1\uC8FC" },
@@ -637,13 +1118,16 @@ function PeriodSelector({
637
1118
  value,
638
1119
  onChange,
639
1120
  activeColor = "var(--fnv-accent, #6C4DFF)",
1121
+ liveColor = "var(--fnv-live, #F04452)",
640
1122
  fullWidth = false,
641
1123
  className,
642
1124
  style,
643
1125
  "aria-label": ariaLabel = "Period"
644
1126
  }) {
645
- const listRef = useRef3(null);
646
- const [pill, setPill] = useState3(null);
1127
+ const activeIsLive = periods.find((p) => p.id === value)?.live ?? false;
1128
+ const pillColor = activeIsLive ? liveColor : activeColor;
1129
+ const listRef = useRef4(null);
1130
+ const [pill, setPill] = useState4(null);
647
1131
  useLayoutEffect(() => {
648
1132
  const list = listRef.current;
649
1133
  const active = list?.querySelector('[aria-selected="true"]');
@@ -665,7 +1149,7 @@ function PeriodSelector({
665
1149
  onChange(p.id);
666
1150
  listRef.current?.querySelector(`[data-period="${CSS.escape(p.id)}"]`)?.focus();
667
1151
  };
668
- return /* @__PURE__ */ jsxs7(
1152
+ return /* @__PURE__ */ jsxs8(
669
1153
  "div",
670
1154
  {
671
1155
  ref: listRef,
@@ -675,7 +1159,7 @@ function PeriodSelector({
675
1159
  onKeyDown,
676
1160
  style: { position: "relative", display: fullWidth ? "flex" : "inline-flex", gap: 2, fontFamily: VAR.font, ...style },
677
1161
  children: [
678
- pill && /* @__PURE__ */ jsx9(
1162
+ pill && /* @__PURE__ */ jsx10(
679
1163
  "span",
680
1164
  {
681
1165
  "aria-hidden": "true",
@@ -686,7 +1170,7 @@ function PeriodSelector({
686
1170
  left: pill.left,
687
1171
  width: pill.width,
688
1172
  borderRadius: 999,
689
- background: activeColor,
1173
+ background: pillColor,
690
1174
  opacity: 0.12,
691
1175
  transition: "left 0.2s ease, width 0.2s ease"
692
1176
  }
@@ -694,7 +1178,7 @@ function PeriodSelector({
694
1178
  ),
695
1179
  periods.map((p) => {
696
1180
  const active = p.id === value;
697
- return /* @__PURE__ */ jsx9(
1181
+ return /* @__PURE__ */ jsx10(
698
1182
  "button",
699
1183
  {
700
1184
  type: "button",
@@ -712,13 +1196,13 @@ function PeriodSelector({
712
1196
  border: 0,
713
1197
  borderRadius: 999,
714
1198
  background: "transparent",
715
- color: active ? activeColor : VAR.text,
1199
+ color: active ? p.live ? liveColor : activeColor : VAR.text,
716
1200
  font: "inherit",
717
1201
  fontSize: 13,
718
1202
  fontWeight: active ? 700 : 500,
719
1203
  cursor: "pointer"
720
1204
  },
721
- children: p.label
1205
+ children: p.live ? /* @__PURE__ */ jsx10(LiveTabLabel, { label: p.label, color: liveColor }) : p.label
722
1206
  },
723
1207
  p.id
724
1208
  );
@@ -729,103 +1213,253 @@ function PeriodSelector({
729
1213
  }
730
1214
 
731
1215
  // src/viz/charts.tsx
732
- import { percentages, seriesColor as seriesColor2 } from "@funnest/charts-viz";
733
- import { useState as useState4 } from "react";
734
- import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
1216
+ import { percentages, seriesColor as seriesColor3 } from "@funnest/charts-viz";
1217
+ import { useState as useState5 } from "react";
1218
+ import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
735
1219
  var axisProps = (v) => v ? v === true ? {} : v : null;
736
1220
  var axisElements = (x, y) => [
737
- y && /* @__PURE__ */ jsx10(YAxis, { ...y }, "y"),
738
- x && /* @__PURE__ */ jsx10(XAxis, { ...x }, "x")
1221
+ y && /* @__PURE__ */ jsx11(YAxis, { ...y }, "y"),
1222
+ x && /* @__PURE__ */ jsx11(XAxis, { ...x }, "x")
739
1223
  ];
740
- function LineChart({ showXAxis, showYAxis, scrubber, curve, strokeWidth, area, connectNulls, children, ...chart }) {
1224
+ var defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1225
+ var KR_SPLIT = { above: "#F04452", below: "#3182F6" };
1226
+ function LineChart({
1227
+ showXAxis,
1228
+ showYAxis,
1229
+ scrubber,
1230
+ curve,
1231
+ strokeWidth,
1232
+ lineStyle,
1233
+ area,
1234
+ connectNulls,
1235
+ points,
1236
+ baseline,
1237
+ splitColors,
1238
+ children,
1239
+ ...chart
1240
+ }) {
741
1241
  const sc = axisProps(scrubber);
742
- return /* @__PURE__ */ jsxs8(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
1242
+ const split = baseline !== void 0 ? splitColors ?? KR_SPLIT : void 0;
1243
+ return /* @__PURE__ */ jsxs9(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
743
1244
  axisElements(axisProps(showXAxis), axisProps(showYAxis)),
744
- chart.series.map((s) => /* @__PURE__ */ jsx10(
1245
+ chart.series.map((s) => /* @__PURE__ */ jsx11(
745
1246
  Line,
746
1247
  {
747
1248
  seriesId: s.id,
748
- ...curve ? { curve } : {},
749
- ...strokeWidth ? { strokeWidth } : {},
750
- ...area ? { area } : {},
751
- ...connectNulls ? { connectNulls } : {}
1249
+ ...defined({ curve, strokeWidth, lineStyle, connectNulls, points, baseline, splitColors: split }),
1250
+ ...s.areaStyle ?? area ? { area: s.areaStyle ?? area } : {}
752
1251
  },
753
1252
  s.id
754
1253
  )),
755
1254
  children,
756
- sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
1255
+ sc && /* @__PURE__ */ jsx11(
1256
+ Scrubber,
1257
+ {
1258
+ ...split && baseline !== void 0 ? { beaconColor: (v) => v >= baseline ? split.above : split.below } : {},
1259
+ ...sc
1260
+ }
1261
+ )
757
1262
  ] });
758
1263
  }
759
- function AreaChart({ showXAxis, showYAxis, scrubber, curve = "monotone", fill = "gradient", showLines = true, strokeWidth = 2, children, ...chart }) {
1264
+ function AreaChart({
1265
+ showXAxis,
1266
+ showYAxis,
1267
+ scrubber,
1268
+ curve = "monotone",
1269
+ fill = "gradient",
1270
+ showLines = true,
1271
+ strokeWidth = 2,
1272
+ lineStyle,
1273
+ baseline,
1274
+ splitColors,
1275
+ gradientStops,
1276
+ fillOpacity,
1277
+ children,
1278
+ ...chart
1279
+ }) {
760
1280
  const sc = axisProps(scrubber);
761
- return /* @__PURE__ */ jsxs8(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
1281
+ return /* @__PURE__ */ jsxs9(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
762
1282
  axisElements(axisProps(showXAxis), axisProps(showYAxis)),
763
- chart.series.map((s) => /* @__PURE__ */ jsx10(Area, { seriesId: s.id, curve, fill: chart.stacked ? "solid" : fill, ...chart.stacked ? { opacity: 0.55 } : {} }, `a${s.id}`)),
764
- showLines && chart.series.map((s) => /* @__PURE__ */ jsx10(Line, { seriesId: s.id, curve, strokeWidth }, `l${s.id}`)),
1283
+ chart.series.map((s) => {
1284
+ const own = s.areaStyle === "none" ? null : s.areaStyle ?? (chart.stacked && fill === "gradient" ? "solid" : fill);
1285
+ return own ? /* @__PURE__ */ jsx11(
1286
+ Area,
1287
+ {
1288
+ seriesId: s.id,
1289
+ curve,
1290
+ fill: own,
1291
+ ...defined({ baseline, splitColors, gradientStops, opacity: fillOpacity ?? (chart.stacked && own === "solid" ? 0.3 : void 0) })
1292
+ },
1293
+ `a${s.id}`
1294
+ ) : null;
1295
+ }),
1296
+ showLines && chart.series.map((s) => /* @__PURE__ */ jsx11(Line, { seriesId: s.id, curve, strokeWidth, ...defined({ lineStyle, baseline, splitColors }) }, `l${s.id}`)),
765
1297
  children,
766
- sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
1298
+ sc && /* @__PURE__ */ jsx11(Scrubber, { ...sc })
767
1299
  ] });
768
1300
  }
769
- function BarChart({ showXAxis, showYAxis, scrubber, radius, getBarColor, groupGap, xAxis, children, ...chart }) {
1301
+ function BarChart({ showXAxis, showYAxis, scrubber, radius, getBarColor, groupGap, stackGap, roundBaseline, barMinSize, layout = "vertical", xAxis, children, ...chart }) {
770
1302
  const sc = axisProps(scrubber);
771
- return /* @__PURE__ */ jsxs8(CartesianChart, { enableScrubbing: !!sc, xAxis: { ...xAxis, scaleType: "band" }, ...chart, children: [
772
- axisElements(axisProps(showXAxis), axisProps(showYAxis)),
773
- /* @__PURE__ */ jsx10(
774
- BarPlot,
1303
+ if (layout === "horizontal") {
1304
+ const yAx = axisProps(showYAxis);
1305
+ return /* @__PURE__ */ jsx11(
1306
+ HorizontalBarChart,
775
1307
  {
776
- ...radius === void 0 ? {} : { radius },
777
- ...getBarColor ? { getBarColor } : {},
778
- ...groupGap === void 0 ? {} : { groupGap },
779
- highlightScrubbed: !!sc
1308
+ series: chart.series,
1309
+ categories: (xAxis?.data ?? []).map(String),
1310
+ showValueAxis: !!showYAxis,
1311
+ showCategoryAxis: showXAxis !== false,
1312
+ enableScrubbing: !!sc || chart.enableScrubbing === true,
1313
+ legend: !!chart.legend,
1314
+ ...defined({
1315
+ width: chart.width,
1316
+ height: chart.height,
1317
+ stacked: chart.stacked,
1318
+ radius,
1319
+ getBarColor,
1320
+ groupGap,
1321
+ stackGap,
1322
+ bandPadding: xAxis?.bandPadding,
1323
+ valueFormatter: yAx?.tickLabelFormatter,
1324
+ palette: chart.palette,
1325
+ className: chart.className,
1326
+ style: chart.style,
1327
+ "aria-label": chart["aria-label"]
1328
+ })
780
1329
  }
781
- ),
1330
+ );
1331
+ }
1332
+ return /* @__PURE__ */ jsxs9(CartesianChart, { enableScrubbing: !!sc, xAxis: { ...xAxis, scaleType: "band" }, ...chart, children: [
1333
+ axisElements(axisProps(showXAxis), axisProps(showYAxis)),
1334
+ /* @__PURE__ */ jsx11(BarPlot, { ...defined({ radius, getBarColor, groupGap, stackGap, roundBaseline, minSize: barMinSize }), highlightScrubbed: !!sc }),
782
1335
  children,
783
- sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
1336
+ sc && /* @__PURE__ */ jsx11(Scrubber, { ...sc })
784
1337
  ] });
785
1338
  }
1339
+ var pct = (_item, f) => `${formatNumber(Math.round(f * 1e3) / 10)}%`;
786
1340
  function PercentageBarChart({
787
1341
  data,
788
- barHeight = 10,
1342
+ rows,
1343
+ layout = "horizontal",
1344
+ barHeight,
1345
+ barLength = 160,
789
1346
  gap = 2,
1347
+ radius,
790
1348
  showLabels = true,
791
- formatLabel = (_item, f) => `${formatNumber(Math.round(f * 1e3) / 10)}%`,
1349
+ legendPosition,
1350
+ legendShape,
1351
+ formatLabel = pct,
792
1352
  palette,
793
1353
  className,
794
1354
  style,
795
1355
  "aria-label": ariaLabel
796
1356
  }) {
797
- const segs = percentages(data);
798
- const items = data.map((d, i) => ({ ...d, color: d.color ?? seriesColor2(i, palette), fraction: segs[i].fraction }));
799
- const visible = items.filter((i) => i.fraction > 0);
800
- const [hovered, setHovered] = useState4(null);
801
- return /* @__PURE__ */ jsxs8("div", { className, style: { fontFamily: VAR.font, ...style }, role: "img", "aria-label": ariaLabel ?? items.map((i) => `${i.label} ${formatLabel(i, i.fraction)}`).join(", "), children: [
802
- /* @__PURE__ */ jsx10("div", { style: { display: "flex", gap, height: barHeight }, children: visible.map((i, k) => /* @__PURE__ */ jsx10(
803
- "span",
1357
+ const vertical = layout === "vertical";
1358
+ const thick = barHeight ?? (vertical ? 28 : 10);
1359
+ const end = radius ?? (vertical ? Math.min(6, thick / 2) : thick / 2);
1360
+ const inner = Math.min(2, end);
1361
+ const legendAt = legendPosition ?? (vertical ? "right" : "bottom");
1362
+ const allRows = rows ?? [{ id: "row", data: data ?? [] }];
1363
+ const multi = allRows.length > 1 || !!rows;
1364
+ const colorOf = /* @__PURE__ */ new Map();
1365
+ const legendItems = [];
1366
+ for (const r of allRows)
1367
+ for (const d of r.data)
1368
+ if (!colorOf.has(d.id)) {
1369
+ colorOf.set(d.id, d.color ?? seriesColor3(colorOf.size, palette));
1370
+ legendItems.push(d);
1371
+ }
1372
+ const [hovered, setHovered] = useState5(null);
1373
+ const [hoverRow, setHoverRow] = useState5(null);
1374
+ const rowSegs = allRows.map((r) => {
1375
+ const segs = percentages(r.data);
1376
+ return { row: r, items: r.data.map((d, i) => ({ ...d, color: colorOf.get(d.id), fraction: segs[i].fraction })) };
1377
+ });
1378
+ const renderBar = (items, rowId) => {
1379
+ const visible = items.filter((i) => i.fraction > 0);
1380
+ const ordered = vertical ? [...visible].reverse() : visible;
1381
+ return /* @__PURE__ */ jsx11(
1382
+ "div",
804
1383
  {
805
- title: `${i.label} ${formatLabel(i, i.fraction)}`,
806
- onPointerEnter: () => setHovered(i.id),
807
- onPointerLeave: () => setHovered(null),
808
1384
  style: {
809
- opacity: hovered && hovered !== i.id ? 0.3 : 1,
810
- flex: `${i.fraction} 1 0`,
811
- minWidth: 2,
812
- background: i.color,
813
- borderRadius: `${k === 0 ? barHeight / 2 : 2}px ${k === visible.length - 1 ? barHeight / 2 : 2}px ${k === visible.length - 1 ? barHeight / 2 : 2}px ${k === 0 ? barHeight / 2 : 2}px`,
814
- transition: "flex-grow 0.4s ease, opacity 0.15s",
815
- cursor: "default"
816
- }
817
- },
818
- i.id
819
- )) }),
820
- showLabels && /* @__PURE__ */ jsx10(
821
- Legend,
822
- {
823
- style: { marginTop: 8 },
824
- items: items.map((i) => ({ id: i.id, label: i.label, color: i.color, value: formatLabel(i, i.fraction), inactive: !!hovered && hovered !== i.id })),
825
- onItemHover: setHovered
1385
+ display: "flex",
1386
+ flexDirection: vertical ? "column" : "row",
1387
+ gap,
1388
+ ...vertical ? { width: thick, height: barLength } : { height: thick, flex: 1, minWidth: 0 }
1389
+ },
1390
+ children: ordered.map((i, k) => {
1391
+ const a = k === 0 ? end : inner;
1392
+ const b = k === ordered.length - 1 ? end : inner;
1393
+ return /* @__PURE__ */ jsx11(
1394
+ "span",
1395
+ {
1396
+ title: `${i.label} ${formatLabel(i, i.fraction)}`,
1397
+ onPointerEnter: () => {
1398
+ setHovered(i.id);
1399
+ setHoverRow(rowId);
1400
+ },
1401
+ onPointerLeave: () => {
1402
+ setHovered(null);
1403
+ setHoverRow(null);
1404
+ },
1405
+ style: {
1406
+ opacity: hovered && hovered !== i.id ? 0.3 : 1,
1407
+ flex: `${i.fraction} 1 0`,
1408
+ [vertical ? "minHeight" : "minWidth"]: 2,
1409
+ background: i.color,
1410
+ borderRadius: vertical ? `${a}px ${a}px ${b}px ${b}px` : `${a}px ${b}px ${b}px ${a}px`,
1411
+ transition: "flex-grow 0.4s ease, opacity 0.15s",
1412
+ cursor: "default"
1413
+ }
1414
+ },
1415
+ i.id
1416
+ );
1417
+ })
826
1418
  }
827
- )
828
- ] });
1419
+ );
1420
+ };
1421
+ const legendRow = multi ? rowSegs.find((r) => r.row.id === hoverRow) : rowSegs[0];
1422
+ const legend = showLabels && /* @__PURE__ */ jsx11(
1423
+ Legend,
1424
+ {
1425
+ style: legendAt === "top" ? { marginBottom: 10 } : legendAt === "bottom" ? { marginTop: 10 } : {},
1426
+ direction: legendAt === "right" ? "column" : "row",
1427
+ ...legendShape ? { shape: legendShape } : {},
1428
+ items: legendItems.map((d) => {
1429
+ const it = legendRow?.items.find((x) => x.id === d.id);
1430
+ return {
1431
+ id: d.id,
1432
+ label: d.label,
1433
+ color: colorOf.get(d.id),
1434
+ ...it ? { value: formatLabel(it, it.fraction) } : {},
1435
+ inactive: !!hovered && hovered !== d.id
1436
+ };
1437
+ }),
1438
+ onItemHover: setHovered
1439
+ }
1440
+ );
1441
+ const label = (r) => multi && r.label ? /* @__PURE__ */ jsx11("span", { style: { fontSize: 12, color: hoverRow === r.id ? VAR.textStrong : VAR.text, fontWeight: hoverRow === r.id ? 700 : 500, whiteSpace: "nowrap" }, children: r.label }) : null;
1442
+ const bars = vertical ? /* @__PURE__ */ jsx11("div", { style: { display: "flex", gap: 14, alignItems: "flex-end" }, children: rowSegs.map(({ row, items }) => /* @__PURE__ */ jsxs9("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }, children: [
1443
+ renderBar(items, row.id),
1444
+ label(row)
1445
+ ] }, row.id)) }) : multi ? /* @__PURE__ */ jsx11("div", { style: { display: "grid", gridTemplateColumns: "max-content 1fr", columnGap: 12, rowGap: 10, alignItems: "center", flex: legendAt === "right" ? 1 : void 0 }, children: rowSegs.map(({ row, items }) => /* @__PURE__ */ jsxs9("div", { style: { display: "contents" }, children: [
1446
+ label(row) ?? /* @__PURE__ */ jsx11("span", {}),
1447
+ renderBar(items, row.id)
1448
+ ] }, row.id)) }) : /* @__PURE__ */ jsx11("div", { style: { display: "flex", flex: legendAt === "right" ? 1 : void 0 }, children: renderBar(rowSegs[0].items, rowSegs[0].row.id) });
1449
+ return /* @__PURE__ */ jsxs9(
1450
+ "div",
1451
+ {
1452
+ className,
1453
+ style: { fontFamily: VAR.font, ...legendAt === "right" ? { display: "flex", alignItems: "center", gap: 16 } : {}, ...style },
1454
+ role: "img",
1455
+ "aria-label": ariaLabel ?? rowSegs.map(({ row, items }) => `${row.label ? `${row.label}: ` : ""}${items.map((i) => `${i.label} ${formatLabel(i, i.fraction)}`).join(", ")}`).join("; "),
1456
+ children: [
1457
+ legendAt === "top" && legend,
1458
+ bars,
1459
+ legendAt !== "top" && legend
1460
+ ]
1461
+ }
1462
+ );
829
1463
  }
830
1464
  export {
831
1465
  Area,
@@ -836,9 +1470,11 @@ export {
836
1470
  DEFAULT_PERIODS,
837
1471
  FnChart,
838
1472
  FnChartSlot,
1473
+ HorizontalBarChart,
839
1474
  Legend,
840
1475
  Line,
841
1476
  LineChart,
1477
+ LiveTabLabel,
842
1478
  PercentageBarChart,
843
1479
  PeriodSelector,
844
1480
  Point,