@funnest/charts-react 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,851 @@
1
+ // src/fn-chart.tsx
2
+ import { createTradingChart } from "@funnest/charts-ui";
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useContext,
7
+ useEffect,
8
+ useImperativeHandle,
9
+ useRef,
10
+ useState
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { jsx } from "react/jsx-runtime";
14
+ var WidgetContext = createContext(null);
15
+ var FnChart = forwardRef(function FnChart2(props, ref) {
16
+ const { className, style, onReady, children, ...options } = props;
17
+ const hostRef = useRef(null);
18
+ const [widget, setWidget] = useState(null);
19
+ const latest = useRef(options);
20
+ latest.current = options;
21
+ const readyRef = useRef(onReady);
22
+ readyRef.current = onReady;
23
+ useImperativeHandle(ref, () => widget, [widget]);
24
+ useEffect(() => {
25
+ const host = hostRef.current;
26
+ if (!host) return;
27
+ const instance = createTradingChart(host, latest.current);
28
+ setWidget(instance);
29
+ readyRef.current?.(instance);
30
+ return () => {
31
+ setWidget(null);
32
+ instance.destroy();
33
+ };
34
+ }, [options.datafeed]);
35
+ const { symbol, resolution, locale, theme, colorScheme } = options;
36
+ useEffect(() => {
37
+ if (!widget) return;
38
+ const current = widget.chart.symbolInfo?.symbol;
39
+ const res = resolution ?? widget.chart.currentResolution ?? void 0;
40
+ if (current && (current !== symbol || resolution && resolution !== widget.chart.currentResolution)) {
41
+ void widget.setSymbol(symbol, res);
42
+ }
43
+ }, [widget, symbol, resolution]);
44
+ useEffect(() => {
45
+ if (widget && locale) widget.setLocale(locale);
46
+ }, [widget, locale]);
47
+ useEffect(() => {
48
+ if (widget && colorScheme) widget.setColorScheme(colorScheme);
49
+ }, [widget, colorScheme]);
50
+ const themeKey = JSON.stringify(theme ?? null);
51
+ useEffect(() => {
52
+ if (widget && theme) widget.setTheme(theme);
53
+ }, [widget, themeKey]);
54
+ return /* @__PURE__ */ jsx("div", { ref: hostRef, className, style: { width: "100%", height: "100%", ...style }, children: /* @__PURE__ */ jsx(WidgetContext.Provider, { value: widget, children }) });
55
+ });
56
+ function useFnChart() {
57
+ return useContext(WidgetContext);
58
+ }
59
+ function useChartEvent(event, handler) {
60
+ const widget = useFnChart();
61
+ const handlerRef = useRef(handler);
62
+ handlerRef.current = handler;
63
+ useEffect(() => widget?.chart.on(event, (payload) => handlerRef.current(payload)), [widget, event]);
64
+ }
65
+ function FnChartSlot({ name, children }) {
66
+ const widget = useFnChart();
67
+ return widget ? createPortal(children, widget.slots[name]) : null;
68
+ }
69
+
70
+ // src/viz/cartesian-chart.tsx
71
+ import {
72
+ bandScale,
73
+ extent,
74
+ linearScale,
75
+ maxLength,
76
+ nearestIndex,
77
+ niceDomain,
78
+ seriesColor,
79
+ stack,
80
+ stackedExtent
81
+ } from "@funnest/charts-viz";
82
+ import {
83
+ Children,
84
+ isValidElement,
85
+ useCallback,
86
+ useEffect as useEffect2,
87
+ useId,
88
+ useMemo,
89
+ useRef as useRef2,
90
+ useState as useState2
91
+ } from "react";
92
+
93
+ // src/viz/context.ts
94
+ import { createContext as createContext2, useContext as useContext2 } from "react";
95
+ var CartesianContext = createContext2(null);
96
+ function useCartesianChart() {
97
+ const ctx = useContext2(CartesianContext);
98
+ if (!ctx) throw new Error("Funnest chart components must be rendered inside <CartesianChart> (or LineChart, AreaChart, BarChart).");
99
+ return ctx;
100
+ }
101
+ var VAR = {
102
+ text: "var(--fnv-text-muted, color-mix(in srgb, CanvasText 50%, Canvas))",
103
+ textStrong: "var(--fnv-text, CanvasText)",
104
+ grid: "var(--fnv-grid, color-mix(in srgb, CanvasText 9%, transparent))",
105
+ line: "var(--fnv-line, color-mix(in srgb, CanvasText 32%, transparent))",
106
+ bg: "var(--fnv-bg, Canvas)",
107
+ font: "var(--fnv-font, inherit)"
108
+ };
109
+ var numberFormat = new Intl.NumberFormat(void 0, { maximumFractionDigits: 2 });
110
+ var compactFormat = new Intl.NumberFormat(void 0, { notation: "compact", maximumFractionDigits: 2 });
111
+ var formatNumber = (v) => Math.abs(v) >= 1e5 ? compactFormat.format(v) : numberFormat.format(v);
112
+ function seriesY(ctx, s, i) {
113
+ const stacked = ctx.stacked?.get(s.id);
114
+ if (stacked) {
115
+ const raw = s.data[i];
116
+ return raw === null || raw === void 0 ? null : ctx.y(stacked.y1[i]);
117
+ }
118
+ const v = s.data[i];
119
+ return v === null || v === void 0 || !Number.isFinite(v) ? null : ctx.y(v);
120
+ }
121
+
122
+ // src/viz/legend.tsx
123
+ 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 };
126
+ return /* @__PURE__ */ jsx2(
127
+ "div",
128
+ {
129
+ className,
130
+ 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 },
132
+ children: items.map((item) => {
133
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
134
+ /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", style: { flex: "none", ...swatch(item.color) } }),
135
+ /* @__PURE__ */ jsx2("span", { children: item.label }),
136
+ item.value !== void 0 && /* @__PURE__ */ jsx2("b", { style: { color: VAR.textStrong, fontWeight: 600, fontVariantNumeric: "tabular-nums" }, children: item.value })
137
+ ] });
138
+ const itemStyle = { display: "inline-flex", alignItems: "center", gap: 6, opacity: item.inactive ? 0.4 : 1, transition: "opacity 0.15s" };
139
+ const hover = onItemHover ? { onPointerEnter: () => onItemHover(item.id), onPointerLeave: () => onItemHover(null) } : {};
140
+ return onItemClick ? /* @__PURE__ */ jsx2(
141
+ "button",
142
+ {
143
+ type: "button",
144
+ "aria-pressed": !item.inactive,
145
+ onClick: () => onItemClick(item.id),
146
+ ...hover,
147
+ style: { ...itemStyle, border: 0, padding: 0, background: "none", color: "inherit", font: "inherit", cursor: "pointer" },
148
+ children: content
149
+ },
150
+ item.id
151
+ ) : /* @__PURE__ */ jsx2("span", { role: "listitem", style: itemStyle, ...hover, children: content }, item.id);
152
+ })
153
+ }
154
+ );
155
+ }
156
+
157
+ // src/viz/cartesian-chart.tsx
158
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
159
+ var DEFAULT_HEIGHT = 240;
160
+ function resolveInset(inset) {
161
+ const d = typeof inset === "number" ? inset : 8;
162
+ const o = typeof inset === "object" ? inset : {};
163
+ return { top: o.top ?? d, right: o.right ?? d, bottom: o.bottom ?? d, left: o.left ?? d };
164
+ }
165
+ function collectAxisSpace(children, acc) {
166
+ Children.forEach(children, (child) => {
167
+ if (!isValidElement(child)) return;
168
+ const type = child.type;
169
+ const props = child.props;
170
+ if (typeof type === "function" || typeof type === "object") {
171
+ const space = type.axisSpace?.(props);
172
+ if (space) acc[space.side] = Math.max(acc[space.side], space.size);
173
+ }
174
+ if (props.children && !type.axisSpace) collectAxisSpace(props.children, acc);
175
+ });
176
+ }
177
+ function useElementWidth(fixed) {
178
+ const ref = useRef2(null);
179
+ const [width, setWidth] = useState2(fixed ?? 0);
180
+ useEffect2(() => {
181
+ if (fixed !== void 0) {
182
+ setWidth(fixed);
183
+ return;
184
+ }
185
+ const el = ref.current;
186
+ if (!el || typeof ResizeObserver === "undefined") return;
187
+ const ro = new ResizeObserver(([entry]) => {
188
+ if (entry) setWidth(Math.round(entry.contentRect.width));
189
+ });
190
+ ro.observe(el);
191
+ setWidth(Math.round(el.getBoundingClientRect().width));
192
+ return () => ro.disconnect();
193
+ }, [fixed]);
194
+ return [ref, width];
195
+ }
196
+ function prefersReducedMotion() {
197
+ return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
198
+ }
199
+ function CartesianChart({
200
+ series,
201
+ width: fixedWidth,
202
+ height = DEFAULT_HEIGHT,
203
+ inset,
204
+ xAxis,
205
+ yAxis,
206
+ stacked = false,
207
+ enableScrubbing = false,
208
+ onScrubberPositionChange,
209
+ animate = true,
210
+ legend = false,
211
+ legendValueFormatter,
212
+ palette,
213
+ className,
214
+ style,
215
+ "aria-label": ariaLabel,
216
+ children
217
+ }) {
218
+ const id = `fnv${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
219
+ const [wrapRef, width] = useElementWidth(fixedWidth);
220
+ const [scrubIndex, setScrubIndex] = useState2(null);
221
+ const [motion, setMotion] = useState2(false);
222
+ 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]
226
+ );
227
+ const axisSpace = useMemo(() => {
228
+ const acc = { top: 0, right: 0, bottom: 0, left: 0 };
229
+ collectAxisSpace(children, acc);
230
+ return acc;
231
+ }, [children]);
232
+ const ctx = useMemo(() => {
233
+ const ins = resolveInset(inset);
234
+ const left = ins.left + axisSpace.left;
235
+ const top = ins.top + axisSpace.top;
236
+ const right = Math.max(left, width - ins.right - axisSpace.right);
237
+ const bottom = Math.max(top, height - ins.bottom - axisSpace.bottom);
238
+ const plot = { left, top, right, bottom, width: right - left, height: bottom - top };
239
+ const length = Math.max(maxLength(resolved), xAxis?.data?.length ?? 0);
240
+ const isBand = xAxis?.scaleType === "band";
241
+ const band = isBand ? bandScale(length, [left, right], xAxis?.bandPadding ?? 0.25) : null;
242
+ const xLinear = linearScale([0, Math.max(1, length - 1)], [left, right]);
243
+ 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);
254
+ }
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]);
259
+ const xData = xAxis?.data;
260
+ const xLabel = (i) => {
261
+ const v = xData?.[i];
262
+ return v === void 0 ? String(i) : typeof v === "number" ? formatNumber(v) : v;
263
+ };
264
+ return {
265
+ id,
266
+ width,
267
+ height,
268
+ plot,
269
+ series: resolved,
270
+ getSeries: (sid) => resolved.find((s) => s.id === sid),
271
+ length,
272
+ x,
273
+ band,
274
+ y,
275
+ stacked: stackedMap,
276
+ xLabel,
277
+ xData,
278
+ scrubIndex,
279
+ animate: motion
280
+ };
281
+ }, [id, width, height, inset, axisSpace, resolved, xAxis, yAxis, stacked, scrubIndex, motion]);
282
+ const xsRef = useRef2([]);
283
+ xsRef.current = useMemo(() => Array.from({ length: ctx.length }, (_, i) => ctx.x(i)), [ctx.length, ctx.x]);
284
+ const updateScrub = useCallback(
285
+ (next) => {
286
+ setScrubIndex((prev) => {
287
+ if (prev !== next) onScrubberPositionChange?.(next);
288
+ return next;
289
+ });
290
+ },
291
+ [onScrubberPositionChange]
292
+ );
293
+ const onPointerMove = (e) => {
294
+ if (!enableScrubbing) return;
295
+ const rect = e.currentTarget.getBoundingClientRect();
296
+ const px = e.clientX - rect.left;
297
+ const i = ctx.band ? ctx.band.invert(px) : nearestIndex(xsRef.current, px);
298
+ updateScrub(i < 0 ? null : i);
299
+ };
300
+ const onPointerLeave = () => enableScrubbing && updateScrub(null);
301
+ const legendPos = legend === true ? "top" : legend === false ? null : legend;
302
+ const legendEl = legendPos && /* @__PURE__ */ jsx3(
303
+ Legend,
304
+ {
305
+ items: resolved.map((s) => {
306
+ const v = scrubIndex === null ? null : s.data[scrubIndex];
307
+ return {
308
+ id: s.id,
309
+ label: s.label ?? s.id,
310
+ color: s.color,
311
+ value: v === null || v === void 0 ? void 0 : legendValueFormatter ? legendValueFormatter(v, s.id) : formatNumber(v)
312
+ };
313
+ })
314
+ }
315
+ );
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
+ }
336
+
337
+ // src/viz/axes.tsx
338
+ import { sampleIndices, ticks as niceTicks } from "@funnest/charts-viz";
339
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
340
+ function XAxis({
341
+ position = "bottom",
342
+ height = 24,
343
+ ticks,
344
+ tickLabelFormatter,
345
+ showLine = false,
346
+ showTickMarks = false,
347
+ showGrid = false,
348
+ minTickSpacing = 80
349
+ }) {
350
+ const ctx = useCartesianChart();
351
+ const { plot } = ctx;
352
+ if (ctx.length === 0) return null;
353
+ const indices = ticks ?? sampleIndices(ctx.length, Math.max(1, Math.floor(plot.width / minTickSpacing)));
354
+ const lineY = position === "bottom" ? ctx.height - height : height;
355
+ const textY = position === "bottom" ? lineY + height / 2 + 4 : lineY - height / 2 + 4;
356
+ return /* @__PURE__ */ jsxs3("g", { "aria-hidden": "true", style: { fontFamily: VAR.font }, children: [
357
+ showGrid && indices.map((i) => /* @__PURE__ */ jsx4("line", { x1: ctx.x(i), x2: ctx.x(i), y1: plot.top, y2: plot.bottom, shapeRendering: "crispEdges", style: { stroke: VAR.grid } }, `g${i}`)),
358
+ showLine && /* @__PURE__ */ jsx4("line", { x1: plot.left, x2: plot.right, y1: lineY, y2: lineY, shapeRendering: "crispEdges", style: { stroke: VAR.line } }),
359
+ indices.map((i) => {
360
+ const x = ctx.x(i);
361
+ const label = tickLabelFormatter ? tickLabelFormatter(ctx.xData?.[i], i) : ctx.xLabel(i);
362
+ const anchor = x - plot.left < 24 ? "start" : plot.right - x < 24 ? "end" : "middle";
363
+ return /* @__PURE__ */ jsxs3("g", { children: [
364
+ showTickMarks && /* @__PURE__ */ jsx4("line", { x1: x, x2: x, y1: lineY, y2: position === "bottom" ? lineY + 4 : lineY - 4, style: { stroke: VAR.line } }),
365
+ /* @__PURE__ */ jsx4("text", { x, y: textY, textAnchor: anchor, fontSize: 11, style: { fill: VAR.text }, children: label })
366
+ ] }, i);
367
+ })
368
+ ] });
369
+ }
370
+ XAxis.axisSpace = (p) => ({ side: p.position ?? "bottom", size: p.height ?? 24 });
371
+ function YAxis({
372
+ position = "right",
373
+ width = 48,
374
+ ticks,
375
+ tickLabelFormatter = formatNumber,
376
+ showGrid = true,
377
+ showLine = false,
378
+ minTickSpacing = 32
379
+ }) {
380
+ const ctx = useCartesianChart();
381
+ const { plot, y } = ctx;
382
+ const [d0, d1] = y.domain;
383
+ const values = (ticks ?? niceTicks(d0, d1, Math.max(2, Math.floor(plot.height / minTickSpacing)))).filter(
384
+ (v) => v >= Math.min(d0, d1) - 1e-9 && v <= Math.max(d0, d1) + 1e-9
385
+ );
386
+ const right = position === "right";
387
+ const textX = right ? ctx.width - width + 8 : width - 8;
388
+ const lineX = right ? ctx.width - width : width;
389
+ return /* @__PURE__ */ jsxs3("g", { "aria-hidden": "true", style: { fontFamily: VAR.font }, children: [
390
+ showGrid && values.map((v) => /* @__PURE__ */ jsx4("line", { x1: plot.left, x2: plot.right, y1: y(v), y2: y(v), shapeRendering: "crispEdges", style: { stroke: VAR.grid } }, `g${v}`)),
391
+ showLine && /* @__PURE__ */ jsx4("line", { x1: lineX, x2: lineX, y1: plot.top, y2: plot.bottom, shapeRendering: "crispEdges", style: { stroke: VAR.line } }),
392
+ values.map((v) => /* @__PURE__ */ jsx4("text", { x: textX, y: y(v) + 4, textAnchor: right ? "start" : "end", fontSize: 11, style: { fill: VAR.text }, children: tickLabelFormatter(v) }, v))
393
+ ] });
394
+ }
395
+ YAxis.axisSpace = (p) => ({ side: p.position ?? "right", size: p.width ?? 48 });
396
+
397
+ // src/viz/line.tsx
398
+ import { areaPath, linePath, segments } from "@funnest/charts-viz";
399
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
400
+ function seriesRuns(ctx, s, connectNulls = false) {
401
+ 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;
404
+ 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
+ return runs.filter((r) => r.values.length > 0).map((r) => {
406
+ const idx = r.values;
407
+ 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;
409
+ return { upper, lower };
410
+ });
411
+ }
412
+ function baselineY(ctx) {
413
+ const [d0, d1] = ctx.y.domain;
414
+ const lo = Math.min(d0, d1);
415
+ const hi = Math.max(d0, d1);
416
+ return lo <= 0 && hi >= 0 ? ctx.y(0) : ctx.plot.bottom;
417
+ }
418
+ function Area({ seriesId, curve = "monotone", fill = "gradient", opacity, connectNulls }) {
419
+ const ctx = useCartesianChart();
420
+ const s = ctx.getSeries(seriesId);
421
+ if (!s) return null;
422
+ 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
+ ] });
442
+ }
443
+ function Line({
444
+ seriesId,
445
+ curve = "monotone",
446
+ strokeWidth = 2,
447
+ dash,
448
+ connectNulls,
449
+ area = "none",
450
+ areaOpacity,
451
+ opacity
452
+ }) {
453
+ const ctx = useCartesianChart();
454
+ const s = ctx.getSeries(seriesId);
455
+ if (!s) return null;
456
+ const drawIn = ctx.animate && !dash;
457
+ 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",
461
+ {
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
+ ))
475
+ ] });
476
+ }
477
+
478
+ // src/viz/bar.tsx
479
+ import { barPath } from "@funnest/charts-viz";
480
+ import { jsx as jsx6 } from "react/jsx-runtime";
481
+ function BarPlot({ seriesIds, radius = 3, groupGap = 2, getBarColor, highlightScrubbed = true }) {
482
+ const ctx = useCartesianChart();
483
+ const band = ctx.band;
484
+ if (!band) return null;
485
+ const list = seriesIds ? ctx.series.filter((s) => seriesIds.includes(s.id)) : ctx.series;
486
+ const groups = ctx.stacked ? 1 : Math.max(1, list.length);
487
+ 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)));
489
+ const dim = highlightScrubbed && ctx.scrubIndex !== null;
490
+ return /* @__PURE__ */ jsx6("g", { children: list.map((s, k) => {
491
+ const st = ctx.stacked?.get(s.id);
492
+ const offset = ctx.stacked ? 0 : k * (barW + groupGap);
493
+ const isTopOfStack = (i, v) => !ctx.stacked || !list.slice(k + 1).some((o) => {
494
+ const ov = o.data[i];
495
+ return ov !== null && ov !== void 0 && ov !== 0 && Math.sign(ov) === Math.sign(v);
496
+ });
497
+ return /* @__PURE__ */ jsx6("g", { children: s.data.map((v, i) => {
498
+ if (v === null || v === void 0 || !Number.isFinite(v)) return null;
499
+ 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);
502
+ const color = getBarColor?.(v, i, s.id) ?? s.color;
503
+ return /* @__PURE__ */ jsx6(
504
+ "path",
505
+ {
506
+ d: barPath(x, barW, y0, y1, isTopOfStack(i, v) ? radius : 0),
507
+ fillOpacity: dim && ctx.scrubIndex !== i ? 0.45 : 1,
508
+ style: { fill: color },
509
+ children: ctx.animate && /* @__PURE__ */ jsx6("animate", { attributeName: "opacity", from: "0", to: "1", dur: "0.5s", fill: "freeze" })
510
+ },
511
+ i
512
+ );
513
+ }) }, s.id);
514
+ }) });
515
+ }
516
+
517
+ // src/viz/scrubber.tsx
518
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
519
+ 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 }) {
522
+ const ctx = useCartesianChart();
523
+ const i = ctx.scrubIndex;
524
+ if (i === null || i >= ctx.length) return null;
525
+ const { plot } = ctx;
526
+ const x = ctx.x(i);
527
+ const text = label ? label(i) : ctx.xLabel(i);
528
+ const list = seriesIds ? ctx.series.filter((s) => seriesIds.includes(s.id)) : ctx.series;
529
+ const textX = Math.min(Math.max(x, plot.left + 30), plot.right - 30);
530
+ const rows = showValues ? list.flatMap((s) => {
531
+ const v = s.data[i];
532
+ return v === null || v === void 0 || !Number.isFinite(v) ? [] : [{ s, text: valueFormatter(v, s.id) }];
533
+ }) : [];
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;
536
+ const tipRight = x + 12 + tipW <= plot.right;
537
+ const tipX = tipRight ? x + 12 : x - 12 - tipW;
538
+ 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 }),
543
+ !ctx.band && list.map((s) => {
544
+ const y = seriesY(ctx, s, i);
545
+ 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 } })
549
+ ] }, s.id);
550
+ }),
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 })
557
+ ] }, r.s.id))
558
+ ] })
559
+ ] });
560
+ }
561
+
562
+ // 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 }) {
565
+ const ctx = useCartesianChart();
566
+ const { plot } = ctx;
567
+ if (dataY !== void 0) {
568
+ const y = ctx.y(dataY);
569
+ if (y < plot.top - 0.5 || y > plot.bottom + 0.5) return null;
570
+ const pos = labelPosition ?? "right";
571
+ const tx = pos === "left" ? plot.left + 4 : pos === "center" ? (plot.left + plot.right) / 2 : plot.right - 4;
572
+ 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 })
576
+ ] });
577
+ }
578
+ if (dataX !== void 0) {
579
+ const x = ctx.x(dataX);
580
+ 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",
585
+ {
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
595
+ }
596
+ )
597
+ ] });
598
+ }
599
+ return null;
600
+ }
601
+ function Point({ dataX, dataY, seriesId, color, radius = 4, label, labelPosition = "top", pulse = false, onClick }) {
602
+ const ctx = useCartesianChart();
603
+ const s = seriesId ? ctx.getSeries(seriesId) : void 0;
604
+ const value = dataY ?? (s ? s.data[dataX] : void 0);
605
+ if (value === null || value === void 0 || !Number.isFinite(value)) return null;
606
+ const st = s ? ctx.stacked?.get(s.id) : void 0;
607
+ const x = ctx.x(dataX);
608
+ const y = ctx.y(st && dataY === void 0 ? st.y1[dataX] : value);
609
+ const fill = color ?? s?.color ?? ctx.series[0]?.color ?? "currentColor";
610
+ const gap = radius + 6;
611
+ 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" })
616
+ ] }),
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 })
619
+ ] });
620
+ }
621
+
622
+ // 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";
625
+ var DEFAULT_PERIODS = [
626
+ { id: "1D", label: "1\uC77C" },
627
+ { id: "1W", label: "1\uC8FC" },
628
+ { id: "1M", label: "1\uAC1C\uC6D4" },
629
+ { id: "3M", label: "3\uAC1C\uC6D4" },
630
+ { id: "1Y", label: "1\uB144" },
631
+ { id: "ALL", label: "\uC804\uCCB4" }
632
+ ];
633
+ function PeriodSelector({
634
+ periods = DEFAULT_PERIODS,
635
+ value,
636
+ onChange,
637
+ activeColor = "var(--fnv-accent, #6C4DFF)",
638
+ fullWidth = false,
639
+ className,
640
+ style,
641
+ "aria-label": ariaLabel = "Period"
642
+ }) {
643
+ const listRef = useRef3(null);
644
+ const [pill, setPill] = useState3(null);
645
+ useLayoutEffect(() => {
646
+ const list = listRef.current;
647
+ const active = list?.querySelector('[aria-selected="true"]');
648
+ if (!list || !active) return setPill(null);
649
+ const measure = () => setPill({ left: active.offsetLeft, width: active.offsetWidth });
650
+ measure();
651
+ if (typeof ResizeObserver === "undefined") return;
652
+ const ro = new ResizeObserver(measure);
653
+ ro.observe(list);
654
+ return () => ro.disconnect();
655
+ }, [value, periods]);
656
+ const onKeyDown = (e) => {
657
+ const i = periods.findIndex((p2) => p2.id === value);
658
+ const next = e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : null;
659
+ if (next === null) return;
660
+ e.preventDefault();
661
+ const p = periods[(next + periods.length) % periods.length];
662
+ if (!p) return;
663
+ onChange(p.id);
664
+ listRef.current?.querySelector(`[data-period="${CSS.escape(p.id)}"]`)?.focus();
665
+ };
666
+ return /* @__PURE__ */ jsxs7(
667
+ "div",
668
+ {
669
+ ref: listRef,
670
+ role: "tablist",
671
+ "aria-label": ariaLabel,
672
+ className,
673
+ onKeyDown,
674
+ style: { position: "relative", display: fullWidth ? "flex" : "inline-flex", gap: 2, fontFamily: VAR.font, ...style },
675
+ children: [
676
+ pill && /* @__PURE__ */ jsx9(
677
+ "span",
678
+ {
679
+ "aria-hidden": "true",
680
+ style: {
681
+ position: "absolute",
682
+ top: 0,
683
+ bottom: 0,
684
+ left: pill.left,
685
+ width: pill.width,
686
+ borderRadius: 999,
687
+ background: activeColor,
688
+ opacity: 0.12,
689
+ transition: "left 0.2s ease, width 0.2s ease"
690
+ }
691
+ }
692
+ ),
693
+ periods.map((p) => {
694
+ const active = p.id === value;
695
+ return /* @__PURE__ */ jsx9(
696
+ "button",
697
+ {
698
+ type: "button",
699
+ role: "tab",
700
+ "data-period": p.id,
701
+ "aria-selected": active,
702
+ tabIndex: active ? 0 : -1,
703
+ onClick: () => onChange(p.id),
704
+ style: {
705
+ position: "relative",
706
+ flex: fullWidth ? 1 : "none",
707
+ minWidth: 44,
708
+ height: 32,
709
+ padding: "0 12px",
710
+ border: 0,
711
+ borderRadius: 999,
712
+ background: "transparent",
713
+ color: active ? activeColor : VAR.text,
714
+ font: "inherit",
715
+ fontSize: 13,
716
+ fontWeight: active ? 700 : 500,
717
+ cursor: "pointer"
718
+ },
719
+ children: p.label
720
+ },
721
+ p.id
722
+ );
723
+ })
724
+ ]
725
+ }
726
+ );
727
+ }
728
+
729
+ // 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";
733
+ var axisProps = (v) => v ? v === true ? {} : v : null;
734
+ var axisElements = (x, y) => [
735
+ y && /* @__PURE__ */ jsx10(YAxis, { ...y }, "y"),
736
+ x && /* @__PURE__ */ jsx10(XAxis, { ...x }, "x")
737
+ ];
738
+ function LineChart({ showXAxis, showYAxis, scrubber, curve, strokeWidth, area, connectNulls, children, ...chart }) {
739
+ const sc = axisProps(scrubber);
740
+ return /* @__PURE__ */ jsxs8(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
741
+ axisElements(axisProps(showXAxis), axisProps(showYAxis)),
742
+ chart.series.map((s) => /* @__PURE__ */ jsx10(
743
+ Line,
744
+ {
745
+ seriesId: s.id,
746
+ ...curve ? { curve } : {},
747
+ ...strokeWidth ? { strokeWidth } : {},
748
+ ...area ? { area } : {},
749
+ ...connectNulls ? { connectNulls } : {}
750
+ },
751
+ s.id
752
+ )),
753
+ children,
754
+ sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
755
+ ] });
756
+ }
757
+ function AreaChart({ showXAxis, showYAxis, scrubber, curve = "monotone", fill = "gradient", showLines = true, strokeWidth = 2, children, ...chart }) {
758
+ const sc = axisProps(scrubber);
759
+ return /* @__PURE__ */ jsxs8(CartesianChart, { enableScrubbing: !!sc, ...chart, children: [
760
+ 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}`)),
763
+ children,
764
+ sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
765
+ ] });
766
+ }
767
+ function BarChart({ showXAxis, showYAxis, scrubber, radius, getBarColor, groupGap, xAxis, children, ...chart }) {
768
+ 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,
773
+ {
774
+ ...radius === void 0 ? {} : { radius },
775
+ ...getBarColor ? { getBarColor } : {},
776
+ ...groupGap === void 0 ? {} : { groupGap },
777
+ highlightScrubbed: !!sc
778
+ }
779
+ ),
780
+ children,
781
+ sc && /* @__PURE__ */ jsx10(Scrubber, { ...sc })
782
+ ] });
783
+ }
784
+ function PercentageBarChart({
785
+ data,
786
+ barHeight = 10,
787
+ gap = 2,
788
+ showLabels = true,
789
+ formatLabel = (_item, f) => `${formatNumber(Math.round(f * 1e3) / 10)}%`,
790
+ palette,
791
+ className,
792
+ style,
793
+ "aria-label": ariaLabel
794
+ }) {
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",
802
+ {
803
+ title: `${i.label} ${formatLabel(i, i.fraction)}`,
804
+ onPointerEnter: () => setHovered(i.id),
805
+ onPointerLeave: () => setHovered(null),
806
+ 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
824
+ }
825
+ )
826
+ ] });
827
+ }
828
+ export {
829
+ Area,
830
+ AreaChart,
831
+ BarChart,
832
+ BarPlot,
833
+ CartesianChart,
834
+ DEFAULT_PERIODS,
835
+ FnChart,
836
+ FnChartSlot,
837
+ Legend,
838
+ Line,
839
+ LineChart,
840
+ PercentageBarChart,
841
+ PeriodSelector,
842
+ Point,
843
+ ReferenceLine,
844
+ Scrubber,
845
+ XAxis,
846
+ YAxis,
847
+ formatNumber,
848
+ useCartesianChart,
849
+ useChartEvent,
850
+ useFnChart
851
+ };