@automattic/charts 4.1.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/TOKENS.md +6 -4
- package/dist/index.cjs +241 -235
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +9 -0
- package/dist/index.d.cts +10 -10
- package/dist/index.d.ts +10 -10
- package/dist/index.js +241 -235
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/heatmap-chart/{private/build-calendar-data.ts → build-calendar-data.ts} +11 -35
- package/src/charts/heatmap-chart/index.ts +9 -3
- package/src/charts/heatmap-chart/private/index.ts +0 -2
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +1 -1
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +1 -1
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +1 -1
- package/src/charts/heatmap-chart/types.ts +29 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +2 -1
- package/src/charts/line-chart/test/line-chart.test.tsx +31 -0
- package/src/components/tooltip/accessible-tooltip.tsx +32 -10
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +32 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +22 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +2 -1
- package/src/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +35 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.1.1] - 2026-09-15
|
|
9
|
+
### Changed
|
|
10
|
+
- Update package dependencies. [#52297]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix unreadable axis labels in forced-colors mode. [#52268]
|
|
14
|
+
- Return keyboard focus to line and area charts after closing a tooltip with Escape. [#52284]
|
|
15
|
+
|
|
8
16
|
## [4.1.0] - 2026-09-14
|
|
9
17
|
### Added
|
|
10
18
|
- Heatmap: add `locale` and `timeZone` options and a `useCalendarHeatmapData` hook, so a host can bucket and label the calendar in its own zone and language instead of the viewer's. Labels with no `locale` now follow the runtime locale rather than always rendering in English, and a calendar `dateString` must start `yyyy-MM-dd`. [#52114]
|
|
@@ -1063,6 +1071,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
1063
1071
|
- Fixed lints following ESLint rule changes for TS [#40584]
|
|
1064
1072
|
- Fixing a bug in Chart storybook data. [#40640]
|
|
1065
1073
|
|
|
1074
|
+
[4.1.1]: https://github.com/Automattic/charts/compare/v4.1.0...v4.1.1
|
|
1066
1075
|
[4.1.0]: https://github.com/Automattic/charts/compare/v4.0.0...v4.1.0
|
|
1067
1076
|
[4.0.0]: https://github.com/Automattic/charts/compare/v3.2.0...v4.0.0
|
|
1068
1077
|
[3.2.0]: https://github.com/Automattic/charts/compare/v3.1.1...v3.2.0
|
package/TOKENS.md
CHANGED
|
@@ -12,7 +12,7 @@ Tooltips render inside the chart, so they carry the class only when no provider
|
|
|
12
12
|
|
|
13
13
|
Inside a provider a tooltip therefore inherits the provider's inline `theme` var and any consumer rule scoped to the provider wrapper, the same as the chart it belongs to.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Catalog entries normally map to WPDS tokens. Source writes these mappings bare; the LightningCSS plugin in `tsdown.config.ts` injects the WPDS spec value as a fallback into `dist/`:
|
|
16
16
|
|
|
17
17
|
```scss
|
|
18
18
|
:where(.a8c-charts-scope) {
|
|
@@ -28,7 +28,7 @@ Highest first:
|
|
|
28
28
|
|
|
29
29
|
1. The role set on any element between the chart and the provider — the closest declaration wins. A role set on the chart's *own* element reaches only what CSS paints; see "The SVG bridge".
|
|
30
30
|
2. The role set by a consumer rule targeting the provider wrapper. It beats the catalog default because `:where()` is zero-specificity.
|
|
31
|
-
3. The catalog default on the provider wrapper,
|
|
31
|
+
3. The catalog default on the provider wrapper, including the [forced-colors defaults](#one-pair-of-roles-per-axis) when active.
|
|
32
32
|
4. The WPDS spec-value fallback, when no `--wpds-*` token is set either (SSR, jsdom, or WPDS not loaded). This is not a rare corner: WordPress itself defines no `--wpds-*` typography tokens, so in wp-admin the fallback is what renders. It is injected at build time by `@wordpress/theme`'s LightningCSS plugin.
|
|
33
33
|
|
|
34
34
|
Configure catalog colors through CSS; the `theme` prop carries no colors. For local LineChart tooltip overrides, see [Below-Axis Tooltips](./?path=/docs/js-packages-charts-library-charts-line-chart--docs#below-axis-tooltips).
|
|
@@ -44,7 +44,7 @@ Some roles are deliberately narrower than the obvious name, so that moving one t
|
|
|
44
44
|
| `--a8c-charts-color-label-axis` | `--a8c-charts-color-label` | legend labels, `.heatmap-chart__cell-value`, funnel labels, the line-chart tooltip |
|
|
45
45
|
| `--a8c-charts-color-background` | — | — |
|
|
46
46
|
|
|
47
|
-
`--a8c-charts-color-label-axis`
|
|
47
|
+
Outside forced-colors mode, `--a8c-charts-color-label-axis` derives from `--a8c-charts-color-label`, so setting the broad role moves every label at once and setting the narrow one moves only the SVG axis labels. See [One pair of roles per axis](#one-pair-of-roles-per-axis) for forced-colors defaults.
|
|
48
48
|
|
|
49
49
|
`--a8c-charts-color-surface` — the annotation label, `.x-zoom__reset`, tooltips — is a **sibling** of `--a8c-charts-color-background`, not a child. The chart's own background and the surfaces floating over it are set apart from each other often enough that one has to be able to move without the other. The consequence: no single role repaints both.
|
|
50
50
|
|
|
@@ -72,7 +72,7 @@ The scope element is the wrapper a chart is rendered into, which sits **above**
|
|
|
72
72
|
|
|
73
73
|
## Tier 1 — semantic catalog (shared, themeable roles)
|
|
74
74
|
|
|
75
|
-
> These tables are checked against `src/styles/chart-scope.scss` by `src/styles/test/chart-scope.test.ts`. Update the stylesheet and the table together.
|
|
75
|
+
> These tables describe the base defaults outside forced-colors mode and are checked against `src/styles/chart-scope.scss` by `src/styles/test/chart-scope.test.ts`. Update the stylesheet and the table together.
|
|
76
76
|
|
|
77
77
|
| Role | Maps to | Fallback |
|
|
78
78
|
|---|---|---|
|
|
@@ -114,6 +114,8 @@ Each axis names itself: `--a8c-charts-color-axis-x` / `-tick-x` paint the x axis
|
|
|
114
114
|
|
|
115
115
|
Gridlines also stay on one role, `--a8c-charts-color-grid`: visx paints rows and columns from a single style object, so the grid is genuinely one thing rather than two.
|
|
116
116
|
|
|
117
|
+
Under `forced-colors: active`, the axis label role uses `CanvasText`, and the grid, x-axis line, and x-axis tick roles use `GrayText`. The y-axis line and ticks remain hidden by default.
|
|
118
|
+
|
|
117
119
|
The terminal `none` in each chain is explicit rather than load-bearing: `stroke` already starts at `none`, so an undeclared role paints nothing either way.
|
|
118
120
|
|
|
119
121
|
**It would be load-bearing on a `fill`.** A `var()` naming an undeclared property with no fallback is invalid at computed-value time, which drops the declaration rather than leaving it absent — and `fill` starts at *black*. That is why `--a8c-charts-color-label-background` carries a terminal `transparent`: without it, an undeclared role would put a black plate behind every pie label. Give any role read by a `fill` a terminal literal, even when the intent is "paint nothing".
|
package/dist/index.cjs
CHANGED
|
@@ -2489,7 +2489,7 @@ const XyChartTooltipContent = ({ tooltipContext, container, renderTooltip, rende
|
|
|
2489
2489
|
..._visx_tooltip.defaultStyles,
|
|
2490
2490
|
zIndex,
|
|
2491
2491
|
backgroundColor: theme?.backgroundColor ?? "white",
|
|
2492
|
-
boxShadow: `0 1px 2px ${theme?.htmlLabel?.color ? `${theme.htmlLabel.color}55` : "#22222255"}`,
|
|
2492
|
+
boxShadow: `0 1px 2px ${isValidHexColor(theme?.htmlLabel?.color) ? `${theme.htmlLabel.color}55` : "#22222255"}`,
|
|
2493
2493
|
...theme?.htmlLabel,
|
|
2494
2494
|
...style
|
|
2495
2495
|
};
|
|
@@ -2672,7 +2672,8 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
|
|
|
2672
2672
|
else element.focus();
|
|
2673
2673
|
}
|
|
2674
2674
|
}, [preventTooltipScroll, selectedIndex]),
|
|
2675
|
-
onChartFocus: (0, react.useCallback)(() => {
|
|
2675
|
+
onChartFocus: (0, react.useCallback)((event) => {
|
|
2676
|
+
if (event.currentTarget.contains(event.relatedTarget)) return;
|
|
2676
2677
|
if (!isNavigating && selectedIndex !== void 0) setSelectedIndex(0);
|
|
2677
2678
|
}, [
|
|
2678
2679
|
isNavigating,
|
|
@@ -2684,15 +2685,19 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
|
|
|
2684
2685
|
}, [setIsNavigating]),
|
|
2685
2686
|
onChartKeyDown: (0, react.useCallback)((event) => {
|
|
2686
2687
|
if (totalPoints === 0) return;
|
|
2688
|
+
const focusChart = () => {
|
|
2689
|
+
if (preventTooltipScroll) chartRef.current?.focus({ preventScroll: true });
|
|
2690
|
+
else chartRef.current?.focus();
|
|
2691
|
+
};
|
|
2687
2692
|
if (event.key === "Tab") {
|
|
2688
|
-
|
|
2693
|
+
focusChart();
|
|
2689
2694
|
setSelectedIndex(void 0);
|
|
2690
2695
|
setIsNavigating(false);
|
|
2691
2696
|
return;
|
|
2692
2697
|
}
|
|
2693
2698
|
const currentSelectedIndex = selectedIndex === void 0 ? -1 : selectedIndex;
|
|
2694
2699
|
if (currentSelectedIndex + 1 >= totalPoints && ["ArrowRight"].includes(event.key)) {
|
|
2695
|
-
|
|
2700
|
+
focusChart();
|
|
2696
2701
|
setSelectedIndex(void 0);
|
|
2697
2702
|
setIsNavigating(false);
|
|
2698
2703
|
return;
|
|
@@ -2707,7 +2712,7 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
|
|
|
2707
2712
|
} else if (event.key === "Escape") {
|
|
2708
2713
|
setSelectedIndex(void 0);
|
|
2709
2714
|
setIsNavigating(false);
|
|
2710
|
-
|
|
2715
|
+
focusChart();
|
|
2711
2716
|
} else if ((event.key === "Enter" || event.key === " ") && selectedIndex !== void 0) onActivate?.(selectedIndex);
|
|
2712
2717
|
}, [
|
|
2713
2718
|
totalPoints,
|
|
@@ -2715,7 +2720,8 @@ const useKeyboardNavigation = ({ selectedIndex, setSelectedIndex, isNavigating,
|
|
|
2715
2720
|
setSelectedIndex,
|
|
2716
2721
|
setIsNavigating,
|
|
2717
2722
|
chartRef,
|
|
2718
|
-
onActivate
|
|
2723
|
+
onActivate,
|
|
2724
|
+
preventTooltipScroll
|
|
2719
2725
|
])
|
|
2720
2726
|
};
|
|
2721
2727
|
};
|
|
@@ -4318,6 +4324,7 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
|
|
|
4318
4324
|
children: ({ contentHeight }) => {
|
|
4319
4325
|
const chartHeight = contentHeight > 0 ? contentHeight : height;
|
|
4320
4326
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4327
|
+
ref: chartRef,
|
|
4321
4328
|
role: "grid",
|
|
4322
4329
|
"aria-label": (0, _wordpress_i18n.__)("Line chart", "jetpack-charts"),
|
|
4323
4330
|
tabIndex: 0,
|
|
@@ -4325,7 +4332,6 @@ const LineChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
|
|
|
4325
4332
|
onFocus: onChartFocus,
|
|
4326
4333
|
onBlur: onChartBlur,
|
|
4327
4334
|
children: chartHeight > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4328
|
-
ref: chartRef,
|
|
4329
4335
|
className: xy_plot_module_default["xy-plot"],
|
|
4330
4336
|
children: [zoomable && zoom.domain && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomResetButton, { onClick: zoom.reset }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_visx_xychart.XYChart, {
|
|
4331
4337
|
theme,
|
|
@@ -4823,6 +4829,7 @@ const AreaChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
|
|
|
4823
4829
|
children: ({ contentHeight }) => {
|
|
4824
4830
|
const chartHeight = contentHeight > 0 ? contentHeight : height;
|
|
4825
4831
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
4832
|
+
ref: chartRef,
|
|
4826
4833
|
role: "grid",
|
|
4827
4834
|
"aria-label": (0, _wordpress_i18n.__)("Area chart", "jetpack-charts"),
|
|
4828
4835
|
tabIndex: 0,
|
|
@@ -4830,7 +4837,6 @@ const AreaChartInternal = (0, react.forwardRef)(({ data, chartId: providedChartI
|
|
|
4830
4837
|
onFocus: onChartFocus,
|
|
4831
4838
|
onBlur: onChartBlur,
|
|
4832
4839
|
children: chartHeight > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
4833
|
-
ref: chartRef,
|
|
4834
4840
|
className: xy_plot_module_default["xy-plot"],
|
|
4835
4841
|
children: [zoomable && zoom.domain && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomResetButton, { onClick: zoom.reset }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_visx_xychart.XYChart, {
|
|
4836
4842
|
theme,
|
|
@@ -6634,233 +6640,6 @@ const getNormalizedValue = (value, extent) => {
|
|
|
6634
6640
|
return Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
6635
6641
|
};
|
|
6636
6642
|
//#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
|
|
6750
|
-
//#region src/charts/heatmap-chart/private/build-calendar-data.ts
|
|
6751
|
-
/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
|
|
6752
|
-
const LABELLED_ROWS = [
|
|
6753
|
-
0,
|
|
6754
|
-
2,
|
|
6755
|
-
4
|
|
6756
|
-
];
|
|
6757
|
-
/**
|
|
6758
|
-
* Resolve one grid bound: an unparseable or narrowing value falls back to the
|
|
6759
|
-
* series' own bound, so the grid can only ever be widened.
|
|
6760
|
-
*
|
|
6761
|
-
* @param bound - Requested bound as `yyyy-MM-dd`, if any.
|
|
6762
|
-
* @param fallback - The series' own bound on this side.
|
|
6763
|
-
* @param direction - Which way the bound is allowed to move.
|
|
6764
|
-
* @return The day key to draw to.
|
|
6765
|
-
*/
|
|
6766
|
-
const widenTo = (bound, fallback, direction) => {
|
|
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;
|
|
6771
|
-
}
|
|
6772
|
-
if (direction === "earlier") return key < fallback ? key : fallback;
|
|
6773
|
-
return key > fallback ? key : fallback;
|
|
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
|
-
*/
|
|
6782
|
-
const buildCalendarHeatmapData = (series, options = {}) => {
|
|
6783
|
-
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
6784
|
-
const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
|
|
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 {
|
|
6805
|
-
data: [],
|
|
6806
|
-
rowLabels: []
|
|
6807
|
-
};
|
|
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)) : "");
|
|
6828
|
-
const MIN_FIRST_MONTH_WEEKS = 2;
|
|
6829
|
-
const firstMonth = gridStart.getUTCMonth();
|
|
6830
|
-
let firstMonthWeeks = 0;
|
|
6831
|
-
while (firstMonthWeeks < weekCount && addCivilDays(gridStart, firstMonthWeeks * 7).getUTCMonth() === firstMonth) firstMonthWeeks++;
|
|
6832
|
-
const showFirstMonthLabel = !(firstMonthWeeks < weekCount) || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
|
|
6833
|
-
const data = [];
|
|
6834
|
-
let previousMonth = -1;
|
|
6835
|
-
for (let week = 0; week < weekCount; week++) {
|
|
6836
|
-
const columnStart = addCivilDays(gridStart, week * 7);
|
|
6837
|
-
const month = columnStart.getUTCMonth();
|
|
6838
|
-
const label = month !== previousMonth && (week !== 0 || showFirstMonthLabel) ? formatMonth(columnStart) : "";
|
|
6839
|
-
previousMonth = month;
|
|
6840
|
-
const cells = [];
|
|
6841
|
-
for (let row = 0; row < 7; row++) {
|
|
6842
|
-
const day = addCivilDays(gridStart, week * 7 + row);
|
|
6843
|
-
const key = civilKey(day);
|
|
6844
|
-
const cell = {
|
|
6845
|
-
label: formatDay(day),
|
|
6846
|
-
value: valueByDay.has(key) ? valueByDay.get(key) : null
|
|
6847
|
-
};
|
|
6848
|
-
if (key < gridMinDayKey || key > gridMaxDayKey) {
|
|
6849
|
-
if (hideOutOfRangeDays) cell.hidden = true;
|
|
6850
|
-
} else if (key < minDayKey || key > maxDayKey) cell.placeholder = true;
|
|
6851
|
-
cells.push(cell);
|
|
6852
|
-
}
|
|
6853
|
-
data.push({
|
|
6854
|
-
label,
|
|
6855
|
-
data: cells
|
|
6856
|
-
});
|
|
6857
|
-
}
|
|
6858
|
-
return {
|
|
6859
|
-
data,
|
|
6860
|
-
rowLabels
|
|
6861
|
-
};
|
|
6862
|
-
};
|
|
6863
|
-
//#endregion
|
|
6864
6643
|
//#region src/charts/heatmap-chart/private/heatmap-context.ts
|
|
6865
6644
|
/** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
|
|
6866
6645
|
const HeatmapContext = (0, react.createContext)(null);
|
|
@@ -7200,6 +6979,233 @@ const HeatmapChartResponsiveInner = (props) => /* @__PURE__ */ (0, react_jsx_run
|
|
|
7200
6979
|
HeatmapChartResponsiveInner.displayName = "HeatmapChart";
|
|
7201
6980
|
const HeatmapChartResponsive = attachSubComponents(withResponsive(HeatmapChartResponsiveInner), { Legend: HeatmapLegend });
|
|
7202
6981
|
//#endregion
|
|
6982
|
+
//#region src/charts/heatmap-chart/private/civil-day.ts
|
|
6983
|
+
const DAY_MS = 864e5;
|
|
6984
|
+
const KEY_OPTIONS = {
|
|
6985
|
+
calendar: "gregory",
|
|
6986
|
+
numberingSystem: "latn",
|
|
6987
|
+
year: "numeric",
|
|
6988
|
+
month: "2-digit",
|
|
6989
|
+
day: "2-digit"
|
|
6990
|
+
};
|
|
6991
|
+
const DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
6992
|
+
const pad = (value, length) => String(value).padStart(length, "0");
|
|
6993
|
+
/**
|
|
6994
|
+
* The day a UTC proxy stands for.
|
|
6995
|
+
*
|
|
6996
|
+
* @param date - UTC proxy.
|
|
6997
|
+
* @return Its day key.
|
|
6998
|
+
*/
|
|
6999
|
+
const civilKey = (date) => `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
|
|
7000
|
+
/**
|
|
7001
|
+
* A day key as a UTC-midnight proxy. UTC has no DST, so every step below is exactly
|
|
7002
|
+
* `DAY_MS`.
|
|
7003
|
+
*
|
|
7004
|
+
* @param key - Day key.
|
|
7005
|
+
* @return The proxy, or null when the key names no real day.
|
|
7006
|
+
*/
|
|
7007
|
+
const civilDate = (key) => {
|
|
7008
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
|
|
7009
|
+
if (!match) return null;
|
|
7010
|
+
const date = /* @__PURE__ */ new Date(0);
|
|
7011
|
+
date.setUTCFullYear(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
7012
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
7013
|
+
return civilKey(date) === key ? date : null;
|
|
7014
|
+
};
|
|
7015
|
+
/**
|
|
7016
|
+
* The day a written date names, ignoring any time and zone it carries.
|
|
7017
|
+
*
|
|
7018
|
+
* @param text - `yyyy-MM-dd`, optionally followed by a time.
|
|
7019
|
+
* @return Its day key, or null.
|
|
7020
|
+
*/
|
|
7021
|
+
const writtenDayKey = (text) => {
|
|
7022
|
+
const match = DATE_PREFIX.exec(text);
|
|
7023
|
+
if (!match) return null;
|
|
7024
|
+
const key = `${match[1]}-${match[2]}-${match[3]}`;
|
|
7025
|
+
return civilDate(key) ? key : null;
|
|
7026
|
+
};
|
|
7027
|
+
/**
|
|
7028
|
+
* Reads the day an instant falls on in a zone.
|
|
7029
|
+
*
|
|
7030
|
+
* @param timeZone - IANA zone name; absent leaves the runtime's own.
|
|
7031
|
+
* @return Reader taking a `Date`, null for a day no key can hold.
|
|
7032
|
+
*/
|
|
7033
|
+
const instantDayReader = (timeZone) => {
|
|
7034
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
7035
|
+
...KEY_OPTIONS,
|
|
7036
|
+
timeZone
|
|
7037
|
+
});
|
|
7038
|
+
return (date) => {
|
|
7039
|
+
const parts = formatter.formatToParts(date);
|
|
7040
|
+
const read = (type) => parts.find((part) => part.type === type)?.value ?? "";
|
|
7041
|
+
const key = `${pad(Number(read("year")), 4)}-${read("month")}-${read("day")}`;
|
|
7042
|
+
return civilDate(key) ? key : null;
|
|
7043
|
+
};
|
|
7044
|
+
};
|
|
7045
|
+
/**
|
|
7046
|
+
* The day a series point belongs to.
|
|
7047
|
+
*
|
|
7048
|
+
* A `Date`, or a string carrying `Z` or an offset, is an instant read in the host's
|
|
7049
|
+
* zone. Anything else is a wall clock the host wrote, so its date part is the day.
|
|
7050
|
+
*
|
|
7051
|
+
* @param point - Series point.
|
|
7052
|
+
* @param readInstant - Reader from `instantDayReader`.
|
|
7053
|
+
* @return Its day key, or null when the point carries no usable date.
|
|
7054
|
+
*/
|
|
7055
|
+
const pointDayKey = (point, readInstant) => {
|
|
7056
|
+
if (point.date instanceof Date && !isNaN(point.date.getTime())) {
|
|
7057
|
+
const key = readInstant(point.date);
|
|
7058
|
+
if (key) return key;
|
|
7059
|
+
}
|
|
7060
|
+
const text = point.dateString;
|
|
7061
|
+
if (!text) return null;
|
|
7062
|
+
if (!hasTimezone(text)) return writtenDayKey(text);
|
|
7063
|
+
const parsed = (0, date_fns.parseISO)(text);
|
|
7064
|
+
return isNaN(parsed.getTime()) ? null : readInstant(parsed);
|
|
7065
|
+
};
|
|
7066
|
+
/**
|
|
7067
|
+
* Steps a proxy by whole days.
|
|
7068
|
+
*
|
|
7069
|
+
* @param date - UTC proxy.
|
|
7070
|
+
* @param days - Days to add; may be negative.
|
|
7071
|
+
* @return The shifted proxy.
|
|
7072
|
+
*/
|
|
7073
|
+
const addCivilDays = (date, days) => new Date(date.getTime() + days * DAY_MS);
|
|
7074
|
+
/**
|
|
7075
|
+
* Rounds a proxy down to its week.
|
|
7076
|
+
*
|
|
7077
|
+
* @param date - UTC proxy.
|
|
7078
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
7079
|
+
* @return The proxy for that week's first day.
|
|
7080
|
+
*/
|
|
7081
|
+
const startOfCivilWeek = (date, weekStartsOn) => {
|
|
7082
|
+
const daysSinceWeekStart = (date.getUTCDay() - weekStartsOn + 7) % 7;
|
|
7083
|
+
return addCivilDays(date, -daysSinceWeekStart);
|
|
7084
|
+
};
|
|
7085
|
+
/**
|
|
7086
|
+
* Counts the columns a span needs.
|
|
7087
|
+
*
|
|
7088
|
+
* @param from - Earlier UTC proxy.
|
|
7089
|
+
* @param to - Later UTC proxy.
|
|
7090
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
7091
|
+
* @return Columns needed to cover the span, inclusive.
|
|
7092
|
+
*/
|
|
7093
|
+
const civilWeekSpan = (from, to, weekStartsOn) => Math.floor((startOfCivilWeek(to, weekStartsOn).getTime() - startOfCivilWeek(from, weekStartsOn).getTime()) / (7 * DAY_MS)) + 1;
|
|
7094
|
+
//#endregion
|
|
7095
|
+
//#region src/charts/heatmap-chart/build-calendar-data.ts
|
|
7096
|
+
/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
|
|
7097
|
+
const LABELLED_ROWS = [
|
|
7098
|
+
0,
|
|
7099
|
+
2,
|
|
7100
|
+
4
|
|
7101
|
+
];
|
|
7102
|
+
/**
|
|
7103
|
+
* Resolve one grid bound: an unparseable or narrowing value falls back to the
|
|
7104
|
+
* series' own bound, so the grid can only ever be widened.
|
|
7105
|
+
*
|
|
7106
|
+
* @param bound - Requested bound as `yyyy-MM-dd`, if any.
|
|
7107
|
+
* @param fallback - The series' own bound on this side.
|
|
7108
|
+
* @param direction - Which way the bound is allowed to move.
|
|
7109
|
+
* @return The day key to draw to.
|
|
7110
|
+
*/
|
|
7111
|
+
const widenTo = (bound, fallback, direction) => {
|
|
7112
|
+
const key = bound ? writtenDayKey(bound) : null;
|
|
7113
|
+
if (!key) {
|
|
7114
|
+
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.`);
|
|
7115
|
+
return fallback;
|
|
7116
|
+
}
|
|
7117
|
+
if (direction === "earlier") return key < fallback ? key : fallback;
|
|
7118
|
+
return key > fallback ? key : fallback;
|
|
7119
|
+
};
|
|
7120
|
+
/**
|
|
7121
|
+
* Lay a day-bucketed series out as calendar columns.
|
|
7122
|
+
*
|
|
7123
|
+
* @param series - Points to bucket.
|
|
7124
|
+
* @param options - Grid shape, plus the locale and zone to read days in.
|
|
7125
|
+
* @return Columns and row labels for `HeatmapChart`.
|
|
7126
|
+
*/
|
|
7127
|
+
const buildCalendarHeatmapData = (series, options = {}) => {
|
|
7128
|
+
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
7129
|
+
const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
|
|
7130
|
+
const { locale, timeZone } = sanitizeFormatting({
|
|
7131
|
+
locale: options.locale,
|
|
7132
|
+
timeZone: options.timeZone
|
|
7133
|
+
});
|
|
7134
|
+
const readInstant = instantDayReader(timeZone);
|
|
7135
|
+
const valueByDay = /* @__PURE__ */ new Map();
|
|
7136
|
+
let minDayKey;
|
|
7137
|
+
let maxDayKey;
|
|
7138
|
+
for (const point of series) {
|
|
7139
|
+
const key = pointDayKey(point, readInstant);
|
|
7140
|
+
if (!key) {
|
|
7141
|
+
const offending = point.dateString ?? (point.date && String(point.date));
|
|
7142
|
+
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.");
|
|
7143
|
+
continue;
|
|
7144
|
+
}
|
|
7145
|
+
valueByDay.set(key, point.value);
|
|
7146
|
+
if (!minDayKey || key < minDayKey) minDayKey = key;
|
|
7147
|
+
if (!maxDayKey || key > maxDayKey) maxDayKey = key;
|
|
7148
|
+
}
|
|
7149
|
+
if (!minDayKey || !maxDayKey) return {
|
|
7150
|
+
data: [],
|
|
7151
|
+
rowLabels: []
|
|
7152
|
+
};
|
|
7153
|
+
const requestedMinDayKey = widenTo(options.gridSpan?.start, minDayKey, "earlier");
|
|
7154
|
+
const gridMaxDayKey = widenTo(options.gridSpan?.end, maxDayKey, "later");
|
|
7155
|
+
const requestedMinDate = civilDate(requestedMinDayKey);
|
|
7156
|
+
const gridMaxDate = civilDate(gridMaxDayKey);
|
|
7157
|
+
const labelFormatting = {
|
|
7158
|
+
locale,
|
|
7159
|
+
timeZone: "UTC"
|
|
7160
|
+
};
|
|
7161
|
+
const formatWeekday = createDateFormatter({ weekday: "short" }, labelFormatting);
|
|
7162
|
+
const formatMonth = createDateFormatter({ month: "short" }, labelFormatting);
|
|
7163
|
+
const formatDay = createDateFormatter({
|
|
7164
|
+
weekday: "short",
|
|
7165
|
+
month: "short",
|
|
7166
|
+
day: "numeric",
|
|
7167
|
+
year: "numeric"
|
|
7168
|
+
}, labelFormatting);
|
|
7169
|
+
const gridStart = startOfCivilWeek(requestedMinDate, weekStartsOn);
|
|
7170
|
+
const gridMinDayKey = requestedMinDayKey < minDayKey ? civilKey(gridStart) : requestedMinDayKey;
|
|
7171
|
+
const weekCount = civilWeekSpan(gridStart, gridMaxDate, weekStartsOn);
|
|
7172
|
+
const rowLabels = Array.from({ length: 7 }, (_, row) => LABELLED_ROWS.includes(row) ? formatWeekday(addCivilDays(gridStart, row)) : "");
|
|
7173
|
+
const MIN_FIRST_MONTH_WEEKS = 2;
|
|
7174
|
+
const firstMonth = gridStart.getUTCMonth();
|
|
7175
|
+
let firstMonthWeeks = 0;
|
|
7176
|
+
while (firstMonthWeeks < weekCount && addCivilDays(gridStart, firstMonthWeeks * 7).getUTCMonth() === firstMonth) firstMonthWeeks++;
|
|
7177
|
+
const showFirstMonthLabel = !(firstMonthWeeks < weekCount) || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
|
|
7178
|
+
const data = [];
|
|
7179
|
+
let previousMonth = -1;
|
|
7180
|
+
for (let week = 0; week < weekCount; week++) {
|
|
7181
|
+
const columnStart = addCivilDays(gridStart, week * 7);
|
|
7182
|
+
const month = columnStart.getUTCMonth();
|
|
7183
|
+
const label = month !== previousMonth && (week !== 0 || showFirstMonthLabel) ? formatMonth(columnStart) : "";
|
|
7184
|
+
previousMonth = month;
|
|
7185
|
+
const cells = [];
|
|
7186
|
+
for (let row = 0; row < 7; row++) {
|
|
7187
|
+
const day = addCivilDays(gridStart, week * 7 + row);
|
|
7188
|
+
const key = civilKey(day);
|
|
7189
|
+
const cell = {
|
|
7190
|
+
label: formatDay(day),
|
|
7191
|
+
value: valueByDay.has(key) ? valueByDay.get(key) : null
|
|
7192
|
+
};
|
|
7193
|
+
if (key < gridMinDayKey || key > gridMaxDayKey) {
|
|
7194
|
+
if (hideOutOfRangeDays) cell.hidden = true;
|
|
7195
|
+
} else if (key < minDayKey || key > maxDayKey) cell.placeholder = true;
|
|
7196
|
+
cells.push(cell);
|
|
7197
|
+
}
|
|
7198
|
+
data.push({
|
|
7199
|
+
label,
|
|
7200
|
+
data: cells
|
|
7201
|
+
});
|
|
7202
|
+
}
|
|
7203
|
+
return {
|
|
7204
|
+
data,
|
|
7205
|
+
rowLabels
|
|
7206
|
+
};
|
|
7207
|
+
};
|
|
7208
|
+
//#endregion
|
|
7203
7209
|
//#region src/charts/heatmap-chart/use-calendar-heatmap-data.ts
|
|
7204
7210
|
/**
|
|
7205
7211
|
* `buildCalendarHeatmapData` with the locale and zone taken from
|