@automattic/charts 4.1.1 → 4.3.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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -1,5 +1,5 @@
1
1
  import type { BaseChartProps } from '../../types';
2
- import type { ReactNode } from 'react';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
3
 
4
4
  /** A single heatmap cell. `value: null` marks an empty cell. */
5
5
  export type HeatmapCell = {
@@ -36,19 +36,46 @@ export type HeatmapColumn = {
36
36
  summary?: boolean;
37
37
  };
38
38
 
39
+ /** A run of consecutive columns sharing one label beneath the grid. */
40
+ export type HeatmapColumnGroup = {
41
+ label: string;
42
+ /** Columns covered; a positive integer. */
43
+ span: number;
44
+ };
45
+
39
46
  export type HeatmapTooltipData = {
40
47
  value: number | null;
41
48
  rowLabel?: string;
42
49
  columnLabel?: string;
50
+ /** Label of the column group the cell falls in, if any. */
51
+ groupLabel?: string;
43
52
  cellLabel?: string;
44
53
  row: number;
45
54
  column: number;
46
55
  };
47
56
 
48
- export interface HeatmapChartProps
49
- extends Omit< BaseChartProps< HeatmapColumn[] >, 'showLegend' | 'legend' | 'gridVisibility' > {
57
+ export interface HeatmapChartProps extends Omit<
58
+ BaseChartProps< HeatmapColumn[] >,
59
+ 'showLegend' | 'legend' | 'gridVisibility'
60
+ > {
50
61
  /** y-axis labels by row index. Empty entries render blank. */
51
62
  rowLabels?: string[];
63
+ /**
64
+ * Consecutive runs of columns sharing one label beneath the grid, set one
65
+ * group gap apart. Runs from the first column; columns past the last group
66
+ * stay ungrouped. Ignored, with a warning, when a span is not a positive
67
+ * integer or the spans reach past the last column.
68
+ */
69
+ columnGroups?: HeatmapColumnGroup[];
70
+ /**
71
+ * How the arrow keys move the selection. `grid` steps across the whole grid.
72
+ * `calendar` reads each column group as a page: Left/Right by one cell (wrapping
73
+ * to the next row, then the next group), Up/Down by one row into the neighboring
74
+ * group past the edge, Page Up/Down by one group. Default `grid`.
75
+ */
76
+ keyboardNavigation?: 'grid' | 'calendar';
77
+ /** Accessible name of the grid. Defaults to a localized "Heatmap chart". */
78
+ ariaLabel?: string;
52
79
  /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
53
80
  compact?: boolean;
54
81
  /** Render the numeric value inside each cell. Default `! compact`. */
@@ -80,6 +107,14 @@ export interface HeatmapChartProps
80
107
  */
81
108
  primaryColor?: string;
82
109
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
110
+ /**
111
+ * The tooltip box: `light` is the plain white box, `dark` the package's
112
+ * tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
113
+ * Default `light`.
114
+ */
115
+ tooltipVariant?: 'light' | 'dark';
116
+ /** Inline styles merged onto the tooltip box, over the variant's own. */
117
+ tooltipStyle?: CSSProperties;
83
118
  children?: ReactNode;
84
119
  }
85
120
 
@@ -111,3 +146,24 @@ export type CalendarHeatmapOptions = {
111
146
  */
112
147
  timeZone?: string;
113
148
  };
149
+
150
+ export type MonthCalendarHeatmapRange = {
151
+ /** First measured day, `yyyy-MM-dd`. */
152
+ start: string;
153
+ /** Last measured day, `yyyy-MM-dd`, inclusive. */
154
+ end: string;
155
+ };
156
+
157
+ export type MonthCalendarHeatmapOptions = {
158
+ /** 0 = Sunday, 1 = Monday. Default 1. */
159
+ weekStartsOn?: 0 | 1;
160
+ /** BCP-47 tag the month and day labels are written in. Defaults to the runtime's locale. */
161
+ locale?: string;
162
+ };
163
+
164
+ export type MonthCalendarHeatmapResult = {
165
+ /** For `HeatmapChart`'s `data`: 7 columns per month, 6 cells each. */
166
+ data: HeatmapColumn[];
167
+ /** For `HeatmapChart`'s `columnGroups`: one per month, in order. */
168
+ columnGroups: HeatmapColumnGroup[];
169
+ };
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+ import { useChartFormatting } from '../../providers';
3
+ import { buildMonthCalendarHeatmapData } from './build-month-calendar-data';
4
+ import type {
5
+ MonthCalendarHeatmapOptions,
6
+ MonthCalendarHeatmapRange,
7
+ MonthCalendarHeatmapResult,
8
+ } from './types';
9
+
10
+ /**
11
+ * `buildMonthCalendarHeatmapData` with the locale taken from `GlobalChartsProvider`
12
+ * where the caller names none.
13
+ *
14
+ * @param valueByDay - Value per `yyyy-MM-dd`. Held by reference, so a caller that rebuilds it each render defeats the memo.
15
+ * @param range - As for `buildMonthCalendarHeatmapData`.
16
+ * @param options - As for `buildMonthCalendarHeatmapData`; `locale` wins over the provider.
17
+ * @return Columns and groups for `HeatmapChart`.
18
+ */
19
+ export const useMonthCalendarHeatmapData = (
20
+ valueByDay: Record< string, number | null >,
21
+ range: MonthCalendarHeatmapRange,
22
+ options: MonthCalendarHeatmapOptions = {}
23
+ ): MonthCalendarHeatmapResult => {
24
+ const formatting = useChartFormatting();
25
+ const locale = options.locale ?? formatting.locale;
26
+ const { weekStartsOn } = options;
27
+ const { start, end } = range;
28
+
29
+ // Spread into primitives: the option and range objects are rebuilt on each render.
30
+ return useMemo(
31
+ () => buildMonthCalendarHeatmapData( valueByDay, { start, end }, { locale, weekStartsOn } ),
32
+ [ valueByDay, start, end, locale, weekStartsOn ]
33
+ );
34
+ };
@@ -1,20 +1,19 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { BaseChartProps, LeaderboardEntry } from '../../types';
3
3
 
4
- export interface LeaderboardChartProps
5
- extends Pick<
6
- BaseChartProps< LeaderboardEntry >,
7
- | 'className'
8
- | 'data'
9
- | 'showLegend'
10
- | 'legend'
11
- | 'chartId'
12
- | 'width'
13
- | 'height'
14
- | 'size'
15
- | 'gap'
16
- | 'animation'
17
- > {
4
+ export interface LeaderboardChartProps extends Pick<
5
+ BaseChartProps< LeaderboardEntry >,
6
+ | 'className'
7
+ | 'data'
8
+ | 'showLegend'
9
+ | 'legend'
10
+ | 'chartId'
11
+ | 'width'
12
+ | 'height'
13
+ | 'size'
14
+ | 'gap'
15
+ | 'animation'
16
+ > {
18
17
  /**
19
18
  * Whether to show comparison data
20
19
  */
@@ -1,5 +1,6 @@
1
- import { formatNumberCompact, formatNumber } from '@automattic/number-formatters';
1
+ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { LinearGradient } from '@visx/gradient';
3
+ import { scaleCanBeZeroed } from '@visx/scale';
3
4
  import { XYChart, AreaSeries, Grid, Axis, DataContext } from '@visx/xychart';
4
5
  import { __ } from '@wordpress/i18n';
5
6
  import { Stack } from '@wordpress/ui';
@@ -37,14 +38,21 @@ import { useChartChildren } from '../private/chart-composition';
37
38
  import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
38
39
  import { ChartLayout } from '../private/chart-layout';
39
40
  import { DefaultGlyph } from '../private/default-glyph';
41
+ import { formatReading, isInvalidReading, isReading } from '../private/readings';
40
42
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
41
43
  import { getCurveType } from '../private/time-axis';
42
44
  import { buildTimeAxisOptions } from '../private/time-axis-options';
45
+ import { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
43
46
  import { withResponsive } from '../private/with-responsive';
44
47
  import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
45
48
  import plotStyles from '../private/xy-plot/xy-plot.module.scss';
46
49
  import styles from './line-chart.module.scss';
47
- import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
50
+ import {
51
+ LineChartAnnotation,
52
+ LineChartAnnotationsOverlay,
53
+ LineChartGlyph,
54
+ NearestPointerEvents,
55
+ } from './private';
48
56
  import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
49
57
  import type {
50
58
  BucketInfo,
@@ -123,9 +131,14 @@ export const renderDefaultTooltip = (
123
131
  const tooltipPoints: TooltipDatum[] = Object.entries( tooltipData?.datumByKey || {} )
124
132
  .map( ( [ key, { datum } ] ) => ( {
125
133
  key,
126
- value: datum.value as number,
134
+ value: datum.value ?? null,
127
135
  } ) )
128
- .sort( ( a, b ) => b.value - a.value );
136
+ .sort( ( a, b ) => {
137
+ if ( a.value === null && b.value === null ) return 0;
138
+ if ( a.value === null ) return 1;
139
+ if ( b.value === null ) return -1;
140
+ return b.value - a.value;
141
+ } );
129
142
 
130
143
  return (
131
144
  <div
@@ -149,7 +162,7 @@ export const renderDefaultTooltip = (
149
162
  >
150
163
  <span className={ styles[ 'line-chart__tooltip-label' ] }>{ point.key }:</span>
151
164
  <span className={ styles[ 'line-chart__tooltip-value' ] }>
152
- { formatNumber( point.value ) }
165
+ { formatReading( point.value ) }
153
166
  </span>
154
167
  </Stack>
155
168
  ) ) }
@@ -158,22 +171,42 @@ export const renderDefaultTooltip = (
158
171
  };
159
172
 
160
173
  const validateData = ( data: SeriesData[] ) => {
161
- if ( ! data?.length ) return 'No data available';
174
+ if ( ! data?.length ) return __( 'No data available', 'jetpack-charts' );
162
175
 
163
176
  const hasInvalidData = data.some( series =>
164
177
  series.data.some(
165
178
  ( point: DataPointDate | DataPoint ) =>
166
- isNaN( point.value as number ) ||
167
- point.value === null ||
168
- point.value === undefined ||
179
+ isInvalidReading( point.value, { allowMissing: true } ) ||
169
180
  ( 'date' in point && point.date && isNaN( point.date.getTime() ) )
170
181
  )
171
182
  );
172
183
 
173
- if ( hasInvalidData ) return 'Invalid data';
184
+ if ( hasInvalidData ) return __( 'Invalid data', 'jetpack-charts' );
174
185
  return null;
175
186
  };
176
187
 
188
+ // visx derives the y domain from the readings, which fails when they have no range: none at all
189
+ // leaves no domain, and a flat line collapses it to one value that d3 draws mid-height.
190
+ const getFallbackYDomain = (
191
+ readingExtent: [ number, number ] | undefined,
192
+ isLogScale: boolean
193
+ ): [ number, number ] | undefined => {
194
+ // A log scale cannot reach zero, so its empty axis starts at 1.
195
+ const emptyDomain: [ number, number ] = isLogScale ? [ 1, 10 ] : [ 0, 1 ];
196
+
197
+ if ( ! readingExtent ) {
198
+ return emptyDomain;
199
+ }
200
+
201
+ const [ min, max ] = readingExtent;
202
+
203
+ if ( min !== max || isLogScale ) {
204
+ return undefined;
205
+ }
206
+
207
+ return min === 0 ? emptyDomain : [ Math.min( 0, min ), Math.max( 0, max ) ];
208
+ };
209
+
177
210
  // Inner component to access DataContext and provide scale data to ref
178
211
  const LineChartScalesRef: FC< {
179
212
  chartRef?: Ref< ChartInstanceRef >;
@@ -249,6 +282,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
249
282
  ) => {
250
283
  const legendInteractive = legend.interactive ?? false;
251
284
  const legendCollapseGroups = legend.collapseGroups ?? false;
285
+ const legendComparisonItem = legend.comparisonItem ?? false;
252
286
  const legendShape = legend.shape ?? 'line';
253
287
  const legendPosition = legend.position ?? 'bottom';
254
288
 
@@ -317,28 +351,42 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
317
351
  return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
318
352
  }, [ seriesWithVisibility ] );
319
353
 
320
- // When series visibility changes — via the interactive legend or programmatically —
321
- // and rescaling is opted out, pin the value axis to the full data range so it stays
322
- // put instead of visx rescaling the domain to whatever is currently visible and
323
- // making the axis jump. Default is to rescale, matching the pre-existing behaviour
324
- // and AreaChart's `rescaleYOnVisibilityChange`.
325
- const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
326
- if ( rescaleYOnVisibilityChange ) {
354
+ // The value extent of the series visx will scale to. Pinning the axis
355
+ // (`rescaleYOnVisibilityChange: false`) measures hidden series too, so hiding
356
+ // one leaves the axis where it is.
357
+ const yDomain = useMemo< [ number, number ] | undefined >( () => {
358
+ // A log scale cannot hold zero; visx ignores the flag there, and so does this.
359
+ const includeZero =
360
+ options?.yScale?.zero === true &&
361
+ scaleCanBeZeroed( { type: options.yScale.type ?? 'linear' } );
362
+ if ( rescaleYOnVisibilityChange && ! includeZero ) {
327
363
  return undefined;
328
364
  }
329
365
  let min = Infinity;
330
366
  let max = -Infinity;
331
367
  for ( const series of dataSorted ) {
368
+ if ( rescaleYOnVisibilityChange && ! isSeriesVisible( series.label ) ) {
369
+ continue;
370
+ }
332
371
  for ( const point of series.data ?? [] ) {
333
372
  const value = point?.value;
334
- if ( typeof value === 'number' && Number.isFinite( value ) ) {
373
+ if ( isReading( value ) ) {
335
374
  min = Math.min( min, value );
336
375
  max = Math.max( max, value );
337
376
  }
338
377
  }
339
378
  }
379
+ // Zero goes into the domain rather than through the scale's `zero` flag: visx
380
+ // applies `nice` before `zero`, which leaves the top of a zeroed axis unrounded.
381
+ if ( includeZero ) {
382
+ if ( min === Infinity ) {
383
+ return undefined;
384
+ }
385
+ // A series that is all zeros has no span, and d3 draws a spanless domain at mid-height.
386
+ return max === 0 && min === 0 ? [ 0, 1 ] : [ Math.min( 0, min ), Math.max( 0, max ) ];
387
+ }
340
388
  return min < max ? [ min, max ] : undefined;
341
- }, [ rescaleYOnVisibilityChange, dataSorted ] );
389
+ }, [ rescaleYOnVisibilityChange, dataSorted, isSeriesVisible, options?.yScale ] );
342
390
 
343
391
  // Keyboard navigation steps through x positions, and the grouped tooltip
344
392
  // reads every series at that position; the first series names the point.
@@ -365,7 +413,37 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
365
413
  preventTooltipScroll: tooltipPlacement === 'below-axis',
366
414
  } );
367
415
 
416
+ // visx's d3.extent skips null/undefined/NaN, so when every visible series is all null
417
+ // (e.g. entirely before a site launched) the y scale has no domain. Zoom filters nothing.
418
+ const visibleReadingExtent = useMemo< [ number, number ] | undefined >( () => {
419
+ let min = Infinity;
420
+ let max = -Infinity;
421
+ for ( const series of dataSorted ) {
422
+ if ( ! isSeriesVisible( series.label ) ) {
423
+ continue;
424
+ }
425
+ for ( const point of series.data ) {
426
+ const value = point?.value;
427
+ if ( isReading( value ) ) {
428
+ min = Math.min( min, value );
429
+ max = Math.max( max, value );
430
+ }
431
+ }
432
+ }
433
+ return min <= max ? [ min, max ] : undefined;
434
+ }, [ dataSorted, isSeriesVisible ] );
435
+
436
+ const hasWholeNumberValues = useMemo(
437
+ () => hasOnlyWholeNumbers( dataSorted.filter( series => isSeriesVisible( series.label ) ) ),
438
+ [ dataSorted, isSeriesVisible ]
439
+ );
440
+
368
441
  const chartOptions = useMemo( () => {
442
+ const fallbackYDomain = getFallbackYDomain(
443
+ visibleReadingExtent,
444
+ options?.yScale?.type === 'log'
445
+ );
446
+
369
447
  return {
370
448
  axis: {
371
449
  x: buildTimeAxisOptions( {
@@ -395,11 +473,21 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
395
473
  type: 'linear' as const,
396
474
  nice: true,
397
475
  zero: false,
398
- ...( stableYDomain ? { domain: stableYDomain } : {} ),
476
+ ...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
477
+ ...( yDomain ? { domain: yDomain } : {} ),
399
478
  ...options?.yScale,
400
479
  },
401
480
  };
402
- }, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
481
+ }, [
482
+ options,
483
+ dataSorted,
484
+ width,
485
+ zoom.domain,
486
+ yDomain,
487
+ visibleReadingExtent,
488
+ formatting,
489
+ isSeriesVisible,
490
+ ] );
403
491
 
404
492
  // Classified from the rendered series, like the axis above: a hidden
405
493
  // hourly line must not leave the tooltip naming an hour the axis dropped.
@@ -445,9 +533,16 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
445
533
  withGlyph: withLegendGlyph,
446
534
  glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
447
535
  collapseGroups: legendCollapseGroups,
536
+ comparisonItem: legendComparisonItem,
448
537
  renderGlyph,
449
538
  } ),
450
- [ withLegendGlyph, glyphStyle?.radius, legendCollapseGroups, renderGlyph ]
539
+ [
540
+ withLegendGlyph,
541
+ glyphStyle?.radius,
542
+ legendCollapseGroups,
543
+ legendComparisonItem,
544
+ renderGlyph,
545
+ ]
451
546
  );
452
547
 
453
548
  // Create legend items using the reusable hook
@@ -505,7 +600,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
505
600
  background: tooltipStyle?.background,
506
601
  backgroundColor: tooltipStyle?.backgroundColor,
507
602
  }
508
- )
603
+ )
509
604
  : renderTooltip( { ...params, bucketInfo } ),
510
605
  [ renderTooltip, bucketInfo, tooltipStyle ]
511
606
  );
@@ -584,24 +679,42 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
584
679
  // xScale and yScale could be set in Axis as well, but they are `scale` props there.
585
680
  xScale={ chartOptions.xScale }
586
681
  yScale={ chartOptions.yScale }
587
- onPointerDown={ zoom.handlers.onPointerDown }
588
- onPointerUp={ zoom.handlers.onPointerUp }
589
- onPointerMove={ zoom.handlers.onPointerMove }
590
682
  onPointerOut={ onPointerOut }
591
- pointerEventsDataKey="nearest"
592
683
  >
684
+ <NearestPointerEvents { ...zoom.handlers } />
593
685
  { /* With every series hidden there is no data to scale against, so the grid and
594
686
  axes are dropped while the empty state stands in — otherwise they render
595
687
  squished at the top. */ }
596
- { ! allSeriesHidden && gridVisibility !== 'none' && (
597
- <Grid columns={ false } numTicks={ 4 } />
598
- ) }
599
- { ! allSeriesHidden && chartOptions.axis.x.display && (
600
- <Axis { ...chartOptions.axis.x } />
601
- ) }
602
- { ! allSeriesHidden && chartOptions.axis.y.display && (
603
- <Axis { ...chartOptions.axis.y } />
604
- ) }
688
+ <WholeNumberTicks
689
+ axis="y"
690
+ numTicks={ chartOptions.axis.y.numTicks }
691
+ enabled={
692
+ hasWholeNumberValues &&
693
+ ! chartOptions.axis.y.tickValues &&
694
+ ! options?.yScale?.domain
695
+ }
696
+ >
697
+ { tickValues => (
698
+ <>
699
+ { ! allSeriesHidden && gridVisibility !== 'none' && (
700
+ <Grid
701
+ columns={ false }
702
+ numTicks={ chartOptions.axis.y.numTicks }
703
+ { ...{ tickValues: tickValues ?? chartOptions.axis.y.tickValues } }
704
+ />
705
+ ) }
706
+ { ! allSeriesHidden && chartOptions.axis.x.display && (
707
+ <Axis { ...chartOptions.axis.x } />
708
+ ) }
709
+ { ! allSeriesHidden && chartOptions.axis.y.display && (
710
+ <Axis
711
+ { ...chartOptions.axis.y }
712
+ { ...( tickValues ? { tickValues } : {} ) }
713
+ />
714
+ ) }
715
+ </>
716
+ ) }
717
+ </WholeNumberTicks>
605
718
 
606
719
  { allSeriesHidden ? (
607
720
  <SvgEmptyState
@@ -2,3 +2,4 @@ export { default as LineChartAnnotationLabelWithPopover } from './line-chart-ann
2
2
  export { default as LineChartAnnotationsOverlay } from './line-chart-annotations-overlay';
3
3
  export { default as LineChartAnnotation } from './line-chart-annotation';
4
4
  export { default as LineChartGlyph } from './line-chart-glyph';
5
+ export { NearestPointerEvents } from './nearest-pointer-events';
@@ -269,7 +269,7 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
269
269
  }px)`,
270
270
  width: labelWidth,
271
271
  height: labelHeight,
272
- }
272
+ }
273
273
  : undefined;
274
274
  };
275
275
 
@@ -20,14 +20,28 @@ const LineChartGlyph: FC< LineChartGlyphProps > = ( {
20
20
  const { xScale, yScale } = useContext( DataContext ) || {};
21
21
  if ( ! xScale || ! yScale ) return null;
22
22
 
23
- if ( data.data.length === 0 ) return null;
23
+ // Skip buckets with no reading so the edge glyph lands on the nearest real one.
24
+ const hasFiniteY = ( datum: ( typeof data.data )[ number ] ) => {
25
+ const scaledY = yScale( accessors.yAccessor( datum ) );
26
+ return typeof scaledY === 'number' && Number.isFinite( scaledY );
27
+ };
24
28
 
25
- const point = position === 'start' ? data.data[ 0 ] : data.data[ data.data.length - 1 ];
29
+ const point =
30
+ position === 'start' ? data.data.find( hasFiniteY ) : data.data.findLast( hasFiniteY );
31
+
32
+ if ( ! point ) return null;
26
33
 
27
34
  const x = xScale( accessors.xAccessor( point ) );
28
35
  const y = yScale( accessors.yAccessor( point ) );
29
36
 
30
- if ( typeof x !== 'number' || typeof y !== 'number' ) return null;
37
+ if (
38
+ typeof x !== 'number' ||
39
+ typeof y !== 'number' ||
40
+ ! Number.isFinite( x ) ||
41
+ ! Number.isFinite( y )
42
+ ) {
43
+ return null;
44
+ }
31
45
 
32
46
  const size = Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 );
33
47
 
@@ -0,0 +1,155 @@
1
+ import { bisector } from '@visx/vendor/d3-array';
2
+ import { DataContext, useEventEmitter } from '@visx/xychart';
3
+ import { useCallback, useContext, useMemo } from 'react';
4
+ import type { EventHandlerParams } from '@visx/xychart';
5
+
6
+ type EmittedPointer = Parameters< NonNullable< Parameters< typeof useEventEmitter >[ 1 ] > >[ 0 ];
7
+ type PointerParams = EventHandlerParams< object >;
8
+ type PointerHandler = ( params: PointerParams ) => void;
9
+ type Scale = ( ( value: unknown ) => unknown ) & { invert?: ( position: number ) => unknown };
10
+ type Candidate = { params: PointerParams; isMissing: boolean };
11
+
12
+ /**
13
+ * Find the datum nearest a pointer along x, in one series sorted by x.
14
+ *
15
+ * @param data - The series' registered data, in ascending x order.
16
+ * @param xAccessor - Reads a datum's x value.
17
+ * @param xScale - The chart's x scale.
18
+ * @param x - Pointer x in SVG coordinates.
19
+ * @return Index of the nearest datum and its distance, or index -1 when none has a position.
20
+ */
21
+ function nearestByX(
22
+ data: object[],
23
+ xAccessor: ( datum: object ) => unknown,
24
+ xScale: Scale,
25
+ x: number
26
+ ): { index: number; distance: number } {
27
+ const indexes =
28
+ typeof xScale.invert === 'function'
29
+ ? [ bisector( xAccessor ).left( data, xScale.invert( x ) ) ].flatMap( index => [
30
+ index - 1,
31
+ index,
32
+ ] )
33
+ : data.map( ( _datum, index ) => index );
34
+
35
+ return indexes
36
+ .filter( index => index >= 0 && index < data.length )
37
+ .reduce(
38
+ ( best, index ) => {
39
+ const distance = Math.abs( Number( xScale( xAccessor( data[ index ] ) ) ) - x );
40
+ return distance < best.distance ? { index, distance } : best;
41
+ },
42
+ { index: -1, distance: Infinity }
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Pick the candidate to report: the closest placed reading, else the closest bucket with no reading along x.
48
+ *
49
+ * @param candidates - One candidate per series.
50
+ * @return The chosen candidate's params, or undefined when there are none.
51
+ */
52
+ function pickNearest( candidates: Candidate[] ): PointerParams | undefined {
53
+ const placed = candidates
54
+ .map( ( { params } ) => params )
55
+ .filter( params => Number.isFinite( params.distanceY ) );
56
+ const missing = candidates
57
+ .filter( ( { isMissing } ) => isMissing )
58
+ .map( ( { params } ) => params );
59
+ // Same metric as visx's TooltipProvider, so a callback names the datum the tooltip shows.
60
+ const distance = ( params: PointerParams ) =>
61
+ placed.length
62
+ ? Math.hypot( params.distanceX ?? 0, params.distanceY ?? 0 )
63
+ : ( params.distanceX ?? 0 );
64
+
65
+ return ( placed.length ? placed : missing ).reduce< PointerParams | undefined >(
66
+ ( best, params ) => ( ! best || distance( params ) < distance( best ) ? params : best ),
67
+ undefined
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Report pointer events at the nearest datum, including a bucket with no reading.
73
+ *
74
+ * visx's own nearest search measures y distance too, which is NaN for a null value, so a pointer
75
+ * over a bucket where no series has a reading fires no event at all.
76
+ *
77
+ * @param props - Handlers to call.
78
+ * @param props.onPointerDown - Receives the nearest datum on pointer down.
79
+ * @param props.onPointerMove - Receives the nearest datum on pointer move.
80
+ * @param props.onPointerUp - Receives the nearest datum on pointer up.
81
+ * @return No visual content.
82
+ */
83
+ export function NearestPointerEvents( {
84
+ onPointerDown,
85
+ onPointerMove,
86
+ onPointerUp,
87
+ }: {
88
+ onPointerDown?: PointerHandler;
89
+ onPointerMove?: PointerHandler;
90
+ onPointerUp?: PointerHandler;
91
+ } ) {
92
+ const { xScale, yScale, dataRegistry } = useContext( DataContext );
93
+
94
+ const findNearest = useCallback(
95
+ ( params: EmittedPointer ) => {
96
+ const point = params?.svgPoint;
97
+ if ( ! point || ! xScale || ! yScale || ! dataRegistry ) {
98
+ return undefined;
99
+ }
100
+ const candidates: Candidate[] = [];
101
+ for ( const key of dataRegistry.keys() ) {
102
+ const entry = dataRegistry.get( key );
103
+ if ( ! entry ) {
104
+ continue;
105
+ }
106
+ const { index, distance } = nearestByX(
107
+ entry.data,
108
+ entry.xAccessor,
109
+ xScale as Scale,
110
+ point.x
111
+ );
112
+ if ( index < 0 ) {
113
+ continue;
114
+ }
115
+ const datum = entry.data[ index ];
116
+ const value = entry.yAccessor( datum );
117
+ candidates.push( {
118
+ params: {
119
+ event: params.event,
120
+ svgPoint: point,
121
+ key,
122
+ datum,
123
+ index,
124
+ distanceX: distance,
125
+ distanceY: Math.abs( Number( ( yScale as Scale )( value ) ) - point.y ),
126
+ },
127
+ isMissing: value == null,
128
+ } );
129
+ }
130
+ return pickNearest( candidates );
131
+ },
132
+ [ xScale, yScale, dataRegistry ]
133
+ );
134
+
135
+ const handlers = useMemo( () => {
136
+ const report = ( handler?: PointerHandler ) =>
137
+ handler &&
138
+ ( ( params: EmittedPointer ) => {
139
+ const nearest = findNearest( params );
140
+ if ( nearest ) {
141
+ handler( nearest );
142
+ }
143
+ } );
144
+ return {
145
+ pointerdown: report( onPointerDown ),
146
+ pointermove: report( onPointerMove ),
147
+ pointerup: report( onPointerUp ),
148
+ };
149
+ }, [ findNearest, onPointerDown, onPointerMove, onPointerUp ] );
150
+
151
+ useEventEmitter( 'pointerdown', handlers.pointerdown );
152
+ useEventEmitter( 'pointermove', handlers.pointermove );
153
+ useEventEmitter( 'pointerup', handlers.pointerup );
154
+ return null;
155
+ }