@x-plat/design-system 0.5.32 → 0.5.33

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.
@@ -157,40 +157,28 @@ var useChartAnimation = (containerRef, dataKey) => {
157
157
  }, [dataKey]);
158
158
  return animate || prefersReducedMotion();
159
159
  };
160
+ var TOOLTIP_OFFSET = 12;
160
161
  var useChartTooltip = (enabled) => {
161
162
  const [tooltip, setTooltip] = import_react.default.useState({
162
163
  visible: false,
163
- x: 0,
164
- y: 0,
164
+ clientX: 0,
165
+ clientY: 0,
165
166
  content: ""
166
167
  });
167
168
  const containerRef = import_react.default.useRef(null);
168
169
  const rafRef = import_react.default.useRef(0);
169
170
  const move = import_react.default.useCallback((e) => {
170
171
  if (!enabled) return;
171
- const clientX = e.clientX;
172
- const clientY = e.clientY;
172
+ const cx = e.clientX;
173
+ const cy = e.clientY;
173
174
  cancelAnimationFrame(rafRef.current);
174
175
  rafRef.current = requestAnimationFrame(() => {
175
- const rect = containerRef.current?.getBoundingClientRect();
176
- if (!rect) return;
177
- setTooltip((prev) => ({
178
- ...prev,
179
- x: clientX - rect.left,
180
- y: clientY - rect.top - 12
181
- }));
176
+ setTooltip((prev) => ({ ...prev, clientX: cx, clientY: cy }));
182
177
  });
183
178
  }, [enabled]);
184
179
  const show = import_react.default.useCallback((e, content) => {
185
180
  if (!enabled) return;
186
- const rect = containerRef.current?.getBoundingClientRect();
187
- if (!rect) return;
188
- setTooltip({
189
- visible: true,
190
- x: e.clientX - rect.left,
191
- y: e.clientY - rect.top - 12,
192
- content
193
- });
181
+ setTooltip({ visible: true, clientX: e.clientX, clientY: e.clientY, content });
194
182
  }, [enabled]);
195
183
  const hide = import_react.default.useCallback(() => {
196
184
  cancelAnimationFrame(rafRef.current);
@@ -224,14 +212,14 @@ var AxisLabels = import_react.default.memo(({ labels, count, chartW, height }) =
224
212
  AxisLabels.displayName = "AxisLabels";
225
213
  var useCrosshair = (seriesPoints, entries, labels, chartH) => {
226
214
  const [activeIndex, setActiveIndex] = import_react.default.useState(null);
227
- const [mouseX, setMouseX] = import_react.default.useState(null);
228
215
  const handleMouseMove = import_react.default.useCallback((e) => {
229
216
  const svg = e.currentTarget;
230
217
  const rect = svg.getBoundingClientRect();
231
218
  const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
232
- setMouseX(mx);
233
219
  if (seriesPoints.length === 0 || seriesPoints[0].length === 0) return;
234
220
  const points = seriesPoints[0];
221
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
222
+ const threshold = step / 2;
235
223
  let closest = 0;
236
224
  let minDist = Math.abs(points[0].x - mx);
237
225
  for (let i = 1; i < points.length; i++) {
@@ -241,11 +229,10 @@ var useCrosshair = (seriesPoints, entries, labels, chartH) => {
241
229
  closest = i;
242
230
  }
243
231
  }
244
- setActiveIndex(closest);
232
+ setActiveIndex(minDist <= threshold ? closest : null);
245
233
  }, [seriesPoints]);
246
234
  const handleMouseLeave = import_react.default.useCallback(() => {
247
235
  setActiveIndex(null);
248
- setMouseX(null);
249
236
  }, []);
250
237
  const tooltipContent = import_react.default.useMemo(() => {
251
238
  if (activeIndex === null) return "";
@@ -254,7 +241,13 @@ var useCrosshair = (seriesPoints, entries, labels, chartH) => {
254
241
  return p ? `${key}: ${p.v}` : "";
255
242
  }).filter(Boolean).join(" / ");
256
243
  }, [activeIndex, entries, seriesPoints]);
257
- return { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent };
244
+ const getTooltipAt = import_react.default.useCallback((idx) => {
245
+ return entries.map(([key], di) => {
246
+ const p = seriesPoints[di]?.[idx];
247
+ return p ? `${key}: ${p.v}` : "";
248
+ }).filter(Boolean).join(" / ");
249
+ }, [entries, seriesPoints]);
250
+ return { activeIndex, handleMouseMove, handleMouseLeave, tooltipContent, getTooltipAt };
258
251
  };
259
252
  var LineChart = import_react.default.memo(({ data, labels, width, height, animate, onHover, onMove, onLeave }) => {
260
253
  const entries = import_react.default.useMemo(() => Object.entries(data), [data]);
@@ -277,7 +270,7 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
277
270
  );
278
271
  const lineRefs = import_react.default.useRef([]);
279
272
  const clipRef = import_react.default.useRef(null);
280
- const { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent } = useCrosshair(seriesPoints, entries, labels, chartH);
273
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
281
274
  import_react.default.useEffect(() => {
282
275
  if (!animate) return;
283
276
  lineRefs.current.forEach((el) => {
@@ -300,8 +293,7 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
300
293
  });
301
294
  }
302
295
  }, [animate, seriesPoints, width]);
303
- const guideX = mouseX != null && mouseX >= PADDING.left && mouseX <= width - PADDING.right ? mouseX : null;
304
- const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x : null;
296
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
305
297
  const lineClipId = "line-area-clip";
306
298
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
307
299
  "svg",
@@ -310,7 +302,26 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
310
302
  className: "chart-svg",
311
303
  onMouseMove: (e) => {
312
304
  handleMouseMove(e);
313
- onMove(e);
305
+ const svg = e.currentTarget;
306
+ const rect = svg.getBoundingClientRect();
307
+ const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
308
+ const points = seriesPoints[0];
309
+ if (!points || points.length === 0) return;
310
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
311
+ let closest = 0;
312
+ let minDist = Math.abs(points[0].x - mx);
313
+ for (let i = 1; i < points.length; i++) {
314
+ const dist = Math.abs(points[i].x - mx);
315
+ if (dist < minDist) {
316
+ minDist = dist;
317
+ closest = i;
318
+ }
319
+ }
320
+ if (minDist <= step / 2) {
321
+ onHover(e, `${labels[closest]} \u2014 ${getTooltipAt(closest)}`);
322
+ } else {
323
+ onLeave();
324
+ }
314
325
  },
315
326
  onMouseLeave: () => {
316
327
  handleMouseLeave();
@@ -365,21 +376,16 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
365
376
  )
366
377
  ] }, di);
367
378
  }),
368
- guideX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
379
+ activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
369
380
  "line",
370
381
  {
371
- x1: guideX,
382
+ x1: activeX,
372
383
  y1: PADDING.top,
373
- x2: guideX,
384
+ x2: activeX,
374
385
  y2: PADDING.top + chartH,
375
386
  className: "chart-crosshair"
376
387
  }
377
388
  ),
378
- activeIndex !== null && activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("foreignObject", { x: activeX - 100, y: 0, width: "200", height: PADDING.top, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-crosshair-label", children: [
379
- labels[activeIndex],
380
- " \u2014 ",
381
- tooltipContent
382
- ] }) }),
383
389
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
384
390
  "rect",
385
391
  {
@@ -417,7 +423,7 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
417
423
  );
418
424
  const lineRefs = import_react.default.useRef([]);
419
425
  const curveClipRef = import_react.default.useRef(null);
420
- const { activeIndex, mouseX, handleMouseMove, handleMouseLeave, tooltipContent } = useCrosshair(seriesPoints, entries, labels, chartH);
426
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
421
427
  import_react.default.useEffect(() => {
422
428
  if (!animate) return;
423
429
  lineRefs.current.forEach((el) => {
@@ -440,8 +446,7 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
440
446
  });
441
447
  }
442
448
  }, [animate, seriesPoints, width]);
443
- const guideX = mouseX != null && mouseX >= PADDING.left && mouseX <= width - PADDING.right ? mouseX : null;
444
- const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x : null;
449
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
445
450
  const curveClipId = "curve-area-clip";
446
451
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
447
452
  "svg",
@@ -450,7 +455,26 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
450
455
  className: "chart-svg",
451
456
  onMouseMove: (e) => {
452
457
  handleMouseMove(e);
453
- onMove(e);
458
+ const svg = e.currentTarget;
459
+ const rect = svg.getBoundingClientRect();
460
+ const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
461
+ const points = seriesPoints[0];
462
+ if (!points || points.length === 0) return;
463
+ const step = points.length > 1 ? Math.abs(points[1].x - points[0].x) : 20;
464
+ let closest = 0;
465
+ let minDist = Math.abs(points[0].x - mx);
466
+ for (let i = 1; i < points.length; i++) {
467
+ const dist = Math.abs(points[i].x - mx);
468
+ if (dist < minDist) {
469
+ minDist = dist;
470
+ closest = i;
471
+ }
472
+ }
473
+ if (minDist <= step / 2) {
474
+ onHover(e, `${labels[closest]} \u2014 ${getTooltipAt(closest)}`);
475
+ } else {
476
+ onLeave();
477
+ }
454
478
  },
455
479
  onMouseLeave: () => {
456
480
  handleMouseLeave();
@@ -505,21 +529,16 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
505
529
  )
506
530
  ] }, di);
507
531
  }),
508
- guideX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
532
+ activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
509
533
  "line",
510
534
  {
511
- x1: guideX,
535
+ x1: activeX,
512
536
  y1: PADDING.top,
513
- x2: guideX,
537
+ x2: activeX,
514
538
  y2: PADDING.top + chartH,
515
539
  className: "chart-crosshair"
516
540
  }
517
541
  ),
518
- activeIndex !== null && activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("foreignObject", { x: activeX - 100, y: 0, width: "200", height: PADDING.top, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-crosshair-label", children: [
519
- labels[activeIndex],
520
- " \u2014 ",
521
- tooltipContent
522
- ] }) }),
523
542
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
524
543
  "rect",
525
544
  {
@@ -698,30 +717,70 @@ var PieDonutChart = import_react.default.memo(
698
717
  }
699
718
  );
700
719
  PieDonutChart.displayName = "PieDonutChart";
701
- var TooltipBubble = ({ x, y, containerWidth, children }) => {
720
+ var ChartTooltipPortal = ({ clientX, clientY, visible, children }) => {
702
721
  const ref = import_react.default.useRef(null);
703
- const [adjustedX, setAdjustedX] = import_react.default.useState(x);
704
- import_react.default.useEffect(() => {
722
+ const [pos, setPos] = import_react.default.useState({ left: 0, top: 0 });
723
+ import_react.default.useLayoutEffect(() => {
705
724
  const el = ref.current;
706
725
  if (!el) return;
707
726
  const w = el.offsetWidth;
708
- const half = w / 2;
709
- const margin = 8;
710
- let nx = x;
711
- if (x - half < margin) nx = half + margin;
712
- else if (x + half > containerWidth - margin) nx = containerWidth - half - margin;
713
- setAdjustedX(nx);
714
- }, [x, containerWidth]);
727
+ const h = el.offsetHeight;
728
+ const vw = window.innerWidth;
729
+ let left = clientX + TOOLTIP_OFFSET;
730
+ let top = clientY - h - TOOLTIP_OFFSET;
731
+ if (left + w > vw - 8) left = clientX - w - TOOLTIP_OFFSET;
732
+ if (top < 8) top = clientY + TOOLTIP_OFFSET;
733
+ if (left < 8) left = 8;
734
+ setPos({ left, top });
735
+ }, [clientX, clientY]);
715
736
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
716
737
  "div",
717
738
  {
718
739
  ref,
719
- className: "chart-tooltip",
720
- style: { left: adjustedX, top: y },
740
+ className: `chart-tooltip ${visible ? "chart-tooltip-show" : "chart-tooltip-hide"}`,
741
+ style: { position: "fixed", left: pos.left, top: pos.top, zIndex: 1100 },
721
742
  children
722
743
  }
723
744
  );
724
745
  };
746
+ var ChartLegend = ({ data, labels, type }) => {
747
+ const entries = Object.entries(data);
748
+ if (type === "pie" || type === "doughnut") {
749
+ const values = entries.flatMap(([, v]) => v);
750
+ const total = values.reduce((a, b) => a + b, 0) || 1;
751
+ const firstKey = entries[0]?.[0] ?? "";
752
+ const colorOffset = hashString(firstKey);
753
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "chart-legend", children: values.map((v, i) => {
754
+ const pct = Math.round(v / total * 100);
755
+ const color = PIE_COLORS[(i + colorOffset) % PIE_COLORS.length];
756
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-legend-item", children: [
757
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "chart-legend-dot", style: { backgroundColor: color } }),
758
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-legend-text", children: [
759
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "chart-legend-label", children: labels[i] || `${i + 1}` }),
760
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "chart-legend-value", children: [
761
+ v.toLocaleString(),
762
+ "(",
763
+ pct,
764
+ "%)"
765
+ ] })
766
+ ] })
767
+ ] }, i);
768
+ }) });
769
+ }
770
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "chart-legend", children: entries.map(([key], di) => {
771
+ const palette = getPalette(LINE_BAR_PALETTES, di, key);
772
+ const color = palette[2];
773
+ const values = entries[di][1];
774
+ const sum = values.reduce((a, b) => a + b, 0);
775
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-legend-item", children: [
776
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "chart-legend-dot", style: { backgroundColor: color } }),
777
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "chart-legend-text", children: [
778
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "chart-legend-label", children: key }),
779
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "chart-legend-value", children: sum.toLocaleString() })
780
+ ] })
781
+ ] }, di);
782
+ }) });
783
+ };
725
784
  var Chart = import_react.default.memo((props) => {
726
785
  const { type, data, labels, tooltip: showTooltip = true } = props;
727
786
  const { tooltip, show, hide, move, containerRef } = useChartTooltip(showTooltip);
@@ -737,7 +796,8 @@ var Chart = import_react.default.memo((props) => {
737
796
  ready && type === "bar" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BarChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
738
797
  ready && type === "pie" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PieDonutChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
739
798
  ready && type === "doughnut" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PieDonutChart, { data: stableData, labels: stableLabels, width, height, animate, isDoughnut: true, onHover: show, onMove: move, onLeave: hide }),
740
- tooltip.visible && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TooltipBubble, { x: tooltip.x, y: tooltip.y, containerWidth: width, children: tooltip.content })
799
+ ready && (type === "bar" || type === "pie" || type === "doughnut") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChartLegend, { data: stableData, labels: stableLabels, type }),
800
+ tooltip.content && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChartTooltipPortal, { clientX: tooltip.clientX, clientY: tooltip.clientY, visible: tooltip.visible, children: tooltip.content })
741
801
  ] });
742
802
  });
743
803
  Chart.displayName = "Chart";
@@ -75,9 +75,9 @@
75
75
  opacity: 1;
76
76
  }
77
77
  .lib-xplat-chart .chart-crosshair {
78
- stroke: var(--semantic-border-subtle);
78
+ stroke: var(--semantic-border-strong);
79
79
  stroke-width: 1;
80
- stroke-dasharray: 4 2;
80
+ stroke-dasharray: 4 3;
81
81
  pointer-events: none;
82
82
  }
83
83
  .lib-xplat-chart .chart-point-active {
@@ -93,18 +93,24 @@
93
93
  overflow: visible;
94
94
  }
95
95
  .lib-xplat-chart .chart-tooltip {
96
- position: absolute;
97
- transform: translate(-50%, -100%);
98
- padding: var(--spacing-space-2) var(--spacing-space-3);
96
+ padding: var(--spacing-space-3);
99
97
  background-color: var(--semantic-surface-neutral-strong);
100
98
  color: var(--semantic-text-inverse);
101
99
  font-size: 12px;
100
+ line-height: 18px;
102
101
  font-weight: 500;
103
102
  border-radius: var(--spacing-radius-md);
104
- white-space: nowrap;
103
+ max-width: 240px;
105
104
  pointer-events: none;
106
- z-index: 10;
107
- animation: chart-tooltip-in 150ms ease-out;
105
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
106
+ }
107
+ .lib-xplat-chart .chart-tooltip.chart-tooltip-show {
108
+ opacity: 1;
109
+ animation: chart-tooltip-in 120ms ease-out;
110
+ }
111
+ .lib-xplat-chart .chart-tooltip.chart-tooltip-hide {
112
+ opacity: 0;
113
+ animation: chart-tooltip-out 80ms ease-in;
108
114
  }
109
115
  .lib-xplat-chart .chart-bar-animate {
110
116
  animation: chart-bar-grow 800ms ease-out both;
@@ -116,6 +122,38 @@
116
122
  .lib-xplat-chart .chart-area[style*=animationDelay] {
117
123
  animation: chart-fade-in 800ms ease-out both;
118
124
  }
125
+ .lib-xplat-chart .chart-legend {
126
+ display: flex;
127
+ flex-wrap: wrap;
128
+ justify-content: center;
129
+ gap: var(--spacing-space-4);
130
+ padding: var(--spacing-space-3) 0;
131
+ }
132
+ .lib-xplat-chart .chart-legend-item {
133
+ display: flex;
134
+ align-items: flex-start;
135
+ gap: var(--spacing-space-2);
136
+ }
137
+ .lib-xplat-chart .chart-legend-dot {
138
+ flex-shrink: 0;
139
+ width: 10px;
140
+ height: 10px;
141
+ border-radius: 50%;
142
+ margin-top: 3px;
143
+ }
144
+ .lib-xplat-chart .chart-legend-text {
145
+ display: flex;
146
+ flex-direction: column;
147
+ }
148
+ .lib-xplat-chart .chart-legend-label {
149
+ font-size: 12px;
150
+ color: var(--semantic-text-muted);
151
+ }
152
+ .lib-xplat-chart .chart-legend-value {
153
+ font-size: 14px;
154
+ font-weight: 600;
155
+ color: var(--semantic-text-strong);
156
+ }
119
157
  @keyframes chart-bar-grow {
120
158
  from {
121
159
  transform: scaleY(0);
@@ -135,11 +173,17 @@
135
173
  @keyframes chart-tooltip-in {
136
174
  from {
137
175
  opacity: 0;
138
- transform: translate(-50%, -90%);
139
176
  }
140
177
  to {
141
178
  opacity: 1;
142
- transform: translate(-50%, -100%);
179
+ }
180
+ }
181
+ @keyframes chart-tooltip-out {
182
+ from {
183
+ opacity: 1;
184
+ }
185
+ to {
186
+ opacity: 0;
143
187
  }
144
188
  }
145
189
  @media (prefers-reduced-motion: reduce) {