@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.
- package/CHANGELOG.md +39 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1335 -348
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -1
- package/dist/index.d.cts +129 -8
- package/dist/index.d.ts +129 -8
- package/dist/index.js +1336 -351
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +42 -10
- package/src/charts/area-chart/private/overlays.tsx +1 -2
- 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 +137 -70
- package/src/charts/bar-chart/index.ts +1 -1
- package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
- package/src/charts/bar-chart/private/band-scale.ts +13 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
- package/src/charts/bar-chart/private/index.ts +3 -0
- package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
- package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
- package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
- package/src/charts/bar-chart/private/value-domain.ts +90 -0
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
- package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
- package/src/charts/bar-chart/types.ts +39 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
- package/src/charts/conversion-funnel-chart/types.ts +4 -2
- package/src/charts/geo-chart/types.ts +4 -2
- package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
- package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
- package/src/charts/heatmap-chart/index.ts +6 -0
- package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
- package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
- package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
- package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
- package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
- package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
- package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
- package/src/charts/heatmap-chart/types.ts +59 -3
- package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
- package/src/charts/leaderboard-chart/types.ts +13 -14
- package/src/charts/line-chart/line-chart.tsx +149 -36
- package/src/charts/line-chart/private/index.ts +1 -0
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- 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 +4 -3
- 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/time-axis-options.ts +1 -1
- package/src/charts/private/whole-number-ticks.tsx +79 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
- 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 +37 -22
- 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 +128 -41
- package/src/components/tooltip/base-tooltip.module.scss +6 -1
- package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
- package/src/hooks/test/use-chart-margin.test.tsx +35 -0
- package/src/hooks/test/use-element-size.test.tsx +13 -13
- package/src/hooks/use-chart-margin.tsx +7 -2
- package/src/hooks/use-xychart-theme.ts +1 -1
- package/src/index.ts +7 -1
- 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/providers/chart-context/themes.ts +1 -0
- package/src/styles/test/chart-scope.test.ts +1 -1
- package/src/types.ts +14 -13
- package/src/utils/format-metric-value.ts +4 -4
- package/src/utils/get-edge-tick-widths.ts +1 -1
- package/src/utils/get-styles.ts +1 -1
- package/src/visx/types.ts +7 -10
- package/src/components/legend/utils/label-transform-factory.ts +0 -24
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-no-bind */
|
|
2
2
|
|
|
3
|
-
import { render, screen, waitFor, within, act } from '@testing-library/react';
|
|
3
|
+
import { render, screen, waitFor, within, act, fireEvent } from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
5
|
import { GlyphDiamond } from '@visx/glyph';
|
|
6
|
-
import { createElement, createRef } from 'react';
|
|
6
|
+
import { createElement, createRef, type ComponentProps } from 'react';
|
|
7
7
|
import { GlobalChartsProvider, defaultTheme } from '../../../providers';
|
|
8
8
|
import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
|
|
9
9
|
import LineChart, { LineChartUnresponsive } from '../line-chart';
|
|
10
10
|
import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
|
|
11
|
+
import type { DataPointDate, SeriesData } from '../../../types';
|
|
11
12
|
import type { ChartInstanceRef } from '../../private/chart-instance-context';
|
|
13
|
+
import type { RenderLineGlyphProps } from '../types';
|
|
12
14
|
|
|
13
15
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
14
16
|
const mockRefCallback = jest.fn();
|
|
@@ -254,18 +256,300 @@ describe( 'LineChart', () => {
|
|
|
254
256
|
expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument();
|
|
255
257
|
} );
|
|
256
258
|
|
|
257
|
-
test( '
|
|
259
|
+
test( 'renders a bucket with no reading instead of failing the whole chart', () => {
|
|
258
260
|
renderWithTheme( {
|
|
259
261
|
data: [
|
|
260
262
|
{
|
|
261
263
|
label: 'Series A',
|
|
262
264
|
data: [
|
|
263
|
-
{ date: new Date( '2024-01-01' ), value: null as number | null
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
266
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
} );
|
|
271
|
+
expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
|
|
272
|
+
expect( screen.getByRole( 'grid', { name: /line chart/i } ) ).toBeInTheDocument();
|
|
273
|
+
} );
|
|
274
|
+
|
|
275
|
+
test( 'tooltip reads No data for a bucket with no reading', async () => {
|
|
276
|
+
const user = userEvent.setup();
|
|
277
|
+
renderWithTheme( {
|
|
278
|
+
data: [
|
|
279
|
+
{
|
|
280
|
+
label: 'Series A',
|
|
281
|
+
data: [
|
|
282
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
283
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
} );
|
|
288
|
+
|
|
289
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
290
|
+
await user.keyboard( '{ArrowRight}' );
|
|
291
|
+
|
|
292
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-0' );
|
|
293
|
+
expect( tooltip ).toHaveTextContent( 'No data' );
|
|
294
|
+
expect( tooltip ).not.toHaveTextContent( 'Series A:0' );
|
|
295
|
+
} );
|
|
296
|
+
|
|
297
|
+
test( 'keeps a y axis when every visible bucket has no reading', () => {
|
|
298
|
+
renderWithTheme( {
|
|
299
|
+
data: [
|
|
300
|
+
{
|
|
301
|
+
label: 'Series A',
|
|
302
|
+
data: [
|
|
303
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
304
|
+
{ date: new Date( '2024-01-02' ), value: null as number | null },
|
|
305
|
+
{ date: new Date( '2024-01-03' ), value: null as number | null },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
} );
|
|
310
|
+
|
|
311
|
+
expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
|
|
312
|
+
|
|
313
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
314
|
+
const ticks = within( chart )
|
|
315
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
316
|
+
.map( el => el.textContent ?? '' );
|
|
317
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
318
|
+
ticks.forEach( tick => {
|
|
319
|
+
expect( Number.isFinite( Number( tick.replace( /,/g, '' ) ) ) ).toBe( true );
|
|
320
|
+
} );
|
|
321
|
+
} );
|
|
322
|
+
|
|
323
|
+
test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
|
|
324
|
+
renderWithTheme( {
|
|
325
|
+
data: [
|
|
326
|
+
{
|
|
327
|
+
label: 'Series A',
|
|
328
|
+
data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
|
|
329
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
330
|
+
value,
|
|
331
|
+
} ) ),
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
} );
|
|
335
|
+
|
|
336
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
337
|
+
const ticks = within( chart )
|
|
338
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
339
|
+
.map( el => el.textContent );
|
|
340
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
341
|
+
} );
|
|
342
|
+
|
|
343
|
+
test( 'ignores a hidden fractional series when checking for whole numbers', () => {
|
|
344
|
+
renderWithTheme( {
|
|
345
|
+
defaultHiddenSeries: [ 'Series B' ],
|
|
346
|
+
data: [
|
|
347
|
+
{
|
|
348
|
+
label: 'Series A',
|
|
349
|
+
data: [ 0, 1, 1, 0 ].map( ( value, i ) => ( {
|
|
350
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
351
|
+
value,
|
|
352
|
+
} ) ),
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
label: 'Series B',
|
|
356
|
+
data: [ 0.5, 0.5, 0.5, 0.5 ].map( ( value, i ) => ( {
|
|
357
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
358
|
+
value,
|
|
359
|
+
} ) ),
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
} );
|
|
363
|
+
|
|
364
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
365
|
+
const ticks = within( chart )
|
|
366
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
367
|
+
.map( el => el.textContent );
|
|
368
|
+
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
369
|
+
} );
|
|
370
|
+
|
|
371
|
+
test( 'keeps fractional ticks when the data has fractions', () => {
|
|
372
|
+
renderWithTheme( {
|
|
373
|
+
data: [
|
|
374
|
+
{
|
|
375
|
+
label: 'Series A',
|
|
376
|
+
data: [ 0, 0.5, 1 ].map( ( value, i ) => ( {
|
|
377
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
378
|
+
value,
|
|
379
|
+
} ) ),
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
} );
|
|
383
|
+
|
|
384
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
385
|
+
expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ).length ).toBeGreaterThan( 2 );
|
|
386
|
+
} );
|
|
387
|
+
|
|
388
|
+
test( "keeps a caller's y tickValues", () => {
|
|
389
|
+
renderWithTheme( {
|
|
390
|
+
data: [
|
|
391
|
+
{
|
|
392
|
+
label: 'Series A',
|
|
393
|
+
data: [ 0, 1 ].map( ( value, i ) => ( { date: new Date( 2024, i + 2, 1 ), value } ) ),
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
|
|
397
|
+
} );
|
|
398
|
+
|
|
399
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
400
|
+
expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
|
|
401
|
+
} );
|
|
402
|
+
|
|
403
|
+
test( 'keeps every tick on a y domain the caller pinned', () => {
|
|
404
|
+
renderWithTheme( {
|
|
405
|
+
data: [
|
|
406
|
+
{
|
|
407
|
+
label: 'Series A',
|
|
408
|
+
data: [ 0, 0, 0 ].map( ( value, i ) => ( {
|
|
409
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
410
|
+
value,
|
|
411
|
+
} ) ),
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
options: {
|
|
415
|
+
yScale: { domain: [ 0, 1 ] },
|
|
416
|
+
axis: { y: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` } },
|
|
417
|
+
},
|
|
418
|
+
} );
|
|
419
|
+
|
|
420
|
+
const chart = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
421
|
+
expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
|
|
422
|
+
} );
|
|
423
|
+
|
|
424
|
+
test( 'keeps a positive y domain on a log scale when every visible bucket has no reading', () => {
|
|
425
|
+
const ref = createRef< ChartInstanceRef >();
|
|
426
|
+
|
|
427
|
+
renderUnwrappedWithTheme(
|
|
428
|
+
{
|
|
429
|
+
options: { yScale: { type: 'log' } },
|
|
430
|
+
data: [
|
|
431
|
+
{
|
|
432
|
+
label: 'Series A',
|
|
433
|
+
data: [
|
|
434
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
435
|
+
{ date: new Date( '2024-01-02' ), value: null as number | null },
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
'default',
|
|
441
|
+
ref
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const domain = (
|
|
445
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
446
|
+
)?.domain();
|
|
447
|
+
|
|
448
|
+
expect( domain ).toEqual( [ 1, 10 ] );
|
|
449
|
+
} );
|
|
450
|
+
|
|
451
|
+
test.each( [
|
|
452
|
+
[ 'a flat reading beside buckets with no reading', [ null, null, 1, 1 ], [ 0, 1 ] ],
|
|
453
|
+
[ 'a flat positive series', [ 5, 5, 5 ], [ 0, 5 ] ],
|
|
454
|
+
[ 'a flat negative series', [ -3, -3 ], [ -3, 0 ] ],
|
|
455
|
+
[ 'an all-zero series', [ 0, 0 ], [ 0, 1 ] ],
|
|
456
|
+
] )( 'starts the y domain at zero for %s', ( _name, values, expected ) => {
|
|
457
|
+
const ref = createRef< ChartInstanceRef >();
|
|
458
|
+
|
|
459
|
+
renderUnwrappedWithTheme(
|
|
460
|
+
{
|
|
461
|
+
data: [
|
|
462
|
+
{
|
|
463
|
+
label: 'Series A',
|
|
464
|
+
data: values.map( ( value, index ) => ( {
|
|
465
|
+
date: new Date( 2024, 0, index + 1 ),
|
|
466
|
+
value: value as number | null,
|
|
467
|
+
} ) ),
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
'default',
|
|
472
|
+
ref
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
const domain = (
|
|
476
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
477
|
+
)?.domain();
|
|
478
|
+
|
|
479
|
+
expect( domain ).toEqual( expected );
|
|
480
|
+
} );
|
|
481
|
+
|
|
482
|
+
test( 'keeps the y domain off zero for a series that varies', () => {
|
|
483
|
+
const ref = createRef< ChartInstanceRef >();
|
|
484
|
+
|
|
485
|
+
renderUnwrappedWithTheme(
|
|
486
|
+
{
|
|
487
|
+
data: [
|
|
488
|
+
{
|
|
489
|
+
label: 'Series A',
|
|
490
|
+
data: [
|
|
491
|
+
{ date: new Date( '2024-01-01' ), value: 50 },
|
|
492
|
+
{ date: new Date( '2024-01-02' ), value: 60 },
|
|
493
|
+
],
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
'default',
|
|
498
|
+
ref
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const domain = (
|
|
502
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
503
|
+
)?.domain();
|
|
504
|
+
|
|
505
|
+
expect( Math.min( ...( domain ?? [] ) ) ).toBeGreaterThan( 0 );
|
|
506
|
+
} );
|
|
507
|
+
|
|
508
|
+
test( 'pins the value axis to a hidden series with real values, not the empty-domain fallback, when rescaleYOnVisibilityChange is false', () => {
|
|
509
|
+
const ref = createRef< ChartInstanceRef >();
|
|
510
|
+
|
|
511
|
+
renderUnwrappedWithTheme(
|
|
512
|
+
{
|
|
513
|
+
rescaleYOnVisibilityChange: false,
|
|
514
|
+
defaultHiddenSeries: [ 'Series A' ],
|
|
515
|
+
data: [
|
|
516
|
+
{
|
|
517
|
+
label: 'Series A',
|
|
518
|
+
data: [
|
|
519
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
520
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
521
|
+
],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
label: 'Series B',
|
|
525
|
+
data: [
|
|
526
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
527
|
+
{ date: new Date( '2024-01-02' ), value: null as number | null },
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
'default',
|
|
533
|
+
ref
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const domain = (
|
|
537
|
+
ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined
|
|
538
|
+
)?.domain();
|
|
539
|
+
|
|
540
|
+
expect( domain ).toBeDefined();
|
|
541
|
+
expect( domain ).not.toEqual( [ 0, 1 ] );
|
|
542
|
+
expect( Math.max( ...( domain ?? [] ) ) ).toBeGreaterThan( 1 );
|
|
543
|
+
} );
|
|
544
|
+
|
|
545
|
+
test( 'still rejects undefined values', () => {
|
|
546
|
+
renderWithTheme( {
|
|
547
|
+
data: [
|
|
548
|
+
{
|
|
549
|
+
label: 'Series A',
|
|
550
|
+
data: [
|
|
551
|
+
{ date: new Date( '2024-01-01' ), value: undefined as number | undefined },
|
|
552
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
269
553
|
],
|
|
270
554
|
},
|
|
271
555
|
],
|
|
@@ -273,6 +557,28 @@ describe( 'LineChart', () => {
|
|
|
273
557
|
expect( screen.getByText( /invalid data/i ) ).toBeInTheDocument();
|
|
274
558
|
} );
|
|
275
559
|
|
|
560
|
+
test( 'breaks the line at a bucket with no reading', () => {
|
|
561
|
+
renderWithTheme( {
|
|
562
|
+
data: [
|
|
563
|
+
{
|
|
564
|
+
label: 'Series A',
|
|
565
|
+
data: [
|
|
566
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
567
|
+
{ date: new Date( '2024-01-02' ), value: null as number | null },
|
|
568
|
+
{ date: new Date( '2024-01-03' ), value: 20 },
|
|
569
|
+
{ date: new Date( '2024-01-04' ), value: 30 },
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
} );
|
|
574
|
+
|
|
575
|
+
const grid = screen.getByRole( 'grid', { name: /line chart/i } );
|
|
576
|
+
// eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a <path class="visx-line">; the axis and grid lines are plain <line> elements sharing that class.
|
|
577
|
+
const linePath = grid.querySelector( 'path.visx-line' );
|
|
578
|
+
const gapCount = linePath?.getAttribute( 'd' )?.match( /M/g )?.length;
|
|
579
|
+
expect( gapCount ).toBe( 2 );
|
|
580
|
+
} );
|
|
581
|
+
|
|
276
582
|
test( 'handles invalid date values', () => {
|
|
277
583
|
renderWithTheme( {
|
|
278
584
|
data: [
|
|
@@ -1042,6 +1348,42 @@ describe( 'LineChart', () => {
|
|
|
1042
1348
|
const endGlyphs = screen.getAllByTestId( /end-glyph/i );
|
|
1043
1349
|
expect( endGlyphs ).toHaveLength( 1 );
|
|
1044
1350
|
} );
|
|
1351
|
+
|
|
1352
|
+
test( 'puts the start and end glyphs on the first and last real reading', () => {
|
|
1353
|
+
const recorded: { position?: 'start' | 'end'; value: number | null; y: number }[] = [];
|
|
1354
|
+
|
|
1355
|
+
renderWithTheme( {
|
|
1356
|
+
withStartGlyphs: true,
|
|
1357
|
+
withEndGlyphs: true,
|
|
1358
|
+
renderGlyph: ( props: RenderLineGlyphProps< DataPointDate > ) => {
|
|
1359
|
+
recorded.push( {
|
|
1360
|
+
position: props.position,
|
|
1361
|
+
value: props.datum.value ?? null,
|
|
1362
|
+
y: props.y,
|
|
1363
|
+
} );
|
|
1364
|
+
return null;
|
|
1365
|
+
},
|
|
1366
|
+
data: [
|
|
1367
|
+
{
|
|
1368
|
+
label: 'Series A',
|
|
1369
|
+
data: [
|
|
1370
|
+
{ date: new Date( '2024-01-01' ), value: null as number | null },
|
|
1371
|
+
{ date: new Date( '2024-01-02' ), value: 10 },
|
|
1372
|
+
{ date: new Date( '2024-01-03' ), value: 20 },
|
|
1373
|
+
{ date: new Date( '2024-01-04' ), value: null as number | null },
|
|
1374
|
+
],
|
|
1375
|
+
},
|
|
1376
|
+
],
|
|
1377
|
+
} );
|
|
1378
|
+
|
|
1379
|
+
const start = recorded.find( entry => entry.position === 'start' );
|
|
1380
|
+
const end = recorded.find( entry => entry.position === 'end' );
|
|
1381
|
+
|
|
1382
|
+
expect( start?.value ).toBe( 10 );
|
|
1383
|
+
expect( end?.value ).toBe( 20 );
|
|
1384
|
+
expect( Number.isFinite( start?.y ) ).toBe( true );
|
|
1385
|
+
expect( Number.isFinite( end?.y ) ).toBe( true );
|
|
1386
|
+
} );
|
|
1045
1387
|
} );
|
|
1046
1388
|
|
|
1047
1389
|
describe( 'Legend Glyphs', () => {
|
|
@@ -1713,6 +2055,127 @@ describe( 'LineChart', () => {
|
|
|
1713
2055
|
} );
|
|
1714
2056
|
} );
|
|
1715
2057
|
|
|
2058
|
+
describe( 'pointer events', () => {
|
|
2059
|
+
let screenTransform: PropertyDescriptor | undefined;
|
|
2060
|
+
|
|
2061
|
+
beforeAll( () => {
|
|
2062
|
+
screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
|
|
2063
|
+
// jsdom lacks the SVG transform used by visx to convert pointer coordinates.
|
|
2064
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
|
|
2065
|
+
configurable: true,
|
|
2066
|
+
value: () => null,
|
|
2067
|
+
} );
|
|
2068
|
+
} );
|
|
2069
|
+
|
|
2070
|
+
afterAll( () => {
|
|
2071
|
+
if ( screenTransform ) {
|
|
2072
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
|
|
2073
|
+
} else {
|
|
2074
|
+
Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
|
|
2075
|
+
}
|
|
2076
|
+
} );
|
|
2077
|
+
|
|
2078
|
+
const pointer = ( type: string, clientX: number, clientY: number ) => {
|
|
2079
|
+
// visx owns the pointer capture rect and does not expose an attribute prop for it.
|
|
2080
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
2081
|
+
const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' );
|
|
2082
|
+
fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) );
|
|
2083
|
+
};
|
|
2084
|
+
|
|
2085
|
+
const renderWithHandlers = ( data: SeriesData[], props = {} ) => {
|
|
2086
|
+
const handlers = {
|
|
2087
|
+
onPointerDown: jest.fn(),
|
|
2088
|
+
onPointerMove: jest.fn(),
|
|
2089
|
+
onPointerUp: jest.fn(),
|
|
2090
|
+
};
|
|
2091
|
+
mockUseXZoom.mockImplementation( () => ( { ...passthroughZoom(), handlers } ) );
|
|
2092
|
+
const ref = createRef< ChartInstanceRef >();
|
|
2093
|
+
renderUnwrappedWithTheme( { data, ...props }, 'default', ref );
|
|
2094
|
+
const xScale = ref.current?.getScales()?.xScale as ( date: Date ) => number;
|
|
2095
|
+
const yScale = ref.current?.getScales()?.yScale as ( value: number ) => number;
|
|
2096
|
+
return { handlers, xScale, yScale };
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
const series = ( label: string, values: ( number | null )[] ) => ( {
|
|
2100
|
+
label,
|
|
2101
|
+
options: {},
|
|
2102
|
+
data: values.map( ( value, index ) => ( {
|
|
2103
|
+
date: new Date( 2024, 0, index + 1 ),
|
|
2104
|
+
value,
|
|
2105
|
+
} ) ),
|
|
2106
|
+
} );
|
|
2107
|
+
|
|
2108
|
+
test( 'reports a bucket with no reading on pointer down, move and up', () => {
|
|
2109
|
+
const data = [ series( 'Series A', [ null, 10, 20 ] ) ];
|
|
2110
|
+
const { handlers, xScale } = renderWithHandlers( data );
|
|
2111
|
+
const x = xScale( data[ 0 ].data[ 0 ].date );
|
|
2112
|
+
|
|
2113
|
+
for ( const [ type, handler ] of [
|
|
2114
|
+
[ 'pointerdown', handlers.onPointerDown ],
|
|
2115
|
+
[ 'pointermove', handlers.onPointerMove ],
|
|
2116
|
+
[ 'pointerup', handlers.onPointerUp ],
|
|
2117
|
+
] as const ) {
|
|
2118
|
+
pointer( type, x, 150 );
|
|
2119
|
+
expect( handler ).toHaveBeenCalledTimes( 1 );
|
|
2120
|
+
expect( handler ).toHaveBeenLastCalledWith(
|
|
2121
|
+
expect.objectContaining( { key: 'Series A', index: 0, datum: data[ 0 ].data[ 0 ] } )
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
} );
|
|
2125
|
+
|
|
2126
|
+
test( 'prefers a series with a reading over one without at the same bucket', () => {
|
|
2127
|
+
const data = [ series( 'Series A', [ null, 10 ] ), series( 'Series B', [ 5, 10 ] ) ];
|
|
2128
|
+
const { handlers, xScale, yScale } = renderWithHandlers( data );
|
|
2129
|
+
|
|
2130
|
+
pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), yScale( 10 ) );
|
|
2131
|
+
|
|
2132
|
+
expect( handlers.onPointerDown ).toHaveBeenCalledTimes( 1 );
|
|
2133
|
+
expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
|
|
2134
|
+
expect.objectContaining( { key: 'Series B', index: 0 } )
|
|
2135
|
+
);
|
|
2136
|
+
} );
|
|
2137
|
+
|
|
2138
|
+
test( 'reports nothing for a reading the log scale cannot place', () => {
|
|
2139
|
+
const data = [ series( 'Series A', [ 0, 10 ] ) ];
|
|
2140
|
+
const { handlers, xScale } = renderWithHandlers( data, {
|
|
2141
|
+
options: { yScale: { type: 'log', domain: [ 1, 100 ] } },
|
|
2142
|
+
} );
|
|
2143
|
+
|
|
2144
|
+
pointer( 'pointerdown', xScale( data[ 0 ].data[ 0 ].date ), 150 );
|
|
2145
|
+
|
|
2146
|
+
expect( handlers.onPointerDown ).not.toHaveBeenCalled();
|
|
2147
|
+
} );
|
|
2148
|
+
|
|
2149
|
+
test( 'reports the nearest bucket in a long series', () => {
|
|
2150
|
+
const data = [
|
|
2151
|
+
series(
|
|
2152
|
+
'Series A',
|
|
2153
|
+
Array.from( { length: 60 }, ( _, i ) => i )
|
|
2154
|
+
),
|
|
2155
|
+
];
|
|
2156
|
+
const { handlers, xScale } = renderWithHandlers( data );
|
|
2157
|
+
const between =
|
|
2158
|
+
( xScale( data[ 0 ].data[ 37 ].date ) * 2 + xScale( data[ 0 ].data[ 38 ].date ) ) / 3;
|
|
2159
|
+
|
|
2160
|
+
pointer( 'pointerdown', between, 150 );
|
|
2161
|
+
|
|
2162
|
+
expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
|
|
2163
|
+
expect.objectContaining( { index: 37, datum: data[ 0 ].data[ 37 ] } )
|
|
2164
|
+
);
|
|
2165
|
+
} );
|
|
2166
|
+
|
|
2167
|
+
test( 'reports the series nearest the pointer when every series has a reading', () => {
|
|
2168
|
+
const data = [ series( 'Series A', [ 10, 10 ] ), series( 'Series B', [ 20, 20 ] ) ];
|
|
2169
|
+
const { handlers, xScale, yScale } = renderWithHandlers( data );
|
|
2170
|
+
|
|
2171
|
+
pointer( 'pointerdown', xScale( data[ 0 ].data[ 1 ].date ), yScale( 11 ) );
|
|
2172
|
+
|
|
2173
|
+
expect( handlers.onPointerDown ).toHaveBeenLastCalledWith(
|
|
2174
|
+
expect.objectContaining( { key: 'Series A', index: 1 } )
|
|
2175
|
+
);
|
|
2176
|
+
} );
|
|
2177
|
+
} );
|
|
2178
|
+
|
|
1716
2179
|
// The line is not animated, so it clips only while actually zoomed.
|
|
1717
2180
|
test( 'clips the series to the plot when zoomed', () => {
|
|
1718
2181
|
mockUseXZoom.mockImplementation( () => ( {
|
|
@@ -1729,6 +2192,138 @@ describe( 'LineChart', () => {
|
|
|
1729
2192
|
);
|
|
1730
2193
|
} );
|
|
1731
2194
|
|
|
2195
|
+
describe( 'Value axis baseline', () => {
|
|
2196
|
+
const steadySeries = [
|
|
2197
|
+
{
|
|
2198
|
+
label: 'Views',
|
|
2199
|
+
data: [ 921, 989, 954, 924, 967, 933, 978 ].map( ( value, day ) => ( {
|
|
2200
|
+
date: new Date( 2024, 0, day + 1 ),
|
|
2201
|
+
value,
|
|
2202
|
+
label: `Jan ${ day + 1 }`,
|
|
2203
|
+
} ) ),
|
|
2204
|
+
options: {},
|
|
2205
|
+
},
|
|
2206
|
+
];
|
|
2207
|
+
const twoSeries = [
|
|
2208
|
+
{
|
|
2209
|
+
label: 'Views',
|
|
2210
|
+
data: [ { date: new Date( '2024-01-01' ), value: 20, label: 'Jan 1' } ],
|
|
2211
|
+
options: {},
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
label: 'Visitors',
|
|
2215
|
+
data: [ { date: new Date( '2024-01-01' ), value: 200, label: 'Jan 1' } ],
|
|
2216
|
+
options: {},
|
|
2217
|
+
},
|
|
2218
|
+
];
|
|
2219
|
+
|
|
2220
|
+
const renderForDomain = (
|
|
2221
|
+
props: Partial< ComponentProps< typeof LineChartUnresponsive > >
|
|
2222
|
+
) => {
|
|
2223
|
+
let context: GlobalChartsContextValue | undefined;
|
|
2224
|
+
const Grab = () => {
|
|
2225
|
+
context = useGlobalChartsContext();
|
|
2226
|
+
return null;
|
|
2227
|
+
};
|
|
2228
|
+
const ref = createRef< ChartInstanceRef >();
|
|
2229
|
+
|
|
2230
|
+
render(
|
|
2231
|
+
<GlobalChartsProvider>
|
|
2232
|
+
<Grab />
|
|
2233
|
+
<LineChartUnresponsive
|
|
2234
|
+
width={ 500 }
|
|
2235
|
+
height={ 300 }
|
|
2236
|
+
withGradientFill={ false }
|
|
2237
|
+
chartId="line-baseline"
|
|
2238
|
+
ref={ ref }
|
|
2239
|
+
data={ steadySeries }
|
|
2240
|
+
{ ...props }
|
|
2241
|
+
/>
|
|
2242
|
+
</GlobalChartsProvider>
|
|
2243
|
+
);
|
|
2244
|
+
|
|
2245
|
+
return {
|
|
2246
|
+
domain: () =>
|
|
2247
|
+
( ref.current?.getScales()?.yScale as { domain: () => number[] } | undefined )?.domain(),
|
|
2248
|
+
hide: ( label: string ) =>
|
|
2249
|
+
act( () => context?.toggleSeriesVisibility( 'line-baseline', label ) ),
|
|
2250
|
+
};
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
it( 'fits the axis to the data by default', () => {
|
|
2254
|
+
expect( renderForDomain( {} ).domain() ).toEqual( [ 920, 990 ] );
|
|
2255
|
+
} );
|
|
2256
|
+
|
|
2257
|
+
it( 'rounds the top of a zero-based axis from zero, not from the data floor', () => {
|
|
2258
|
+
expect( renderForDomain( { options: { yScale: { zero: true } } } ).domain() ).toEqual( [
|
|
2259
|
+
0, 1000,
|
|
2260
|
+
] );
|
|
2261
|
+
} );
|
|
2262
|
+
|
|
2263
|
+
it( 'leaves a log scale to fit the data, since it cannot hold zero', () => {
|
|
2264
|
+
const { domain } = renderForDomain( { options: { yScale: { type: 'log', zero: true } } } );
|
|
2265
|
+
expect( domain() ).toEqual( [ 100, 1000 ] );
|
|
2266
|
+
} );
|
|
2267
|
+
|
|
2268
|
+
it( 'extends a zero-based axis below zero when a series goes negative', () => {
|
|
2269
|
+
const { domain } = renderForDomain( {
|
|
2270
|
+
options: { yScale: { zero: true } },
|
|
2271
|
+
data: [
|
|
2272
|
+
{
|
|
2273
|
+
label: 'Net change',
|
|
2274
|
+
data: [
|
|
2275
|
+
{ date: new Date( '2024-01-01' ), value: -15, label: 'Jan 1' },
|
|
2276
|
+
{ date: new Date( '2024-01-02' ), value: 30, label: 'Jan 2' },
|
|
2277
|
+
],
|
|
2278
|
+
options: {},
|
|
2279
|
+
},
|
|
2280
|
+
],
|
|
2281
|
+
} );
|
|
2282
|
+
expect( domain() ).toEqual( [ -15, 30 ] );
|
|
2283
|
+
} );
|
|
2284
|
+
|
|
2285
|
+
it( 'gives an all-zero series an axis from 0 to 1 instead of a spanless one', () => {
|
|
2286
|
+
const { domain } = renderForDomain( {
|
|
2287
|
+
options: { yScale: { zero: true } },
|
|
2288
|
+
data: [
|
|
2289
|
+
{
|
|
2290
|
+
label: 'Views',
|
|
2291
|
+
data: [
|
|
2292
|
+
{ date: new Date( '2024-01-01' ), value: 0, label: 'Jan 1' },
|
|
2293
|
+
{ date: new Date( '2024-01-02' ), value: 0, label: 'Jan 2' },
|
|
2294
|
+
],
|
|
2295
|
+
options: {},
|
|
2296
|
+
},
|
|
2297
|
+
],
|
|
2298
|
+
} );
|
|
2299
|
+
expect( domain() ).toEqual( [ 0, 1 ] );
|
|
2300
|
+
} );
|
|
2301
|
+
|
|
2302
|
+
it( 'rescales a zero-based axis to the visible series', () => {
|
|
2303
|
+
const { domain, hide } = renderForDomain( {
|
|
2304
|
+
options: { yScale: { zero: true } },
|
|
2305
|
+
data: twoSeries,
|
|
2306
|
+
} );
|
|
2307
|
+
expect( domain() ).toEqual( [ 0, 200 ] );
|
|
2308
|
+
|
|
2309
|
+
hide( 'Visitors' );
|
|
2310
|
+
|
|
2311
|
+
expect( domain() ).toEqual( [ 0, 20 ] );
|
|
2312
|
+
} );
|
|
2313
|
+
|
|
2314
|
+
it( 'pins a zero-based axis when rescaleYOnVisibilityChange is false', () => {
|
|
2315
|
+
const { domain, hide } = renderForDomain( {
|
|
2316
|
+
options: { yScale: { zero: true } },
|
|
2317
|
+
rescaleYOnVisibilityChange: false,
|
|
2318
|
+
data: twoSeries,
|
|
2319
|
+
} );
|
|
2320
|
+
|
|
2321
|
+
hide( 'Visitors' );
|
|
2322
|
+
|
|
2323
|
+
expect( domain() ).toEqual( [ 0, 200 ] );
|
|
2324
|
+
} );
|
|
2325
|
+
} );
|
|
2326
|
+
|
|
1732
2327
|
describe( 'Legend group collapsing', () => {
|
|
1733
2328
|
const comparisonPair = [
|
|
1734
2329
|
{
|
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
AnnotationStyles,
|
|
9
9
|
DataPoint,
|
|
10
10
|
} from '../../types';
|
|
11
|
-
import type { CrosshairStyle, RenderTooltipParams
|
|
11
|
+
import type { CrosshairStyle, RenderTooltipParams } from '../../visx/types';
|
|
12
12
|
import type { GlyphProps } from '@visx/xychart';
|
|
13
13
|
import type { ReactNode, SVGProps, FC, CSSProperties } from 'react';
|
|
14
14
|
|
|
@@ -45,7 +45,7 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
|
|
|
45
45
|
* Place the panel below the x-axis tick labels with a pointer at the datum x; horizontal bounds still apply.
|
|
46
46
|
* @default 'auto'
|
|
47
47
|
*/
|
|
48
|
-
tooltipPlacement?:
|
|
48
|
+
tooltipPlacement?: 'auto' | 'below-axis';
|
|
49
49
|
/**
|
|
50
50
|
* Inline container styles; see Below-Axis Tooltips in stories/index.docs.mdx for content color overrides.
|
|
51
51
|
*/
|
|
@@ -81,7 +81,8 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVi
|
|
|
81
81
|
|
|
82
82
|
export type TooltipDatum = {
|
|
83
83
|
key: string;
|
|
84
|
-
|
|
84
|
+
/** The series' reading, or null when the bucket has none. */
|
|
85
|
+
value: number | null;
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
export type LineChartGlyphProps = {
|