@automattic/charts 4.2.0 → 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.
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +470 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +471 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/validate-data.ts +2 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
- package/src/charts/bar-chart/bar-chart.tsx +100 -83
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
- package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
- package/src/charts/bar-chart/private/index.ts +1 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +484 -9
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/line-chart/line-chart.tsx +148 -35
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
- package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
- package/src/charts/line-chart/types.ts +2 -1
- package/src/charts/private/readings.ts +34 -0
- package/src/charts/private/test/readings.test.ts +29 -0
- package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
- package/src/components/legend/private/base-legend.tsx +36 -21
- package/src/components/legend/test/legend.test.tsx +84 -0
- package/src/components/legend/types.ts +2 -0
- package/src/components/legend/utils/index.ts +0 -1
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/providers/chart-context/global-charts-provider.tsx +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
- package/src/types.ts +8 -0
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { formatNumberCompact
|
|
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 {
|
|
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
|
|
134
|
+
value: datum.value ?? null,
|
|
127
135
|
} ) )
|
|
128
|
-
.sort( ( a, b ) =>
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 (
|
|
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
|
-
...(
|
|
476
|
+
...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
|
|
477
|
+
...( yDomain ? { domain: yDomain } : {} ),
|
|
399
478
|
...options?.yScale,
|
|
400
479
|
},
|
|
401
480
|
};
|
|
402
|
-
}, [
|
|
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
|
-
[
|
|
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
|
|
@@ -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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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';
|
|
@@ -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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
+
}
|