From cbe7faffe68db579b3a6914b157ab8bdb93b64bb Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 10:33:56 +0200 Subject: [PATCH 01/12] Added echart add barchart --- package-lock.json | 32 +++++++++++++++ packages/pxweb2-ui/package.json | 1 + packages/pxweb2-ui/src/index.ts | 1 + .../src/lib/components/Chart/Chart.tsx | 24 +++++++++++ .../lib/components/Chart/Charts/BarChart.tsx | 40 +++++++++++++++++++ .../components/Presentation/Presentation.tsx | 11 ++++- 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx diff --git a/package-lock.json b/package-lock.json index aa60f3b5a..4f3fab606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6120,6 +6120,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { "version": "1.5.321", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", @@ -13684,6 +13700,21 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -13729,6 +13760,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" diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index 9a9b6f147..aea41c90f 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -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" diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index 3c8afc3f1..2275e039b 100644 --- a/packages/pxweb2-ui/src/index.ts +++ b/packages/pxweb2-ui/src/index.ts @@ -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'; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx new file mode 100644 index 000000000..8e02467b1 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -0,0 +1,24 @@ +import BarChart from './Charts/BarChart'; + +export function Chart() { + // const data = { + // title: { + // text: 'ECharts Getting Started Example', + // }, + // tooltip: {}, + // xAxis: { + // data: ['shirt', 'cardigan', 'chiffon', 'pants', 'heels', 'socks'], + // }, + // yAxis: {}, + // series: [ + // { + // name: 'sales', + // type: 'bar', + // data: [5, 20, 36, 10, 10, 20], + // }, + // ], + // }; + + return ; +} +export default Chart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx new file mode 100644 index 000000000..4834d6bf5 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -0,0 +1,40 @@ +import { useRef, useEffect } from 'react'; +import * as echarts from 'echarts'; + +export function BarChart() { + const divRef = useRef(null); + + useEffect(() => { + if (!divRef.current) { + return; + } + // Create the echarts instance + const myChart = echarts.init(divRef.current as HTMLElement); + // Draw the chart + myChart.setOption({ + title: { + text: 'ECharts Getting Started Example', + }, + tooltip: {}, + xAxis: { + data: ['shirt', 'cardigan', 'chiffon', 'pants', 'heels', 'socks'], + }, + yAxis: {}, + series: [ + { + name: 'sales', + type: 'bar', + data: [5, 20, 36, 10, 10, 20], + }, + ], + }); + }, [divRef]); + return ( +
+ ); +} +export default BarChart; diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 3f48d6014..1e7de0509 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -6,7 +6,13 @@ import isEqual from 'lodash/isEqual'; import classes from './Presentation.module.scss'; import useApp from '../../context/useApp'; import { ContentTop } from '../ContentTop/ContentTop'; -import { Table, EmptyState, PxTable, LocalAlert } from '@pxweb2/pxweb2-ui'; +import { + Table, + EmptyState, + PxTable, + LocalAlert, + Chart, +} from '@pxweb2/pxweb2-ui'; import useTableData from '../../context/useTableData'; import useVariables from '../../context/useVariables'; import { useDebounce } from '@uidotdev/usehooks'; @@ -269,6 +275,9 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > +
+ +
From 83d7f9398acbc8f045ba6e3a8fdb8997262afdab Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 11:04:48 +0200 Subject: [PATCH 02/12] Send dataset to BarChart --- .../src/lib/components/Chart/Chart.tsx | 29 +++++++------------ .../lib/components/Chart/Charts/BarChart.tsx | 28 ++++++++++-------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 8e02467b1..f2345b644 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,24 +1,17 @@ import BarChart from './Charts/BarChart'; export function Chart() { - // const data = { - // title: { - // text: 'ECharts Getting Started Example', - // }, - // tooltip: {}, - // xAxis: { - // data: ['shirt', 'cardigan', 'chiffon', 'pants', 'heels', 'socks'], - // }, - // yAxis: {}, - // series: [ - // { - // name: 'sales', - // type: 'bar', - // data: [5, 20, 36, 10, 10, 20], - // }, - // ], - // }; + const dataset = { + // Provide a set of data. + source: [ + ['product', '2015', '2016', '2017'], + ['Matcha Latte', 43.3, 85.8, 93.7], + ['Milk Tea', 83.1, 73.4, 55.1], + ['Cheese Cocoa', 86.4, 65.2, 82.5], + ['Walnut Brownie', 72.4, 53.9, 39.1], + ], + }; - return ; + return ; } export default Chart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index 4834d6bf5..75f66da6c 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -1,7 +1,10 @@ import { useRef, useEffect } from 'react'; import * as echarts from 'echarts'; -export function BarChart() { +interface BarChartProps { + readonly dataset: any; +} +export function BarChart({ dataset }: BarChartProps) { const divRef = useRef(null); useEffect(() => { @@ -9,24 +12,25 @@ export function BarChart() { return; } // Create the echarts instance - const myChart = echarts.init(divRef.current as HTMLElement); + const myChart = echarts.init(divRef.current as HTMLElement, null, { + renderer: 'svg', + }); // Draw the chart myChart.setOption({ title: { text: 'ECharts Getting Started Example', }, + dataset: dataset, + legend: {}, tooltip: {}, - xAxis: { - data: ['shirt', 'cardigan', 'chiffon', 'pants', 'heels', 'socks'], - }, + // Declare an x-axis (category axis). + // The category map the first column in the dataset by default. + xAxis: { type: 'category' }, + // Declare a y-axis (value axis). yAxis: {}, - series: [ - { - name: 'sales', - type: 'bar', - data: [5, 20, 36, 10, 10, 20], - }, - ], + // Declare several 'bar' series, + // every series will auto-map to each column by default. + series: [{ type: 'bar' }, { type: 'bar' }, { type: 'bar' }], }); }, [divRef]); return ( From bf09ac0283da3134193d6570b991a3f6d860d61a Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 12:34:50 +0200 Subject: [PATCH 03/12] Added horizontal bar chart --- packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx | 7 ++++++- .../src/lib/components/Chart/Charts/BarChart.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index f2345b644..0fb9b1cdb 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -12,6 +12,11 @@ export function Chart() { ], }; - return ; + return ( + <> + ; + ; + + ); } export default Chart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index 75f66da6c..842203b6a 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -3,9 +3,12 @@ import * as echarts from 'echarts'; interface BarChartProps { readonly dataset: any; + readonly isHorizontal?: boolean; } -export function BarChart({ dataset }: BarChartProps) { +export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { const divRef = useRef(null); + const xAxisType = isHorizontal ? { type: 'category' } : {}; + const yAxisType = isHorizontal ? {} : { type: 'category' }; useEffect(() => { if (!divRef.current) { @@ -25,9 +28,9 @@ export function BarChart({ dataset }: BarChartProps) { tooltip: {}, // Declare an x-axis (category axis). // The category map the first column in the dataset by default. - xAxis: { type: 'category' }, + xAxis: xAxisType, // Declare a y-axis (value axis). - yAxis: {}, + yAxis: yAxisType, // Declare several 'bar' series, // every series will auto-map to each column by default. series: [{ type: 'bar' }, { type: 'bar' }, { type: 'bar' }], From 4f5e7259680fcd99ee39b45b9701ce7a83b3642f Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 12:54:45 +0200 Subject: [PATCH 04/12] Added LineChart Fix dependencies --- .../src/lib/components/Chart/Chart.tsx | 2 + .../lib/components/Chart/Charts/BarChart.tsx | 14 ++----- .../lib/components/Chart/Charts/LineChart.tsx | 38 +++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 0fb9b1cdb..f08b179ad 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,4 +1,5 @@ import BarChart from './Charts/BarChart'; +import LineChart from './Charts/LineChart'; export function Chart() { const dataset = { @@ -16,6 +17,7 @@ export function Chart() { <> ; ; + ; ); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index 842203b6a..e13cfb36f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -7,13 +7,13 @@ interface BarChartProps { } export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { const divRef = useRef(null); - const xAxisType = isHorizontal ? { type: 'category' } : {}; - const yAxisType = isHorizontal ? {} : { type: 'category' }; useEffect(() => { if (!divRef.current) { return; } + const xAxisType = isHorizontal ? { type: 'category' } : {}; + const yAxisType = isHorizontal ? {} : { type: 'category' }; // Create the echarts instance const myChart = echarts.init(divRef.current as HTMLElement, null, { renderer: 'svg', @@ -35,13 +35,7 @@ export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { // every series will auto-map to each column by default. series: [{ type: 'bar' }, { type: 'bar' }, { type: 'bar' }], }); - }, [divRef]); - return ( -
- ); + }, [divRef, dataset, isHorizontal]); + return
; } export default BarChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx new file mode 100644 index 000000000..79ce6e21e --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -0,0 +1,38 @@ +import { useRef, useEffect } from 'react'; +import * as echarts from 'echarts'; + +interface LineChartProps { + readonly dataset: any; +} +export function LineChart({ dataset }: LineChartProps) { + const divRef = useRef(null); + + useEffect(() => { + if (!divRef.current) { + return; + } + // Create the echarts instance + const myChart = echarts.init(divRef.current as HTMLElement, null, { + renderer: 'svg', + }); + // Draw the chart + myChart.setOption({ + title: { + text: 'ECharts Getting Started Example', + }, + dataset: dataset, + legend: {}, + tooltip: {}, + // Declare an x-axis (category axis). + // The category map the first column in the dataset by default. + xAxis: { type: 'category' }, + // Declare a y-axis (value axis). + yAxis: {}, + // Declare several 'bar' series, + // every series will auto-map to each column by default. + series: [{ type: 'line' }, { type: 'line' }, { type: 'line' }], + }); + }, [divRef, dataset]); + return
; +} +export default LineChart; From c8cf131dd2ddb2e16a84fc5f66cade2bbff121db Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 13:12:15 +0200 Subject: [PATCH 05/12] Use pxtable as dataset --- .../lib/components/Chart/chartDataMapper.ts | 106 ++++++++++++++++++ .../src/lib/components/Chart/chartTypes.ts | 43 +++++++ .../components/Presentation/Presentation.tsx | 2 +- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts new file mode 100644 index 000000000..8e39d6c53 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -0,0 +1,106 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; + +import type { ChartConfig, ChartDataPoint, ChartSeries } from './chartTypes'; + +interface CombinationItem { + readonly variableId: string; + readonly code: string; + readonly label: string; +} + +interface Combination { + readonly items: CombinationItem[]; +} + +function buildCombinations( + variables: PxTable['metadata']['variables'], +): Combination[] { + if (variables.length === 0) { + return [{ items: [] }]; + } + + return variables.reduce( + (acc, variable) => { + const next: Combination[] = []; + const values = variable.values.length > 0 ? variable.values : []; + + for (const combo of acc) { + for (const value of values) { + next.push({ + items: [ + ...combo.items, + { + variableId: variable.id, + code: value.code, + label: value.label, + }, + ], + }); + } + } + + return next; + }, + [{ items: [] }], + ); +} + +function toCodeMap(items: CombinationItem[]): Record { + return Object.fromEntries(items.map((item) => [item.variableId, item.code])); +} + +function getLabel(items: CombinationItem[], fallback: string): string { + if (items.length === 0) { + return fallback; + } + + return items.map((item) => item.label).join(' / '); +} + +export function mapPxTableToChart(pxtable: PxTable): ChartConfig { + const rowCombinations = buildCombinations(pxtable.stub); + const seriesCombinations = buildCombinations(pxtable.heading); + + const series: ChartSeries[] = seriesCombinations.map( + (combination, index) => ({ + key: + combination.items.map((item) => item.code).join('|') || + `series-${index}`, + name: getLabel(combination.items, 'Value'), + }), + ); + + const data = rowCombinations.map((rowCombination) => { + const rowMap = toCodeMap(rowCombination.items); + const point: Record = { + name: getLabel(rowCombination.items, 'Value'), + }; + + seriesCombinations.forEach((seriesCombination, seriesIndex) => { + const seriesKey = series[seriesIndex].key; + const allCodes = { + ...rowMap, + ...toCodeMap(seriesCombination.items), + }; + + const dimensions = pxtable.data.variableOrder.map( + (variableId) => allCodes[variableId], + ); + + if (dimensions.some((dimension) => !dimension)) { + point[seriesKey] = null; + return; + } + + const dataCell = getPxTableData(pxtable.data.cube, dimensions); + + point[seriesKey] = dataCell?.value ?? null; + }); + + return point as ChartDataPoint; + }); + + return { data, series }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts new file mode 100644 index 000000000..da31715f8 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts @@ -0,0 +1,43 @@ +export interface ChartDataPoint { + readonly name: string; + readonly [seriesKey: string]: number | string | null; +} + +export interface ChartSeries { + readonly key: string; + readonly name: string; +} + +export interface ChartConfig { + readonly data: ChartDataPoint[]; + readonly series: ChartSeries[]; +} + +export interface PopulationPyramidDataPoint { + readonly name: string; + readonly left: number | null; + readonly right: number | null; +} + +export interface PopulationPyramidConfig { + readonly data: PopulationPyramidDataPoint[]; + readonly leftSeriesName: string; + readonly rightSeriesName: string; +} + +export interface PopulationPyramidValidationResult { + readonly isValid: boolean; + readonly reason?: + | 'MISSING_TWO_VALUE_DIMENSION' + | 'MULTIPLE_TWO_VALUE_DIMENSIONS' + | 'MISSING_MULTI_VALUE_DIMENSION' + | 'MULTIPLE_MULTI_VALUE_DIMENSIONS' + | 'NON_SINGLE_VALUE_REMAINING_DIMENSIONS'; + readonly twoValueDimensionId?: string; + readonly multiValueDimensionId?: string; +} + +export interface PopulationPyramidMappingResult { + readonly validation: PopulationPyramidValidationResult; + readonly config?: PopulationPyramidConfig; +} diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 1e7de0509..f36596315 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -276,7 +276,7 @@ export function Presentation({ ref={gradientContainerRef} >
- +
From 75bcc0e63eed4f2309f2d08c46c14e65c3325fa5 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 13:44:05 +0200 Subject: [PATCH 06/12] Use real data from pxtable --- .../src/lib/components/Chart/Chart.tsx | 25 ++++++----- .../lib/components/Chart/Charts/BarChart.tsx | 20 ++++++--- .../lib/components/Chart/Charts/LineChart.tsx | 20 ++++++--- .../components/Chart/chartDataMapper.spec.ts | 45 +++++++++++++++++++ .../lib/components/Chart/chartDataMapper.ts | 36 ++++++++++++++- .../src/lib/components/Chart/chartTypes.ts | 6 +++ 6 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index f08b179ad..542e647a8 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,17 +1,20 @@ import BarChart from './Charts/BarChart'; import LineChart from './Charts/LineChart'; +import { useMemo } from 'react'; +import { mapChartConfigToEChartsDataset, mapPxTableToChart } from './chartDataMapper'; -export function Chart() { - const dataset = { - // Provide a set of data. - source: [ - ['product', '2015', '2016', '2017'], - ['Matcha Latte', 43.3, 85.8, 93.7], - ['Milk Tea', 83.1, 73.4, 55.1], - ['Cheese Cocoa', 86.4, 65.2, 82.5], - ['Walnut Brownie', 72.4, 53.9, 39.1], - ], - }; +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], + ); return ( <> diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index e13cfb36f..b1315e197 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -1,8 +1,10 @@ import { useRef, useEffect } from 'react'; import * as echarts from 'echarts'; +import type { EChartsDataset } from '../chartTypes'; + interface BarChartProps { - readonly dataset: any; + readonly dataset: EChartsDataset; readonly isHorizontal?: boolean; } export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { @@ -23,7 +25,10 @@ export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { title: { text: 'ECharts Getting Started Example', }, - dataset: dataset, + dataset: { + dimensions: dataset.dimensions, + source: dataset.source, + }, legend: {}, tooltip: {}, // Declare an x-axis (category axis). @@ -31,10 +36,15 @@ export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { xAxis: xAxisType, // Declare a y-axis (value axis). yAxis: yAxisType, - // Declare several 'bar' series, - // every series will auto-map to each column by default. - series: [{ type: 'bar' }, { type: 'bar' }, { type: 'bar' }], + series: dataset.series.map((series) => ({ + name: series.name, + type: 'bar', + })), }); + + return () => { + myChart.dispose(); + }; }, [divRef, dataset, isHorizontal]); return
; } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx index 79ce6e21e..f6ea6e785 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -1,8 +1,10 @@ import { useRef, useEffect } from 'react'; import * as echarts from 'echarts'; +import type { EChartsDataset } from '../chartTypes'; + interface LineChartProps { - readonly dataset: any; + readonly dataset: EChartsDataset; } export function LineChart({ dataset }: LineChartProps) { const divRef = useRef(null); @@ -20,7 +22,10 @@ export function LineChart({ dataset }: LineChartProps) { title: { text: 'ECharts Getting Started Example', }, - dataset: dataset, + dataset: { + dimensions: dataset.dimensions, + source: dataset.source, + }, legend: {}, tooltip: {}, // Declare an x-axis (category axis). @@ -28,10 +33,15 @@ export function LineChart({ dataset }: LineChartProps) { xAxis: { type: 'category' }, // Declare a y-axis (value axis). yAxis: {}, - // Declare several 'bar' series, - // every series will auto-map to each column by default. - series: [{ type: 'line' }, { type: 'line' }, { type: 'line' }], + series: dataset.series.map((series) => ({ + name: series.name, + type: 'line', + })), }); + + return () => { + myChart.dispose(); + }; }, [divRef, dataset]); return
; } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts new file mode 100644 index 000000000..6d106faba --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { mapChartConfigToEChartsDataset } from './chartDataMapper'; + +import type { ChartConfig } from './chartTypes'; + +describe('mapChartConfigToEChartsDataset', () => { + it('maps dimensions and values using chart series order', () => { + const chartConfig: ChartConfig = { + series: [ + { key: 'year-2024', name: '2024' }, + { key: 'year-2025', name: '2025' }, + ], + data: [ + { name: 'Product A', 'year-2024': 10, 'year-2025': 20 }, + { name: 'Product B', 'year-2024': 30, 'year-2025': 40 }, + ], + }; + + const dataset = mapChartConfigToEChartsDataset(chartConfig); + + expect(dataset.dimensions).toEqual(['name', 'year-2024', 'year-2025']); + expect(dataset.series).toEqual(chartConfig.series); + expect(dataset.source).toEqual([ + { name: 'Product A', 'year-2024': 10, 'year-2025': 20 }, + { name: 'Product B', 'year-2024': 30, 'year-2025': 40 }, + ]); + }); + + it('preserves null values and normalizes missing/non-numeric series values to null', () => { + const chartConfig: ChartConfig = { + series: [ + { key: 'first', name: 'First' }, + { key: 'second', name: 'Second' }, + ], + data: [{ name: 'Category 1', first: null }], + }; + + const dataset = mapChartConfigToEChartsDataset(chartConfig); + + expect(dataset.source).toEqual([ + { name: 'Category 1', first: null, second: null }, + ]); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts index 8e39d6c53..b3770a610 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -2,7 +2,12 @@ import { getPxTableData } from '../Table/cubeHelper'; import type { PxTable } from '../../shared-types/pxTable'; import type { DataCell } from '../../shared-types/pxTableData'; -import type { ChartConfig, ChartDataPoint, ChartSeries } from './chartTypes'; +import type { + ChartConfig, + ChartDataPoint, + ChartSeries, + EChartsDataset, +} from './chartTypes'; interface CombinationItem { readonly variableId: string; @@ -104,3 +109,32 @@ export function mapPxTableToChart(pxtable: PxTable): ChartConfig { return { data, series }; } + +export function mapChartConfigToEChartsDataset( + chartConfig: ChartConfig, +): EChartsDataset { + const dimensions = [ + 'name', + ...chartConfig.series.map((series) => series.key), + ]; + + const source = chartConfig.data.map((point) => { + const row: Record = { + name: point.name, + }; + + for (const series of chartConfig.series) { + const value = point[series.key]; + row[series.key] = + typeof value === 'number' || value === null ? value : null; + } + + return row; + }); + + return { + dimensions, + source, + series: chartConfig.series, + }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts index da31715f8..aef06726b 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts @@ -13,6 +13,12 @@ export interface ChartConfig { readonly series: ChartSeries[]; } +export interface EChartsDataset { + readonly dimensions: string[]; + readonly source: Array>; + readonly series: ChartSeries[]; +} + export interface PopulationPyramidDataPoint { readonly name: string; readonly left: number | null; From af5c36811aedbd1f1eb61159c7ac1d2a0511cea3 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 13:47:12 +0200 Subject: [PATCH 07/12] formatting --- packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 542e647a8..b2dcce533 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,11 +1,13 @@ import BarChart from './Charts/BarChart'; import LineChart from './Charts/LineChart'; import { useMemo } from 'react'; -import { mapChartConfigToEChartsDataset, mapPxTableToChart } from './chartDataMapper'; +import { + mapChartConfigToEChartsDataset, + mapPxTableToChart, +} from './chartDataMapper'; import type { PxTable } from '../../shared-types/pxTable'; - interface ChartProps { readonly pxtable: PxTable; } From 9e6d491ec964a21d7ff47bec7cc6ae47bedf455a Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 14:23:35 +0200 Subject: [PATCH 08/12] Add Population Pyramid chart and validation logic - Implemented PopulationPyramid component for visualizing demographic data. - Added validation for population pyramid input to ensure correct dimensions. - Refactored BarChart and LineChart components to use hooks for chart options. - Introduced utility functions for building dataset and series options. - Enhanced chart data mapping functions to support population pyramid configuration. --- .../src/lib/components/Chart/Chart.tsx | 37 ++- .../lib/components/Chart/Charts/BarChart.tsx | 49 +--- .../lib/components/Chart/Charts/LineChart.tsx | 49 +--- .../Chart/Charts/PopulationPyramid.tsx | 63 ++++ .../Chart/Charts/useEChartOption.ts | 24 ++ .../components/Chart/chartDataMapper.spec.ts | 252 +++++++++++++++- .../lib/components/Chart/chartDataMapper.ts | 273 ++++++++++++++++++ .../components/Chart/chartOptionBuilder.ts | 29 ++ 8 files changed, 700 insertions(+), 76 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartOptionBuilder.ts diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index b2dcce533..536f0b51f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,9 +1,12 @@ 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'; @@ -17,12 +20,40 @@ export function Chart({ pxtable }: ChartProps) { () => 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 ( <> - ; - ; - ; + + + + {populationPyramidResult.config ? ( + + ) : ( + + {pyramidWarningText} + + )} ); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index b1315e197..3d2a0fd89 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -1,51 +1,28 @@ -import { useRef, useEffect } from 'react'; -import * as echarts from 'echarts'; +import { useMemo } from 'react'; +import type * as echarts from 'echarts'; +import { buildDatasetOption, buildSeriesOption } from '../chartOptionBuilder'; import type { EChartsDataset } from '../chartTypes'; +import { useEChartOption } from './useEChartOption'; interface BarChartProps { readonly dataset: EChartsDataset; readonly isHorizontal?: boolean; } export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { - const divRef = useRef(null); + const option = useMemo(() => { + const xAxisType = isHorizontal ? ({ type: 'category' } as const) : {}; + const yAxisType = isHorizontal ? {} : ({ type: 'category' } as const); - useEffect(() => { - if (!divRef.current) { - return; - } - const xAxisType = isHorizontal ? { type: 'category' } : {}; - const yAxisType = isHorizontal ? {} : { type: 'category' }; - // Create the echarts instance - const myChart = echarts.init(divRef.current as HTMLElement, null, { - renderer: 'svg', - }); - // Draw the chart - myChart.setOption({ - title: { - text: 'ECharts Getting Started Example', - }, - dataset: { - dimensions: dataset.dimensions, - source: dataset.source, - }, - legend: {}, - tooltip: {}, - // Declare an x-axis (category axis). - // The category map the first column in the dataset by default. + return { + ...buildDatasetOption(dataset), xAxis: xAxisType, - // Declare a y-axis (value axis). yAxis: yAxisType, - series: dataset.series.map((series) => ({ - name: series.name, - type: 'bar', - })), - }); - - return () => { - myChart.dispose(); + series: buildSeriesOption(dataset, 'bar'), }; - }, [divRef, dataset, isHorizontal]); + }, [dataset, isHorizontal]); + + const divRef = useEChartOption(option); return
; } export default BarChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx index f6ea6e785..677f18433 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -1,48 +1,25 @@ -import { useRef, useEffect } from 'react'; -import * as echarts from 'echarts'; +import { useMemo } from 'react'; +import type * as echarts from 'echarts'; +import { buildDatasetOption, buildSeriesOption } from '../chartOptionBuilder'; import type { EChartsDataset } from '../chartTypes'; +import { useEChartOption } from './useEChartOption'; interface LineChartProps { readonly dataset: EChartsDataset; } export function LineChart({ dataset }: LineChartProps) { - const divRef = useRef(null); - - useEffect(() => { - if (!divRef.current) { - return; - } - // Create the echarts instance - const myChart = echarts.init(divRef.current as HTMLElement, null, { - renderer: 'svg', - }); - // Draw the chart - myChart.setOption({ - title: { - text: 'ECharts Getting Started Example', - }, - dataset: { - dimensions: dataset.dimensions, - source: dataset.source, - }, - legend: {}, - tooltip: {}, - // Declare an x-axis (category axis). - // The category map the first column in the dataset by default. - xAxis: { type: 'category' }, - // Declare a y-axis (value axis). + const option = useMemo( + () => ({ + ...buildDatasetOption(dataset), + xAxis: { type: 'category' as const }, yAxis: {}, - series: dataset.series.map((series) => ({ - name: series.name, - type: 'line', - })), - }); + series: buildSeriesOption(dataset, 'line'), + }), + [dataset], + ); - return () => { - myChart.dispose(); - }; - }, [divRef, dataset]); + const divRef = useEChartOption(option); return
; } export default LineChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx new file mode 100644 index 000000000..e508ef7a6 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; + +import type * as echarts from 'echarts'; + +import type { PopulationPyramidConfig } from '../chartTypes'; +import { useEChartOption } from './useEChartOption'; + +interface PopulationPyramidProps { + readonly dataset: PopulationPyramidConfig; +} + +export function PopulationPyramid({ dataset }: PopulationPyramidProps) { + const option = useMemo(() => { + 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 = useEChartOption(option); + return
; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts new file mode 100644 index 000000000..20220e8ee --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts'; + +export function useEChartOption( + option: echarts.EChartsOption, + renderer: 'canvas' | 'svg' = 'svg', +) { + const divRef = useRef(null); + + useEffect(() => { + if (!divRef.current) { + return; + } + + const chart = echarts.init(divRef.current, null, { renderer }); + chart.setOption(option); + + return () => { + chart.dispose(); + }; + }, [option, renderer]); + + return divRef; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts index 6d106faba..69c8ee98f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts @@ -1,8 +1,163 @@ import { describe, expect, it } from 'vitest'; -import { mapChartConfigToEChartsDataset } from './chartDataMapper'; +import { + mapChartConfigToEChartsDataset, + mapPxTableToPopulationPyramid, + validatePopulationPyramidInput, +} from './chartDataMapper'; import type { ChartConfig } from './chartTypes'; +import type { PxTable } from '../../shared-types/pxTable'; +import { VartypeEnum } from '../../shared-types/vartypeEnum'; + +function createPyramidPxTable(overrides?: { + sexCount?: number; + ageCount?: number; + regionCount?: number; + timeCount?: number; +}): PxTable { + const sexCount = overrides?.sexCount ?? 2; + const ageCount = overrides?.ageCount ?? 3; + const regionCount = overrides?.regionCount ?? 1; + const timeCount = overrides?.timeCount ?? 1; + + const sexValues = Array.from({ length: sexCount }, (_, index) => ({ + code: `S${index + 1}`, + label: `Sex ${index + 1}`, + })); + const ageValues = Array.from({ length: ageCount }, (_, index) => ({ + code: `A${index + 1}`, + label: `Age ${index + 1}`, + })); + const regionValues = Array.from({ length: regionCount }, (_, index) => ({ + code: `R${index + 1}`, + label: `Region ${index + 1}`, + })); + const timeValues = Array.from({ length: timeCount }, (_, index) => ({ + code: `T${index + 1}`, + label: `Time ${index + 1}`, + })); + + const cube: Record< + string, + Record>> + > = {}; + for (let sexIndex = 0; sexIndex < sexValues.length; sexIndex += 1) { + const sexCode = sexValues[sexIndex].code; + cube[sexCode] = {}; + for (let ageIndex = 0; ageIndex < ageValues.length; ageIndex += 1) { + const ageCode = ageValues[ageIndex].code; + cube[sexCode][ageCode] = {}; + for ( + let regionIndex = 0; + regionIndex < regionValues.length; + regionIndex += 1 + ) { + const regionCode = regionValues[regionIndex].code; + cube[sexCode][ageCode][regionCode] = {}; + for (let timeIndex = 0; timeIndex < timeValues.length; timeIndex += 1) { + const timeCode = timeValues[timeIndex].code; + cube[sexCode][ageCode][regionCode][timeCode] = { + value: + (sexIndex + 1) * 100 + + (ageIndex + 1) * 10 + + (regionIndex + 1) + + timeIndex, + }; + } + } + } + } + + return { + metadata: { + id: 'pyramid-test', + language: 'en', + label: 'Population pyramid', + updated: new Date('2026-01-01'), + source: '', + infofile: '', + decimals: 0, + officialStatistics: false, + aggregationAllowed: false, + contents: '', + descriptionDefault: false, + matrix: '', + subjectCode: '', + subjectArea: '', + variables: [ + { + id: 'sex', + label: 'Sex', + type: VartypeEnum.REGULAR_VARIABLE, + mandatory: false, + values: sexValues, + }, + { + id: 'age', + label: 'Age', + type: VartypeEnum.REGULAR_VARIABLE, + mandatory: false, + values: ageValues, + }, + { + id: 'region', + label: 'Region', + type: VartypeEnum.GEOGRAPHICAL_VARIABLE, + mandatory: false, + values: regionValues, + }, + { + id: 'time', + label: 'Time', + type: VartypeEnum.TIME_VARIABLE, + mandatory: false, + values: timeValues, + }, + ], + contacts: [], + definitions: {}, + notes: [], + }, + data: { + cube, + variableOrder: ['sex', 'age', 'region', 'time'], + isLoaded: true, + }, + heading: [ + { + id: 'sex', + label: 'Sex', + type: VartypeEnum.REGULAR_VARIABLE, + mandatory: false, + values: sexValues, + }, + ], + stub: [ + { + id: 'age', + label: 'Age', + type: VartypeEnum.REGULAR_VARIABLE, + mandatory: false, + values: ageValues, + }, + { + id: 'region', + label: 'Region', + type: VartypeEnum.GEOGRAPHICAL_VARIABLE, + mandatory: false, + values: regionValues, + }, + { + id: 'time', + label: 'Time', + type: VartypeEnum.TIME_VARIABLE, + mandatory: false, + values: timeValues, + }, + ], + }; +} describe('mapChartConfigToEChartsDataset', () => { it('maps dimensions and values using chart series order', () => { @@ -43,3 +198,98 @@ describe('mapChartConfigToEChartsDataset', () => { ]); }); }); + +describe('validatePopulationPyramidInput', () => { + it('returns valid result for one two-value dimension, one multi-value dimension and single-value remaining dimensions', () => { + const pxtable = createPyramidPxTable(); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result).toEqual({ + isValid: true, + twoValueDimensionId: 'sex', + multiValueDimensionId: 'age', + }); + }); + + it('returns missing two-value dimension when no dimension has exactly two values', () => { + const pxtable = createPyramidPxTable({ sexCount: 1 }); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('MISSING_TWO_VALUE_DIMENSION'); + }); + + it('returns multiple two-value dimensions when more than one dimension has exactly two values', () => { + const pxtable = createPyramidPxTable({ regionCount: 2 }); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('MULTIPLE_TWO_VALUE_DIMENSIONS'); + }); + + it('returns missing multi-value dimension when no dimension has several values', () => { + const pxtable = createPyramidPxTable({ + ageCount: 1, + regionCount: 1, + timeCount: 1, + }); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('MISSING_MULTI_VALUE_DIMENSION'); + }); + + it('returns multiple multi-value dimensions when more than one dimension has several values', () => { + const pxtable = createPyramidPxTable({ regionCount: 3 }); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('MULTIPLE_MULTI_VALUE_DIMENSIONS'); + }); + + it('returns non-single remaining dimensions when dimensions outside pyramid split are not single-valued', () => { + const pxtable = createPyramidPxTable({ timeCount: 0 }); + + const result = validatePopulationPyramidInput(pxtable); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('NON_SINGLE_VALUE_REMAINING_DIMENSIONS'); + }); +}); + +describe('mapPxTableToPopulationPyramid', () => { + it('maps valid pyramid data with left and right series names from the two-value dimension', () => { + const pxtable = createPyramidPxTable(); + + const result = mapPxTableToPopulationPyramid(pxtable); + + expect(result.validation.isValid).toBe(true); + expect(result.config).toBeDefined(); + expect(result.config?.leftSeriesName).toBe('Sex 1'); + expect(result.config?.rightSeriesName).toBe('Sex 2'); + expect(result.config?.data).toEqual([ + { name: 'Age 1', left: 111, right: 211 }, + { name: 'Age 2', left: 121, right: 221 }, + { name: 'Age 3', left: 131, right: 231 }, + ]); + }); + + it('returns only validation result when input is invalid', () => { + const pxtable = createPyramidPxTable({ + ageCount: 1, + regionCount: 1, + timeCount: 1, + }); + + const result = mapPxTableToPopulationPyramid(pxtable); + + expect(result.validation.isValid).toBe(false); + expect(result.validation.reason).toBe('MISSING_MULTI_VALUE_DIMENSION'); + expect(result.config).toBeUndefined(); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts index b3770a610..b39171dbe 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -7,6 +7,9 @@ import type { ChartDataPoint, ChartSeries, EChartsDataset, + PopulationPyramidConfig, + PopulationPyramidMappingResult, + PopulationPyramidValidationResult, } from './chartTypes'; interface CombinationItem { @@ -64,6 +67,87 @@ function getLabel(items: CombinationItem[], fallback: string): string { return items.map((item) => item.label).join(' / '); } +type SelectionEntry = string | { code: string }; + +interface VariableWithOptionalSelection { + readonly selectedValues?: unknown; + readonly selection?: unknown; +} + +interface ValueWithOptionalSelectedFlag { + readonly code: string; + readonly selected?: boolean; +} + +function isCodeObject(entry: unknown): entry is { code: string } { + return ( + typeof entry === 'object' && + entry !== null && + 'code' in entry && + typeof entry.code === 'string' + ); +} + +function selectionEntriesToCodes(entries: SelectionEntry[]): string[] { + return entries + .map((entry) => (typeof entry === 'string' ? entry : entry.code)) + .filter((code) => code.length > 0); +} + +function selectedFromEntries( + values: Array<{ code: string; label: string }>, + entries: unknown, +): Array<{ code: string; label: string }> { + if (!Array.isArray(entries) || entries.length === 0) { + return []; + } + + const normalizedEntries = entries.filter( + (entry): entry is SelectionEntry => + typeof entry === 'string' || isCodeObject(entry), + ); + + if (normalizedEntries.length === 0) { + return []; + } + + const selectedCodes = new Set(selectionEntriesToCodes(normalizedEntries)); + return values.filter((value) => selectedCodes.has(value.code)); +} + +function resolveSelectedValues( + variable: PxTable['metadata']['variables'][number], +) { + const variableWithOptionalSelection = + variable as unknown as VariableWithOptionalSelection; + + const selectedFromSelectedValues = selectedFromEntries( + variable.values, + variableWithOptionalSelection.selectedValues, + ); + if (selectedFromSelectedValues.length > 0) { + return selectedFromSelectedValues; + } + + const selectedFromSelection = selectedFromEntries( + variable.values, + variableWithOptionalSelection.selection, + ); + if (selectedFromSelection.length > 0) { + return selectedFromSelection; + } + + const selectedByFlag = variable.values.filter( + (value) => + (value as unknown as ValueWithOptionalSelectedFlag).selected === true, + ); + if (selectedByFlag.length > 0) { + return selectedByFlag; + } + + return variable.values; +} + export function mapPxTableToChart(pxtable: PxTable): ChartConfig { const rowCombinations = buildCombinations(pxtable.stub); const seriesCombinations = buildCombinations(pxtable.heading); @@ -138,3 +222,192 @@ export function mapChartConfigToEChartsDataset( series: chartConfig.series, }; } + +export function validatePopulationPyramidInput( + pxtable: PxTable, +): PopulationPyramidValidationResult { + const variables = [...pxtable.stub, ...pxtable.heading]; + const selectedCountById = new Map( + variables.map((variable) => [ + variable.id, + resolveSelectedValues(variable).length, + ]), + ); + + const twoValueDimensions = variables.filter( + (variable) => selectedCountById.get(variable.id) === 2, + ); + if (twoValueDimensions.length === 0) { + return { + isValid: false, + reason: 'MISSING_TWO_VALUE_DIMENSION', + }; + } + if (twoValueDimensions.length > 1) { + return { + isValid: false, + reason: 'MULTIPLE_TWO_VALUE_DIMENSIONS', + }; + } + + const multiValueDimensions = variables.filter( + (variable) => (selectedCountById.get(variable.id) ?? 0) > 2, + ); + if (multiValueDimensions.length === 0) { + return { + isValid: false, + reason: 'MISSING_MULTI_VALUE_DIMENSION', + twoValueDimensionId: twoValueDimensions[0].id, + }; + } + if (multiValueDimensions.length > 1) { + return { + isValid: false, + reason: 'MULTIPLE_MULTI_VALUE_DIMENSIONS', + twoValueDimensionId: twoValueDimensions[0].id, + }; + } + + const twoValueDimensionId = twoValueDimensions[0].id; + const multiValueDimensionId = multiValueDimensions[0].id; + const nonSingleRemainingDimension = variables.find((variable) => { + if ( + variable.id === twoValueDimensionId || + variable.id === multiValueDimensionId + ) { + return false; + } + + return (selectedCountById.get(variable.id) ?? 0) !== 1; + }); + + if (nonSingleRemainingDimension) { + return { + isValid: false, + reason: 'NON_SINGLE_VALUE_REMAINING_DIMENSIONS', + twoValueDimensionId, + multiValueDimensionId, + }; + } + + return { + isValid: true, + twoValueDimensionId, + multiValueDimensionId, + }; +} + +function getPopulationPyramidValue( + pxtable: PxTable, + codesByVariableId: Record, +): number | null { + const dimensions = pxtable.data.variableOrder.map( + (variableId) => codesByVariableId[variableId], + ); + + if (dimensions.some((dimension) => !dimension)) { + return null; + } + + const dataCell = getPxTableData(pxtable.data.cube, dimensions); + return dataCell?.value ?? null; +} + +export function mapPxTableToPopulationPyramid( + pxtable: PxTable, +): PopulationPyramidMappingResult { + const validation = validatePopulationPyramidInput(pxtable); + if (!validation.isValid) { + return { validation }; + } + + const variables = [...pxtable.stub, ...pxtable.heading]; + const variableById = new Map( + variables.map((variable) => [variable.id, variable]), + ); + const twoValueVariable = validation.twoValueDimensionId + ? variableById.get(validation.twoValueDimensionId) + : undefined; + const multiValueVariable = validation.multiValueDimensionId + ? variableById.get(validation.multiValueDimensionId) + : undefined; + + if (!twoValueVariable || !multiValueVariable) { + return { + validation: { + isValid: false, + reason: 'MISSING_TWO_VALUE_DIMENSION', + }, + }; + } + + const twoValueDimensionValues = resolveSelectedValues(twoValueVariable); + const multiValueDimensionValues = resolveSelectedValues(multiValueVariable); + + if (twoValueDimensionValues.length !== 2) { + return { + validation: { + isValid: false, + reason: 'MISSING_TWO_VALUE_DIMENSION', + }, + }; + } + + if (multiValueDimensionValues.length < 3) { + return { + validation: { + isValid: false, + reason: 'MISSING_MULTI_VALUE_DIMENSION', + }, + }; + } + + const fixedSelectionCodes = Object.fromEntries( + variables + .filter( + (variable) => + variable.id !== twoValueVariable.id && + variable.id !== multiValueVariable.id, + ) + .map((variable) => { + const selectedValues = resolveSelectedValues(variable); + return [variable.id, selectedValues[0]?.code]; + }) + .filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); + + const leftValue = twoValueDimensionValues[0]; + const rightValue = twoValueDimensionValues[1]; + + const data: PopulationPyramidConfig['data'] = multiValueDimensionValues.map( + (categoryValue) => { + const sharedCodes = { + ...fixedSelectionCodes, + [multiValueVariable.id]: categoryValue.code, + }; + + return { + name: categoryValue.label, + left: getPopulationPyramidValue(pxtable, { + ...sharedCodes, + [twoValueVariable.id]: leftValue.code, + }), + right: getPopulationPyramidValue(pxtable, { + ...sharedCodes, + [twoValueVariable.id]: rightValue.code, + }), + }; + }, + ); + + return { + validation, + config: { + data, + leftSeriesName: leftValue.label, + rightSeriesName: rightValue.label, + }, + }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartOptionBuilder.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartOptionBuilder.ts new file mode 100644 index 000000000..a891cf1e4 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartOptionBuilder.ts @@ -0,0 +1,29 @@ +import type * as echarts from 'echarts'; + +import type { EChartsDataset } from './chartTypes'; + +export function buildDatasetOption( + dataset: EChartsDataset, +): echarts.EChartsOption { + return { + title: { + text: 'ECharts Getting Started Example', + }, + dataset: { + dimensions: dataset.dimensions, + source: dataset.source, + }, + legend: {}, + tooltip: {}, + }; +} + +export function buildSeriesOption( + dataset: EChartsDataset, + type: 'bar' | 'line', +): echarts.SeriesOption[] { + return dataset.series.map((series) => ({ + name: series.name, + type, + })); +} From 8a5f3c4a465fd011e01b9b1ed7f45f85c5c2e1b0 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 15 Apr 2026 14:24:49 +0200 Subject: [PATCH 09/12] Refactor Chart component for improved readability and formatting --- packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 536f0b51f..a898fc9f3 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -48,9 +48,15 @@ export function Chart({ pxtable }: ChartProps) { {populationPyramidResult.config ? ( - + ) : ( - + {pyramidWarningText} )} From 7dce690164bcd4b7b4e9fc585e7cb888d46b8da6 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Wed, 15 Apr 2026 15:17:15 +0200 Subject: [PATCH 10/12] Add save to png and svg buttons --- .../lib/components/Chart/Charts/BarChart.tsx | 14 ++- .../Chart/Charts/ChartExportButtons.tsx | 116 ++++++++++++++++++ .../lib/components/Chart/Charts/LineChart.tsx | 11 +- .../Chart/Charts/PopulationPyramid.tsx | 11 +- .../Chart/Charts/useEChartOption.ts | 5 +- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/ChartExportButtons.tsx diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index 3d2a0fd89..f37d9e315 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -3,6 +3,7 @@ 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 { @@ -22,7 +23,16 @@ export function BarChart({ dataset, isHorizontal = false }: BarChartProps) { }; }, [dataset, isHorizontal]); - const divRef = useEChartOption(option); - return
; + const { divRef, chartRef } = useEChartOption(option); + + return ( +
+ +
+
+ ); } export default BarChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/ChartExportButtons.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/ChartExportButtons.tsx new file mode 100644 index 000000000..b02c71bfd --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/ChartExportButtons.tsx @@ -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; + 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 ( +
+ + +
+ ); +} + +export default ChartExportButtons; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx index 677f18433..d3a336902 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -3,6 +3,7 @@ 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 { @@ -19,7 +20,13 @@ export function LineChart({ dataset }: LineChartProps) { [dataset], ); - const divRef = useEChartOption(option); - return
; + const { divRef, chartRef } = useEChartOption(option); + + return ( +
+ +
+
+ ); } export default LineChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx index e508ef7a6..520de9352 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx @@ -3,6 +3,7 @@ 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 { @@ -58,6 +59,12 @@ export function PopulationPyramid({ dataset }: PopulationPyramidProps) { }; }, [dataset]); - const divRef = useEChartOption(option); - return
; + const { divRef, chartRef } = useEChartOption(option); + + return ( +
+ +
+
+ ); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts index 20220e8ee..14ead1d13 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts @@ -6,6 +6,7 @@ export function useEChartOption( renderer: 'canvas' | 'svg' = 'svg', ) { const divRef = useRef(null); + const chartRef = useRef(null); useEffect(() => { if (!divRef.current) { @@ -13,12 +14,14 @@ export function useEChartOption( } const chart = echarts.init(divRef.current, null, { renderer }); + chartRef.current = chart; chart.setOption(option); return () => { + chartRef.current = null; chart.dispose(); }; }, [option, renderer]); - return divRef; + return { divRef, chartRef }; } From e8f265993b682575a713b08685d12b2a3a3782b5 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Wed, 15 Apr 2026 15:24:56 +0200 Subject: [PATCH 11/12] Update Content-Security-Policy to include img src directive --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index dc4d3611d..3aef5f063 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com;img src 'self' data:; From f740dded4abaa93acd586ab158900b540c162d39 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Wed, 15 Apr 2026 15:31:12 +0200 Subject: [PATCH 12/12] fix typo --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index 3aef5f063..e747ac19b 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com;img src 'self' data:; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; img-src 'self' data:;