From 53194f0a396079d4b13972faf1941b8e5c424d91 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Wed, 3 Jun 2026 11:11:49 +0200 Subject: [PATCH 1/6] Refactor Chart component to use LineChart and update imports accordingly --- packages/pxweb2-ui/src/index.ts | 1 + .../src/lib/components/Chart/Chart.tsx | 4 +-- .../lib/components/Chart/Charts/LineChart.tsx | 34 ++++++++++++------- .../components/Presentation/Presentation.tsx | 10 ++++-- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index 2275e039b..e02c133e8 100644 --- a/packages/pxweb2-ui/src/index.ts +++ b/packages/pxweb2-ui/src/index.ts @@ -5,6 +5,7 @@ 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/Chart/Charts/LineChart'; 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 index 594708308..a742eba3a 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,5 +1,5 @@ import BarChart from './Charts/BarChart'; -import LineChart from './Charts/LineChart'; +// import LineChart from './Charts/LineChart'; import { PopulationPyramid } from './Charts/PopulationPyramid'; import { useMemo } from 'react'; import LocalAlert from '../LocalAlert/LocalAlert'; @@ -51,7 +51,7 @@ export function Chart({ pxtable, colors }: ChartProps) { isHorizontal={true} > - + {/* */} {populationPyramidResult.config ? ( mapPxTableToChart(pxtable), [pxtable]); + const dataset = useMemo( + () => mapChartConfigToEChartsDataset(chartConfig), + [chartConfig], + ); const option = useMemo( () => ({ ...buildDatasetOption(dataset), @@ -30,15 +40,15 @@ export function LineChart({ dataset, colors }: LineChartProps) { height: 40 * dataset.series.length, // increase legend height based on number of series to prevent overlap with x-axis labels }, series: buildSeriesOption(dataset, 'line', colors), - dataZoom: [ - { - id: 'dataZoomX', - type: 'slider', - xAxisIndex: [0], - filterMode: 'filter', - bottom: 60, - }, - ], + // dataZoom: [ + // { + // id: 'dataZoomX', + // type: 'slider', + // xAxisIndex: [0], + // filterMode: 'filter', + // bottom: 60, + // }, + // ], // For line charts, tooltips are more useful when triggered by axis to show values of all series at a given category tooltip: { trigger: 'axis', diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index b34b20d5f..9ebb0eb00 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -11,7 +11,7 @@ import { EmptyState, PxTable, LocalAlert, - Chart, + LineChart, } from '@pxweb2/pxweb2-ui'; import useTableData from '../../context/useTableData'; import useVariables from '../../context/useVariables'; @@ -276,10 +276,14 @@ export function Presentation({ ref={gradientContainerRef} >
- + > + {/* */}
From 3d8fcb29ae149287a59e615fafb3a3545898ddd3 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 4 Jun 2026 13:51:49 +0200 Subject: [PATCH 2/6] Add standalone line chart component and related utilities --- packages/pxweb2-ui/package.json | 1 + packages/pxweb2-ui/src/index.ts | 4 + .../Standalone/PxwebLineChartElement.tsx | 95 ++++++++ .../Charts/Standalone/StandaloneLineChart.tsx | 46 ++++ .../Charts/Standalone/loadPxTableData.ts | 84 ++++++++ .../mapJsonStat2DatasetToPxTable.ts | 203 ++++++++++++++++++ .../Standalone/parseTableDataUrl.spec.ts | 51 +++++ .../Charts/Standalone/parseTableDataUrl.ts | 134 ++++++++++++ .../Charts/Standalone/pxwebLineChartGlobal.ts | 29 +++ .../Standalone/useStandalonePxTableData.ts | 92 ++++++++ packages/pxweb2-ui/src/web-component.ts | 55 +++++ packages/pxweb2-ui/verify-iis.html | 64 ++++++ .../pxweb2-ui/vite.web-component.config.ts | 35 +++ 13 files changed, 893 insertions(+) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/StandaloneLineChart.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/loadPxTableData.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/useStandalonePxTableData.ts create mode 100644 packages/pxweb2-ui/src/web-component.ts create mode 100644 packages/pxweb2-ui/verify-iis.html create mode 100644 packages/pxweb2-ui/vite.web-component.config.ts diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index aea41c90f..9afe03b0a 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "npm run storybook", "build": "npm run build-style-dictionary && storybook build && mv ./storybook-static ../pxweb2/dist/storybook", + "build:web-component": "vite build --config vite.web-component.config.ts", "build-style-dictionary": "node ./style-dictionary/build.mjs", "storybook": "storybook dev -p 6006", "test": "vitest run", diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index e02c133e8..d3142c8b5 100644 --- a/packages/pxweb2-ui/src/index.ts +++ b/packages/pxweb2-ui/src/index.ts @@ -6,6 +6,10 @@ export * from './lib/components/Breadcrumbs/Breadcrumbs'; export * from './lib/components/Button/Button'; export * from './lib/components/Chart/Chart'; export * from './lib/components/Chart/Charts/LineChart'; +export * from './lib/components/Chart/Charts/Standalone/StandaloneLineChart'; +export * from './lib/components/Chart/Charts/Standalone/parseTableDataUrl'; +export * from './lib/components/Chart/Charts/Standalone/PxwebLineChartElement'; +export * from './lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal'; 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/Charts/Standalone/PxwebLineChartElement.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx new file mode 100644 index 000000000..e5026d7ab --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx @@ -0,0 +1,95 @@ +// This file defines the actual custom HTML element and renders a React chart inside it. +// In short: this file is the bridge between plain HTML custom-element usage and your React chart component, +// including attribute parsing and lifecycle-safe mounting. +import { createRoot, type Root } from 'react-dom/client'; + +import { StandaloneLineChart } from './StandaloneLineChart'; + +const ELEMENT_NAME = 'pxweb-line-chart'; + +function parseBooleanAttribute( + value: string | null, + defaultValue: boolean, +): boolean { + if (value === null) { + return defaultValue; + } + + if (value === '' || value.toLowerCase() === 'true') { + return true; + } + + if (value.toLowerCase() === 'false') { + return false; + } + + return defaultValue; +} + +function parseColorsAttribute(value: string | null): string[] | undefined { + if (!value) { + return undefined; + } + + const colors = value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + return colors.length > 0 ? colors : undefined; +} + +class PxwebLineChartElement extends HTMLElement { + static get observedAttributes() { + return ['data-url', 'colors', 'strict-base-match']; + } + + private root: Root | null = null; + + connectedCallback() { + if (!this.root) { + this.root = createRoot(this); + } + + this.renderComponent(); + } + + attributeChangedCallback() { + this.renderComponent(); + } + + disconnectedCallback() { + this.root?.unmount(); + this.root = null; + } + + private renderComponent() { + if (!this.root) { + return; + } + + const dataUrl = this.getAttribute('data-url'); + if (!dataUrl) { + this.root.render(
Missing required attribute: data-url
); + return; + } + + this.root.render( + , + ); + } +} + +// Registers the custom element safely +export function definePxwebLineChartElement() { + if (!customElements.get(ELEMENT_NAME)) { + customElements.define(ELEMENT_NAME, PxwebLineChartElement); + } +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/StandaloneLineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/StandaloneLineChart.tsx new file mode 100644 index 000000000..bbdff682f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/StandaloneLineChart.tsx @@ -0,0 +1,46 @@ +// This file is the UI wrapper for rendering a standalone line chart from a dataUrl. +import LineChart from '../LineChart'; +import { PXWEB_LINE_CHART_CONFIG_CHANGED_EVENT } from './pxwebLineChartGlobal'; +import { useStandalonePxTableData } from './useStandalonePxTableData'; + +export interface StandaloneLineChartProps { + readonly dataUrl: string; + readonly colors?: string[]; + readonly strictBaseMatch?: boolean; + readonly loadingRenderer?: React.ReactNode; + readonly errorRenderer?: (errorMessage: string) => React.ReactNode; +} + +export function StandaloneLineChart({ + dataUrl, + colors, + strictBaseMatch = true, + loadingRenderer, + errorRenderer, +}: StandaloneLineChartProps) { + const { pxTable, error, isLoading } = useStandalonePxTableData({ + dataUrl, + strictBaseMatch, + reloadEventName: PXWEB_LINE_CHART_CONFIG_CHANGED_EVENT, + }); + + if (isLoading) { + return loadingRenderer ??
Loading chart...
; + } + + if (error) { + return errorRenderer ? ( + <>{errorRenderer(error)} + ) : ( +
Unable to load chart: {error}
+ ); + } + + if (!pxTable) { + return null; + } + + return ; +} + +export default StandaloneLineChart; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/loadPxTableData.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/loadPxTableData.ts new file mode 100644 index 000000000..a0a860ebe --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/loadPxTableData.ts @@ -0,0 +1,84 @@ +//This file is the shared data-loading utility for standalone chart components. +//It centralizes all non-UI work needed to get chart-ready data from a table URL. +import { + OpenAPI, + OutputFormatType, + TablesService, + type ApiError, + type Dataset, +} from '@pxweb2/pxweb2-api-client'; + +import { parseTableDataUrl } from './parseTableDataUrl'; +import { mapJsonStat2DatasetToPxTable } from './mapJsonStat2DatasetToPxTable'; +import type { PxTable } from '../../../../shared-types/pxTable'; + +export interface LoadPxTableDataOptions { + readonly dataUrl: string; + readonly strictBaseMatch?: boolean; +} + +export function getBaseOrigin(): string { + if (!OpenAPI.BASE) { + return ''; + } + + try { + const fallbackOrigin = + globalThis.window?.location.origin ?? 'http://localhost'; + return new URL(OpenAPI.BASE, fallbackOrigin).origin; + } catch { + return ''; + } +} + +export function getLoadPxTableDataErrorMessage(error: unknown): string { + if ( + typeof error === 'object' && + error !== null && + 'status' in error && + 'message' in error + ) { + const apiError = error as ApiError; + return `Request failed (${apiError.status}): ${apiError.message}`; + } + + if (error instanceof Error) { + return error.message; + } + + return 'Unknown error while loading chart data.'; +} + +export async function loadPxTableData({ + dataUrl, + strictBaseMatch = true, +}: LoadPxTableDataOptions): Promise { + const parsedUrl = parseTableDataUrl(dataUrl); + + const baseOrigin = getBaseOrigin(); + if (!baseOrigin) { + throw new Error( + 'OpenAPI.BASE is not configured. Configure OpenAPI.BASE before rendering StandaloneLineChart.', + ); + } + + if (strictBaseMatch && parsedUrl.origin !== baseOrigin) { + throw new Error( + `dataUrl origin (${parsedUrl.origin}) does not match OpenAPI.BASE origin (${baseOrigin}).`, + ); + } + + const response = await TablesService.getTableData( + parsedUrl.tableId, + parsedUrl.lang, + parsedUrl.valuecodes, + parsedUrl.codelist, + OutputFormatType.JSON_STAT2, + parsedUrl.outputFormatParams, + parsedUrl.heading?.length ? parsedUrl.heading : undefined, + parsedUrl.stub?.length ? parsedUrl.stub : undefined, + ); + + const dataset = response as unknown as Dataset; + return mapJsonStat2DatasetToPxTable(dataset); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts new file mode 100644 index 000000000..3e5a60da2 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts @@ -0,0 +1,203 @@ +// Duplicate jsonstat2-to-pxtable mapping logic. +// The original mapper is in the pxweb2 package. SHOULD WE MOVE THIS TO A SHARED PACKAGE? +import type { Dataset, jsonstat_category } from '@pxweb2/pxweb2-api-client'; + +import type { PxTable } from '../../../../shared-types/pxTable'; +import type { PxTableMetadata } from '../../../../shared-types/pxTableMetadata'; +import type { Variable } from '../../../../shared-types/variable'; +import type { Value } from '../../../../shared-types/value'; +import type { DataCell, PxData } from '../../../../shared-types/pxTableData'; +import { VartypeEnum } from '../../../../shared-types/vartypeEnum'; +import { setPxTableData } from '../../../Table/cubeHelper'; + +function getOrderedCodes(category?: jsonstat_category): string[] { + if (!category?.index) { + return []; + } + + return Object.entries(category.index) + .sort((a, b) => a[1] - b[1]) + .map(([code]) => code); +} + +function mapValue( + code: string, + label: string, + unit?: { base?: string; decimals?: number }, +): Value { + return { + code, + label, + contentInfo: unit + ? { + unit: unit.base ?? '', + decimals: unit.decimals ?? 0, + referencePeriod: '', + basePeriod: '', + alternativeText: '', + } + : undefined, + }; +} + +function resolveVariableType( + variableId: string, + role: Dataset['role'], +): VartypeEnum { + if (role?.metric?.includes(variableId)) { + return VartypeEnum.CONTENTS_VARIABLE; + } + + if (role?.time?.includes(variableId)) { + return VartypeEnum.TIME_VARIABLE; + } + + if (role?.geo?.includes(variableId)) { + return VartypeEnum.GEOGRAPHICAL_VARIABLE; + } + + return VartypeEnum.REGULAR_VARIABLE; +} + +function mapVariables(dataset: Dataset): Variable[] { + return dataset.id.map((variableId) => { + const dimension = dataset.dimension[variableId]; + const category = dimension?.category; + const labels = category?.label ?? {}; + const units = category?.unit; + const codes = getOrderedCodes(category); + + return { + id: variableId, + label: dimension?.label ?? variableId, + type: resolveVariableType(variableId, dataset.role), + mandatory: false, + values: codes.map((code) => mapValue(code, labels[code] ?? code, units?.[code])), + }; + }); +} + +function toMultiDimensionalIndices( + linearIndex: number, + dimensions: number[], +): number[] { + const indices: number[] = []; + let remaining = linearIndex; + + for (let i = dimensions.length - 1; i >= 0; i--) { + const dimensionSize = dimensions[i]; + indices.unshift(remaining % dimensionSize); + remaining = Math.floor(remaining / dimensionSize); + } + + return indices; +} + +function createCubeData( + dataset: Dataset, + orderedCodesByVariableId: Record, +): PxData { + const cube: PxData = {}; + const values = dataset.value ?? []; + + values.forEach((value, index) => { + const dimensionIndices = toMultiDimensionalIndices(index, dataset.size); + const codesByDimension = dataset.id.map( + (variableId, variableIndex) => + orderedCodesByVariableId[variableId][dimensionIndices[variableIndex]], + ); + + const dataCell: DataCell = { + value, + status: dataset.status?.[index.toString()], + }; + + setPxTableData(cube, codesByDimension, dataCell); + }); + + return cube; +} + +function mapStubAndHeading( + metadataVariables: Variable[], + dataset: Dataset, +): Pick { + const variableById = new Map( + metadataVariables.map((variable) => [variable.id, variable]), + ); + + const stub = (dataset.extension?.px?.stub ?? []) + .map((variableId) => variableById.get(variableId)) + .filter((variable): variable is Variable => Boolean(variable)); + + const heading = (dataset.extension?.px?.heading ?? []) + .map((variableId) => variableById.get(variableId)) + .filter((variable): variable is Variable => Boolean(variable)); + + if (stub.length === 0 && heading.length === 0) { + const fallbackStub = metadataVariables[0] ? [metadataVariables[0]] : []; + const fallbackHeading = metadataVariables.slice(1); + + return { + stub: fallbackStub, + heading: fallbackHeading, + }; + } + + return { + stub, + heading, + }; +} + +export function mapJsonStat2DatasetToPxTable(dataset: Dataset): PxTable { + const variables = mapVariables(dataset); + const orderedCodesByVariableId = Object.fromEntries( + dataset.id.map((variableId) => { + const codes = getOrderedCodes(dataset.dimension[variableId]?.category); + return [variableId, codes]; + }), + ); + + const metadata: PxTableMetadata = { + id: dataset.extension?.px?.tableid ?? '', + language: dataset.extension?.px?.language ?? '', + label: dataset.label ?? '', + description: dataset.extension?.px?.description, + updated: dataset.updated ? new Date(dataset.updated) : new Date(), + source: dataset.source ?? '', + infofile: dataset.extension?.px?.infofile ?? '', + decimals: dataset.extension?.px?.decimals ?? 0, + officialStatistics: dataset.extension?.px?.['official-statistics'] ?? false, + aggregationAllowed: dataset.extension?.px?.aggregallowed ?? true, + contents: dataset.extension?.px?.contents ?? '', + descriptionDefault: dataset.extension?.px?.descriptiondefault ?? false, + matrix: dataset.extension?.px?.matrix ?? '', + survey: dataset.extension?.px?.survey, + updateFrequency: dataset.extension?.px?.updateFrequency, + link: dataset.extension?.px?.link, + copyright: dataset.extension?.px?.copyright, + nextUpdate: dataset.extension?.px?.nextUpdate + ? new Date(dataset.extension.px.nextUpdate) + : undefined, + subjectCode: dataset.extension?.px?.['subject-code'] ?? '', + subjectArea: dataset.extension?.px?.['subject-area'] ?? '', + variables, + contacts: dataset.extension?.contact ?? [], + definitions: {}, + notes: [], + }; + + const { stub, heading } = mapStubAndHeading(variables, dataset); + + return { + metadata, + data: { + cube: createCubeData(dataset, orderedCodesByVariableId), + variableOrder: dataset.id, + isLoaded: true, + }, + stub, + heading, + }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts new file mode 100644 index 000000000..7c6be1abe --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTableDataUrl } from '../parseTableDataUrl'; + +describe('parseTableDataUrl', () => { + it('parses table id and simple query parameters', () => { + const parsed = parseTableDataUrl( + 'https://api.example.com/tables/TAB123/data?lang=en&heading=year&stub=region', + ); + + expect(parsed.origin).toBe('https://api.example.com'); + expect(parsed.tableId).toBe('TAB123'); + expect(parsed.lang).toBe('en'); + expect(parsed.heading).toEqual(['year']); + expect(parsed.stub).toEqual(['region']); + }); + + it('parses valuecodes and codelist using bracket syntax', () => { + const parsed = parseTableDataUrl( + 'https://api.example.com/tables/TAB123/data?valuecodes[time]=2024&valuecodes[time][]=2025&codelist[region]=county', + ); + + expect(parsed.valuecodes).toEqual({ + time: ['2024', '2025'], + }); + expect(parsed.codelist).toEqual({ + region: 'county', + }); + }); + + it('supports comma-separated and repeated heading values', () => { + const parsed = parseTableDataUrl( + 'https://api.example.com/tables/TAB123/data?heading=time,region&heading=sex', + ); + + expect(parsed.heading).toEqual(['time', 'region', 'sex']); + }); + + it('throws when URL does not target /tables/{id}/data', () => { + expect(() => + parseTableDataUrl('https://api.example.com/tables/TAB123/metadata'), + ).toThrow('URL path must match /tables/{id}/data'); + }); + + it('supports relative URLs for same-origin hosting', () => { + const parsed = parseTableDataUrl('/tables/TAB123/data?lang=no'); + + expect(parsed.tableId).toBe('TAB123'); + expect(parsed.lang).toBe('no'); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts new file mode 100644 index 000000000..d74c90948 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts @@ -0,0 +1,134 @@ +// This file parses a table data URL into structured request parameters your API client can use. +import type { OutputFormatParamType } from '@pxweb2/pxweb2-api-client'; + +export interface ParsedTableDataUrl { + readonly origin: string; + readonly tableId: string; + readonly lang?: string; + readonly valuecodes?: Record; + readonly codelist?: Record; + readonly outputFormatParams?: OutputFormatParamType[]; + readonly heading?: string[]; + readonly stub?: string[]; +} + +function parseBracketKey( + key: string, + prefix: string, +): { variableCode: string; isArraySyntax: boolean } | null { + const baseRegex = new RegExp( + String.raw`^${prefix}\[([^\]]+)\](\[\])?$`, + ); + const match = baseRegex.exec(key); + if (!match) { + return null; + } + + return { + variableCode: match[1], + isArraySyntax: match[2] === '[]', + }; +} + +function splitRepeatedOrCsv(entries: string[]): string[] { + return entries + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function parseRecordArray( + searchParams: URLSearchParams, + prefix: string, +): Record | undefined { + const entries = Array.from(searchParams.entries()) + .map(([key, value]) => { + const parsedKey = parseBracketKey(key, prefix); + if (!parsedKey) { + return null; + } + + return { + variableCode: parsedKey.variableCode, + value, + }; + }) + .filter((entry): entry is { variableCode: string; value: string } => + entry !== null, + ); + + if (entries.length === 0) { + return undefined; + } + + return entries.reduce>((acc, entry) => { + const existing = acc[entry.variableCode] ?? []; + acc[entry.variableCode] = [...existing, entry.value]; + return acc; + }, {}); +} + +function parseRecord( + searchParams: URLSearchParams, + prefix: string, +): Record | undefined { + const entries = Array.from(searchParams.entries()) + .map(([key, value]) => { + const parsedKey = parseBracketKey(key, prefix); + if (!parsedKey) { + return null; + } + + return { + variableCode: parsedKey.variableCode, + value, + }; + }) + .filter((entry): entry is { variableCode: string; value: string } => + entry !== null, + ); + + if (entries.length === 0) { + return undefined; + } + + return Object.fromEntries( + entries.map((entry) => [entry.variableCode, entry.value]), + ); +} + +function parseOutputFormatParams( + searchParams: URLSearchParams, +): OutputFormatParamType[] | undefined { + const values = splitRepeatedOrCsv(searchParams.getAll('outputFormatParams')); + + if (values.length === 0) { + return undefined; + } + + return values as OutputFormatParamType[]; +} + +export function parseTableDataUrl(dataUrl: string): ParsedTableDataUrl { + const baseOrigin = globalThis.window?.location.origin ?? 'http://localhost'; + const url = new URL(dataUrl, baseOrigin); + const pathMatch = /\/tables\/([^/]+)\/data\/?$/.exec(url.pathname); + + if (!pathMatch) { + throw new Error('URL path must match /tables/{id}/data'); + } + + const tableId = decodeURIComponent(pathMatch[1]); + const lang = url.searchParams.get('lang') ?? undefined; + + return { + origin: url.origin, + tableId, + lang, + valuecodes: parseRecordArray(url.searchParams, 'valuecodes'), + codelist: parseRecord(url.searchParams, 'codelist'), + outputFormatParams: parseOutputFormatParams(url.searchParams), + heading: splitRepeatedOrCsv(url.searchParams.getAll('heading')), + stub: splitRepeatedOrCsv(url.searchParams.getAll('stub')), + }; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal.ts new file mode 100644 index 000000000..00203552e --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal.ts @@ -0,0 +1,29 @@ +// This file defines the global runtime API contract for configuring the line-chart web component, mainly around API base URL updates. +// In short: this file is the global configuration bridge between host page settings and chart runtime behavior +import { OpenAPI } from '@pxweb2/pxweb2-api-client'; + +export const PXWEB_LINE_CHART_CONFIG_CHANGED_EVENT = + 'pxweb-line-chart-config-changed'; + +export interface PxwebLineChartGlobalConfig { + readonly baseUrl?: string; +} + +export interface PxwebLineChartGlobalApi { + readonly define: () => void; + readonly configure: (config: PxwebLineChartGlobalConfig) => void; +} + +export function configurePxwebLineChart(config: PxwebLineChartGlobalConfig) { + if (config.baseUrl) { + OpenAPI.BASE = config.baseUrl; + } + + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent(PXWEB_LINE_CHART_CONFIG_CHANGED_EVENT, { + detail: config, + }), + ); + } +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/useStandalonePxTableData.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/useStandalonePxTableData.ts new file mode 100644 index 000000000..8f32294b4 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/useStandalonePxTableData.ts @@ -0,0 +1,92 @@ +// This file defines a reusable React hook that handles standalone chart data fetching state. +// In short: this file is the shared “fetch + reload + error/loading state” hook so each standalone chart component can focus on rendering only. +import { useEffect, useState } from 'react'; + +import type { PxTable } from '../../../../shared-types/pxTable'; +import { + getLoadPxTableDataErrorMessage, + loadPxTableData, +} from './loadPxTableData'; + +export interface UseStandalonePxTableDataOptions { + readonly dataUrl: string; + readonly strictBaseMatch?: boolean; + readonly reloadEventName?: string; +} + +export interface UseStandalonePxTableDataResult { + readonly pxTable: PxTable | null; + readonly error: string | null; + readonly isLoading: boolean; +} + +export function useStandalonePxTableData({ + dataUrl, + strictBaseMatch = true, + reloadEventName, +}: UseStandalonePxTableDataOptions): UseStandalonePxTableDataResult { + const [pxTable, setPxTable] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + + useEffect(() => { + if (!globalThis.window || !reloadEventName) { + return; + } + + const handleReloadRequested = () => { + setReloadVersion((version) => version + 1); + }; + + globalThis.window.addEventListener(reloadEventName, handleReloadRequested); + + return () => { + globalThis.window.removeEventListener( + reloadEventName, + handleReloadRequested, + ); + }; + }, [reloadEventName]); + + useEffect(() => { + let isCancelled = false; + + const loadData = async () => { + try { + setIsLoading(true); + setError(null); + + const mappedPxTable = await loadPxTableData({ + dataUrl, + strictBaseMatch, + }); + + if (!isCancelled) { + setPxTable(mappedPxTable); + } + } catch (loadError) { + if (!isCancelled) { + setPxTable(null); + setError(getLoadPxTableDataErrorMessage(loadError)); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + loadData(); + + return () => { + isCancelled = true; + }; + }, [dataUrl, strictBaseMatch, reloadVersion]); + + return { + pxTable, + error, + isLoading, + }; +} diff --git a/packages/pxweb2-ui/src/web-component.ts b/packages/pxweb2-ui/src/web-component.ts new file mode 100644 index 000000000..ca70eb3f4 --- /dev/null +++ b/packages/pxweb2-ui/src/web-component.ts @@ -0,0 +1,55 @@ +/** + * Entry module for the PxWeb line chart web component bundle. + * Defines and registers the custom element, publishes the global + * PxwebLineChart API on window, and applies any queued configuration + * so host pages can configure the component before or after script load. + */ +import { definePxwebLineChartElement } from './lib/components/Chart/Charts/Standalone/PxwebLineChartElement'; +import { + configurePxwebLineChart, + type PxwebLineChartGlobalConfig, + type PxwebLineChartGlobalApi, +} from './lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal'; + +interface PendingPxwebLineChartApi { + readonly configure?: (config: PxwebLineChartGlobalConfig) => void; + readonly __pendingConfig?: PxwebLineChartGlobalConfig; +} + +declare global { + interface Window { + PxwebLineChart?: PxwebLineChartGlobalApi; + PxwebLineChartConfig?: PxwebLineChartGlobalConfig; + } +} + +function applyPendingConfigIfAny( + pendingApi: PendingPxwebLineChartApi | undefined, +): void { + if (pendingApi?.__pendingConfig) { + configurePxwebLineChart(pendingApi.__pendingConfig); + } + + if (globalThis.window?.PxwebLineChartConfig) { + configurePxwebLineChart(globalThis.window.PxwebLineChartConfig); + } +} + +const api: PxwebLineChartGlobalApi = { + define: definePxwebLineChartElement, + configure: configurePxwebLineChart, +}; + +if (globalThis.window) { + const pendingApi = + globalThis.window.PxwebLineChart as unknown as + | PendingPxwebLineChartApi + | undefined; + + globalThis.window.PxwebLineChart = api; + applyPendingConfigIfAny(pendingApi); +} + +definePxwebLineChartElement(); + +export { api as PxwebLineChart, configurePxwebLineChart }; diff --git a/packages/pxweb2-ui/verify-iis.html b/packages/pxweb2-ui/verify-iis.html new file mode 100644 index 000000000..e262b420c --- /dev/null +++ b/packages/pxweb2-ui/verify-iis.html @@ -0,0 +1,64 @@ + + + + + + PxWeb Standalone LineChart (IIS Sample) + + + + + + + + +
+

Standalone LineChart (IIS)

+

+ Replace API_BASE_URL and TABLE_ID placeholders before deployment. +

+ + + + +
+ + diff --git a/packages/pxweb2-ui/vite.web-component.config.ts b/packages/pxweb2-ui/vite.web-component.config.ts new file mode 100644 index 000000000..91f4ea1a5 --- /dev/null +++ b/packages/pxweb2-ui/vite.web-component.config.ts @@ -0,0 +1,35 @@ +/** + * Build configuration for the PxWeb line chart web component bundle. + * Uses Vite in library mode to compile src/web-component.ts into a production + * ES module output in dist/web-component, with React support and browser-safe + * process.env.NODE_ENV handling for runtime compatibility. + */ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/libs/pxweb2-ui-web-component', + plugins: [react()], + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + build: { + outDir: './dist/web-component/', + emptyOutDir: false, + reportCompressedSize: true, + lib: { + entry: 'src/web-component.ts', + name: 'PxwebLineChartElement', + fileName: 'pxweb-line-chart.wc', + formats: ['es'], + }, + rollupOptions: { + external: [], + output: { + intro: + 'var process = globalThis.process ?? { env: { NODE_ENV: "production" } };', + }, + }, + }, +}); From 8127513233221f0a2d59d2c922cba40c77e8db1a Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 4 Jun 2026 13:52:18 +0200 Subject: [PATCH 3/6] Prettier code --- .../Standalone/PxwebLineChartElement.tsx | 2 +- .../mapJsonStat2DatasetToPxTable.ts | 6 ++- .../Charts/Standalone/parseTableDataUrl.ts | 14 +++--- packages/pxweb2-ui/src/web-component.ts | 47 +++++++++---------- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx index e5026d7ab..b4942ef64 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx @@ -1,5 +1,5 @@ // This file defines the actual custom HTML element and renders a React chart inside it. -// In short: this file is the bridge between plain HTML custom-element usage and your React chart component, +// In short: this file is the bridge between plain HTML custom-element usage and your React chart component, // including attribute parsing and lifecycle-safe mounting. import { createRoot, type Root } from 'react-dom/client'; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts index 3e5a60da2..799ee17b7 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/mapJsonStat2DatasetToPxTable.ts @@ -1,4 +1,4 @@ -// Duplicate jsonstat2-to-pxtable mapping logic. +// Duplicate jsonstat2-to-pxtable mapping logic. // The original mapper is in the pxweb2 package. SHOULD WE MOVE THIS TO A SHARED PACKAGE? import type { Dataset, jsonstat_category } from '@pxweb2/pxweb2-api-client'; @@ -72,7 +72,9 @@ function mapVariables(dataset: Dataset): Variable[] { label: dimension?.label ?? variableId, type: resolveVariableType(variableId, dataset.role), mandatory: false, - values: codes.map((code) => mapValue(code, labels[code] ?? code, units?.[code])), + values: codes.map((code) => + mapValue(code, labels[code] ?? code, units?.[code]), + ), }; }); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts index d74c90948..8f8eec7a9 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.ts @@ -16,9 +16,7 @@ function parseBracketKey( key: string, prefix: string, ): { variableCode: string; isArraySyntax: boolean } | null { - const baseRegex = new RegExp( - String.raw`^${prefix}\[([^\]]+)\](\[\])?$`, - ); + const baseRegex = new RegExp(String.raw`^${prefix}\[([^\]]+)\](\[\])?$`); const match = baseRegex.exec(key); if (!match) { return null; @@ -53,8 +51,9 @@ function parseRecordArray( value, }; }) - .filter((entry): entry is { variableCode: string; value: string } => - entry !== null, + .filter( + (entry): entry is { variableCode: string; value: string } => + entry !== null, ); if (entries.length === 0) { @@ -84,8 +83,9 @@ function parseRecord( value, }; }) - .filter((entry): entry is { variableCode: string; value: string } => - entry !== null, + .filter( + (entry): entry is { variableCode: string; value: string } => + entry !== null, ); if (entries.length === 0) { diff --git a/packages/pxweb2-ui/src/web-component.ts b/packages/pxweb2-ui/src/web-component.ts index ca70eb3f4..a5fa6bb35 100644 --- a/packages/pxweb2-ui/src/web-component.ts +++ b/packages/pxweb2-ui/src/web-component.ts @@ -6,48 +6,47 @@ */ import { definePxwebLineChartElement } from './lib/components/Chart/Charts/Standalone/PxwebLineChartElement'; import { - configurePxwebLineChart, - type PxwebLineChartGlobalConfig, - type PxwebLineChartGlobalApi, + configurePxwebLineChart, + type PxwebLineChartGlobalConfig, + type PxwebLineChartGlobalApi, } from './lib/components/Chart/Charts/Standalone/pxwebLineChartGlobal'; interface PendingPxwebLineChartApi { - readonly configure?: (config: PxwebLineChartGlobalConfig) => void; - readonly __pendingConfig?: PxwebLineChartGlobalConfig; + readonly configure?: (config: PxwebLineChartGlobalConfig) => void; + readonly __pendingConfig?: PxwebLineChartGlobalConfig; } declare global { - interface Window { - PxwebLineChart?: PxwebLineChartGlobalApi; - PxwebLineChartConfig?: PxwebLineChartGlobalConfig; - } + interface Window { + PxwebLineChart?: PxwebLineChartGlobalApi; + PxwebLineChartConfig?: PxwebLineChartGlobalConfig; + } } function applyPendingConfigIfAny( - pendingApi: PendingPxwebLineChartApi | undefined, + pendingApi: PendingPxwebLineChartApi | undefined, ): void { - if (pendingApi?.__pendingConfig) { - configurePxwebLineChart(pendingApi.__pendingConfig); - } + if (pendingApi?.__pendingConfig) { + configurePxwebLineChart(pendingApi.__pendingConfig); + } - if (globalThis.window?.PxwebLineChartConfig) { - configurePxwebLineChart(globalThis.window.PxwebLineChartConfig); - } + if (globalThis.window?.PxwebLineChartConfig) { + configurePxwebLineChart(globalThis.window.PxwebLineChartConfig); + } } const api: PxwebLineChartGlobalApi = { - define: definePxwebLineChartElement, - configure: configurePxwebLineChart, + define: definePxwebLineChartElement, + configure: configurePxwebLineChart, }; if (globalThis.window) { - const pendingApi = - globalThis.window.PxwebLineChart as unknown as - | PendingPxwebLineChartApi - | undefined; + const pendingApi = globalThis.window.PxwebLineChart as unknown as + | PendingPxwebLineChartApi + | undefined; - globalThis.window.PxwebLineChart = api; - applyPendingConfigIfAny(pendingApi); + globalThis.window.PxwebLineChart = api; + applyPendingConfigIfAny(pendingApi); } definePxwebLineChartElement(); From f5d3f5de811bc4c2466cd2f0940bc82fd513dc71 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 4 Jun 2026 13:55:20 +0200 Subject: [PATCH 4/6] Fix import path for parseTableDataUrl in test file --- .../Chart/Charts/Standalone/parseTableDataUrl.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts index 7c6be1abe..855ba19db 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/parseTableDataUrl.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseTableDataUrl } from '../parseTableDataUrl'; +import { parseTableDataUrl } from './parseTableDataUrl'; describe('parseTableDataUrl', () => { it('parses table id and simple query parameters', () => { From c7865256e15371c9af60dcb73043967c96a2fe2b Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 4 Jun 2026 16:30:43 +0200 Subject: [PATCH 5/6] Update comments in PxwebLineChartElement to clarify purpose and functionality --- .../Chart/Charts/Standalone/PxwebLineChartElement.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx index b4942ef64..1b14b30d7 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx @@ -1,6 +1,5 @@ -// This file defines the actual custom HTML element and renders a React chart inside it. -// In short: this file is the bridge between plain HTML custom-element usage and your React chart component, -// including attribute parsing and lifecycle-safe mounting. +// This file implements the web component that renders a line chart from a given data URL, using React under the hood. +// Some of the functions in this file are web component specific and should exist for web components. import { createRoot, type Root } from 'react-dom/client'; import { StandaloneLineChart } from './StandaloneLineChart'; From 76b88e5d52dc6ea095cbf9e04b421961bd87d541 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Thu, 4 Jun 2026 16:31:04 +0200 Subject: [PATCH 6/6] Fix formatting of comments in PxwebLineChartElement for consistency --- .../Chart/Charts/Standalone/PxwebLineChartElement.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx index 1b14b30d7..181377a8f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/Standalone/PxwebLineChartElement.tsx @@ -1,5 +1,5 @@ // This file implements the web component that renders a line chart from a given data URL, using React under the hood. -// Some of the functions in this file are web component specific and should exist for web components. +// Some of the functions in this file are web component specific and should exist for web components. import { createRoot, type Root } from 'react-dom/client'; import { StandaloneLineChart } from './StandaloneLineChart';