diff --git a/package-lock.json b/package-lock.json
index aa60f3b5a..6dedcbf0f 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",
@@ -7712,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",
@@ -7956,6 +8215,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 +8305,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 +10974,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 +11004,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 +11098,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 +11155,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 +11293,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 +12235,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 +12827,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",
@@ -13730,7 +14102,9 @@
"@vitejs/plugin-react": "^5.2.0",
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",
+ "html-to-image": "^1.11.13",
"motion": "^12.38.0",
+ "recharts": "^3.8.1",
"vite-plugin-dts": "^4.5.4"
},
"devDependencies": {
diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json
index 9a9b6f147..27a6a6ca6 100644
--- a/packages/pxweb2-ui/package.json
+++ b/packages/pxweb2-ui/package.json
@@ -19,7 +19,9 @@
"@vitejs/plugin-react": "^5.2.0",
"clsx": "^2.1.1",
"hast-util-to-jsx-runtime": "^2.3.6",
+ "html-to-image": "^1.11.13",
"motion": "^12.38.0",
+ "recharts": "^3.8.1",
"vite-plugin-dts": "^4.5.4"
},
"devDependencies": {
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..c7872382f
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/Chart/Chart.tsx
@@ -0,0 +1,49 @@
+import { useMemo } from 'react';
+
+import { LineChart } from './Charts/LineChart';
+import { BarChart } from './Charts/BarChart';
+import { PopulationPyramid } from './Charts/PopulationPyramid';
+import { ExportableChart } from './ExportableChart';
+import { mapPxTableToChart } from './chartDataMapper';
+import type { PxTable } from '../../shared-types/pxTable';
+
+interface ChartProps {
+ readonly pxtable: PxTable;
+}
+
+export function Chart({ pxtable }: ChartProps) {
+ const chartConfig = useMemo(() => mapPxTableToChart(pxtable), [pxtable]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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..899d096a7
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/BarChart.tsx
@@ -0,0 +1,59 @@
+import {
+ CartesianGrid,
+ Legend,
+ Bar,
+ BarChart as RechartsBarChart,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import type { ChartDataPoint, ChartSeries } from '../chartTypes';
+
+const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700'];
+
+interface BarChartProps {
+ readonly data: ChartDataPoint[];
+ readonly series: ChartSeries[];
+ readonly isHorizontal?: boolean;
+}
+
+export function BarChart({
+ data,
+ series,
+ isHorizontal = false,
+}: BarChartProps) {
+ const chartSeries =
+ series.length > 0 ? series : [{ key: 'value', name: 'Value' }];
+
+ return (
+
+
+
+
+
+
+
+ {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
new file mode 100644
index 000000000..b413814a5
--- /dev/null
+++ b/packages/pxweb2-ui/src/lib/components/Chart/Charts/LineChart.tsx
@@ -0,0 +1,49 @@
+import {
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart as RechartsLineChart,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+
+import type { ChartDataPoint, ChartSeries } from '../chartTypes';
+
+interface LineChartProps {
+ readonly data: ChartDataPoint[];
+ readonly series: ChartSeries[];
+}
+
+const seriesColors = ['#5f3dc4', '#1864ab', '#0b7285', '#2b8a3e', '#e67700'];
+
+export function LineChart({ data, series }: LineChartProps) {
+ const chartSeries =
+ series.length > 0 ? series : [{ key: 'value', name: 'Value' }];
+
+ return (
+
+
+
+
+
+
+
+ {chartSeries.map((serie, index) => (
+
+ ))}
+
+ );
+}
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/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}
+
+ );
+}
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,
+ };
+}
diff --git a/packages/pxweb2/public/_headers b/packages/pxweb2/public/_headers
index dc4d3611d..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' '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'; script-src 'self'; img-src 'self' data:;
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.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 3f48d6014..d8ace49ec 100644
--- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx
+++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx
@@ -1,12 +1,25 @@
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';
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';
@@ -33,6 +46,7 @@ export function Presentation({
isExpanded,
setIsExpanded,
}: Readonly) {
+ const [searchParams] = useSearchParams();
const { isMobile, getSavedQueryId } = useApp();
const config = getConfig();
const { i18n, t } = useTranslation();
@@ -52,6 +66,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);
@@ -269,9 +284,16 @@ export function Presentation({
className={classes.gradientContainer}
ref={gradientContainerRef}
>
-
-
-
+
+
+
+
+
+
+
+
+
+
)}
{isMissingMandatoryVariables &&
@@ -281,9 +303,22 @@ export function Presentation({
className={classes.gradientContainer}
ref={gradientContainerRef}
>
-
-
-
+
+
+
+
+
+
+
+
+
+
)}
{!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) {