diff --git a/package-lock.json b/package-lock.json
index aa60f3b5a..a4a7873fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2486,6 +2486,12 @@
"tslib": "2"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@@ -5517,6 +5523,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -13728,6 +13746,7 @@
"license": "MIT",
"dependencies": {
"@vitejs/plugin-react": "^5.2.0",
+ "chart.js": "^4.5.1",
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",
"motion": "^12.38.0",
diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json
index 9a9b6f147..9b54bca63 100644
--- a/packages/pxweb2-ui/package.json
+++ b/packages/pxweb2-ui/package.json
@@ -17,6 +17,7 @@
"license": "MIT",
"dependencies": {
"@vitejs/plugin-react": "^5.2.0",
+ "chart.js": "^4.5.1",
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",
"motion": "^12.38.0",
diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts
index 3c8afc3f1..30253b5a0 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/ChartPx/ChartPx';
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/ChartPx/ChartPx.tsx b/packages/pxweb2-ui/src/lib/components/ChartPx/ChartPx.tsx
new file mode 100644
index 000000000..f2ad3b39b
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/ChartPx.tsx
@@ -0,0 +1,30 @@
+import type { PxTable } from '../../shared-types/pxTable';
+import { useMemo } from 'react';
+import { mapPxTableToChart } from './chartDataMapper';
+import { PopulationPyramid } from './Charts/PopulationPyramid';
+import { mapPxTableToPopulationPyramid } from './populationPyramidMapper';
+import { RegularCharts } from './Charts/RegularCharts';
+
+interface ChartProps {
+ readonly pxtable: PxTable;
+}
+
+export function ChartPx({ pxtable }: ChartProps) {
+ const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]);
+ const populationPyramid = useMemo(
+ () => mapPxTableToPopulationPyramid(pxtable),
+ [pxtable],
+ );
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default ChartPx;
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/BarChart.tsx
new file mode 100644
index 000000000..148b0c843
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/BarChart.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef } from 'react';
+import Chart from 'chart.js/auto';
+import type { ChartConfig } from '../chartTypes';
+import { downloadCanvasAsPng } from '../chartExport';
+
+interface BarChartProps {
+ readonly chartConfig: ChartConfig;
+ readonly isHorizontal?: boolean;
+}
+
+export function BarChart({ chartConfig, isHorizontal = false }: BarChartProps) {
+ const canvasRef = useRef(null);
+
+ const chartName = isHorizontal ? 'bar-chart-horizontal.png' : 'bar-chart.png';
+
+ const handleDownloadPng = () => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ downloadCanvasAsPng(canvasRef.current, chartName);
+ };
+
+ useEffect(() => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ const chart = new Chart(canvasRef.current, {
+ type: 'bar',
+ options: {
+ indexAxis: isHorizontal ? 'y' : 'x',
+ },
+ data: {
+ labels: chartConfig.data.map((row) => row.name),
+ datasets: chartConfig.series.map((series) => ({
+ label: series.name,
+ data: chartConfig.data.map((row) => row[series.key] as number | null),
+ })),
+ },
+ });
+
+ return () => {
+ chart.destroy();
+ };
+ }, [chartConfig, isHorizontal]);
+
+ return (
+ <>
+ Chart
+
+
+
+ >
+ );
+}
+
+export default BarChart;
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/LineChart.tsx
new file mode 100644
index 000000000..2f6c28789
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/LineChart.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useRef } from 'react';
+import Chart from 'chart.js/auto';
+import type { ChartConfig } from '../chartTypes';
+import { downloadCanvasAsPng } from '../chartExport';
+
+interface LineChartProps {
+ readonly chartConfig: ChartConfig;
+}
+
+export function LineChart({ chartConfig }: LineChartProps) {
+ const canvasRef = useRef(null);
+
+ const handleDownloadPng = () => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ downloadCanvasAsPng(canvasRef.current, 'line-chart.png');
+ };
+
+ useEffect(() => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ const chart = new Chart(canvasRef.current, {
+ type: 'line',
+ data: {
+ labels: chartConfig.data.map((row) => row.name),
+ datasets: chartConfig.series.map((series) => ({
+ label: series.name,
+ data: chartConfig.data.map((row) => row[series.key] as number | null),
+ })),
+ },
+ });
+
+ return () => {
+ chart.destroy();
+ };
+ }, [chartConfig]);
+
+ return (
+ <>
+ Chart
+
+
+
+ >
+ );
+}
+
+export default LineChart;
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/PopulationPyramid.tsx b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/PopulationPyramid.tsx
new file mode 100644
index 000000000..b7a7fd1be
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/PopulationPyramid.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useMemo, useRef } from 'react';
+import Chart from 'chart.js/auto';
+import type {
+ PopulationPyramidConfig,
+ PopulationPyramidValidationResult,
+} from '../chartTypes';
+import { downloadCanvasAsPng } from '../chartExport';
+
+interface PopulationPyramidProps {
+ readonly config?: PopulationPyramidConfig;
+ readonly validation: PopulationPyramidValidationResult;
+}
+
+function formatAbsoluteValue(value: number | string): string {
+ const numericValue = typeof value === 'number' ? value : Number(value);
+
+ if (Number.isNaN(numericValue)) {
+ return String(value);
+ }
+
+ return Math.abs(numericValue).toLocaleString();
+}
+
+function getValidationMessage(
+ validation: PopulationPyramidValidationResult,
+): string {
+ if (!validation.reason) {
+ return 'Population pyramid data is not valid.';
+ }
+
+ const messages: Record<
+ NonNullable,
+ string
+ > = {
+ MISSING_TWO_VALUE_DIMENSION:
+ 'Population pyramid requires exactly one dimension with 2 values.',
+ MULTIPLE_TWO_VALUE_DIMENSIONS:
+ 'Population pyramid supports only one dimension with exactly 2 values.',
+ MISSING_MULTI_VALUE_DIMENSION:
+ 'Population pyramid requires exactly one dimension with more than 2 values.',
+ MULTIPLE_MULTI_VALUE_DIMENSIONS:
+ 'Population pyramid supports only one dimension with more than 2 values.',
+ NON_SINGLE_VALUE_REMAINING_DIMENSIONS:
+ 'All remaining dimensions must have exactly one value for population pyramid.',
+ };
+
+ return messages[validation.reason];
+}
+
+export function PopulationPyramid({
+ config,
+ validation,
+}: PopulationPyramidProps) {
+ const canvasRef = useRef(null);
+ const validationMessage = useMemo(
+ () => getValidationMessage(validation),
+ [validation],
+ );
+
+ const handleDownloadPng = () => {
+ if (!canvasRef.current) {
+ return;
+ }
+
+ downloadCanvasAsPng(canvasRef.current, 'population-pyramid.png');
+ };
+
+ useEffect(() => {
+ if (!validation.isValid || !config || !canvasRef.current) {
+ return;
+ }
+
+ const chart = new Chart(canvasRef.current, {
+ type: 'bar',
+ options: {
+ indexAxis: 'y',
+ scales: {
+ x: {
+ stacked: true,
+ ticks: {
+ callback: (value) => formatAbsoluteValue(value),
+ },
+ },
+ y: {
+ stacked: true,
+ },
+ },
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: (context) => {
+ const label = context.dataset.label
+ ? `${context.dataset.label}: `
+ : '';
+ const rawValue = context.parsed?.x ?? 0;
+ return `${label}${formatAbsoluteValue(rawValue)}`;
+ },
+ },
+ },
+ },
+ },
+ data: {
+ labels: config.data.map((row) => row.name),
+ datasets: [
+ {
+ label: config.leftSeriesName,
+ data: config.data.map((row) =>
+ row.left === null ? null : -Math.abs(row.left),
+ ),
+ },
+ {
+ label: config.rightSeriesName,
+ data: config.data.map((row) => row.right),
+ },
+ ],
+ },
+ });
+
+ return () => {
+ chart.destroy();
+ };
+ }, [config, validation.isValid]);
+
+ if (!validation.isValid || !config) {
+ return (
+ <>
+ Population Pyramid
+ {validationMessage}
+ >
+ );
+ }
+
+ return (
+ <>
+ Population Pyramid
+
+
+ >
+ );
+}
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/RegularCharts.tsx b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/RegularCharts.tsx
new file mode 100644
index 000000000..adb4a9097
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/Charts/RegularCharts.tsx
@@ -0,0 +1,19 @@
+import type { ChartConfig } from '../chartTypes';
+import { BarChart } from './BarChart';
+import { LineChart } from './LineChart';
+
+interface RegularChartsProps {
+ readonly chartConfig: ChartConfig;
+}
+
+export function RegularCharts({ chartConfig }: RegularChartsProps) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export default RegularCharts;
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/chartDataMapper.ts
new file mode 100644
index 000000000..8e39d6c53
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/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/ChartPx/chartExport.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/chartExport.ts
new file mode 100644
index 000000000..d1dc2306d
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/chartExport.ts
@@ -0,0 +1,27 @@
+export function downloadCanvasAsPng(
+ canvas: HTMLCanvasElement,
+ filename: string,
+): void {
+ const downloadFromUrl = (url: string) => {
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ };
+
+ if (canvas.toBlob) {
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ downloadFromUrl(canvas.toDataURL('image/png'));
+ return;
+ }
+
+ const objectUrl = URL.createObjectURL(blob);
+ downloadFromUrl(objectUrl);
+ URL.revokeObjectURL(objectUrl);
+ }, 'image/png');
+ return;
+ }
+
+ downloadFromUrl(canvas.toDataURL('image/png'));
+}
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/chartTypes.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/chartTypes.ts
new file mode 100644
index 000000000..da31715f8
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/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-ui/src/lib/components/ChartPx/populationPyramidMapper.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidMapper.ts
new file mode 100644
index 000000000..c45936026
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidMapper.ts
@@ -0,0 +1,87 @@
+import type { PxTable } from '../../shared-types/pxTable';
+import type { DataCell } from '../../shared-types/pxTableData';
+import { getPxTableData } from '../Table/cubeHelper';
+import type { PopulationPyramidMappingResult } from './chartTypes';
+import { validatePopulationPyramidData } from './populationPyramidValidator';
+
+function getValueFromCube(
+ pxtable: PxTable,
+ dimensionCodes: Record,
+): number | null {
+ const dimensions = pxtable.data.variableOrder.map(
+ (dimensionId) => dimensionCodes[dimensionId],
+ );
+
+ 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 = validatePopulationPyramidData(pxtable);
+
+ if (!validation.isValid) {
+ return { validation };
+ }
+
+ const twoValueDimension = pxtable.metadata.variables.find(
+ (dimension) => dimension.id === validation.twoValueDimensionId,
+ );
+ const multiValueDimension = pxtable.metadata.variables.find(
+ (dimension) => dimension.id === validation.multiValueDimensionId,
+ );
+
+ if (!twoValueDimension || !multiValueDimension) {
+ return {
+ validation: {
+ ...validation,
+ isValid: false,
+ reason: 'NON_SINGLE_VALUE_REMAINING_DIMENSIONS',
+ },
+ };
+ }
+
+ const [leftValue, rightValue] = twoValueDimension.values;
+ const fixedDimensions = Object.fromEntries(
+ pxtable.metadata.variables
+ .filter(
+ (dimension) =>
+ dimension.id !== twoValueDimension.id &&
+ dimension.id !== multiValueDimension.id,
+ )
+ .map((dimension) => [dimension.id, dimension.values[0].code]),
+ );
+
+ const data = multiValueDimension.values.map((value) => {
+ const leftDimensionCodes = {
+ ...fixedDimensions,
+ [multiValueDimension.id]: value.code,
+ [twoValueDimension.id]: leftValue.code,
+ };
+ const rightDimensionCodes = {
+ ...fixedDimensions,
+ [multiValueDimension.id]: value.code,
+ [twoValueDimension.id]: rightValue.code,
+ };
+
+ return {
+ name: value.label,
+ left: getValueFromCube(pxtable, leftDimensionCodes),
+ right: getValueFromCube(pxtable, rightDimensionCodes),
+ };
+ });
+
+ return {
+ validation,
+ config: {
+ data,
+ leftSeriesName: leftValue.label,
+ rightSeriesName: rightValue.label,
+ },
+ };
+}
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.spec.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.spec.ts
new file mode 100644
index 000000000..bff197799
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.spec.ts
@@ -0,0 +1,120 @@
+import type { PxTable } from '../../shared-types/pxTable';
+import type { Variable } from '../../shared-types/variable';
+import { mapPxTableToPopulationPyramid } from './populationPyramidMapper';
+import { validatePopulationPyramidData } from './populationPyramidValidator';
+
+function createVariable(id: string, valueCount: number): Variable {
+ return {
+ id,
+ label: id,
+ type: 'd' as never,
+ mandatory: true,
+ values: Array.from({ length: valueCount }, (_, index) => ({
+ code: `${id}-${index}`,
+ label: `${id}-${index}`,
+ })),
+ };
+}
+
+function createPxTable(variables: Variable[]): PxTable {
+ return {
+ metadata: {
+ variables,
+ },
+ data: {
+ cube: {
+ 'sex-0': {
+ 'age-0': {
+ 'region-0': { value: 11 },
+ },
+ 'age-1': {
+ 'region-0': { value: 12 },
+ },
+ 'age-2': {
+ 'region-0': { value: 13 },
+ },
+ },
+ 'sex-1': {
+ 'age-0': {
+ 'region-0': { value: 21 },
+ },
+ 'age-1': {
+ 'region-0': { value: 22 },
+ },
+ 'age-2': {
+ 'region-0': { value: 23 },
+ },
+ },
+ },
+ variableOrder: ['sex', 'age', 'region'],
+ isLoaded: true,
+ },
+ stub: [],
+ heading: [],
+ } as unknown as PxTable;
+}
+
+describe('validatePopulationPyramidData', () => {
+ it('validates when there is exactly one 2-value dimension, one multi-value dimension, and remaining single-value dimensions', () => {
+ const table = createPxTable([
+ createVariable('sex', 2),
+ createVariable('age', 3),
+ createVariable('region', 1),
+ ]);
+
+ const result = validatePopulationPyramidData(table);
+
+ expect(result).toEqual({
+ isValid: true,
+ twoValueDimensionId: 'sex',
+ multiValueDimensionId: 'age',
+ });
+ });
+
+ it('fails when no 2-value dimension exists', () => {
+ const table = createPxTable([
+ createVariable('sex', 3),
+ createVariable('age', 3),
+ createVariable('region', 1),
+ ]);
+
+ const result = validatePopulationPyramidData(table);
+
+ expect(result.isValid).toBe(false);
+ expect(result.reason).toBe('MISSING_TWO_VALUE_DIMENSION');
+ });
+
+ it('fails when remaining dimensions are not single-value', () => {
+ const table = createPxTable([
+ createVariable('sex', 2),
+ createVariable('age', 3),
+ createVariable('region', 0),
+ ]);
+
+ const result = validatePopulationPyramidData(table);
+
+ expect(result.isValid).toBe(false);
+ expect(result.reason).toBe('NON_SINGLE_VALUE_REMAINING_DIMENSIONS');
+ });
+});
+
+describe('mapPxTableToPopulationPyramid', () => {
+ it('maps valid table data to population pyramid config', () => {
+ const table = createPxTable([
+ createVariable('sex', 2),
+ createVariable('age', 3),
+ createVariable('region', 1),
+ ]);
+
+ const result = mapPxTableToPopulationPyramid(table);
+
+ expect(result.validation.isValid).toBe(true);
+ expect(result.config?.leftSeriesName).toBe('sex-0');
+ expect(result.config?.rightSeriesName).toBe('sex-1');
+ expect(result.config?.data).toEqual([
+ { name: 'age-0', left: 11, right: 21 },
+ { name: 'age-1', left: 12, right: 22 },
+ { name: 'age-2', left: 13, right: 23 },
+ ]);
+ });
+});
diff --git a/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.ts b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.ts
new file mode 100644
index 000000000..ee4a68e78
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/ChartPx/populationPyramidValidator.ts
@@ -0,0 +1,73 @@
+import type { PxTable } from '../../shared-types/pxTable';
+import type { PopulationPyramidValidationResult } from './chartTypes';
+
+export function validatePopulationPyramidData(
+ pxtable: PxTable,
+): PopulationPyramidValidationResult {
+ const dimensions = pxtable.metadata.variables;
+
+ const twoValueDimensions = dimensions.filter(
+ (dimension) => dimension.values.length === 2,
+ );
+ const multiValueDimensions = dimensions.filter(
+ (dimension) => dimension.values.length > 2,
+ );
+
+ if (twoValueDimensions.length === 0) {
+ return {
+ isValid: false,
+ reason: 'MISSING_TWO_VALUE_DIMENSION',
+ multiValueDimensionId: multiValueDimensions[0]?.id,
+ };
+ }
+
+ if (twoValueDimensions.length > 1) {
+ return {
+ isValid: false,
+ reason: 'MULTIPLE_TWO_VALUE_DIMENSIONS',
+ twoValueDimensionId: twoValueDimensions[0]?.id,
+ multiValueDimensionId: multiValueDimensions[0]?.id,
+ };
+ }
+
+ 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,
+ multiValueDimensionId: multiValueDimensions[0]?.id,
+ };
+ }
+
+ const twoValueDimension = twoValueDimensions[0];
+ const multiValueDimension = multiValueDimensions[0];
+ const invalidRemainingDimensions = dimensions.filter(
+ (dimension) =>
+ dimension.id !== twoValueDimension.id &&
+ dimension.id !== multiValueDimension.id &&
+ dimension.values.length !== 1,
+ );
+
+ if (invalidRemainingDimensions.length > 0) {
+ return {
+ isValid: false,
+ reason: 'NON_SINGLE_VALUE_REMAINING_DIMENSIONS',
+ twoValueDimensionId: twoValueDimension.id,
+ multiValueDimensionId: multiValueDimension.id,
+ };
+ }
+
+ return {
+ isValid: true,
+ twoValueDimensionId: twoValueDimension.id,
+ multiValueDimensionId: multiValueDimension.id,
+ };
+}
diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx
index 3f48d6014..a01e1ca09 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 {
+ ChartPx,
+ Table,
+ EmptyState,
+ PxTable,
+ LocalAlert,
+} 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}
>
+
+
+