@automattic/charts 1.11.0 → 2.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.
- package/CHANGELOG.md +24 -2
- package/TOKENS.md +19 -1
- package/dist/index.cjs +2196 -2711
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +23 -43
- package/dist/index.d.cts +72 -6
- package/dist/index.d.ts +72 -6
- package/dist/index.js +278 -792
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/charts/area-chart/area-chart.tsx +31 -10
- package/src/charts/area-chart/test/area-chart.test.tsx +102 -0
- package/src/charts/area-chart/types.ts +20 -6
- package/src/charts/bar-chart/bar-chart.tsx +33 -9
- package/src/charts/bar-chart/test/bar-chart.test.tsx +117 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +7 -7
- package/src/charts/line-chart/line-chart.module.scss +3 -3
- package/src/charts/line-chart/line-chart.tsx +43 -7
- package/src/charts/line-chart/test/line-chart.test.tsx +234 -5
- package/src/charts/line-chart/types.ts +14 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +1 -1
- package/src/charts/private/test/time-axis.test.ts +182 -0
- package/src/charts/private/time-axis.ts +84 -5
- package/src/charts/private/x-zoom/index.ts +1 -0
- package/src/charts/private/{test → x-zoom/test}/x-zoom.test.tsx +92 -4
- package/src/charts/private/x-zoom/x-zoom.module.scss +35 -0
- package/src/charts/private/{x-zoom.tsx → x-zoom/x-zoom.tsx} +44 -27
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +152 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +118 -16
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/legend/private/base-legend.tsx +21 -9
- package/src/components/legend/test/legend-config-types.test.tsx +32 -0
- package/src/components/legend/test/legend.test.tsx +121 -1
- package/src/components/legend/types.ts +6 -0
- package/src/components/tooltip/base-tooltip.module.scss +1 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +1 -1
- package/src/index.ts +2 -0
- package/src/types.ts +31 -0
- package/src/charts/private/x-zoom.module.scss +0 -50
|
@@ -179,6 +179,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
179
179
|
onPointerMove = undefined,
|
|
180
180
|
onPointerOut = undefined,
|
|
181
181
|
zoomable = false,
|
|
182
|
+
rescaleYOnVisibilityChange = true,
|
|
182
183
|
children,
|
|
183
184
|
gridVisibility,
|
|
184
185
|
gap = 'md',
|
|
@@ -186,6 +187,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
186
187
|
ref
|
|
187
188
|
) => {
|
|
188
189
|
const legendInteractive = legend.interactive ?? false;
|
|
190
|
+
const legendCollapseGroups = legend.collapseGroups ?? false;
|
|
189
191
|
const legendShape = legend.shape ?? 'line';
|
|
190
192
|
const legendPosition = legend.position ?? 'bottom';
|
|
191
193
|
|
|
@@ -252,6 +254,28 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
252
254
|
return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
|
|
253
255
|
}, [ seriesWithVisibility ] );
|
|
254
256
|
|
|
257
|
+
// When the interactive legend can hide series and rescaling is opted out, pin the value axis
|
|
258
|
+
// to the full data range so it stays put as series are toggled instead of visx rescaling the
|
|
259
|
+
// domain to whatever is currently visible and making the axis jump. Default is to rescale,
|
|
260
|
+
// matching the pre-existing behaviour and AreaChart's `rescaleYOnVisibilityChange`.
|
|
261
|
+
const stableYDomain = useMemo< [ number, number ] | undefined >( () => {
|
|
262
|
+
if ( ! legendInteractive || rescaleYOnVisibilityChange ) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
let min = Infinity;
|
|
266
|
+
let max = -Infinity;
|
|
267
|
+
for ( const series of dataSorted ) {
|
|
268
|
+
for ( const point of series.data ?? [] ) {
|
|
269
|
+
const value = point?.value;
|
|
270
|
+
if ( typeof value === 'number' && Number.isFinite( value ) ) {
|
|
271
|
+
min = Math.min( min, value );
|
|
272
|
+
max = Math.max( max, value );
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return min < max ? [ min, max ] : undefined;
|
|
277
|
+
}, [ legendInteractive, rescaleYOnVisibilityChange, dataSorted ] );
|
|
278
|
+
|
|
255
279
|
// Use the keyboard navigation hook
|
|
256
280
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
257
281
|
selectedIndex,
|
|
@@ -263,7 +287,8 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
263
287
|
} );
|
|
264
288
|
|
|
265
289
|
const chartOptions = useMemo( () => {
|
|
266
|
-
const
|
|
290
|
+
const { tickResolution, ...xAxisOptions } = options?.axis?.x ?? {};
|
|
291
|
+
const formatter = xAxisOptions.tickFormat || getFormatter( dataSorted, tickResolution );
|
|
267
292
|
|
|
268
293
|
return {
|
|
269
294
|
axis: {
|
|
@@ -272,7 +297,7 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
272
297
|
numTicks: guessOptimalNumTicks( dataSorted, width, formatter ),
|
|
273
298
|
tickFormat: formatter,
|
|
274
299
|
display: true,
|
|
275
|
-
...
|
|
300
|
+
...xAxisOptions,
|
|
276
301
|
},
|
|
277
302
|
y: {
|
|
278
303
|
orientation: 'left' as const,
|
|
@@ -291,10 +316,11 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
291
316
|
type: 'linear' as const,
|
|
292
317
|
nice: true,
|
|
293
318
|
zero: false,
|
|
319
|
+
...( stableYDomain ? { domain: stableYDomain } : {} ),
|
|
294
320
|
...options?.yScale,
|
|
295
321
|
},
|
|
296
322
|
};
|
|
297
|
-
}, [ options, dataSorted, width, zoom.domain ] );
|
|
323
|
+
}, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
|
|
298
324
|
|
|
299
325
|
const tooltipRenderGlyph = useMemo( () => {
|
|
300
326
|
return ( props: GlyphProps< DataPointDate > ) => {
|
|
@@ -328,9 +354,10 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
328
354
|
() => ( {
|
|
329
355
|
withGlyph: withLegendGlyph,
|
|
330
356
|
glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
|
|
357
|
+
collapseGroups: legendCollapseGroups,
|
|
331
358
|
renderGlyph,
|
|
332
359
|
} ),
|
|
333
|
-
[ withLegendGlyph, glyphStyle?.radius, renderGlyph ]
|
|
360
|
+
[ withLegendGlyph, glyphStyle?.radius, legendCollapseGroups, renderGlyph ]
|
|
334
361
|
);
|
|
335
362
|
|
|
336
363
|
// Create legend items using the reusable hook
|
|
@@ -444,9 +471,18 @@ const LineChartInternal = forwardRef< SingleChartRef, LineChartProps >(
|
|
|
444
471
|
onPointerOut={ onPointerOut }
|
|
445
472
|
pointerEventsDataKey="nearest"
|
|
446
473
|
>
|
|
447
|
-
{
|
|
448
|
-
|
|
449
|
-
|
|
474
|
+
{ /* With every series hidden there is no data to scale against, so the grid and
|
|
475
|
+
axes are dropped while the empty state stands in — otherwise they render
|
|
476
|
+
squished at the top. */ }
|
|
477
|
+
{ ! allSeriesHidden && gridVisibility !== 'none' && (
|
|
478
|
+
<Grid columns={ false } numTicks={ 4 } />
|
|
479
|
+
) }
|
|
480
|
+
{ ! allSeriesHidden && chartOptions.axis.x.display && (
|
|
481
|
+
<Axis { ...chartOptions.axis.x } />
|
|
482
|
+
) }
|
|
483
|
+
{ ! allSeriesHidden && chartOptions.axis.y.display && (
|
|
484
|
+
<Axis { ...chartOptions.axis.y } />
|
|
485
|
+
) }
|
|
450
486
|
|
|
451
487
|
{ allSeriesHidden ? (
|
|
452
488
|
<SvgEmptyState
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-no-bind */
|
|
2
2
|
|
|
3
|
-
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
5
|
import { GlyphDiamond } from '@visx/glyph';
|
|
6
6
|
import { createElement, createRef } from 'react';
|
|
@@ -436,7 +436,48 @@ describe( 'LineChart', () => {
|
|
|
436
436
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
437
437
|
} );
|
|
438
438
|
|
|
439
|
+
test( 'renders hour ticks when tickResolution declares the buckets sub-daily.', () => {
|
|
440
|
+
renderWithTheme( {
|
|
441
|
+
width: 800,
|
|
442
|
+
options: { axis: { x: { tickResolution: 'hour' } } },
|
|
443
|
+
data: [
|
|
444
|
+
{
|
|
445
|
+
label: 'Series A',
|
|
446
|
+
// A day apart: spacing inference would read this as daily buckets.
|
|
447
|
+
data: [
|
|
448
|
+
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
449
|
+
{ date: new Date( '2024-01-02T00:00:00' ), value: 20 },
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
} );
|
|
454
|
+
|
|
455
|
+
const ticks = screen.getAllByText( /\d+\s(AM|PM)/ );
|
|
456
|
+
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
457
|
+
} );
|
|
458
|
+
|
|
439
459
|
test( 'renders ticks in short date format.', () => {
|
|
460
|
+
renderWithTheme( {
|
|
461
|
+
width: 800,
|
|
462
|
+
data: [
|
|
463
|
+
{
|
|
464
|
+
label: 'Series A',
|
|
465
|
+
// Weekly resolution: fine enough to keep full dates on the ticks.
|
|
466
|
+
data: Array.from( { length: 16 }, ( _, i ) => ( {
|
|
467
|
+
date: new Date( Date.UTC( 2024, 0, 1 + i * 7 ) ),
|
|
468
|
+
value: 10 + i,
|
|
469
|
+
} ) ),
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
} );
|
|
473
|
+
|
|
474
|
+
const ticks = screen.getAllByText(
|
|
475
|
+
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
|
|
476
|
+
);
|
|
477
|
+
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
478
|
+
} );
|
|
479
|
+
|
|
480
|
+
test( 'renders month ticks for month-or-coarser buckets within a year.', () => {
|
|
440
481
|
renderWithTheme( {
|
|
441
482
|
width: 800,
|
|
442
483
|
data: [
|
|
@@ -453,9 +494,7 @@ describe( 'LineChart', () => {
|
|
|
453
494
|
],
|
|
454
495
|
} );
|
|
455
496
|
|
|
456
|
-
const ticks = screen.getAllByText(
|
|
457
|
-
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d+$/
|
|
458
|
-
);
|
|
497
|
+
const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
|
|
459
498
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
460
499
|
} );
|
|
461
500
|
|
|
@@ -502,7 +541,8 @@ describe( 'LineChart', () => {
|
|
|
502
541
|
],
|
|
503
542
|
} );
|
|
504
543
|
|
|
505
|
-
|
|
544
|
+
// Roughly monthly buckets render month ticks under the resolution-aware formatter.
|
|
545
|
+
const ticks = screen.getAllByText( /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct)$/ );
|
|
506
546
|
expect( ticks.length ).toBeLessThan( 6 ); // Not much space
|
|
507
547
|
} );
|
|
508
548
|
|
|
@@ -1294,4 +1334,193 @@ describe( 'LineChart', () => {
|
|
|
1294
1334
|
'url(#chart-zoom-clip-zoomtest)'
|
|
1295
1335
|
);
|
|
1296
1336
|
} );
|
|
1337
|
+
|
|
1338
|
+
describe( 'Legend group collapsing', () => {
|
|
1339
|
+
const comparisonPair = [
|
|
1340
|
+
{
|
|
1341
|
+
label: 'Views',
|
|
1342
|
+
group: 'views',
|
|
1343
|
+
data: [
|
|
1344
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1345
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1346
|
+
],
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
label: 'Views — previous',
|
|
1350
|
+
group: 'views',
|
|
1351
|
+
options: { type: 'comparison' as const },
|
|
1352
|
+
data: [
|
|
1353
|
+
{ date: new Date( '2024-01-01' ), value: 8, label: 'Jan 1' },
|
|
1354
|
+
{ date: new Date( '2024-01-02' ), value: 16, label: 'Jan 2' },
|
|
1355
|
+
],
|
|
1356
|
+
},
|
|
1357
|
+
];
|
|
1358
|
+
|
|
1359
|
+
it( 'renders one legend item per series by default', () => {
|
|
1360
|
+
renderWithTheme( {
|
|
1361
|
+
showLegend: true,
|
|
1362
|
+
chartId: 'legend-groups-default',
|
|
1363
|
+
data: comparisonPair,
|
|
1364
|
+
} );
|
|
1365
|
+
|
|
1366
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1367
|
+
expect( screen.getByText( 'Views — previous' ) ).toBeInTheDocument();
|
|
1368
|
+
} );
|
|
1369
|
+
|
|
1370
|
+
it( 'collapses a group to one item when legend.collapseGroups is set', () => {
|
|
1371
|
+
renderWithTheme( {
|
|
1372
|
+
showLegend: true,
|
|
1373
|
+
legend: { collapseGroups: true },
|
|
1374
|
+
chartId: 'legend-groups-collapsed',
|
|
1375
|
+
data: comparisonPair,
|
|
1376
|
+
} );
|
|
1377
|
+
|
|
1378
|
+
expect( screen.getByText( 'Views' ) ).toBeInTheDocument();
|
|
1379
|
+
expect( screen.queryByText( 'Views — previous' ) ).not.toBeInTheDocument();
|
|
1380
|
+
} );
|
|
1381
|
+
|
|
1382
|
+
it( 'toggles only the clicked series when interactive without collapseGroups', async () => {
|
|
1383
|
+
const user = userEvent.setup();
|
|
1384
|
+
|
|
1385
|
+
renderWithTheme( {
|
|
1386
|
+
showLegend: true,
|
|
1387
|
+
legend: { interactive: true },
|
|
1388
|
+
chartId: 'legend-groups-interactive',
|
|
1389
|
+
data: comparisonPair,
|
|
1390
|
+
} );
|
|
1391
|
+
|
|
1392
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1393
|
+
await user.click( buttons[ 0 ] );
|
|
1394
|
+
|
|
1395
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1396
|
+
expect( buttons[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
|
|
1397
|
+
} );
|
|
1398
|
+
|
|
1399
|
+
it( 'toggles the whole group when interactive with collapseGroups', async () => {
|
|
1400
|
+
const user = userEvent.setup();
|
|
1401
|
+
|
|
1402
|
+
renderWithTheme( {
|
|
1403
|
+
showLegend: true,
|
|
1404
|
+
legend: { interactive: true, collapseGroups: true },
|
|
1405
|
+
chartId: 'legend-groups-interactive-collapsed',
|
|
1406
|
+
data: comparisonPair,
|
|
1407
|
+
} );
|
|
1408
|
+
|
|
1409
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1410
|
+
expect( buttons ).toHaveLength( 1 );
|
|
1411
|
+
|
|
1412
|
+
await user.click( buttons[ 0 ] );
|
|
1413
|
+
|
|
1414
|
+
expect( buttons[ 0 ] ).toHaveAttribute( 'aria-pressed', 'false' );
|
|
1415
|
+
expect(
|
|
1416
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1417
|
+
).toBeInTheDocument();
|
|
1418
|
+
} );
|
|
1419
|
+
} );
|
|
1420
|
+
|
|
1421
|
+
describe( 'Interactive Legend — value axis', () => {
|
|
1422
|
+
const twoSeries = [
|
|
1423
|
+
{
|
|
1424
|
+
label: 'Views',
|
|
1425
|
+
data: [
|
|
1426
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
1427
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
1428
|
+
],
|
|
1429
|
+
options: {},
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
label: 'Visitors',
|
|
1433
|
+
data: [
|
|
1434
|
+
{ date: new Date( '2024-01-01' ), value: 100, label: 'Jan 1' },
|
|
1435
|
+
{ date: new Date( '2024-01-02' ), value: 200, label: 'Jan 2' },
|
|
1436
|
+
],
|
|
1437
|
+
options: {},
|
|
1438
|
+
},
|
|
1439
|
+
];
|
|
1440
|
+
// Bare numbers inside the plot (scoped via the chart's grid role, away from visx's
|
|
1441
|
+
// off-screen text-measurement SVGs) are value-axis ticks: the series labels carry no digits
|
|
1442
|
+
// and the empty-state message is text-only.
|
|
1443
|
+
const numericTick = /^[\d,]+$/;
|
|
1444
|
+
|
|
1445
|
+
it( 'pins the value axis when rescaleYOnVisibilityChange is false', async () => {
|
|
1446
|
+
const user = userEvent.setup();
|
|
1447
|
+
|
|
1448
|
+
renderWithTheme( {
|
|
1449
|
+
showLegend: true,
|
|
1450
|
+
legend: { interactive: true },
|
|
1451
|
+
rescaleYOnVisibilityChange: false,
|
|
1452
|
+
chartId: 'line-stable-axis',
|
|
1453
|
+
data: twoSeries,
|
|
1454
|
+
} );
|
|
1455
|
+
|
|
1456
|
+
const chart = screen.getByRole( 'grid' );
|
|
1457
|
+
const ticksBefore = within( chart )
|
|
1458
|
+
.getAllByText( numericTick )
|
|
1459
|
+
.map( el => el.textContent )
|
|
1460
|
+
.sort();
|
|
1461
|
+
expect( ticksBefore.length ).toBeGreaterThan( 0 );
|
|
1462
|
+
|
|
1463
|
+
// Hide the high-range Visitors series; with a pinned domain the axis keeps its ticks.
|
|
1464
|
+
await user.click( screen.getAllByRole( 'button' )[ 1 ] );
|
|
1465
|
+
|
|
1466
|
+
const ticksAfter = within( chart )
|
|
1467
|
+
.getAllByText( numericTick )
|
|
1468
|
+
.map( el => el.textContent )
|
|
1469
|
+
.sort();
|
|
1470
|
+
expect( ticksAfter ).toEqual( ticksBefore );
|
|
1471
|
+
} );
|
|
1472
|
+
|
|
1473
|
+
it( 'rescales the value axis by default when a series is toggled off', async () => {
|
|
1474
|
+
const user = userEvent.setup();
|
|
1475
|
+
|
|
1476
|
+
renderWithTheme( {
|
|
1477
|
+
showLegend: true,
|
|
1478
|
+
legend: { interactive: true },
|
|
1479
|
+
chartId: 'line-rescale-axis',
|
|
1480
|
+
data: twoSeries,
|
|
1481
|
+
} );
|
|
1482
|
+
|
|
1483
|
+
const chart = screen.getByRole( 'grid' );
|
|
1484
|
+
const ticksBefore = within( chart )
|
|
1485
|
+
.getAllByText( numericTick )
|
|
1486
|
+
.map( el => el.textContent )
|
|
1487
|
+
.sort();
|
|
1488
|
+
expect( ticksBefore.length ).toBeGreaterThan( 0 );
|
|
1489
|
+
|
|
1490
|
+
// Default preserves the pre-existing behaviour: hiding the high-range Visitors series
|
|
1491
|
+
// lets the axis rescale to the remaining Views range, so its ticks change.
|
|
1492
|
+
await user.click( screen.getAllByRole( 'button' )[ 1 ] );
|
|
1493
|
+
|
|
1494
|
+
const ticksAfter = within( chart )
|
|
1495
|
+
.getAllByText( numericTick )
|
|
1496
|
+
.map( el => el.textContent )
|
|
1497
|
+
.sort();
|
|
1498
|
+
expect( ticksAfter ).not.toEqual( ticksBefore );
|
|
1499
|
+
} );
|
|
1500
|
+
|
|
1501
|
+
it( 'drops the axes when all series are hidden so they do not collapse', async () => {
|
|
1502
|
+
const user = userEvent.setup();
|
|
1503
|
+
|
|
1504
|
+
renderWithTheme( {
|
|
1505
|
+
showLegend: true,
|
|
1506
|
+
legend: { interactive: true },
|
|
1507
|
+
chartId: 'line-hidden-axes',
|
|
1508
|
+
data: twoSeries,
|
|
1509
|
+
} );
|
|
1510
|
+
|
|
1511
|
+
const chart = screen.getByRole( 'grid' );
|
|
1512
|
+
expect( within( chart ).getAllByText( numericTick ).length ).toBeGreaterThan( 0 );
|
|
1513
|
+
|
|
1514
|
+
const buttons = screen.getAllByRole( 'button' );
|
|
1515
|
+
await user.click( buttons[ 0 ] );
|
|
1516
|
+
await user.click( buttons[ 1 ] );
|
|
1517
|
+
|
|
1518
|
+
// With no visible data the value scale would collapse, so the axes are removed rather
|
|
1519
|
+
// than rendered squished at the top — no tick labels remain.
|
|
1520
|
+
expect( within( chart ).queryAllByText( numericTick ) ).toHaveLength( 0 );
|
|
1521
|
+
expect(
|
|
1522
|
+
screen.getByText( /all series are hidden.*click legend items to show data/i )
|
|
1523
|
+
).toBeInTheDocument();
|
|
1524
|
+
} );
|
|
1525
|
+
} );
|
|
1297
1526
|
} );
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
BaseChartProps,
|
|
3
3
|
DataPointDate,
|
|
4
4
|
SeriesData,
|
|
5
|
+
SeriesChartLegendConfig,
|
|
5
6
|
AnnotationStyles,
|
|
6
7
|
DataPoint,
|
|
7
8
|
} from '../../types';
|
|
@@ -28,6 +29,10 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
|
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export interface LineChartProps extends BaseChartProps< SeriesData[] > {
|
|
32
|
+
/**
|
|
33
|
+
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
34
|
+
*/
|
|
35
|
+
legend?: SeriesChartLegendConfig;
|
|
31
36
|
withGradientFill: boolean;
|
|
32
37
|
smoothing?: boolean;
|
|
33
38
|
curveType?: CurveType;
|
|
@@ -47,6 +52,15 @@ export interface LineChartProps extends BaseChartProps< SeriesData[] > {
|
|
|
47
52
|
* button appears in the top-right of the chart while zoomed.
|
|
48
53
|
*/
|
|
49
54
|
zoomable?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the Y axis rescales to fit only the visible series when series are
|
|
57
|
+
* hidden or shown through the interactive legend.
|
|
58
|
+
* Defaults to `true` (the pre-existing behaviour). Set to `false` to pin the Y
|
|
59
|
+
* axis to the full data extent so hiding series does not move the chart's
|
|
60
|
+
* baseline — useful for comparison charts. Matches `AreaChart`.
|
|
61
|
+
* @default true
|
|
62
|
+
*/
|
|
63
|
+
rescaleYOnVisibilityChange?: boolean;
|
|
50
64
|
children?: ReactNode;
|
|
51
65
|
}
|
|
52
66
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { getFormatter } from '../time-axis';
|
|
2
|
+
import type { useChartDataTransform } from '../../../hooks';
|
|
3
|
+
|
|
4
|
+
type SortedData = ReturnType< typeof useChartDataTransform >;
|
|
5
|
+
|
|
6
|
+
const toSeries = ( dates: Date[] ): SortedData =>
|
|
7
|
+
[
|
|
8
|
+
{
|
|
9
|
+
label: 'series',
|
|
10
|
+
data: dates.map( date => ( { date, value: 1 } ) ),
|
|
11
|
+
},
|
|
12
|
+
] as unknown as SortedData;
|
|
13
|
+
|
|
14
|
+
const hourlyDates = ( start: Date, hours: number ): Date[] =>
|
|
15
|
+
Array.from( { length: hours }, ( _, i ) => new Date( start.getTime() + i * 60 * 60 * 1000 ) );
|
|
16
|
+
|
|
17
|
+
const dailyDates = ( start: Date, days: number ): Date[] =>
|
|
18
|
+
Array.from( { length: days }, ( _, i ) => new Date( start.getTime() + i * 24 * 60 * 60 * 1000 ) );
|
|
19
|
+
|
|
20
|
+
// TZ is pinned to UTC by the test script, so local formatting is deterministic.
|
|
21
|
+
describe( 'getFormatter', () => {
|
|
22
|
+
it( 'uses hour ticks within a single day', () => {
|
|
23
|
+
const formatter = getFormatter(
|
|
24
|
+
toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 ) )
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'uses hour ticks with dates at midnight for sub-daily data spanning multiple days', () => {
|
|
31
|
+
const formatter = getFormatter(
|
|
32
|
+
toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 48 ) )
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect( formatter( new Date( '2026-08-03T00:00:00' ).getTime() ) ).toMatch( /Aug 3/ );
|
|
36
|
+
expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'keeps hour ticks up to a full week of sub-daily data', () => {
|
|
40
|
+
const formatter = getFormatter(
|
|
41
|
+
toSeries( hourlyDates( new Date( '2026-08-02T00:00:00' ), 24 * 7 + 1 ) )
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect( formatter( new Date( '2026-08-05T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'keeps date ticks for a two-point daily series spanning exactly a day', () => {
|
|
48
|
+
const formatter = getFormatter(
|
|
49
|
+
toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 2 ) )
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
it( 'keeps date ticks for daily data over the same multi-day span', () => {
|
|
56
|
+
const formatter = getFormatter(
|
|
57
|
+
toSeries( dailyDates( new Date( '2026-08-01T00:00:00' ), 3 ) )
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
|
|
61
|
+
expect( formatter( new Date( '2026-08-02T13:00:00' ).getTime() ) ).not.toMatch( /PM/ );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
it( 'keeps date ticks for daily buckets across a spring-forward transition', () => {
|
|
65
|
+
// A 23-hour first gap, as a daily series straddling spring-forward
|
|
66
|
+
// produces, must not demote the series to the sub-daily regime.
|
|
67
|
+
const start = new Date( '2026-03-08T00:00:00' );
|
|
68
|
+
const dstDates = [ 0, 23, 47, 71 ].map(
|
|
69
|
+
offsetHours => new Date( start.getTime() + offsetHours * 60 * 60 * 1000 )
|
|
70
|
+
);
|
|
71
|
+
const formatter = getFormatter( toSeries( dstDates ) );
|
|
72
|
+
|
|
73
|
+
expect( formatter( dstDates[ 2 ].getTime() ) ).not.toMatch( /AM|PM/ );
|
|
74
|
+
expect( formatter( dstDates[ 2 ].getTime() ) ).toMatch( /Mar/ );
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
it( 'keeps date ticks when no series has two points', () => {
|
|
78
|
+
const formatter = getFormatter( [
|
|
79
|
+
...toSeries( [ new Date( '2026-03-01T00:00:00' ) ] ),
|
|
80
|
+
...toSeries( [ new Date( '2026-03-04T00:00:00' ) ] ),
|
|
81
|
+
] );
|
|
82
|
+
|
|
83
|
+
expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
it( 'uses month ticks for buckets exactly a shortest-month apart', () => {
|
|
87
|
+
const formatter = getFormatter(
|
|
88
|
+
toSeries( [
|
|
89
|
+
new Date( '2026-02-01T00:00:00' ),
|
|
90
|
+
new Date( '2026-03-01T00:00:00' ),
|
|
91
|
+
new Date( '2026-04-01T00:00:00' ),
|
|
92
|
+
] )
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
|
|
96
|
+
} );
|
|
97
|
+
|
|
98
|
+
it( 'uses month ticks with the year at January for monthly buckets', () => {
|
|
99
|
+
const monthlyDates = Array.from(
|
|
100
|
+
{ length: 13 },
|
|
101
|
+
( _, i ) => new Date( Date.UTC( 2025, 7 + i, 1 ) )
|
|
102
|
+
);
|
|
103
|
+
const formatter = getFormatter( toSeries( monthlyDates ) );
|
|
104
|
+
|
|
105
|
+
expect( formatter( new Date( '2025-09-01T00:00:00' ).getTime() ) ).toBe( 'Sep' );
|
|
106
|
+
expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
it( 'keeps date ticks for weekly buckets within a year', () => {
|
|
110
|
+
const weeklyDates = Array.from(
|
|
111
|
+
{ length: 20 },
|
|
112
|
+
( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
|
|
113
|
+
);
|
|
114
|
+
const formatter = getFormatter( toSeries( weeklyDates ) );
|
|
115
|
+
|
|
116
|
+
expect( formatter( new Date( '2026-03-02T00:00:00' ).getTime() ) ).toMatch( /Mar 2/ );
|
|
117
|
+
} );
|
|
118
|
+
|
|
119
|
+
it( 'uses year ticks beyond a year', () => {
|
|
120
|
+
const yearly = getFormatter(
|
|
121
|
+
toSeries( [ new Date( '2020-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] )
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect( yearly( new Date( '2023-01-01T00:00:00' ).getTime() ) ).toBe( '2023' );
|
|
125
|
+
} );
|
|
126
|
+
|
|
127
|
+
describe( 'with an explicit tickResolution', () => {
|
|
128
|
+
it( 'uses hour ticks for two hourly points a day apart, where spacing would read as daily', () => {
|
|
129
|
+
const formatter = getFormatter(
|
|
130
|
+
toSeries( [ new Date( '2026-08-01T00:00:00' ), new Date( '2026-08-02T00:00:00' ) ] ),
|
|
131
|
+
'hour'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect( formatter( new Date( '2026-08-01T13:00:00' ).getTime() ) ).toMatch( /1\sPM/ );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'uses date ticks for a lone daily point, where spacing is unknowable', () => {
|
|
138
|
+
const formatter = getFormatter( toSeries( [ new Date( '2026-08-02T00:00:00' ) ] ), 'day' );
|
|
139
|
+
|
|
140
|
+
expect( formatter( new Date( '2026-08-02T00:00:00' ).getTime() ) ).toMatch( /Aug 2/ );
|
|
141
|
+
} );
|
|
142
|
+
|
|
143
|
+
it( 'uses month ticks for monthly buckets regardless of their measured gaps', () => {
|
|
144
|
+
const formatter = getFormatter(
|
|
145
|
+
toSeries( [ new Date( '2026-02-01T00:00:00' ), new Date( '2026-03-01T00:00:00' ) ] ),
|
|
146
|
+
'month'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect( formatter( new Date( '2026-03-01T00:00:00' ).getTime() ) ).toBe( 'Mar' );
|
|
150
|
+
} );
|
|
151
|
+
|
|
152
|
+
it( 'uses date ticks for weekly buckets', () => {
|
|
153
|
+
const weeklyDates = Array.from(
|
|
154
|
+
{ length: 8 },
|
|
155
|
+
( _, i ) => new Date( Date.UTC( 2026, 0, 5 + i * 7 ) )
|
|
156
|
+
);
|
|
157
|
+
const formatter = getFormatter( toSeries( weeklyDates ), 'week' );
|
|
158
|
+
|
|
159
|
+
expect( formatter( new Date( '2026-01-12T00:00:00' ).getTime() ) ).toMatch( /Jan 12/ );
|
|
160
|
+
} );
|
|
161
|
+
|
|
162
|
+
it( 'uses year ticks for a pair of yearly buckets', () => {
|
|
163
|
+
const formatter = getFormatter(
|
|
164
|
+
toSeries( [ new Date( '2025-01-01T00:00:00' ), new Date( '2026-01-01T00:00:00' ) ] ),
|
|
165
|
+
'year'
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect( formatter( new Date( '2025-01-01T00:00:00' ).getTime() ) ).toBe( '2025' );
|
|
169
|
+
expect( formatter( new Date( '2026-01-01T00:00:00' ).getTime() ) ).toBe( '2026' );
|
|
170
|
+
} );
|
|
171
|
+
|
|
172
|
+
it( 'uses year ticks for yearly buckets that do not start in January', () => {
|
|
173
|
+
const formatter = getFormatter(
|
|
174
|
+
toSeries( [ new Date( '2025-06-01T00:00:00' ), new Date( '2026-06-01T00:00:00' ) ] ),
|
|
175
|
+
'year'
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect( formatter( new Date( '2025-06-01T00:00:00' ).getTime() ) ).toBe( '2025' );
|
|
179
|
+
expect( formatter( new Date( '2026-06-01T00:00:00' ).getTime() ) ).toBe( '2026' );
|
|
180
|
+
} );
|
|
181
|
+
} );
|
|
182
|
+
} );
|