Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.lineChart {
display: grid;
gap: 12px;
}

.chart {
width: 100%;
height: 680px;

&.expandedLegend {
height: 800px;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render } from '@testing-library/react';
import { createRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, beforeEach, vi } from 'vitest';
import * as echarts from 'echarts';

import LineChart from './LineChart';
import { LineChart, LineChartHandle } from './LineChart';
import { mapPxTableToChartDataset } from '../Utils/chartDataMapper';
import { useEChartOption } from '../Utils/useEChartOption';
import {
Expand All @@ -11,6 +13,7 @@ import {
import { getChartColorsFromCssVariables } from '../Utils/chartHelper';
import type { EChartsDataset } from '../Utils/chartTypes';
import type { PxTable } from '../../../shared-types/pxTable';
import styles from './LineChart.module.scss';

vi.mock('../Utils/chartDataMapper', () => ({
mapPxTableToChartDataset: vi.fn(),
Expand All @@ -36,6 +39,14 @@ vi.mock('../Utils/chartHelper', () => ({
getChartColorsFromCssVariables: vi.fn(),
}));

vi.mock('echarts', async () => {
const actual = await vi.importActual<typeof import('echarts')>('echarts');
return {
...actual,
init: vi.fn(actual.init),
};
});

const mockDataset: EChartsDataset = {
title: 'Population by year',
origin: 'Statistics Demo',
Expand Down Expand Up @@ -64,6 +75,30 @@ function getTooltipFormatter(option: { tooltip?: unknown }) {
: undefined;
}

function getLastChartOption() {
const calls = vi.mocked(useEChartOption).mock.calls;
const lastCall = calls.at(-1);

if (!lastCall) {
throw new Error('Expected useEChartOption to be called');
}

return lastCall[0];
}

const overflowingDataset: EChartsDataset = {
...mockDataset,
dimensions: ['name', 's1', 's2', 's3', 's4', 's5'],
source: [{ name: '2024', s1: 10, s2: 12, s3: 14, s4: 16, s5: 18 }],
series: [
{ key: 's1', name: 'Detailed population segment 1' },
{ key: 's2', name: 'Detailed population segment 2' },
{ key: 's3', name: 'Detailed population segment 3' },
{ key: 's4', name: 'Detailed population segment 4' },
{ key: 's5', name: 'Detailed population segment 5' },
],
};

describe('LineChart', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -105,7 +140,7 @@ describe('LineChart', () => {
const option = vi.mocked(useEChartOption).mock.calls[0][0];

expect(option.legend).toEqual({
height: 40 * mockDataset.series.length,
height: 72,
});
expect(option.yAxis).toMatchObject({
name: 'persons',
Expand Down Expand Up @@ -147,15 +182,13 @@ describe('LineChart', () => {
);
});

it('renders chart container with height based on number of series', () => {
it('renders chart container with stylesheet sizing', () => {
const { container } = render(<LineChart pxtable={{} as PxTable} />);

const chartDiv = Array.from(container.querySelectorAll('div')).find(
(element) => element.style.height,
);
const chartDiv = container.querySelector(`.${styles.chart}`);

expect(chartDiv).toBeTruthy();
expect(chartDiv?.style.height).toBe('630px');
expect(chartDiv?.getAttribute('style')).toBeNull();
});

it('returns empty tooltip text for empty params', () => {
Expand Down Expand Up @@ -198,4 +231,233 @@ describe('LineChart', () => {
expect(html).toContain('<circle');
expect(html).toContain('<rect');
});

it('uses ECharts paginated legend without changing chart data', () => {
vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset);

render(
<LineChart
pxtable={{} as PxTable}
legendOverflowMode="pagination"
visibleLegendItemCount={2}
/>,
);

expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset);
expect(buildSeriesOption).toHaveBeenLastCalledWith(
overflowingDataset,
'line',
['#333333', '#444444'],
);
expect(getLastChartOption().legend).toEqual({
height: 48,
type: 'scroll',
orient: 'vertical',
data: overflowingDataset.series.map((series) => series.name),
pageButtonPosition: 'end',
pageFormatter: '{current} / {total}',
});
expect(getLastChartOption().dataZoom).toBeUndefined();
});

it('supports a horizontal ECharts paginated legend', () => {
vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset);

render(
<LineChart
pxtable={{} as PxTable}
legendOverflowMode="pagination"
legendPaginationOrientation="horizontal"
visibleLegendItemCount={2}
/>,
);

expect(getLastChartOption().legend).toEqual({
height: 48,
type: 'scroll',
orient: 'horizontal',
data: overflowingDataset.series.map((series) => series.name),
pageButtonPosition: 'end',
pageFormatter: '{current} / {total}',
});
});

it('reveals all legend items when show more mode is expanded', () => {
vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset);

render(
<LineChart
pxtable={{} as PxTable}
legendOverflowMode="showMore"
visibleLegendItemCount={2}
/>,
);

expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset);
expect(getLastChartOption().legend).toEqual({
height: 48,
data: ['Detailed population segment 1', 'Detailed population segment 2'],
});

fireEvent.click(screen.getByRole('button', { name: 'Show more' }));

expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset);
expect(getLastChartOption().legend).toEqual({
height: 120,
data: overflowingDataset.series.map((series) => series.name),
});
expect(screen.getByRole('button', { name: 'Show less' })).toBeTruthy();
});

it('exposes getSvgDataURL that renders the option offscreen with an svg renderer', () => {
vi.mocked(useEChartOption).mockReturnValue({
divRef: { current: null },
chartRef: {
current: {
getDataURL: vi.fn(),
getWidth: () => 640,
getHeight: () => 480,
} as never,
},
});

const svgExportGetDataURL = vi
.fn()
.mockReturnValue('data:image/svg+xml;base64,abc');
const dispose = vi.fn();
const setOption = vi.fn();
const initSpy = vi.mocked(echarts.init).mockReturnValue({
setOption,
getDataURL: svgExportGetDataURL,
dispose,
} as never);

const ref = createRef<LineChartHandle>();
render(<LineChart pxtable={{} as PxTable} ref={ref} />);

expect(ref.current?.getSvgDataURL()).toBe('data:image/svg+xml;base64,abc');
expect(initSpy).toHaveBeenCalledWith(
expect.any(HTMLDivElement),
null,
expect.objectContaining({ renderer: 'svg', width: 640, height: 480 }),
);
expect(svgExportGetDataURL).toHaveBeenCalledWith({ type: 'svg' });
expect(setOption).toHaveBeenCalledWith(
expect.objectContaining({ animation: false }),
);
expect(dispose).toHaveBeenCalledTimes(1);

initSpy.mockRestore();
});

it('exposes getPngDataURL that renders the option offscreen with a canvas renderer', () => {
const svgChartGetDataURL = vi.fn();
vi.mocked(useEChartOption).mockReturnValue({
divRef: { current: null },
chartRef: {
current: {
getDataURL: svgChartGetDataURL,
getWidth: () => 640,
getHeight: () => 480,
} as never,
},
});

const pngChartGetDataURL = vi
.fn()
.mockReturnValue('data:image/png;base64,abc');
const dispose = vi.fn();
const setOption = vi.fn();
const initSpy = vi.mocked(echarts.init).mockReturnValue({
setOption,
getDataURL: pngChartGetDataURL,
dispose,
} as never);

const ref = createRef<LineChartHandle>();
render(<LineChart pxtable={{} as PxTable} ref={ref} />);

expect(ref.current?.getPngDataURL()).toBe('data:image/png;base64,abc');
expect(initSpy).toHaveBeenCalledWith(
expect.any(HTMLDivElement),
null,
expect.objectContaining({ renderer: 'canvas', width: 640, height: 480 }),
);
expect(pngChartGetDataURL).toHaveBeenCalledWith({ type: 'png' });
expect(setOption).toHaveBeenCalledWith(
expect.objectContaining({ animation: false }),
);
expect(dispose).toHaveBeenCalledTimes(1);
expect(svgChartGetDataURL).not.toHaveBeenCalled();

initSpy.mockRestore();
});

it('always exports the full legend and extra height, even when "show more" truncates it on screen', () => {
vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset);
vi.mocked(useEChartOption).mockReturnValue({
divRef: { current: null },
chartRef: {
current: {
getDataURL: vi.fn(),
getWidth: () => 640,
getHeight: () => 480,
} as never,
},
});

const setOption = vi.fn();
const initSpy = vi.mocked(echarts.init).mockReturnValue({
setOption,
getDataURL: vi.fn().mockReturnValue('data:image/png;base64,abc'),
dispose: vi.fn(),
} as never);

const ref = createRef<LineChartHandle>();
render(
<LineChart
pxtable={{} as PxTable}
legendOverflowMode="showMore"
visibleLegendItemCount={2}
ref={ref}
/>,
);

// On screen, only 2 of the 5 legend items are shown until "Show more" is clicked
expect(getLastChartOption().legend).toEqual({
height: 48,
data: overflowingDataset.series.slice(0, 2).map((series) => series.name),
});

ref.current?.getPngDataURL();

// Exported legend always contains every series, with no truncated `data` list
expect(setOption).toHaveBeenCalledWith(
expect.objectContaining({ legend: { height: 120 } }),
);
// Chart height grows to fit the full legend instead of the collapsed on-screen height
expect(initSpy).toHaveBeenCalledWith(
expect.any(HTMLDivElement),
null,
expect.objectContaining({ height: 480 + (120 - 48) }),
);

initSpy.mockRestore();
});

it('returns undefined from getSvgDataURL when the chart instance is not ready', () => {
const ref = createRef<LineChartHandle>();

render(<LineChart pxtable={{} as PxTable} ref={ref} />);

expect(ref.current?.getSvgDataURL()).toBeUndefined();
});

it('returns undefined from getPngDataURL when the chart instance is not ready', () => {
const ref = createRef<LineChartHandle>();

render(<LineChart pxtable={{} as PxTable} ref={ref} />);

expect(ref.current?.getPngDataURL()).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.storyWrapper {
max-width: 960px;
margin: 0 auto;
}

.legendOverflowComparison {
display: grid;
gap: 32px;
}
Loading
Loading