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..a898fc9f3 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -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 ( + <> + + + + {populationPyramidResult.config ? ( + + ) : ( + + {pyramidWarningText} + + )} + + ); +} +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..f37d9e315 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -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(() => { + 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 ( +
+ +
+
+ ); +} +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 new file mode 100644 index 000000000..d3a336902 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -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( + () => ({ + ...buildDatasetOption(dataset), + xAxis: { type: 'category' as const }, + yAxis: {}, + series: buildSeriesOption(dataset, 'line'), + }), + [dataset], + ); + + 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 new file mode 100644 index 000000000..520de9352 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx @@ -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(() => { + 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 ( +
+ +
+
+ ); +} 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..14ead1d13 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/useEChartOption.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts'; + +export function useEChartOption( + option: echarts.EChartsOption, + renderer: 'canvas' | 'svg' = 'svg', +) { + const divRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!divRef.current) { + return; + } + + const chart = echarts.init(divRef.current, null, { renderer }); + chartRef.current = chart; + chart.setOption(option); + + return () => { + chartRef.current = null; + chart.dispose(); + }; + }, [option, renderer]); + + return { divRef, chartRef }; +} 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..69c8ee98f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.spec.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from 'vitest'; + +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', () => { + 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 }, + ]); + }); +}); + +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 new file mode 100644 index 000000000..b39171dbe --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -0,0 +1,413 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; + +import type { + ChartConfig, + ChartDataPoint, + ChartSeries, + EChartsDataset, + PopulationPyramidConfig, + PopulationPyramidMappingResult, + PopulationPyramidValidationResult, +} 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(' / '); +} + +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); + + 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 }; +} + +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, + }; +} + +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, + })); +} 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..aef06726b --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts @@ -0,0 +1,49 @@ +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 EChartsDataset { + readonly dimensions: string[]; + readonly source: Array>; + 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/public/_headers b/packages/pxweb2/public/_headers index dc4d3611d..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; + 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:; diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 3f48d6014..f36596315 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} > +
+ +