From 302f0bab31b3552433e7b0f1b5534244dfad77a4 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 11:21:00 +0200 Subject: [PATCH 01/11] feat: add Chart component with LineChart and BarChart examples to pxweb2-ui --- package-lock.json | 373 +++++++++++++++++- packages/pxweb2-ui/package.json | 1 + packages/pxweb2-ui/src/index.ts | 1 + .../src/lib/components/Chart/Chart.tsx | 53 +++ .../lib/components/Chart/Charts/BarChart.tsx | 39 ++ .../lib/components/Chart/Charts/LineChart.tsx | 44 +++ .../components/Presentation/Presentation.tsx | 11 +- 7 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx diff --git a/package-lock.json b/package-lock.json index aa60f3b5a..17a8872d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2994,6 +2994,42 @@ "resolved": "packages/pxweb2-ui", "link": true }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3658,7 +3694,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -4002,6 +4043,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4134,6 +4238,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -5819,6 +5929,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5924,6 +6155,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6351,6 +6588,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -7061,6 +7308,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -7956,6 +8209,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -8036,6 +8299,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -10696,7 +10968,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, @@ -10727,6 +10998,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -10798,6 +11092,36 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10825,6 +11149,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10948,6 +11287,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -11884,7 +12229,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -12477,6 +12821,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", @@ -13731,6 +14097,7 @@ "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "motion": "^12.38.0", + "recharts": "^3.8.1", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index 9a9b6f147..8b07ae56d 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -20,6 +20,7 @@ "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "motion": "^12.38.0", + "recharts": "^3.8.1", "vite-plugin-dts": "^4.5.4" }, "devDependencies": { diff --git a/packages/pxweb2-ui/src/index.ts b/packages/pxweb2-ui/src/index.ts index 3c8afc3f1..2275e039b 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/Chart/Chart'; 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 new file mode 100644 index 000000000..c1296b49f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -0,0 +1,53 @@ +import { LineChart } from './Charts/LineChart'; +import { BarChart } from './Charts/BarChart'; + +// #region Sample data +const data = [ + { + name: 'Page A', + uv: 400, + pv: 2400, + amt: 2400, + }, + { + name: 'Page B', + uv: 300, + pv: 4567, + amt: 2400, + }, + { + name: 'Page C', + uv: 320, + pv: 1398, + amt: 2400, + }, + { + name: 'Page D', + uv: 200, + pv: 9800, + amt: 2400, + }, + { + name: 'Page E', + uv: 278, + pv: 3908, + amt: 2400, + }, + { + name: 'Page F', + uv: 189, + pv: 4800, + amt: 2400, + }, +]; + +export function Chart() { + return ( +
+

Line Chart Example

+ +

Bar Chart Example

+ +
+ ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx new file mode 100644 index 000000000..3e2114d17 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -0,0 +1,39 @@ +import { + CartesianGrid, + Legend, + Bar, + BarChart as RechartsBarChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export function BarChart(data: any) { + const { data: chartData } = data; + + return ( + + + + + + + + + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx new file mode 100644 index 000000000..f1c83929f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -0,0 +1,44 @@ +import { + CartesianGrid, + Legend, + Line, + LineChart as RechartsLineChart, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export function LineChart(data: any) { + const { data: chartData } = data; + + return ( + + + + + + + + + + ); +} diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 3f48d6014..d64f51f56 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 { + Chart, + 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} > +
+ +
From 3afa7b56aa13ee607b2d367bf91c9af9c58be8dc Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 11:38:07 +0200 Subject: [PATCH 02/11] feat: enhance BarChart component with horizontal layout support and update Chart examples --- .../src/lib/components/Chart/Chart.tsx | 4 ++- .../lib/components/Chart/Charts/BarChart.tsx | 28 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index c1296b49f..23af2e254 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -46,7 +46,9 @@ export function Chart() {

Line Chart Example

-

Bar Chart Example

+

Bar Chart Horizontal Example

+ +

Bar Chart Vertical Example

); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index 3e2114d17..a564e583d 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -8,31 +8,47 @@ import { YAxis, } from 'recharts'; -export function BarChart(data: any) { - const { data: chartData } = data; +interface BarChartDataPoint { + readonly name: string; + readonly pv: number; + readonly uv: number; +} + +interface BarChartProps { + readonly data: BarChartDataPoint[] | { readonly data: BarChartDataPoint[] }; + readonly isHorizontal?: boolean; +} +export function BarChart({ data, isHorizontal = false }: BarChartProps) { + const chartData = Array.isArray(data) ? data : data?.data; return ( - - + + ); From f2e8e3ea4cee3e21a94cd78d9d830735f41996bd Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 13:02:46 +0200 Subject: [PATCH 03/11] feat: integrate Chart component with dynamic data mapping and series support --- .../src/lib/components/Chart/Chart.tsx | 177 +++++++++++++----- .../lib/components/Chart/Charts/BarChart.tsx | 42 +++-- .../lib/components/Chart/Charts/LineChart.tsx | 47 ++--- .../components/Presentation/Presentation.tsx | 2 +- 4 files changed, 183 insertions(+), 85 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 23af2e254..0b1807b13 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -1,55 +1,144 @@ +import { useMemo } from 'react'; + import { LineChart } from './Charts/LineChart'; import { BarChart } from './Charts/BarChart'; +import { getPxTableData } from '../Table/cubeHelper'; +import { PxTable } from '../../shared-types/pxTable'; +import { DataCell } from '../../shared-types/pxTableData'; + +export interface ChartDataPoint { + readonly name: string; + readonly [seriesKey: string]: number | string | null; +} + +export interface ChartSeries { + readonly key: string; + readonly name: string; +} + +interface CombinationItem { + readonly variableId: string; + readonly code: string; + readonly label: string; +} + +interface Combination { + readonly items: CombinationItem[]; +} + +interface ChartProps { + readonly pxtable: PxTable; +} + +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(' / '); +} + +function mapPxTableToChart(pxtable: PxTable): { + data: ChartDataPoint[]; + series: ChartSeries[]; +} { + const rowCombinations = buildCombinations(pxtable.stub); + const seriesCombinations = buildCombinations(pxtable.heading); + + const series: ChartSeries[] = seriesCombinations.map( + (combination, index) => ({ + key: + combination.items.map((item) => item.code).join('|') || + `series-${index}`, + name: getLabel(combination.items, 'Value'), + }), + ); + + const data = rowCombinations.map((rowCombination) => { + const rowMap = toCodeMap(rowCombination.items); + const point: Record = { + name: getLabel(rowCombination.items, 'Value'), + }; + + seriesCombinations.forEach((seriesCombination, seriesIndex) => { + const seriesKey = series[seriesIndex].key; + const allCodes = { + ...rowMap, + ...toCodeMap(seriesCombination.items), + }; + + const dimensions = pxtable.data.variableOrder.map( + (variableId) => allCodes[variableId], + ); + + if (dimensions.some((dimension) => !dimension)) { + point[seriesKey] = null; + return; + } + + const dataCell = getPxTableData(pxtable.data.cube, dimensions); + + point[seriesKey] = dataCell?.value ?? null; + }); + + return point as ChartDataPoint; + }); + + return { data, series }; +} + +export function Chart({ pxtable }: ChartProps) { + const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]); -// #region Sample data -const data = [ - { - name: 'Page A', - uv: 400, - pv: 2400, - amt: 2400, - }, - { - name: 'Page B', - uv: 300, - pv: 4567, - amt: 2400, - }, - { - name: 'Page C', - uv: 320, - pv: 1398, - amt: 2400, - }, - { - name: 'Page D', - uv: 200, - pv: 9800, - amt: 2400, - }, - { - name: 'Page E', - uv: 278, - pv: 3908, - amt: 2400, - }, - { - name: 'Page F', - uv: 189, - pv: 4800, - amt: 2400, - }, -]; - -export function Chart() { return (

Line Chart Example

- +

Bar Chart Horizontal Example

- +

Bar Chart Vertical Example

- +
); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index a564e583d..a0704f60f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -8,25 +8,30 @@ import { YAxis, } from 'recharts'; -interface BarChartDataPoint { - readonly name: string; - readonly pv: number; - readonly uv: number; -} +import type { ChartDataPoint, ChartSeries } from '../Chart'; + +const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700']; interface BarChartProps { - readonly data: BarChartDataPoint[] | { readonly data: BarChartDataPoint[] }; + readonly data: ChartDataPoint[]; + readonly series: ChartSeries[]; readonly isHorizontal?: boolean; } -export function BarChart({ data, isHorizontal = false }: BarChartProps) { - const chartData = Array.isArray(data) ? data : data?.data; + +export function BarChart({ + data, + series, + isHorizontal = false, +}: BarChartProps) { + const chartSeries = + series.length > 0 ? series : [{ key: 'value', name: 'Value' }]; return ( - - + + {chartSeries.map((serie, index) => ( + + ))} ); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx index f1c83929f..5d7e02af9 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -8,37 +8,42 @@ import { YAxis, } from 'recharts'; -export function LineChart(data: any) { - const { data: chartData } = data; +import type { ChartDataPoint, ChartSeries } from '../Chart'; + +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 ( - - - + + + {chartSeries.map((serie, index) => ( + + ))} ); } diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index d64f51f56..7dd0e685a 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -276,7 +276,7 @@ export function Presentation({ ref={gradientContainerRef} >
- +
From 578f1c6c261976869f4825da98c37959192a15b1 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 14:07:12 +0200 Subject: [PATCH 04/11] feat: add PopulationPyramid component and refactor chart data mapping --- .../src/lib/components/Chart/Chart.tsx | 122 +------------- .../lib/components/Chart/Charts/BarChart.tsx | 2 +- .../lib/components/Chart/Charts/LineChart.tsx | 2 +- .../Chart/Charts/PopulationPyramid.tsx | 70 ++++++++ .../lib/components/Chart/chartDataMapper.ts | 109 +++++++++++++ .../src/lib/components/Chart/chartTypes.ts | 28 ++++ .../components/Chart/populationPyramidData.ts | 151 ++++++++++++++++++ 7 files changed, 365 insertions(+), 119 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 0b1807b13..5e9d675f5 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -2,128 +2,14 @@ import { useMemo } from 'react'; import { LineChart } from './Charts/LineChart'; import { BarChart } from './Charts/BarChart'; -import { getPxTableData } from '../Table/cubeHelper'; -import { PxTable } from '../../shared-types/pxTable'; -import { DataCell } from '../../shared-types/pxTableData'; - -export interface ChartDataPoint { - readonly name: string; - readonly [seriesKey: string]: number | string | null; -} - -export interface ChartSeries { - readonly key: string; - readonly name: string; -} - -interface CombinationItem { - readonly variableId: string; - readonly code: string; - readonly label: string; -} - -interface Combination { - readonly items: CombinationItem[]; -} +import { PopulationPyramid } from './Charts/PopulationPyramid'; +import { mapPxTableToChart } from './chartDataMapper'; +import type { PxTable } from '../../shared-types/pxTable'; interface ChartProps { readonly pxtable: PxTable; } -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(' / '); -} - -function mapPxTableToChart(pxtable: PxTable): { - data: ChartDataPoint[]; - series: ChartSeries[]; -} { - const rowCombinations = buildCombinations(pxtable.stub); - const seriesCombinations = buildCombinations(pxtable.heading); - - const series: ChartSeries[] = seriesCombinations.map( - (combination, index) => ({ - key: - combination.items.map((item) => item.code).join('|') || - `series-${index}`, - name: getLabel(combination.items, 'Value'), - }), - ); - - const data = rowCombinations.map((rowCombination) => { - const rowMap = toCodeMap(rowCombination.items); - const point: Record = { - name: getLabel(rowCombination.items, 'Value'), - }; - - seriesCombinations.forEach((seriesCombination, seriesIndex) => { - const seriesKey = series[seriesIndex].key; - const allCodes = { - ...rowMap, - ...toCodeMap(seriesCombination.items), - }; - - const dimensions = pxtable.data.variableOrder.map( - (variableId) => allCodes[variableId], - ); - - if (dimensions.some((dimension) => !dimension)) { - point[seriesKey] = null; - return; - } - - const dataCell = getPxTableData(pxtable.data.cube, dimensions); - - point[seriesKey] = dataCell?.value ?? null; - }); - - return point as ChartDataPoint; - }); - - return { data, series }; -} - export function Chart({ pxtable }: ChartProps) { const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]); @@ -139,6 +25,8 @@ export function Chart({ pxtable }: ChartProps) { />

Bar Chart Vertical Example

+

Population Pyramid Example

+
); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx index a0704f60f..899d096a7 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx @@ -8,7 +8,7 @@ import { YAxis, } from 'recharts'; -import type { ChartDataPoint, ChartSeries } from '../Chart'; +import type { ChartDataPoint, ChartSeries } from '../chartTypes'; const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700']; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx index 5d7e02af9..b413814a5 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx @@ -8,7 +8,7 @@ import { YAxis, } from 'recharts'; -import type { ChartDataPoint, ChartSeries } from '../Chart'; +import type { ChartDataPoint, ChartSeries } from '../chartTypes'; interface LineChartProps { readonly data: ChartDataPoint[]; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx new file mode 100644 index 000000000..bb8cdeb96 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/PopulationPyramid.tsx @@ -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 ( +
+ Population pyramid is not available for this dataset. +
{validation.reason}
+
+ ); + } + + return ( + + formatAbsolute(value)} /> + + formatAbsolute(value as number | string | null)} + /> + + + + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts new file mode 100644 index 000000000..eb9d81873 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartDataMapper.ts @@ -0,0 +1,109 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; + +import type { 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): { + data: ChartDataPoint[]; + series: ChartSeries[]; +} { + 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/Chart/chartTypes.ts b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts new file mode 100644 index 000000000..1aaf457ef --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/chartTypes.ts @@ -0,0 +1,28 @@ +export interface ChartDataPoint { + readonly name: string; + readonly [seriesKey: string]: number | string | null; +} + +export interface ChartSeries { + readonly key: string; + readonly name: string; +} + +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?: string; + readonly twoValueDimensionId?: string; + readonly multiValueDimensionId?: string; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts b/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts new file mode 100644 index 000000000..e6930ec00 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/populationPyramidData.ts @@ -0,0 +1,151 @@ +import { getPxTableData } from '../Table/cubeHelper'; +import type { PxTable } from '../../shared-types/pxTable'; +import type { DataCell } from '../../shared-types/pxTableData'; +import type { Variable } from '../../shared-types/variable'; + +import { + type PopulationPyramidConfig, + type PopulationPyramidValidationResult, +} from './chartTypes'; + +function allDimensions(pxtable: PxTable): Variable[] { + return [...pxtable.stub, ...pxtable.heading]; +} + +export function validatePopulationPyramidData( + pxtable: PxTable, +): PopulationPyramidValidationResult { + const dimensions = allDimensions(pxtable); + + const twoValueDimensions = dimensions.filter( + (dimension) => dimension.values.length === 2, + ); + const multiValueDimensions = dimensions.filter( + (dimension) => dimension.values.length > 2, + ); + + if (twoValueDimensions.length !== 1) { + return { + isValid: false, + reason: + 'Population pyramid requires exactly one dimension with 2 values.', + }; + } + + if (multiValueDimensions.length !== 1) { + return { + isValid: false, + reason: + 'Population pyramid requires exactly one dimension with more than 2 values.', + }; + } + + const twoValueDimension = twoValueDimensions[0]; + const multiValueDimension = multiValueDimensions[0]; + + const restDimensions = dimensions.filter( + (dimension) => + dimension.id !== twoValueDimension.id && + dimension.id !== multiValueDimension.id, + ); + + if (restDimensions.some((dimension) => dimension.values.length !== 1)) { + return { + isValid: false, + reason: + 'Population pyramid requires all remaining dimensions to have exactly one value.', + }; + } + + return { + isValid: true, + twoValueDimensionId: twoValueDimension.id, + multiValueDimensionId: multiValueDimension.id, + }; +} + +export function mapPopulationPyramidData( + pxtable: PxTable, +): PopulationPyramidConfig | null { + const validation = validatePopulationPyramidData(pxtable); + + if ( + !validation.isValid || + !validation.twoValueDimensionId || + !validation.multiValueDimensionId + ) { + return null; + } + + const dimensions = allDimensions(pxtable); + const splitDimension = dimensions.find( + (dimension) => dimension.id === validation.twoValueDimensionId, + ); + const categoryDimension = dimensions.find( + (dimension) => dimension.id === validation.multiValueDimensionId, + ); + + if (!splitDimension || !categoryDimension) { + return null; + } + + const [leftValue, rightValue] = splitDimension.values; + + const fixedCodes = Object.fromEntries( + dimensions + .filter( + (dimension) => + dimension.id !== splitDimension.id && + dimension.id !== categoryDimension.id && + dimension.values.length === 1, + ) + .map((dimension) => [dimension.id, dimension.values[0].code]), + ); + + const data = categoryDimension.values.map((categoryValue) => { + const leftCodes: Record = { + ...fixedCodes, + [categoryDimension.id]: categoryValue.code, + [splitDimension.id]: leftValue.code, + }; + + const rightCodes: Record = { + ...fixedCodes, + [categoryDimension.id]: categoryValue.code, + [splitDimension.id]: rightValue.code, + }; + + const leftDimensions = pxtable.data.variableOrder.map( + (variableId) => leftCodes[variableId], + ); + const rightDimensions = pxtable.data.variableOrder.map( + (variableId) => rightCodes[variableId], + ); + + const leftCell = leftDimensions.some((dimension) => !dimension) + ? undefined + : getPxTableData(pxtable.data.cube, leftDimensions); + const rightCell = rightDimensions.some((dimension) => !dimension) + ? undefined + : getPxTableData(pxtable.data.cube, rightDimensions); + + const leftValueNumber = leftCell?.value; + const rightValueNumber = rightCell?.value; + + return { + name: categoryValue.label, + left: + typeof leftValueNumber === 'number' ? -Math.abs(leftValueNumber) : null, + right: + typeof rightValueNumber === 'number' + ? Math.abs(rightValueNumber) + : null, + }; + }); + + return { + data, + leftSeriesName: leftValue.label, + rightSeriesName: rightValue.label, + }; +} From fe82714d40679f6cac3e6db3dc4cd19fa7d7c605 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 14:44:20 +0200 Subject: [PATCH 05/11] feat: add ExportableChart component for chart export functionality and update Chart component to utilize it --- package-lock.json | 7 ++ packages/pxweb2-ui/package.json | 1 + .../src/lib/components/Chart/Chart.tsx | 41 ++++++--- .../lib/components/Chart/ExportableChart.tsx | 92 +++++++++++++++++++ 4 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx diff --git a/package-lock.json b/package-lock.json index 17a8872d8..6dedcbf0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7965,6 +7965,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -14096,6 +14102,7 @@ "@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" diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index 8b07ae56d..27a6a6ca6 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -19,6 +19,7 @@ "@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" diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx index 5e9d675f5..c7872382f 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx @@ -3,6 +3,7 @@ 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'; @@ -15,18 +16,34 @@ export function Chart({ pxtable }: ChartProps) { return (
-

Line Chart Example

- -

Bar Chart Horizontal Example

- -

Bar Chart Vertical Example

- -

Population Pyramid Example

- + + + + + + + + + + + + + + +
); } diff --git a/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx new file mode 100644 index 000000000..d64714281 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/ExportableChart.tsx @@ -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 { + 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 { + 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(null); + + return ( +
+
+

{title}

+
+ + +
+
+
{children}
+
+ ); +} From 1b7661a6651712f94dd84bf433abab25bacfccbf Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 15:28:09 +0200 Subject: [PATCH 06/11] feat: implement view mode toggle in DrawerView and update Presentation component to reflect selected view --- .../Drawers/DrawerView.module.scss | 9 +++- .../Drawers/DrawerView.spec.tsx | 7 +++- .../NavigationDrawer/Drawers/DrawerView.tsx | 40 ++++++++++++++++-- .../components/Presentation/Presentation.tsx | 36 ++++++++++++---- .../src/app/savedQueryRouteLoader.spec.ts | 42 +++++++++++++++++++ .../pxweb2/src/app/savedQueryRouteLoader.ts | 14 ++++++- 6 files changed, 130 insertions(+), 18 deletions(-) diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss index 0aa3c1701..a3162055f 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.module.scss @@ -1,3 +1,8 @@ -.alert { - margin: 8px; +.operationList { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; } diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx index 245bcbf94..ae646654f 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.spec.tsx @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest'; import { render } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; +import { MemoryRouter } from 'react-router'; import { DrawerView } from './DrawerView'; describe('DrawerView', () => { it('renders successfully', () => { - const element = render(); + const element = render( + + + , + ); expect(element).toBeTruthy(); }); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx index f9a0810aa..e19e9b3da 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerView.tsx @@ -1,16 +1,48 @@ import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router'; -import { ContentBox, LocalAlert } from '@pxweb2/pxweb2-ui'; +import { ActionItem, ContentBox } from '@pxweb2/pxweb2-ui'; import classes from './DrawerView.module.scss'; +type ViewMode = 'table' | 'graph'; + +function getViewMode(searchParams: URLSearchParams): ViewMode { + return searchParams.get('view') === 'graph' ? 'graph' : 'table'; +} + export function DrawerView() { const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedViewMode = getViewMode(searchParams); + + function setViewMode(viewMode: ViewMode) { + const nextSearchParams = new URLSearchParams(searchParams); + nextSearchParams.set('view', viewMode); + setSearchParams(nextSearchParams); + } return ( - - {t('common.status_messages.drawer_view')} - +
    +
  • + setViewMode('table')} + toggleState={selectedViewMode === 'table'} + /> +
  • +
  • + setViewMode('graph')} + toggleState={selectedViewMode === 'graph'} + /> +
  • +
); } diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 7dd0e685a..b6b707683 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -1,6 +1,7 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import { useSearchParams } from 'react-router'; import isEqual from 'lodash/isEqual'; import classes from './Presentation.module.scss'; @@ -39,6 +40,7 @@ export function Presentation({ isExpanded, setIsExpanded, }: Readonly) { + const [searchParams] = useSearchParams(); const { isMobile, getSavedQueryId } = useApp(); const config = getConfig(); const { i18n, t } = useTranslation(); @@ -58,6 +60,7 @@ export function Presentation({ const { isFadingTable, setIsFadingTable } = tableData; const [isMandatoryNotSelectedFirst, setIsMandatoryNotSelectedFirst] = useState(true); + const isGraphView = searchParams.get('view') === 'graph'; const tableContainerRef = useRef(null); const gradientContainerRef = useRef(null); @@ -275,12 +278,15 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > -
- -
-
- -
+ {isGraphView ? ( +
+ +
+ ) : ( +
+ +
+ )} )} {isMissingMandatoryVariables && @@ -290,9 +296,21 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > -
- -
+ {isGraphView ? ( +
+ +
+ ) : ( +
+ +
+ )} )} {!isLoadingMetadata && diff --git a/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts b/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts index 41de68035..9fd2ad2ed 100644 --- a/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts +++ b/packages/pxweb2/src/app/savedQueryRouteLoader.spec.ts @@ -17,6 +17,16 @@ const makeArgs = (sqId?: string): LoaderFunctionArgs => ({ unstable_pattern: '', }); +const makeArgsWithSearch = ( + sqId: string, + search: string, +): LoaderFunctionArgs => ({ + params: { sqId }, + request: new Request(`http://localhost/sq/${sqId}${search}`), + context: undefined, + unstable_pattern: '', +}); + vi.mock('./util/config/getConfig', () => ({ getConfig: vi.fn(), })); @@ -86,6 +96,38 @@ describe('savedQueryRouteLoader', () => { expect(i18n.changeLanguage).toHaveBeenCalledWith('en'); }); + it('preserves valid view query parameter on redirect', async () => { + vi.mocked(getConfig).mockReturnValue({ + apiUrl: 'https://api.example', + baseApplicationPath: '', + language: { + defaultLanguage: 'en', + showDefaultLanguageInPath: true, + supportedLanguages: [ + { shorthand: 'en', languageName: 'English' }, + { shorthand: 'sv', languageName: 'Swedish' }, + ], + }, + } as unknown as AppConfig); + vi.mocked(SavedQueriesService.getSaveQuery).mockResolvedValue({ + language: 'en', + id: '123', + savedQuery: { + tableId: 'T1', + language: 'en', + selection: { selection: [] }, + }, + links: [], + } as SavedQueryResponse); + + const res = await savedQueryRouteLoader( + makeArgsWithSearch('123', '?view=graph'), + ); + + expect(res.status).toBe(302); + expect(res.headers.get('Location')).toBe('/en/table/T1?sq=123&view=graph'); + }); + it('redirects to /table when lang is default and showDefaultLanguageInPath=false', async () => { vi.mocked(getConfig).mockReturnValue({ apiUrl: 'https://api.example', diff --git a/packages/pxweb2/src/app/savedQueryRouteLoader.ts b/packages/pxweb2/src/app/savedQueryRouteLoader.ts index cb806d4ab..ca1387c56 100644 --- a/packages/pxweb2/src/app/savedQueryRouteLoader.ts +++ b/packages/pxweb2/src/app/savedQueryRouteLoader.ts @@ -6,9 +6,14 @@ import { getLanguagePath } from './util/language/getLanguagePath'; // Handles URL:s for saved queries containing only the saved query id and redirects to the proper table URL. // Example: https://www.yourpxweb.com/sq/123456 -> https://www.yourpxweb.com/{lang}/table/{tableId}?sq=123456 -export async function savedQueryRouteLoader({ params }: LoaderFunctionArgs) { +export async function savedQueryRouteLoader({ + params, + request, +}: LoaderFunctionArgs) { const config = getConfig(); const { sqId } = params as { sqId?: string }; + const requestUrl = new URL(request.url); + const incomingView = requestUrl.searchParams.get('view'); if (!sqId) { throw new Response('Missing saved query id', { status: 400 }); @@ -34,7 +39,12 @@ export async function savedQueryRouteLoader({ params }: LoaderFunctionArgs) { config.language.positionInPath, ); - const search = `?${new URLSearchParams({ sq: sqId }).toString()}`; + const redirectParams = new URLSearchParams({ sq: sqId }); + if (incomingView === 'table' || incomingView === 'graph') { + redirectParams.set('view', incomingView); + } + + const search = `?${redirectParams.toString()}`; return redirect(`${path}${search}`); } catch (e: unknown) { From 84d34da67c4e6c02086a76f87e2d7214e78fa256 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 15:38:47 +0200 Subject: [PATCH 07/11] feat: integrate Activity component for conditional rendering in Presentation --- .../Presentation/Presentation.spec.tsx | 13 ++++++----- .../components/Presentation/Presentation.tsx | 22 +++++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx index ddc5ac0c5..98971e658 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.spec.tsx @@ -1,4 +1,5 @@ import { renderWithProviders } from '../../util/testing-utils'; +import { MemoryRouter } from 'react-router'; import Presentation from './Presentation'; describe('Presentation', () => { @@ -20,11 +21,13 @@ describe('Presentation', () => { it('should render successfully', () => { const { baseElement } = renderWithProviders( - , + + + , ); expect(baseElement).toBeTruthy(); diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index b6b707683..d8ace49ec 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -1,6 +1,12 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; -import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import React, { + Activity, + useRef, + useEffect, + useState, + useLayoutEffect, +} from 'react'; import { useSearchParams } from 'react-router'; import isEqual from 'lodash/isEqual'; @@ -278,15 +284,16 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > - {isGraphView ? ( +
- ) : ( +
+
- )} +
)} {isMissingMandatoryVariables && @@ -296,11 +303,12 @@ export function Presentation({ className={classes.gradientContainer} ref={gradientContainerRef} > - {isGraphView ? ( +
- ) : ( +
+
- )} +
)} {!isLoadingMetadata && From 8e1ee84fb0facdd378dab4b0eba158e579a122d4 Mon Sep 17 00:00:00 2001 From: Sjur Sutterud Sagen Date: Mon, 13 Apr 2026 15:58:23 +0200 Subject: [PATCH 08/11] feat: update Content-Security-Policy to include img-src directive in _headers --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index dc4d3611d..0f55f6f24 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; img-src 'self'; From 9c81b91574f565076ef496c750124184198a3963 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Tue, 14 Apr 2026 15:34:39 +0200 Subject: [PATCH 09/11] feat: update Content-Security-Policy to include data URI in img-src directive --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index 0f55f6f24..db2ad7d35 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; img-src 'self'; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; img-src 'self' img-src 'self' data:; From ed3cd4a792ba87a00012118d9b2aff5c0a6e72a4 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Tue, 14 Apr 2026 15:47:35 +0200 Subject: [PATCH 10/11] feat: simplify Content-Security-Policy by removing redundant img-src directive --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index db2ad7d35..a2020e015 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net ajax.googleapis.com; img-src 'self' img-src 'self' data:; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self'; script-src 'self'; img-src 'self' img-src 'self' data:; From 382091f66d0cd511b94206bc2a54ea90c2c343b2 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Tue, 14 Apr 2026 16:01:02 +0200 Subject: [PATCH 11/11] feat: update Content-Security-Policy by removing redundant img-src directive --- packages/pxweb2/public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers index a2020e015..b2793216b 100644 --- a/packages/pxweb2/public/_headers +++ b/packages/pxweb2/public/_headers @@ -8,4 +8,4 @@ Cache-Control: no-cache, must-revalidate Pragma: no-cache Expires: 0 - Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self'; script-src 'self'; img-src 'self' img-src 'self' data:; + Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.scb.se https://data.ssb.no https://data.qa.ssb.no; style-src 'self'; script-src 'self'; img-src 'self' data:;