@automattic/charts 3.1.1 → 4.0.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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +38 -11
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
package/src/types.ts CHANGED
@@ -108,8 +108,15 @@ export type ChartType =
108
108
 
109
109
  export type OrientationType = ValueOf< typeof Orientation >;
110
110
 
111
+ /**
112
+ * Styling for one annotation, as its `styles` prop. Colors belong here because
113
+ * `--a8c-charts-color-annotation` moves every annotation on the page at once and no
114
+ * selector narrows it to one.
115
+ */
111
116
  export type AnnotationStyles = {
112
- circleSubject?: Omit< CircleSubjectProps, 'x' | 'y' > & { fill?: string };
117
+ circleSubject?: Omit< CircleSubjectProps, 'x' | 'y' | 'fill' > & {
118
+ fill?: string;
119
+ };
113
120
  lineSubject?: Omit< LineSubjectProps, 'x' | 'y' >;
114
121
  connector?: Omit< ConnectorProps, 'x' | 'y' | 'dx' | 'dy' >;
115
122
  label?: Omit< LabelProps, 'title' | 'subtitle' | 'x' | 'y' > & {
@@ -118,6 +125,23 @@ export type AnnotationStyles = {
118
125
  };
119
126
  };
120
127
 
128
+ /**
129
+ * Annotation styling on the theme: geometry only. Colors come from
130
+ * `--a8c-charts-color-annotation` and `--a8c-charts-color-surface`.
131
+ */
132
+ export type ThemeAnnotationStyles = {
133
+ circleSubject?: Omit< CircleSubjectProps, 'x' | 'y' | 'fill' | 'stroke' >;
134
+ lineSubject?: Omit< LineSubjectProps, 'x' | 'y' | 'stroke' >;
135
+ connector?: Omit< ConnectorProps, 'x' | 'y' | 'dx' | 'dy' | 'stroke' >;
136
+ label?: Omit<
137
+ LabelProps,
138
+ 'title' | 'subtitle' | 'x' | 'y' | 'anchorLineStroke' | 'backgroundFill'
139
+ > & {
140
+ x?: number | 'start' | 'end';
141
+ y?: number | 'start' | 'end';
142
+ };
143
+ };
144
+
121
145
  export type DataPoint = {
122
146
  label: string;
123
147
  value: number;
@@ -305,7 +329,7 @@ export type DataPointPercentage = {
305
329
  */
306
330
  valueDisplay?: string;
307
331
  /**
308
- * Color code for the segment, by default colours are taken from the theme but this property can overrides it
332
+ * Color code for the segment, by default colors are taken from the theme but this property can overrides it
309
333
  */
310
334
  color?: string;
311
335
  /**
@@ -326,80 +350,55 @@ export type DataPointPercentageCalculated = DataPointPercentage & {
326
350
  };
327
351
 
328
352
  /**
329
- * Base theme configuration for chart components with optional properties
353
+ * Base theme configuration for chart components: shape and spacing. Chart colors are
354
+ * `--a8c-charts-color-*` roles set in CSS inside the provider tree, catalogued in
355
+ * `TOKENS.md`.
330
356
  */
331
357
  export type ChartTheme = {
332
- /** Background color for chart components */
333
- backgroundColor: string;
334
- /** Background color for labels */
335
- labelBackgroundColor?: string;
336
- /** Text color for labels */
337
- labelTextColor?: string;
338
- /** Array of colors used for data visualization */
339
- colors: string[];
340
- /** Optional CSS styles for grid lines */
341
- gridStyles?: GridStyles;
358
+ /** Optional CSS styles for grid lines. */
359
+ gridStyles?: Omit< GridStyles, 'stroke' >;
342
360
  /** Length of axis ticks in pixels */
343
361
  tickLength: number;
344
- /** Color of the grid lines */
345
- gridColor: string;
346
- /** Color of the grid lines in dark mode */
347
- gridColorDark: string;
348
362
  /** Styles for x-axis tick lines */
349
- xTickLineStyles?: LineStyles;
363
+ xTickLineStyles?: Omit< LineStyles, 'stroke' >;
350
364
  /** Styles for x-axis line */
351
- xAxisLineStyles?: LineStyles;
352
- /** Styles for series lines */
353
- seriesLineStyles?: LineStyles[];
365
+ xAxisLineStyles?: Omit< LineStyles, 'stroke' >;
366
+ /**
367
+ * Per-series line styling — dash pattern, width, cap. These spread on top of the
368
+ * palette-derived stroke, so a color here would override it; a single series takes
369
+ * one through `options.seriesLineStyle`.
370
+ */
371
+ seriesLineStyles?: Array< Omit< LineStyles, 'stroke' > >;
354
372
  /** Array of render functions for glyphs */
355
373
  glyphs?: Array< < Datum extends object >( props: GlyphProps< Datum > ) => ReactNode >;
356
374
  /** Legend specific settings */
357
375
  legend?: {
358
376
  /** Styles for legend shapes */
359
377
  shapeStyles?: Record< string, unknown >[];
360
- /** Styles for legend labels */
361
- labelStyles?: CSSProperties;
378
+ /** Styles for legend labels. */
379
+ labelStyles?: Omit< CSSProperties, 'color' >;
362
380
  /** Styles for legend container */
363
381
  containerStyles?: CSSProperties;
364
382
  };
365
- /** Styles for small SVG text (eg. axis tick labels), passed through to the XYChart theme. */
366
- svgLabelSmall?: TextProps;
383
+ /**
384
+ * Styles for small SVG text (eg. axis tick labels), passed through to the XYChart theme.
385
+ *
386
+ * `fontFamily: 'inherit'` is load-bearing here — it overrides the font stack
387
+ * `buildChartTheme` injects inline on SVG `<text>`.
388
+ */
389
+ svgLabelSmall?: Omit< TextProps, 'fill' >;
367
390
  /** Styles for large SVG text (eg. axis titles), passed through to the XYChart theme. */
368
391
  svgLabelBig?: TextProps;
369
- annotationStyles?: AnnotationStyles;
370
- /** GeoChart specific settings */
371
- geoChart?: {
372
- /** Default fill color for a geo chart feature (e.g. country) with no data */
373
- featureFillColor?: string;
374
- };
392
+ annotationStyles?: ThemeAnnotationStyles;
375
393
  /** LeaderboardChart specific settings */
376
394
  leaderboardChart?: {
377
- /** Gap between rows in the leaderboard grid */
378
- rowGap?: number;
379
- /** Gap between columns in the leaderboard grid */
380
- columnGap?: number;
381
395
  /** Spacing between label and progress bars */
382
396
  labelSpacing?: GapSize;
383
- /** Primary color for current period bars */
384
- primaryColor?: string;
385
- /** Secondary color for comparison period bars */
386
- secondaryColor?: string;
387
- /** Delta colors: [negative, neutral, positive] */
388
- deltaColors?: [ string, string, string ];
389
- };
390
- /** ConversionFunnelChart specific settings */
391
- conversionFunnelChart?: {
392
- /** Primary color for funnel bars */
393
- primaryColor?: string;
394
- /** Background color for chart container */
395
- backgroundColor?: string;
396
- /** Color for positive change indicators */
397
- positiveChangeColor?: string;
398
- /** Color for negative change indicators */
399
- negativeChangeColor?: string;
400
397
  };
401
398
  lineChart?: {
402
- lineStyles?: Partial< Record< NonNullable< SeriesDataOptions[ 'type' ] >, LineStyles > >;
399
+ lineStyles?: Partial<
400
+ Record< NonNullable< SeriesDataOptions[ 'type' ] >, Omit< LineStyles, 'stroke' > >
401
+ >;
403
402
  };
404
403
  barChart?: {
405
404
  barStyles?: Partial< Record< NonNullable< SeriesDataOptions[ 'type' ] >, BarStyles > >;
@@ -418,14 +417,9 @@ export type ChartTheme = {
418
417
  };
419
418
  /**
420
419
  * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
421
- * WPDS tokens in CSS, so only the scale color and the compact sizing live here.
420
+ * WPDS tokens in CSS, so only the compact sizing lives here.
422
421
  */
423
422
  heatmapChart?: {
424
- /**
425
- * Color the cell scale interpolates toward at the highest value (prop > this >
426
- * palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color.
427
- */
428
- primaryColor?: string;
429
423
  /** Gap in px between cells in compact mode */
430
424
  compactCellGap?: number;
431
425
  /** Fixed square cell size in px for compact mode */
@@ -438,18 +432,9 @@ export type ChartTheme = {
438
432
  * Useful for merged themes where defaults are provided for all optional properties.
439
433
  */
440
434
  export type CompleteChartTheme = Required< ChartTheme > & {
441
- leaderboardChart: Omit<
442
- Required< NonNullable< ChartTheme[ 'leaderboardChart' ] > >,
443
- 'primaryColor' | 'secondaryColor'
444
- > &
445
- Pick< NonNullable< ChartTheme[ 'leaderboardChart' ] >, 'primaryColor' | 'secondaryColor' >;
446
- conversionFunnelChart: Omit<
447
- Required< NonNullable< ChartTheme[ 'conversionFunnelChart' ] > >,
448
- 'primaryColor'
449
- > &
450
- Pick< NonNullable< ChartTheme[ 'conversionFunnelChart' ] >, 'primaryColor' >;
435
+ leaderboardChart: Required< NonNullable< ChartTheme[ 'leaderboardChart' ] > >;
451
436
  lineChart: {
452
- lineStyles: Record< NonNullable< SeriesDataOptions[ 'type' ] >, LineStyles >;
437
+ lineStyles: Record< NonNullable< SeriesDataOptions[ 'type' ] >, Omit< LineStyles, 'stroke' > >;
453
438
  };
454
439
  barChart: {
455
440
  barStyles: Record< NonNullable< SeriesDataOptions[ 'type' ] >, BarStyles >;
@@ -458,8 +443,7 @@ export type CompleteChartTheme = Required< ChartTheme > & {
458
443
  sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
459
444
  margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
460
445
  };
461
- heatmapChart: Omit< Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >, 'primaryColor' > &
462
- Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >;
446
+ heatmapChart: Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >;
463
447
  };
464
448
 
465
449
  /**
@@ -468,6 +452,42 @@ export type CompleteChartTheme = Required< ChartTheme > & {
468
452
  */
469
453
  export type TickResolution = 'hour' | 'day' | 'week' | 'month' | 'year';
470
454
 
455
+ /**
456
+ * How a chart classified the buckets of the data it's drawing.
457
+ */
458
+ export type BucketInfo = {
459
+ /**
460
+ * The bucket the series has. Only reports `'week'` when declared: seven-day
461
+ * spacing is indistinguishable from sparse daily data.
462
+ */
463
+ bucket: TickResolution;
464
+ /** The classification tick formats are keyed on. `'week'` reads as `'day'`. */
465
+ displayResolution: Exclude< TickResolution, 'week' >;
466
+ };
467
+
468
+ /**
469
+ * The locale and time zone the time axis and the built-in tooltips render dates
470
+ * in, set once by the host on `GlobalChartsProvider`.
471
+ *
472
+ * Both halves are plain strings the host resolves for itself, so a non-WordPress
473
+ * consumer needs no WordPress package to supply them. Both are optional and
474
+ * default to the JavaScript runtime's own — the viewer's browser locale and
475
+ * browser time zone — which is what every version before this one used
476
+ * unconditionally.
477
+ *
478
+ * `timeZone` re-dates the instants the host supplies, so a value that is really a
479
+ * calendar day shifts under it: `new Date( '2026-08-02' )` is UTC midnight and
480
+ * labels as Aug 1 in `America/Los_Angeles`. Supply true instants alongside a
481
+ * `timeZone`, or set only `locale` for day-bucketed data. `HeatmapChart`'s
482
+ * calendar labels take neither — see `buildCalendarHeatmapData`.
483
+ */
484
+ export type ChartFormatting = {
485
+ /** BCP-47 language tag, e.g. `de-DE`. Defaults to the runtime's locale. */
486
+ locale?: string;
487
+ /** IANA time zone name, e.g. `Asia/Tokyo`. Defaults to the runtime's zone. */
488
+ timeZone?: string;
489
+ };
490
+
471
491
  export type AxisOptions = {
472
492
  orientation?: OrientationType;
473
493
  numTicks?: number;
@@ -626,7 +646,7 @@ export type ChartLegendConfig< T = DataPoint | DataPointDate | LeaderboardEntry
626
646
  /**
627
647
  * Legend config for charts built from `SeriesData` (line, bar, area). Adds `collapseGroups` on top
628
648
  * of the shared config. It is intentionally absent from the base `ChartLegendConfig` so point-based
629
- * charts (pie, semi-circle pie) — whose data points carry `group` only to coordinate colours — can't
649
+ * charts (pie, semi-circle pie) — whose data points carry `group` only to coordinate colors — can't
630
650
  * set it.
631
651
  */
632
652
  export type SeriesChartLegendConfig = ChartLegendConfig< SeriesData[] > & {
@@ -706,6 +726,16 @@ export type BaseChartProps< T = DataPoint | DataPointDate | LeaderboardEntry > =
706
726
  * Callback function for pointer out event
707
727
  */
708
728
  onPointerOut?: ( event: PointerEvent< Element > ) => void;
729
+ /**
730
+ * Callback for Enter or Space on the point keyboard navigation has selected:
731
+ * the keyboard counterpart of a click. `index` is the point's position in its
732
+ * series and `key` the series label.
733
+ */
734
+ onDatumActivate?: ( params: {
735
+ datum: DataPoint | DataPointDate;
736
+ index: number;
737
+ key: string;
738
+ } ) => void;
709
739
  /**
710
740
  * Whether to show tooltips on hover. False by default.
711
741
  */
@@ -0,0 +1,87 @@
1
+ import { differenceInHours } from 'date-fns';
2
+ import { parseAsLocalDate } from './date-parsing';
3
+ import type { BucketInfo, DataPoint, DataPointDate, SeriesData, TickResolution } from '../types';
4
+
5
+ // `dateString` is parsed here too: `getBucketInfo` is public, and hosts call it
6
+ // with the raw series, before `useChartDataTransform` has dated the points.
7
+ const getInstant = ( point?: DataPointDate | DataPoint ): number | undefined => {
8
+ if ( ! point ) {
9
+ return undefined;
10
+ }
11
+ if ( 'date' in point && point.date ) {
12
+ return point.date.getTime();
13
+ }
14
+ if ( 'dateString' in point && point.dateString ) {
15
+ return parseAsLocalDate( point.dateString ).getTime();
16
+ }
17
+ return undefined;
18
+ };
19
+
20
+ // Smallest interval between consecutive points across all series, in hours.
21
+ // Infinity when no series carries two distinct instants.
22
+ const getPointSpacingInHours = ( data: SeriesData[] ) => {
23
+ return data.reduce( ( spacing, datom ) => {
24
+ // Sorted and deduplicated rather than read in array order: `getBucketInfo`
25
+ // is public, and a repeated instant is padding, not a sub-daily gap.
26
+ const instants = [ ...new Set( datom.data.map( point => getInstant( point ) ) ) ]
27
+ .filter( ( time ): time is number => time !== undefined && Number.isFinite( time ) )
28
+ .sort( ( a, b ) => a - b );
29
+
30
+ return instants.reduce(
31
+ ( seriesSpacing, time, index ) =>
32
+ index === 0
33
+ ? seriesSpacing
34
+ : Math.min( seriesSpacing, Math.abs( differenceInHours( time, instants[ index - 1 ] ) ) ),
35
+ spacing
36
+ );
37
+ }, Number.POSITIVE_INFINITY );
38
+ };
39
+
40
+ // 23, not 24: a daily gap shrinks to 23 wall-clock hours across a
41
+ // spring-forward DST transition.
42
+ const SUB_DAILY_SPACING_HOURS = 23;
43
+
44
+ // The shortest month, so monthly buckets clear it but weekly ones don't.
45
+ const MONTHLY_SPACING_HOURS = 28 * 24;
46
+
47
+ // The bucket size behind the data, as the resolution label the tick formats are
48
+ // keyed on. Exported so consumers that label a single point — bar chart
49
+ // tooltips — read the same classification the axis does instead of inferring
50
+ // the resolution a second way. Weeks report as 'day': both are calendar-date
51
+ // buckets as far as labeling goes.
52
+ export const getBucketResolution = (
53
+ data: SeriesData[],
54
+ tickResolution?: TickResolution
55
+ ): Exclude< TickResolution, 'week' > => {
56
+ if ( tickResolution ) {
57
+ return tickResolution === 'week' ? 'day' : tickResolution;
58
+ }
59
+
60
+ const spacingInHours = getPointSpacingInHours( data );
61
+ if ( spacingInHours < SUB_DAILY_SPACING_HOURS ) {
62
+ return 'hour';
63
+ }
64
+ // Fewer than two points leaves the spacing unknowable, so fall back to
65
+ // calendar dates rather than reading Infinity as a very coarse bucket.
66
+ if ( ! Number.isFinite( spacingInHours ) || spacingInHours < MONTHLY_SPACING_HOURS ) {
67
+ return 'day';
68
+ }
69
+ // Twelve shortest months: above any monthly gap (31 days at most) and below
70
+ // any yearly one (365 days at least).
71
+ return spacingInHours < 12 * MONTHLY_SPACING_HOURS ? 'month' : 'year';
72
+ };
73
+
74
+ /**
75
+ * How this data was classified, for consumers that render their own labels.
76
+ *
77
+ * @param data - The series the chart draws, in any order, dated by `date` or `dateString`.
78
+ * @param tickResolution - Caller-declared bucket resolution, when known.
79
+ * @return The declared or inferred bucket, and the resolution formats key on.
80
+ */
81
+ export const getBucketInfo = (
82
+ data: SeriesData[],
83
+ tickResolution?: TickResolution
84
+ ): BucketInfo => ( {
85
+ bucket: tickResolution ?? getBucketResolution( data ),
86
+ displayResolution: getBucketResolution( data, tickResolution ),
87
+ } );
@@ -108,33 +108,6 @@ export const parseHslString = ( hslString: string ): [ number, number, number ]
108
108
  return [ h, parsed.s * 100, parsed.l * 100 ];
109
109
  };
110
110
 
111
- /**
112
- * Parse an RGB string like 'rgb(255, 0, 0)' into a hex color.
113
- *
114
- * @deprecated Use normalizeColorToHex() instead, which handles all color formats including rgb() and rgba().
115
- * @param rgbString - RGB color string (not RGBA)
116
- * @return hex color string or null if invalid
117
- */
118
- export const parseRgbString = ( rgbString: string ): string | null => {
119
- const lower = rgbString.toLowerCase().trim();
120
-
121
- // Check prefix - only handle rgb(), not rgba()
122
- // This is intentional - use normalizeColorToHex for rgba() support
123
- if ( ! lower.startsWith( 'rgb(' ) || lower.startsWith( 'rgba(' ) ) {
124
- return null;
125
- }
126
-
127
- const parsed = d3Color( lower );
128
-
129
- // d3Color returns null for invalid colors
130
- if ( ! parsed ) {
131
- return null;
132
- }
133
-
134
- // d3-color clamps values automatically
135
- return parsed.formatHex();
136
- };
137
-
138
111
  /**
139
112
  * Normalize any CSS color value to a hex color string.
140
113
  * Handles hex, HSL, HSLA, RGB, RGBA, named CSS colors, and CSS variables.
@@ -0,0 +1,174 @@
1
+ import type { ChartFormatting } from '../types';
2
+
3
+ /**
4
+ * `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
5
+ * minimal `process` locally so this file type-checks as source under `jetpack:src`.
6
+ */
7
+ declare const process: { env: Record< string, string | undefined > };
8
+
9
+ // One `Intl.DateTimeFormat` per locale, zone and options triple. The axis formats
10
+ // every bucket in the domain, and rebuilding the formatter is what costs; the tick
11
+ // formats themselves are rebuilt on every resize, so a per-closure cache is not
12
+ // enough. Bounded by the host's own locale and zone, so a handful of entries.
13
+ const formatters = new Map< string, Intl.DateTimeFormat >();
14
+
15
+ const getFormatter = (
16
+ options: Intl.DateTimeFormatOptions,
17
+ { locale, timeZone }: ChartFormatting
18
+ ): Intl.DateTimeFormat => {
19
+ const key = `${ locale ?? '' }|${ timeZone ?? '' }|${ JSON.stringify( options ) }`;
20
+ let formatter = formatters.get( key );
21
+
22
+ if ( ! formatter ) {
23
+ formatter = new Intl.DateTimeFormat( locale, { ...options, timeZone } );
24
+ formatters.set( key, formatter );
25
+ }
26
+
27
+ return formatter;
28
+ };
29
+
30
+ const warned = new Set< string >();
31
+
32
+ const warnOnce = ( key: string, message: string ): void => {
33
+ if ( warned.has( key ) || process.env.NODE_ENV === 'production' ) {
34
+ return;
35
+ }
36
+
37
+ warned.add( key );
38
+ // eslint-disable-next-line no-console
39
+ console.warn( `[Charts] ${ message }` );
40
+ };
41
+
42
+ const isUsable = ( formatting: ChartFormatting ): boolean => {
43
+ try {
44
+ // Constructing is the check: `Intl` throws on a tag or zone it cannot use.
45
+ Intl.DateTimeFormat( formatting.locale, { timeZone: formatting.timeZone } );
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ // WordPress underscores its locales, where BCP-47 wants a hyphen: `get_locale()`
53
+ // returns `en_US` for what Intl knows as `en-US`.
54
+ const toBcp47 = ( locale?: string ) => locale?.replace( /_/g, '-' );
55
+
56
+ /**
57
+ * Repairs a host locale for `Intl`, dropping either half it still cannot use.
58
+ *
59
+ * Every formatter below is built during render, so an unusable string would throw
60
+ * where React unmounts the whole host tree rather than one chart. WordPress hands
61
+ * out the likeliest one either way: `timezone_string` is `''` on a site set to a
62
+ * raw UTC offset, and has no repair.
63
+ *
64
+ * @param formatting - Locale and time zone as the host supplied them.
65
+ * @return The usable halves, each `undefined` where the host's value was not.
66
+ */
67
+ export const sanitizeFormatting = ( formatting: ChartFormatting ): ChartFormatting => {
68
+ const repaired = { ...formatting, locale: toBcp47( formatting.locale ) };
69
+
70
+ if ( isUsable( repaired ) ) {
71
+ return repaired;
72
+ }
73
+
74
+ const locale = isUsable( { locale: repaired.locale } ) ? repaired.locale : undefined;
75
+ const timeZone = isUsable( { timeZone: repaired.timeZone } ) ? repaired.timeZone : undefined;
76
+
77
+ if ( locale === undefined && formatting.locale !== undefined ) {
78
+ warnOnce(
79
+ `locale:${ formatting.locale }`,
80
+ `locale ${ JSON.stringify(
81
+ formatting.locale
82
+ ) } is not a BCP-47 tag Intl accepts, so dates render in the browser's locale.`
83
+ );
84
+ }
85
+
86
+ if ( timeZone === undefined && formatting.timeZone !== undefined ) {
87
+ warnOnce(
88
+ `timeZone:${ formatting.timeZone }`,
89
+ `timeZone ${ JSON.stringify(
90
+ formatting.timeZone
91
+ ) } is not a zone Intl accepts, so dates render in the browser's zone. Pass an IANA name or a UTC offset such as "+05:30".`
92
+ );
93
+ }
94
+
95
+ return { locale, timeZone };
96
+ };
97
+
98
+ /**
99
+ * A date formatter bound to the host's locale and time zone.
100
+ *
101
+ * @param options - Date-part options, as for `Intl.DateTimeFormat`.
102
+ * @param formatting - Host locale and time zone; either half absent leaves the runtime's own.
103
+ * @return Formatter taking a timestamp or a `Date`.
104
+ */
105
+ export const createDateFormatter = (
106
+ options: Intl.DateTimeFormatOptions,
107
+ formatting: ChartFormatting
108
+ ) => {
109
+ return ( value: number | Date ): string => {
110
+ // `Intl.DateTimeFormat` throws on an invalid date where the `Date` methods
111
+ // it replaces return "Invalid Date", and charts are expected to render past
112
+ // a bad point rather than fail. visx hands its tick formatter a `Date`, so
113
+ // coerce before the check.
114
+ if ( ! Number.isFinite( Number( value ) ) ) {
115
+ return new Date( Number( value ) ).toLocaleDateString();
116
+ }
117
+ return getFormatter( options, formatting ).format( value );
118
+ };
119
+ };
120
+
121
+ // Enough of the calendar to place a tick on a boundary, read in the host's zone
122
+ // rather than the browser's. `en-US` pins the digits as Latin and the calendar as
123
+ // Gregorian: a non-Gregorian `locale` labels its own new year, but the boundary
124
+ // still fires on January 1.
125
+ const CLOCK_OPTIONS: Intl.DateTimeFormatOptions = {
126
+ month: 'numeric',
127
+ hour: 'numeric',
128
+ hourCycle: 'h23',
129
+ };
130
+
131
+ export type ZonedFields = {
132
+ /** 1-12. */
133
+ month: number;
134
+ /** 0-23. */
135
+ hour: number;
136
+ };
137
+
138
+ // What the `Date` getters answer for an invalid date, so a bad point leaves every
139
+ // boundary untriggered rather than throwing out of `formatToParts`.
140
+ const NO_FIELDS: ZonedFields = { month: NaN, hour: NaN };
141
+
142
+ /**
143
+ * A clock reading calendar fields in the host's time zone.
144
+ *
145
+ * `Date`'s own getters read the browser's zone, so a chart viewed from another
146
+ * country anchors its ticks to the wrong midnights and the wrong January
147
+ * without this.
148
+ *
149
+ * @param timeZone - IANA zone name; absent leaves the runtime's own.
150
+ * @return Reader taking a `Date`.
151
+ */
152
+ export const createZonedClock = ( timeZone?: string ) => {
153
+ // The `Date` getters already answer in the runtime zone, and cost nothing.
154
+ if ( ! timeZone ) {
155
+ return ( date: Date ): ZonedFields => ( {
156
+ month: date.getMonth() + 1,
157
+ hour: date.getHours(),
158
+ } );
159
+ }
160
+
161
+ return ( date: Date ): ZonedFields => {
162
+ if ( ! Number.isFinite( Number( date ) ) ) {
163
+ return NO_FIELDS;
164
+ }
165
+
166
+ const parts = getFormatter( CLOCK_OPTIONS, { locale: 'en-US', timeZone } ).formatToParts(
167
+ date
168
+ );
169
+ const read = ( type: Intl.DateTimeFormatPartTypes ) =>
170
+ Number( parts.find( part => part.type === type )?.value );
171
+
172
+ return { month: read( 'month' ), hour: read( 'hour' ) };
173
+ };
174
+ };
@@ -50,9 +50,15 @@ export function getSeriesBarStyles(
50
50
  /**
51
51
  * Utility function to get stroke color for a series
52
52
  *
53
+ * `themeColors` must already be resolved. `useGlobalChartsTheme().colors` is not: its entries are
54
+ * the palette's `var()` chains, which an SVG presentation attribute cannot resolve. Prefer
55
+ * `getElementStyles( { data, index } ).color` from
56
+ * `useGlobalChartsContext()`, which resolves at the chart's scope element and generates past the
57
+ * seeds.
58
+ *
53
59
  * @param {SeriesData} seriesData - The series data containing styling options
54
60
  * @param {number} index - The index of the series in the data array
55
- * @param {string[]} themeColors - Array of theme colors
61
+ * @param {string[]} themeColors - The resolved series palette
56
62
  * @return {string} The stroke color for the series
57
63
  */
58
64
  export function getSeriesStroke(
@@ -1,6 +1,9 @@
1
1
  // Chart composition utilities
2
2
  export { attachSubComponents } from './create-composition';
3
3
 
4
+ // Date bucket classification
5
+ export { getBucketInfo } from './bucket-info';
6
+
4
7
  // Date parsing utilities
5
8
  export { parseAsLocalDate } from './date-parsing';
6
9