@automattic/charts 4.0.0 → 4.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +1 -1
  4. package/dist/index.cjs +548 -199
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +21 -0
  7. package/dist/index.d.cts +86 -72
  8. package/dist/index.d.ts +86 -72
  9. package/dist/index.js +549 -201
  10. package/dist/index.js.map +1 -1
  11. package/package.json +2 -1
  12. package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
  13. package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
  14. package/src/charts/heatmap-chart/index.ts +2 -1
  15. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
  16. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  17. package/src/charts/heatmap-chart/private/index.ts +1 -1
  18. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  19. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  20. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
  22. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  23. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  24. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  25. package/src/charts/heatmap-chart/types.ts +6 -0
  26. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  27. package/src/charts/leaderboard-chart/types.ts +2 -2
  28. package/src/charts/line-chart/line-chart.tsx +39 -6
  29. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
  31. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  32. package/src/charts/line-chart/types.ts +13 -2
  33. package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
  34. package/src/components/tooltip/accessible-tooltip.tsx +8 -2
  35. package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
  36. package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
  37. package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
  38. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
  39. package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
  40. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  41. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  42. package/src/hooks/use-chart-data-transform.ts +10 -3
  43. package/src/hooks/use-chart-margin.tsx +54 -9
  44. package/src/index.ts +4 -0
  45. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  46. package/src/types.ts +2 -2
  47. package/src/utils/date-formatting.ts +1 -18
  48. package/src/utils/date-parsing.ts +103 -52
  49. package/src/utils/get-edge-tick-widths.ts +33 -0
  50. package/src/utils/index.ts +1 -0
  51. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  52. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  53. package/src/utils/test/date-parsing.test.ts +16 -1
  54. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  55. package/src/utils/warn-once.ts +23 -0
  56. package/src/visx/types.ts +27 -3
package/dist/index.cjs CHANGED
@@ -31,6 +31,7 @@ let react = require("react");
31
31
  react = __toESM(react);
32
32
  let _visx_vendor_d3_color = require("@visx/vendor/d3-color");
33
33
  let date_fns = require("date-fns");
34
+ let _date_fns_tz = require("@date-fns/tz");
34
35
  let _visx_text = require("@visx/text");
35
36
  let deepmerge = require("deepmerge");
36
37
  deepmerge = __toESM(deepmerge, 1);
@@ -86,46 +87,35 @@ function attachSubComponents(Chart, subComponents) {
86
87
  return Object.assign(Chart, subComponents);
87
88
  }
88
89
  //#endregion
89
- //#region src/utils/date-parsing.ts
90
+ //#region src/utils/warn-once.ts
91
+ const warned = /* @__PURE__ */ new Set();
90
92
  /**
91
- * @file Date parsing utilities using date-fns for local timezone handling
92
- *
93
- * This module provides utilities for parsing various date string formats and converting
94
- * them to local timezone dates using the battle-tested date-fns library. For formats
95
- * without timezone info, they're treated as local. For formats with timezone info,
96
- * they're converted to the equivalent local time.
93
+ * Warns about a host-supplied value once per process, and never in production.
97
94
  *
98
- * Note: And specifically it prevents format `YYYY-MM-DD` being parsed as UTC date.
99
- *
100
- * Key Features:
101
- * - All parsed dates are in local timezone
102
- * - Converts timezone-aware strings to local equivalent
103
- * - Robust input validation and error handling using date-fns
104
- * - TypeScript type safety
105
- * - Much smaller codebase than custom parsing
95
+ * @param key - Identifies the value, so a bad prop on every point warns once.
96
+ * @param message - What the host got wrong and what happens instead.
97
+ */
98
+ const warnOnce = (key, message) => {
99
+ if (warned.has(key) || process.env.NODE_ENV === "production") return;
100
+ warned.add(key);
101
+ console.warn(`[Charts] ${message}`);
102
+ };
103
+ //#endregion
104
+ //#region src/utils/date-parsing.ts
105
+ /**
106
+ * @file Date parsing: a naive string is dated in a supplied IANA zone, or the runtime's own
106
107
  *
107
- * Supported Formats:
108
- * - YYYY-MM-DD (treated as local)
109
- * - YYYY-MM-DD HH:mm:ss (treated as local)
110
- * - YYYY-MM-DD HH:mm (treated as local)
111
- * - YYYY-MM-DDTHH:mm:ss (treated as local)
112
- * - YYYY-MM-DDTHH:mm:ss.SSS (treated as local)
113
- * - YYYY-MM-DDTHH:mm (treated as local)
114
- * - YYYY-MM-DDTHH:mm:ssZ (converted to local)
115
- * - YYYY-MM-DDTHH:mm:ss±HH:mm (converted to local)
108
+ * A string carrying an offset is already an instant and parses the same everywhere. A string
109
+ * without one is only a wall-clock reading, so it means nothing until a zone is named. See
110
+ * `parseAsLocalDate` for the supported formats.
116
111
  *
117
- * @example
118
- * ```typescript
119
- * parseAsLocalDate("2025-01-01"); // Local timezone
120
- * parseAsLocalDate("2025-01-01 14:30:00"); // Local timezone
121
- * parseAsLocalDate("2025-01-01 14:30"); // Local timezone
122
- * parseAsLocalDate("2025-01-01T14:30:45.123"); // Local timezone
123
- * parseAsLocalDate("2025-01-01T14:30:00Z"); // UTC 14:30 → Local equivalent
124
- * parseAsLocalDate("2025-01-01T14:30:00+05:00"); // +05:00 14:30 → Local equivalent
125
- * ```
112
+ * Note: this specifically avoids date-fns's default of parsing `YYYY-MM-DD` as a UTC date.
126
113
  */
127
114
  /**
128
115
  * Checks if a date string contains timezone information
116
+ *
117
+ * The minutes are optional because ISO 8601 allows a bare `±hh`, which `parseISO` reads.
118
+ *
129
119
  * @param {string} dateString - The date string to check for timezone information
130
120
  * @return {boolean} True if the date string contains timezone information, false otherwise
131
121
  */
@@ -133,28 +123,57 @@ const hasTimezone = (dateString) => {
133
123
  const tIndex = dateString.indexOf("T");
134
124
  if (tIndex === -1) return false;
135
125
  if (dateString.endsWith("Z")) return true;
136
- return /[+-]\d{2}:?\d{2}$/.test(dateString.slice(tIndex + 1));
126
+ return /[+-]\d{2}(?::?\d{2})?$/.test(dateString.slice(tIndex + 1));
127
+ };
128
+ const NAIVE = /^(\d{1,4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.(\d{1,3}))?)?)?$/;
129
+ const asUtcMs = ([, ...fields]) => {
130
+ const [year, month, day, hour, minute, second, ms] = fields.map((field) => Number(field ?? 0));
131
+ const date = /* @__PURE__ */ new Date(0);
132
+ date.setUTCFullYear(year, month - 1, day);
133
+ date.setUTCHours(hour, minute, second, ms);
134
+ return date.getTime();
137
135
  };
138
136
  /**
139
- * Parses any supported date string format and returns a local timezone date
137
+ * The instant a wall-clock reading names in a given zone.
140
138
  *
141
- * Uses date-fns for robust parsing and validation. For strings without timezone
142
- * info, treats as local timezone. For strings with timezone info, converts to
143
- * local timezone equivalent.
139
+ * A reading a spring-forward gap deleted moves forward past the gap, and one a fall-back
140
+ * repeated resolves to a single instant. Neither is configurable: see CHARTS-268.
144
141
  *
145
- * Supports:
146
- * - YYYY-MM-DD (local)
147
- * - YYYY-MM-DD HH:mm:ss (local)
148
- * - YYYY-MM-DD HH:mm (local)
149
- * - YYYY-MM-DDTHH:mm:ss (local)
150
- * - YYYY-MM-DDTHH:mm:ss.SSS (local)
151
- * - YYYY-MM-DDTHH:mm (local)
152
- * - YYYY-MM-DDTHH:mm:ssZ (UTC → local)
153
- * - YYYY-MM-DDTHH:mm:ss±HH:mm (offset → local)
154
- * @param {string} dateString - The date string to parse into a local timezone date
155
- * @return {Date} A Date object representing the parsed date in local timezone, or an invalid Date if parsing fails
142
+ * @param wallClock - The reading, as the milliseconds it would be if it were UTC.
143
+ * @param timeZone - IANA zone the reading is dated in.
144
+ * @return The instant, or `null` where `timeZone` is not a zone Intl accepts.
156
145
  */
157
- const parseAsLocalDate = (dateString) => {
146
+ const wallClockToInstant = (wallClock, timeZone) => {
147
+ const first = (0, _date_fns_tz.tzOffset)(timeZone, new Date(wallClock));
148
+ if (Number.isNaN(first)) {
149
+ warnOnce(`parse:timeZone:${timeZone}`, `timeZone ${JSON.stringify(timeZone)} is not a zone Intl accepts, so dates are read in the browser's zone. Pass an IANA name or a UTC offset such as "+05:30".`);
150
+ return null;
151
+ }
152
+ const guess = wallClock - first * 6e4;
153
+ const second = (0, _date_fns_tz.tzOffset)(timeZone, new Date(guess));
154
+ if (first === second) return new Date(guess);
155
+ const corrected = wallClock - second * 6e4;
156
+ const third = (0, _date_fns_tz.tzOffset)(timeZone, new Date(corrected));
157
+ if (second === third) return new Date(corrected);
158
+ return /* @__PURE__ */ new Date(wallClock - Math.min(second, third) * 6e4);
159
+ };
160
+ /**
161
+ * Parses any supported date string format into an instant, dated in `timeZone` or the runtime's own
162
+ *
163
+ * Uses date-fns for robust parsing and validation. A string carrying timezone info
164
+ * is already an instant and is returned as one, whatever `timeZone` says. A string
165
+ * without it is a wall-clock reading, dated in `timeZone` when one is supplied and
166
+ * in the runtime's own zone when none is.
167
+ *
168
+ * A wall clock a DST gap deleted is moved forward past the gap; one a fall-back repeated
169
+ * resolves to a single instant. Neither policy is selectable. A zone `Intl` rejects falls
170
+ * back to the runtime's own, and warns once outside production.
171
+ *
172
+ * @param {string} dateString - The date string to parse into a date
173
+ * @param {string} [timeZone] - IANA zone a naive string is read in; the runtime's own when absent, and ignored by a string that carries its own offset
174
+ * @return {Date} A Date object representing the parsed instant, or an invalid Date if parsing fails
175
+ */
176
+ const parseAsLocalDate = (dateString, timeZone) => {
158
177
  const trimmedString = dateString.trim();
159
178
  if (hasTimezone(trimmedString)) {
160
179
  const isoDate = (0, date_fns.parseISO)(trimmedString);
@@ -170,7 +189,10 @@ const parseAsLocalDate = (dateString) => {
170
189
  "yyyy-MM-dd'T'HH:mm"
171
190
  ]) {
172
191
  const result = (0, date_fns.parse)(trimmedString, format, /* @__PURE__ */ new Date());
173
- if ((0, date_fns.isValid)(result)) return result;
192
+ if (!(0, date_fns.isValid)(result)) continue;
193
+ if (!timeZone) return result;
194
+ const fields = NAIVE.exec(trimmedString);
195
+ return (fields && wallClockToInstant(asUtcMs(fields), timeZone)) ?? result;
174
196
  }
175
197
  return /* @__PURE__ */ new Date(NaN);
176
198
  };
@@ -275,6 +297,31 @@ const formatPercentage = (value) => {
275
297
  } });
276
298
  };
277
299
  //#endregion
300
+ //#region src/utils/get-edge-tick-widths.ts
301
+ /**
302
+ * Rendered widths of the first and last tick labels on an axis.
303
+ *
304
+ * An unmeasurable label reserves nothing, which is what a width of 0 already
305
+ * means to every caller, so it is reported as 0 rather than as its own case.
306
+ *
307
+ * @param ticks - Tick values, in axis order.
308
+ * @param formatTick - Function to format a tick.
309
+ * @param {object} labelStyle - Style object for the label.
310
+ * @return {object} - Widths in pixels.
311
+ */
312
+ const getEdgeTickWidths = (ticks, formatTick, labelStyle) => {
313
+ if (!ticks.length) return {
314
+ first: 0,
315
+ last: 0
316
+ };
317
+ const lastIndex = ticks.length - 1;
318
+ const label = (tick, index) => String(formatTick ? formatTick(tick, index, []) ?? "" : tick);
319
+ return {
320
+ first: (0, _visx_text.getStringWidth)(label(ticks[0], 0), labelStyle) ?? 0,
321
+ last: (0, _visx_text.getStringWidth)(label(ticks[lastIndex], lastIndex), labelStyle) ?? 0
322
+ };
323
+ };
324
+ //#endregion
278
325
  //#region src/utils/get-longest-tick-width.ts
279
326
  /**
280
327
  * Returns the width of the longest tick.
@@ -661,12 +708,6 @@ const getFormatter$1 = (options, { locale, timeZone }) => {
661
708
  }
662
709
  return formatter;
663
710
  };
664
- const warned = /* @__PURE__ */ new Set();
665
- const warnOnce = (key, message) => {
666
- if (warned.has(key) || process.env.NODE_ENV === "production") return;
667
- warned.add(key);
668
- console.warn(`[Charts] ${message}`);
669
- };
670
711
  const isUsable = (formatting) => {
671
712
  try {
672
713
  Intl.DateTimeFormat(formatting.locale, { timeZone: formatting.timeZone });
@@ -1292,13 +1333,28 @@ const useXYChartTheme = (data) => {
1292
1333
  ]);
1293
1334
  };
1294
1335
  //#endregion
1336
+ //#region src/providers/chart-context/hooks/use-chart-formatting.ts
1337
+ const RUNTIME_FORMATTING = {};
1338
+ /**
1339
+ * The locale and time zone dates are rendered in, as set on `GlobalChartsProvider`.
1340
+ *
1341
+ * Falls back to the runtime's own rather than throwing: charts render outside a
1342
+ * provider, and a host that sets neither gets exactly the browser-default
1343
+ * behavior this package had before the context existed.
1344
+ *
1345
+ * @return The host's formatting context.
1346
+ */
1347
+ const useChartFormatting = () => {
1348
+ return (0, react.useContext)(GlobalChartsContext)?.formatting ?? RUNTIME_FORMATTING;
1349
+ };
1350
+ //#endregion
1295
1351
  //#region src/hooks/use-chart-data-transform.ts
1296
1352
  /**
1297
1353
  * Hook that transforms and sorts chart data, handling date parsing and sorting
1298
1354
  *
1299
1355
  * This hook extracts the common data transformation logic used in both line-chart
1300
1356
  * and bar-chart components. It:
1301
- * 1. Parses date strings into Date objects using parseAsLocalDate
1357
+ * 1. Parses date strings into Date objects, dated in the provider's time zone
1302
1358
  * 2. Sorts data points by date when date properties are present
1303
1359
  * 3. Returns the original data unchanged when no date properties are found
1304
1360
  *
@@ -1306,6 +1362,7 @@ const useXYChartTheme = (data) => {
1306
1362
  * @return {SeriesData[]} The transformed and sorted data
1307
1363
  */
1308
1364
  const useChartDataTransform = (data) => {
1365
+ const { timeZone } = useChartFormatting();
1309
1366
  return (0, react.useMemo)(() => {
1310
1367
  const firstPoint = data?.[0]?.data?.[0];
1311
1368
  if (!(firstPoint && ("date" in firstPoint || "dateString" in firstPoint))) return data;
@@ -1314,7 +1371,7 @@ const useChartDataTransform = (data) => {
1314
1371
  data: series.data.map((point) => {
1315
1372
  let date;
1316
1373
  if ("date" in point && point.date) date = point.date;
1317
- else if ("dateString" in point && point.dateString) date = parseAsLocalDate(point.dateString);
1374
+ else if ("dateString" in point && point.dateString) date = parseAsLocalDate(point.dateString, timeZone);
1318
1375
  return {
1319
1376
  ...point,
1320
1377
  date
@@ -1324,7 +1381,7 @@ const useChartDataTransform = (data) => {
1324
1381
  return a.date.getTime() - b.date.getTime();
1325
1382
  })
1326
1383
  }));
1327
- }, [data]);
1384
+ }, [data, timeZone]);
1328
1385
  };
1329
1386
  //#endregion
1330
1387
  //#region src/hooks/use-chart-margin.tsx
@@ -1366,11 +1423,32 @@ const DEFAULT_TICK_LENGTH = 8;
1366
1423
  * measure them via getLongestTickWidth.
1367
1424
  */
1368
1425
  const DEFAULT_Y_TICK_WIDTH = 40;
1426
+ /**
1427
+ * Copy a label style with its lengths spelled out in px.
1428
+ *
1429
+ * `getStringWidth` applies the style through CSSOM. Blink resolves a bare number
1430
+ * on an SVG `<text>`, but that is its own leniency rather than the CSS rule, and
1431
+ * `buildChartTheme` hands us bare numbers.
1432
+ *
1433
+ * @param style - Raw label style from the theme.
1434
+ * @return The same style with px-qualified lengths.
1435
+ */
1436
+ const toMeasurableStyle = (style) => {
1437
+ if (!style) return style;
1438
+ const fontSize = resolveFontSize(style.fontSize);
1439
+ const { letterSpacing } = style;
1440
+ return {
1441
+ ...style,
1442
+ ...fontSize === void 0 ? {} : { fontSize: `${fontSize}px` },
1443
+ ...typeof letterSpacing === "number" ? { letterSpacing: `${letterSpacing}px` } : {}
1444
+ };
1445
+ };
1369
1446
  const getXAxisLabelMetrics = (theme, orientation) => {
1370
1447
  const xAxisStyles = orientation === "top" ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
1371
1448
  return {
1372
1449
  fontSize: resolveFontSize(xAxisStyles?.axisLabel?.fontSize) || resolveFontSize(theme.svgLabelSmall?.fontSize) || DEFAULT_FONT_SIZE,
1373
- tickLength: xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH
1450
+ tickLength: xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH,
1451
+ tickLabelStyle: toMeasurableStyle(xAxisStyles?.tickLabel)
1374
1452
  };
1375
1453
  };
1376
1454
  const useChartMargin = (height, options, data, theme, horizontal = false) => {
@@ -1382,7 +1460,7 @@ const useChartMargin = (height, options, data, theme, horizontal = false) => {
1382
1460
  const maxY = Math.max(...allDataPoints.map((d) => d.value));
1383
1461
  const yScale = (0, _visx_scale.createScale)({
1384
1462
  ...options.yScale,
1385
- domain: [minY, maxY],
1463
+ domain: options.yScale?.domain ?? [minY, maxY],
1386
1464
  range: [height, 0]
1387
1465
  });
1388
1466
  return (0, _visx_scale.getTicks)(yScale, options.axis?.y?.numTicks);
@@ -1401,18 +1479,25 @@ const useChartMargin = (height, options, data, theme, horizontal = false) => {
1401
1479
  };
1402
1480
  const yAxisOrientation = options.axis?.y?.orientation;
1403
1481
  const yAxisStyles = yAxisOrientation === "right" ? theme.axisStyles.y.right : theme.axisStyles.y.left;
1404
- const yTickWidth = getLongestTickWidth(yTicks, options.axis?.y?.tickFormat, yAxisStyles.axisLabel);
1482
+ const yTickWidth = getLongestTickWidth(yTicks, options.axis?.y?.tickFormat, toMeasurableStyle(yAxisStyles.axisLabel));
1405
1483
  const yTickLabelFontSize = resolveFontSize(yAxisStyles?.tickLabel?.fontSize) || DEFAULT_FONT_SIZE;
1406
1484
  const yMarginValue = (yTickWidth ?? DEFAULT_Y_TICK_WIDTH) + (yAxisStyles?.tickLength ?? 0) + Math.ceil(yTickLabelFontSize * .25);
1407
- if (yAxisOrientation === "right") defaultMargin.right = yMarginValue;
1408
- else defaultMargin.left = yMarginValue;
1485
+ if (options.axis?.y?.display !== false) {
1486
+ if (yAxisOrientation === "right") defaultMargin.right = yMarginValue;
1487
+ else defaultMargin.left = yMarginValue;
1488
+ }
1409
1489
  const xOrientation = options.axis?.x?.orientation === "top" ? "top" : "bottom";
1410
- const { fontSize, tickLength } = getXAxisLabelMetrics(theme, xOrientation);
1490
+ const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics(theme, xOrientation);
1411
1491
  const computedXMargin = fontSize + tickLength;
1412
1492
  if (xOrientation === "top") {
1413
1493
  defaultMargin.top = Math.max(defaultMargin.top, computedXMargin);
1414
1494
  defaultMargin.bottom = DEFAULT_BOTTOM_FOR_TOP_AXIS;
1415
1495
  } else defaultMargin.bottom = Math.max(defaultMargin.bottom, computedXMargin);
1496
+ if (options.axis?.x?.display !== false) {
1497
+ const { first, last } = getEdgeTickWidths(options.axis?.x?.tickValues ?? [], options.axis?.x?.tickFormat, tickLabelStyle);
1498
+ defaultMargin.left = Math.max(defaultMargin.left, Math.ceil(first / 2));
1499
+ defaultMargin.right = Math.max(defaultMargin.right, Math.ceil(last / 2));
1500
+ }
1416
1501
  return defaultMargin;
1417
1502
  }, [
1418
1503
  options,
@@ -1688,21 +1773,6 @@ const useGlobalChartsTheme = () => {
1688
1773
  return (0, react.useContext)(GlobalChartsContext)?.theme ?? defaultTheme;
1689
1774
  };
1690
1775
  //#endregion
1691
- //#region src/providers/chart-context/hooks/use-chart-formatting.ts
1692
- const RUNTIME_FORMATTING = {};
1693
- /**
1694
- * The locale and time zone dates are rendered in, as set on `GlobalChartsProvider`.
1695
- *
1696
- * Falls back to the runtime's own rather than throwing: charts render outside a
1697
- * provider, and a host that sets neither gets exactly the browser-default
1698
- * behavior this package had before the context existed.
1699
- *
1700
- * @return The host's formatting context.
1701
- */
1702
- const useChartFormatting = () => {
1703
- return (0, react.useContext)(GlobalChartsContext)?.formatting ?? RUNTIME_FORMATTING;
1704
- };
1705
- //#endregion
1706
1776
  //#region src/components/legend/utils/value-or-identity.ts
1707
1777
  /**
1708
1778
  * Returns an object's value if defined, or the object.
@@ -2142,6 +2212,8 @@ const BaseTooltip = ({ data, top, left, component: Component = DefaultTooltipCon
2142
2212
  //#endregion
2143
2213
  //#region src/components/tooltip/private/bounded-tooltip.tsx
2144
2214
  const DEFAULT_OFFSET = 10;
2215
+ const POINTER_HEIGHT = 6;
2216
+ const clamp = (position, min, max, size) => Math.min(Math.max(position, min), Math.max(min, max - size));
2145
2217
  const isClipping = (element) => {
2146
2218
  const { overflow, overflowX, overflowY } = getComputedStyle(element);
2147
2219
  return [
@@ -2159,33 +2231,49 @@ const findClippingAncestor = (wrapper) => {
2159
2231
  return null;
2160
2232
  };
2161
2233
  /**
2162
- * Where the box goes, in wrapper coordinates. Below and to the right of the
2163
- * anchor; flipped to the other side when that side clips less, as visx's
2164
- * `TooltipWithBounds` decides it; then clamped so the box stays inside
2165
- * `bounds` whenever it fits at all.
2234
+ * Where the box goes, in wrapper coordinates. Automatic placement flips to
2235
+ * the side that clips less, then clamps inside `bounds`; below-axis placement
2236
+ * is centered on the anchor and clamped horizontally.
2166
2237
  *
2167
- * @param params - Anchor, offsets, the box size and the bounds to keep inside.
2238
+ * The flip measures against the visible part of the wrapper, its intersection
2239
+ * with `bounds`. Only the clamp is bound by the page: a box that fits inside the
2240
+ * chart on one side stays there, so it does not reach past the chart into
2241
+ * siblings that paint over it.
2242
+ *
2243
+ * @param params - Anchor, offsets, the box size and the rects to keep inside.
2168
2244
  * @param params.left - Anchor x, in wrapper coordinates.
2169
2245
  * @param params.top - Anchor y, in wrapper coordinates.
2170
2246
  * @param params.offsetLeft - Gap between the anchor and the box, horizontally.
2171
2247
  * @param params.offsetTop - Gap between the anchor and the box, vertically.
2172
2248
  * @param params.box - Rendered size of the box.
2249
+ * @param params.wrapper - The wrapper's own edges, in wrapper coordinates.
2250
+ * @param params.placement - Fixed below-axis placement or automatic flipping.
2173
2251
  * @param params.bounds - Edges the box must keep inside, in wrapper coordinates.
2174
- * @return The box's top-left corner, rounded to whole pixels.
2252
+ * @return The box's top-left corner; fixed placement preserves the axis's subpixel y.
2175
2253
  */
2176
- const getBoundedPosition = ({ left, top, offsetLeft, offsetTop, box, bounds }) => {
2254
+ const getBoundedPosition = ({ left, top, offsetLeft, offsetTop, box, wrapper, bounds, placement = "auto" }) => {
2255
+ if (placement === "below-axis") return {
2256
+ x: Math.round(clamp(left - box.width / 2, bounds.left, bounds.right, box.width)),
2257
+ y: top + POINTER_HEIGHT
2258
+ };
2259
+ const fit = {
2260
+ left: Math.max(wrapper.left, bounds.left),
2261
+ top: Math.max(wrapper.top, bounds.top),
2262
+ right: Math.min(wrapper.right, bounds.right),
2263
+ bottom: Math.min(wrapper.bottom, bounds.bottom)
2264
+ };
2177
2265
  const rightX = left + offsetLeft;
2178
2266
  const leftX = left - offsetLeft - box.width;
2179
- const rightOverflow = rightX + box.width - bounds.right;
2180
- const leftOverflow = bounds.left - leftX;
2267
+ const rightOverflow = rightX + box.width - fit.right;
2268
+ const leftOverflow = fit.left - leftX;
2181
2269
  let x = rightOverflow > 0 && rightOverflow > leftOverflow ? leftX : rightX;
2182
2270
  const downY = top + offsetTop;
2183
2271
  const upY = top - offsetTop - box.height;
2184
- const downOverflow = downY + box.height - bounds.bottom;
2185
- const upOverflow = bounds.top - upY;
2272
+ const downOverflow = downY + box.height - fit.bottom;
2273
+ const upOverflow = fit.top - upY;
2186
2274
  let y = downOverflow > 0 && downOverflow > upOverflow ? upY : downY;
2187
- x = Math.min(Math.max(x, bounds.left), Math.max(bounds.left, bounds.right - box.width));
2188
- y = Math.min(Math.max(y, bounds.top), Math.max(bounds.top, bounds.bottom - box.height));
2275
+ x = clamp(x, bounds.left, bounds.right, box.width);
2276
+ y = clamp(y, bounds.top, bounds.bottom, box.height);
2189
2277
  return {
2190
2278
  x: Math.round(x),
2191
2279
  y: Math.round(y)
@@ -2199,7 +2287,8 @@ const getBoundedPosition = ({ left, top, offsetLeft, offsetTop, box, bounds }) =
2199
2287
  * alone would let the box spill into an `overflow: hidden` card and be cut off.
2200
2288
  *
2201
2289
  * Re-measures on every render, so a box whose content changes width between
2202
- * two hovers is placed for its current size.
2290
+ * two hovers is placed for its current size. See `getBoundedPosition` for the
2291
+ * below-axis exception.
2203
2292
  *
2204
2293
  * @param props - visx `Tooltip` props.
2205
2294
  * @param props.left - Anchor x, in wrapper coordinates.
@@ -2209,9 +2298,10 @@ const getBoundedPosition = ({ left, top, offsetLeft, offsetTop, box, bounds }) =
2209
2298
  * @param props.style - Box styles; visx's defaults unless `unstyled`.
2210
2299
  * @param props.unstyled - Skip `style` and leave the box bare.
2211
2300
  * @param props.children - Box content.
2301
+ * @param props.placement - Fixed below-axis placement or automatic flipping.
2212
2302
  * @return The tooltip box.
2213
2303
  */
2214
- const BoundedTooltip = ({ left = 0, top = 0, offsetLeft = DEFAULT_OFFSET, offsetTop = DEFAULT_OFFSET, style = _visx_tooltip.defaultStyles, unstyled = false, children, ...rest }) => {
2304
+ const BoundedTooltip = ({ left = 0, top = 0, offsetLeft = DEFAULT_OFFSET, offsetTop = DEFAULT_OFFSET, style = _visx_tooltip.defaultStyles, unstyled = false, children, placement = "auto", ...rest }) => {
2215
2305
  const nodeRef = (0, react.useRef)(null);
2216
2306
  const clipRef = (0, react.useRef)(null);
2217
2307
  const [position, setPosition] = (0, react.useState)(null);
@@ -2236,10 +2326,17 @@ const BoundedTooltip = ({ left = 0, top = 0, offsetLeft = DEFAULT_OFFSET, offset
2236
2326
  top,
2237
2327
  offsetLeft,
2238
2328
  offsetTop,
2329
+ placement,
2239
2330
  box: {
2240
2331
  width: own.width,
2241
2332
  height: own.height
2242
2333
  },
2334
+ wrapper: {
2335
+ left: 0,
2336
+ top: 0,
2337
+ right: wrapperRect.width,
2338
+ bottom: wrapperRect.height
2339
+ },
2243
2340
  bounds: {
2244
2341
  left: clipRect.left - wrapperRect.left,
2245
2342
  top: clipRect.top - wrapperRect.top,
@@ -2253,11 +2350,12 @@ const BoundedTooltip = ({ left = 0, top = 0, offsetLeft = DEFAULT_OFFSET, offset
2253
2350
  top,
2254
2351
  offsetLeft,
2255
2352
  offsetTop,
2256
- children
2353
+ children,
2354
+ placement
2257
2355
  ]);
2258
2356
  const x = position?.x ?? left + offsetLeft;
2259
- const y = position?.y ?? top + offsetTop;
2260
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_visx_tooltip.Tooltip, {
2357
+ const y = position?.y ?? top + (placement === "below-axis" ? POINTER_HEIGHT : offsetTop);
2358
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_visx_tooltip.Tooltip, {
2261
2359
  ref: nodeRef,
2262
2360
  style: {
2263
2361
  position: "absolute",
@@ -2267,12 +2365,40 @@ const BoundedTooltip = ({ left = 0, top = 0, offsetLeft = DEFAULT_OFFSET, offset
2267
2365
  ...!unstyled && style
2268
2366
  },
2269
2367
  ...rest,
2270
- children
2368
+ children: [placement === "below-axis" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2369
+ "aria-hidden": "true",
2370
+ style: {
2371
+ position: "absolute",
2372
+ left: left - x - POINTER_HEIGHT,
2373
+ top: -6,
2374
+ width: 12,
2375
+ height: POINTER_HEIGHT,
2376
+ background: "inherit",
2377
+ clipPath: "polygon(50% 0, 100% 100%, 0 100%)",
2378
+ pointerEvents: "none"
2379
+ }
2380
+ }), children]
2271
2381
  });
2272
2382
  };
2273
2383
  //#endregion
2274
2384
  //#region src/components/tooltip/xy-chart-tooltip.tsx
2275
2385
  const CROSSHAIR_STROKE_WIDTH = 1.5;
2386
+ const CROSSHAIR_PAINT_PROPERTIES = /* @__PURE__ */ new Set([
2387
+ "stroke",
2388
+ "strokeWidth",
2389
+ "strokeOpacity",
2390
+ "strokeDasharray",
2391
+ "strokeLinecap",
2392
+ "opacity"
2393
+ ]);
2394
+ const crosshairPaintProps = (props = {}) => {
2395
+ const pickPaint = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => CROSSHAIR_PAINT_PROPERTIES.has(key)));
2396
+ return {
2397
+ ...pickPaint(props),
2398
+ ...props.className === void 0 ? {} : { className: props.className },
2399
+ ...props.style === void 0 ? {} : { style: pickPaint(props.style) }
2400
+ };
2401
+ };
2276
2402
  const DEFAULT_GLYPH_RADIUS = 4;
2277
2403
  const FALLBACK_COLOR = "#222";
2278
2404
  const DEFAULT_TOOLTIP_Z_INDEX = 3;
@@ -2303,7 +2429,7 @@ const defaultRenderGlyph$1 = ({ key, ...props }) => /* @__PURE__ */ (0, react_js
2303
2429
  seriesKey: key,
2304
2430
  ...props
2305
2431
  }, key);
2306
- const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, renderGlyph = defaultRenderGlyph$1, glyphStyle, snapTooltipToDatumX = false, snapTooltipToDatumY = false, showVerticalCrosshair = false, showHorizontalCrosshair = false, showDatumGlyph = false, showSeriesGlyphs = false, verticalCrosshairStyle, horizontalCrosshairStyle, detectBounds = true, zIndex = DEFAULT_TOOLTIP_Z_INDEX, ...rest }) => {
2432
+ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, renderGlyph = defaultRenderGlyph$1, glyphStyle, snapTooltipToDatumX = false, snapTooltipToDatumY = false, showVerticalCrosshair = false, showHorizontalCrosshair = false, showDatumGlyph = false, showSeriesGlyphs = false, verticalCrosshairStyle, horizontalCrosshairStyle, detectBounds = true, tooltipPlacement = "auto", zIndex = DEFAULT_TOOLTIP_Z_INDEX, style, ...rest }) => {
2307
2433
  const tooltipProps = Object.fromEntries(Object.entries(rest).filter(([key]) => !PORTAL_OPTIONS.has(key)));
2308
2434
  const { colorScale, theme, innerHeight = 0, innerWidth = 0, margin, xScale, yScale, dataRegistry } = (0, react.useContext)(_visx_xychart.DataContext) || {};
2309
2435
  const tooltipContent = renderTooltip ? renderTooltip({
@@ -2322,9 +2448,9 @@ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, rende
2322
2448
  };
2323
2449
  const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
2324
2450
  let { tooltipLeft, tooltipTop } = tooltipContext;
2325
- if (nearestDatum && (snapTooltipToDatumX || snapTooltipToDatumY)) {
2451
+ if (nearestDatum && (snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === "below-axis")) {
2326
2452
  const { left, top } = getDatumLeftTop(nearestDatum.key, nearestDatum.datum);
2327
- if (snapTooltipToDatumX && isValidNumber(left)) tooltipLeft = left;
2453
+ if ((snapTooltipToDatumX || tooltipPlacement === "below-axis") && isValidNumber(left)) tooltipLeft = left;
2328
2454
  if (snapTooltipToDatumY && isValidNumber(top)) tooltipTop = top;
2329
2455
  }
2330
2456
  const glyphs = [];
@@ -2358,13 +2484,14 @@ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, rende
2358
2484
  const crosshairStroke = theme?.gridStyles?.stroke ?? theme?.htmlLabel?.color ?? FALLBACK_COLOR;
2359
2485
  const marginTop = margin?.top ?? 0;
2360
2486
  const marginLeft = margin?.left ?? 0;
2361
- const TooltipComponent = detectBounds ? BoundedTooltip : _visx_tooltip.Tooltip;
2487
+ const TooltipComponent = detectBounds || tooltipPlacement === "below-axis" ? BoundedTooltip : _visx_tooltip.Tooltip;
2362
2488
  const boxStyle = {
2363
2489
  ..._visx_tooltip.defaultStyles,
2364
2490
  zIndex,
2365
- background: theme?.backgroundColor ?? "white",
2491
+ backgroundColor: theme?.backgroundColor ?? "white",
2366
2492
  boxShadow: `0 1px 2px ${theme?.htmlLabel?.color ? `${theme.htmlLabel.color}55` : "#22222255"}`,
2367
- ...theme?.htmlLabel
2493
+ ...theme?.htmlLabel,
2494
+ ...style
2368
2495
  };
2369
2496
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", {
2370
2497
  className: "visx-tooltip-overlay",
@@ -2372,32 +2499,33 @@ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, rende
2372
2499
  children: [
2373
2500
  showVerticalCrosshair && isValidNumber(tooltipLeft) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
2374
2501
  className: "visx-crosshair visx-crosshair-vertical",
2502
+ stroke: crosshairStroke,
2503
+ strokeWidth: CROSSHAIR_STROKE_WIDTH,
2504
+ ...crosshairPaintProps(verticalCrosshairStyle),
2375
2505
  x1: tooltipLeft,
2376
2506
  x2: tooltipLeft,
2377
2507
  y1: marginTop,
2378
- y2: marginTop + innerHeight,
2379
- stroke: crosshairStroke,
2380
- strokeWidth: CROSSHAIR_STROKE_WIDTH,
2381
- ...verticalCrosshairStyle
2508
+ y2: marginTop + innerHeight
2382
2509
  }),
2383
2510
  showHorizontalCrosshair && isValidNumber(tooltipTop) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
2384
2511
  className: "visx-crosshair visx-crosshair-horizontal",
2512
+ stroke: crosshairStroke,
2513
+ strokeWidth: CROSSHAIR_STROKE_WIDTH,
2514
+ ...crosshairPaintProps(horizontalCrosshairStyle),
2385
2515
  x1: marginLeft,
2386
2516
  x2: marginLeft + innerWidth,
2387
2517
  y1: tooltipTop,
2388
- y2: tooltipTop,
2389
- stroke: crosshairStroke,
2390
- strokeWidth: CROSSHAIR_STROKE_WIDTH,
2391
- ...horizontalCrosshairStyle
2518
+ y2: tooltipTop
2392
2519
  }),
2393
2520
  glyphs
2394
2521
  ]
2395
2522
  }), container && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TooltipComponent, {
2396
2523
  left: tooltipLeft,
2397
- top: tooltipTop,
2524
+ top: tooltipPlacement === "below-axis" ? marginTop + innerHeight + (margin?.bottom ?? 0) : tooltipTop,
2398
2525
  style: boxStyle,
2399
2526
  applyPositionStyle: true,
2400
2527
  ...tooltipProps,
2528
+ ...tooltipPlacement === "below-axis" && { placement: tooltipPlacement },
2401
2529
  children: tooltipContent
2402
2530
  }), container)] });
2403
2531
  };
@@ -2418,7 +2546,8 @@ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, rende
2418
2546
  * isolation keeps the box's `zIndex` from competing with page chrome. The box
2419
2547
  * flips and clamps to stay inside the nearest ancestor that clips its overflow
2420
2548
  * (or the viewport), so it may extend past the chart wrapper but is never cut
2421
- * off unless that ancestor is smaller than the box itself.
2549
+ * off unless that ancestor is smaller than the box itself. Below-axis placement
2550
+ * pins it beneath the x-axis and applies horizontal bounds only.
2422
2551
  *
2423
2552
  * @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
2424
2553
  * @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
@@ -2535,11 +2664,14 @@ const AccessibleTooltip = ({ renderTooltip, selectedIndex, tooltipRef, keyboardF
2535
2664
  renderTooltip: focusableRenderTooltip
2536
2665
  });
2537
2666
  };
2538
- const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating, setIsNavigating, chartRef, totalPoints, onActivate }) => {
2667
+ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating, setIsNavigating, chartRef, totalPoints, onActivate, preventTooltipScroll = false }) => {
2539
2668
  return {
2540
2669
  tooltipRef: (0, react.useCallback)((element) => {
2541
- if (element && selectedIndex !== void 0) element.focus();
2542
- }, [selectedIndex]),
2670
+ if (element && selectedIndex !== void 0) {
2671
+ if (preventTooltipScroll) element.focus({ preventScroll: true });
2672
+ else element.focus();
2673
+ }
2674
+ }, [preventTooltipScroll, selectedIndex]),
2543
2675
  onChartFocus: (0, react.useCallback)(() => {
2544
2676
  if (!isNavigating && selectedIndex !== void 0) setSelectedIndex(0);
2545
2677
  }, [
@@ -3871,10 +4003,11 @@ const TooltipDate = ({ date, displayResolution }) => {
3871
4003
  * one row per visible series (label + formatted value), sorted descending by
3872
4004
  * value. Reused by AreaChart, which has the same multi-series shape.
3873
4005
  *
3874
- * @param params - visx `RenderTooltipParams< DataPointDate >`, plus the chart's optional `bucketInfo`.
4006
+ * @param params - visx tooltip data and the chart's optional `bucketInfo`.
4007
+ * @param contentStyle - Explicit tooltip content color overrides.
3875
4008
  * @return Tooltip JSX, or `null` when no datum is hovered.
3876
4009
  */
3877
- const renderDefaultTooltip = (params) => {
4010
+ const renderDefaultTooltip = (params, contentStyle) => {
3878
4011
  const { tooltipData, bucketInfo } = params;
3879
4012
  const nearestDatum = tooltipData?.nearestDatum?.datum;
3880
4013
  if (!nearestDatum) return null;
@@ -3884,6 +4017,7 @@ const renderDefaultTooltip = (params) => {
3884
4017
  })).sort((a, b) => b.value - a.value);
3885
4018
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3886
4019
  className: line_chart_module_default["line-chart__tooltip"],
4020
+ style: contentStyle,
3887
4021
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3888
4022
  className: line_chart_module_default["line-chart__tooltip-date"],
3889
4023
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TooltipDate, {
@@ -3933,7 +4067,7 @@ const LineChartScalesRef = ({ chartRef, width, height, margin }) => {
3933
4067
  ]);
3934
4068
  return null;
3935
4069
  };
3936
- const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartId, width, height, className, margin, withTooltips = true, withTooltipCrosshairs, showLegend = false, legend = {}, renderGlyph = defaultRenderGlyph, glyphStyle = {}, withLegendGlyph = false, withGradientFill = false, smoothing = true, curveType, renderTooltip = renderDefaultTooltip, withStartGlyphs = false, withEndGlyphs = false, animation, options = {}, onPointerDown = void 0, onPointerUp = void 0, onPointerMove = void 0, onPointerOut = void 0, onDatumActivate = void 0, zoomable = false, rescaleYOnVisibilityChange = true, defaultHiddenSeries, children, gridVisibility, gap = "md" }, ref) => {
4070
+ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartId, width, height, className, margin, withTooltips = true, withTooltipCrosshairs, showLegend = false, legend = {}, renderGlyph = defaultRenderGlyph, glyphStyle = {}, withLegendGlyph = false, withGradientFill = false, smoothing = true, curveType, renderTooltip = renderDefaultTooltip, tooltipPlacement, tooltipStyle, withStartGlyphs = false, withEndGlyphs = false, animation, options = {}, onPointerDown = void 0, onPointerUp = void 0, onPointerMove = void 0, onPointerOut = void 0, onDatumActivate = void 0, zoomable = false, rescaleYOnVisibilityChange = true, defaultHiddenSeries, children, gridVisibility, gap = "md" }, ref) => {
3937
4071
  const legendInteractive = legend.interactive ?? false;
3938
4072
  const legendCollapseGroups = legend.collapseGroups ?? false;
3939
4073
  const legendShape = legend.shape ?? "line";
@@ -4011,7 +4145,8 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
4011
4145
  setIsNavigating,
4012
4146
  chartRef,
4013
4147
  totalPoints: dataSorted[0]?.data.length || 0,
4014
- onActivate: activateSelectedPoint
4148
+ onActivate: activateSelectedPoint,
4149
+ preventTooltipScroll: tooltipPlacement === "below-axis"
4015
4150
  });
4016
4151
  const chartOptions = (0, react.useMemo)(() => {
4017
4152
  return {
@@ -4120,10 +4255,29 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
4120
4255
  xAccessor: (d) => d?.date,
4121
4256
  yAccessor: (d) => d?.value
4122
4257
  };
4123
- const tooltipRenderer = (0, react.useMemo)(() => (params) => renderTooltip({
4258
+ const resolvedTooltipStyle = (0, react.useMemo)(() => {
4259
+ if (renderTooltip !== renderDefaultTooltip || !tooltipStyle) return tooltipStyle;
4260
+ if (!tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor) return tooltipStyle;
4261
+ return {
4262
+ backgroundColor: "var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))",
4263
+ ...tooltipStyle
4264
+ };
4265
+ }, [renderTooltip, tooltipStyle]);
4266
+ const tooltipRenderer = (0, react.useMemo)(() => (params) => renderTooltip === renderDefaultTooltip ? renderDefaultTooltip({
4124
4267
  ...params,
4125
4268
  bucketInfo
4126
- }), [renderTooltip, bucketInfo]);
4269
+ }, {
4270
+ color: tooltipStyle?.color,
4271
+ background: tooltipStyle?.background,
4272
+ backgroundColor: tooltipStyle?.backgroundColor
4273
+ }) : renderTooltip({
4274
+ ...params,
4275
+ bucketInfo
4276
+ }), [
4277
+ renderTooltip,
4278
+ bucketInfo,
4279
+ tooltipStyle
4280
+ ]);
4127
4281
  if (error) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4128
4282
  className: (0, clsx.default)("line-chart", line_chart_module_default["line-chart"]),
4129
4283
  children: error
@@ -4262,6 +4416,8 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
4262
4416
  withTooltips && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessibleTooltip, {
4263
4417
  detectBounds: true,
4264
4418
  snapTooltipToDatumX: true,
4419
+ tooltipPlacement,
4420
+ style: resolvedTooltipStyle,
4265
4421
  snapTooltipToDatumY: true,
4266
4422
  showSeriesGlyphs: true,
4267
4423
  renderTooltip: tooltipRenderer,
@@ -4269,6 +4425,8 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
4269
4425
  glyphStyle,
4270
4426
  showVerticalCrosshair: withTooltipCrosshairs?.showVertical,
4271
4427
  showHorizontalCrosshair: withTooltipCrosshairs?.showHorizontal,
4428
+ verticalCrosshairStyle: withTooltipCrosshairs?.verticalStyle,
4429
+ horizontalCrosshairStyle: withTooltipCrosshairs?.horizontalStyle,
4272
4430
  selectedIndex,
4273
4431
  tooltipRef,
4274
4432
  keyboardFocusedClassName: line_chart_module_default["line-chart__tooltip--keyboard-focused"],
@@ -6421,9 +6579,13 @@ var heatmap_chart_module_default = {
6421
6579
  "heatmap-chart__cell--placeholder": "a8ccharts-O3YMOW-heatmap-chart__cell--placeholder",
6422
6580
  "heatmap-chart__cell--selected": "a8ccharts-O3YMOW-heatmap-chart__cell--selected",
6423
6581
  "heatmap-chart__cell--strong": "a8ccharts-O3YMOW-heatmap-chart__cell--strong",
6582
+ "heatmap-chart__cell--summary": "a8ccharts-O3YMOW-heatmap-chart__cell--summary",
6424
6583
  "heatmap-chart__cell-value": "a8ccharts-O3YMOW-heatmap-chart__cell-value",
6425
6584
  "heatmap-chart__col-label": "a8ccharts-O3YMOW-heatmap-chart__col-label",
6585
+ "heatmap-chart__col-label--summary": "a8ccharts-O3YMOW-heatmap-chart__col-label--summary",
6426
6586
  "heatmap-chart__empty": "a8ccharts-O3YMOW-heatmap-chart__empty",
6587
+ "heatmap-chart__gap-end": "a8ccharts-O3YMOW-heatmap-chart__gap-end",
6588
+ "heatmap-chart__gap-start": "a8ccharts-O3YMOW-heatmap-chart__gap-start",
6427
6589
  "heatmap-chart__grid": "a8ccharts-O3YMOW-heatmap-chart__grid",
6428
6590
  "heatmap-chart__grid--compact": "a8ccharts-O3YMOW-heatmap-chart__grid--compact",
6429
6591
  "heatmap-chart__grid--height-capped": "a8ccharts-O3YMOW-heatmap-chart__grid--height-capped",
@@ -6437,17 +6599,21 @@ var heatmap_chart_module_default = {
6437
6599
  //#region src/charts/heatmap-chart/private/use-heatmap-colors.ts
6438
6600
  const isPresent = (value) => value !== null && value !== void 0 && !isNaN(value);
6439
6601
  /**
6440
- * Get the min and max values from heatmap data, ignoring null/NaN.
6602
+ * Get the min and max values from heatmap data, ignoring null/NaN. Summary
6603
+ * columns stay out: a roll-up on the scale would flatten every real cell.
6441
6604
  * @param data - The heatmap columns
6442
6605
  * @return Tuple of [min, max] values
6443
6606
  */
6444
6607
  const getValueExtent = (data) => {
6445
6608
  let min = Infinity;
6446
6609
  let max = -Infinity;
6447
- for (const column of data) for (const cell of column.data) {
6448
- if (!isPresent(cell.value)) continue;
6449
- if (cell.value < min) min = cell.value;
6450
- if (cell.value > max) max = cell.value;
6610
+ for (const column of data) {
6611
+ if (column.summary) continue;
6612
+ for (const cell of column.data) {
6613
+ if (!isPresent(cell.value)) continue;
6614
+ if (cell.value < min) min = cell.value;
6615
+ if (cell.value > max) max = cell.value;
6616
+ }
6451
6617
  }
6452
6618
  if (min === Infinity) return [0, 0];
6453
6619
  return [min, max];
@@ -6468,6 +6634,119 @@ const getNormalizedValue = (value, extent) => {
6468
6634
  return Math.min(1, Math.max(0, (value - min) / (max - min)));
6469
6635
  };
6470
6636
  //#endregion
6637
+ //#region src/charts/heatmap-chart/private/civil-day.ts
6638
+ const DAY_MS = 864e5;
6639
+ const KEY_OPTIONS = {
6640
+ calendar: "gregory",
6641
+ numberingSystem: "latn",
6642
+ year: "numeric",
6643
+ month: "2-digit",
6644
+ day: "2-digit"
6645
+ };
6646
+ const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
6647
+ const pad = (value, length) => String(value).padStart(length, "0");
6648
+ /**
6649
+ * The day a UTC proxy stands for.
6650
+ *
6651
+ * @param date - UTC proxy.
6652
+ * @return Its day key.
6653
+ */
6654
+ const civilKey = (date) => `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
6655
+ /**
6656
+ * A day key as a UTC-midnight proxy. UTC has no DST, so every step below is exactly
6657
+ * `DAY_MS`.
6658
+ *
6659
+ * @param key - Day key.
6660
+ * @return The proxy, or null when the key names no real day.
6661
+ */
6662
+ const civilDate = (key) => {
6663
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
6664
+ if (!match) return null;
6665
+ const date = /* @__PURE__ */ new Date(0);
6666
+ date.setUTCFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
6667
+ date.setUTCHours(0, 0, 0, 0);
6668
+ return civilKey(date) === key ? date : null;
6669
+ };
6670
+ /**
6671
+ * The day a written date names, ignoring any time and zone it carries.
6672
+ *
6673
+ * @param text - `yyyy-MM-dd`, optionally followed by a time.
6674
+ * @return Its day key, or null.
6675
+ */
6676
+ const writtenDayKey = (text) => {
6677
+ const match = DATE_PREFIX.exec(text);
6678
+ if (!match) return null;
6679
+ const key = `${match[1]}-${match[2]}-${match[3]}`;
6680
+ return civilDate(key) ? key : null;
6681
+ };
6682
+ /**
6683
+ * Reads the day an instant falls on in a zone.
6684
+ *
6685
+ * @param timeZone - IANA zone name; absent leaves the runtime's own.
6686
+ * @return Reader taking a `Date`, null for a day no key can hold.
6687
+ */
6688
+ const instantDayReader = (timeZone) => {
6689
+ const formatter = new Intl.DateTimeFormat("en-US", {
6690
+ ...KEY_OPTIONS,
6691
+ timeZone
6692
+ });
6693
+ return (date) => {
6694
+ const parts = formatter.formatToParts(date);
6695
+ const read = (type) => parts.find((part) => part.type === type)?.value ?? "";
6696
+ const key = `${pad(Number(read("year")), 4)}-${read("month")}-${read("day")}`;
6697
+ return civilDate(key) ? key : null;
6698
+ };
6699
+ };
6700
+ /**
6701
+ * The day a series point belongs to.
6702
+ *
6703
+ * A `Date`, or a string carrying `Z` or an offset, is an instant read in the host's
6704
+ * zone. Anything else is a wall clock the host wrote, so its date part is the day.
6705
+ *
6706
+ * @param point - Series point.
6707
+ * @param readInstant - Reader from `instantDayReader`.
6708
+ * @return Its day key, or null when the point carries no usable date.
6709
+ */
6710
+ const pointDayKey = (point, readInstant) => {
6711
+ if (point.date instanceof Date && !isNaN(point.date.getTime())) {
6712
+ const key = readInstant(point.date);
6713
+ if (key) return key;
6714
+ }
6715
+ const text = point.dateString;
6716
+ if (!text) return null;
6717
+ if (!hasTimezone(text)) return writtenDayKey(text);
6718
+ const parsed = (0, date_fns.parseISO)(text);
6719
+ return isNaN(parsed.getTime()) ? null : readInstant(parsed);
6720
+ };
6721
+ /**
6722
+ * Steps a proxy by whole days.
6723
+ *
6724
+ * @param date - UTC proxy.
6725
+ * @param days - Days to add; may be negative.
6726
+ * @return The shifted proxy.
6727
+ */
6728
+ const addCivilDays = (date, days) => new Date(date.getTime() + days * DAY_MS);
6729
+ /**
6730
+ * Rounds a proxy down to its week.
6731
+ *
6732
+ * @param date - UTC proxy.
6733
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
6734
+ * @return The proxy for that week's first day.
6735
+ */
6736
+ const startOfCivilWeek = (date, weekStartsOn) => {
6737
+ const daysSinceWeekStart = (date.getUTCDay() - weekStartsOn + 7) % 7;
6738
+ return addCivilDays(date, -daysSinceWeekStart);
6739
+ };
6740
+ /**
6741
+ * Counts the columns a span needs.
6742
+ *
6743
+ * @param from - Earlier UTC proxy.
6744
+ * @param to - Later UTC proxy.
6745
+ * @param weekStartsOn - 0 for Sunday, 1 for Monday.
6746
+ * @return Columns needed to cover the span, inclusive.
6747
+ */
6748
+ const civilWeekSpan = (from, to, weekStartsOn) => Math.floor((startOfCivilWeek(to, weekStartsOn).getTime() - startOfCivilWeek(from, weekStartsOn).getTime()) / (7 * DAY_MS)) + 1;
6749
+ //#endregion
6471
6750
  //#region src/charts/heatmap-chart/private/build-calendar-data.ts
6472
6751
  /** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
6473
6752
  const LABELLED_ROWS = [
@@ -6482,69 +6761,88 @@ const LABELLED_ROWS = [
6482
6761
  * @param bound - Requested bound as `yyyy-MM-dd`, if any.
6483
6762
  * @param fallback - The series' own bound on this side.
6484
6763
  * @param direction - Which way the bound is allowed to move.
6485
- * @return The bound to draw to.
6764
+ * @return The day key to draw to.
6486
6765
  */
6487
6766
  const widenTo = (bound, fallback, direction) => {
6488
- if (!bound) return fallback;
6489
- const parsed = (0, date_fns.parseISO)(bound);
6490
- if (isNaN(parsed.getTime())) return fallback;
6491
- if (direction === "earlier") return parsed < fallback ? parsed : fallback;
6492
- return parsed > fallback ? parsed : fallback;
6493
- };
6494
- const toDate = (point) => {
6495
- if (point.date instanceof Date && !isNaN(point.date.getTime())) return point.date;
6496
- if (point.dateString) {
6497
- const parsed = (0, date_fns.parseISO)(point.dateString);
6498
- if (!isNaN(parsed.getTime())) return parsed;
6767
+ const key = bound ? writtenDayKey(bound) : null;
6768
+ if (!key) {
6769
+ if (bound) warnOnce(`heatmap:gridSpan:${bound}`, `gridSpan.${direction === "earlier" ? "start" : "end"} ${JSON.stringify(bound)} is not a \`yyyy-MM-dd\` day, so the grid is drawn over the series' own span.`);
6770
+ return fallback;
6499
6771
  }
6500
- return null;
6772
+ if (direction === "earlier") return key < fallback ? key : fallback;
6773
+ return key > fallback ? key : fallback;
6501
6774
  };
6775
+ /**
6776
+ * Lay a day-bucketed series out as calendar columns.
6777
+ *
6778
+ * @param series - Points to bucket.
6779
+ * @param options - Grid shape, plus the locale and zone to read days in.
6780
+ * @return Columns and row labels for `HeatmapChart`.
6781
+ */
6502
6782
  const buildCalendarHeatmapData = (series, options = {}) => {
6503
6783
  const weekStartsOn = options.weekStartsOn ?? 1;
6504
6784
  const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
6505
- const entries = series.map((point) => ({
6506
- date: toDate(point),
6507
- value: point.value
6508
- })).filter((entry) => entry.date !== null);
6509
- if (!entries.length) return {
6785
+ const { locale, timeZone } = sanitizeFormatting({
6786
+ locale: options.locale,
6787
+ timeZone: options.timeZone
6788
+ });
6789
+ const readInstant = instantDayReader(timeZone);
6790
+ const valueByDay = /* @__PURE__ */ new Map();
6791
+ let minDayKey;
6792
+ let maxDayKey;
6793
+ for (const point of series) {
6794
+ const key = pointDayKey(point, readInstant);
6795
+ if (!key) {
6796
+ const offending = point.dateString ?? (point.date && String(point.date));
6797
+ warnOnce(`heatmap:unreadableDate:${offending}`, offending ? `${JSON.stringify(offending)} is not a day this can read, so its point is left out of the calendar. A \`dateString\` must start \`yyyy-MM-dd\`.` : "A point carries neither `date` nor `dateString`, so it is left out of the calendar.");
6798
+ continue;
6799
+ }
6800
+ valueByDay.set(key, point.value);
6801
+ if (!minDayKey || key < minDayKey) minDayKey = key;
6802
+ if (!maxDayKey || key > maxDayKey) maxDayKey = key;
6803
+ }
6804
+ if (!minDayKey || !maxDayKey) return {
6510
6805
  data: [],
6511
6806
  rowLabels: []
6512
6807
  };
6513
- const valueByDay = /* @__PURE__ */ new Map();
6514
- let minDate = entries[0].date;
6515
- let maxDate = entries[0].date;
6516
- for (const { date, value } of entries) {
6517
- valueByDay.set((0, date_fns.format)(date, "yyyy-MM-dd"), value);
6518
- if (date < minDate) minDate = date;
6519
- if (date > maxDate) maxDate = date;
6520
- }
6521
- const minDayKey = (0, date_fns.format)(minDate, "yyyy-MM-dd");
6522
- const maxDayKey = (0, date_fns.format)(maxDate, "yyyy-MM-dd");
6523
- const gridMinDate = widenTo(options.gridSpan?.start, minDate, "earlier");
6524
- const gridMaxDate = widenTo(options.gridSpan?.end, maxDate, "later");
6525
- const gridMaxDayKey = (0, date_fns.format)(gridMaxDate, "yyyy-MM-dd");
6526
- const gridStart = (0, date_fns.startOfWeek)(gridMinDate, { weekStartsOn });
6527
- const gridMinDayKey = (0, date_fns.format)(gridMinDate < minDate ? gridStart : gridMinDate, "yyyy-MM-dd");
6528
- const weekCount = (0, date_fns.differenceInCalendarWeeks)(gridMaxDate, gridStart, { weekStartsOn }) + 1;
6529
- const rowLabels = Array.from({ length: 7 }, (_, row) => LABELLED_ROWS.includes(row) ? (0, date_fns.format)((0, date_fns.addDays)(gridStart, row), "EEE") : "");
6808
+ const requestedMinDayKey = widenTo(options.gridSpan?.start, minDayKey, "earlier");
6809
+ const gridMaxDayKey = widenTo(options.gridSpan?.end, maxDayKey, "later");
6810
+ const requestedMinDate = civilDate(requestedMinDayKey);
6811
+ const gridMaxDate = civilDate(gridMaxDayKey);
6812
+ const labelFormatting = {
6813
+ locale,
6814
+ timeZone: "UTC"
6815
+ };
6816
+ const formatWeekday = createDateFormatter({ weekday: "short" }, labelFormatting);
6817
+ const formatMonth = createDateFormatter({ month: "short" }, labelFormatting);
6818
+ const formatDay = createDateFormatter({
6819
+ weekday: "short",
6820
+ month: "short",
6821
+ day: "numeric",
6822
+ year: "numeric"
6823
+ }, labelFormatting);
6824
+ const gridStart = startOfCivilWeek(requestedMinDate, weekStartsOn);
6825
+ const gridMinDayKey = requestedMinDayKey < minDayKey ? civilKey(gridStart) : requestedMinDayKey;
6826
+ const weekCount = civilWeekSpan(gridStart, gridMaxDate, weekStartsOn);
6827
+ const rowLabels = Array.from({ length: 7 }, (_, row) => LABELLED_ROWS.includes(row) ? formatWeekday(addCivilDays(gridStart, row)) : "");
6530
6828
  const MIN_FIRST_MONTH_WEEKS = 2;
6531
- const firstMonth = gridStart.getMonth();
6829
+ const firstMonth = gridStart.getUTCMonth();
6532
6830
  let firstMonthWeeks = 0;
6533
- while (firstMonthWeeks < weekCount && (0, date_fns.addDays)(gridStart, firstMonthWeeks * 7).getMonth() === firstMonth) firstMonthWeeks++;
6831
+ while (firstMonthWeeks < weekCount && addCivilDays(gridStart, firstMonthWeeks * 7).getUTCMonth() === firstMonth) firstMonthWeeks++;
6534
6832
  const showFirstMonthLabel = !(firstMonthWeeks < weekCount) || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
6535
6833
  const data = [];
6536
6834
  let previousMonth = -1;
6537
6835
  for (let week = 0; week < weekCount; week++) {
6538
- const columnStart = (0, date_fns.addDays)(gridStart, week * 7);
6539
- const month = columnStart.getMonth();
6540
- const label = month !== previousMonth && (week !== 0 || showFirstMonthLabel) ? (0, date_fns.format)(columnStart, "MMM") : "";
6836
+ const columnStart = addCivilDays(gridStart, week * 7);
6837
+ const month = columnStart.getUTCMonth();
6838
+ const label = month !== previousMonth && (week !== 0 || showFirstMonthLabel) ? formatMonth(columnStart) : "";
6541
6839
  previousMonth = month;
6542
6840
  const cells = [];
6543
6841
  for (let row = 0; row < 7; row++) {
6544
- const day = (0, date_fns.addDays)(gridStart, week * 7 + row);
6545
- const key = (0, date_fns.format)(day, "yyyy-MM-dd");
6842
+ const day = addCivilDays(gridStart, week * 7 + row);
6843
+ const key = civilKey(day);
6546
6844
  const cell = {
6547
- label: (0, date_fns.format)(day, "EEE, MMM d, yyyy"),
6845
+ label: formatDay(day),
6548
6846
  value: valueByDay.has(key) ? valueByDay.get(key) : null
6549
6847
  };
6550
6848
  if (key < gridMinDayKey || key > gridMaxDayKey) {
@@ -6612,7 +6910,8 @@ const HeatmapLegend = ({ steps = 5, lessLabel, moreLabel }) => {
6612
6910
  //#endregion
6613
6911
  //#region src/charts/heatmap-chart/heatmap-chart.tsx
6614
6912
  const CELL_MIX_FLOOR = .15;
6615
- const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, height = 0, className, compact = false, showValues, maxCellWidth, maxCellHeight, minCellWidth, minCellHeight, rowLabels = [], primaryColor, gap = "md", withTooltips = false, renderTooltip, children }) => {
6913
+ const NO_ROW_LABELS = [];
6914
+ const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, height = 0, className, compact = false, showValues, maxCellWidth, maxCellHeight, minCellWidth, minCellHeight, rowLabels = NO_ROW_LABELS, primaryColor, gap = "md", withTooltips = false, renderTooltip, children }) => {
6616
6915
  const chartId = useChartId(providedChartId);
6617
6916
  const { getElementStyles, theme } = useGlobalChartsContext();
6618
6917
  const scopeElement = useChartScopeElement();
@@ -6763,13 +7062,20 @@ const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, heigh
6763
7062
  const rowTrack = compact ? "var(--a8c-charts-dimension-heatmap-cell-size)" : `minmax(${minCellHeight ?? 0}px, ${maxCellHeight ? `${maxCellHeight}px` : "1fr"})`;
6764
7063
  const gridStyle = {
6765
7064
  "--a8c-charts-color-heatmap-primary": primaryColorHex,
6766
- gridTemplateColumns: `auto repeat(${columns}, ${columnTrack})`,
7065
+ gridTemplateColumns: `auto ${data.some((column) => column.summary) ? data.map((column) => column.summary ? "minmax(auto, max-content)" : columnTrack).join(" ") : `repeat(${columns}, ${columnTrack})`}`,
6767
7066
  gridTemplateRows: `auto repeat(${rows}, ${rowTrack})`
6768
7067
  };
6769
7068
  if (compact) {
6770
7069
  gridStyle["--a8c-charts-dimension-heatmap-cell-gap"] = `${compactCellGap}px`;
6771
7070
  gridStyle["--a8c-charts-dimension-heatmap-cell-size"] = `${compactCellSize}px`;
6772
7071
  }
7072
+ const summaryGaps = (columnIndex) => {
7073
+ if (!data[columnIndex]?.summary) return {};
7074
+ return {
7075
+ [heatmap_chart_module_default["heatmap-chart__gap-start"]]: columnIndex > 0 && !data[columnIndex - 1]?.summary,
7076
+ [heatmap_chart_module_default["heatmap-chart__gap-end"]]: columnIndex < columns - 1 && !data[columnIndex + 1]?.summary
7077
+ };
7078
+ };
6773
7079
  const activeDescendant = selectedIndex !== void 0 ? `${chartId}-cell-${Math.floor(selectedIndex / rows)}-${selectedIndex % rows}` : void 0;
6774
7080
  const heightCapped = !compact && Boolean(maxCellHeight);
6775
7081
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HeatmapContext.Provider, {
@@ -6807,7 +7113,10 @@ const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, heigh
6807
7113
  "aria-hidden": "true",
6808
7114
  className: heatmap_chart_module_default["heatmap-chart__row"],
6809
7115
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {}), data.map((column, columnIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6810
- className: heatmap_chart_module_default["heatmap-chart__col-label"],
7116
+ className: (0, clsx.default)(heatmap_chart_module_default["heatmap-chart__col-label"], {
7117
+ [heatmap_chart_module_default["heatmap-chart__col-label--summary"]]: column.summary,
7118
+ ...summaryGaps(columnIndex)
7119
+ }),
6811
7120
  children: column.label
6812
7121
  }, `col-${columnIndex}`))]
6813
7122
  }), Array.from({ length: rows }).map((_row, rowIndex) => {
@@ -6832,7 +7141,8 @@ const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, heigh
6832
7141
  }, `cell-${columnIndex}-${rowIndex}`);
6833
7142
  const value = cell?.value ?? null;
6834
7143
  const present = isPresent(value);
6835
- const normalized = present ? getNormalizedValue(value, extent) : 0;
7144
+ const filled = present && !column.summary;
7145
+ const normalized = filled ? getNormalizedValue(value, extent) : 0;
6836
7146
  const flatIndex = columnIndex * rows + rowIndex;
6837
7147
  const info = buildTooltipData(columnIndex, rowIndex);
6838
7148
  const accessibleLabel = `${info.cellLabel || `${info.columnLabel ?? ""} ${info.rowLabel ?? ""}`.trim()}: ${info.value === null ? (0, _wordpress_i18n.__)("No data", "jetpack-charts") : (0, _automattic_number_formatters.formatNumber)(info.value)}`;
@@ -6845,14 +7155,16 @@ const HeatmapChartInternal = ({ data, chartId: providedChartId, width = 0, heigh
6845
7155
  "data-column": columnIndex,
6846
7156
  "data-row": rowIndex,
6847
7157
  className: (0, clsx.default)(heatmap_chart_module_default["heatmap-chart__cell"], {
6848
- [heatmap_chart_module_default["heatmap-chart__cell--filled"]]: present,
6849
- [heatmap_chart_module_default["heatmap-chart__cell--strong"]]: present && cellHasLightText(normalized),
7158
+ [heatmap_chart_module_default["heatmap-chart__cell--filled"]]: filled,
7159
+ [heatmap_chart_module_default["heatmap-chart__cell--strong"]]: filled && cellHasLightText(normalized),
7160
+ [heatmap_chart_module_default["heatmap-chart__cell--summary"]]: column.summary,
7161
+ ...summaryGaps(columnIndex),
6850
7162
  [heatmap_chart_module_default["heatmap-chart__cell--selected"]]: selectedIndex === flatIndex
6851
7163
  }),
6852
- style: present ? { "--a8c-charts-heatmap-cell-intensity": normalized } : void 0,
7164
+ style: filled ? { "--a8c-charts-heatmap-cell-intensity": normalized } : void 0,
6853
7165
  onMouseMove: handleCellMouseMove,
6854
7166
  onMouseLeave: handleCellMouseLeave,
6855
- children: drawValues && present && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7167
+ children: (drawValues || column.summary) && present && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6856
7168
  className: heatmap_chart_module_default["heatmap-chart__cell-value"],
6857
7169
  children: (0, _automattic_number_formatters.formatNumberCompact)(value)
6858
7170
  })
@@ -6888,6 +7200,42 @@ const HeatmapChartResponsiveInner = (props) => /* @__PURE__ */ (0, react_jsx_run
6888
7200
  HeatmapChartResponsiveInner.displayName = "HeatmapChart";
6889
7201
  const HeatmapChartResponsive = attachSubComponents(withResponsive(HeatmapChartResponsiveInner), { Legend: HeatmapLegend });
6890
7202
  //#endregion
7203
+ //#region src/charts/heatmap-chart/use-calendar-heatmap-data.ts
7204
+ /**
7205
+ * `buildCalendarHeatmapData` with the locale and zone taken from
7206
+ * `GlobalChartsProvider` where the caller names neither.
7207
+ *
7208
+ * @param series - Points to bucket. Held by reference, so a caller that rebuilds it each render defeats the memo.
7209
+ * @param options - As for `buildCalendarHeatmapData`; `locale` and `timeZone` win over the provider.
7210
+ * @return Columns and row labels for `HeatmapChart`.
7211
+ */
7212
+ const useCalendarHeatmapData = (series, options = {}) => {
7213
+ const formatting = useChartFormatting();
7214
+ const locale = options.locale ?? formatting.locale;
7215
+ const timeZone = options.timeZone ?? formatting.timeZone;
7216
+ const { weekStartsOn, hideOutOfRangeDays } = options;
7217
+ const gridStart = options.gridSpan?.start;
7218
+ const gridEnd = options.gridSpan?.end;
7219
+ return (0, react.useMemo)(() => buildCalendarHeatmapData(series, {
7220
+ locale,
7221
+ timeZone,
7222
+ weekStartsOn,
7223
+ hideOutOfRangeDays,
7224
+ gridSpan: {
7225
+ start: gridStart,
7226
+ end: gridEnd
7227
+ }
7228
+ }), [
7229
+ series,
7230
+ locale,
7231
+ timeZone,
7232
+ weekStartsOn,
7233
+ hideOutOfRangeDays,
7234
+ gridStart,
7235
+ gridEnd
7236
+ ]);
7237
+ };
7238
+ //#endregion
6891
7239
  //#region ../../../node_modules/.pnpm/is-plain-object@5.1.0/node_modules/is-plain-object/dist/is-plain-object.mjs
6892
7240
  /*!
6893
7241
  * is-plain-object <https://github.com/jonschlinkert/is-plain-object>
@@ -7251,7 +7599,7 @@ var require_dist = /* @__PURE__ */ __commonJSMin(((exports) => {
7251
7599
  tslib_1.__exportStar(require_dist$1(), exports);
7252
7600
  }));
7253
7601
  //#endregion
7254
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/hooks/use-update-effect.mjs
7602
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/hooks/use-update-effect.mjs
7255
7603
  function useUpdateEffect(effect, deps) {
7256
7604
  const mountedRef = (0, react.useRef)(false);
7257
7605
  (0, react.useEffect)(() => {
@@ -8889,7 +9237,7 @@ _createEmotion.css;
8889
9237
  _createEmotion.sheet;
8890
9238
  _createEmotion.cache;
8891
9239
  //#endregion
8892
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/hooks/use-cx.mjs
9240
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/hooks/use-cx.mjs
8893
9241
  var isSerializedStyles = (o) => typeof o !== "undefined" && o !== null && ["name", "styles"].every((p) => typeof o[p] !== "undefined");
8894
9242
  var useCx = () => {
8895
9243
  const cache = __unsafe_useEmotionCache();
@@ -8996,7 +9344,7 @@ function memize(fn, options) {
8996
9344
  return memoized;
8997
9345
  }
8998
9346
  //#endregion
8999
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/colors-values.mjs
9347
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/colors-values.mjs
9000
9348
  var white = "#fff";
9001
9349
  var GRAY = {
9002
9350
  900: "#1e1e1e",
@@ -9075,7 +9423,7 @@ var COLORS = Object.freeze({
9075
9423
  ui: UI
9076
9424
  });
9077
9425
  //#endregion
9078
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/config-values.mjs
9426
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/config-values.mjs
9079
9427
  var CONTROL_HEIGHT = "36px";
9080
9428
  var CONTROL_PROPS = {
9081
9429
  controlPaddingX: 12,
@@ -9193,7 +9541,7 @@ function warning(message) {
9193
9541
  logged.add(message);
9194
9542
  }
9195
9543
  //#endregion
9196
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/context-system-provider.mjs
9544
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/context-system-provider.mjs
9197
9545
  var import_es6 = /* @__PURE__ */ __toESM(require_es6(), 1);
9198
9546
  var ComponentsContext = (0, react.createContext)(
9199
9547
  /** @type {Record<string, any>} */
@@ -9220,19 +9568,19 @@ var BaseContextSystemProvider = ({ children, value }) => {
9220
9568
  };
9221
9569
  (0, react.memo)(BaseContextSystemProvider);
9222
9570
  //#endregion
9223
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/constants.mjs
9571
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/constants.mjs
9224
9572
  var COMPONENT_NAMESPACE = "data-wp-component";
9225
9573
  var CONNECTED_NAMESPACE = "data-wp-c16t";
9226
9574
  var CONNECT_STATIC_NAMESPACE = "__contextSystemKey__";
9227
9575
  //#endregion
9228
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/get-styled-class-name-from-key.mjs
9576
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/get-styled-class-name-from-key.mjs
9229
9577
  var import_dist = require_dist();
9230
9578
  function getStyledClassName(namespace) {
9231
9579
  return `components-${(0, import_dist.paramCase)(namespace)}`;
9232
9580
  }
9233
9581
  var getStyledClassNameFromKey = memize(getStyledClassName);
9234
9582
  //#endregion
9235
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/context-connect.mjs
9583
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/context-connect.mjs
9236
9584
  function contextConnect(Component, namespace) {
9237
9585
  return _contextConnect(Component, namespace, { forwardsRef: true });
9238
9586
  }
@@ -9249,7 +9597,7 @@ function _contextConnect(Component, namespace, options) {
9249
9597
  });
9250
9598
  }
9251
9599
  //#endregion
9252
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/utils.mjs
9600
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/utils.mjs
9253
9601
  function getNamespace(componentName) {
9254
9602
  return { [COMPONENT_NAMESPACE]: componentName };
9255
9603
  }
@@ -9257,7 +9605,7 @@ function getConnectedNamespace() {
9257
9605
  return { [CONNECTED_NAMESPACE]: true };
9258
9606
  }
9259
9607
  //#endregion
9260
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/context/use-context-system.mjs
9608
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/context/use-context-system.mjs
9261
9609
  function useContextSystem(props, namespace) {
9262
9610
  const contextSystemProps = useComponentsContext();
9263
9611
  if (typeof namespace === "undefined") globalThis.SCRIPT_DEBUG === true && warning("useContextSystem: Please provide a namespace");
@@ -9277,7 +9625,7 @@ function useContextSystem(props, namespace) {
9277
9625
  return finalComponentProps;
9278
9626
  }
9279
9627
  //#endregion
9280
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/visually-hidden/styles.mjs
9628
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/visually-hidden/styles.mjs
9281
9629
  var visuallyHidden = {
9282
9630
  border: 0,
9283
9631
  clip: "rect(1px, 1px, 1px, 1px)",
@@ -9293,7 +9641,7 @@ var visuallyHidden = {
9293
9641
  wordBreak: "normal"
9294
9642
  };
9295
9643
  //#endregion
9296
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/polymorphic-element.mjs
9644
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/polymorphic-element.mjs
9297
9645
  var customAttributeRegExp = /^(data|aria|x)-/i;
9298
9646
  var eventHandlerRegExp = /^on[A-Z]/;
9299
9647
  var svgElementNames = new Set(`animate animateMotion animateTransform circle clipPath defs desc ellipse
@@ -9383,7 +9731,7 @@ function UnforwardedPolymorphicElement({ as, ...props }, ref) {
9383
9731
  }
9384
9732
  var PolymorphicElement = (0, react.forwardRef)(UnforwardedPolymorphicElement);
9385
9733
  //#endregion
9386
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/view/component.mjs
9734
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/view/component.mjs
9387
9735
  function UnforwardedView({ css, ...restProps }, ref) {
9388
9736
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PolymorphicElement, {
9389
9737
  ref,
@@ -9392,7 +9740,7 @@ function UnforwardedView({ css, ...restProps }, ref) {
9392
9740
  }
9393
9741
  var component_default$2 = Object.assign((0, react.forwardRef)(UnforwardedView), { selector: ".components-view" });
9394
9742
  //#endregion
9395
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/visually-hidden/component.mjs
9743
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/visually-hidden/component.mjs
9396
9744
  function UnconnectedVisuallyHidden(props, forwardedRef) {
9397
9745
  const { style: styleProp, ...contextProps } = useContextSystem(props, "VisuallyHidden");
9398
9746
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(component_default$2, {
@@ -9407,7 +9755,7 @@ function UnconnectedVisuallyHidden(props, forwardedRef) {
9407
9755
  }
9408
9756
  var component_default$1 = contextConnect(UnconnectedVisuallyHidden, "VisuallyHidden");
9409
9757
  //#endregion
9410
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/utils/use-responsive-value.mjs
9758
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/utils/use-responsive-value.mjs
9411
9759
  var breakpoints = [
9412
9760
  "40em",
9413
9761
  "52em",
@@ -9441,7 +9789,7 @@ function useResponsiveValue(values, options = {}) {
9441
9789
  return array[index >= array.length ? array.length - 1 : index];
9442
9790
  }
9443
9791
  //#endregion
9444
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/grid/utils.mjs
9792
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/grid/utils.mjs
9445
9793
  var ALIGNMENTS = {
9446
9794
  bottom: {
9447
9795
  alignItems: "flex-end",
@@ -9489,7 +9837,7 @@ function getAlignmentProps(alignment) {
9489
9837
  return alignment ? ALIGNMENTS[alignment] : {};
9490
9838
  }
9491
9839
  //#endregion
9492
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/grid/hook.mjs
9840
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/grid/hook.mjs
9493
9841
  function useGrid(props) {
9494
9842
  const { align, alignment, className, columnGap, columns = 2, gap = 3, isInline = false, justify, rowGap, rows, templateColumns, templateRows, ...otherProps } = useContextSystem(props, "Grid");
9495
9843
  const column = useResponsiveValue(Array.isArray(columns) ? columns : [columns]);
@@ -9531,7 +9879,7 @@ function useGrid(props) {
9531
9879
  };
9532
9880
  }
9533
9881
  //#endregion
9534
- //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@types+react-dom@18.3.7_@types+react@18.3.31__@types+react_d122c1aea9dc8a45ffffc1d3c2f19116/node_modules/@wordpress/components/build-module/grid/component.mjs
9882
+ //#region ../../../node_modules/.pnpm/@wordpress+components@40.0.0_@date-fns+tz@1.4.1_@types+react-dom@18.3.7_@types+react@18_4546240ebdf94d561ebf2fde285113fe/node_modules/@wordpress/components/build-module/grid/component.mjs
9535
9883
  function UnconnectedGrid(props, forwardedRef) {
9536
9884
  const gridProps = useGrid(props);
9537
9885
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(component_default$2, {
@@ -10833,6 +11181,7 @@ exports.parseHslString = parseHslString;
10833
11181
  exports.prefersLightText = prefersLightText;
10834
11182
  exports.relativeLuminance = relativeLuminance;
10835
11183
  exports.resolveCssVariable = resolveCssVariable;
11184
+ exports.useCalendarHeatmapData = useCalendarHeatmapData;
10836
11185
  exports.useChartFormatting = useChartFormatting;
10837
11186
  exports.useChartLegendItems = useChartLegendItems;
10838
11187
  exports.useChartRegistration = useChartRegistration;