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