From 8ee79cf44712f991d12d834cedbe266da3b11076 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 8 Jul 2026 02:20:30 +0200 Subject: [PATCH] refactor(frontend): replace recharts with uPlot for charts Swap the Sparkline chart component (the only chart in the panel) from recharts to uPlot, keeping its public prop API identical so the three consumers (SystemHistoryModal, XrayMetricsModal, NodeHistoryPanel) are untouched. This drops recharts and 32 transitive deps (es-toolkit, victory-vendor, d3, redux, immer), shrinking the chart vendor chunk to ~51KB (22KB gzip). The uPlot port reimplements every recharts feature on canvas: gradient area fill, spline curves, up to three series, dashed horizontal grid, formatted axes, hover tooltip and marker, reference lines, and min/max extrema dots. Because canvas cannot read CSS variables, axis and ring colors are resolved via getComputedStyle and repainted on theme changes through a MutationObserver on the body class and documentElement data-theme. Also removes the es-toolkit/compat resolver shim from vite.config.js, which existed only for recharts, and swaps the manualChunks entry to vendor-uplot. Note: repaint with redraw(false); a bare uPlot redraw() re-runs _setScale and nulls the index-based x-scale, which collapsed the series to a flat, partial line. --- docs/architecture.md | 2 +- frontend/package-lock.json | 340 +----------- frontend/package.json | 2 +- frontend/src/components/viz/Sparkline.css | 56 +- frontend/src/components/viz/Sparkline.tsx | 616 +++++++++++++++------- frontend/vite.config.js | 52 +- 6 files changed, 487 insertions(+), 581 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 481a4dc6f..27164966e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ Two key ideas that explain most of the complexity: **Frontend (`frontend/`):** - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**. - Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas. -- Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**. +- Router: **react-router-dom 7**. Charts: **uPlot** (`frontend/src/components/viz/Sparkline.tsx`). Editor: **CodeMirror 6**. - **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA), `login.html`, `subpage.html`. The Go server serves the SPA; there is no separate frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bdc992f3c..4ee2906e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,8 +24,8 @@ "react-dom": "^19.2.7", "react-i18next": "^17.0.8", "react-router-dom": "^7.18.1", - "recharts": "^3.9.1", "swagger-ui-react": "^5.32.8", + "uplot": "^1.6.32", "zod": "^4.4.3" }, "devDependencies": { @@ -1816,32 +1816,6 @@ "react-dom": ">=18.0.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", - "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "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/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -2135,12 +2109,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "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==", + "dev": true, "license": "MIT" }, "node_modules/@swagger-api/apidom-ast": { @@ -2932,69 +2901,6 @@ "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/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4259,127 +4165,6 @@ "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", @@ -4485,12 +4270,6 @@ "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", @@ -4833,16 +4612,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5122,12 +4891,6 @@ "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/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -5746,16 +5509,6 @@ "node": ">= 4" } }, - "node_modules/immer": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz", - "integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/immutable": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", @@ -5796,15 +5549,6 @@ "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/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -7633,13 +7377,6 @@ "react": "^18.0.0 || ^19.0.0" } }, - "node_modules/react-is": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "license": "MIT", - "peer": true - }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -7721,36 +7458,6 @@ "react": ">= 0.14.0" } }, - "node_modules/recharts": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.1.tgz", - "integrity": "sha512-WMcwlXcB7l+BbxiEdyClkG+1sxrMHNZpzT577LEvU4+rXPd8oTAy1wXk72hnk2KOOmxuLvw3z5DtXT7HEAydtg==", - "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": "^11.1.8", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.2.0", - "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/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -7766,15 +7473,6 @@ "immutable": "^3.8.1 || ^4.0.0-rc.1" } }, - "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", @@ -8503,12 +8201,6 @@ "node": ">=12.22" } }, - "node_modules/tiny-invariant": { - "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==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8897,6 +8589,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -8926,28 +8624,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "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": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 21d6596fc..775811345 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,8 +37,8 @@ "react-dom": "^19.2.7", "react-i18next": "^17.0.8", "react-router-dom": "^7.18.1", - "recharts": "^3.9.1", "swagger-ui-react": "^5.32.8", + "uplot": "^1.6.32", "zod": "^4.4.3" }, "devDependencies": { diff --git a/frontend/src/components/viz/Sparkline.css b/frontend/src/components/viz/Sparkline.css index ce2fdde63..073c52ede 100644 --- a/frontend/src/components/viz/Sparkline.css +++ b/frontend/src/components/viz/Sparkline.css @@ -1,13 +1,67 @@ -.sparkline-svg { +.sparkline-plot { display: block; width: 100%; } +.sparkline-plot .uplot, +.sparkline-plot .u-wrap { + width: 100%; +} + +.sparkline-plot .u-cursor-x { + border-right: 1px dashed var(--ant-color-border); +} + .sparkline-container { position: relative; width: 100%; } +.sparkline-tooltip { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + white-space: nowrap; + z-index: 2; + padding: 6px 10px; + border: 1px solid var(--ant-color-border-secondary); + border-radius: 6px; + background: var(--ant-color-bg-elevated); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); + color: var(--ant-color-text); + font-size: 12px; +} + +.sparkline-tooltip .spk-tt-label { + margin-bottom: 4px; + color: var(--ant-color-text-tertiary); + font-size: 11px; +} + +.sparkline-tooltip .spk-tt-row { + display: flex; + align-items: center; + gap: 6px; + font-weight: 500; + line-height: 16px; +} + +.sparkline-tooltip .spk-tt-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} + +.sparkline-tooltip .spk-tt-name { + color: var(--ant-color-text-tertiary); +} + +.sparkline-tooltip .spk-tt-val { + margin-left: auto; +} + .sparkline-extrema { position: absolute; top: 2px; diff --git a/frontend/src/components/viz/Sparkline.tsx b/frontend/src/components/viz/Sparkline.tsx index d209ad676..210025cb8 100644 --- a/frontend/src/components/viz/Sparkline.tsx +++ b/frontend/src/components/viz/Sparkline.tsx @@ -1,15 +1,6 @@ -import { useId, useMemo } from 'react'; -import { - Area, - AreaChart, - CartesianGrid, - ReferenceDot, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; +import { useEffect, useMemo, useRef } from 'react'; +import uPlot from 'uplot'; +import 'uplot/dist/uPlot.min.css'; import './Sparkline.css'; export interface SparklineReferenceLine { @@ -26,8 +17,14 @@ export interface SparklineExtrema { maxColor?: string; } +const DEFAULT_STROKE = '#008771'; +const DEFAULT_STROKE2 = '#722ed1'; +const DEFAULT_STROKE3 = '#a0d911'; const DEFAULT_MIN_COLOR = '#52c41a'; const DEFAULT_MAX_COLOR = '#fa541c'; +const GRID_COLOR = 'rgba(128, 128, 140, 0.35)'; +const AXIS_FONT = '10px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; +const LABEL_FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'; interface SparklineProps { data: number[]; @@ -68,41 +65,79 @@ interface ChartPoint { label: string; } -export default function Sparkline({ - data, - data2 = [], - data3 = [], - stroke2 = '#722ed1', - stroke3 = '#a0d911', - name1, - name2, - name3, - labels = [], - height = 80, - stroke = '#008771', - strokeWidth = 2, - maxPoints = 120, - showGrid = true, - fillOpacity = 0.22, - showMarker = true, - markerRadius = 3, - showAxes = false, - yTickStep = 25, - tickCountX = 4, - showTooltip = false, - valueMin = 0, - valueMax = 100, - yFormatter = (v: number) => `${Math.round(v)}%`, - tooltipFormatter = null, - tooltipLabelFormatter = null, - referenceLines, - extrema, -}: SparklineProps) { - const reactId = useId(); - const safeId = reactId.replace(/[^a-zA-Z0-9]/g, ''); - const gradId = `spkGrad-${safeId}`; - const gradId2 = `spkGrad2-${safeId}`; - const gradId3 = `spkGrad3-${safeId}`; +interface ExtremaResult { + min: ChartPoint; + max: ChartPoint; + minIdx: number; + maxIdx: number; +} + +interface SparklineView { + points: ChartPoint[]; + yDomain: [number, number]; + yTicks: number[] | undefined; + xTickIndexes: number[] | undefined; + extremaPoints: ExtremaResult | null; +} + +function hexToRgba(hex: string, alpha: number): string { + let h = hex.trim(); + if (h.startsWith('#')) h = h.slice(1); + if (h.length === 3) h = h.split('').map((c) => c + c).join(''); + if (h.length !== 6) return hex; + const int = Number.parseInt(h, 16); + if (Number.isNaN(int)) return hex; + const r = (int >> 16) & 255; + const g = (int >> 8) & 255; + const b = int & 255; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +function cssVar(el: HTMLElement, name: string, fallback: string): string { + const v = getComputedStyle(el).getPropertyValue(name).trim(); + return v || fallback; +} + +function parseDash(dash: string, dpr: number): number[] { + return dash.trim().split(/\s+/).map((n) => (Number(n) || 0) * dpr); +} + +function dprOf(u: uPlot): number { + return u.width > 0 ? u.ctx.canvas.width / u.width : (uPlot.pxRatio || 1); +} + +export default function Sparkline(props: SparklineProps) { + const { + data, + data2 = [], + data3 = [], + stroke = DEFAULT_STROKE, + stroke2 = DEFAULT_STROKE2, + stroke3 = DEFAULT_STROKE3, + name1, + name2, + name3, + labels = [], + height = 80, + strokeWidth = 2, + maxPoints = 120, + showGrid = true, + fillOpacity = 0.22, + showMarker = true, + markerRadius = 3, + showAxes = false, + yTickStep = 25, + tickCountX = 4, + showTooltip = false, + valueMin = 0, + valueMax = 100, + yFormatter = (v: number) => `${Math.round(v)}%`, + tooltipFormatter = null, + tooltipLabelFormatter = null, + referenceLines, + extrema, + } = props; + const hasSeries2 = data2.length > 0; const hasSeries3 = data3.length > 0; const multiSeries = hasSeries2 || hasSeries3; @@ -135,7 +170,7 @@ export default function Sparkline({ return [valueMin, max * 1.1]; }, [points, valueMin, valueMax, hasSeries2, hasSeries3]); - const yTicks = useMemo(() => { + const yTicks = useMemo(() => { if (!showAxes) return undefined; const [min, max] = yDomain; if (valueMax === 100 && valueMin === 0 && yTickStep > 0) { @@ -147,15 +182,13 @@ export default function Sparkline({ return Array.from({ length: n }, (_, i) => min + ((max - min) * i) / (n - 1)); }, [showAxes, yDomain, valueMin, valueMax, yTickStep]); - const xTickIndexes = useMemo(() => { + const xTickIndexes = useMemo(() => { if (!showAxes || points.length === 0) return undefined; const m = Math.max(2, tickCountX); return Array.from({ length: m }, (_, i) => Math.round((i * (points.length - 1)) / (m - 1))); }, [showAxes, tickCountX, points.length]); - const fmtTooltip = tooltipFormatter ?? yFormatter; - - const extremaPoints = useMemo(() => { + const extremaPoints = useMemo(() => { if (!extrema?.show || multiSeries || points.length < 2) return null; let minIdx = 0; let maxIdx = 0; @@ -191,6 +224,340 @@ export default function Sparkline({ return parts.join(', '); }, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]); + const cfg = { + stroke, + stroke2, + stroke3, + strokeWidth, + fillOpacity, + markerRadius, + showGrid, + showMarker, + showAxes, + showTooltip, + height, + name1, + name2, + name3, + yFormatter, + tooltipFormatter, + tooltipLabelFormatter, + referenceLines, + extrema, + }; + const cfgRef = useRef(cfg); + cfgRef.current = cfg; + const viewRef = useRef({ points, yDomain, yTicks, xTickIndexes, extremaPoints }); + viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints }; + + const containerRef = useRef(null); + const plotRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let tooltipEl: HTMLDivElement | null = null; + + const seriesColor = (idx: number): string => { + const p = cfgRef.current; + if (idx <= 1) return p.stroke ?? DEFAULT_STROKE; + if (idx === 2) return p.stroke2 ?? DEFAULT_STROKE2; + return p.stroke3 ?? DEFAULT_STROKE3; + }; + + const gridTicks = (): number[] => { + const yt = viewRef.current.yTicks; + if (yt && yt.length) return yt; + const [mn, mx] = viewRef.current.yDomain; + const n = 4; + return Array.from({ length: n + 1 }, (_, i) => mn + ((mx - mn) * i) / n); + }; + + const buildData = (): uPlot.AlignedData => { + const v = viewRef.current; + const xs = v.points.map((_, i) => i); + const series: number[][] = [v.points.map((p) => p.value)]; + if (hasSeries2) series.push(v.points.map((p) => p.value2)); + if (hasSeries3) series.push(v.points.map((p) => p.value3)); + return [xs, ...series]; + }; + + const makeSeries = (): uPlot.Series => ({ + stroke: (_u, sidx) => seriesColor(sidx), + width: cfgRef.current.strokeWidth ?? 2, + fill: (u, sidx) => { + const { ctx, bbox } = u; + const color = seriesColor(sidx); + const grad = ctx.createLinearGradient(0, bbox.top, 0, bbox.top + bbox.height); + grad.addColorStop(0, hexToRgba(color, cfgRef.current.fillOpacity ?? 0.22)); + grad.addColorStop(1, hexToRgba(color, 0)); + return grad; + }, + paths: uPlot.paths.spline?.(), + points: { show: false }, + spanGaps: true, + }); + + const series: uPlot.Series[] = [{}, makeSeries()]; + if (hasSeries2) series.push(makeSeries()); + if (hasSeries3) series.push(makeSeries()); + + const axisStroke = (u: uPlot) => cssVar(u.root, '--ant-color-text-tertiary', '#8c8c8c'); + + const axes: uPlot.Axis[] = [ + { + show: showAxes, + stroke: axisStroke, + grid: { show: false }, + ticks: { show: false }, + font: AXIS_FONT, + gap: 6, + size: 28, + splits: () => viewRef.current.xTickIndexes ?? [], + values: (_u, splits) => splits.map((i) => viewRef.current.points[i]?.label ?? ''), + }, + { + show: showAxes, + scale: 'y', + side: 3, + stroke: axisStroke, + grid: { show: false }, + ticks: { show: false }, + font: AXIS_FONT, + gap: 4, + size: 56, + splits: () => viewRef.current.yTicks ?? [], + values: (_u, splits) => + splits.map((v) => (cfgRef.current.yFormatter ? cfgRef.current.yFormatter(v) : String(v))), + }, + ]; + + const drawGrid = (u: uPlot) => { + if (cfgRef.current.showGrid === false) return; + const { ctx, bbox } = u; + const dpr = dprOf(u); + ctx.save(); + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = dpr; + ctx.setLineDash([3 * dpr, 4 * dpr]); + ctx.beginPath(); + for (const ty of gridTicks()) { + const py = Math.round(u.valToPos(ty, 'y', true)) + 0.5; + ctx.moveTo(bbox.left, py); + ctx.lineTo(bbox.left + bbox.width, py); + } + ctx.stroke(); + ctx.restore(); + }; + + const drawOverlay = (u: uPlot) => { + const p = cfgRef.current; + const v = viewRef.current; + const { ctx, bbox } = u; + const dpr = dprOf(u); + const right = bbox.left + bbox.width; + + if (p.referenceLines?.length) { + for (const rl of p.referenceLines) { + const color = rl.color || p.stroke || DEFAULT_STROKE; + const py = Math.round(u.valToPos(rl.y, 'y', true)) + 0.5; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.4 * dpr; + ctx.setLineDash(parseDash(rl.dash ?? '5 4', dpr)); + ctx.beginPath(); + ctx.moveTo(bbox.left, py); + ctx.lineTo(right, py); + ctx.stroke(); + ctx.restore(); + if (rl.label) { + ctx.save(); + ctx.fillStyle = color; + ctx.font = `600 ${10 * dpr}px ${LABEL_FONT}`; + ctx.textAlign = 'right'; + ctx.textBaseline = 'bottom'; + ctx.fillText(rl.label, right - 4 * dpr, py - 3 * dpr); + ctx.restore(); + } + } + } + + const ex = v.extremaPoints; + if (p.extrema?.show && ex) { + const ringColor = cssVar(u.root, '--ant-color-bg-elevated', '#ffffff'); + const dot = (value: number, idx: number, color: string) => { + const px = u.valToPos(idx, 'x', true); + const py = u.valToPos(value, 'y', true); + ctx.save(); + ctx.beginPath(); + ctx.arc(px, py, 4.5 * dpr, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2 * dpr; + ctx.strokeStyle = ringColor; + ctx.stroke(); + ctx.restore(); + }; + dot(ex.max.value, ex.maxIdx, p.extrema.maxColor ?? DEFAULT_MAX_COLOR); + dot(ex.min.value, ex.minIdx, p.extrema.minColor ?? DEFAULT_MIN_COLOR); + } + }; + + const updateTooltip = (u: uPlot) => { + if (!tooltipEl) return; + const idx = u.cursor.idx; + const v = viewRef.current; + const p = cfgRef.current; + if (idx == null || idx < 0 || idx >= v.points.length) { + tooltipEl.style.display = 'none'; + return; + } + const pt = v.points[idx]; + const fmt = p.tooltipFormatter ?? p.yFormatter ?? ((x: number) => String(x)); + const label = p.tooltipLabelFormatter ? p.tooltipLabelFormatter(String(pt.label)) : String(pt.label); + const multi = hasSeries2 || hasSeries3; + + tooltipEl.textContent = ''; + const labelDiv = document.createElement('div'); + labelDiv.className = 'spk-tt-label'; + labelDiv.textContent = label; + tooltipEl.appendChild(labelDiv); + + const rows = [ + { name: p.name1, color: p.stroke ?? DEFAULT_STROKE, val: pt.value, on: true }, + { name: p.name2, color: p.stroke2 ?? DEFAULT_STROKE2, val: pt.value2, on: hasSeries2 }, + { name: p.name3, color: p.stroke3 ?? DEFAULT_STROKE3, val: pt.value3, on: hasSeries3 }, + ]; + for (const r of rows) { + if (!r.on) continue; + const row = document.createElement('div'); + row.className = 'spk-tt-row'; + if (multi) { + const marker = document.createElement('span'); + marker.className = 'spk-tt-dot'; + marker.style.background = r.color; + row.appendChild(marker); + const nm = document.createElement('span'); + nm.className = 'spk-tt-name'; + nm.textContent = r.name ?? ''; + row.appendChild(nm); + } + const val = document.createElement('span'); + val.className = 'spk-tt-val'; + val.textContent = fmt(r.val); + row.appendChild(val); + tooltipEl.appendChild(row); + } + + tooltipEl.style.display = ''; + const overW = u.over.clientWidth; + const overH = u.over.clientHeight; + const cx = u.cursor.left ?? 0; + const cy = u.cursor.top ?? 0; + const w = tooltipEl.offsetWidth; + const h = tooltipEl.offsetHeight; + let x = cx + 12; + if (x + w + 8 > overW) x = cx - w - 12; + if (x < 0) x = 4; + let y = cy - h - 12; + if (y < 0) y = Math.min(cy + 12, overH - h - 4); + tooltipEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`; + }; + + const opts: uPlot.Options = { + width: container.clientWidth || 600, + height, + padding: [8, 8, showAxes ? 0 : 2, showAxes ? 0 : 2], + legend: { show: false }, + cursor: { + show: showTooltip, + x: showTooltip, + y: false, + drag: { x: false, y: false, setScale: false }, + points: { + show: showMarker, + size: () => (cfgRef.current.markerRadius ?? 3) * 2, + width: 0, + stroke: (_u, sidx) => seriesColor(sidx), + fill: (_u, sidx) => seriesColor(sidx), + }, + }, + scales: { + x: { + time: false, + range: (_u, dmin, dmax) => (dmin === dmax ? [dmin - 0.5, dmax + 0.5] : [dmin, dmax]), + }, + y: { + range: () => { + const [mn, mx] = viewRef.current.yDomain; + return [mn, mx]; + }, + }, + }, + series, + axes, + hooks: { + init: [ + (u) => { + if (!cfgRef.current.showTooltip) return; + tooltipEl = document.createElement('div'); + tooltipEl.className = 'sparkline-tooltip'; + tooltipEl.style.display = 'none'; + u.over.appendChild(tooltipEl); + }, + ], + drawClear: [drawGrid], + draw: [drawOverlay], + setCursor: [updateTooltip], + }, + }; + + const u = new uPlot(opts, buildData(), container); + plotRef.current = u; + + const ro = new ResizeObserver(() => { + const w = container.clientWidth; + if (w > 0) u.setSize({ width: w, height }); + }); + ro.observe(container); + + return () => { + ro.disconnect(); + u.destroy(); + plotRef.current = null; + tooltipEl = null; + }; + }, [hasSeries2, hasSeries3, showAxes, showTooltip, showMarker, height]); + + useEffect(() => { + plotRef.current?.setData( + (() => { + const xs = points.map((_, i) => i); + const s: number[][] = [points.map((p) => p.value)]; + if (hasSeries2) s.push(points.map((p) => p.value2)); + if (hasSeries3) s.push(points.map((p) => p.value3)); + return [xs, ...s] as uPlot.AlignedData; + })(), + ); + }, [points, hasSeries2, hasSeries3, valueMin, valueMax]); + + useEffect(() => { + plotRef.current?.redraw(false); + }); + + useEffect(() => { + const redraw = () => plotRef.current?.redraw(false); + const moBody = new MutationObserver(redraw); + moBody.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + const moRoot = new MutationObserver(redraw); + moRoot.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + return () => { + moBody.disconnect(); + moRoot.disconnect(); + }; + }, []); + return (
{extremaPoints && ( @@ -210,150 +577,7 @@ export default function Sparkline({ ))}
)} - - - - - - - - - - - - - - - - - {showGrid && ( - - )} - points[i]?.label).filter(Boolean) as string[] | undefined} - /> - - {showTooltip && ( - [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']} - labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))} - separator={multiSeries ? ': ' : ''} - /> - )} - {referenceLines?.map((rl, idx) => ( - - ))} - {extremaPoints && ( - <> - - - - )} - - {hasSeries2 && ( - - )} - {hasSeries3 && ( - - )} - - +
); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 46cb46139..4c97971ea 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -77,45 +77,6 @@ function injectBasePathPlugin() { }; } -// es-toolkit's `./compat/*` exports map only declares a CJS condition, so deep -// imports like `es-toolkit/compat/get` resolve to a CJS shim. That shim uses a -// `require_X.Y` pattern that Vite's optimizer and Rolldown both mishandle -// (TypeError: require_isUnsafeProperty is not a function). The ESM build at -// `dist/compat//.mjs` is fine but only carries a named export, -// while consumers like recharts use default imports — so emit a virtual module -// that re-exports the named symbol as default. -const ES_TOOLKIT_COMPAT_DIRS = ['array', 'function', 'math', 'object', 'predicate', 'string', 'util']; -const ES_TOOLKIT_SHIM_PREFIX = '\0es-toolkit-compat:'; - -function findEsToolkitCompatMjs(name) { - for (const sub of ES_TOOLKIT_COMPAT_DIRS) { - const candidate = path.resolve(__dirname, 'node_modules/es-toolkit/dist/compat', sub, `${name}.mjs`); - if (fs.existsSync(candidate)) return candidate; - } - return null; -} - -function esToolkitCompatEsmResolver() { - return { - name: 'es-toolkit-compat-esm', - enforce: 'pre', - resolveId(id) { - const m = id.match(/^es-toolkit\/compat\/(.+)$/); - if (!m) return null; - if (!findEsToolkitCompatMjs(m[1])) return null; - return ES_TOOLKIT_SHIM_PREFIX + m[1]; - }, - load(id) { - if (!id.startsWith(ES_TOOLKIT_SHIM_PREFIX)) return null; - const name = id.slice(ES_TOOLKIT_SHIM_PREFIX.length); - const target = findEsToolkitCompatMjs(name); - if (!target) return null; - const url = target.replace(/\\/g, '/'); - return `import { ${name} } from ${JSON.stringify(url)};\nexport { ${name} };\nexport default ${name};\n`; - }, - }; -} - function bypassMigratedRoute(req) { if (req.method !== 'GET') return undefined; const url = req.url.split('?')[0]; @@ -179,17 +140,12 @@ function makeBackendProxy(target) { } export default defineConfig({ - plugins: [esToolkitCompatEsmResolver(), react(), injectBasePathPlugin()], + plugins: [react(), injectBasePathPlugin()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, - optimizeDeps: { - rolldownOptions: { - plugins: [esToolkitCompatEsmResolver()], - }, - }, experimental: { renderBuiltUrl(filename, { hostType }) { if (hostType === 'js') { @@ -249,11 +205,7 @@ export default defineConfig({ || id.includes('/node_modules/swagger-ui/') || id.includes('/node_modules/swagger-client/') ) return 'vendor-swagger'; - if ( - id.includes('/node_modules/recharts/') - || id.includes('/node_modules/victory-vendor/') - || id.includes('/node_modules/d3-') - ) return 'vendor-recharts'; + if (id.includes('/node_modules/uplot/')) return 'vendor-uplot'; if (id.includes('dayjs')) return 'vendor-dayjs'; return 'vendor'; },