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'; },