From 5c6d1c61516a750358d068194dcaec3562e410a4 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 17 Jul 2026 16:07:42 +0100 Subject: [PATCH 1/2] feat: introduce dither-kit component library and integrate generative avatar system --- .../java/com/drmq/broker/raft/RaftNode.java | 31 -- drmq-dashboard/components.json | 21 + drmq-dashboard/dither-kit.json | 250 +++++++++ drmq-dashboard/package-lock.json | 212 +++++++- drmq-dashboard/package.json | 7 + drmq-dashboard/src/App.tsx | 32 +- .../src/components/ClusterTopology.tsx | 2 +- .../src/components/DashboardWidgets.tsx | 4 +- .../src/components/dither-kit/area-chart.tsx | 25 + .../src/components/dither-kit/area.tsx | 102 ++++ .../src/components/dither-kit/avatar.tsx | 210 ++++++++ .../src/components/dither-kit/bar-canvas.tsx | 225 ++++++++ .../src/components/dither-kit/bar-chart.tsx | 14 + .../src/components/dither-kit/bar.tsx | 83 +++ .../components/dither-kit/block-legend.tsx | 61 +++ .../src/components/dither-kit/button.tsx | 216 ++++++++ .../dither-kit/cartesian-canvas.tsx | 401 ++++++++++++++ .../components/dither-kit/cartesian-root.tsx | 190 +++++++ .../components/dither-kit/chart-context.tsx | 508 ++++++++++++++++++ .../components/dither-kit/common-context.tsx | 48 ++ .../src/components/dither-kit/dither-paint.ts | 177 ++++++ .../src/components/dither-kit/dot.tsx | 88 +++ .../src/components/dither-kit/gradient.tsx | 167 ++++++ .../src/components/dither-kit/grid.tsx | 46 ++ .../src/components/dither-kit/index.ts | 50 ++ .../src/components/dither-kit/legend.tsx | 69 +++ .../src/components/dither-kit/lib.ts | 8 + .../src/components/dither-kit/palette.ts | 40 ++ .../src/components/dither-kit/pie-canvas.tsx | 223 ++++++++ .../src/components/dither-kit/pie-chart.tsx | 33 ++ .../src/components/dither-kit/pie.tsx | 24 + .../src/components/dither-kit/pixel.ts | 109 ++++ .../components/dither-kit/polar-context.tsx | 382 +++++++++++++ .../src/components/dither-kit/polar-root.tsx | 173 ++++++ .../src/components/dither-kit/polar.ts | 122 +++++ .../components/dither-kit/radar-canvas.tsx | 237 ++++++++ .../src/components/dither-kit/radar-chart.tsx | 42 ++ .../src/components/dither-kit/radar-frame.tsx | 70 +++ .../src/components/dither-kit/radar.tsx | 32 ++ .../components/dither-kit/reference-line.tsx | 50 ++ .../src/components/dither-kit/scales.ts | 109 ++++ .../components/dither-kit/series-context.tsx | 21 + .../src/components/dither-kit/sparkline.tsx | 64 +++ .../src/components/dither-kit/tooltip.tsx | 106 ++++ .../dither-kit/use-chart-dimensions.ts | 37 ++ .../src/components/dither-kit/x-axis.tsx | 42 ++ .../src/components/dither-kit/y-axis.tsx | 33 ++ drmq-dashboard/src/index.css | 17 + drmq-dashboard/src/pages/Dashboard.tsx | 318 ++++++++--- .../telemetry/MockTelemetryProvider.ts | 6 + .../telemetry/WebSocketTelemetryProvider.ts | 29 +- drmq-dashboard/src/types/telemetry.ts | 2 + drmq-dashboard/src/useClusterTelemetry.ts | 35 +- drmq-dashboard/tsconfig.app.json | 7 +- drmq-dashboard/vite.config.ts | 6 + 55 files changed, 5480 insertions(+), 136 deletions(-) create mode 100644 drmq-dashboard/components.json create mode 100644 drmq-dashboard/dither-kit.json create mode 100644 drmq-dashboard/src/components/dither-kit/area-chart.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/area.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/avatar.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/bar-canvas.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/bar-chart.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/bar.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/block-legend.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/button.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/cartesian-canvas.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/cartesian-root.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/chart-context.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/common-context.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/dither-paint.ts create mode 100644 drmq-dashboard/src/components/dither-kit/dot.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/gradient.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/grid.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/index.ts create mode 100644 drmq-dashboard/src/components/dither-kit/legend.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/lib.ts create mode 100644 drmq-dashboard/src/components/dither-kit/palette.ts create mode 100644 drmq-dashboard/src/components/dither-kit/pie-canvas.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/pie-chart.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/pie.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/pixel.ts create mode 100644 drmq-dashboard/src/components/dither-kit/polar-context.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/polar-root.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/polar.ts create mode 100644 drmq-dashboard/src/components/dither-kit/radar-canvas.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/radar-chart.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/radar-frame.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/radar.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/reference-line.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/scales.ts create mode 100644 drmq-dashboard/src/components/dither-kit/series-context.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/sparkline.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/tooltip.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/use-chart-dimensions.ts create mode 100644 drmq-dashboard/src/components/dither-kit/x-axis.tsx create mode 100644 drmq-dashboard/src/components/dither-kit/y-axis.tsx diff --git a/drmq-broker/src/main/java/com/drmq/broker/raft/RaftNode.java b/drmq-broker/src/main/java/com/drmq/broker/raft/RaftNode.java index 459e476..d9329bc 100644 --- a/drmq-broker/src/main/java/com/drmq/broker/raft/RaftNode.java +++ b/drmq-broker/src/main/java/com/drmq/broker/raft/RaftNode.java @@ -952,14 +952,6 @@ public long propose(String topic, byte[] payload, String key, long timestamp) th lock.unlock(); } - if (peers.isEmpty()) { - lock.lock(); - try { - advanceCommitIndex(); - } finally { - lock.unlock(); - } - } sendHeartbeats(); try { return future.get(PROPOSAL_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -1028,15 +1020,6 @@ public long proposeBatch(String topic, List entr } finally { lock.unlock(); } - - if (peers.isEmpty()) { - lock.lock(); - try { - advanceCommitIndex(); - } finally { - lock.unlock(); - } - } sendHeartbeats(); try { return future.get(PROPOSAL_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -1103,14 +1086,6 @@ public long proposeOffsetCommit(String consumerGroup, String topic, long offset) lock.unlock(); } - if (peers.isEmpty()) { - lock.lock(); - try { - advanceCommitIndex(); - } finally { - lock.unlock(); - } - } sendHeartbeats(); try { @@ -1329,7 +1304,6 @@ public InstallSnapshotResponse handleInstallSnapshot(InstallSnapshotRequest requ leaderId = request.getLeaderId(); state = RaftState.FOLLOWER; - // Initialize or reset receive state for the first chunk or if index changes if (request.getOffset() == 0 || request.getLastIncludedIndex() != expectedSnapshotIndex) { if (snapshotReceiveStream != null) { try { snapshotReceiveStream.close(); } catch (Exception ignored) {} @@ -1343,19 +1317,16 @@ public InstallSnapshotResponse handleInstallSnapshot(InstallSnapshotRequest requ expectedSnapshotIndex = request.getLastIncludedIndex(); } - // Verify offset if (request.getOffset() != snapshotReceiveOffset) { throw new IllegalStateException("Expected chunk offset " + snapshotReceiveOffset + " but got " + request.getOffset()); } - // Append chunk data if (request.getData().size() > 0) { snapshotReceiveStream.write(request.getData().toByteArray()); snapshotReceiveOffset += request.getData().size(); } - // If final chunk, process hot-swap if (request.getDone()) { snapshotReceiveStream.close(); snapshotReceiveStream = null; @@ -1364,14 +1335,12 @@ public InstallSnapshotResponse handleInstallSnapshot(InstallSnapshotRequest requ if (snapshotIndex > lastApplied) { logger.info("[{}] Received full snapshot. Applying hot-swap up to index {}", nodeId, snapshotIndex); - // Restore snapshot and clear memory state snapshotManager.restoreSnapshot(snapshotTempFile); messageStore.reload(); if (offsetManager != null) { offsetManager.reload(); } - // Discard all local log entries up to the snapshot point if (raftLog.getLastIndex() > 0) { try { long compactUpTo = Math.min(snapshotIndex, raftLog.getLastIndex()); diff --git a/drmq-dashboard/components.json b/drmq-dashboard/components.json new file mode 100644 index 0000000..e065a23 --- /dev/null +++ b/drmq-dashboard/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/drmq-dashboard/dither-kit.json b/drmq-dashboard/dither-kit.json new file mode 100644 index 0000000..e4628bc --- /dev/null +++ b/drmq-dashboard/dither-kit.json @@ -0,0 +1,250 @@ +{ + "$schema": "https://tripwire.sh/schema/dither-kit.json", + "lockfileVersion": 1, + "registry": "https://tripwire.sh", + "mode": "source", + "components": { + "area-chart": { + "version": "0.1.0", + "hash": "sha256:197d088204a38580fec861579c89e8c5aff7fe158222ce9b7a1a4b0a605e415e", + "files": [ + { + "path": "components/dither-kit/area-chart.tsx", + "hash": "sha256:307883b537e005386a8090499eaf3ea636767a10235271181cb089c3477eab1e" + }, + { + "path": "components/dither-kit/area.tsx", + "hash": "sha256:ecfb990f9083dd4c813eb431e3674822b7272076294de4ae038e81552d678532" + }, + { + "path": "components/dither-kit/cartesian-canvas.tsx", + "hash": "sha256:180a1525cbe10ec35bc79f689bd018cfdf115ebb4e1e6b1ff31d4bd37ecc6f09" + }, + { + "path": "components/dither-kit/sparkline.tsx", + "hash": "sha256:b5dab0b68af7ebc976ec51a74776761ac26d79f4aacd9c453a6e79c49e8d11ff" + } + ] + }, + "avatar": { + "version": "0.1.0", + "hash": "sha256:c7a639c023dcfb9f1365f11de35986d265de494b60c1887807aedf6504acf0cd", + "files": [ + { + "path": "components/dither-kit/avatar.tsx", + "hash": "sha256:bf9736b474a5e050b19b23fb4d858924758d6df1d0a1006eed95f1fc42b84cb5" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/pixel.ts", + "hash": "sha256:eea1493d54a86d164b40d69c95956238777331ded7a556f099230dab161ab5a7" + } + ] + }, + "bar-chart": { + "version": "0.1.0", + "hash": "sha256:fb4a2e9bba84e3558e0e162de42eaf07a741557bfa43167c0e319cdfa75acd0d", + "files": [ + { + "path": "components/dither-kit/bar-canvas.tsx", + "hash": "sha256:80ed40b87eb3dbf3d9f768cf42b3b55f4f738db8b193c1a616f7abf525d83f87" + }, + { + "path": "components/dither-kit/bar-chart.tsx", + "hash": "sha256:e19ce3540f15756aba615eb19072aeec81d8ac71e51219691cc1d0be004c18bb" + }, + { + "path": "components/dither-kit/bar.tsx", + "hash": "sha256:2904ff1b0813ae20b976f5a78d57a314729ea3e890b18ba762bbb46d2f550057" + } + ] + }, + "button": { + "version": "0.1.0", + "hash": "sha256:d6439a837bfe9f4287bdf59279f4c3c4e7f37f2994b095464b4ed6ba423aae26", + "files": [ + { + "path": "components/dither-kit/button.tsx", + "hash": "sha256:fd2e5a37b581ce5d8c8adba291da7ccecce3ed79e331ec7a0dda25687eb170ad" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/pixel.ts", + "hash": "sha256:eea1493d54a86d164b40d69c95956238777331ded7a556f099230dab161ab5a7" + } + ] + }, + "core": { + "version": "0.1.0", + "hash": "sha256:c2d71baa1313a822cbef24de39414e21df7029fc80ad297ab8471d3d727235e9", + "files": [ + { + "path": "components/dither-kit/block-legend.tsx", + "hash": "sha256:4ddf215c6187ac0ca2640501c0342699bc210b33c1cec1d3e138ee3d38f77433" + }, + { + "path": "components/dither-kit/cartesian-root.tsx", + "hash": "sha256:6b5297d75de22ae5a0fc1960bef0419a4984b54ff493e7acd527aa0ccb150098" + }, + { + "path": "components/dither-kit/chart-context.tsx", + "hash": "sha256:955657f21fdbf7e04b80917be52b7c3839eb535b8d07f15053c06d9320683d8f" + }, + { + "path": "components/dither-kit/common-context.tsx", + "hash": "sha256:aafb26404977148144a3d81da81a678d5956e14024f394ccdc7ae881dd07e52a" + }, + { + "path": "components/dither-kit/dither-paint.ts", + "hash": "sha256:1bcd3d7de3ae3091caf63ed4c2c862d80d1899c9b55a70078acb12f7b44e51c7" + }, + { + "path": "components/dither-kit/dot.tsx", + "hash": "sha256:207bf90662237064940d14a4d8411135d75027b97a9582c0e61c8af74ea5d65a" + }, + { + "path": "components/dither-kit/grid.tsx", + "hash": "sha256:ad09d80b3898a4d3f588ff1f2f4f82984864c3918cb09d31350be6ac18d2446f" + }, + { + "path": "components/dither-kit/legend.tsx", + "hash": "sha256:2a067ebdc484c7bd9efb2afc4e8a0aa6718a14ec44ccd7bb94c871d192e501e6" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/polar-context.tsx", + "hash": "sha256:514ac9a4984b036598a14ecd7acc29ee121e9259c8a85309d7ed3c1e22af6831" + }, + { + "path": "components/dither-kit/polar-root.tsx", + "hash": "sha256:63cecb6193ce3cd296b93b018e2441ccdefcc055cdc31bf131c808f0cf0c0c52" + }, + { + "path": "components/dither-kit/polar.ts", + "hash": "sha256:3ad3f99bac2ee21e6580abd6903720af11cedcb8d8c69a06c40690be6229672c" + }, + { + "path": "components/dither-kit/reference-line.tsx", + "hash": "sha256:b0df8cf1144ab56fcce577bac4a39e06d9215a1fdf81bb112a4190a0445b138b" + }, + { + "path": "components/dither-kit/scales.ts", + "hash": "sha256:62ec74acdd3e335b959423a228b47edd70c14f07a468a385d039f5c6b66ebc2b" + }, + { + "path": "components/dither-kit/series-context.tsx", + "hash": "sha256:1802f1f488cc46542294f533c55fae03a4b067feca0761df75ce21de8a6a429a" + }, + { + "path": "components/dither-kit/tooltip.tsx", + "hash": "sha256:d053ac2135c6f29d011cb0a83be29a797f8954ccbb02916402d2d3c2a52b17f2" + }, + { + "path": "components/dither-kit/use-chart-dimensions.ts", + "hash": "sha256:74b7f2a08cce5b891348eed845fc8c218ed16fce362ef4f35587ec4cc864c834" + }, + { + "path": "components/dither-kit/x-axis.tsx", + "hash": "sha256:7997bd96c341816a0f1cc6f966ad386fedc78be39e51201fa368dab1fd943d62" + }, + { + "path": "components/dither-kit/y-axis.tsx", + "hash": "sha256:96d8cad3d7ca87a2a6f092399a60ba2f32612413a190f2885da67854229cdeff" + } + ] + }, + "dither-kit": { + "version": "0.1.0", + "hash": "sha256:e1f5eaa8d8349a15952b5e09584db171156dd0caef7eca76a5033bbbab70556e", + "files": [ + { + "path": "components/dither-kit/index.ts", + "hash": "sha256:139ea729bb0b21e79e1e85587361fbc178f245fc46ff2b79b7fe0475911546ca" + } + ] + }, + "gradient": { + "version": "0.1.0", + "hash": "sha256:25f54f9db394e0392fc661e86f8d087ec84450eaf7cf3759c88bc4c6e2d83b7f", + "files": [ + { + "path": "components/dither-kit/gradient.tsx", + "hash": "sha256:c5cfeaff9b5cfe88cf963109c679dc8202d2543d277e11c346f8d02c2a67dfcd" + }, + { + "path": "components/dither-kit/lib.ts", + "hash": "sha256:41493601eb53a70f7629f6e50bad233f7185eaad42ba2397ca56ac1afcf1bb5f" + }, + { + "path": "components/dither-kit/palette.ts", + "hash": "sha256:fd020c230ad818a5d9d6dfdfcb6ef50b0dba012c77f44f1a61449fc9a65947f7" + }, + { + "path": "components/dither-kit/pixel.ts", + "hash": "sha256:eea1493d54a86d164b40d69c95956238777331ded7a556f099230dab161ab5a7" + } + ] + }, + "pie-chart": { + "version": "0.1.0", + "hash": "sha256:2c08ce4e2ebe3b522541b38f3e37d2b124ab7692c6501225a3558dbf3ea964ca", + "files": [ + { + "path": "components/dither-kit/pie-canvas.tsx", + "hash": "sha256:bcf6f1d8024448b55a3a198afad0ee5db002b2748c5282bb27679422c6a6c2bf" + }, + { + "path": "components/dither-kit/pie-chart.tsx", + "hash": "sha256:2dae2f8e6193e0bca31164ba7d7b829cc5295533cdb207783d6bdb4e346d2c13" + }, + { + "path": "components/dither-kit/pie.tsx", + "hash": "sha256:1e44525a3119913b042b660be50c9c2c0e34a265ee8ed0aa32c65ce4ce9fd655" + } + ] + }, + "radar-chart": { + "version": "0.1.0", + "hash": "sha256:78c9b966e31f80224947f417a7b7f29cc2e367bad865b97c3b5e18fa1f053b0f", + "files": [ + { + "path": "components/dither-kit/radar-canvas.tsx", + "hash": "sha256:be08e9d801c1dc866a3d8eb74fe70208c888a1f6857b584e5f9de631863535ae" + }, + { + "path": "components/dither-kit/radar-chart.tsx", + "hash": "sha256:b62686351465de7d14cf4d919938e005691e8363d45ac8e625cb863a4ad421db" + }, + { + "path": "components/dither-kit/radar-frame.tsx", + "hash": "sha256:a516ed03eb4f5e864532df1f49f265498f1d2c89a676eb93251fad73a6914a67" + }, + { + "path": "components/dither-kit/radar.tsx", + "hash": "sha256:f55dae75073cef3eab55ede93a5fa394711a9c5736046236ebd11623d02849e9" + } + ] + } + } +} diff --git a/drmq-dashboard/package-lock.json b/drmq-dashboard/package-lock.json index 0a9986a..91fa0a9 100644 --- a/drmq-dashboard/package-lock.json +++ b/drmq-dashboard/package-lock.json @@ -10,16 +10,23 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@types/react-syntax-highlighter": "^15.5.13", + "clsx": "^2.1.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "framer-motion": "^12.40.0", "lucide-react": "^1.17.0", + "motion": "^12.42.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.18.0", "react-syntax-highlighter": "^16.1.1", + "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1111,6 +1118,40 @@ "tslib": "^2.4.0" } }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1621,6 +1662,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1672,6 +1722,109 @@ "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-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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2056,12 +2209,12 @@ } }, "node_modules/framer-motion": { - "version": "12.40.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", - "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.40.0", + "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -2221,6 +2374,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -2705,10 +2867,36 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.42.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { - "version": "12.40.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", - "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -3139,6 +3327,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", diff --git a/drmq-dashboard/package.json b/drmq-dashboard/package.json index 6dfaef4..18da704 100644 --- a/drmq-dashboard/package.json +++ b/drmq-dashboard/package.json @@ -12,16 +12,23 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@types/react-syntax-highlighter": "^15.5.13", + "clsx": "^2.1.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", "framer-motion": "^12.40.0", "lucide-react": "^1.17.0", + "motion": "^12.42.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.18.0", "react-syntax-highlighter": "^16.1.1", + "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.8", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/drmq-dashboard/src/App.tsx b/drmq-dashboard/src/App.tsx index 037fac3..d06bd70 100644 --- a/drmq-dashboard/src/App.tsx +++ b/drmq-dashboard/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom'; import { LayoutDashboard, Book, Server, ChevronRight } from 'lucide-react'; +import { useState, useCallback } from 'react'; import Dashboard from './pages/Dashboard'; import { useClusterTelemetry } from './useClusterTelemetry'; @@ -91,15 +92,40 @@ function Sidebar({ telemetryState }: { telemetryState: any }) { } export default function App() { - const { data: telemetryState, error: telemetryError } = useClusterTelemetry(); + const [paused, setPaused] = useState(false); + const { data: telemetryState, error: telemetryError, provider } = useClusterTelemetry(); + + const handleTogglePause = useCallback(() => { + setPaused(prev => { + const next = !prev; + if (next) provider.disconnect(); + else provider.reconnect(); + return next; + }); + }, [provider]); + return (
- } /> - Documentation is available at drmq.vercel.app
} /> + + } /> + + } /> diff --git a/drmq-dashboard/src/components/ClusterTopology.tsx b/drmq-dashboard/src/components/ClusterTopology.tsx index c50c7e9..abd4810 100644 --- a/drmq-dashboard/src/components/ClusterTopology.tsx +++ b/drmq-dashboard/src/components/ClusterTopology.tsx @@ -336,7 +336,7 @@ export default function ClusterTopology({ nodes, metrics, latencies }: any) { const latencyValues = [latencies.alphaBeta, latencies.betaGamma, latencies.raftRpcMs]; return ( -
{/* Corner labels */} diff --git a/drmq-dashboard/src/components/DashboardWidgets.tsx b/drmq-dashboard/src/components/DashboardWidgets.tsx index 7ba6774..670aeee 100644 --- a/drmq-dashboard/src/components/DashboardWidgets.tsx +++ b/drmq-dashboard/src/components/DashboardWidgets.tsx @@ -8,10 +8,11 @@ export interface StatCardProps { unit?: string; color?: string; sub?: ReactNode; + spark?: ReactNode; } /* ── Stat Card ───────────────────────────────────────────────────────── */ -export function StatCard({ icon: Icon, label, value, unit, color = '#06b6d4', sub }: StatCardProps) { +export function StatCard({ icon: Icon, label, value, unit, color = '#06b6d4', sub, spark }: StatCardProps) { return (
@@ -26,6 +27,7 @@ export function StatCard({ icon: Icon, label, value, unit, color = '#06b6d4', su {unit && {unit}}
{sub &&
{sub}
} + {spark &&
{spark}
}
); } diff --git a/drmq-dashboard/src/components/dither-kit/area-chart.tsx b/drmq-dashboard/src/components/dither-kit/area-chart.tsx new file mode 100644 index 0000000..a8c6220 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/area-chart.tsx @@ -0,0 +1,25 @@ +"use client" + +import { CartesianCanvas } from "./cartesian-canvas" +import { type CartesianChartProps, CartesianRoot } from "./cartesian-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +/** Composable dither **area** chart. Compose ``, ``, axes, … inside. */ +export function AreaChart( + props: CartesianChartProps +) { + return +} + +/** Composable dither **line** chart — `` series with a glow under the line. */ +export function LineChart( + props: CartesianChartProps +) { + return +} + +export type AreaChartProps = CartesianChartProps diff --git a/drmq-dashboard/src/components/dither-kit/area.tsx b/drmq-dashboard/src/components/dither-kit/area.tsx new file mode 100644 index 0000000..7fa4762 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/area.tsx @@ -0,0 +1,102 @@ +"use client" + +import { type ReactNode, useEffect } from "react" +import { + type AreaVariant, + type SeriesKind, + type StrokeVariant, + useChartPart, +} from "./chart-context" +import { SeriesContext } from "./series-context" + +export type SeriesProps = { + dataKey: string + variant?: AreaVariant + strokeVariant?: StrokeVariant + isClickable?: boolean + children?: ReactNode +} + +/** + * Shared implementation for the continuous series (``, ``). The + * dithered fill/line is painted on the canvas; this registers the series so the + * canvas knows how to draw it, wires click-to-select via a transparent band + * polygon, and exposes the series to child ``/`` markers. + */ +function CartesianSeries({ + part, + kind, + dataKey, + variant = "gradient", + strokeVariant = "solid", + isClickable = false, + children, +}: SeriesProps & { part: string; kind: SeriesKind }) { + const ctx = useChartPart(part, kind === "line" ? "line" : "area") + const { registerSeries, unregisterSeries } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `<${part} dataKey="${dataKey}" />: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerSeries({ dataKey, kind, variant, strokeVariant }) + return () => unregisterSeries(dataKey) + }, [dataKey, kind, variant, strokeVariant, registerSeries, unregisterSeries]) + + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + + const seed = ctx.seedOf(dataKey) + const emphasis = ctx.selectedDataKey ?? ctx.focusDataKey + const dimmed = emphasis !== null && emphasis !== dataKey + const onClick = isClickable + ? () => ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey) + : undefined + + // Transparent hit polygon tracing the series' own band, so clicking a series + // selects *that* series. The Legend offers the same toggle accessibly. + // One pass out along the top edge, one pass back along the floor. + let hitPath: string | null = null + if (isClickable) { + const parts: string[] = [] + band.forEach((b, i) => { + parts.push(`${i === 0 ? "M" : "L"}${ctx.xCenter(i)},${ctx.y(b[1])}`) + }) + for (let i = band.length - 1; i >= 0; i -= 1) { + parts.push(`L${ctx.xCenter(i)},${ctx.y(band[i][0])}`) + } + hitPath = `${parts.join(" ")} Z` + } + + return ( + <> + {hitPath && ( + // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly + + )} + + {children} + + + ) +} + +export type AreaProps = SeriesProps + +/** One area series — dithered fill from the value line down to its floor. */ +export function Area(props: AreaProps) { + return +} + +/** One line series — bright line with a thin dither glow hugging it. */ +export function Line(props: AreaProps) { + return +} diff --git a/drmq-dashboard/src/components/dither-kit/avatar.tsx b/drmq-dashboard/src/components/dither-kit/avatar.tsx new file mode 100644 index 0000000..333e5be --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/avatar.tsx @@ -0,0 +1,210 @@ +import { useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + clamp01, + fnv1a, + hueFill, + type PixelBloom, + pixelBloomStyle, + pixelPrefersReducedMotion, + xorshift32, +} from "./pixel" + +// 8×8 cells, mirrored across one axis → 32 free pattern bits. With the mirror +// axis bit and 180 hues that's 2^33 × 180 ≈ 1.5 trillion distinct avatars. +const GRID = 8 +const CELL_PX = 4 // backing px per cell → a 32×32 canvas, scaled up pixelated + +export type AvatarMirror = "auto" | "horizontal" | "vertical" + +export type DitherAvatarProps = { + /** The seed — same name, same avatar, every time. */ + name: string + /** Hue override (0–360). Derived from the name when omitted. */ + hue?: number + /** Mirror axis. "auto" picks one from the name — half the avatars fold + * left/right, half fold top/bottom. */ + mirror?: AvatarMirror + /** Square size in px. Omit to size via className (e.g. `size-12`). */ + size?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + /** Play the Bayer-ordered materialize entrance. */ + animate?: boolean + animationDuration?: number + /** Bump to replay the entrance. */ + replayToken?: number + className?: string +} + +type AvatarModel = { + on: boolean[] // GRID×GRID, row-major + density: number[] // per-cell dither density for on cells + fill: [number, number, number] +} + +/** + * Derive the full 8×8 cell grid from the name: 32 pattern bits + the mirror + * axis + the hue + per-cell densities, all from one deterministic PRNG stream. + * Every draw happens unconditionally so overriding `hue` or `mirror` never + * shifts the pattern. + */ +function avatarModel( + name: string, + hueProp: number | undefined, + mirrorProp: AvatarMirror +): AvatarModel { + const rand = xorshift32(fnv1a(name)) + const bits = Array.from({ length: 32 }, () => rand() < 0.5) + const drawnVertical = rand() < 0.5 + const drawnHue = Math.floor(rand() * 180) * 2 + const halfDensity = Array.from({ length: 32 }, () => 0.55 + rand() * 0.45) + + const vertical = + mirrorProp === "auto" ? drawnVertical : mirrorProp === "vertical" + const hue = hueProp ?? drawnHue + + const on = new Array(GRID * GRID) + const density = new Array(GRID * GRID) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + // Fold across the chosen axis: left/right symmetric ("horizontal" + // mirror) or top/bottom symmetric ("vertical"). + const i = vertical + ? Math.min(r, GRID - 1 - r) * GRID + c + : r * (GRID / 2) + Math.min(c, GRID - 1 - c) + on[r * GRID + c] = bits[i] + density[r * GRID + c] = halfDensity[i] + } + } + return { on, density, fill: hueFill(hue) } +} + +/** + * Paint the avatar, optionally sweeping cells in with the Bayer-ordered + * materialize entrance. Lives outside the component (same shape as the chart + * canvases). Returns a cleanup that cancels the entrance loop. + */ +function paintAvatar( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + model: AvatarModel, + animate: boolean, + duration: number +): (() => void) | undefined { + const ctx = canvas.getContext("2d") + if (!ctx) return undefined + const px = GRID * CELL_PX + canvas.width = px + canvas.height = px + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = px + bloomCanvas.height = px + } + + const draw = (progress: number) => { + ctx.clearRect(0, 0, px, px) + for (let r = 0; r < GRID; r++) { + for (let c = 0; c < GRID; c++) { + if (!model.on[r * GRID + c]) continue + // Cells materialize in Bayer order — the entrance is made of the same + // matrix as the texture. + const start = BAYER4[r % 4][c % 4] * 0.7 + const cellAlpha = clamp01((progress - start) / 0.3) + if (cellAlpha <= 0) continue + const density = model.density[r * GRID + c] + const base = 0.35 + 0.65 * density + for (let py = 0; py < CELL_PX; py++) { + for (let pxi = 0; pxi < CELL_PX; pxi++) { + const gx = c * CELL_PX + pxi + const gy = r * CELL_PX + py + const lit = density > BAYER4[gy & 3][gx & 3] + // On/off cells modulate alpha tiers of the one fill colour, so the + // avatar holds up on light and dark backgrounds alike. + const alpha = (lit ? base : base * 0.35) * cellAlpha + ctx.fillStyle = rgb(model.fill, 1, alpha) + ctx.fillRect(gx, gy, 1, 1) + } + } + } + } + if (bloomCtx) { + bloomCtx.clearRect(0, 0, px, px) + bloomCtx.drawImage(canvas, 0, 0) + } + } + + if (!animate || pixelPrefersReducedMotion()) { + draw(1) + return undefined + } + + let raf = 0 + const startTime = performance.now() + const tick = (now: number) => { + const t = clamp01((now - startTime) / duration) + draw(1 - (1 - t) ** 3) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) +} + +/** + * Generative dithered avatar — a mirrored 8×8 pixel glyph derived from a name, + * rendered with the ordered-dither texture the charts are made of. Same name, + * same avatar; ~1.5 trillion combinations across pattern, mirror axis, and hue. + */ +export function DitherAvatar({ + name, + hue, + mirror = "auto", + size, + bloom = "off", + animate = true, + animationDuration = 600, + replayToken = 0, + className, +}: DitherAvatarProps) { + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + return paintAvatar( + canvas, + bloomRef.current, + avatarModel(name, hue, mirror), + animate, + animationDuration + ) + }, [name, hue, mirror, animate, animationDuration, replayToken, bloom]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( +
+ + {bloomStyle && ( + + )} +
+ ) +} diff --git a/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx b/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx new file mode 100644 index 0000000..62a3b3c --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useRef } from "react" +import { useChart } from "./chart-context" +import { + backingSize, + bloomLayerStyle, + clamp01, + easeOutCubic, + paintColumn, + prefersReducedMotion, +} from "./dither-paint" + +type Bars = { top: number[]; base: number[] } // per data index, in backing rows + +// Fraction of the timeline spent staggering bar starts — the rest is each bar's +// own grow window, so the rise sweeps across the chart as a wave. +const STAGGER = 0.55 + +/** + * Dither canvas for bar charts. Each category owns a band; grouped series split + * it into side-by-side bars, stacked series share its full width and pile in y. + * Every bar is filled with the shared {@link paintColumn} ordered dither. Bars + * grow up from their base in a staggered left-to-right wave (eased), and the + * hovered category lifts while the rest dim. + */ +export function BarCanvas() { + const ctx = useChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + const { ready, configKeys, bands, y } = ctx + + // Memoized: per-series bar tops/bases (backing rows) over the data indices. + // The canvas re-renders on every hover/cursor tick, so pin this map to the + // exact ctx fields it reads plus the backing geometry — a bar hover must not + // rebuild every band's geometry. + const targets = useMemo(() => { + const out: Record = {} + if (!ready) return out + const h = height || 1 + for (const key of configKeys) { + const band = bands[key] + if (!band) continue + out[key] = { + top: band.map((b) => (y(b[1]) / h) * (rows - 1)), + base: band.map((b) => (y(b[0]) / h) * (rows - 1)), + } + } + return out + }, [ready, configKeys, bands, y, height, rows]) + + // The RAF loop reads these through refs so it always sees the latest values; + // refs are written in an effect (never during render) — mutating a ref + // mid-render tears under Strict Mode / concurrent rendering. + const state = useRef(ctx) + const targetsRef = useRef(targets) + useEffect(() => { + state.current = ctx + targetsRef.current = targets + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + const fx = cols / Math.max(width, 1) + + // Eased grow factor for bar `i` at global progress `prog`. + const barProgress = (i: number, len: number, prog: number) => { + if (!animate) return 1 + const start = len > 1 ? (i / (len - 1)) * STAGGER : 0 + return easeOutCubic(clamp01((prog - start) / (1 - STAGGER))) + } + + const paint = (prog: number) => { + const s = state.current + c.clearRect(0, 0, cols, rows) + const stacked = s.stackType === "stacked" || s.stackType === "percent" + const keys = s.configKeys + keys.forEach((key, si) => { + const t = targetsRef.current[key] + if (!t) return + const seed = s.seedOf(key) + const variant = s.seriesSpecs[key]?.variant ?? "gradient" + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + for (let i = 0; i < s.dataLength; i++) { + const bp = barProgress(i, s.dataLength, prog) + const base = t.base[i] ?? rows - 1 + const grown = base + ((t.top[i] ?? base) - base) * bp + // Bars grow from the zero baseline toward the value. Positive values + // sit above the baseline (smaller pixel), negative ones below it — + // paintColumn wants the higher edge first, so order the pair. + const top = Math.min(grown, base) + const bottom = Math.max(grown, base) + const active = s.hoverIndex === i + const hoverDim = + s.hoverIndex != null && !active && s.isMouseInChart ? 0.5 : 1 + const slot = s.barSlot(i, si, keys.length) + const c0 = Math.round(slot.x * fx) + const c1 = Math.round((slot.x + slot.width) * fx) + for (let x = c0; x < c1; x++) { + paintColumn(c, x, top, bottom, seed, { + variant, + intensity: intensity + (active ? 0.4 : 0), + dim: selDim * hoverDim, + stacked, + }) + } + } + }) + } + + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready) return + if (bloomCtx) { + const on = + s.bloom !== "off" && + (!s.bloomOnHover || s.isMouseInChart || s.hovered) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 // re-play the wave on data change / replay + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + needsFill = true + } + const itTarget = s.isMouseInChart || s.hovered ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + + // Live tweak repaint (variant, stacking) without replaying the wave. + const paintSig = `${s.stackType}|${s.configKeys + .map((k) => s.seriesSpecs[k]?.variant ?? "") + .join(",")}` + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width]) + + const bloomActive = ctx.bloomOnHover + ? ctx.isMouseInChart || ctx.hovered + : true + const bloom = bloomLayerStyle(ctx.bloom, bloomActive) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/bar-chart.tsx b/drmq-dashboard/src/components/dither-kit/bar-chart.tsx new file mode 100644 index 0000000..423e3f5 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/bar-chart.tsx @@ -0,0 +1,14 @@ +"use client" + +import { BarCanvas } from "./bar-canvas" +import { type CartesianChartProps, CartesianRoot } from "./cartesian-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +/** Composable dither **bar** chart — `` series, grouped or stacked. */ +export function BarChart(props: CartesianChartProps) { + return +} diff --git a/drmq-dashboard/src/components/dither-kit/bar.tsx b/drmq-dashboard/src/components/dither-kit/bar.tsx new file mode 100644 index 0000000..15c8217 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/bar.tsx @@ -0,0 +1,83 @@ +"use client" + +import { type ReactNode, useEffect } from "react" +import { + type AreaVariant, + type StrokeVariant, + useChartPart, +} from "./chart-context" +import { SeriesContext } from "./series-context" + +export type BarProps = { + dataKey: string + variant?: AreaVariant + strokeVariant?: StrokeVariant + isClickable?: boolean + children?: ReactNode +} + +/** + * One bar series. The dithered bars are painted on the canvas; this registers + * the series and (when `isClickable`) lays transparent hit rects over each bar + * — using the shared `barSlot` geometry so clicks line up with the pixels — to + * select the series. The Legend offers the same toggle accessibly. + */ +export function Bar({ + dataKey, + variant = "gradient", + strokeVariant = "solid", + isClickable = false, + children, +}: BarProps) { + const ctx = useChartPart("Bar", "bar") + const { registerSeries, unregisterSeries } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerSeries({ dataKey, kind: "bar", variant, strokeVariant }) + return () => unregisterSeries(dataKey) + }, [dataKey, variant, strokeVariant, registerSeries, unregisterSeries]) + + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + + const seed = ctx.seedOf(dataKey) + const dimmed = ctx.selectedDataKey !== null && ctx.selectedDataKey !== dataKey + const si = ctx.configKeys.indexOf(dataKey) + const n = ctx.configKeys.length + const onClick = () => + ctx.selectDataKey(ctx.selectedDataKey === dataKey ? null : dataKey) + + return ( + <> + {isClickable && + band.map((b, i) => { + const slot = ctx.barSlot(i, si, n) + const top = ctx.y(b[1]) + const base = ctx.y(b[0]) + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: progressive enhancement; the Legend offers the same toggle accessibly + + ) + })} + + {children} + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/block-legend.tsx b/drmq-dashboard/src/components/dither-kit/block-legend.tsx new file mode 100644 index 0000000..199ab96 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/block-legend.tsx @@ -0,0 +1,61 @@ +import type { ChartConfig } from "./chart-context" +import { cn } from "./lib" +import { rgb, seedOfColor } from "./palette" + +/** + * An in-flow legend rendered as a sibling of the chart rather than an overlay. + * + * The overlay {@link Legend} is pinned absolutely to the top of the plot, so + * with more than ~3 entries (or a narrow container) its wrapped rows sit on top + * of the chart. `` lives in normal document flow, so it can never + * overlap the plot at any width — use it for multi-entry charts (donuts, many + * series) and reserve the overlay `` for ≤2–3 entries. + * + * It needs no chart context: feed it the same `config` you pass the chart, and + * optionally a `values` map to show a number beside each entry (e.g. allocation + * shares or totals). + */ +export function BlockLegend({ + config, + values, + valueFormatter = (v) => String(v), + align = "start", + className, +}: { + config: ChartConfig + values?: Record + valueFormatter?: (value: number) => string + align?: "start" | "center" | "end" + className?: string +}) { + return ( +
    + {Object.entries(config).map(([name, entry]) => { + const seed = seedOfColor(entry.color) + const value = values?.[name] + return ( +
  • + + {entry.label ?? name} + {value !== undefined ? ( + {valueFormatter(value)} + ) : null} +
  • + ) + })} +
+ ) +} diff --git a/drmq-dashboard/src/components/dither-kit/button.tsx b/drmq-dashboard/src/components/dither-kit/button.tsx new file mode 100644 index 0000000..8ed52a7 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/button.tsx @@ -0,0 +1,216 @@ +"use client" + +import { type ComponentProps, useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + clamp01, + fillOf, + type PixelBloom, + type PixelColor, + pixelBloomStyle, + pixelPrefersReducedMotion, +} from "./pixel" + +const CELL = 2 // css px per dither cell — same chunk as the charts + +export type ButtonVariant = "gradient" | "dotted" | "hatched" | "solid" + +export type DitherButtonProps = ComponentProps<"button"> & { + /** Fill colour — a palette name or a hue (0–360). */ + color?: PixelColor + /** Fill texture — the same four variants the charts use. */ + variant?: ButtonVariant + /** Glow on the dither fill. */ + bloom?: PixelBloom +} + +type PaintState = { + fill: [number, number, number] + variant: ButtonVariant +} + +/** + * Paint one frame of the button fill: the ordered-dither texture (dense at the + * bottom, dissolving upward for "gradient") capped by a soft border outline, + * with `intensity` (0 rest, 1 hover, ~1.5 pressed) lifting density and alpha — + * the same lift the charts do on hover. + */ +function paintButton( + ctx: CanvasRenderingContext2D, + bloomCtx: CanvasRenderingContext2D | null, + cols: number, + rows: number, + { fill, variant }: PaintState, + intensity: number +): void { + ctx.clearRect(0, 0, cols, rows) + const bias = variant === "dotted" ? 0.12 : 0 + for (let y = 0; y < rows; y++) { + const density = + variant === "gradient" + ? 0.25 + 0.75 * ((y + 0.5) / rows) + : variant === "dotted" + ? 0.5 + : 0.75 + for (let x = 0; x < cols; x++) { + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > BAYER4[y & 3][x & 3] - 0.1 * intensity - bias + if (variant === "dotted" && !lit) continue + const k = (0.3 + density * 0.7) * (1 + 0.22 * intensity) + ctx.fillStyle = rgb(fill, 1, clamp01(lit ? k : k * 0.4)) + ctx.fillRect(x, y, 1, 1) + } + } + // Soft outline in the fill colour, brightening a touch on hover. + ctx.fillStyle = rgb(fill, 1, clamp01(0.5 + 0.25 * intensity)) + ctx.fillRect(0, 0, cols, 1) + ctx.fillRect(0, rows - 1, cols, 1) + ctx.fillRect(0, 0, 1, rows) + ctx.fillRect(cols - 1, 0, 1, rows) + if (bloomCtx) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(ctx.canvas, 0, 0) + } +} + +/** + * Dithered button — a native ` + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/cartesian-canvas.tsx b/drmq-dashboard/src/components/dither-kit/cartesian-canvas.tsx new file mode 100644 index 0000000..938e66b --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/cartesian-canvas.tsx @@ -0,0 +1,401 @@ +import { type RefObject, useEffect, useMemo, useRef } from "react" +import { type ChartContextValue, useChart } from "./chart-context" +import { + backingSize, + bloomLayerStyle, + easeInOutCubic, + paintColumn, + prefersReducedMotion, + resample, +} from "./dither-paint" +import { rgb } from "./palette" + +type Star = { key: string; xi: number; depth: number; phase: number } +type Surface = { top: number[]; floor: number[] } + +type LoopArgs = { + canvas: HTMLCanvasElement + bloomCanvas: HTMLCanvasElement | null + cols: number + rows: number + state: RefObject + targets: RefObject> + stars: RefObject +} + +/** + * The requestAnimationFrame paint loop — eases each series toward its target + * surface, paints the dither fill (with the entrance reveal), then layers the + * crosshair marker and winking stars on top. Lives outside the component so the + * component stays small and this hot closure isn't re-created on every render. + * Returns a cleanup that cancels the loop. + */ +function startCartesianLoop({ + canvas, + bloomCanvas, + cols, + rows, + state, + targets, + stars, +}: LoopArgs): (() => void) | undefined { + const c = canvas.getContext("2d") + if (!c || cols <= 0 || rows <= 0) return undefined + canvas.width = cols + canvas.height = rows + + const off = document.createElement("canvas") + off.width = cols + off.height = rows + const octx = off.getContext("2d") + if (!octx) return undefined + + // Bloom layer: a blurred, additive copy of the crisp canvas. + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const EASE = reduce ? 1 : 0.18 + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + const current: Record = {} + + // `reveal` (0–1) sweeps the fill in left-to-right on first paint. + const paintFill = (intensity: number, reveal: number) => { + octx.clearRect(0, 0, cols, rows) + const s = state.current + const stacked = s.stackType === "stacked" || s.stackType === "percent" + const revealCols = Math.ceil(reveal * cols) + s.configKeys.forEach((key, si) => { + const cur = current[key] + if (!cur) return + const seed = s.seedOf(key) + const variant = s.seriesSpecs[key]?.variant ?? "gradient" + const isLine = + (s.seriesSpecs[key]?.kind ?? + (s.chartType === "line" ? "line" : "area")) === "line" + const emphasis = s.selectedDataKey ?? s.focusDataKey + const dim = emphasis !== null && emphasis !== key ? 0.3 : 1 + // Overlapping (non-stacked) layers thin out front-to-back so they + // read as distinct layers instead of a muddy blend. + const sparse = stacked ? 0 : si * 0.14 + for (let x = 0; x < cols; x++) { + if (x > revealCols) break + // For a value that dips below the zero baseline the value line ends up + // *below* the floor in pixels; paintColumn needs the higher edge first, + // so order the pair (a no-op for the common positive case). + const a = cur.top[x] ?? 0 + const b = cur.floor[x] ?? 0 + paintColumn(octx, x, Math.min(a, b), Math.max(a, b), seed, { + variant, + intensity, + dim, + stacked: stacked && !isLine, + sparse, + }) + } + }) + } + + let raf = 0 + let tick = 0 + let last = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let entranceReported = !animate + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready) return + // Keep the bloom layer in sync with the crisp canvas while it's active. + if (bloomCtx) { + const on = + s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart || s.hovered) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + const tgt = targets.current + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 // re-play the entrance on data change / replay + lastProg = -1 + entranceReported = false + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + const progChanged = prog !== lastProg + // Tell the context the reveal is done so DOM markers fade in in sync. + if (prog >= 1 && !entranceReported) { + entranceReported = true + s.markEntranceDone() + } + + let moving = false + for (const key of s.configKeys) { + const t = tgt[key] + if (!t) continue + const cur = current[key] + if (!cur || cur.top.length !== cols) { + current[key] = { top: t.top.slice(), floor: t.floor.slice() } + needsFill = true + continue + } + for (let x = 0; x < cols; x++) { + const dt = t.top[x] - cur.top[x] + const df = t.floor[x] - cur.floor[x] + if (Math.abs(dt) > 0.01 || Math.abs(df) > 0.01) { + cur.top[x] += dt * EASE + cur.floor[x] += df * EASE + moving = true + } else { + cur.top[x] = t.top[x] + cur.floor[x] = t.floor[x] + } + } + } + for (const key of Object.keys(current)) { + if (!tgt[key]) { + delete current[key] + needsFill = true + } + } + if (moving) needsFill = true + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + + const itTarget = s.isMouseInChart || s.hovered ? 1 : 0 + let settling = false + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * 0.16 + settling = true + needsFill = true + } else intensity = itTarget + + // Live hover wins; the controlled markerIndex (e.g. a committed point) + // is the fallback shown when nothing is hovered. + const marker = s.hoverIndex != null ? s.hoverIndex : s.markerIndex + const winkDue = !reduce && now - last >= 100 + // Repaint when a tweak-driven paint input changes (variant, stacking) so + // the panel updates the fill live — without resetting the entrance reveal. + const paintSig = `${s.stackType}|${s.configKeys + .map((k) => s.seriesSpecs[k]?.variant ?? "") + .join(",")}` + const sigChanged = paintSig !== lastPaintSig + if (sigChanged) { + lastPaintSig = paintSig + needsFill = true + } + if ( + !( + moving || + settling || + winkDue || + marker != null || + progChanged || + sigChanged + ) + ) + return + if (progChanged) { + lastProg = prog + needsFill = true + } + if (winkDue) { + last = now + tick += 1 + } + + // Reveal front (left-to-right) — stars + crosshair stay behind it so + // they don't float over the not-yet-drawn area during the entrance. + const reveal = animate ? easeInOutCubic(prog) : 1 + const revealCols = reveal * cols + + if (needsFill) { + paintFill(intensity, reveal) + needsFill = false + } + c.clearRect(0, 0, cols, rows) + c.drawImage(off, 0, 0) + + const mx = + marker != null && s.dataLength > 1 + ? Math.round((marker / (s.dataLength - 1)) * (cols - 1)) + : -1 + if (mx >= 0 && mx <= revealCols) { + for (const key of s.configKeys) { + const cur = current[key] + if (!cur) continue + const seed = s.seedOf(key) + const my = Math.round(cur.top[mx] ?? 0) + // Full-height column + a chunky marker block at the point — the + // series colour at higher opacity, so it reads on either theme. + c.fillStyle = rgb(seed.fill, 1, 0.55) + for (let y = my; y < rows; y++) c.fillRect(mx, y, 1, 1) + c.fillStyle = rgb(seed.fill) + c.fillRect(mx - 1, my - 1, 3, 3) + } + } + + for (const star of stars.current) { + const cur = current[star.key] + if (!cur) continue + const sx = Math.round( + (star.xi / Math.max(s.dataLength - 1, 1)) * (cols - 1) + ) + if (sx > revealCols) continue // behind the reveal front + const top = cur.top[sx] ?? 0 + const floor = cur.floor[sx] ?? rows - 1 + const sy = Math.round(top + star.depth * (floor - top)) + const tw = reduce ? 0.85 : (Math.sin((tick + star.phase) * 0.35) + 1) / 2 + const lift = tw * (0.7 + 0.3 * intensity) + if (lift < 0.55 || sy < 0 || sy >= rows) continue + // Sparkles glint in the series colour via opacity (the `lift` wink) + // rather than a lighter shade — so they never read as stray white + // pixels on a light background. + const starColor = s.seedOf(star.key).fill + c.fillStyle = rgb(starColor, 1, lift) + c.fillRect(sx, sy, 1, 1) + // At the peak of a wink the star flares into a 4-point glint. + if (tw > 0.9) { + c.fillStyle = rgb(starColor, 1, lift * 0.6 * (tw - 0.9) * 10) + c.fillRect(sx - 1, sy, 1, 1) + c.fillRect(sx + 1, sy, 1, 1) + c.fillRect(sx, sy - 1, 1, 1) + c.fillRect(sx, sy + 1, 1, 1) + } + } + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) +} + +/** + * Continuous dither canvas for area and line charts. Each series is reduced to a + * `[top, floor]` band per backing column: areas fill from their value line down + * to their floor; lines fill only a thin glow band hugging the line. The shared + * {@link paintColumn} renders the ordered-dither scatter, capped by the bright + * series line, with winking stars + scrub crosshair on top. + */ +export function CartesianCanvas() { + const ctx = useChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + const { ready, chartType, configKeys, bands, seriesSpecs, y, dataLength } = ctx + + // Memoized: the pricey bit in the render path — a `resample` per series to + // the backing column count. The canvas re-renders on every hover/cursor tick + // (it consumes ctx), so without this the whole surface is rebuilt each time. + // Pinned to the exact ctx fields it reads, plus the backing geometry. + const targets = useMemo(() => { + const out: Record = {} + if (!ready) return out + const h = height || 1 + const glow = Math.max(6, Math.round(rows * 0.16)) + const defaultKind = chartType === "line" ? "line" : "area" + for (const key of configKeys) { + const band = bands[key] + if (!band) continue + const line = (seriesSpecs[key]?.kind ?? defaultKind) === "line" + const top = band.map((b) => (y(b[1]) / h) * (rows - 1)) + const floor = band.map((b, i) => + line ? Math.min(rows - 1, top[i] + glow) : (y(b[0]) / h) * (rows - 1) + ) + out[key] = { top: resample(top, cols), floor: resample(floor, cols) } + } + return out + }, [ready, chartType, configKeys, bands, seriesSpecs, y, height, rows, cols]) + + // Memoized: the star field is deterministic — only its shape (series × + // column count) matters, so it need not be rebuilt on unrelated re-renders. + const stars = useMemo(() => { + const out: Star[] = [] + const per = Math.max(4, Math.round(cols / 14)) + configKeys.forEach((key, k) => { + for (let i = 0; i < per; i++) { + const seed = i * 67 + 13 + k * 131 + out.push({ + key, + xi: seed % Math.max(dataLength, 1), + depth: ((seed * 53 + 7) % 100) / 100, + phase: (seed * 41) % 360, + }) + } + }) + return out + }, [configKeys, dataLength, cols]) + + // The RAF loop reads these through refs so it always sees the latest values + // without re-subscribing. Refs are written in an effect (never during + // render) — mutating a ref mid-render is a React anti-pattern that tears + // under Strict Mode / concurrent rendering. + const stateRef = useRef(ctx) + const targetsRef = useRef(targets) + const starsRef = useRef(stars) + useEffect(() => { + stateRef.current = ctx + targetsRef.current = targets + starsRef.current = stars + }) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + return startCartesianLoop({ + canvas, + bloomCanvas: bloomRef.current, + cols, + rows, + state: stateRef, + targets: targetsRef, + stars: starsRef, + }) + }, [cols, rows]) + + const bloomActive = ctx.bloomOnHover + ? ctx.isMouseInChart || ctx.hovered + : true + const bloom = bloomLayerStyle(ctx.bloom, bloomActive) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/cartesian-root.tsx b/drmq-dashboard/src/components/dither-kit/cartesian-root.tsx new file mode 100644 index 0000000..05aa5e6 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/cartesian-root.tsx @@ -0,0 +1,190 @@ +import { + Children, + type ComponentType, + isValidElement, + type ReactNode, +} from "react" +import { + type ChartConfig, + ChartContext, + type ChartType, + type Margins, + useChartController, +} from "./chart-context" +import { CommonChartContext } from "./common-context" +import type { BloomInput } from "./dither-paint" +import { cn } from "./lib" +import type { StackType } from "./scales" +import { useChartDimensions } from "./use-chart-dimensions" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +const DEFAULT_MARGINS: Margins = { + top: 10, + right: 12, + bottom: 22, + left: 36, +} + +export type CartesianChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + stackType?: StackType + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number // change to re-play the entrance without remounting + /** Set false for a decorative sparkline: keeps the hover lift but no scrub + * crosshair / tooltip. */ + interactive?: boolean + /** Controlled crosshair position (e.g. a committed point) — overrides the + * internal hover when set. */ + markerIndex?: number | null + /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */ + hovered?: boolean + /** Glow on the dither fill. */ + bloom?: BloomInput + /** Only bloom while the chart is hovered. */ + bloomOnHover?: boolean + /** Fires with the scrubbed index as the pointer moves (null on leave). */ + onHoverChange?: (index: number | null) => void + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Which render layer a composed part targets — defaults to the front SVG. */ +function layerOf(node: ReactNode): "back" | "dom" | "svg" { + if (!isValidElement(node) || typeof node.type === "string") return "svg" + return (node.type as { chartLayer?: "back" | "dom" }).chartLayer ?? "svg" +} + +/** + * Shared root for the cartesian dither charts (area, line, bar). Owns the + * measured size, the shared context, and pointer interaction; every visual is + * composed as children. Back chrome (grid) sits behind the dither canvas; the + * canvas paints the fill/line/bars + stars; front chrome (axes, dots) and DOM + * legend/tooltip layer on top. `chartType` drives the scales/interaction and the + * `Canvas` prop supplies the family's painter (continuous for area/line, bars for + * bar) — so each chart ships only its own canvas. + */ +export function CartesianRoot({ + chartType, + Canvas, + data, + config, + children, + stackType = "default", + margins: marginsProp, + className, + animate = true, + animationDuration = 900, + replayToken = 0, + interactive = true, + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + onHoverChange, + defaultSelectedDataKey = null, + onSelectionChange, +}: CartesianChartProps & { + chartType: ChartType + Canvas: ComponentType +}) { + const { ref, size } = useChartDimensions() + const margins = { ...DEFAULT_MARGINS, ...marginsProp } + + const ctx = useChartController({ + chartType, + // Safe: the controller only reads row[key] for the configured series keys. + data: data as Record[], + config, + stackType, + dimensions: size, + margins, + animate, + animationDuration, + replayToken, + markerIndex, + hovered, + bloom, + bloomOnHover, + defaultSelectedDataKey, + onSelectionChange, + }) + + const backChildren: ReactNode[] = [] + const svgChildren: ReactNode[] = [] + const domChildren: ReactNode[] = [] + Children.forEach(children, (child) => { + const layer = layerOf(child) + if (layer === "back") backChildren.push(child) + else if (layer === "dom") domChildren.push(child) + else svgChildren.push(child) + }) + + const onMove = (clientX: number) => { + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() + const px = clientX - rect.left - margins.left + const index = ctx.indexAtX(px) + ctx.setHoverIndex(index) + ctx.setCursorX(clientX - rect.left) + onHoverChange?.(index) + } + + return ( + + +
ctx.setMouseInChart(true)} + onPointerMove={interactive ? (e) => onMove(e.clientX) : undefined} + onPointerLeave={() => { + ctx.setMouseInChart(false) + ctx.setHoverIndex(null) + onHoverChange?.(null) + }} + > + {ctx.ready && backChildren.length > 0 && ( + + + {backChildren} + + + )} + + {ctx.ready && ( + + + {svgChildren} + + + )} + {domChildren} +
+
+
+ ) +} + +export type AreaChartProps = CartesianChartProps diff --git a/drmq-dashboard/src/components/dither-kit/chart-context.tsx b/drmq-dashboard/src/components/dither-kit/chart-context.tsx new file mode 100644 index 0000000..5f10f8a --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/chart-context.tsx @@ -0,0 +1,508 @@ +import type { ScaleLinear } from "d3-scale" +import { createContext, use, useCallback, useMemo, useState } from "react" +import type { CommonChart } from "./common-context" +import type { BloomInput } from "./dither-paint" +import type { DitherColor, Seed } from "./palette" +import { seedOfColor } from "./palette" +import { + buildBandScale, + buildXScale, + buildYScale, + computeBands, + indexAtBand, + nearestIndex, + type StackType, +} from "./scales" +import type { Dimensions } from "./use-chart-dimensions" + +/** Which chart root a part is composed under — drives the boundary guards. */ +export type ChartType = "area" | "bar" | "line" | "pie" | "radar" + +export type ChartConfig = Record + +export type Margins = { + top: number + right: number + bottom: number + left: number +} + +type Row = Record + +export type AreaVariant = "gradient" | "dotted" | "hatched" | "solid" +export type StrokeVariant = "solid" | "dashed" +export type SeriesKind = "area" | "line" | "bar" + +/** What each series part (, , ) registers so the canvas + * knows which series to paint and how. */ +export type SeriesSpec = { + dataKey: string + kind: SeriesKind + variant: AreaVariant + strokeVariant: StrokeVariant +} + +export type ChartContextValue = { + chartType: ChartType // which root this part is under + config: ChartConfig + configKeys: string[] // series order — drives stacking + legend + data: Row[] + dataLength: number + stackType: StackType + + margins: Margins + plot: { width: number; height: number } // inner drawing area + ready: boolean // true once measured (width > 0) + + xCenter: (index: number) => number // category centre px within the plot + bandwidth: number // category slot width (0 for point/area scales) + indexAtX: (px: number) => number // nearest category for a pointer x + // Bar geometry in plot px — one source of truth for the canvas + click rects. + barSlot: ( + index: number, + seriesIndex: number, + seriesCount: number + ) => { x: number; width: number } + y: ScaleLinear // value → px within the plot + bands: Record // per-series [y0, y1] per row + max: number + min: number // most-negative value (0 when nothing dips below the baseline) + + // Interaction state, shared by every part. + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Legend-hover spotlight — dims every series but this one while set. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + setHoverIndex: (index: number | null) => void + markerIndex: number | null // controlled crosshair override (e.g. committed point) + cursorX: number + setCursorX: (px: number) => void + isMouseInChart: boolean + setMouseInChart: (over: boolean) => void + hovered: boolean // parent-driven hover (e.g. the whole card) — lifts the fill + bloom: BloomInput // glow on the dither canvas + bloomOnHover: boolean // only bloom while hovered + + // Series register themselves so the canvas knows what (and how) to paint. + seriesSpecs: Record + registerSeries: (spec: SeriesSpec) => void + unregisterSeries: (dataKey: string) => void + + // Entrance animation (prop-driven). `revision` bumps when the data changes or + // the replay token advances, so the canvas can re-play its entrance. + animate: boolean + animationDuration: number + revision: number + entranceDone: boolean // true once the entrance has played — gates SVG markers + markEntranceDone: () => void // the canvas calls this when its reveal completes + + // Helpers. + seedOf: (key: string) => Seed + common: CommonChart // shared surface for / +} + +const ChartContext = createContext(null) + +const ROOT_OF: Record = { + area: "", + bar: "", + line: "", + pie: "", + radar: "", +} + +/** Generic accessor for internal layers (canvas/overlay) that work for any root. */ +export function useChart() { + const ctx = use(ChartContext) + if (!ctx) { + throw new Error( + "Chart parts must be used within a chart root (e.g. )." + ) + } + return ctx +} + +/** + * Boundary guard for a composable part. Throws a precise error when used outside + * a root, or inside the wrong chart type — e.g. `` placed in an area + * chart. `kind` omitted means the part works under any root (grid, axes, …). + */ +export function useChartPart( + part: string, + kind?: ChartType | ChartType[] +): ChartContextValue { + const ctx = use(ChartContext) + if (!ctx) { + const where = kind + ? ROOT_OF[Array.isArray(kind) ? kind[0] : kind] + : "a chart root" + throw new Error(`<${part} /> must be used within ${where}.`) + } + if (kind) { + const allowed = Array.isArray(kind) ? kind : [kind] + if (!allowed.includes(ctx.chartType)) { + throw new Error( + `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${allowed + .map((k) => ROOT_OF[k]) + .join(" or ")}.` + ) + } + } + return ctx +} + +export { ChartContext } + +/** A counter that advances whenever `data` changes identity or `token` advances + * — drives entrance replays without remounting. Uses the adjust-state-during- + * render pattern (https://react.dev/reference/react/useState) instead of a ref: + * the revision is derived purely from render inputs, so it stays consistent + * across the memoized values below rather than lagging a render behind. */ +export function useRevision(data: unknown, token: number) { + const [prev, setPrev] = useState({ data, token, revision: 0 }) + if (prev.data !== data || prev.token !== token) { + const next = { data, token, revision: prev.revision + 1 } + setPrev(next) + return next.revision + } + return prev.revision +} + +/** + * Builds the shared context value: resolves the plot rect from the measured + * size minus margins, computes the x/y scales and the per-series stack bands, + * and owns the selection + hover state every part reads. + */ +export function useChartController({ + chartType, + data, + config, + stackType, + dimensions, + margins, + animate = true, + animationDuration = 900, + replayToken = 0, + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: { + chartType: ChartType + data: Row[] + config: ChartConfig + stackType: StackType + dimensions: Dimensions + margins: Margins + animate?: boolean + animationDuration?: number + replayToken?: number + markerIndex?: number | null + hovered?: boolean + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +}): ChartContextValue { + // This object becomes the ChartContext value, so its identity — and the + // identity of every function/object it carries — must stay stable across + // renders that don't change the underlying inputs. Otherwise every consumer + // (axes, legend, tooltip, dots) re-renders on every parent render. So the + // expensive derivations, the exposed callbacks, and the returned value are + // memoized below; only cheap scalars (bandwidth, ready, plot sizes) are left + // bare, since they're just recomputed reads, not identities anyone depends on. + + // Memoized: configKeys is the dep that drives `bands`, `common` and the + // canvas `targets` memo — a fresh array each render would bust all of them. + const configKeys = useMemo(() => Object.keys(config), [config]) + const revision = useRevision(data, replayToken) + + const [selectedDataKey, setSelectedDataKey] = useState( + defaultSelectedDataKey + ) + const [focusDataKey, setFocusDataKey] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + const [cursorX, setCursorX] = useState(0) + const [isMouseInChart, setMouseInChart] = useState(false) + const [seriesSpecs, setSeriesSpecs] = useState>({}) + + // useCallback because the series effects in area.tsx/bar.tsx list these as + // deps — without stable identities the unregister/register effect re-fires + // every render and its setState pair loops ("Maximum update depth exceeded"). + const registerSeries = useCallback((spec: SeriesSpec) => { + setSeriesSpecs((prev) => { + const cur = prev[spec.dataKey] + return cur && + cur.kind === spec.kind && + cur.variant === spec.variant && + cur.strokeVariant === spec.strokeVariant + ? prev + : { ...prev, [spec.dataKey]: spec } + }) + }, []) + const unregisterSeries = useCallback((dataKey: string) => { + setSeriesSpecs((prev) => { + if (!(dataKey in prev)) return prev + const next = { ...prev } + delete next[dataKey] + return next + }) + }, []) + + // Stable so the memoized value keeps its identity; only re-created when the + // caller's selection handler does. + const selectDataKey = useCallback( + (key: string | null) => { + setSelectedDataKey(key) + onSelectionChange?.(key) + }, + [onSelectionChange] + ) + + // The root spreads `{ ...DEFAULT_MARGINS, ...marginsProp }` fresh every + // render, so `margins` never keeps its identity. Pin one off the four numbers + // so it doesn't, on its own, invalidate the value or the plot geometry. + const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins + const stableMargins = useMemo( + () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }), + [mTop, mRight, mBottom, mLeft] + ) + + const plotWidth = Math.max(0, dimensions.width - mLeft - mRight) + const plotHeight = Math.max(0, dimensions.height - mTop - mBottom) + const ready = plotWidth > 0 && plotHeight > 0 + + // The entrance gate flips true when the canvas reveal completes (via + // `markEntranceDone`) so DOM markers fade in with the fill, and re-arms on + // each replay. Adjust-state-during-render instead of an effect, so the reset + // lands in the same render as the revision bump. + const [entrance, setEntrance] = useState({ revision, done: !animate }) + if (entrance.revision !== revision) { + setEntrance({ revision, done: !animate }) + } + const entranceDone = entrance.revision === revision ? entrance.done : !animate + // Stable across renders at the same revision; the canvas holds this in a ref. + const markEntranceDone = useCallback( + () => setEntrance({ revision, done: true }), + [revision] + ) + + // Memoized: the priciest derivation in the render path — it walks every + // row × series to build the stack bands. Hover/cursor state changes must not + // recompute it, only a real data/series/stack change. + const { bands, max, min } = useMemo( + () => computeBands(data, configKeys, stackType), + [data, configKeys, stackType] + ) + + const isBar = chartType === "bar" + // The d3 scale factories are memoized so `y` keeps a stable identity: the + // canvas `targets` memo (cartesian-canvas / bar-canvas) deps on ctx.y, and + // xCenter/indexAtX/barSlot below close over these. + const xPoint = useMemo( + () => buildXScale(data.length, plotWidth), + [data.length, plotWidth] + ) + const xBand = useMemo( + () => buildBandScale(data.length, plotWidth), + [data.length, plotWidth] + ) + const bandwidth = isBar ? xBand.bandwidth() : 0 + const xCenter = useCallback( + (i: number) => + isBar ? (xBand(i) ?? 0) + xBand.bandwidth() / 2 : (xPoint(i) ?? 0), + [isBar, xBand, xPoint] + ) + const indexAtX = useCallback( + (px: number) => + isBar + ? indexAtBand(px, data.length, plotWidth) + : nearestIndex(px, data.length, plotWidth), + [isBar, data.length, plotWidth] + ) + const stacked = stackType === "stacked" || stackType === "percent" + const barSlot = useCallback( + (i: number, si: number, n: number) => { + const center = xCenter(i) + if (stacked) { + const w = bandwidth * 0.9 + return { x: center - w / 2, width: w } + } + const slot = bandwidth / Math.max(n, 1) + return { + x: center - bandwidth / 2 + si * slot + slot * 0.08, + width: slot * 0.84, + } + }, + [xCenter, stacked, bandwidth] + ) + const y = useMemo( + () => buildYScale(min, max, plotHeight), + [min, max, plotHeight] + ) + + // Stable so `common` and the value stay stable; re-created only on config. + const seedOf = useCallback( + (key: string) => seedOfColor(config[key]?.color ?? "grey"), + [config] + ) + + // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip), + // so it needs its own stable identity independent of the parent value. + const common: CommonChart = useMemo(() => ({ + names: configKeys, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft: Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX)), + // Follow the highest hovered node so the card rides the data path, but + // keep enough headroom that the upward-lifted card never clips the top. + tooltipTop: (() => { + const floor = mTop + 44 + if (hoverIndex == null) return floor + let minY = Number.POSITIVE_INFINITY + for (const key of configKeys) { + const b = bands[key]?.[hoverIndex] + if (b) minY = Math.min(minY, y(b[1])) + } + if (!Number.isFinite(minY)) return floor + return Math.max(floor, mTop + minY) + })(), + heading: (i, labelKey) => + labelKey ? String(data[i]?.[labelKey] ?? "") : null, + itemsAt: (i) => + configKeys.map((name) => { + const raw = data[i]?.[name] + return { + name, + label: config[name]?.label ?? name, + value: typeof raw === "number" ? raw : 0, + seed: seedOf(name), + dimmed: (() => { + const emphasis = selectedDataKey ?? focusDataKey + return emphasis !== null && emphasis !== name + })(), + } + }), + }), [ + configKeys, + config, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + plotWidth, + mLeft, + mTop, + cursorX, + bands, + y, + data, + ]) + + // Memoized: this is the ChartContext value. A fresh object here would + // re-render every consumer on every parent render — the whole reason the + // pieces above are stabilized. Rebuilds only when a listed input changes + // (which is exactly when a consumer needs the update). The useState setters + // are listed but never change identity, so they never trigger a rebuild. + return useMemo( + () => ({ + chartType, + config, + configKeys, + data, + dataLength: data.length, + stackType, + margins: stableMargins, + plot: { width: plotWidth, height: plotHeight }, + ready, + xCenter, + bandwidth, + indexAtX, + barSlot, + y, + bands, + max, + min, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + markerIndex, + cursorX, + setCursorX, + isMouseInChart, + setMouseInChart, + hovered, + bloom, + bloomOnHover, + seriesSpecs, + registerSeries, + unregisterSeries, + animate, + animationDuration, + revision, + entranceDone, + markEntranceDone, + seedOf, + common, + }), + [ + chartType, + config, + configKeys, + data, + stackType, + stableMargins, + plotWidth, + plotHeight, + ready, + xCenter, + bandwidth, + indexAtX, + barSlot, + y, + bands, + max, + min, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + markerIndex, + cursorX, + setCursorX, + isMouseInChart, + setMouseInChart, + hovered, + bloom, + bloomOnHover, + seriesSpecs, + registerSeries, + unregisterSeries, + animate, + animationDuration, + revision, + entranceDone, + markEntranceDone, + seedOf, + common, + ] + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/common-context.tsx b/drmq-dashboard/src/components/dither-kit/common-context.tsx new file mode 100644 index 0000000..dae0cc2 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/common-context.tsx @@ -0,0 +1,48 @@ +"use client" + +import { createContext, use } from "react" +import type { Seed } from "./palette" + +/** A single tooltip row — one series (cartesian/radar) or one slice (pie). */ +export type TooltipItem = { + name: string + label: string + value: number + seed: Seed + dimmed: boolean +} + +/** + * The minimal surface shared by every chart family, so `` and + * `` work identically whether they sit in a cartesian, bar, or polar + * root. Each root publishes one of these alongside its family-specific context. + */ +export type CommonChart = { + names: string[] // legend entries — series keys (cartesian) or slice names (pie) + labelOf: (name: string) => string + seedOf: (name: string) => Seed + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Transient legend-hover emphasis — spotlights one series (others dim) + * while the pointer rests on its legend entry. Selection still wins. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + heading: (index: number, labelKey?: string) => string | null + itemsAt: (index: number) => TooltipItem[] + ready: boolean + tooltipLeft: number // clamped px for the floating tooltip + tooltipTop: number // px — follows the hovered node (cartesian) / cursor (polar) +} + +export const CommonChartContext = createContext(null) + +export function useCommonChart() { + const ctx = use(CommonChartContext) + if (!ctx) { + throw new Error( + " / must be used within a chart root." + ) + } + return ctx +} diff --git a/drmq-dashboard/src/components/dither-kit/dither-paint.ts b/drmq-dashboard/src/components/dither-kit/dither-paint.ts new file mode 100644 index 0000000..395636b --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/dither-paint.ts @@ -0,0 +1,177 @@ +import type { AreaVariant } from "./chart-context" +import { rgb, type Seed } from "./palette" + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the exact matrix +// the legacy chart dithers with. +export const BAYER = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((v) => (v + 0.5) / 16)) + +export const CELL = 2 // css px per dither cell — chunky enough to read pixelated +export const MAX_COLS = 520 +export const MAX_ROWS = 200 +// Opacity of the top border outline (just under solid, so it reads as a soft +// edge rather than a hard line). See the note on colour vs opacity below. +export const BORDER_ALPHA = 0.72 +// Opacity of a dither "off" cell relative to an "on" cell. The scatter modulates +// between these two tiers of the *same* colour instead of leaving holes, so the +// background never shows through as stark white on a light theme. +export const OFF_TIER = 0.4 + +export type PaintOpts = { + variant: AreaVariant + intensity: number // 0–1 hover lift + dim: number // selection dim multiplier (0.3 dimmed, 1 normal) + stacked: boolean // denser + solid floor when layers stack + sparse?: number // raise the dither threshold (thin out) — front layers +} + +// Colour vs opacity — the guiding rule for the whole engine: +// +// Work with opacities instead of different shades of the same color. This will +// make sure it looks good on both light and dark mode. +// +// So every pixel is the series' single `fill` colour and we vary only its alpha. +// The old lighter `line` / near-white `star` shades were dropped: a shade that +// pops on a dark background reads as a jarring bright speck on a light one, while +// the same colour at a lower opacity simply blends into whatever sits behind it. + +/** + * Fill one backing-canvas column `x` from row `top` down to `floor` with the + * ordered-dither scatter — solid at the floor, dissolving upward so it *fades + * out toward the value line* — then cap the top with a soft border outline in + * the series colour. Density drives opacity (see the note above), so the fade + * reads correctly against both light and dark backgrounds. The single source of + * the dither look across area / line / bar. + */ +export function paintColumn( + octx: CanvasRenderingContext2D, + x: number, + top: number, + floor: number, + seed: Seed, + { variant, intensity, dim, stacked, sparse = 0 }: PaintOpts +) { + const t = Math.round(top) + const f = Math.round(floor) + const depth = f - t + if (depth <= 0) { + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim) + octx.fillRect(x, t, 1, 1) + return + } + const bias = (variant === "dotted" ? 0.12 : 0) + (stacked ? 0.2 : 0) - sparse + for (let y = t; y < f; y++) { + // Inverted falloff: 0 at the top line, 1 at the floor — dense at the + // bottom, thinning as it rises toward the outline. + let density = (y - t) / depth + if (stacked) density = 0.5 + 0.5 * density + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > BAYER[y & 3][x & 3] - 0.1 * intensity - bias + // "dotted" keeps real gaps for its open look; every other variant covers + // the cell and lets the dither ride the alpha (on = full tier, off = a + // faint tint) so nothing shows the background through as white. + if (variant === "dotted" && !lit) continue + // Density → alpha (see the colour-vs-opacity note above). + const k = (0.3 + density * 0.7) * (1 + 0.22 * intensity) + const alpha = clamp01((lit ? k : k * OFF_TIER) * dim) + octx.fillStyle = rgb(seed.fill, 1, alpha) + octx.fillRect(x, y, 1, 1) + } + // Top border outline — the shape's edge now that the fill fades out here. + // Kept just under full opacity, with a faint feather row beneath, so it reads + // as a soft edge rather than a hard line floating over the fade. + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim) + octx.fillRect(x, t, 1, 1) + if (depth > 1) { + octx.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * 0.5 * dim) + octx.fillRect(x, t + 1, 1, 1) + } +} + +/** Linear-resample a per-index fraction array to `cols` columns. */ +export function resample(src: number[], cols: number): number[] { + const out = new Array(cols) + const last = Math.max(src.length - 1, 1) + for (let c = 0; c < cols; c++) { + const t = (c / Math.max(cols - 1, 1)) * last + const i = Math.floor(t) + const f = t - i + const a = src[i] ?? 0 + const b = src[Math.min(i + 1, src.length - 1)] ?? a + out[c] = a + (b - a) * f + } + return out +} + +/** Backing-canvas resolution for a plot rect — low-res, scaled up `pixelated`. */ +export function backingSize(width: number, height: number) { + return { + cols: Math.min(MAX_COLS, Math.max(8, Math.round(width / CELL))), + rows: Math.min(MAX_ROWS, Math.max(8, Math.round(height / CELL))), + } +} + +// Bloom — a real "shader" glow that comes from the colours themselves: a blurred +// copy of the rendered canvas, composited additively (`plus-lighter`) so each +// hue blooms in its own colour instead of a grey wash. Lives on a second canvas +// layered over the crisp one (which stays sharp/pixelated). +export type BloomLevel = "off" | "low" | "high" | "aura" +export type BloomBlend = "plus-lighter" | "screen" | "lighten" +export type BloomConfig = { + blur: number // px + brightness: number // 1 = none + opacity: number // 0–1 + /** Saturation of the glow — >1 keeps it vividly in the dither's colour + * instead of washing toward white. */ + saturate?: number + blend?: BloomBlend // additive by default +} +/** A preset name, a full config, or "off". */ +export type BloomInput = BloomLevel | BloomConfig + +const PRESET: Record, BloomConfig> = { + low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 }, + high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 }, + aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 }, +} + +export type BloomStyle = { + filter: string + opacity: number + mixBlendMode: BloomBlend + imageRendering: "auto" +} + +/** Style for the bloom *layer* canvas (a blurred, additive copy). null when off. */ +export function bloomLayerStyle( + input: BloomInput, + active: boolean +): BloomStyle | null { + if (!active || input === "off") return null + const cfg = typeof input === "string" ? PRESET[input] : input + return { + filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate ?? 1})`, + opacity: cfg.opacity, + mixBlendMode: cfg.blend ?? "plus-lighter", + imageRendering: "auto", + } +} + +// Easing — gentle start + soft settle so entrances don't feel linear. +export const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2 +export const easeOutCubic = (t: number) => 1 - (1 - t) ** 3 +export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) + +/** Whether the OS asks for reduced motion (snap + steady stars). */ +export function prefersReducedMotion() { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/dot.tsx b/drmq-dashboard/src/components/dither-kit/dot.tsx new file mode 100644 index 0000000..41cdc1d --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/dot.tsx @@ -0,0 +1,88 @@ +import { useChart } from "./chart-context" +import { rgb, type Seed } from "./palette" +import { useSeries } from "./series-context" + +export type DotVariant = "border" | "colored-border" | "filled" + +function dotPaint(variant: DotVariant, seed: Seed) { + switch (variant) { + case "colored-border": + return { + fill: "var(--card, #0b0b0c)", + stroke: rgb(seed.line), + strokeWidth: 1.5, + } + case "filled": + return { fill: rgb(seed.star), stroke: rgb(seed.line), strokeWidth: 1 } + default: + return { + fill: "var(--card, #0b0b0c)", + stroke: rgb(seed.star, 0.8), + strokeWidth: 1, + } + } +} + +/** A marker at every data point along the series' top line. */ +export function Dot({ + variant = "border", + r = 2, +}: { + variant?: DotVariant + r?: number +}) { + const ctx = useChart() + const { dataKey, seed } = useSeries("Dot") + const band = ctx.bands[dataKey] + if (!ctx.ready || !band) return null + const paint = dotPaint(variant, seed) + + return ( + // Fade in once the fill has drawn so dots don't float over the entrance. + + {band.map((b, i) => ( + + ))} + + ) +} + +/** A single marker at the hovered point — keys off the shared hover index. */ +export function ActiveDot({ + variant = "colored-border", + r = 3, +}: { + variant?: DotVariant + r?: number +}) { + const ctx = useChart() + const { dataKey, seed } = useSeries("ActiveDot") + const band = ctx.bands[dataKey] + if (!ctx.ready || !band || ctx.hoverIndex == null || !ctx.entranceDone) + return null + const b = band[ctx.hoverIndex] + if (!b) return null + const paint = dotPaint(variant, seed) + const cx = ctx.xCenter(ctx.hoverIndex) + const cy = ctx.y(b[1]) + + return ( + + {/* Soft halo so the active point is unmistakable over the dither. */} + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/gradient.tsx b/drmq-dashboard/src/components/dither-kit/gradient.tsx new file mode 100644 index 0000000..b36f7c3 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/gradient.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef } from "react" +import { cn } from "./lib" +import { rgb } from "./palette" +import { + BAYER4, + fillOf, + type PixelBloom, + type PixelColor, + pixelBloomStyle, +} from "./pixel" + +// Backing-resolution caps — a background wash never needs more cells than this. +const MAX_COLS = 960 +const MAX_ROWS = 600 + +export type GradientDirection = "up" | "down" | "left" | "right" + +export type DitherGradientProps = { + /** The colour the gradient starts solid as — a palette name or a hue. */ + from: PixelColor + /** What it dissolves into: another colour for a two-tone dither blend, or + * "transparent" (default) so the background shows through. */ + to?: PixelColor | "transparent" + /** Where `to` ends up — "up" reads as a glow rising from the bottom edge. */ + direction?: GradientDirection + /** CSS px per dither cell — bigger is chunkier. */ + cell?: number + /** Overall opacity multiplier. */ + opacity?: number + /** Glow on the dither fill. */ + bloom?: PixelBloom + className?: string +} + +type PaintSpec = { + from: PixelColor + to: PixelColor | "transparent" + direction: GradientDirection + cell: number + opacity: number +} + +/** + * Paint the ordered-dither ramp onto a low-res backing canvas sized from the + * wrapper's box. Static — one paint per prop/size change, no animation loop, + * so it's free to use as a page-wide background. + */ +function paintGradient( + canvas: HTMLCanvasElement, + bloomCanvas: HTMLCanvasElement | null, + width: number, + height: number, + spec: PaintSpec +): void { + const ctx = canvas.getContext("2d") + if (!ctx || width <= 0 || height <= 0) return + const cols = Math.min(MAX_COLS, Math.max(4, Math.round(width / spec.cell))) + const rows = Math.min(MAX_ROWS, Math.max(4, Math.round(height / spec.cell))) + canvas.width = cols + canvas.height = rows + + const fromFill = fillOf(spec.from) + const toFill = spec.to === "transparent" ? null : fillOf(spec.to) + const o = spec.opacity + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + // t runs 0 at the `from` edge → 1 at the `to` edge. + const t = + spec.direction === "up" + ? 1 - (y + 0.5) / rows + : spec.direction === "down" + ? (y + 0.5) / rows + : spec.direction === "left" + ? 1 - (x + 0.5) / cols + : (x + 0.5) / cols + const density = 1 - t + const lit = density > BAYER4[y & 3][x & 3] + if (toFill) { + // Two-tone: every cell is painted, the dither decides which colour. + ctx.fillStyle = rgb(lit ? fromFill : toFill, 1, o) + ctx.fillRect(x, y, 1, 1) + } else { + // Dissolve to transparent: lit cells carry the ramp, off cells keep a + // faint tint that also fades out, so the falloff reads smooth. + const alpha = (lit ? 0.35 + 0.65 * density : 0.12 * density) * o + if (alpha <= 0.004) continue + ctx.fillStyle = rgb(fromFill, 1, alpha) + ctx.fillRect(x, y, 1, 1) + } + } + } + + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas && bloomCtx) { + bloomCanvas.width = cols + bloomCanvas.height = rows + bloomCtx.drawImage(canvas, 0, 0) + } +} + +/** + * Dithered gradient wash — the charts' ordered-dither texture as a background. + * Fills its nearest positioned ancestor (footer glows, section fades, card + * backdrops). Dissolves to transparent by default, or dither-blends between + * two colours when `to` is set. + */ +export function DitherGradient({ + from, + to = "transparent", + direction = "up", + cell = 3, + opacity = 1, + bloom = "off", + className, +}: DitherGradientProps) { + const wrapRef = useRef(null) + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + useEffect(() => { + const wrap = wrapRef.current + const canvas = canvasRef.current + if (!wrap || !canvas) return + const paint = () => { + const box = wrap.getBoundingClientRect() + paintGradient(canvas, bloomRef.current, box.width, box.height, { + from, + to, + direction, + cell, + opacity, + }) + } + paint() + if (typeof ResizeObserver === "undefined") return + const ro = new ResizeObserver(paint) + ro.observe(wrap) + return () => ro.disconnect() + }, [from, to, direction, cell, opacity, bloom]) + + const bloomStyle = pixelBloomStyle(bloom) + + return ( +
+ + {bloomStyle && ( + + )} +
+ ) +} diff --git a/drmq-dashboard/src/components/dither-kit/grid.tsx b/drmq-dashboard/src/components/dither-kit/grid.tsx new file mode 100644 index 0000000..8d81218 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/grid.tsx @@ -0,0 +1,46 @@ +import { useChartPart } from "./chart-context" + +export function Grid({ + horizontal = true, + vertical = false, + strokeDasharray = "3 3", +}: { + horizontal?: boolean + vertical?: boolean + strokeDasharray?: string +}) { + const ctx = useChartPart("Grid") + if (!ctx.ready) return null + const { width } = ctx.plot + + return ( + + {horizontal && + ctx.y + .ticks(4) + .map((t) => ( + + ))} + {vertical && + ctx.data.map((_, i) => ( + + ))} + + ) +} + +// Render beneath the dither canvas so grid lines sit behind the fill. +Grid.chartLayer = "back" as const diff --git a/drmq-dashboard/src/components/dither-kit/index.ts b/drmq-dashboard/src/components/dither-kit/index.ts new file mode 100644 index 0000000..e80819e --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/index.ts @@ -0,0 +1,50 @@ +export { Area, type AreaProps, Line, type SeriesProps } from "./area" +export { AreaChart, type AreaChartProps, LineChart } from "./area-chart" +export { + type AvatarMirror, + DitherAvatar, + type DitherAvatarProps, +} from "./avatar" +export { Bar, type BarProps } from "./bar" +export { BarChart } from "./bar-chart" +export { BlockLegend } from "./block-legend" +export { + type ButtonVariant, + DitherButton, + type DitherButtonProps, +} from "./button" +export type { CartesianChartProps } from "./cartesian-root" +export type { + AreaVariant, + ChartConfig, + ChartType, + Margins, + SeriesKind, + StrokeVariant, +} from "./chart-context" +export type { + BloomBlend, + BloomConfig, + BloomInput, + BloomLevel, +} from "./dither-paint" +export { ActiveDot, Dot, type DotVariant } from "./dot" +export { + DitherGradient, + type DitherGradientProps, + type GradientDirection, +} from "./gradient" +export { Grid } from "./grid" +export { Legend } from "./legend" +export type { DitherColor } from "./palette" +export type { PixelBloom, PixelColor } from "./pixel" +export { Pie, type PieProps } from "./pie" +export { PieChart, type PieChartProps } from "./pie-chart" +export { Radar, type RadarProps } from "./radar" +export { RadarChart, type RadarChartProps } from "./radar-chart" +export { ReferenceLine } from "./reference-line" +export type { StackType } from "./scales" +export { Sparkline, type SparklineProps } from "./sparkline" +export { Tooltip, type TooltipVariant } from "./tooltip" +export { XAxis } from "./x-axis" +export { YAxis } from "./y-axis" diff --git a/drmq-dashboard/src/components/dither-kit/legend.tsx b/drmq-dashboard/src/components/dither-kit/legend.tsx new file mode 100644 index 0000000..f6ced68 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/legend.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useCommonChart } from "./common-context" +import { cn } from "./lib" +import { rgb } from "./palette" + +/** Series/slice legend. With `isClickable`, each entry toggles its selection. + * Works in every chart family via the shared common context. + * + * Note: this is an absolute overlay pinned to the top of the plot, so it's best + * for ≤2–3 entries. With more entries (or a narrow container) it wraps onto + * extra rows that overlay the chart — reach for the in-flow `` + * instead, which renders as a sibling and can't overlap at any width. */ +export function Legend({ + isClickable = false, + align = "right", +}: { + isClickable?: boolean + align?: "left" | "center" | "right" +}) { + const chart = useCommonChart() + + return ( +
+ {chart.names.map((name) => { + const seed = chart.seedOf(name) + const emphasis = chart.selectedDataKey ?? chart.focusDataKey + const dimmed = emphasis !== null && emphasis !== name + return ( + + ) + })} +
+ ) +} + +Legend.chartLayer = "dom" as const diff --git a/drmq-dashboard/src/components/dither-kit/lib.ts b/drmq-dashboard/src/components/dither-kit/lib.ts new file mode 100644 index 0000000..da71254 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/lib.ts @@ -0,0 +1,8 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +/** Tailwind-aware className combiner — local copy so the chart pack is + * self-contained and portable as a registry. */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/drmq-dashboard/src/components/dither-kit/palette.ts b/drmq-dashboard/src/components/dither-kit/palette.ts new file mode 100644 index 0000000..93faa58 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/palette.ts @@ -0,0 +1,40 @@ +export type Rgb = [number, number, number] + +export type DitherColor = + | "green" + | "blue" + | "purple" + | "pink" + | "orange" + | "red" + | "grey" + +export type Seed = { fill: Rgb; line: Rgb; star: Rgb } + +// Each seed: the area-fill hue, the bright series line, and the star sparkle. +export const PALETTE: Record = { + green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] }, + blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] }, + purple: { + fill: [150, 110, 255], + line: [200, 175, 255], + star: [225, 210, 255], + }, + pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] }, + orange: { + fill: [255, 150, 50], + line: [255, 195, 130], + star: [255, 220, 175], + }, + red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] }, + // No-data: a muted grey so empty metrics read as "nothing here". + grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] }, +} + +export const rgb = ([r, g, b]: Rgb, k = 1, a = 1) => + `rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})` + +export const seedOfColor = (color: DitherColor): Seed => PALETTE[color] + +export const isDitherColor = (value: unknown): value is DitherColor => + typeof value === "string" && value in PALETTE diff --git a/drmq-dashboard/src/components/dither-kit/pie-canvas.tsx b/drmq-dashboard/src/components/dither-kit/pie-canvas.tsx new file mode 100644 index 0000000..10e0549 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/pie-canvas.tsx @@ -0,0 +1,223 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + BAYER, + backingSize, + bloomLayerStyle, + easeInOutCubic, + OFF_TIER, + prefersReducedMotion, +} from "./dither-paint" +import { rgb } from "./palette" +import { sliceAtAngle } from "./polar" +import { usePolarChart } from "./polar-context" + +const TOP = -Math.PI / 2 +const TAU = Math.PI * 2 +const POP = 6 // px the hovered slice bulges outward + +/** + * Dither canvas for pie / donut charts. Each backing pixel is mapped back to + * plot space, tested for its slice by angle, and filled with the ordered-dither + * scatter — dense at the outer edge, thinning toward the centre — capped by a + * bright arc on the rim. The pie sweeps in clockwise on mount; the hovered slice + * bulges outward with a brighter rim while the rest dim. + */ +export function PieCanvas() { + const ctx = usePolarChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + + // The RAF loop reads the latest ctx through a ref; written in an effect + // (never during render) — mutating a ref mid-render tears under Strict Mode / + // concurrent rendering. + const state = useRef(ctx) + useEffect(() => { + state.current = ctx + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let popEase = 0 // eases the hovered slice's outward bulge + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const paint = (prog: number) => { + const s = state.current + const slices = s.pie + if (!slices) return + c.clearRect(0, 0, cols, rows) + const cx = s.center.x + const cy = s.center.y + const outerR = s.outerRadius + const innerR = s.innerRadius + const revealAngle = TOP + easeInOutCubic(prog) * TAU + + for (let y = 0; y < rows; y++) { + const py = ((y + 0.5) * height) / rows + for (let x = 0; x < cols; x++) { + const px = ((x + 0.5) * width) / cols + const dx = px - cx + const dy = py - cy + const r = Math.hypot(dx, dy) + if (r < innerR) continue + const angle = Math.atan2(dy, dx) + let na = angle + while (na < TOP) na += TAU + while (na >= TOP + TAU) na -= TAU + if (na > revealAngle) continue // clockwise sweep-in + const si = sliceAtAngle(slices, angle) + if (si < 0) continue + const slice = slices[si] + const active = s.hoverIndex === si + const localOuter = active ? outerR + POP * popEase : outerR + if (r > localOuter) continue + + const seed = s.seedOf(slice.name) + const variant = s.variantOf(slice.name) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== slice.name ? 0.3 : 1 + const it = intensity + (active ? 0.4 * popEase : 0) + + // Bright rim on the outer edge — thicker on the hovered slice. + if (localOuter - r < (active ? 1.4 + popEase : 1.4)) { + c.fillStyle = rgb(seed.fill, 1, selDim) + c.fillRect(x, y, 1, 1) + continue + } + const density = (r - innerR) / Math.max(localOuter - innerR, 1) + const bias = variant === "dotted" ? 0.12 : 0 + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > BAYER[y & 3][x & 3] - 0.1 * it - bias + if (variant === "dotted" && !lit) continue + // Density → opacity (see the colour-vs-opacity note in dither-paint); + // off cells drop to a faint tier, never a hole to the background. + const k = (0.35 + density * 0.65) * (1 + 0.22 * it) + const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim) + c.fillStyle = rgb(seed.fill, 1, alpha) + c.fillRect(x, y, 1, 1) + } + } + } + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready || !s.pie) return + if (bloomCtx) { + const on = s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + popEase = 0 // a freshly-hovered slice bulges out from rest + needsFill = true + } + const itTarget = s.isMouseInChart ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + // Ease the hovered slice's bulge in (and back out when nothing's hovered). + const popTarget = s.hoverIndex != null ? 1 : 0 + if (Math.abs(popEase - popTarget) > 0.001) { + popEase += (popTarget - popEase) * (reduce ? 1 : 0.22) + needsFill = true + } else popEase = popTarget + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + + // Live tweak repaint (variant, donut inner radius) without re-sweeping. + const paintSig = `${s.innerRadius}|${s.pie + .map((sl) => s.variantOf(sl.name)) + .join(",")}` + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width, height]) + + const bloom = bloomLayerStyle( + ctx.bloom, + ctx.bloomOnHover ? ctx.isMouseInChart : true + ) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/pie-chart.tsx b/drmq-dashboard/src/components/dither-kit/pie-chart.tsx new file mode 100644 index 0000000..3953214 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/pie-chart.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react" +import type { ChartConfig, Margins } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import { PieCanvas } from "./pie-canvas" +import { PolarRoot } from "./polar-root" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +export type PieChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + dataKey: string // value field + nameKey: string // slice-name field (looked up in config for colour) + innerRadius?: number // 0–1 ratio for a donut + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Composable dither **pie / donut** chart. Compose ``, ``, … inside. */ +export function PieChart(props: PieChartProps) { + return +} diff --git a/drmq-dashboard/src/components/dither-kit/pie.tsx b/drmq-dashboard/src/components/dither-kit/pie.tsx new file mode 100644 index 0000000..f02f6db --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/pie.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react" +import type { AreaVariant } from "./chart-context" +import { usePolarPart } from "./polar-context" + +export type PieProps = { + /** Fill texture applied to every slice. */ + variant?: AreaVariant +} + +/** + * The pie/donut ring. Slices come from the chart `data` (one per row); this part + * sets the shared fill variant. The dithered wedges are painted on the canvas. + */ +export function Pie({ variant = "gradient" }: PieProps) { + const ctx = usePolarPart("Pie", "pie") + const { registerVariant, unregisterVariant } = ctx + + useEffect(() => { + registerVariant("*", variant) + return () => unregisterVariant("*") + }, [variant, registerVariant, unregisterVariant]) + + return null +} diff --git a/drmq-dashboard/src/components/dither-kit/pixel.ts b/drmq-dashboard/src/components/dither-kit/pixel.ts new file mode 100644 index 0000000..3a0dbef --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/pixel.ts @@ -0,0 +1,109 @@ +import { type DitherColor, PALETTE, type Rgb } from "./palette" + +// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds — the same matrix +// the charts dither with. +export const BAYER4 = [ + [0, 8, 2, 10], + [12, 4, 14, 6], + [3, 11, 1, 9], + [15, 7, 13, 5], +].map((row) => row.map((v) => (v + 0.5) / 16)) + +export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t) + +/** 32-bit FNV-1a hash — turns any string seed into a stable uint32. */ +export function fnv1a(str: string): number { + let h = 0x811c9dc5 + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return h >>> 0 +} + +/** Tiny deterministic PRNG (xorshift32) — returns floats in [0, 1). */ +export function xorshift32(seed: number): () => number { + let s = seed || 0x9e3779b9 + return () => { + s ^= s << 13 + s >>>= 0 + s ^= s >>> 17 + s ^= s << 5 + s >>>= 0 + return s / 0x100000000 + } +} + +/** A named palette colour or a raw hue (0–360). */ +export type PixelColor = DitherColor | number + +/** Hue (0–360) → an rgb fill tuned to sit alongside the chart palette. */ +export function hueFill(hue: number): Rgb { + const h = ((hue % 360) + 360) % 360 + const s = 0.85 + const l = 0.58 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + const [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x] + return [ + Math.round((r + m) * 255), + Math.round((g + m) * 255), + Math.round((b + m) * 255), + ] +} + +/** Resolve a {@link PixelColor} to its rgb fill. */ +export function fillOf(color: PixelColor): Rgb { + return typeof color === "number" ? hueFill(color) : PALETTE[color].fill +} + +// Bloom — same recipe as the charts: a blurred copy of the crisp canvas, +// composited additively so the glow stays in the dither's own colour. +export type PixelBloom = "off" | "low" | "high" | "aura" + +const BLOOM_PRESET: Record< + Exclude, + { blur: number; brightness: number; opacity: number; saturate: number } +> = { + low: { blur: 3, brightness: 1.35, opacity: 0.7, saturate: 1.4 }, + high: { blur: 5, brightness: 1.5, opacity: 0.78, saturate: 1.5 }, + aura: { blur: 15, brightness: 2.9, opacity: 0.1, saturate: 3 }, +} + +export type PixelBloomStyle = { + filter: string + opacity: number + mixBlendMode: "plus-lighter" + imageRendering: "auto" +} + +/** Style for the bloom layer canvas. null when off. */ +export function pixelBloomStyle(bloom: PixelBloom): PixelBloomStyle | null { + if (bloom === "off") return null + const cfg = BLOOM_PRESET[bloom] + return { + filter: `blur(${cfg.blur}px) brightness(${cfg.brightness}) saturate(${cfg.saturate})`, + opacity: cfg.opacity, + mixBlendMode: "plus-lighter", + imageRendering: "auto", + } +} + +/** Whether the OS asks for reduced motion (skip entrances). */ +export function pixelPrefersReducedMotion(): boolean { + return ( + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/polar-context.tsx b/drmq-dashboard/src/components/dither-kit/polar-context.tsx new file mode 100644 index 0000000..0fd06b3 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/polar-context.tsx @@ -0,0 +1,382 @@ +"use client" + +import { createContext, use, useCallback, useMemo, useState } from "react" +import { + type AreaVariant, + type ChartConfig, + type ChartType, + type Margins, + useRevision, +} from "./chart-context" +import type { CommonChart } from "./common-context" +import type { BloomInput } from "./dither-paint" +import type { Seed } from "./palette" +import { seedOfColor } from "./palette" +import { type PieSlice, pieSlices, type RadarAxis, radarAxes } from "./polar" +import type { Dimensions } from "./use-chart-dimensions" + +type Row = Record + +const ROOT_OF: Record = { + pie: "", + radar: "", +} + +export type PolarChartContextValue = { + chartType: ChartType + config: ChartConfig + configKeys: string[] + data: Row[] + dataLength: number + ready: boolean + plot: { width: number; height: number } + margins: Margins + center: { x: number; y: number } + outerRadius: number + innerRadius: number + animate: boolean + animationDuration: number + revision: number + bloom: BloomInput + bloomOnHover: boolean + + seedOf: (key: string) => Seed + variantOf: (key: string) => AreaVariant + registerVariant: (key: string, variant: AreaVariant) => void + unregisterVariant: (key: string) => void + + selectedDataKey: string | null + selectDataKey: (key: string | null) => void + /** Legend-hover spotlight — dims every series but this one while set. */ + focusDataKey: string | null + setFocusDataKey: (key: string | null) => void + hoverIndex: number | null + setHoverIndex: (i: number | null) => void + setCursor: (px: number, py: number) => void + isMouseInChart: boolean + setMouseInChart: (over: boolean) => void + + pie: PieSlice[] | null // present for pie charts + radar: { axes: RadarAxis[]; max: number } | null // present for radar charts + + common: CommonChart +} + +const PolarChartContext = createContext(null) + +export function usePolarChart() { + const ctx = use(PolarChartContext) + if (!ctx) { + throw new Error("Polar chart parts must be used within a polar chart root.") + } + return ctx +} + +/** Boundary guard for polar parts (``, ``). */ +export function usePolarPart(part: string, kind: "pie" | "radar") { + const ctx = use(PolarChartContext) + if (!ctx) { + throw new Error(`<${part} /> must be used within ${ROOT_OF[kind]}.`) + } + if (ctx.chartType !== kind) { + throw new Error( + `<${part} /> is not valid inside ${ROOT_OF[ctx.chartType]} — it belongs in ${ROOT_OF[kind]}.` + ) + } + return ctx +} + +export { PolarChartContext } + +export function usePolarController({ + chartType, + data, + config, + dataKey, + nameKey, + innerRadiusRatio, + dimensions, + margins, + animate = true, + animationDuration = 900, + replayToken = 0, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: { + chartType: "pie" | "radar" + data: Row[] + config: ChartConfig + dataKey: string + nameKey: string + innerRadiusRatio: number + dimensions: Dimensions + margins: Margins + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +}): PolarChartContextValue { + // This object becomes the PolarChartContext value, so its identity — and the + // identity of every function/object it carries — must stay stable across + // renders that don't change the inputs; otherwise every consumer (legend, + // tooltip, slices, axes) re-renders on every parent render. The expensive + // derivations, exposed callbacks, and returned value are memoized below; + // cheap scalars (radii, ready) are left bare as plain recomputed reads. + + // Memoized: drives `pie`/`radar`/`common` — a fresh array would bust them. + const configKeys = useMemo(() => Object.keys(config), [config]) + const revision = useRevision(data, replayToken) + + const [selectedDataKey, setSelectedDataKey] = useState( + defaultSelectedDataKey + ) + const [focusDataKey, setFocusDataKey] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + const [cursorX, setCursorX] = useState(0) + const [cursorY, setCursorY] = useState(0) + const [isMouseInChart, setMouseInChart] = useState(false) + // Stable (only wraps two useState setters) so the value keeps its identity. + const setCursor = useCallback((px: number, py: number) => { + setCursorX(px) + setCursorY(py) + }, []) + const [variants, setVariants] = useState>({}) + + // useCallback for the same reason as registerSeries in chart-context.tsx: + // pie.tsx/radar.tsx list these as effect deps, so without stable identities + // the unregister/register effect re-fires and its setState pair loops. + const registerVariant = useCallback((key: string, variant: AreaVariant) => { + setVariants((prev) => + prev[key] === variant ? prev : { ...prev, [key]: variant } + ) + }, []) + const unregisterVariant = useCallback((key: string) => { + setVariants((prev) => { + if (!(key in prev)) return prev + const next = { ...prev } + delete next[key] + return next + }) + }, []) + + // Stable so the value keeps its identity; re-created only on config change. + const selectDataKey = useCallback( + (key: string | null) => { + setSelectedDataKey(key) + onSelectionChange?.(key) + }, + [onSelectionChange] + ) + + // The root spreads margins fresh every render; pin a stable object off the + // four numbers so it doesn't, on its own, invalidate the value. + const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = margins + const stableMargins = useMemo( + () => ({ top: mTop, right: mRight, bottom: mBottom, left: mLeft }), + [mTop, mRight, mBottom, mLeft] + ) + + const plotWidth = Math.max(0, dimensions.width - mLeft - mRight) + const plotHeight = Math.max(0, dimensions.height - mTop - mBottom) + const ready = plotWidth > 0 && plotHeight > 0 + const pad = chartType === "radar" ? 20 : 6 + const outerRadius = Math.max(0, Math.min(plotWidth, plotHeight) / 2 - pad) + const innerRadius = chartType === "pie" ? outerRadius * innerRadiusRatio : 0 + const centerX = plotWidth / 2 + const centerY = plotHeight / 2 + + // Stable so `common` and the value stay stable; re-created only on config. + const seedOf = useCallback( + (key: string) => seedOfColor(config[key]?.color ?? "grey"), + [config] + ) + // "*" is the pie-wide variant set by ; radar registers per series key. + const variantOf = useCallback( + (key: string) => variants[key] ?? variants["*"] ?? "gradient", + [variants] + ) + + // Memoized: slice geometry — recomputing it on every hover/cursor tick would + // rebuild the pie layout needlessly. + const pie = useMemo( + () => (chartType === "pie" ? pieSlices(data, dataKey, nameKey) : null), + [chartType, data, dataKey, nameKey] + ) + + // Memoized: walks every row × series for the axis max, then builds the axes. + const radar = useMemo(() => { + if (chartType !== "radar") return null + let max = 0 + for (const row of data) { + for (const key of configKeys) { + const v = Number(row[key]) || 0 + if (v > max) max = v + } + } + return { axes: radarAxes(data, nameKey), max: max || 1 } + }, [chartType, data, configKeys, nameKey]) + + // Memoized: this is the value handed to CommonChartContext (Legend/Tooltip), + // so it needs its own stable identity independent of the parent value. + const common: CommonChart = useMemo(() => { + const tooltipLeft = Math.max(48, Math.min(plotWidth + mLeft - 48, cursorX)) + const tooltipTop = Math.max(mTop + 44, cursorY) + const emphasis = selectedDataKey ?? focusDataKey + if (chartType === "pie" && pie) { + const names = pie.map((s) => s.name) + return { + names, + tooltipTop, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft, + heading: (i) => pie[i]?.name ?? null, + itemsAt: (i) => { + const s = pie[i] + if (!s) return [] + return [ + { + name: s.name, + label: config[s.name]?.label ?? s.name, + value: s.value, + seed: seedOf(s.name), + dimmed: emphasis !== null && emphasis !== s.name, + }, + ] + }, + } + } + // radar + return { + names: configKeys, + tooltipTop, + labelOf: (n) => config[n]?.label ?? n, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + tooltipLeft, + heading: (i) => radar?.axes[i]?.label ?? null, + itemsAt: (i) => + configKeys.map((name) => { + const raw = data[i]?.[name] + return { + name, + label: config[name]?.label ?? name, + value: typeof raw === "number" ? raw : 0, + seed: seedOf(name), + dimmed: emphasis !== null && emphasis !== name, + } + }), + } + }, [ + chartType, + config, + configKeys, + data, + pie, + radar, + seedOf, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + ready, + plotWidth, + mLeft, + mTop, + cursorX, + cursorY, + ]) + + // Memoized: this is the PolarChartContext value. A fresh object here would + // re-render every consumer on every parent render — the reason the pieces + // above are stabilized. Rebuilds only when a listed input changes. The + // useState setters are listed but never change identity. + return useMemo( + () => ({ + chartType, + config, + configKeys, + data, + dataLength: data.length, + ready, + plot: { width: plotWidth, height: plotHeight }, + margins: stableMargins, + center: { x: centerX, y: centerY }, + outerRadius, + innerRadius, + animate, + animationDuration, + revision, + bloom, + bloomOnHover, + seedOf, + variantOf, + registerVariant, + unregisterVariant, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + setCursor, + isMouseInChart, + setMouseInChart, + pie, + radar, + common, + }), + [ + chartType, + config, + configKeys, + data, + ready, + plotWidth, + plotHeight, + stableMargins, + centerX, + centerY, + outerRadius, + innerRadius, + animate, + animationDuration, + revision, + bloom, + bloomOnHover, + seedOf, + variantOf, + registerVariant, + unregisterVariant, + selectedDataKey, + selectDataKey, + focusDataKey, + setFocusDataKey, + hoverIndex, + setHoverIndex, + setCursor, + isMouseInChart, + setMouseInChart, + pie, + radar, + common, + ] + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/polar-root.tsx b/drmq-dashboard/src/components/dither-kit/polar-root.tsx new file mode 100644 index 0000000..0b39377 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/polar-root.tsx @@ -0,0 +1,173 @@ +"use client" + +import { + Children, + type ComponentType, + isValidElement, + type ReactNode, +} from "react" +import type { ChartConfig, Margins } from "./chart-context" +import { CommonChartContext } from "./common-context" +import type { BloomInput } from "./dither-paint" +import { cn } from "./lib" +import { axisAtAngle, sliceAtAngle } from "./polar" +import { PolarChartContext, usePolarController } from "./polar-context" +import { useChartDimensions } from "./use-chart-dimensions" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +const DEFAULT_POLAR_MARGINS: Margins = { + top: 22, + right: 14, + bottom: 14, + left: 14, +} + +function layerOf(node: ReactNode): "back" | "dom" | "svg" { + if (!isValidElement(node) || typeof node.type === "string") return "svg" + return (node.type as { chartLayer?: "back" | "dom" }).chartLayer ?? "svg" +} + +export type PolarRootProps = { + chartType: "pie" | "radar" + /** Family painter — `PieCanvas` or `RadarCanvas`; ships with each chart. */ + Canvas: ComponentType + /** Extra back-layer SVG content (e.g. the radar frame). */ + backDecoration?: ReactNode + data: TData[] + config: ChartConfig + children: ReactNode + dataKey: string + nameKey: string + innerRadius?: number // 0–1 ratio (donut); pie only + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +export function PolarRoot({ + chartType, + Canvas, + backDecoration, + data, + config, + children, + dataKey, + nameKey, + innerRadius = 0, + margins: marginsProp, + className, + animate = true, + animationDuration = 900, + replayToken = 0, + bloom = "off", + bloomOnHover = false, + defaultSelectedDataKey = null, + onSelectionChange, +}: PolarRootProps) { + const { ref, size } = useChartDimensions() + const margins = { ...DEFAULT_POLAR_MARGINS, ...marginsProp } + + const ctx = usePolarController({ + chartType, + // Safe: the controller only reads row[key] for the configured keys. + data: data as Record[], + config, + dataKey, + nameKey, + innerRadiusRatio: innerRadius, + dimensions: size, + margins, + animate, + animationDuration, + replayToken, + bloom, + bloomOnHover, + defaultSelectedDataKey, + onSelectionChange, + }) + + const backChildren: ReactNode[] = [] + const svgChildren: ReactNode[] = [] + const domChildren: ReactNode[] = [] + Children.forEach(children, (child) => { + const layer = layerOf(child) + if (layer === "back") backChildren.push(child) + else if (layer === "dom") domChildren.push(child) + else svgChildren.push(child) + }) + + const onMove = (clientX: number, clientY: number) => { + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() + const dx = clientX - rect.left - margins.left - ctx.center.x + const dy = clientY - rect.top - margins.top - ctx.center.y + const angle = Math.atan2(dy, dx) + const r = Math.hypot(dx, dy) + if (chartType === "pie" && ctx.pie) { + const inside = r <= ctx.outerRadius && r >= ctx.innerRadius + const i = inside ? sliceAtAngle(ctx.pie, angle) : -1 + ctx.setHoverIndex(i >= 0 ? i : null) + } else if (ctx.radar) { + ctx.setHoverIndex(axisAtAngle(ctx.radar.axes, angle)) + } + ctx.setCursor(clientX - rect.left, clientY - rect.top) + } + + return ( + + +
ctx.setMouseInChart(true)} + onPointerMove={(e) => onMove(e.clientX, e.clientY)} + onPointerLeave={() => { + ctx.setMouseInChart(false) + ctx.setHoverIndex(null) + }} + > + {ctx.ready && ( + + + {backDecoration} + {backChildren} + + + )} + + {ctx.ready && ( + + + {svgChildren} + + + )} + {domChildren} +
+
+
+ ) +} diff --git a/drmq-dashboard/src/components/dither-kit/polar.ts b/drmq-dashboard/src/components/dither-kit/polar.ts new file mode 100644 index 0000000..f4283bc --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/polar.ts @@ -0,0 +1,122 @@ +type Row = Record + +const TOP = -Math.PI / 2 +const TAU = Math.PI * 2 + +export type PieSlice = { + name: string + value: number + start: number // radians + end: number + mid: number +} + +/** Slice angles from each data row's value under `dataKey`, named by `nameKey`. */ +export function pieSlices( + data: Row[], + dataKey: string, + nameKey: string +): PieSlice[] { + const vals = data.map((r) => Math.max(0, Number(r[dataKey]) || 0)) + const total = vals.reduce((a, b) => a + b, 0) || 1 + let a = TOP + return data.map((r, i) => { + const span = (vals[i] / total) * TAU + const slice = { + name: String(r[nameKey] ?? i), + value: vals[i], + start: a, + end: a + span, + mid: a + span / 2, + } + a += span + return slice + }) +} + +/** Which slice a pointer angle falls in (or -1). */ +export function sliceAtAngle(slices: PieSlice[], angle: number): number { + // Normalize so comparisons against [start, end) (which begin at TOP) work. + let a = angle + while (a < TOP) a += TAU + while (a >= TOP + TAU) a -= TAU + return slices.findIndex((s) => a >= s.start && a < s.end) +} + +export type RadarAxis = { label: string; angle: number } + +/** Evenly-spaced spokes, one per data row, labelled by `nameKey`. */ +export function radarAxes(data: Row[], nameKey: string): RadarAxis[] { + const n = Math.max(data.length, 1) + return data.map((r, i) => ({ + label: String(r[nameKey] ?? i), + angle: TOP + (i / n) * TAU, + })) +} + +/** Nearest radar spoke to a pointer angle. */ +export function axisAtAngle(axes: RadarAxis[], angle: number): number { + let best = 0 + let bestD = Infinity + axes.forEach((ax, i) => { + let d = Math.abs(((angle - ax.angle + Math.PI * 3) % TAU) - Math.PI) + d = Math.abs(d) + if (d < bestD) { + bestD = d + best = i + } + }) + return best +} + +export const polarX = (cx: number, r: number, angle: number) => + cx + Math.cos(angle) * r +export const polarY = (cy: number, r: number, angle: number) => + cy + Math.sin(angle) * r + +/** Even-odd point-in-polygon test (polygon as flat [x0,y0,x1,y1,…]). */ +export function pointInPolygon( + px: number, + py: number, + poly: number[] +): boolean { + let inside = false + const n = poly.length / 2 + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i * 2] + const yi = poly[i * 2 + 1] + const xj = poly[j * 2] + const yj = poly[j * 2 + 1] + if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + return inside +} + +/** Distance from a point to the nearest edge of a polygon — drives the radial + * dither density (dense near the edge, thinning to the centre). */ +export function distToPolygonEdge( + px: number, + py: number, + poly: number[] +): number { + let best = Infinity + const n = poly.length / 2 + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i * 2] + const yi = poly[i * 2 + 1] + const xj = poly[j * 2] + const yj = poly[j * 2 + 1] + const dx = xj - xi + const dy = yj - yi + const len2 = dx * dx + dy * dy || 1 + let t = ((px - xi) * dx + (py - yi) * dy) / len2 + t = Math.max(0, Math.min(1, t)) + const ex = xi + t * dx - px + const ey = yi + t * dy - py + const d = Math.hypot(ex, ey) + if (d < best) best = d + } + return best +} diff --git a/drmq-dashboard/src/components/dither-kit/radar-canvas.tsx b/drmq-dashboard/src/components/dither-kit/radar-canvas.tsx new file mode 100644 index 0000000..58df83f --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/radar-canvas.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef } from "react" +import { + BAYER, + backingSize, + bloomLayerStyle, + easeInOutCubic, + OFF_TIER, + prefersReducedMotion, +} from "./dither-paint" +import { rgb } from "./palette" +import { distToPolygonEdge, pointInPolygon, polarX, polarY } from "./polar" +import { usePolarChart } from "./polar-context" + +/** + * Dither canvas for radar charts. Each series is a closed polygon over the + * spokes (value → radius per axis). Backing pixels inside a polygon are filled + * with the ordered-dither scatter — dense near the polygon edge, thinning toward + * the centre — and each vertex is marked with a bright dot (larger on the hovered + * axis). The polygons scale in from the centre on mount. + */ +export function RadarCanvas() { + const ctx = usePolarChart() + const canvasRef = useRef(null) + const bloomRef = useRef(null) + + const { width, height } = ctx.plot + const { cols, rows } = backingSize(width, height) + + // The RAF loop reads the latest ctx through a ref; written in an effect + // (never during render) — mutating a ref mid-render tears under Strict Mode / + // concurrent rendering. + const state = useRef(ctx) + useEffect(() => { + state.current = ctx + }) + + useEffect(() => { + const canvas = canvasRef.current + const c = canvas?.getContext("2d") + if (!(canvas && c) || cols <= 0 || rows <= 0) return + canvas.width = cols + canvas.height = rows + + const bloomCanvas = bloomRef.current + const bloomCtx = bloomCanvas?.getContext("2d") ?? null + if (bloomCanvas) { + bloomCanvas.width = cols + bloomCanvas.height = rows + } + + const reduce = prefersReducedMotion() + const animate = state.current.animate && !reduce + const duration = state.current.animationDuration + let raf = 0 + let animStart = 0 + let lastProg = -1 + let lastRevision = state.current.revision + let intensity = 0 + let needsFill = true + let lastPaintSig = "" + let lastSelected: string | null | undefined = Symbol() as never + let lastHover: number | null | undefined = Symbol() as never + + const fx = cols / Math.max(width, 1) + const fy = rows / Math.max(height, 1) + + // Build each series polygon in plot coords, scaled by `prog`. + const buildPolys = (prog: number) => { + const s = state.current + const radar = s.radar + if (!radar) return [] + return s.configKeys.map((key) => { + const poly: number[] = [] + const pts: { x: number; y: number }[] = [] + radar.axes.forEach((ax, i) => { + const v = Number(s.data[i]?.[key]) || 0 + const r = (v / radar.max) * s.outerRadius * prog + const x = polarX(s.center.x, r, ax.angle) + const y = polarY(s.center.y, r, ax.angle) + poly.push(x, y) + pts.push({ x, y }) + }) + return { key, poly, pts } + }) + } + + const paint = (prog: number) => { + const s = state.current + if (!s.radar) return + c.clearRect(0, 0, cols, rows) + const polys = buildPolys(easeInOutCubic(prog)) + const band = Math.max(s.outerRadius * 0.45, 1) + + for (let y = 0; y < rows; y++) { + const py = ((y + 0.5) * height) / rows + for (let x = 0; x < cols; x++) { + const px = ((x + 0.5) * width) / cols + // Whether a layer behind already coloured this pixel — front layers + // then keep true gaps in their dither so the back layer shows + // through, instead of tinting the overlap into a muddy blend. + let covered = false + for (let pi = 0; pi < polys.length; pi++) { + const { key, poly } = polys[pi] + if (!pointInPolygon(px, py, poly)) continue + const seed = s.seedOf(key) + const variant = s.variantOf(key) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + const dist = distToPolygonEdge(px, py, poly) + if (dist < 1.4) { + c.fillStyle = rgb(seed.fill, 1, selDim) + c.fillRect(x, y, 1, 1) + covered = true + continue + } + const density = 1 - Math.min(1, dist / band) + const bias = variant === "dotted" ? 0.12 : 0 + // Thin each successive (front) layer so overlapping polygons + // read as distinct layers, not a muddy blend. + const sparse = pi * 0.2 + if (variant === "hatched" && ((x + y) & 3) >= 2) continue + const lit = + variant === "solid" || + density > BAYER[y & 3][x & 3] - 0.1 * intensity - bias + sparse + // Unlit cells: over another layer, stay a real gap (back layer + // shows through); over bare background, paint the faint tier so + // the page never bleeds in. + if (!lit && (variant === "dotted" || covered)) continue + const k = (0.32 + density * 0.68) * (1 + 0.22 * intensity) + const alpha = Math.min(1, (lit ? k : k * OFF_TIER) * selDim) + c.fillStyle = rgb(seed.fill, 1, alpha) + c.fillRect(x, y, 1, 1) + covered = true + } + } + } + + // Vertex markers — larger on the hovered axis. + for (const { key, pts } of polys) { + const seed = s.seedOf(key) + const emphasis = s.selectedDataKey ?? s.focusDataKey + const selDim = emphasis !== null && emphasis !== key ? 0.3 : 1 + pts.forEach((p, i) => { + const bx = Math.round(p.x * fx) + const by = Math.round(p.y * fy) + const big = s.hoverIndex === i + c.fillStyle = rgb(seed.fill, 1, selDim) + const sz = big ? 2 : 1 + c.fillRect(bx - (sz - 1), by - (sz - 1), sz * 2 - 1, sz * 2 - 1) + }) + } + } + + const draw = (now: number) => { + raf = requestAnimationFrame(draw) + const s = state.current + if (!s.ready || !s.radar) return + if (bloomCtx) { + const on = s.bloom !== "off" && (!s.bloomOnHover || s.isMouseInChart) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } + if (s.revision !== lastRevision) { + lastRevision = s.revision + animStart = 0 + lastProg = -1 + } + if (!animStart) animStart = now + const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + + const emphasisNow = s.selectedDataKey ?? s.focusDataKey + if (emphasisNow !== lastSelected) { + lastSelected = emphasisNow + needsFill = true + } + if (s.hoverIndex !== lastHover) { + lastHover = s.hoverIndex + needsFill = true + } + const itTarget = s.isMouseInChart ? 1 : 0 + if (Math.abs(intensity - itTarget) > 0.001) { + intensity += (itTarget - intensity) * (reduce ? 1 : 0.16) + needsFill = true + } else intensity = itTarget + if (prog !== lastProg) { + lastProg = prog + needsFill = true + } + + // Live tweak repaint (variant) without replaying the scale-in. + const paintSig = s.configKeys.map((k) => s.variantOf(k)).join(",") + if (paintSig !== lastPaintSig) { + lastPaintSig = paintSig + needsFill = true + } + + if (!needsFill) return + paint(prog) + needsFill = false + } + + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, [cols, rows, width, height]) + + const bloom = bloomLayerStyle( + ctx.bloom, + ctx.bloomOnHover ? ctx.isMouseInChart : true + ) + const pos = { + left: ctx.margins.left, + top: ctx.margins.top, + width, + height, + } as const + + return ( + <> + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/radar-chart.tsx b/drmq-dashboard/src/components/dither-kit/radar-chart.tsx new file mode 100644 index 0000000..0561ccf --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/radar-chart.tsx @@ -0,0 +1,42 @@ +"use client" + +import type { ReactNode } from "react" +import type { ChartConfig, Margins } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import { PolarRoot } from "./polar-root" +import { RadarCanvas } from "./radar-canvas" +import { RadarFrame } from "./radar-frame" + +// `object` rather than `Record`: interfaces don't get an +// implicit index signature, so interface-typed rows failed to satisfy the +// generic. Internal layers still index rows through their own Row type. +type Row = object + +export type RadarChartProps = { + data: TData[] + config: ChartConfig + children: ReactNode + nameKey: string // axis-label field + margins?: Partial + className?: string + animate?: boolean + animationDuration?: number + replayToken?: number + bloom?: BloomInput + bloomOnHover?: boolean + defaultSelectedDataKey?: string | null + onSelectionChange?: (key: string | null) => void +} + +/** Composable dither **radar** chart. Compose `` series, ``, … inside. */ +export function RadarChart(props: RadarChartProps) { + return ( + } + dataKey="" + {...props} + /> + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/radar-frame.tsx b/drmq-dashboard/src/components/dither-kit/radar-frame.tsx new file mode 100644 index 0000000..be40a81 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/radar-frame.tsx @@ -0,0 +1,70 @@ +import { polarX, polarY } from "./polar" +import { usePolarChart } from "./polar-context" + +const LEVELS = 4 + +/** Built-in radar chrome: concentric polygon rings, spokes, and axis labels. + * Rendered behind the dither canvas by the radar root. */ +export function RadarFrame() { + const ctx = usePolarChart() + if (!ctx.ready || !ctx.radar) return null + const { axes } = ctx.radar + const { x: cx, y: cy } = ctx.center + const R = ctx.outerRadius + + const ring = (radius: number) => + `${axes + .map( + (ax, i) => + `${i === 0 ? "M" : "L"}${polarX(cx, radius, ax.angle).toFixed(1)},${polarY(cy, radius, ax.angle).toFixed(1)}` + ) + .join(" ")} Z` + + return ( + + + {Array.from({ length: LEVELS }, (_, l) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed ring levels + + ))} + {axes.map((ax, i) => ( + + ))} + + + {axes.map((ax, i) => { + const lx = polarX(cx, R + 10, ax.angle) + const ly = polarY(cy, R + 10, ax.angle) + const anchor = + Math.abs(Math.cos(ax.angle)) < 0.3 + ? "middle" + : Math.cos(ax.angle) > 0 + ? "start" + : "end" + const hot = ctx.hoverIndex === i + return ( + + {ax.label} + + ) + })} + + + ) +} + +RadarFrame.chartLayer = "back" as const diff --git a/drmq-dashboard/src/components/dither-kit/radar.tsx b/drmq-dashboard/src/components/dither-kit/radar.tsx new file mode 100644 index 0000000..990c602 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/radar.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useEffect } from "react" +import type { AreaVariant } from "./chart-context" +import { usePolarPart } from "./polar-context" + +export type RadarProps = { + dataKey: string + variant?: AreaVariant +} + +/** + * One radar series — a closed polygon over the spokes. Sets this series' fill + * variant; the dithered polygon is painted on the canvas. + */ +export function Radar({ dataKey, variant = "gradient" }: RadarProps) { + const ctx = usePolarPart("Radar", "radar") + const { registerVariant, unregisterVariant } = ctx + + if (process.env.NODE_ENV !== "production" && !ctx.config[dataKey]) { + console.warn( + `: "${dataKey}" is not in the chart \`config\`. Add it so the series has a colour and label.` + ) + } + + useEffect(() => { + registerVariant(dataKey, variant) + return () => unregisterVariant(dataKey) + }, [dataKey, variant, registerVariant, unregisterVariant]) + + return null +} diff --git a/drmq-dashboard/src/components/dither-kit/reference-line.tsx b/drmq-dashboard/src/components/dither-kit/reference-line.tsx new file mode 100644 index 0000000..7629794 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/reference-line.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useChartPart } from "./chart-context" + +/** + * A horizontal marker line at a value on the y-axis — most useful as the zero + * baseline for diverging data (``), or to mark a target + * / threshold. Renders in the front SVG layer so it stays visible over the + * dither fill; pass an optional `label` to annotate it at the right edge. + */ +export function ReferenceLine({ + y = 0, + label, + strokeDasharray = "4 4", + className = "stroke-muted-foreground/60", +}: { + y?: number + label?: string + strokeDasharray?: string + className?: string +}) { + const ctx = useChartPart("ReferenceLine") + if (!ctx.ready) return null + + const { width } = ctx.plot + const py = ctx.y(y) + + return ( + + + {label ? ( + + {label} + + ) : null} + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/scales.ts b/drmq-dashboard/src/components/dither-kit/scales.ts new file mode 100644 index 0000000..ed0fd2f --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/scales.ts @@ -0,0 +1,109 @@ +import { scaleBand, scaleLinear, scalePoint } from "d3-scale" +import { stack as d3Stack, stackOffsetExpand } from "d3-shape" + +export type StackType = "default" | "stacked" | "percent" + +type Row = Record + +const num = (v: unknown) => + typeof v === "number" && Number.isFinite(v) ? v : 0 + +/** + * Per-series [y0, y1] bands for every row. For `default` every series sits on + * the zero baseline (y0 = 0), so a negative value yields `[0, v]` with `v < 0` + * and draws below the baseline; for `stacked`/`percent` they pile on top of + * each other via d3's stack layout (which splits negatives below zero). The + * shape `bands[key][i] = [y0, y1]` is what both the SVG area paths and the + * canvas overlay read from. `max`/`min` bound the value range so the y-scale + * can span a diverging (below-zero) domain. + */ +export function computeBands( + data: Row[], + keys: string[], + stackType: StackType +): { bands: Record; max: number; min: number } { + if (stackType === "default") { + const bands: Record = {} + let max = 0 + let min = 0 + for (const key of keys) { + bands[key] = data.map((row) => { + const v = num(row[key]) + if (v > max) max = v + if (v < min) min = v + return [0, v] + }) + } + // Only fall back to a unit span when there's no range at all (empty / + // all-zero) — a purely negative series keeps max = 0 so the baseline + // stays pinned to the top of the plot. + const flat = max === 0 && min === 0 + return { bands, max: flat ? 1 : max, min } + } + + const series = d3Stack() + .keys(keys) + .value((row, key) => num(row[key])) + .offset(stackType === "percent" ? stackOffsetExpand : (undefined as never))( + data + ) + + const bands: Record = {} + let max = 0 + let min = 0 + series.forEach((layer) => { + bands[layer.key] = layer.map((point) => { + if (point[1] > max) max = point[1] + if (point[0] < min) min = point[0] + return [point[0], point[1]] + }) + }) + const flat = max === 0 && min === 0 + return { bands, max: flat ? 1 : max, min } +} + +/** x positions for each row index, evenly spread across the plot width. */ +export function buildXScale(length: number, plotWidth: number) { + return scalePoint() + .domain(Array.from({ length }, (_, i) => i)) + .range([0, plotWidth]) +} + +/** Banded x for bar categories — each index owns a slot of `bandwidth` width. */ +export function buildBandScale(length: number, plotWidth: number) { + return scaleBand() + .domain(Array.from({ length }, (_, i) => i)) + .range([0, plotWidth]) + .paddingInner(0.28) + .paddingOuter(0.18) +} + +/** Index of the category whose band a horizontal pixel offset falls in. */ +export function indexAtBand(px: number, length: number, plotWidth: number) { + if (length <= 0 || plotWidth <= 0) return 0 + const t = Math.max(0, Math.min(0.999, px / plotWidth)) + return Math.min(length - 1, Math.floor(t * length)) +} + +/** + * value → vertical pixel. The domain always includes zero, so charts with only + * positive values keep a floor at the plot bottom, while diverging data (values + * below zero) draws below a zero baseline that sits somewhere inside the plot. + */ +export function buildYScale(min: number, max: number, plotHeight: number) { + const lo = Math.min(0, min) + const hi = Math.max(0, max) + // Guard a degenerate (zero-width) domain so `nice()` and the range map stay + // finite even when every value is exactly zero. + return scaleLinear() + .domain([lo, hi === lo ? lo + 1 : hi]) + .nice() + .range([plotHeight, 0]) +} + +/** Index of the row nearest a horizontal pixel offset within the plot. */ +export function nearestIndex(px: number, length: number, plotWidth: number) { + if (length <= 1 || plotWidth <= 0) return 0 + const t = Math.max(0, Math.min(1, px / plotWidth)) + return Math.round(t * (length - 1)) +} diff --git a/drmq-dashboard/src/components/dither-kit/series-context.tsx b/drmq-dashboard/src/components/dither-kit/series-context.tsx new file mode 100644 index 0000000..eb07d26 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/series-context.tsx @@ -0,0 +1,21 @@ +import { createContext, use } from "react" +import type { Seed } from "./palette" + +export type SeriesContextValue = { + dataKey: string + seed: Seed + dimmed: boolean +} + +export const SeriesContext = createContext(null) + +/** Boundary guard for series-scoped markers (``, ``). */ +export function useSeries(part: string) { + const ctx = use(SeriesContext) + if (!ctx) { + throw new Error( + `<${part} /> must be rendered inside a series (e.g. ).` + ) + } + return ctx +} diff --git a/drmq-dashboard/src/components/dither-kit/sparkline.tsx b/drmq-dashboard/src/components/dither-kit/sparkline.tsx new file mode 100644 index 0000000..d04d0a5 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/sparkline.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react" +import { Area } from "./area" +import { AreaChart } from "./area-chart" +import type { AreaVariant } from "./chart-context" +import type { BloomInput } from "./dither-paint" +import type { DitherColor } from "./palette" + +export type SparklineProps = { + /** Plain numeric series — the common sparkline case. */ + data: number[] + color: DitherColor + variant?: AreaVariant + /** Controlled crosshair position (e.g. a committed point). */ + markerIndex?: number | null + /** Parent-driven hover (e.g. the whole card/row) — lifts the fill. */ + hovered?: boolean + /** Glow on the dither fill. */ + bloom?: BloomInput + /** Only bloom while hovered. */ + bloomOnHover?: boolean + /** Play the entrance sweep — off by default for a calm spark. */ + animate?: boolean + className?: string +} + +/** + * Thin wrapper over {@link AreaChart} for the decorative-sparkline case: a + * single `number[]` series, no axes/grid/tooltip, no scrub crosshair (unless a + * `markerIndex` is supplied). Keeps the hover brightness lift. + */ +export function Sparkline({ + data, + color, + variant = "gradient", + markerIndex = null, + hovered = false, + bloom = "off", + bloomOnHover = false, + animate = false, + className, +}: SparklineProps) { + // Memoized explicitly so the chart works without React Compiler: `rows` + // identity drives the entrance-replay revision, so a fresh array every + // render would re-trigger the revision's state adjustment each pass. + const rows = useMemo(() => data.map((v) => ({ v })), [data]) + const config = useMemo(() => ({ v: { color } }), [color]) + + return ( + + + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/tooltip.tsx b/drmq-dashboard/src/components/dither-kit/tooltip.tsx new file mode 100644 index 0000000..3e53ad4 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/tooltip.tsx @@ -0,0 +1,106 @@ +"use client" + +import { AnimatePresence, motion } from "motion/react" +import { useState } from "react" +import { useCommonChart } from "./common-context" +import { cn } from "./lib" +import { rgb } from "./palette" + +export type TooltipVariant = "default" | "frosted-glass" + +const VARIANT: Record = { + default: "bg-popover", + "frosted-glass": "bg-popover/70 backdrop-blur-sm", +} + +/** + * Floating hover tooltip. Reads the shared common context so it works in every + * chart family. It glides between points and fades in/out (instead of snapping), + * and dims unselected series/slices. + */ +export function Tooltip({ + labelKey, + valueFormatter, + variant = "default", +}: { + labelKey?: string + valueFormatter?: (value: number, name: string) => string + variant?: TooltipVariant +}) { + const chart = useCommonChart() + const show = chart.ready && chart.hoverIndex != null + + // Retain the last hovered index so the card keeps its content while fading + // out — adjust-state-during-render (no refs in render). + const [lastIndex, setLastIndex] = useState(0) + if (chart.hoverIndex != null && chart.hoverIndex !== lastIndex) { + setLastIndex(chart.hoverIndex) + } + const index = chart.hoverIndex ?? lastIndex + + const heading = chart.heading(index, labelKey) + const items = chart.itemsAt(index) + + return ( + + {show && items.length > 0 && ( + + {heading && ( +
+ {heading} +
+ )} +
+ {items.map((item) => ( +
+ + {item.label} + + {valueFormatter + ? valueFormatter(item.value, item.name) + : item.value.toLocaleString()} + +
+ ))} +
+
+ )} +
+ ) +} + +Tooltip.chartLayer = "dom" as const diff --git a/drmq-dashboard/src/components/dither-kit/use-chart-dimensions.ts b/drmq-dashboard/src/components/dither-kit/use-chart-dimensions.ts new file mode 100644 index 0000000..6a69a1f --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/use-chart-dimensions.ts @@ -0,0 +1,37 @@ +import { useLayoutEffect, useRef, useState } from "react" + +export type Dimensions = { width: number; height: number } + +/** + * Tracks an element's CSS pixel size via {@link ResizeObserver}. Uses + * `clientWidth`/`clientHeight` (the layout size) rather than + * `getBoundingClientRect()` so a parent `layoutId` morph — which scales the + * element via a transform — can't trick the chart into measuring a scaled size + * and locking its canvas to it. + */ +export function useChartDimensions() { + const ref = useRef(null) + const [size, setSize] = useState({ width: 0, height: 0 }) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + + const measure = () => { + const width = Math.max(0, el.clientWidth) + const height = Math.max(0, el.clientHeight) + setSize((prev) => + prev.width === width && prev.height === height + ? prev // guard against repeat fires + : { width, height } + ) + } + + const ro = new ResizeObserver(measure) + ro.observe(el) + measure() + return () => ro.disconnect() + }, []) + + return { ref, size } +} diff --git a/drmq-dashboard/src/components/dither-kit/x-axis.tsx b/drmq-dashboard/src/components/dither-kit/x-axis.tsx new file mode 100644 index 0000000..ea9f8e6 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/x-axis.tsx @@ -0,0 +1,42 @@ +import { useChartPart } from "./chart-context" + +export function XAxis({ + dataKey, + tickFormatter, + tickMargin = 8, + maxTicks = 8, +}: { + dataKey?: string + tickFormatter?: (value: unknown, index: number) => string + tickMargin?: number + maxTicks?: number +}) { + const ctx = useChartPart("XAxis") + if (!ctx.ready) return null + + const step = Math.max(1, Math.ceil(ctx.dataLength / maxTicks)) + const y = ctx.plot.height + tickMargin + + return ( + + {ctx.data.map((row, i) => { + if (i % step !== 0) return null + const raw = dataKey ? row[dataKey] : i + const label = tickFormatter ? tickFormatter(raw, i) : String(raw ?? "") + return ( + + {label} + + ) + })} + + ) +} diff --git a/drmq-dashboard/src/components/dither-kit/y-axis.tsx b/drmq-dashboard/src/components/dither-kit/y-axis.tsx new file mode 100644 index 0000000..b03db57 --- /dev/null +++ b/drmq-dashboard/src/components/dither-kit/y-axis.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useChartPart } from "./chart-context" + +export function YAxis({ + tickFormatter, + tickCount = 4, + tickMargin = 8, +}: { + tickFormatter?: (value: number) => string + tickCount?: number + tickMargin?: number +}) { + const ctx = useChartPart("YAxis") + if (!ctx.ready) return null + + return ( + + {ctx.y.ticks(tickCount).map((t) => ( + + {tickFormatter ? tickFormatter(t) : t} + + ))} + + ) +} diff --git a/drmq-dashboard/src/index.css b/drmq-dashboard/src/index.css index db8b9a0..81853e5 100644 --- a/drmq-dashboard/src/index.css +++ b/drmq-dashboard/src/index.css @@ -19,6 +19,14 @@ --color-surface-2: #0c1019; --color-surface-3: #111827; --color-surface-4: #1a2035; + + /* Dither Kit Theme Variables */ + --color-foreground: #f4f4f5; + --color-muted-foreground: #71717a; + --color-border: rgba(255, 255, 255, 0.1); + --color-background: #000000; + --color-popover-foreground: #f4f4f5; + --color-popover: #18181b; } @layer base { @@ -215,6 +223,15 @@ font-family: 'JetBrains Mono', monospace; } +/* ── Technical Grid Background ───────────────────────────────────────── */ +.technical-grid { + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 20px 20px; + background-position: center center; +} + /* ── Reduced motion ──────────────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/drmq-dashboard/src/pages/Dashboard.tsx b/drmq-dashboard/src/pages/Dashboard.tsx index f67574c..e436b87 100644 --- a/drmq-dashboard/src/pages/Dashboard.tsx +++ b/drmq-dashboard/src/pages/Dashboard.tsx @@ -1,7 +1,18 @@ -import { Activity, Zap, Users, AlertTriangle, HardDrive, Clock, Radio, GitBranch, Wifi, Camera, XCircle } from 'lucide-react'; +import { Activity, Zap, Users, AlertTriangle, HardDrive, Radio, GitBranch, Wifi, Camera, XCircle, Pause, Play } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useEffect, useRef } from 'react'; import ClusterTopology from '../components/ClusterTopology'; -import { StatCard, SparkLine, Panel, LabeledProgress, formatNum } from '../components/DashboardWidgets'; +import { StatCard, Panel, formatNum } from '../components/DashboardWidgets'; +import { + LineChart, XAxis, YAxis, Legend, Tooltip, Line, + AreaChart, Area, + RadarChart, Radar, + Sparkline, + DitherButton, + DitherAvatar, + BlockLegend, +} from '../components/dither-kit'; +import type { ChartConfig } from '../components/dither-kit'; import type { ClusterEvent } from '../types/telemetry'; /* ── Event Icon Map ──────────────────────────────────────────────────── */ @@ -25,10 +36,46 @@ function timeAgo(ts: number): string { return `${Math.floor(s / 60)}m ago`; } +/* ── Live Clock ─────────────────────────────────────────────────────── */ +function LiveClock() { + const [time, setTime] = useState(() => new Date().toUTCString().slice(17, 25)); + useEffect(() => { + const id = setInterval(() => setTime(new Date().toUTCString().slice(17, 25)), 1000); + return () => clearInterval(id); + }, []); + return {time} UTC; +} + +/* ── Dither Kit chart configs ───────────────────────────────────────── */ +const throughputConfig: ChartConfig = { + produce: { label: 'Produce MB/s', color: 'blue' }, + consume: { label: 'Consume MB/s', color: 'purple' }, +}; + +const radarConfig: ChartConfig = { + value: { label: 'Health', color: 'blue' }, +}; + +const latencyConfig: ChartConfig = { + produce: { label: 'Produce', color: 'blue' }, + consume: { label: 'Consume', color: 'purple' }, + rpc: { label: 'Raft RPC', color: 'orange' }, +}; + /* ═══════════════════════════════════════════════════════════════════════ Dashboard Page ═══════════════════════════════════════════════════════════════════════ */ -export default function Dashboard({ telemetryState, telemetryError }: { telemetryState: any; telemetryError?: string | null }) { +export default function Dashboard({ + telemetryState, + telemetryError, + paused, + onTogglePause, +}: { + telemetryState: any; + telemetryError?: string | null; + paused: boolean; + onTogglePause: () => void; +}) { /* ── Error state ───────────────────────────────────────────────── */ if (telemetryError) { @@ -65,26 +112,87 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr const healthColor = metrics.health === 'OPTIMAL' ? '#10b981' : metrics.health === 'DEGRADED' ? '#f59e0b' : '#ef4444'; const leaderName = nodes.find((n: any) => n.status === 'LEADER')?.name ?? 'No Leader'; + /* ── Throughput chart data ─────────────────────────────────────── */ + const produceHist = metrics.throughputHistory ?? []; + const consumeHist = metrics.consumeHistory ?? []; + const errorHist = metrics.errorHistory ?? []; + + const throughputData = produceHist.map((v: number, i: number) => ({ + t: `-${produceHist.length - i}s`, + produce: parseFloat(((v / 100) * 50).toFixed(2)), + consume: parseFloat((((consumeHist[i] ?? 0) / 100) * 50).toFixed(2)), + })); + + /* ── Radar data for selected/leader node ──────────────────────── */ + const leaderNode = nodes.find((n: any) => n.status === 'LEADER'); + const logScale = (val: number, maxLog = 4) => val <= 0 ? 0 : Math.min(100, (Math.log10(val) / maxLog) * 100); + + const radarData = [ + { axis: 'Sync%', value: metrics.followerSync }, + { axis: 'Produce', value: logScale(metrics.produceRate) }, + { axis: 'Consume', value: logScale(metrics.consumeRate) }, + { axis: 'Latency', value: Math.max(0, 100 - (metrics.produceLatencyMs / 50) * 100) }, + { axis: 'Health', value: metrics.health === 'OPTIMAL' ? 100 : metrics.health === 'DEGRADED' ? 55 : 15 }, + ]; + + /* ── Commit index delta ────────────────────────────────────────── */ + const prevCommit = useRef(metrics.commitIndex); + const delta = metrics.commitIndex - prevCommit.current; + useEffect(() => { prevCommit.current = metrics.commitIndex; }, [metrics.commitIndex]); + + /* ── Latency History (Local) ───────────────────────────────────── */ + const [latencyHistory, setLatencyHistory] = useState([]); + + useEffect(() => { + if (!paused) { + setLatencyHistory(prev => { + const next = [...prev, { + t: new Date().toLocaleTimeString([], { second: '2-digit', minute: '2-digit' }), + produce: metrics.produceLatencyMs, + consume: metrics.consumeLatencyMs, + rpc: latencies.raftRpcMs + }].slice(-30); + return next; + }); + } + }, [metrics.produceLatencyMs, metrics.consumeLatencyMs, latencies.raftRpcMs, paused]); + + return (
{/* ── Header ────────────────────────────────────────────────── */}
-
- Live Telemetry +
+ + {paused ? 'Paused' : 'Live Telemetry'} + / {leaderName}
+ {metrics.health} + }`}>{paused ? 'PAUSED' : metrics.health} {metrics.logSegments} seg · {metrics.topicCount} topics + {/* ── Pause / Resume ─────────────────────────────────── */} + + {paused + ? Resume + : Pause} +
@@ -101,25 +209,28 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr
+ sub={`${metrics.produceThroughputMB.toFixed(2)} MB/s`} + spark={} /> + sub={`${metrics.consumeThroughputMB.toFixed(2)} MB/s`} + spark={} /> + sub={delta > 0 ? ↑ +{formatNum(delta)}/tick : `${metrics.logSegments} segments`} /> 0 ? '#ef4444' : '#3f3f46'} - sub={metrics.errorRate > 0 ? 'check logs' : 'clean'} /> + sub={metrics.errorRate > 0 ? 'check logs' : 'clean'} + spark={ 0 ? 'red' : 'grey'} bloom={metrics.errorRate > 0 ? 'high' : 'off'} className="h-6 w-full" />} />
{/* ── Right panel ───────────────────────────────────────── */} -
+
{/* System Health */} @@ -132,9 +243,9 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr
Term {metrics.term}
-
-
+
+
@@ -144,39 +255,29 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr {metrics.followerSync}%
-
- + = 95 ? '#10b981' : '#f59e0b' }} initial={{ width: 0 }} animate={{ width: `${metrics.followerSync}%` }} transition={{ duration: 0.8 }} />
-
-
-
Commit Index
-
{formatNum(metrics.commitIndex)}
-
-
-
Last Applied
-
{formatNum(metrics.lastApplied)}
-
-
- - - {/* Latency */} - - p50 -
- } className="shrink-0"> -
- - - +
+ formatNum(val).toString()} + />
- {/* I/O Throughput */} + {/* ── I/O Throughput — Dither Kit LineChart ──────────── */} @@ -189,7 +290,60 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr
↑ {metrics.produceThroughputMB.toFixed(2)} · ↓ {metrics.consumeThroughputMB.toFixed(2)}
- +
+ + + + + + + + +
+
+ + {/* ── Node Health Radar ──────────────────────────────── */} + {leaderNode.name} + } className="shrink-0"> +
+ + + +
+
+ + {/* Latency */} + +
+ + + + + + + + + +
{/* Broker Roster */} @@ -199,19 +353,34 @@ export default function Dashboard({ telemetryState, telemetryError }: { telemetr {sortedNodes.map((node: any) => ( + className="flex justify-between items-center bg-white/[0.02] px-3 py-2.5 rounded-lg border border-white/[0.04] hover:border-white/[0.08] transition-colors cursor-pointer">
-
- {node.name} +
+ + {node.status === 'LEADER' && ( +
+ )} +
+
+ {node.name} + {node.id.substring(0, 8)} +
{node.replicationLag !== undefined && node.replicationLag < 0 && ( offline )} {node.replicationLag !== undefined && node.replicationLag > 0 && ( - +{node.replicationLag} +
+
+
15 ? '#ef4444' : '#f59e0b', + }} /> +
+ +{node.replicationLag} +
)} - {/* ── Event Feed ─────────────────────────────────────────── */} + {/* ── Activity Feed ─────────────────────────────────────── */} {events.length} events - } className="flex-1 flex flex-col min-h-[400px]"> + } className="flex-1 flex flex-col min-h-[200px]">
- - {(events as ClusterEvent[]).slice(0, 20).map((evt) => { - const Icon = EVENT_ICON[evt.type] || Radio; - const borderColor = EVENT_COLOR[evt.severity]; - return ( - -
- -
-
-

{evt.message}

- {timeAgo(evt.timestamp)} -
-
- ); - })} -
+ {events.length === 0 ? ( +
+ +

No events yet — waiting
for cluster activity…

+
+ ) : ( + + {(events as ClusterEvent[]).slice(0, 20).map((evt) => { + const Icon = EVENT_ICON[evt.type] || Radio; + const borderColor = EVENT_COLOR[evt.severity]; + return ( + +
+ +
+
+

{evt.message}

+ {timeAgo(evt.timestamp)} +
+
+ ); + })} +
+ )}
diff --git a/drmq-dashboard/src/services/telemetry/MockTelemetryProvider.ts b/drmq-dashboard/src/services/telemetry/MockTelemetryProvider.ts index b860035..a45aca6 100644 --- a/drmq-dashboard/src/services/telemetry/MockTelemetryProvider.ts +++ b/drmq-dashboard/src/services/telemetry/MockTelemetryProvider.ts @@ -64,6 +64,10 @@ export class MockTelemetryProvider implements TelemetryProvider { throughputHistory: Array.from({ length: 30 }, (_, i) => Math.max(5, 50 + Math.sin(i / 4) * 30 + Math.random() * 10) ), + consumeHistory: Array.from({ length: 30 }, (_, i) => + Math.max(3, 30 + Math.sin(i / 4 + 1) * 20 + Math.random() * 8) + ), + errorHistory: Array.from({ length: 30 }, () => 0), }, latencies: { alphaBeta: 3.2, betaGamma: 2.8, raftRpcMs: 3.2 }, events: [], @@ -151,6 +155,8 @@ export class MockTelemetryProvider implements TelemetryProvider { logSegments: metrics.logSegments + (newCommitIndex % 5000 === 0 ? 1 : 0), cachedMessages: Math.min(3000, metrics.cachedMessages + produceRate - consumeRate * 0.8), throughputHistory: newHistory, + consumeHistory: [...metrics.consumeHistory.slice(1), Math.min(100, Math.max(3, ((consumeMB) / 50) * 100))], + errorHistory: [...metrics.errorHistory.slice(1), errorRate], }; this.state.nodes = nodes.map((node, idx) => ({ diff --git a/drmq-dashboard/src/services/telemetry/WebSocketTelemetryProvider.ts b/drmq-dashboard/src/services/telemetry/WebSocketTelemetryProvider.ts index 50cd220..269a728 100644 --- a/drmq-dashboard/src/services/telemetry/WebSocketTelemetryProvider.ts +++ b/drmq-dashboard/src/services/telemetry/WebSocketTelemetryProvider.ts @@ -103,6 +103,19 @@ export class WebSocketTelemetryProvider implements TelemetryProvider { this.reconnectTimers.set(url, timer); } + /** + * Backfill any history arrays the broker doesn't send yet. + * This prevents the dashboard from crashing on undefined.slice(). + */ + private static normalizeMetrics(m: ClusterMetrics): ClusterMetrics { + return { + ...m, + throughputHistory: m.throughputHistory ?? [], + consumeHistory: m.consumeHistory ?? [], + errorHistory: m.errorHistory ?? [], + }; + } + /** * Merge all latest frames into one coherent TelemetryState. * @@ -111,8 +124,7 @@ export class WebSocketTelemetryProvider implements TelemetryProvider { * the leader frame. Followers don't receive client traffic, so their * counts are meaningless for the cluster-level view. * - Node list is a union of all frames, with each broker's self-report - * preferred for its own entry (most accurate), but replicationLag from - * the leader preserved via merge. + * preferred for its own entry (most accurate self-knowledge). * - followerSync uses a fixed scale: 0 lag = 100%, ≥1000 lag = 0%. */ private merge(): TelemetryState { @@ -125,13 +137,20 @@ export class WebSocketTelemetryProvider implements TelemetryProvider { produceRate: 0, consumeRate: 0, errorRate: 0, produceLatencyMs: 0, consumeLatencyMs: 0, activeProducers: 0, activeConsumers: 0, totalConnections: 0, health: 'CRITICAL', term: 0, commitIndex: 0, lastApplied: 0, followerSync: 0, - globalOffset: 0, topicCount: 0, logSegments: 0, cachedMessages: 0, throughputHistory: [] + globalOffset: 0, topicCount: 0, logSegments: 0, cachedMessages: 0, + throughputHistory: [], consumeHistory: [], errorHistory: [] }, latencies: { alphaBeta: 0, betaGamma: 0, raftRpcMs: 0 }, events: [], }; } - if (frames.length === 1) return { ...frames[0], events: frames[0].events ?? [] }; + if (frames.length === 1) { + return { + ...frames[0], + metrics: WebSocketTelemetryProvider.normalizeMetrics(frames[0].metrics), + events: frames[0].events ?? [], + }; + } // Find the most authoritative frame (leader with highest term) const maxTerm = Math.max(...frames.map(f => f.metrics.term ?? 0)); @@ -182,7 +201,7 @@ export class WebSocketTelemetryProvider implements TelemetryProvider { // ── 2. Metrics: exclusively from the leader frame ───────────────────────── // This prevents doubling of activeProducers/activeConsumers when multiple // surviving brokers each report their own Netty connection count. - const metrics: ClusterMetrics = { ...leaderFrame.metrics }; + const metrics: ClusterMetrics = WebSocketTelemetryProvider.normalizeMetrics(leaderFrame.metrics); // Recompute followerSync with a FIXED scale (not proportional to commitIndex). // Scale: 0 lag = 100%, 1000+ lag = 0%. diff --git a/drmq-dashboard/src/types/telemetry.ts b/drmq-dashboard/src/types/telemetry.ts index 42fa7c7..cd80017 100644 --- a/drmq-dashboard/src/types/telemetry.ts +++ b/drmq-dashboard/src/types/telemetry.ts @@ -46,6 +46,8 @@ export interface ClusterMetrics { // Chart throughputHistory: number[]; // 0-100 scaled, 30 points + consumeHistory: number[]; // 0-100 scaled, 30 points + errorHistory: number[]; // errors/s per tick, 30 points } export interface Latencies { diff --git a/drmq-dashboard/src/useClusterTelemetry.ts b/drmq-dashboard/src/useClusterTelemetry.ts index 244d4c2..52c54b9 100644 --- a/drmq-dashboard/src/useClusterTelemetry.ts +++ b/drmq-dashboard/src/useClusterTelemetry.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import type { TelemetryState, TelemetryProvider } from './types/telemetry'; import { MockTelemetryProvider } from './services/telemetry/MockTelemetryProvider'; import { WebSocketTelemetryProvider } from './services/telemetry/WebSocketTelemetryProvider'; @@ -7,35 +7,40 @@ export function useClusterTelemetry() { const [data, setData] = useState(null); const [error, setError] = useState(null); const providerRef = useRef(null); + const onDataRef = useRef<((d: TelemetryState) => void) | null>(null); + const onErrorRef = useRef<((e: string) => void) | null>(null); useEffect(() => { - // Phase 2: Switch between Mock and WebSocket based on environment - // For now, we default to MockProvider until the backend is ready. + const onData = (newData: TelemetryState) => { setData(newData); setError(null); }; + const onError = (errMsg: string) => { setError(errMsg); }; + onDataRef.current = onData; + onErrorRef.current = onError; + const useWebSocket = import.meta.env.VITE_USE_WEBSOCKET === 'true'; - if (useWebSocket) { const defaultUrls = 'ws://localhost:9292,ws://localhost:9293,ws://localhost:9294'; const wsUrlsString = import.meta.env.VITE_WEBSOCKET_URLS || defaultUrls; const wsUrls = wsUrlsString.split(',').map((u: string) => u.trim()); - providerRef.current = new WebSocketTelemetryProvider(wsUrls); } else { providerRef.current = new MockTelemetryProvider(); } - providerRef.current.connect((newData) => { - setData(newData); - setError(null); - }, (errMsg) => { - setError(errMsg); - }); + providerRef.current.connect(onData, onError); return () => { - if (providerRef.current) { - providerRef.current.disconnect(); - } + providerRef.current?.disconnect(); }; }, []); - return { data, error }; + const disconnect = useCallback(() => { + providerRef.current?.disconnect(); + }, []); + + const reconnect = useCallback(() => { + if (!providerRef.current || !onDataRef.current) return; + providerRef.current.connect(onDataRef.current, onErrorRef.current ?? undefined); + }, []); + + return { data, error, provider: { disconnect, reconnect } }; } diff --git a/drmq-dashboard/tsconfig.app.json b/drmq-dashboard/tsconfig.app.json index 7f42e5f..36ddd40 100644 --- a/drmq-dashboard/tsconfig.app.json +++ b/drmq-dashboard/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "skipLibCheck": true, /* Bundler mode */ @@ -19,7 +19,10 @@ "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/drmq-dashboard/vite.config.ts b/drmq-dashboard/vite.config.ts index c4069b7..0cca23e 100644 --- a/drmq-dashboard/vite.config.ts +++ b/drmq-dashboard/vite.config.ts @@ -1,8 +1,14 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, }) From 26329668bcdb85cd16dbbf8d1385d20c1c4bd365 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 17 Jul 2026 20:15:47 +0100 Subject: [PATCH 2/2] fix: improve charting stability with robust animation timing, state handling, and data normalization. --- drmq-dashboard/src/App.tsx | 8 ++- .../src/components/dither-kit/bar-canvas.tsx | 21 +++--- .../src/components/dither-kit/bar.tsx | 3 +- .../src/components/dither-kit/button.tsx | 26 +++++++- .../dither-kit/cartesian-canvas.tsx | 29 ++++---- .../components/dither-kit/chart-context.tsx | 2 +- .../src/components/dither-kit/legend.tsx | 16 +++-- .../src/components/dither-kit/palette.ts | 2 +- .../src/components/dither-kit/pie-canvas.tsx | 21 +++--- .../components/dither-kit/polar-context.tsx | 5 +- .../components/dither-kit/radar-canvas.tsx | 17 ++--- .../src/components/dither-kit/scales.ts | 10 ++- drmq-dashboard/src/pages/Dashboard.tsx | 66 +++++++++++-------- .../telemetry/MockTelemetryProvider.ts | 2 +- 14 files changed, 139 insertions(+), 89 deletions(-) diff --git a/drmq-dashboard/src/App.tsx b/drmq-dashboard/src/App.tsx index d06bd70..fa43a3c 100644 --- a/drmq-dashboard/src/App.tsx +++ b/drmq-dashboard/src/App.tsx @@ -98,8 +98,12 @@ export default function App() { const handleTogglePause = useCallback(() => { setPaused(prev => { const next = !prev; - if (next) provider.disconnect(); - else provider.reconnect(); + // Side effects scheduled outside the updater via queueMicrotask so + // they run exactly once even under StrictMode's double-invoke. + queueMicrotask(() => { + if (next) provider.disconnect(); + else provider.reconnect(); + }); return next; }); }, [provider]); diff --git a/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx b/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx index 62a3b3c..4997eec 100644 --- a/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx +++ b/drmq-dashboard/src/components/dither-kit/bar-canvas.tsx @@ -139,22 +139,14 @@ export function BarCanvas() { raf = requestAnimationFrame(draw) const s = state.current if (!s.ready) return - if (bloomCtx) { - const on = - s.bloom !== "off" && - (!s.bloomOnHover || s.isMouseInChart || s.hovered) - if (on) { - bloomCtx.clearRect(0, 0, cols, rows) - bloomCtx.drawImage(canvas, 0, 0) - } - } if (s.revision !== lastRevision) { lastRevision = s.revision animStart = 0 // re-play the wave on data change / replay lastProg = -1 } if (!animStart) animStart = now - const prog = animate ? Math.min(1, (now - animStart) / duration) : 1 + const safeDuration = duration > 0 ? duration : 1 + const prog = animate ? Math.min(1, (now - animStart) / safeDuration) : 1 if (prog !== lastProg) { lastProg = prog @@ -187,6 +179,15 @@ export function BarCanvas() { if (!needsFill) return paint(prog) needsFill = false + if (bloomCtx) { + const on = + s.bloom !== "off" && + (!s.bloomOnHover || s.isMouseInChart || s.hovered) + if (on) { + bloomCtx.clearRect(0, 0, cols, rows) + bloomCtx.drawImage(canvas, 0, 0) + } + } } raf = requestAnimationFrame(draw) diff --git a/drmq-dashboard/src/components/dither-kit/bar.tsx b/drmq-dashboard/src/components/dither-kit/bar.tsx index 15c8217..4d37bc7 100644 --- a/drmq-dashboard/src/components/dither-kit/bar.tsx +++ b/drmq-dashboard/src/components/dither-kit/bar.tsx @@ -47,7 +47,8 @@ export function Bar({ if (!ctx.ready || !band) return null const seed = ctx.seedOf(dataKey) - const dimmed = ctx.selectedDataKey !== null && ctx.selectedDataKey !== dataKey + const emphasis = ctx.selectedDataKey ?? ctx.focusDataKey + const dimmed = emphasis !== null && emphasis !== dataKey const si = ctx.configKeys.indexOf(dataKey) const n = ctx.configKeys.length const onClick = () => diff --git a/drmq-dashboard/src/components/dither-kit/button.tsx b/drmq-dashboard/src/components/dither-kit/button.tsx index 8ed52a7..b1dcdf6 100644 --- a/drmq-dashboard/src/components/dither-kit/button.tsx +++ b/drmq-dashboard/src/components/dither-kit/button.tsx @@ -1,6 +1,12 @@ "use client" -import { type ComponentProps, useEffect, useRef } from "react" +import { + type ComponentProps, + type Ref, + useCallback, + useEffect, + useRef, +} from "react" import { cn } from "./lib" import { rgb } from "./palette" import { @@ -17,13 +23,15 @@ const CELL = 2 // css px per dither cell — same chunk as the charts export type ButtonVariant = "gradient" | "dotted" | "hatched" | "solid" -export type DitherButtonProps = ComponentProps<"button"> & { +export type DitherButtonProps = Omit, "ref"> & { /** Fill colour — a palette name or a hue (0–360). */ color?: PixelColor /** Fill texture — the same four variants the charts use. */ variant?: ButtonVariant /** Glow on the dither fill. */ bloom?: PixelBloom + /** Forward a ref to the underlying button. */ + ref?: Ref } type PaintState = { @@ -89,12 +97,24 @@ export function DitherButton({ bloom = "off", className, children, + ref: consumerRef, ...props }: DitherButtonProps) { const buttonRef = useRef(null) const canvasRef = useRef(null) const bloomRef = useRef(null) + // Compose the internal buttonRef with any consumer-supplied ref so both + // receive the rendered button element. + const composedRef = useCallback( + (el: HTMLButtonElement | null) => { + (buttonRef as React.MutableRefObject).current = el + if (typeof consumerRef === "function") consumerRef(el) + else if (consumerRef) (consumerRef as React.MutableRefObject).current = el + }, + [consumerRef] + ) + useEffect(() => { const button = buttonRef.current const canvas = canvasRef.current @@ -188,7 +208,7 @@ export function DitherButton({ return (