@x-plat/design-system 0.5.32 → 0.5.34

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.
@@ -116,45 +116,35 @@ var useChartAnimation = (containerRef, dataKey) => {
116
116
  prevDataKey.current = dataKey;
117
117
  if (prefersReducedMotion()) return;
118
118
  setAnimate(false);
119
- requestAnimationFrame(() => setAnimate(true));
119
+ requestAnimationFrame(() => {
120
+ requestAnimationFrame(() => setAnimate(true));
121
+ });
120
122
  }
121
123
  }, [dataKey]);
122
124
  return animate || prefersReducedMotion();
123
125
  };
126
+ var TOOLTIP_OFFSET = 12;
124
127
  var useChartTooltip = (enabled) => {
125
128
  const [tooltip, setTooltip] = React.useState({
126
129
  visible: false,
127
- x: 0,
128
- y: 0,
130
+ clientX: 0,
131
+ clientY: 0,
129
132
  content: ""
130
133
  });
131
134
  const containerRef = React.useRef(null);
132
135
  const rafRef = React.useRef(0);
133
136
  const move = React.useCallback((e) => {
134
137
  if (!enabled) return;
135
- const clientX = e.clientX;
136
- const clientY = e.clientY;
138
+ const cx = e.clientX;
139
+ const cy = e.clientY;
137
140
  cancelAnimationFrame(rafRef.current);
138
141
  rafRef.current = requestAnimationFrame(() => {
139
- const rect = containerRef.current?.getBoundingClientRect();
140
- if (!rect) return;
141
- setTooltip((prev) => ({
142
- ...prev,
143
- x: clientX - rect.left,
144
- y: clientY - rect.top - 12
145
- }));
142
+ setTooltip((prev) => ({ ...prev, clientX: cx, clientY: cy }));
146
143
  });
147
144
  }, [enabled]);
148
145
  const show = React.useCallback((e, content) => {
149
146
  if (!enabled) return;
150
- const rect = containerRef.current?.getBoundingClientRect();
151
- if (!rect) return;
152
- setTooltip({
153
- visible: true,
154
- x: e.clientX - rect.left,
155
- y: e.clientY - rect.top - 12,
156
- content
157
- });
147
+ setTooltip({ visible: true, clientX: e.clientX, clientY: e.clientY, content });
158
148
  }, [enabled]);
159
149
  const hide = React.useCallback(() => {
160
150
  cancelAnimationFrame(rafRef.current);
@@ -188,14 +178,14 @@ var AxisLabels = React.memo(({ labels, count, chartW, height }) => {
188
178
  AxisLabels.displayName = "AxisLabels";
189
179
  var useCrosshair = (seriesPoints, entries, labels, chartH) => {
190
180
  const [activeIndex, setActiveIndex] = React.useState(null);
191
- const [mouseX, setMouseX] = React.useState(null);
192
181
  const handleMouseMove = React.useCallback((e) => {
193
182
  const svg = e.currentTarget;
194
183
  const rect = svg.getBoundingClientRect();
195
184
  const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
196
- setMouseX(mx);
197
185
  if (seriesPoints.length === 0 || seriesPoints[0].length === 0) return;
198
186
  const points = seriesPoints[0];
187
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
188
+ const threshold = step / 2;
199
189
  let closest = 0;
200
190
  let minDist = Math.abs(points[0].x - mx);
201
191
  for (let i = 1; i < points.length; i++) {
@@ -205,11 +195,10 @@ var useCrosshair = (seriesPoints, entries, labels, chartH) => {
205
195
  closest = i;
206
196
  }
207
197
  }
208
- setActiveIndex(closest);
198
+ setActiveIndex(minDist <= threshold ? closest : null);
209
199
  }, [seriesPoints]);
210
200
  const handleMouseLeave = React.useCallback(() => {
211
201
  setActiveIndex(null);
212
- setMouseX(null);
213
202
  }, []);
214
203
  const tooltipContent = React.useMemo(() => {
215
204
  if (activeIndex === null) return "";
@@ -218,7 +207,13 @@ var useCrosshair = (seriesPoints, entries, labels, chartH) => {
218
207
  return p ? `${key}: ${p.v}` : "";
219
208
  }).filter(Boolean).join(" / ");
220
209
  }, [activeIndex, entries, seriesPoints]);
221
- return { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent };
210
+ const getTooltipAt = React.useCallback((idx) => {
211
+ return entries.map(([key], di) => {
212
+ const p = seriesPoints[di]?.[idx];
213
+ return p ? `${key}: ${p.v}` : "";
214
+ }).filter(Boolean).join(" / ");
215
+ }, [entries, seriesPoints]);
216
+ return { activeIndex, handleMouseMove, handleMouseLeave, tooltipContent, getTooltipAt };
222
217
  };
223
218
  var LineChart = React.memo(({ data, labels, width, height, animate, onHover, onMove, onLeave }) => {
224
219
  const entries = React.useMemo(() => Object.entries(data), [data]);
@@ -239,33 +234,19 @@ var LineChart = React.memo(({ data, labels, width, height, animate, onHover, onM
239
234
  ),
240
235
  [entries, count, chartW, chartH, maxVal]
241
236
  );
242
- const lineRefs = React.useRef([]);
243
237
  const clipRef = React.useRef(null);
244
- const { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent } = useCrosshair(seriesPoints, entries, labels, chartH);
238
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
245
239
  React.useEffect(() => {
246
- if (!animate) return;
247
- lineRefs.current.forEach((el) => {
248
- if (!el) return;
249
- const len = el.getTotalLength();
250
- el.style.strokeDasharray = `${len}`;
251
- el.style.strokeDashoffset = `${len}`;
252
- requestAnimationFrame(() => {
253
- el.style.transition = "stroke-dashoffset 1200ms ease-out 200ms";
254
- el.style.strokeDashoffset = "0";
255
- });
240
+ if (!animate || !clipRef.current) return;
241
+ clipRef.current.setAttribute("width", "0");
242
+ requestAnimationFrame(() => {
243
+ if (clipRef.current) {
244
+ clipRef.current.style.transition = "width 1200ms ease-out 200ms";
245
+ clipRef.current.setAttribute("width", `${width}`);
246
+ }
256
247
  });
257
- if (clipRef.current) {
258
- clipRef.current.setAttribute("width", "0");
259
- requestAnimationFrame(() => {
260
- if (clipRef.current) {
261
- clipRef.current.style.transition = "width 1200ms ease-out 200ms";
262
- clipRef.current.setAttribute("width", `${width}`);
263
- }
264
- });
265
- }
266
- }, [animate, seriesPoints, width]);
267
- const guideX = mouseX != null && mouseX >= PADDING.left && mouseX <= width - PADDING.right ? mouseX : null;
268
- const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x : null;
248
+ }, [animate, width]);
249
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
269
250
  const lineClipId = "line-area-clip";
270
251
  return /* @__PURE__ */ jsxs(
271
252
  "svg",
@@ -274,7 +255,26 @@ var LineChart = React.memo(({ data, labels, width, height, animate, onHover, onM
274
255
  className: "chart-svg",
275
256
  onMouseMove: (e) => {
276
257
  handleMouseMove(e);
277
- onMove(e);
258
+ const svg = e.currentTarget;
259
+ const rect = svg.getBoundingClientRect();
260
+ const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
261
+ const points = seriesPoints[0];
262
+ if (!points || points.length === 0) return;
263
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
264
+ let closest = 0;
265
+ let minDist = Math.abs(points[0].x - mx);
266
+ for (let i = 1; i < points.length; i++) {
267
+ const dist = Math.abs(points[i].x - mx);
268
+ if (dist < minDist) {
269
+ minDist = dist;
270
+ closest = i;
271
+ }
272
+ }
273
+ if (minDist <= step / 2) {
274
+ onHover(e, `${labels[closest]} \u2014 ${getTooltipAt(closest)}`);
275
+ } else {
276
+ onLeave();
277
+ }
278
278
  },
279
279
  onMouseLeave: () => {
280
280
  handleMouseLeave();
@@ -297,26 +297,10 @@ var LineChart = React.memo(({ data, labels, width, height, animate, onHover, onM
297
297
  /* @__PURE__ */ jsx("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.2" }),
298
298
  /* @__PURE__ */ jsx("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0" })
299
299
  ] }) }),
300
- /* @__PURE__ */ jsx(
301
- "path",
302
- {
303
- d: areaD,
304
- fill: `url(#${gradientId})`,
305
- clipPath: animate ? `url(#${lineClipId})` : void 0
306
- }
307
- ),
308
- /* @__PURE__ */ jsx(
309
- "polyline",
310
- {
311
- ref: (el) => {
312
- lineRefs.current[di] = el;
313
- },
314
- points: polyPoints,
315
- fill: "none",
316
- stroke: color,
317
- strokeWidth: "2"
318
- }
319
- ),
300
+ /* @__PURE__ */ jsxs("g", { clipPath: animate ? `url(#${lineClipId})` : void 0, children: [
301
+ /* @__PURE__ */ jsx("path", { d: areaD, fill: `url(#${gradientId})` }),
302
+ /* @__PURE__ */ jsx("polyline", { points: polyPoints, fill: "none", stroke: color, strokeWidth: "2" })
303
+ ] }),
320
304
  activeIndex !== null && points[activeIndex] && /* @__PURE__ */ jsx(
321
305
  "circle",
322
306
  {
@@ -329,21 +313,16 @@ var LineChart = React.memo(({ data, labels, width, height, animate, onHover, onM
329
313
  )
330
314
  ] }, di);
331
315
  }),
332
- guideX !== null && /* @__PURE__ */ jsx(
316
+ activeX !== null && /* @__PURE__ */ jsx(
333
317
  "line",
334
318
  {
335
- x1: guideX,
319
+ x1: activeX,
336
320
  y1: PADDING.top,
337
- x2: guideX,
321
+ x2: activeX,
338
322
  y2: PADDING.top + chartH,
339
323
  className: "chart-crosshair"
340
324
  }
341
325
  ),
342
- activeIndex !== null && activeX !== null && /* @__PURE__ */ jsx("foreignObject", { x: activeX - 100, y: 0, width: "200", height: PADDING.top, children: /* @__PURE__ */ jsxs("div", { className: "chart-crosshair-label", children: [
343
- labels[activeIndex],
344
- " \u2014 ",
345
- tooltipContent
346
- ] }) }),
347
326
  /* @__PURE__ */ jsx(
348
327
  "rect",
349
328
  {
@@ -379,33 +358,19 @@ var CurveChart = React.memo(({ data, labels, width, height, animate, onHover, on
379
358
  ),
380
359
  [entries, count, chartW, chartH, maxVal]
381
360
  );
382
- const lineRefs = React.useRef([]);
383
361
  const curveClipRef = React.useRef(null);
384
- const { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent } = useCrosshair(seriesPoints, entries, labels, chartH);
362
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
385
363
  React.useEffect(() => {
386
- if (!animate) return;
387
- lineRefs.current.forEach((el) => {
388
- if (!el) return;
389
- const len = el.getTotalLength();
390
- el.style.strokeDasharray = `${len}`;
391
- el.style.strokeDashoffset = `${len}`;
392
- requestAnimationFrame(() => {
393
- el.style.transition = "stroke-dashoffset 1200ms ease-out 200ms";
394
- el.style.strokeDashoffset = "0";
395
- });
364
+ if (!animate || !curveClipRef.current) return;
365
+ curveClipRef.current.setAttribute("width", "0");
366
+ requestAnimationFrame(() => {
367
+ if (curveClipRef.current) {
368
+ curveClipRef.current.style.transition = "width 1200ms ease-out 200ms";
369
+ curveClipRef.current.setAttribute("width", `${width}`);
370
+ }
396
371
  });
397
- if (curveClipRef.current) {
398
- curveClipRef.current.setAttribute("width", "0");
399
- requestAnimationFrame(() => {
400
- if (curveClipRef.current) {
401
- curveClipRef.current.style.transition = "width 1200ms ease-out 200ms";
402
- curveClipRef.current.setAttribute("width", `${width}`);
403
- }
404
- });
405
- }
406
- }, [animate, seriesPoints, width]);
407
- const guideX = mouseX != null && mouseX >= PADDING.left && mouseX <= width - PADDING.right ? mouseX : null;
408
- const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x : null;
372
+ }, [animate, width]);
373
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
409
374
  const curveClipId = "curve-area-clip";
410
375
  return /* @__PURE__ */ jsxs(
411
376
  "svg",
@@ -414,7 +379,26 @@ var CurveChart = React.memo(({ data, labels, width, height, animate, onHover, on
414
379
  className: "chart-svg",
415
380
  onMouseMove: (e) => {
416
381
  handleMouseMove(e);
417
- onMove(e);
382
+ const svg = e.currentTarget;
383
+ const rect = svg.getBoundingClientRect();
384
+ const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
385
+ const points = seriesPoints[0];
386
+ if (!points || points.length === 0) return;
387
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
388
+ let closest = 0;
389
+ let minDist = Math.abs(points[0].x - mx);
390
+ for (let i = 1; i < points.length; i++) {
391
+ const dist = Math.abs(points[i].x - mx);
392
+ if (dist < minDist) {
393
+ minDist = dist;
394
+ closest = i;
395
+ }
396
+ }
397
+ if (minDist <= step / 2) {
398
+ onHover(e, `${labels[closest]} \u2014 ${getTooltipAt(closest)}`);
399
+ } else {
400
+ onLeave();
401
+ }
418
402
  },
419
403
  onMouseLeave: () => {
420
404
  handleMouseLeave();
@@ -437,26 +421,10 @@ var CurveChart = React.memo(({ data, labels, width, height, animate, onHover, on
437
421
  /* @__PURE__ */ jsx("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.4" }),
438
422
  /* @__PURE__ */ jsx("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0.02" })
439
423
  ] }) }),
440
- /* @__PURE__ */ jsx(
441
- "path",
442
- {
443
- d: areaPath,
444
- fill: `url(#${gradientId})`,
445
- clipPath: animate ? `url(#${curveClipId})` : void 0
446
- }
447
- ),
448
- /* @__PURE__ */ jsx(
449
- "path",
450
- {
451
- ref: (el) => {
452
- lineRefs.current[di] = el;
453
- },
454
- d: linePath,
455
- fill: "none",
456
- stroke: color,
457
- strokeWidth: "2"
458
- }
459
- ),
424
+ /* @__PURE__ */ jsxs("g", { clipPath: animate ? `url(#${curveClipId})` : void 0, children: [
425
+ /* @__PURE__ */ jsx("path", { d: areaPath, fill: `url(#${gradientId})` }),
426
+ /* @__PURE__ */ jsx("path", { d: linePath, fill: "none", stroke: color, strokeWidth: "2" })
427
+ ] }),
460
428
  activeIndex !== null && points[activeIndex] && /* @__PURE__ */ jsx(
461
429
  "circle",
462
430
  {
@@ -469,21 +437,16 @@ var CurveChart = React.memo(({ data, labels, width, height, animate, onHover, on
469
437
  )
470
438
  ] }, di);
471
439
  }),
472
- guideX !== null && /* @__PURE__ */ jsx(
440
+ activeX !== null && /* @__PURE__ */ jsx(
473
441
  "line",
474
442
  {
475
- x1: guideX,
443
+ x1: activeX,
476
444
  y1: PADDING.top,
477
- x2: guideX,
445
+ x2: activeX,
478
446
  y2: PADDING.top + chartH,
479
447
  className: "chart-crosshair"
480
448
  }
481
449
  ),
482
- activeIndex !== null && activeX !== null && /* @__PURE__ */ jsx("foreignObject", { x: activeX - 100, y: 0, width: "200", height: PADDING.top, children: /* @__PURE__ */ jsxs("div", { className: "chart-crosshair-label", children: [
483
- labels[activeIndex],
484
- " \u2014 ",
485
- tooltipContent
486
- ] }) }),
487
450
  /* @__PURE__ */ jsx(
488
451
  "rect",
489
452
  {
@@ -662,30 +625,70 @@ var PieDonutChart = React.memo(
662
625
  }
663
626
  );
664
627
  PieDonutChart.displayName = "PieDonutChart";
665
- var TooltipBubble = ({ x, y, containerWidth, children }) => {
628
+ var ChartTooltipPortal = ({ clientX, clientY, visible, children }) => {
666
629
  const ref = React.useRef(null);
667
- const [adjustedX, setAdjustedX] = React.useState(x);
668
- React.useEffect(() => {
630
+ const [pos, setPos] = React.useState({ left: 0, top: 0 });
631
+ React.useLayoutEffect(() => {
669
632
  const el = ref.current;
670
633
  if (!el) return;
671
634
  const w = el.offsetWidth;
672
- const half = w / 2;
673
- const margin = 8;
674
- let nx = x;
675
- if (x - half < margin) nx = half + margin;
676
- else if (x + half > containerWidth - margin) nx = containerWidth - half - margin;
677
- setAdjustedX(nx);
678
- }, [x, containerWidth]);
635
+ const h = el.offsetHeight;
636
+ const vw = window.innerWidth;
637
+ let left = clientX + TOOLTIP_OFFSET;
638
+ let top = clientY - h - TOOLTIP_OFFSET;
639
+ if (left + w > vw - 8) left = clientX - w - TOOLTIP_OFFSET;
640
+ if (top < 8) top = clientY + TOOLTIP_OFFSET;
641
+ if (left < 8) left = 8;
642
+ setPos({ left, top });
643
+ }, [clientX, clientY]);
679
644
  return /* @__PURE__ */ jsx(
680
645
  "div",
681
646
  {
682
647
  ref,
683
- className: "chart-tooltip",
684
- style: { left: adjustedX, top: y },
648
+ className: `chart-tooltip ${visible ? "chart-tooltip-show" : "chart-tooltip-hide"}`,
649
+ style: { position: "fixed", left: pos.left, top: pos.top, zIndex: 1100 },
685
650
  children
686
651
  }
687
652
  );
688
653
  };
654
+ var ChartLegend = ({ data, labels, type }) => {
655
+ const entries = Object.entries(data);
656
+ if (type === "pie" || type === "doughnut") {
657
+ const values = entries.flatMap(([, v]) => v);
658
+ const total = values.reduce((a, b) => a + b, 0) || 1;
659
+ const firstKey = entries[0]?.[0] ?? "";
660
+ const colorOffset = hashString(firstKey);
661
+ return /* @__PURE__ */ jsx("div", { className: "chart-legend", children: values.map((v, i) => {
662
+ const pct = Math.round(v / total * 100);
663
+ const color = PIE_COLORS[(i + colorOffset) % PIE_COLORS.length];
664
+ return /* @__PURE__ */ jsxs("div", { className: "chart-legend-item", children: [
665
+ /* @__PURE__ */ jsx("span", { className: "chart-legend-dot", style: { backgroundColor: color } }),
666
+ /* @__PURE__ */ jsxs("div", { className: "chart-legend-text", children: [
667
+ /* @__PURE__ */ jsx("span", { className: "chart-legend-label", children: labels[i] || `${i + 1}` }),
668
+ /* @__PURE__ */ jsxs("span", { className: "chart-legend-value", children: [
669
+ v.toLocaleString(),
670
+ "(",
671
+ pct,
672
+ "%)"
673
+ ] })
674
+ ] })
675
+ ] }, i);
676
+ }) });
677
+ }
678
+ return /* @__PURE__ */ jsx("div", { className: "chart-legend", children: entries.map(([key], di) => {
679
+ const palette = getPalette(LINE_BAR_PALETTES, di, key);
680
+ const color = palette[2];
681
+ const values = entries[di][1];
682
+ const sum = values.reduce((a, b) => a + b, 0);
683
+ return /* @__PURE__ */ jsxs("div", { className: "chart-legend-item", children: [
684
+ /* @__PURE__ */ jsx("span", { className: "chart-legend-dot", style: { backgroundColor: color } }),
685
+ /* @__PURE__ */ jsxs("div", { className: "chart-legend-text", children: [
686
+ /* @__PURE__ */ jsx("span", { className: "chart-legend-label", children: key }),
687
+ /* @__PURE__ */ jsx("span", { className: "chart-legend-value", children: sum.toLocaleString() })
688
+ ] })
689
+ ] }, di);
690
+ }) });
691
+ };
689
692
  var Chart = React.memo((props) => {
690
693
  const { type, data, labels, tooltip: showTooltip = true } = props;
691
694
  const { tooltip, show, hide, move, containerRef } = useChartTooltip(showTooltip);
@@ -701,7 +704,8 @@ var Chart = React.memo((props) => {
701
704
  ready && type === "bar" && /* @__PURE__ */ jsx(BarChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
702
705
  ready && type === "pie" && /* @__PURE__ */ jsx(PieDonutChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
703
706
  ready && type === "doughnut" && /* @__PURE__ */ jsx(PieDonutChart, { data: stableData, labels: stableLabels, width, height, animate, isDoughnut: true, onHover: show, onMove: move, onLeave: hide }),
704
- tooltip.visible && /* @__PURE__ */ jsx(TooltipBubble, { x: tooltip.x, y: tooltip.y, containerWidth: width, children: tooltip.content })
707
+ ready && (type === "bar" || type === "pie" || type === "doughnut") && /* @__PURE__ */ jsx(ChartLegend, { data: stableData, labels: stableLabels, type }),
708
+ tooltip.content && /* @__PURE__ */ jsx(ChartTooltipPortal, { clientX: tooltip.clientX, clientY: tooltip.clientY, visible: tooltip.visible, children: tooltip.content })
705
709
  ] });
706
710
  });
707
711
  Chart.displayName = "Chart";