@funnest/charts-react 0.1.0 → 0.2.0

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