@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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +36 -2
- package/dist/fn-chart.d.ts +30 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +851 -0
- package/dist/viz/annotations.d.ts +34 -0
- package/dist/viz/axes.d.ts +43 -0
- package/dist/viz/bar.d.ts +17 -0
- package/dist/viz/cartesian-chart.d.ts +64 -0
- package/dist/viz/charts.d.ts +68 -0
- package/dist/viz/context.d.ts +65 -0
- package/dist/viz/index.d.ts +10 -0
- package/dist/viz/legend.d.ts +24 -0
- package/dist/viz/line.d.ts +35 -0
- package/dist/viz/period-selector.d.ts +21 -0
- package/dist/viz/scrubber.d.ts +18 -0
- package/package.json +64 -3
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
|
+
};
|