Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 377 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/pxweb2-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"@vitejs/plugin-react": "^5.2.0",
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",
"html-to-image": "^1.11.13",
"motion": "^12.38.0",
"recharts": "^3.8.1",
"vite-plugin-dts": "^4.5.4"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/pxweb2-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
49 changes: 49 additions & 0 deletions packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useMemo } from 'react';

import { LineChart } from './Charts/LineChart';
import { BarChart } from './Charts/BarChart';
import { PopulationPyramid } from './Charts/PopulationPyramid';
import { ExportableChart } from './ExportableChart';
import { mapPxTableToChart } 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]);

return (
<div>
<ExportableChart title="Line Chart Example" fileName="line-chart">
<LineChart data={chartConfig.data} series={chartConfig.series} />
</ExportableChart>

<ExportableChart
title="Bar Chart Horizontal Example"
fileName="bar-chart-horizontal"
>
<BarChart
data={chartConfig.data}
series={chartConfig.series}
isHorizontal={true}
/>
</ExportableChart>

<ExportableChart
title="Bar Chart Vertical Example"
fileName="bar-chart-vertical"
>
<BarChart data={chartConfig.data} series={chartConfig.series} />
</ExportableChart>

<ExportableChart
title="Population Pyramid Example"
fileName="population-pyramid"
>
<PopulationPyramid pxtable={pxtable} />
</ExportableChart>
</div>
);
}
59 changes: 59 additions & 0 deletions packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
CartesianGrid,
Legend,
Bar,
BarChart as RechartsBarChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts';

import type { ChartDataPoint, ChartSeries } from '../chartTypes';

const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700'];

interface BarChartProps {
readonly data: ChartDataPoint[];
readonly series: ChartSeries[];
readonly isHorizontal?: boolean;
}

export function BarChart({
data,
series,
isHorizontal = false,
}: BarChartProps) {
const chartSeries =
series.length > 0 ? series : [{ key: 'value', name: 'Value' }];

return (
<RechartsBarChart
style={{ width: '100%', aspectRatio: 1.618, maxWidth: 600 }}
responsive
layout={isHorizontal ? 'vertical' : 'horizontal'}
data={data}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey={isHorizontal ? undefined : 'name'}
type={isHorizontal ? 'number' : 'category'}
/>
<YAxis
dataKey={isHorizontal ? 'name' : undefined}
type={isHorizontal ? 'category' : 'number'}
width="auto"
/>
<Tooltip />
<Legend />

{chartSeries.map((serie, index) => (
<Bar
key={serie.key}
dataKey={serie.key}
name={serie.name}
fill={seriesColors[index % seriesColors.length]}
/>
))}
</RechartsBarChart>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
CartesianGrid,
Legend,
Line,
LineChart as RechartsLineChart,
Tooltip,
XAxis,
YAxis,
} from 'recharts';

import type { ChartDataPoint, ChartSeries } from '../chartTypes';

interface LineChartProps {
readonly data: ChartDataPoint[];
readonly series: ChartSeries[];
}

const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700'];

export function LineChart({ data, series }: LineChartProps) {
const chartSeries =
series.length > 0 ? series : [{ key: 'value', name: 'Value' }];

return (
<RechartsLineChart
style={{ width: '100%', aspectRatio: 1.618, maxWidth: 600 }}
responsive
data={data}
>
<CartesianGrid stroke="#aaa" strokeDasharray="5 5" />
<XAxis dataKey="name" />
<YAxis width="auto" />
<Legend align="right" />
<Tooltip />

{chartSeries.map((serie, index) => (
<Line
key={serie.key}
type="monotone"
dataKey={serie.key}
stroke={seriesColors[index % seriesColors.length]}
strokeWidth={2}
name={serie.name}
connectNulls
/>
))}
</RechartsLineChart>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo } from 'react';
import { Bar, BarChart, Legend, Tooltip, XAxis, YAxis } from 'recharts';

import type { PxTable } from '../../../shared-types/pxTable';
import {
mapPopulationPyramidData,
validatePopulationPyramidData,
} from '../populationPyramidData';

interface PopulationPyramidProps {
readonly pxtable: PxTable;
}

function formatAbsolute(value: number | string | null): string {
if (typeof value !== 'number') {
return '-';
}

return Math.abs(value).toString();
}

export function PopulationPyramid({ pxtable }: PopulationPyramidProps) {
const validation = useMemo(
() => validatePopulationPyramidData(pxtable),
[pxtable],
);
const chartConfig = useMemo(
() => mapPopulationPyramidData(pxtable),
[pxtable],
);

if (!validation.isValid || !chartConfig) {
return (
<div>
<strong>Population pyramid is not available for this dataset.</strong>
<div>{validation.reason}</div>
</div>
);
}

return (
<BarChart
style={{ width: '100%', aspectRatio: 1.618, maxWidth: 600 }}
responsive
data={chartConfig.data}
layout="vertical"
stackOffset="sign"
barCategoryGap={1}
>
<XAxis type="number" tickFormatter={(value) => formatAbsolute(value)} />
<YAxis dataKey="name" type="category" width="auto" />
<Tooltip
formatter={(value) => formatAbsolute(value as number | string | null)}
/>
<Legend />
<Bar
stackId="name"
dataKey="left"
name={chartConfig.leftSeriesName}
fill="#5f3dc4"
/>
<Bar
stackId="name"
dataKey="right"
name={chartConfig.rightSeriesName}
fill="#1864ab"
/>
</BarChart>
);
}
92 changes: 92 additions & 0 deletions packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useRef, type ReactNode } from 'react';
import { toPng, toSvg } from 'html-to-image';

interface ExportableChartProps {
readonly title: string;
readonly fileName: string;
readonly children: ReactNode;
}

function downloadDataUrl(dataUrl: string, fileName: string): void {
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
link.click();
}

async function exportSvg(
container: HTMLDivElement | null,
fileName: string,
): Promise<void> {
if (!container) {
return;
}

const dataUrl = await toSvg(container, {
cacheBust: true,
backgroundColor: '#ffffff',
});

downloadDataUrl(dataUrl, `${fileName}.svg`);
}

async function exportPng(
container: HTMLDivElement | null,
fileName: string,
): Promise<void> {
if (!container) {
return;
}

const dataUrl = await toPng(container, {
cacheBust: true,
backgroundColor: '#ffffff',
pixelRatio: 2,
});

downloadDataUrl(dataUrl, `${fileName}.png`);
}

export function ExportableChart({
title,
fileName,
children,
}: ExportableChartProps) {
const chartContainerRef = useRef<HTMLDivElement | null>(null);

return (
<section style={{ marginBottom: '2rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<h2 style={{ margin: 0 }}>{title}</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
type="button"
onClick={() => {
void exportPng(chartContainerRef.current, fileName);
}}
>
Save PNG
</button>
<button
type="button"
onClick={() => {
void exportSvg(chartContainerRef.current, fileName);
}}
>
Save SVG
</button>
</div>
</div>
<div ref={chartContainerRef}>{children}</div>
</section>
);
}
Loading
Loading