mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+8
-332
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number[] | undefined>(() => {
|
||||
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<number[] | undefined>(() => {
|
||||
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<ExtremaResult | null>(() => {
|
||||
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<SparklineView>({ points, yDomain, yTicks, xTickIndexes, extremaPoints });
|
||||
viewRef.current = { points, yDomain, yTicks, xTickIndexes, extremaPoints };
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const plotRef = useRef<uPlot | null>(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 (
|
||||
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
|
||||
{extremaPoints && (
|
||||
@@ -210,150 +577,7 @@ export default function Sparkline({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={height} className="sparkline-svg">
|
||||
<AreaChart
|
||||
data={points}
|
||||
margin={{
|
||||
top: showAxes ? 14 : 6,
|
||||
right: showAxes ? 12 : 6,
|
||||
bottom: showAxes ? 26 : 4,
|
||||
left: 4,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id={gradId2} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke2} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke2} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id={gradId3} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke3} stopOpacity={fillOpacity} />
|
||||
<stop offset="100%" stopColor={stroke3} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{showGrid && (
|
||||
<CartesianGrid stroke="rgba(128, 128, 140, 0.35)" strokeDasharray="3 4" vertical={false} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)' }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={14}
|
||||
interval={0}
|
||||
ticks={xTickIndexes?.map((i) => points[i]?.label).filter(Boolean) as string[] | undefined}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
hide={!showAxes}
|
||||
tick={{ fontSize: 10, fill: 'var(--ant-color-text-tertiary)', dx: -4 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={yFormatter}
|
||||
ticks={yTicks}
|
||||
width={56}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'var(--ant-color-border)', strokeDasharray: '2 4' }}
|
||||
contentStyle={{
|
||||
background: 'var(--ant-color-bg-elevated)',
|
||||
border: '1px solid var(--ant-color-border-secondary)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
padding: '6px 10px',
|
||||
boxShadow: '0 4px 14px rgba(0, 0, 0, 0.12)',
|
||||
}}
|
||||
labelStyle={{ color: 'var(--ant-color-text-tertiary)', marginBottom: 4, fontSize: 11 }}
|
||||
itemStyle={{ color: 'var(--ant-color-text)', padding: 0, fontWeight: 500 }}
|
||||
formatter={(v, name) => [fmtTooltip(Number(v) || 0), multiSeries && typeof name === 'string' ? name : '']}
|
||||
labelFormatter={(label) => (tooltipLabelFormatter ? tooltipLabelFormatter(String(label)) : String(label))}
|
||||
separator={multiSeries ? ': ' : ''}
|
||||
/>
|
||||
)}
|
||||
{referenceLines?.map((rl, idx) => (
|
||||
<ReferenceLine
|
||||
key={`ref-${idx}-${rl.y}`}
|
||||
y={rl.y}
|
||||
stroke={rl.color || stroke}
|
||||
strokeDasharray={rl.dash || '5 4'}
|
||||
strokeWidth={1.4}
|
||||
label={rl.label ? {
|
||||
value: rl.label,
|
||||
position: 'insideTopRight',
|
||||
fill: rl.color || stroke,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
} : undefined}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
))}
|
||||
{extremaPoints && (
|
||||
<>
|
||||
<ReferenceDot
|
||||
x={extremaPoints.max.label}
|
||||
y={extremaPoints.max.value}
|
||||
r={4.5}
|
||||
fill={maxColor}
|
||||
stroke="var(--ant-color-bg-elevated)"
|
||||
strokeWidth={2}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
<ReferenceDot
|
||||
x={extremaPoints.min.label}
|
||||
y={extremaPoints.min.value}
|
||||
r={4.5}
|
||||
fill={minColor}
|
||||
stroke="var(--ant-color-bg-elevated)"
|
||||
strokeWidth={2}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name={multiSeries ? name1 : undefined}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={`url(#${gradId})`}
|
||||
dot={false}
|
||||
activeDot={showMarker ? { r: markerRadius, fill: stroke, strokeWidth: 0 } : false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{hasSeries2 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value2"
|
||||
name={name2}
|
||||
stroke={stroke2}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={`url(#${gradId2})`}
|
||||
dot={false}
|
||||
activeDot={showMarker ? { r: markerRadius, fill: stroke2, strokeWidth: 0 } : false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{hasSeries3 && (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value3"
|
||||
name={name3}
|
||||
stroke={stroke3}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={`url(#${gradId3})`}
|
||||
dot={false}
|
||||
activeDot={showMarker ? { r: markerRadius, fill: stroke3, strokeWidth: 0 } : false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div ref={containerRef} className="sparkline-plot" style={{ height }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-50
@@ -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/<category>/<name>.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';
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user