Skip to content

Commit 17d886c

Browse files
Feature/line chart (#1323)
Display table data as a line chart. Note that the chart functionality is still under construction. This is a first PR that will be followed by a number of more PR regarding charts. For this reason chart is disabled by default and is not visible in PxWeb yet.
1 parent 762e113 commit 17d886c

28 files changed

Lines changed: 1924 additions & 48 deletions

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pxweb2-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dependencies": {
1919
"@tanstack/react-virtual": "^3.13.21",
2020
"clsx": "^2.1.1",
21+
"echarts": "^6.1.0",
2122
"hast-util-to-jsx-runtime": "^2.3.6",
2223
"motion": "^12.40.0",
2324
"vite-plugin-dts": "^4.5.4"

packages/pxweb2-ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './lib/components/Badge/Badge';
55
export * from './lib/components/BottomSheet/BottomSheet';
66
export * from './lib/components/Breadcrumbs/Breadcrumbs';
77
export * from './lib/components/Button/Button';
8+
export * from './lib/components/Chart';
89
export * from './lib/components/Checkbox/Checkbox';
910
export * from './lib/components/CheckCircle/CheckCircleIcon';
1011
export * from './lib/components/CheckCircle/CheckCircleToggle';
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, it, beforeEach, vi } from 'vitest';
3+
4+
import LineChart from './LineChart';
5+
import { mapPxTableToChartDataset } from '../Utils/chartDataMapper';
6+
import { useEChartOption } from '../Utils/useEChartOption';
7+
import {
8+
buildDatasetOption,
9+
buildSeriesOption,
10+
} from '../Utils/chartOptionBuilder';
11+
import type { EChartsDataset } from '../Utils/chartTypes';
12+
import type { PxTable } from '../../../shared-types/pxTable';
13+
14+
vi.mock('../Utils/chartDataMapper', () => ({
15+
mapPxTableToChartDataset: vi.fn(),
16+
}));
17+
18+
vi.mock('../Utils/useEChartOption', () => ({
19+
useEChartOption: vi.fn(),
20+
}));
21+
22+
vi.mock('../Utils/chartOptionBuilder', async () => {
23+
const actual = await vi.importActual<
24+
typeof import('../Utils/chartOptionBuilder')
25+
>('../Utils/chartOptionBuilder');
26+
27+
return {
28+
...actual,
29+
buildDatasetOption: vi.fn(),
30+
buildSeriesOption: vi.fn(),
31+
};
32+
});
33+
34+
const mockDataset: EChartsDataset = {
35+
title: 'Population by year',
36+
origin: 'Statistics Demo',
37+
unit: 'persons',
38+
dimensions: ['name', 'men', 'women'],
39+
source: [{ name: '2024', men: 10, women: 12 }],
40+
series: [
41+
{ key: 'men', name: 'Men' },
42+
{ key: 'women', name: 'Women' },
43+
{ key: 'total', name: 'Total' },
44+
],
45+
};
46+
47+
function getTooltipFormatter(option: { tooltip?: unknown }) {
48+
const tooltip = Array.isArray(option.tooltip)
49+
? option.tooltip[0]
50+
: option.tooltip;
51+
52+
if (!tooltip || typeof tooltip !== 'object' || !('formatter' in tooltip)) {
53+
return undefined;
54+
}
55+
56+
const formatter = tooltip.formatter;
57+
return typeof formatter === 'function'
58+
? (formatter as (params: unknown) => string)
59+
: undefined;
60+
}
61+
62+
describe('LineChart', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks();
65+
66+
vi.mocked(mapPxTableToChartDataset).mockReturnValue(mockDataset);
67+
vi.mocked(buildDatasetOption).mockReturnValue({
68+
dataset: {
69+
dimensions: mockDataset.dimensions,
70+
source: mockDataset.source,
71+
},
72+
legend: {},
73+
tooltip: {},
74+
});
75+
vi.mocked(buildSeriesOption).mockReturnValue([
76+
{ name: 'Men', type: 'line', symbol: 'circle', symbolSize: 8 },
77+
{ name: 'Women', type: 'line', symbol: 'rect', symbolSize: 8 },
78+
{ name: 'Total', type: 'line', symbol: 'triangle', symbolSize: 8 },
79+
]);
80+
vi.mocked(useEChartOption).mockReturnValue({
81+
divRef: { current: null },
82+
chartRef: { current: null },
83+
});
84+
});
85+
86+
it('builds chart option with mapped dataset and provided colors', () => {
87+
const colors = ['#111111', '#222222'];
88+
89+
render(<LineChart pxtable={{} as PxTable} colors={colors} />);
90+
91+
expect(mapPxTableToChartDataset).toHaveBeenCalledWith({});
92+
expect(buildDatasetOption).toHaveBeenCalledWith(mockDataset);
93+
expect(buildSeriesOption).toHaveBeenCalledWith(mockDataset, 'line', colors);
94+
95+
const option = vi.mocked(useEChartOption).mock.calls[0][0];
96+
97+
expect(option.legend).toEqual({
98+
height: 40 * mockDataset.series.length,
99+
});
100+
expect(option.yAxis).toMatchObject({
101+
name: 'persons',
102+
});
103+
expect(option.grid).toEqual({
104+
top: 0,
105+
bottom: 200,
106+
left: '0',
107+
right: '0',
108+
containLabel: false,
109+
});
110+
});
111+
112+
it('renders chart container with height based on number of series', () => {
113+
const { container } = render(<LineChart pxtable={{} as PxTable} />);
114+
115+
const chartDiv = Array.from(container.querySelectorAll('div')).find(
116+
(element) => element.style.height,
117+
);
118+
119+
expect(chartDiv).toBeTruthy();
120+
expect(chartDiv?.style.height).toBe('630px');
121+
});
122+
123+
it('returns empty tooltip text for empty params', () => {
124+
render(<LineChart pxtable={{} as PxTable} />);
125+
126+
const option = vi.mocked(useEChartOption).mock.calls[0][0];
127+
const formatter = getTooltipFormatter(option);
128+
129+
expect(formatter).toBeTypeOf('function');
130+
expect(formatter?.([])).toBe('');
131+
});
132+
133+
it('formats tooltip rows with symbol svg, labels, values and fallback color', () => {
134+
render(<LineChart pxtable={{} as PxTable} />);
135+
136+
const option = vi.mocked(useEChartOption).mock.calls[0][0];
137+
const formatter = getTooltipFormatter(option);
138+
139+
const html = formatter?.([
140+
{
141+
axisValueLabel: '2024',
142+
seriesIndex: 0,
143+
seriesName: 'Men',
144+
data: { men: 10, women: 12 },
145+
},
146+
{
147+
axisValueLabel: '2024',
148+
seriesIndex: 1,
149+
seriesName: 'Women',
150+
color: '#ff0000',
151+
data: { men: 10, women: 12 },
152+
},
153+
]);
154+
155+
expect(html).toContain('<div><div>2024</div>');
156+
expect(html).toContain('Men: 10');
157+
expect(html).toContain('Women: 12');
158+
expect(html).toContain('fill="#666666"');
159+
expect(html).toContain('fill="#ff0000"');
160+
expect(html).toContain('<circle');
161+
expect(html).toContain('<rect');
162+
});
163+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { useMemo } from 'react';
2+
import type * as echarts from 'echarts';
3+
4+
import {
5+
buildDatasetOption,
6+
buildSeriesOption,
7+
LINE_SERIES_SYMBOLS,
8+
} from '../Utils/chartOptionBuilder';
9+
import { useEChartOption } from '../Utils/useEChartOption';
10+
import type { PxTable } from '../../../shared-types/pxTable';
11+
import { mapPxTableToChartDataset } from '../Utils/chartDataMapper';
12+
13+
interface LineChartProps {
14+
readonly pxtable: PxTable;
15+
readonly colors?: string[];
16+
}
17+
18+
type TooltipParam = {
19+
axisValueLabel?: string;
20+
seriesIndex: number;
21+
seriesName: string;
22+
data?: Record<string, string | number>;
23+
color?: string;
24+
};
25+
26+
function getTooltipSymbolSvg(symbol: string, color: string): string {
27+
switch (symbol) {
28+
case 'rect':
29+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect x="1" y="1" width="8" height="8" fill="${color}" /></svg>`;
30+
case 'triangle':
31+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><polygon points="5,1 9,9 1,9" fill="${color}" /></svg>`;
32+
case 'diamond':
33+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><polygon points="5,1 9,5 5,9 1,5" fill="${color}" /></svg>`;
34+
case 'pin':
35+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M5 1a2.2 2.2 0 0 0-2.2 2.2c0 1.8 2.2 5.6 2.2 5.6s2.2-3.8 2.2-5.6A2.2 2.2 0 0 0 5 1z" fill="${color}" /></svg>`;
36+
case 'arrow':
37+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M1 5h5V3l3 2-3 2V5H1z" fill="${color}" /></svg>`;
38+
default:
39+
return `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="4" fill="${color}" /></svg>`;
40+
}
41+
}
42+
43+
export function LineChart({ pxtable, colors }: LineChartProps) {
44+
const dataset = useMemo(() => mapPxTableToChartDataset(pxtable), [pxtable]);
45+
const option = useMemo<echarts.EChartsOption>(
46+
() => ({
47+
...buildDatasetOption(dataset),
48+
grid: { top: 0, bottom: 200, left: '0', right: '0', containLabel: false },
49+
xAxis: { type: 'category' as const, axisLabel: { rotate: 45 } },
50+
yAxis: {
51+
name: dataset.unit,
52+
min: (value) => value.min,
53+
},
54+
legend: {
55+
height: 40 * dataset.series.length, // increase legend height based on number of series to prevent overlap with x-axis labels
56+
},
57+
series: buildSeriesOption(dataset, 'line', colors),
58+
tooltip: {
59+
trigger: 'axis',
60+
formatter: (params: unknown) => {
61+
const axisParams = (Array.isArray(params) ? params : [params]) as
62+
| TooltipParam[]
63+
| undefined;
64+
65+
if (!axisParams || axisParams.length === 0) {
66+
return '';
67+
}
68+
69+
const title = axisParams[0].axisValueLabel;
70+
const rows = axisParams
71+
.map((param) => {
72+
const seriesMeta = dataset.series[param.seriesIndex];
73+
const row = param.data as Record<string, string | number>;
74+
const value = row?.[seriesMeta.key];
75+
const symbol =
76+
LINE_SERIES_SYMBOLS[
77+
param.seriesIndex % LINE_SERIES_SYMBOLS.length
78+
];
79+
const color = param.color ?? '#666666';
80+
81+
return `<div style="display:flex;align-items:center;gap:6px"><span style="display:inline-flex;align-items:center">${getTooltipSymbolSvg(symbol, color)}</span><span>${param.seriesName}: ${value ?? ''}</span></div>`;
82+
})
83+
.join('');
84+
85+
return `<div><div>${title}</div>${rows}</div>`;
86+
},
87+
},
88+
}),
89+
[dataset, colors],
90+
);
91+
92+
const { divRef } = useEChartOption(option);
93+
const height = 600 + dataset.series.length * 10; // increase chart height based on number of series to prevent legend overlap
94+
95+
return (
96+
<div>
97+
<div ref={divRef} style={{ width: '100%', height: `${height}px` }}></div>
98+
</div>
99+
);
100+
}
101+
export default LineChart;

0 commit comments

Comments
 (0)