;
+ 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}
>
+
+
+