Skip to content
Draft
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
32 changes: 32 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/pxweb2-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@vitejs/plugin-react": "^5.2.0",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"motion": "^12.38.0",
"vite-plugin-dts": "^4.5.4"
Expand Down
1 change: 1 addition & 0 deletions packages/pxweb2-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './lib/components/ActionItem/ActionItem';
export * from './lib/components/BottomSheet/BottomSheet';
export * from './lib/components/Breadcrumbs/Breadcrumbs';
export * from './lib/components/Button/Button';
export * from './lib/components/Chart/Chart';
export * from './lib/components/Checkbox/Checkbox';
export * from './lib/components/CheckCircle/CheckCircleIcon';
export * from './lib/components/CheckCircle/CheckCircleToggle';
Expand Down
66 changes: 66 additions & 0 deletions packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import BarChart from './Charts/BarChart';
import LineChart from './Charts/LineChart';
import { PopulationPyramid } from './Charts/PopulationPyramid';
import { useMemo } from 'react';
import LocalAlert from '../LocalAlert/LocalAlert';
import {
mapChartConfigToEChartsDataset,
mapPxTableToChart,
mapPxTableToPopulationPyramid,
} from './chartDataMapper';

import type { PxTable } from '../../shared-types/pxTable';

interface ChartProps {
readonly pxtable: PxTable;
}
export function Chart({ pxtable }: ChartProps) {
const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]);
const dataset = useMemo(
() => mapChartConfigToEChartsDataset(chartConfig),
[chartConfig],
);
const populationPyramidResult = useMemo(
() => mapPxTableToPopulationPyramid(pxtable),
[pxtable],
);

const pyramidWarningText = useMemo(() => {
switch (populationPyramidResult.validation.reason) {
case 'MISSING_TWO_VALUE_DIMENSION':
return 'Population pyramid requires exactly one dimension with two selected values.';
case 'MULTIPLE_TWO_VALUE_DIMENSIONS':
return 'Population pyramid supports only one two-value dimension.';
case 'MISSING_MULTI_VALUE_DIMENSION':
return 'Population pyramid requires one dimension with several selected values.';
case 'MULTIPLE_MULTI_VALUE_DIMENSIONS':
return 'Population pyramid supports only one multi-value dimension.';
case 'NON_SINGLE_VALUE_REMAINING_DIMENSIONS':
return 'All remaining dimensions must have exactly one selected value for population pyramid.';
default:
return '';
}
}, [populationPyramidResult.validation.reason]);

return (
<>
<BarChart dataset={dataset} isHorizontal={true}></BarChart>
<BarChart dataset={dataset}></BarChart>
<LineChart dataset={dataset}></LineChart>
{populationPyramidResult.config ? (
<PopulationPyramid
dataset={populationPyramidResult.config}
></PopulationPyramid>
) : (
<LocalAlert
variant="warning"
size="small"
heading="Population pyramid unavailable"
>
{pyramidWarningText}
</LocalAlert>
)}
</>
);
}
export default Chart;
38 changes: 38 additions & 0 deletions packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import type * as echarts from 'echarts';

import { buildDatasetOption, buildSeriesOption } from '../chartOptionBuilder';
import type { EChartsDataset } from '../chartTypes';
import ChartExportButtons from './ChartExportButtons';
import { useEChartOption } from './useEChartOption';

interface BarChartProps {
readonly dataset: EChartsDataset;
readonly isHorizontal?: boolean;
}
export function BarChart({ dataset, isHorizontal = false }: BarChartProps) {
const option = useMemo<echarts.EChartsOption>(() => {
const xAxisType = isHorizontal ? ({ type: 'category' } as const) : {};
const yAxisType = isHorizontal ? {} : ({ type: 'category' } as const);

return {
...buildDatasetOption(dataset),
xAxis: xAxisType,
yAxis: yAxisType,
series: buildSeriesOption(dataset, 'bar'),
};
}, [dataset, isHorizontal]);

const { divRef, chartRef } = useEChartOption(option);

return (
<div>
<ChartExportButtons
chartRef={chartRef}
fileName={isHorizontal ? 'bar-chart-horizontal' : 'bar-chart-vertical'}
/>
<div ref={divRef} style={{ width: '600px', height: '400px' }}></div>
</div>
);
}
export default BarChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import type * as echarts from 'echarts';

import { Button } from '../../Button/Button';

interface ChartExportButtonsProps {
readonly chartRef: React.RefObject<echarts.EChartsType | null>;
readonly fileName: string;
}

export function ChartExportButtons({
chartRef,
fileName,
}: ChartExportButtonsProps) {
const triggerDownload = useCallback((href: string, downloadName: string) => {
const link = document.createElement('a');
link.href = href;
link.download = downloadName;
document.body.appendChild(link);
link.click();
link.remove();
}, []);

const exportPngFromSvgDataUrl = useCallback(
(svgDataUrl: string, outputName: string, pixelRatio = 2) => {
const chart = chartRef.current;
if (!chart) {
return;
}

const image = new Image();

image.onload = () => {
const width = Math.max(1, Math.round(chart.getWidth() * pixelRatio));
const height = Math.max(1, Math.round(chart.getHeight() * pixelRatio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

const context = canvas.getContext('2d');
if (!context) {
return;
}

context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.drawImage(image, 0, 0, chart.getWidth(), chart.getHeight());

canvas.toBlob((blob) => {
if (!blob) {
return;
}

const pngBlobUrl = URL.createObjectURL(blob);
triggerDownload(pngBlobUrl, outputName);
URL.revokeObjectURL(pngBlobUrl);
}, 'image/png');
};

image.src = svgDataUrl;
},
[chartRef, triggerDownload],
);

const downloadImage = useCallback(
(type: 'png' | 'svg') => {
const chart = chartRef.current;
if (!chart) {
return;
}

const dataUrl = chart.getDataURL({
type,
pixelRatio: type === 'png' ? 2 : 1,
});

if (type === 'png' && !dataUrl.startsWith('data:image/png')) {
const svgDataUrl = chart.getDataURL({ type: 'svg' });
exportPngFromSvgDataUrl(svgDataUrl, `${fileName}.png`);
return;
}

triggerDownload(dataUrl, `${fileName}.${type}`);
},
[chartRef, exportPngFromSvgDataUrl, fileName, triggerDownload],
);

return (
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
marginBottom: '0.5rem',
}}
>
<Button
type="button"
variant="tertiary"
size="small"
onClick={() => downloadImage('png')}
>
Download PNG
</Button>
<Button
type="button"
variant="tertiary"
size="small"
onClick={() => downloadImage('svg')}
>
Download SVG
</Button>
</div>
);
}

export default ChartExportButtons;
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import type * as echarts from 'echarts';

import { buildDatasetOption, buildSeriesOption } from '../chartOptionBuilder';
import type { EChartsDataset } from '../chartTypes';
import ChartExportButtons from './ChartExportButtons';
import { useEChartOption } from './useEChartOption';

interface LineChartProps {
readonly dataset: EChartsDataset;
}
export function LineChart({ dataset }: LineChartProps) {
const option = useMemo<echarts.EChartsOption>(
() => ({
...buildDatasetOption(dataset),
xAxis: { type: 'category' as const },
yAxis: {},
series: buildSeriesOption(dataset, 'line'),
}),
[dataset],
);

const { divRef, chartRef } = useEChartOption(option);

return (
<div>
<ChartExportButtons chartRef={chartRef} fileName="line-chart" />
<div ref={divRef} style={{ width: '600px', height: '400px' }}></div>
</div>
);
}
export default LineChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo } from 'react';

import type * as echarts from 'echarts';

import type { PopulationPyramidConfig } from '../chartTypes';
import ChartExportButtons from './ChartExportButtons';
import { useEChartOption } from './useEChartOption';

interface PopulationPyramidProps {
readonly dataset: PopulationPyramidConfig;
}

export function PopulationPyramid({ dataset }: PopulationPyramidProps) {
const option = useMemo<echarts.EChartsOption>(() => {
const categories = dataset.data.map((point) => point.name);
const leftData = dataset.data.map((point) =>
point.left === null ? null : -Math.abs(point.left),
);
const rightData = dataset.data.map((point) =>
point.right === null ? null : Math.abs(point.right),
);

return {
title: {
text: 'Population pyramid',
},
legend: {
data: [dataset.leftSeriesName, dataset.rightSeriesName],
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
valueFormatter: (value) => `${Math.abs(Number(value ?? 0))}`,
},
xAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => `${Math.abs(value)}`,
},
},
yAxis: {
type: 'category',
data: categories,
},
series: [
{
name: dataset.leftSeriesName,
type: 'bar',
data: leftData,
stack: 'quantity',
},
{
name: dataset.rightSeriesName,
type: 'bar',
data: rightData,
stack: 'quantity',
},
],
};
}, [dataset]);

const { divRef, chartRef } = useEChartOption(option);

return (
<div>
<ChartExportButtons chartRef={chartRef} fileName="population-pyramid" />
<div ref={divRef} style={{ width: '600px', height: '400px' }}></div>
</div>
);
}
Loading
Loading