@x-plat/design-system 0.5.31 → 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);
@@ -222,6 +210,45 @@ var AxisLabels = import_react.default.memo(({ labels, count, chartW, height }) =
222
210
  }) });
223
211
  });
224
212
  AxisLabels.displayName = "AxisLabels";
213
+ var useCrosshair = (seriesPoints, entries, labels, chartH) => {
214
+ const [activeIndex, setActiveIndex] = import_react.default.useState(null);
215
+ const handleMouseMove = import_react.default.useCallback((e) => {
216
+ const svg = e.currentTarget;
217
+ const rect = svg.getBoundingClientRect();
218
+ const mx = (e.clientX - rect.left) / rect.width * svg.viewBox.baseVal.width;
219
+ if (seriesPoints.length === 0 || seriesPoints[0].length === 0) return;
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;
223
+ let closest = 0;
224
+ let minDist = Math.abs(points[0].x - mx);
225
+ for (let i = 1; i < points.length; i++) {
226
+ const dist = Math.abs(points[i].x - mx);
227
+ if (dist < minDist) {
228
+ minDist = dist;
229
+ closest = i;
230
+ }
231
+ }
232
+ setActiveIndex(minDist <= threshold ? closest : null);
233
+ }, [seriesPoints]);
234
+ const handleMouseLeave = import_react.default.useCallback(() => {
235
+ setActiveIndex(null);
236
+ }, []);
237
+ const tooltipContent = import_react.default.useMemo(() => {
238
+ if (activeIndex === null) return "";
239
+ return entries.map(([key], di) => {
240
+ const p = seriesPoints[di]?.[activeIndex];
241
+ return p ? `${key}: ${p.v}` : "";
242
+ }).filter(Boolean).join(" / ");
243
+ }, [activeIndex, entries, seriesPoints]);
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 };
251
+ };
225
252
  var LineChart = import_react.default.memo(({ data, labels, width, height, animate, onHover, onMove, onLeave }) => {
226
253
  const entries = import_react.default.useMemo(() => Object.entries(data), [data]);
227
254
  const maxVal = import_react.default.useMemo(() => {
@@ -241,8 +268,9 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
241
268
  ),
242
269
  [entries, count, chartW, chartH, maxVal]
243
270
  );
244
- const showPoints = count <= 100;
245
271
  const lineRefs = import_react.default.useRef([]);
272
+ const clipRef = import_react.default.useRef(null);
273
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
246
274
  import_react.default.useEffect(() => {
247
275
  if (!animate) return;
248
276
  lineRefs.current.forEach((el) => {
@@ -255,61 +283,123 @@ var LineChart = import_react.default.memo(({ data, labels, width, height, animat
255
283
  el.style.strokeDashoffset = "0";
256
284
  });
257
285
  });
258
- }, [animate, seriesPoints]);
259
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: `0 0 ${width} ${height}`, className: "chart-svg", children: [
260
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GridLines, { width, height, chartH, maxVal }),
261
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AxisLabels, { labels, count, chartW, height }),
262
- entries.map(([key], di) => {
263
- const palette = getPalette(LINE_BAR_PALETTES, di, key);
264
- const color = palette[2];
265
- const areaColor = palette[0];
266
- const points = seriesPoints[di];
267
- const gradientId = `line-gradient-${di}`;
268
- const polyPoints = points.map((p) => `${p.x},${p.y}`).join(" ");
269
- const areaD = `M ${points[0].x},${points[0].y} ${points.map((p) => `L ${p.x},${p.y}`).join(" ")} L ${points[points.length - 1].x},${PADDING.top + chartH} L ${points[0].x},${PADDING.top + chartH} Z`;
270
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
271
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [
272
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.2" }),
273
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0" })
274
- ] }) }),
275
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
276
- "path",
286
+ if (clipRef.current) {
287
+ clipRef.current.setAttribute("width", "0");
288
+ requestAnimationFrame(() => {
289
+ if (clipRef.current) {
290
+ clipRef.current.style.transition = "width 1200ms ease-out 200ms";
291
+ clipRef.current.setAttribute("width", `${width}`);
292
+ }
293
+ });
294
+ }
295
+ }, [animate, seriesPoints, width]);
296
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
297
+ const lineClipId = "line-area-clip";
298
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
299
+ "svg",
300
+ {
301
+ viewBox: `0 0 ${width} ${height}`,
302
+ className: "chart-svg",
303
+ onMouseMove: (e) => {
304
+ handleMouseMove(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
+ }
325
+ },
326
+ onMouseLeave: () => {
327
+ handleMouseLeave();
328
+ onLeave();
329
+ },
330
+ children: [
331
+ animate && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("clipPath", { id: lineClipId, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { ref: clipRef, x: "0", y: "0", width: animate ? 0 : width, height }) }) }),
332
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GridLines, { width, height, chartH, maxVal }),
333
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AxisLabels, { labels, count, chartW, height }),
334
+ entries.map(([key], di) => {
335
+ const palette = getPalette(LINE_BAR_PALETTES, di, key);
336
+ const color = palette[2];
337
+ const areaColor = palette[0];
338
+ const points = seriesPoints[di];
339
+ const gradientId = `line-gradient-${di}`;
340
+ const polyPoints = points.map((p) => `${p.x},${p.y}`).join(" ");
341
+ const areaD = `M ${points[0].x},${points[0].y} ${points.map((p) => `L ${p.x},${p.y}`).join(" ")} L ${points[points.length - 1].x},${PADDING.top + chartH} L ${points[0].x},${PADDING.top + chartH} Z`;
342
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
343
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [
344
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.2" }),
345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0" })
346
+ ] }) }),
347
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
348
+ "path",
349
+ {
350
+ d: areaD,
351
+ fill: `url(#${gradientId})`,
352
+ clipPath: animate ? `url(#${lineClipId})` : void 0
353
+ }
354
+ ),
355
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
356
+ "polyline",
357
+ {
358
+ ref: (el) => {
359
+ lineRefs.current[di] = el;
360
+ },
361
+ points: polyPoints,
362
+ fill: "none",
363
+ stroke: color,
364
+ strokeWidth: "2"
365
+ }
366
+ ),
367
+ activeIndex !== null && points[activeIndex] && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
368
+ "circle",
369
+ {
370
+ cx: points[activeIndex].x,
371
+ cy: points[activeIndex].y,
372
+ r: "5",
373
+ fill: color,
374
+ className: "chart-point-active"
375
+ }
376
+ )
377
+ ] }, di);
378
+ }),
379
+ activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
380
+ "line",
277
381
  {
278
- d: areaD,
279
- fill: `url(#${gradientId})`,
280
- className: "chart-area",
281
- style: animate ? { animationDelay: "600ms" } : { opacity: 1 }
382
+ x1: activeX,
383
+ y1: PADDING.top,
384
+ x2: activeX,
385
+ y2: PADDING.top + chartH,
386
+ className: "chart-crosshair"
282
387
  }
283
388
  ),
284
389
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
285
- "polyline",
390
+ "rect",
286
391
  {
287
- ref: (el) => {
288
- lineRefs.current[di] = el;
289
- },
290
- points: polyPoints,
291
- fill: "none",
292
- stroke: color,
293
- strokeWidth: "2"
392
+ x: PADDING.left,
393
+ y: PADDING.top,
394
+ width: chartW,
395
+ height: chartH,
396
+ fill: "transparent",
397
+ style: { cursor: "crosshair" }
294
398
  }
295
- ),
296
- showPoints && points.map((p, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
297
- "circle",
298
- {
299
- cx: p.x,
300
- cy: p.y,
301
- r: "4",
302
- fill: color,
303
- className: "chart-point",
304
- onMouseEnter: (e) => onHover(e, `${key}: ${labels[i]} \u2014 ${p.v}`),
305
- onMouseMove: onMove,
306
- onMouseLeave: onLeave
307
- },
308
- i
309
- ))
310
- ] }, di);
311
- })
312
- ] });
399
+ )
400
+ ]
401
+ }
402
+ );
313
403
  });
314
404
  LineChart.displayName = "LineChart";
315
405
  var CurveChart = import_react.default.memo(({ data, labels, width, height, animate, onHover, onMove, onLeave }) => {
@@ -331,8 +421,9 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
331
421
  ),
332
422
  [entries, count, chartW, chartH, maxVal]
333
423
  );
334
- const showPoints = count <= 100;
335
424
  const lineRefs = import_react.default.useRef([]);
425
+ const curveClipRef = import_react.default.useRef(null);
426
+ const { activeIndex, handleMouseMove, handleMouseLeave, getTooltipAt } = useCrosshair(seriesPoints, entries, labels, chartH);
336
427
  import_react.default.useEffect(() => {
337
428
  if (!animate) return;
338
429
  lineRefs.current.forEach((el) => {
@@ -345,61 +436,123 @@ var CurveChart = import_react.default.memo(({ data, labels, width, height, anima
345
436
  el.style.strokeDashoffset = "0";
346
437
  });
347
438
  });
348
- }, [animate, seriesPoints]);
349
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: `0 0 ${width} ${height}`, className: "chart-svg", children: [
350
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GridLines, { width, height, chartH, maxVal }),
351
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AxisLabels, { labels, count, chartW, height }),
352
- entries.map(([key], di) => {
353
- const palette = getPalette(LINE_BAR_PALETTES, di, key);
354
- const color = palette[2];
355
- const areaColor = palette[0];
356
- const points = seriesPoints[di];
357
- const gradientId = `curve-gradient-${di}`;
358
- const linePath = toSmoothPath(points);
359
- const areaPath = linePath + ` L ${points[points.length - 1].x} ${PADDING.top + chartH} L ${points[0].x} ${PADDING.top + chartH} Z`;
360
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
361
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [
362
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.4" }),
363
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0.02" })
364
- ] }) }),
365
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
366
- "path",
439
+ if (curveClipRef.current) {
440
+ curveClipRef.current.setAttribute("width", "0");
441
+ requestAnimationFrame(() => {
442
+ if (curveClipRef.current) {
443
+ curveClipRef.current.style.transition = "width 1200ms ease-out 200ms";
444
+ curveClipRef.current.setAttribute("width", `${width}`);
445
+ }
446
+ });
447
+ }
448
+ }, [animate, seriesPoints, width]);
449
+ const activeX = activeIndex !== null ? seriesPoints[0]?.[activeIndex]?.x ?? null : null;
450
+ const curveClipId = "curve-area-clip";
451
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
452
+ "svg",
453
+ {
454
+ viewBox: `0 0 ${width} ${height}`,
455
+ className: "chart-svg",
456
+ onMouseMove: (e) => {
457
+ handleMouseMove(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
+ }
478
+ },
479
+ onMouseLeave: () => {
480
+ handleMouseLeave();
481
+ onLeave();
482
+ },
483
+ children: [
484
+ animate && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("clipPath", { id: curveClipId, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { ref: curveClipRef, x: "0", y: "0", width: animate ? 0 : width, height }) }) }),
485
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GridLines, { width, height, chartH, maxVal }),
486
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AxisLabels, { labels, count, chartW, height }),
487
+ entries.map(([key], di) => {
488
+ const palette = getPalette(LINE_BAR_PALETTES, di, key);
489
+ const color = palette[2];
490
+ const areaColor = palette[0];
491
+ const points = seriesPoints[di];
492
+ const gradientId = `curve-gradient-${di}`;
493
+ const linePath = toSmoothPath(points);
494
+ const areaPath = linePath + ` L ${points[points.length - 1].x} ${PADDING.top + chartH} L ${points[0].x} ${PADDING.top + chartH} Z`;
495
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
496
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("linearGradient", { id: gradientId, x1: "0", y1: "0", x2: "0", y2: "1", children: [
497
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "0%", stopColor: areaColor, stopOpacity: "0.4" }),
498
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "100%", stopColor: areaColor, stopOpacity: "0.02" })
499
+ ] }) }),
500
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
501
+ "path",
502
+ {
503
+ d: areaPath,
504
+ fill: `url(#${gradientId})`,
505
+ clipPath: animate ? `url(#${curveClipId})` : void 0
506
+ }
507
+ ),
508
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
509
+ "path",
510
+ {
511
+ ref: (el) => {
512
+ lineRefs.current[di] = el;
513
+ },
514
+ d: linePath,
515
+ fill: "none",
516
+ stroke: color,
517
+ strokeWidth: "2"
518
+ }
519
+ ),
520
+ activeIndex !== null && points[activeIndex] && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
521
+ "circle",
522
+ {
523
+ cx: points[activeIndex].x,
524
+ cy: points[activeIndex].y,
525
+ r: "5",
526
+ fill: color,
527
+ className: "chart-point-active"
528
+ }
529
+ )
530
+ ] }, di);
531
+ }),
532
+ activeX !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
533
+ "line",
367
534
  {
368
- d: areaPath,
369
- fill: `url(#${gradientId})`,
370
- className: "chart-area",
371
- style: animate ? { animationDelay: "600ms" } : { opacity: 1 }
535
+ x1: activeX,
536
+ y1: PADDING.top,
537
+ x2: activeX,
538
+ y2: PADDING.top + chartH,
539
+ className: "chart-crosshair"
372
540
  }
373
541
  ),
374
542
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
375
- "path",
543
+ "rect",
376
544
  {
377
- ref: (el) => {
378
- lineRefs.current[di] = el;
379
- },
380
- d: linePath,
381
- fill: "none",
382
- stroke: color,
383
- strokeWidth: "2"
545
+ x: PADDING.left,
546
+ y: PADDING.top,
547
+ width: chartW,
548
+ height: chartH,
549
+ fill: "transparent",
550
+ style: { cursor: "crosshair" }
384
551
  }
385
- ),
386
- showPoints && points.map((p, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
387
- "circle",
388
- {
389
- cx: p.x,
390
- cy: p.y,
391
- r: "4",
392
- fill: color,
393
- className: "chart-point",
394
- onMouseEnter: (e) => onHover(e, `${key}: ${labels[i]} \u2014 ${p.v}`),
395
- onMouseMove: onMove,
396
- onMouseLeave: onLeave
397
- },
398
- i
399
- ))
400
- ] }, di);
401
- })
402
- ] });
552
+ )
553
+ ]
554
+ }
555
+ );
403
556
  });
404
557
  CurveChart.displayName = "CurveChart";
405
558
  var BarChart = import_react.default.memo(({ data, labels, width, height, animate, onHover, onMove, onLeave }) => {
@@ -564,30 +717,70 @@ var PieDonutChart = import_react.default.memo(
564
717
  }
565
718
  );
566
719
  PieDonutChart.displayName = "PieDonutChart";
567
- var TooltipBubble = ({ x, y, containerWidth, children }) => {
720
+ var ChartTooltipPortal = ({ clientX, clientY, visible, children }) => {
568
721
  const ref = import_react.default.useRef(null);
569
- const [adjustedX, setAdjustedX] = import_react.default.useState(x);
570
- import_react.default.useEffect(() => {
722
+ const [pos, setPos] = import_react.default.useState({ left: 0, top: 0 });
723
+ import_react.default.useLayoutEffect(() => {
571
724
  const el = ref.current;
572
725
  if (!el) return;
573
726
  const w = el.offsetWidth;
574
- const half = w / 2;
575
- const margin = 8;
576
- let nx = x;
577
- if (x - half < margin) nx = half + margin;
578
- else if (x + half > containerWidth - margin) nx = containerWidth - half - margin;
579
- setAdjustedX(nx);
580
- }, [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]);
581
736
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
582
737
  "div",
583
738
  {
584
739
  ref,
585
- className: "chart-tooltip",
586
- 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 },
587
742
  children
588
743
  }
589
744
  );
590
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
+ };
591
784
  var Chart = import_react.default.memo((props) => {
592
785
  const { type, data, labels, tooltip: showTooltip = true } = props;
593
786
  const { tooltip, show, hide, move, containerRef } = useChartTooltip(showTooltip);
@@ -603,7 +796,8 @@ var Chart = import_react.default.memo((props) => {
603
796
  ready && type === "bar" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BarChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
604
797
  ready && type === "pie" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PieDonutChart, { data: stableData, labels: stableLabels, width, height, animate, onHover: show, onMove: move, onLeave: hide }),
605
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 }),
606
- 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 })
607
801
  ] });
608
802
  });
609
803
  Chart.displayName = "Chart";
@@ -74,19 +74,43 @@
74
74
  .lib-xplat-chart .chart-area {
75
75
  opacity: 1;
76
76
  }
77
+ .lib-xplat-chart .chart-crosshair {
78
+ stroke: var(--semantic-border-strong);
79
+ stroke-width: 1;
80
+ stroke-dasharray: 4 3;
81
+ pointer-events: none;
82
+ }
83
+ .lib-xplat-chart .chart-point-active {
84
+ pointer-events: none;
85
+ transition: cx 0.05s, cy 0.05s;
86
+ }
87
+ .lib-xplat-chart .chart-crosshair-label {
88
+ font-size: 11px;
89
+ font-weight: 500;
90
+ color: var(--semantic-text-strong);
91
+ text-align: center;
92
+ white-space: nowrap;
93
+ overflow: visible;
94
+ }
77
95
  .lib-xplat-chart .chart-tooltip {
78
- position: absolute;
79
- transform: translate(-50%, -100%);
80
- padding: var(--spacing-space-2) var(--spacing-space-3);
96
+ padding: var(--spacing-space-3);
81
97
  background-color: var(--semantic-surface-neutral-strong);
82
98
  color: var(--semantic-text-inverse);
83
99
  font-size: 12px;
100
+ line-height: 18px;
84
101
  font-weight: 500;
85
102
  border-radius: var(--spacing-radius-md);
86
- white-space: nowrap;
103
+ max-width: 240px;
87
104
  pointer-events: none;
88
- z-index: 10;
89
- 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;
90
114
  }
91
115
  .lib-xplat-chart .chart-bar-animate {
92
116
  animation: chart-bar-grow 800ms ease-out both;
@@ -98,6 +122,38 @@
98
122
  .lib-xplat-chart .chart-area[style*=animationDelay] {
99
123
  animation: chart-fade-in 800ms ease-out both;
100
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
+ }
101
157
  @keyframes chart-bar-grow {
102
158
  from {
103
159
  transform: scaleY(0);
@@ -117,11 +173,17 @@
117
173
  @keyframes chart-tooltip-in {
118
174
  from {
119
175
  opacity: 0;
120
- transform: translate(-50%, -90%);
121
176
  }
122
177
  to {
123
178
  opacity: 1;
124
- transform: translate(-50%, -100%);
179
+ }
180
+ }
181
+ @keyframes chart-tooltip-out {
182
+ from {
183
+ opacity: 1;
184
+ }
185
+ to {
186
+ opacity: 0;
125
187
  }
126
188
  }
127
189
  @media (prefers-reduced-motion: reduce) {