mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(dashboard): adopt Tremor for charts — first integration
Brings Tremor (Apache-2.0) in as the project's chart component
library and converts the response-time chart as the first
integration. Tremor is distributed copy-paste ("Tremor Raw") so
the components live under `src/components/tremor/`; the only
runtime peer dependency added is `recharts`. Two small local
adaptations to the vendored `BarChart`:
- `cx` (Tremor's `clsx`+`twMerge` helper) → our `cn` from
`@/lib/utils`, aliased locally to keep diff vs upstream minimal.
- `@remixicon/react` legend-slider icons →
`lucide-react`'s `ChevronLeft` / `ChevronRight`, since
`lucide-react` is already a project dep.
- `ChartTooltipProps.label` widened from `string` to
`string | number | undefined` to match recharts 3.x (Tremor's
source still types it for recharts 2.x).
Why Tremor and not a wider switch (Mantine, HeroUI):
- The dashboard ships ~700 lines of hand-rolled SVG across three
chart files — a focused charting library is the highest-ROI
addition without touching the wider component surface.
- Tremor is Tailwind-native and built on the same Radix/Base UI
layer as the existing `src/components/ui/*`, so it composes
rather than displaces.
- Apache-2.0 + copy-paste distribution means no upgrade lock-in.
What this PR does
- Adds `src/components/tremor/{bar-chart.tsx,chart-colors.ts,
get-y-axis-domain.ts,use-on-window-resize.ts}` (vendored from
tremorlabs/tremor with the adaptations above).
- Installs `recharts@^3.8.1` (React 19 + Next.js 16 compatible).
- Rewrites `src/components/dashboard/response-time-chart.tsx`:
drops 130 lines of bespoke SVG math (axis scaling, bar
positioning, grid lines, tooltip rendering), reuses the same
card/header/empty-state shell.
Deferred (own follow-up PRs)
- `pipeline-donut.tsx` — has user-customisable per-stage hex
colours; Tremor's palette would override them. Needs either a
small upstream patch or a `colors` override path.
- `conversations-chart.tsx` — area-chart conversion (largest of
the three, 341 lines).
- The previous hand-rolled bar chart drew a horizontal dashed
"target N minutes" reference line. Tremor BarChart doesn't
expose Recharts primitives, so for now the target is promoted
to a header pill (`target 5m`). On-chart reference line is a
follow-up — either via a positioned overlay or an upstream
`referenceLines` prop.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test` → 319/319 pass.
- `npm run build` → succeeds, dashboard remains a static route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df29707abf
commit
bf90c370fc
318
package-lock.json
generated
318
package-lock.json
generated
@ -24,6 +24,7 @@
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@ -2024,6 +2025,42 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
@ -2336,7 +2373,12 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
@ -2891,6 +2933,12 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
@ -2906,6 +2954,12 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@ -2915,12 +2969,48 @@
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
@ -3004,6 +3094,12 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@ -4628,6 +4724,18 @@
|
||||
"devOptional": true,
|
||||
"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",
|
||||
@ -4668,6 +4776,15 @@
|
||||
"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",
|
||||
@ -4680,6 +4797,31 @@
|
||||
"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-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
@ -4689,6 +4831,42 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@ -4830,6 +5008,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||
@ -5261,6 +5445,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
||||
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@ -5727,6 +5921,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@ -6569,6 +6769,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@ -6616,6 +6826,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
@ -8927,9 +9146,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.11",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
|
||||
@ -8946,6 +9187,57 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@ -10543,6 +10835,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
|
||||
@ -54,6 +54,7 @@
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
@ -3,19 +3,27 @@
|
||||
import { Clock } from 'lucide-react'
|
||||
import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils'
|
||||
import type { ResponseTimeSummary } from '@/lib/dashboard/types'
|
||||
import { BarChart } from '@/components/tremor/bar-chart'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface ResponseTimeChartProps {
|
||||
data: ResponseTimeSummary | null
|
||||
loading: boolean
|
||||
/** Minutes. Horizontal dashed line rendered at this height. */
|
||||
/** Minutes. Surfaced as a "target" pill in the header. The
|
||||
* hand-rolled SVG version drew this as a horizontal dashed
|
||||
* line on the chart; Tremor BarChart doesn't expose Recharts
|
||||
* primitives, so we promote it to the header for now. A
|
||||
* follow-up can introduce an overlay or extend the vendored
|
||||
* BarChart with a `referenceLines` prop. */
|
||||
thresholdMinutes?: number
|
||||
}
|
||||
|
||||
const VB_W = 760
|
||||
const VB_H = 220
|
||||
const PADDING = { top: 24, right: 16, bottom: 32, left: 44 }
|
||||
// Single category, single colour — the data is "average minutes
|
||||
// per weekday". Tremor expects categories as the second tuple in
|
||||
// the row object, so we shape the buckets into
|
||||
// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below.
|
||||
const CATEGORY = 'Avg minutes'
|
||||
|
||||
export function ResponseTimeChart({
|
||||
data,
|
||||
@ -24,9 +32,20 @@ export function ResponseTimeChart({
|
||||
}: ResponseTimeChartProps) {
|
||||
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
|
||||
|
||||
// Map buckets → Tremor rows. Null `avgMinutes` (no samples)
|
||||
// collapses to 0; the chart will render an empty slot for it.
|
||||
// We attach `samples` on the row so a future customTooltip can
|
||||
// surface "no samples" copy without losing the data shape.
|
||||
const chartData =
|
||||
data?.buckets.map((b, i) => ({
|
||||
day: DOW_SHORT_MON_FIRST[i],
|
||||
[CATEGORY]: b.avgMinutes ?? 0,
|
||||
samples: b.samples,
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Average First Response Time
|
||||
@ -36,8 +55,14 @@ export function ResponseTimeChart({
|
||||
weekday
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right text-xs">
|
||||
{thresholdMinutes > 0 && (
|
||||
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
|
||||
target {thresholdMinutes}m
|
||||
</span>
|
||||
)}
|
||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||
<div className="text-right text-xs">
|
||||
<div>
|
||||
<div className="text-slate-400">
|
||||
This week:{' '}
|
||||
<span className="font-medium text-white tabular-nums">
|
||||
@ -50,11 +75,12 @@ export function ResponseTimeChart({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-5">
|
||||
{loading || !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
<Skeleton className="h-[260px] w-full" />
|
||||
) : !hasData ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
@ -62,141 +88,29 @@ export function ResponseTimeChart({
|
||||
hint="This chart fills in as you reply to customer messages."
|
||||
/>
|
||||
) : (
|
||||
<Bars data={data} thresholdMinutes={thresholdMinutes} />
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="day"
|
||||
categories={[CATEGORY]}
|
||||
// 'violet' maps to Tailwind's `fill-violet-500` — matches
|
||||
// the brand accent the hand-rolled bars used (#7c3aed).
|
||||
colors={['violet']}
|
||||
valueFormatter={(value) => `${value.toFixed(1)}m`}
|
||||
showLegend={false}
|
||||
yAxisWidth={48}
|
||||
// Compact height so the chart sits well inside the card
|
||||
// without dominating the row alongside the donut + activity feed.
|
||||
className="h-[260px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Bars({
|
||||
data,
|
||||
thresholdMinutes,
|
||||
}: {
|
||||
data: ResponseTimeSummary
|
||||
thresholdMinutes: number
|
||||
}) {
|
||||
const chartW = VB_W - PADDING.left - PADDING.right
|
||||
const chartH = VB_H - PADDING.top - PADDING.bottom
|
||||
|
||||
const values = data.buckets.map((b) => b.avgMinutes ?? 0)
|
||||
const rawMax = Math.max(thresholdMinutes * 1.2, ...values)
|
||||
const maxY = niceCeil(rawMax)
|
||||
const yFor = (v: number) =>
|
||||
maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH
|
||||
|
||||
const barSlot = chartW / 7
|
||||
const barW = Math.min(44, barSlot * 0.55)
|
||||
|
||||
const ticks = [0, maxY / 2, maxY].map((t) => Math.round(t))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="h-[220px] w-full" role="img">
|
||||
{/* Y grid */}
|
||||
{ticks.map((t) => {
|
||||
const y = yFor(t)
|
||||
return (
|
||||
<g key={t}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgb(30 41 59)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 8}
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
>
|
||||
{t}m
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Threshold line — rendered after grid so it sits on top, but
|
||||
we keep it muted so bars remain the visual focus. */}
|
||||
{thresholdMinutes > 0 && thresholdMinutes <= maxY && (
|
||||
<g>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={yFor(thresholdMinutes)}
|
||||
y2={yFor(thresholdMinutes)}
|
||||
stroke="rgb(244 63 94)"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.25}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<text
|
||||
x={VB_W - PADDING.right - 4}
|
||||
y={yFor(thresholdMinutes) - 4}
|
||||
textAnchor="end"
|
||||
className="fill-rose-300 text-[10px]"
|
||||
>
|
||||
target {thresholdMinutes}m
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Bars */}
|
||||
{data.buckets.map((b, i) => {
|
||||
const v = b.avgMinutes ?? 0
|
||||
const x = PADDING.left + barSlot * i + (barSlot - barW) / 2
|
||||
const y = yFor(v)
|
||||
const h = PADDING.top + chartH - y
|
||||
const muted = b.avgMinutes == null
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={x}
|
||||
y={muted ? PADDING.top + chartH - 2 : y}
|
||||
width={barW}
|
||||
height={muted ? 2 : Math.max(1, h)}
|
||||
rx={4}
|
||||
fill={muted ? 'rgb(51 65 85)' : '#7c3aed'}
|
||||
opacity={muted ? 0.6 : 1}
|
||||
>
|
||||
<title>
|
||||
{DOW_SHORT_MON_FIRST[i]}:{' '}
|
||||
{b.avgMinutes == null ? 'no samples' : `${b.avgMinutes.toFixed(1)} min avg`}
|
||||
{b.samples > 0 ? ` (${b.samples} sample${b.samples === 1 ? '' : 's'})` : ''}
|
||||
</title>
|
||||
</rect>
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={VB_H - 10}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-400 text-[11px]"
|
||||
>
|
||||
{DOW_SHORT_MON_FIRST[i]}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function fmt(mins: number | null): string {
|
||||
if (mins == null) return '—'
|
||||
if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s`
|
||||
if (mins < 60) return `${mins.toFixed(1)}m`
|
||||
return `${(mins / 60).toFixed(1)}h`
|
||||
}
|
||||
|
||||
function niceCeil(max: number): number {
|
||||
if (max <= 0) return 10
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(max)))
|
||||
const n = max / pow
|
||||
let nice: number
|
||||
if (n <= 1) nice = 1
|
||||
else if (n <= 2) nice = 2
|
||||
else if (n <= 5) nice = 5
|
||||
else nice = 10
|
||||
return nice * pow
|
||||
}
|
||||
|
||||
889
src/components/tremor/bar-chart.tsx
Normal file
889
src/components/tremor/bar-chart.tsx
Normal file
@ -0,0 +1,889 @@
|
||||
// ============================================================
|
||||
// Tremor BarChart [v1.0.0] — vendored from tremorlabs/tremor.
|
||||
//
|
||||
// Two local adaptations vs the upstream file:
|
||||
// 1. `cx` (Tremor's `clsx`+`twMerge` helper) is replaced with
|
||||
// our own `cn` from `@/lib/utils`. Aliased locally so the
|
||||
// diff against upstream stays scoped to this `import` line.
|
||||
// 2. The legend slider's arrow icons (`RiArrowLeftSLine` /
|
||||
// `RiArrowRightSLine` from `@remixicon/react`) are swapped
|
||||
// for `ChevronLeft` / `ChevronRight` from `lucide-react`,
|
||||
// which is already in the project's deps.
|
||||
//
|
||||
// Everything else — the chart logic, accessibility behaviour,
|
||||
// tooltip / legend / slider implementation — is unchanged from
|
||||
// the canonical Tremor Raw source.
|
||||
//
|
||||
// License: Apache 2.0 (Tremor).
|
||||
// Source: https://github.com/tremorlabs/tremor/blob/main/src/components/BarChart/BarChart.tsx
|
||||
// ============================================================
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
BarChart as RechartsBarChart,
|
||||
Legend as RechartsLegend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import type { AxisDomain } from "recharts/types/util/types"
|
||||
|
||||
import { cn as cx } from "@/lib/utils"
|
||||
import {
|
||||
AvailableChartColors,
|
||||
type AvailableChartColorsKeys,
|
||||
constructCategoryColors,
|
||||
getColorClassName,
|
||||
} from "./chart-colors"
|
||||
import { getYAxisDomain } from "./get-y-axis-domain"
|
||||
import { useOnWindowResize } from "./use-on-window-resize"
|
||||
|
||||
//#region Shape
|
||||
|
||||
function deepEqual<T>(obj1: T, obj2: T): boolean {
|
||||
if (obj1 === obj2) return true
|
||||
|
||||
if (
|
||||
typeof obj1 !== "object" ||
|
||||
typeof obj2 !== "object" ||
|
||||
obj1 === null ||
|
||||
obj2 === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1) as Array<keyof T>
|
||||
const keys2 = Object.keys(obj2) as Array<keyof T>
|
||||
|
||||
if (keys1.length !== keys2.length) return false
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const renderShape = (
|
||||
props: any,
|
||||
activeBar: any | undefined,
|
||||
activeLegend: string | undefined,
|
||||
layout: string,
|
||||
) => {
|
||||
const { fillOpacity, name, payload, value } = props
|
||||
let { x, width, y, height } = props
|
||||
|
||||
if (layout === "horizontal" && height < 0) {
|
||||
y += height
|
||||
height = Math.abs(height)
|
||||
} else if (layout === "vertical" && width < 0) {
|
||||
x += width
|
||||
width = Math.abs(width)
|
||||
}
|
||||
|
||||
return (
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
opacity={
|
||||
activeBar || (activeLegend && activeLegend !== name)
|
||||
? deepEqual(activeBar, { ...payload, value })
|
||||
? fillOpacity
|
||||
: 0.3
|
||||
: fillOpacity
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
//#region Legend
|
||||
|
||||
interface LegendItemProps {
|
||||
name: string
|
||||
color: AvailableChartColorsKeys
|
||||
onClick?: (name: string, color: AvailableChartColorsKeys) => void
|
||||
activeLegend?: string
|
||||
}
|
||||
|
||||
const LegendItem = ({
|
||||
name,
|
||||
color,
|
||||
onClick,
|
||||
activeLegend,
|
||||
}: LegendItemProps) => {
|
||||
const hasOnValueChange = !!onClick
|
||||
return (
|
||||
<li
|
||||
className={cx(
|
||||
"group inline-flex flex-nowrap items-center gap-1.5 rounded-sm px-2 py-1 whitespace-nowrap transition",
|
||||
hasOnValueChange
|
||||
? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
: "cursor-default",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.(name, color)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"size-2 shrink-0 rounded-xs",
|
||||
getColorClassName(color, "bg"),
|
||||
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
|
||||
)}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<p
|
||||
className={cx(
|
||||
"truncate text-xs whitespace-nowrap",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
hasOnValueChange &&
|
||||
"group-hover:text-gray-900 dark:group-hover:text-gray-50",
|
||||
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScrollButtonProps {
|
||||
icon: React.ElementType
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => {
|
||||
const Icon = icon
|
||||
const [isPressed, setIsPressed] = React.useState(false)
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isPressed) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
onClick?.()
|
||||
}, 300)
|
||||
} else {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout)
|
||||
}
|
||||
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
|
||||
}, [isPressed, onClick])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disabled) {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout)
|
||||
setIsPressed(false)
|
||||
}
|
||||
}, [disabled])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
"group inline-flex size-5 items-center truncate rounded-sm transition",
|
||||
disabled
|
||||
? "cursor-not-allowed text-gray-400 dark:text-gray-600"
|
||||
: "cursor-pointer text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.()
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsPressed(true)
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsPressed(false)
|
||||
}}
|
||||
>
|
||||
<Icon className="size-full" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
|
||||
categories: string[]
|
||||
colors?: AvailableChartColorsKeys[]
|
||||
onClickLegendItem?: (category: string, color: string) => void
|
||||
activeLegend?: string
|
||||
enableLegendSlider?: boolean
|
||||
}
|
||||
|
||||
type HasScrollProps = {
|
||||
left: boolean
|
||||
right: boolean
|
||||
}
|
||||
|
||||
const Legend = React.forwardRef<HTMLOListElement, LegendProps>((props, ref) => {
|
||||
const {
|
||||
categories,
|
||||
colors = AvailableChartColors,
|
||||
className,
|
||||
onClickLegendItem,
|
||||
activeLegend,
|
||||
enableLegendSlider = false,
|
||||
...other
|
||||
} = props
|
||||
const scrollableRef = React.useRef<HTMLInputElement>(null)
|
||||
const scrollButtonsRef = React.useRef<HTMLDivElement>(null)
|
||||
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(null)
|
||||
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null)
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const checkScroll = React.useCallback(() => {
|
||||
const scrollable = scrollableRef?.current
|
||||
if (!scrollable) return
|
||||
|
||||
const hasLeftScroll = scrollable.scrollLeft > 0
|
||||
const hasRightScroll =
|
||||
scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft
|
||||
|
||||
setHasScroll({ left: hasLeftScroll, right: hasRightScroll })
|
||||
}, [setHasScroll])
|
||||
|
||||
const scrollToTest = React.useCallback(
|
||||
(direction: "left" | "right") => {
|
||||
const element = scrollableRef?.current
|
||||
const scrollButtons = scrollButtonsRef?.current
|
||||
const scrollButtonsWith = scrollButtons?.clientWidth ?? 0
|
||||
const width = element?.clientWidth ?? 0
|
||||
|
||||
if (element && enableLegendSlider) {
|
||||
element.scrollTo({
|
||||
left:
|
||||
direction === "left"
|
||||
? element.scrollLeft - width + scrollButtonsWith
|
||||
: element.scrollLeft + width - scrollButtonsWith,
|
||||
behavior: "smooth",
|
||||
})
|
||||
setTimeout(() => {
|
||||
checkScroll()
|
||||
}, 400)
|
||||
}
|
||||
},
|
||||
[enableLegendSlider, checkScroll],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const keyDownHandler = (key: string) => {
|
||||
if (key === "ArrowLeft") {
|
||||
scrollToTest("left")
|
||||
} else if (key === "ArrowRight") {
|
||||
scrollToTest("right")
|
||||
}
|
||||
}
|
||||
if (isKeyDowned) {
|
||||
keyDownHandler(isKeyDowned)
|
||||
intervalRef.current = setInterval(() => {
|
||||
keyDownHandler(isKeyDowned)
|
||||
}, 300)
|
||||
} else {
|
||||
clearInterval(intervalRef.current as NodeJS.Timeout)
|
||||
}
|
||||
return () => clearInterval(intervalRef.current as NodeJS.Timeout)
|
||||
}, [isKeyDowned, scrollToTest])
|
||||
|
||||
const keyDown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault()
|
||||
setIsKeyDowned(e.key)
|
||||
}
|
||||
}
|
||||
const keyUp = (e: KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsKeyDowned(null)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const scrollable = scrollableRef?.current
|
||||
if (enableLegendSlider) {
|
||||
checkScroll()
|
||||
scrollable?.addEventListener("keydown", keyDown)
|
||||
scrollable?.addEventListener("keyup", keyUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollable?.removeEventListener("keydown", keyDown)
|
||||
scrollable?.removeEventListener("keyup", keyUp)
|
||||
}
|
||||
}, [checkScroll, enableLegendSlider])
|
||||
|
||||
return (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cx("relative overflow-hidden", className)}
|
||||
{...other}
|
||||
>
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
tabIndex={0}
|
||||
className={cx(
|
||||
"flex h-full",
|
||||
enableLegendSlider
|
||||
? hasScroll?.right || hasScroll?.left
|
||||
? "snap-mandatory items-center overflow-auto pr-12 pl-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
: ""
|
||||
: "flex-wrap",
|
||||
)}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<LegendItem
|
||||
key={`item-${index}`}
|
||||
name={category}
|
||||
color={colors[index] as AvailableChartColorsKeys}
|
||||
onClick={onClickLegendItem}
|
||||
activeLegend={activeLegend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
|
||||
<>
|
||||
<div
|
||||
ref={scrollButtonsRef}
|
||||
className={cx(
|
||||
"absolute top-0 right-0 bottom-0 flex h-full items-center justify-center pr-1",
|
||||
"bg-white dark:bg-gray-950",
|
||||
)}
|
||||
>
|
||||
<ScrollButton
|
||||
icon={ChevronLeft}
|
||||
onClick={() => {
|
||||
setIsKeyDowned(null)
|
||||
scrollToTest("left")
|
||||
}}
|
||||
disabled={!hasScroll?.left}
|
||||
/>
|
||||
<ScrollButton
|
||||
icon={ChevronRight}
|
||||
onClick={() => {
|
||||
setIsKeyDowned(null)
|
||||
scrollToTest("right")
|
||||
}}
|
||||
disabled={!hasScroll?.right}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</ol>
|
||||
)
|
||||
})
|
||||
|
||||
Legend.displayName = "Legend"
|
||||
|
||||
const ChartLegend = (
|
||||
{ payload }: any,
|
||||
categoryColors: Map<string, AvailableChartColorsKeys>,
|
||||
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
|
||||
activeLegend: string | undefined,
|
||||
onClick?: (category: string, color: string) => void,
|
||||
enableLegendSlider?: boolean,
|
||||
legendPosition?: "left" | "center" | "right",
|
||||
yAxisWidth?: number,
|
||||
) => {
|
||||
const legendRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
useOnWindowResize(() => {
|
||||
const calculateHeight = (height: number | undefined) =>
|
||||
height ? Number(height) + 15 : 60
|
||||
setLegendHeight(calculateHeight(legendRef.current?.clientHeight))
|
||||
})
|
||||
|
||||
const filteredPayload = payload.filter((item: any) => item.type !== "none")
|
||||
|
||||
const paddingLeft =
|
||||
legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ paddingLeft: paddingLeft }}
|
||||
ref={legendRef}
|
||||
className={cx(
|
||||
"flex items-center",
|
||||
{ "justify-center": legendPosition === "center" },
|
||||
{
|
||||
"justify-start": legendPosition === "left",
|
||||
},
|
||||
{ "justify-end": legendPosition === "right" },
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
categories={filteredPayload.map((entry: any) => entry.value)}
|
||||
colors={filteredPayload.map((entry: any) =>
|
||||
categoryColors.get(entry.value),
|
||||
)}
|
||||
onClickLegendItem={onClick}
|
||||
activeLegend={activeLegend}
|
||||
enableLegendSlider={enableLegendSlider}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//#region Tooltip
|
||||
|
||||
type TooltipProps = Pick<ChartTooltipProps, "active" | "payload" | "label">
|
||||
|
||||
type PayloadItem = {
|
||||
category: string
|
||||
value: number
|
||||
index: string
|
||||
color: AvailableChartColorsKeys
|
||||
type?: string
|
||||
payload: any
|
||||
}
|
||||
|
||||
interface ChartTooltipProps {
|
||||
active: boolean | undefined
|
||||
payload: PayloadItem[]
|
||||
// Recharts 3.x widened this from `string` to `string | number | undefined`.
|
||||
// Tremor's source (written against recharts ^2.13.3) still types it as
|
||||
// `string`; we widen here so the vendored file typechecks against the
|
||||
// current recharts release without forcing a downgrade.
|
||||
label: string | number | undefined
|
||||
valueFormatter: (value: number) => string
|
||||
}
|
||||
|
||||
const ChartTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter,
|
||||
}: ChartTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"rounded-md border text-sm shadow-md",
|
||||
"border-gray-200 dark:border-gray-800",
|
||||
"bg-white dark:bg-gray-950",
|
||||
)}
|
||||
>
|
||||
<div className={cx("border-b border-inherit px-4 py-2")}>
|
||||
<p
|
||||
className={cx("font-medium", "text-gray-900 dark:text-gray-50")}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cx("space-y-1 px-4 py-2")}>
|
||||
{payload.map(({ value, category, color }, index) => (
|
||||
<div
|
||||
key={`id-${index}`}
|
||||
className="flex items-center justify-between space-x-8"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
"size-2 shrink-0 rounded-xs",
|
||||
getColorClassName(color, "bg"),
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cx(
|
||||
"text-right whitespace-nowrap",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={cx(
|
||||
"text-right font-medium whitespace-nowrap tabular-nums",
|
||||
"text-gray-900 dark:text-gray-50",
|
||||
)}
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
//#region BarChart
|
||||
|
||||
type BaseEventProps = {
|
||||
eventType: "category" | "bar"
|
||||
categoryClicked: string
|
||||
[key: string]: number | string
|
||||
}
|
||||
|
||||
type BarChartEventProps = BaseEventProps | null | undefined
|
||||
|
||||
interface BarChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Record<string, any>[]
|
||||
index: string
|
||||
categories: string[]
|
||||
colors?: AvailableChartColorsKeys[]
|
||||
valueFormatter?: (value: number) => string
|
||||
startEndOnly?: boolean
|
||||
showXAxis?: boolean
|
||||
showYAxis?: boolean
|
||||
showGridLines?: boolean
|
||||
yAxisWidth?: number
|
||||
intervalType?: "preserveStartEnd" | "equidistantPreserveStart"
|
||||
showTooltip?: boolean
|
||||
showLegend?: boolean
|
||||
autoMinValue?: boolean
|
||||
minValue?: number
|
||||
maxValue?: number
|
||||
allowDecimals?: boolean
|
||||
onValueChange?: (value: BarChartEventProps) => void
|
||||
enableLegendSlider?: boolean
|
||||
tickGap?: number
|
||||
barCategoryGap?: string | number
|
||||
xAxisLabel?: string
|
||||
yAxisLabel?: string
|
||||
layout?: "vertical" | "horizontal"
|
||||
type?: "default" | "stacked" | "percent"
|
||||
legendPosition?: "left" | "center" | "right"
|
||||
tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void
|
||||
customTooltip?: React.ComponentType<TooltipProps>
|
||||
}
|
||||
|
||||
const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
|
||||
(props, forwardedRef) => {
|
||||
const {
|
||||
data = [],
|
||||
categories = [],
|
||||
index,
|
||||
colors = AvailableChartColors,
|
||||
valueFormatter = (value: number) => value.toString(),
|
||||
startEndOnly = false,
|
||||
showXAxis = true,
|
||||
showYAxis = true,
|
||||
showGridLines = true,
|
||||
yAxisWidth = 56,
|
||||
intervalType = "equidistantPreserveStart",
|
||||
showTooltip = true,
|
||||
showLegend = true,
|
||||
autoMinValue = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
allowDecimals = true,
|
||||
className,
|
||||
onValueChange,
|
||||
enableLegendSlider = false,
|
||||
barCategoryGap,
|
||||
tickGap = 5,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
layout = "horizontal",
|
||||
type = "default",
|
||||
legendPosition = "right",
|
||||
tooltipCallback,
|
||||
customTooltip,
|
||||
...other
|
||||
} = props
|
||||
const CustomTooltip = customTooltip
|
||||
const paddingValue =
|
||||
(!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20
|
||||
const [legendHeight, setLegendHeight] = React.useState(60)
|
||||
const [activeLegend, setActiveLegend] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const categoryColors = constructCategoryColors(categories, colors)
|
||||
const [activeBar, setActiveBar] = React.useState<any | undefined>(undefined)
|
||||
const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue)
|
||||
const hasOnValueChange = !!onValueChange
|
||||
const stacked = type === "stacked" || type === "percent"
|
||||
|
||||
const prevActiveRef = React.useRef<boolean | undefined>(undefined)
|
||||
// Widened from `string | undefined` to match the recharts 3.x
|
||||
// `label` type (see the ChartTooltipProps note below).
|
||||
const prevLabelRef = React.useRef<string | number | undefined>(undefined)
|
||||
|
||||
function valueToPercent(value: number) {
|
||||
return `${(value * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function onBarClick(data: any, _: any, event: React.MouseEvent) {
|
||||
event.stopPropagation()
|
||||
if (!onValueChange) return
|
||||
if (deepEqual(activeBar, { ...data.payload, value: data.value })) {
|
||||
setActiveLegend(undefined)
|
||||
setActiveBar(undefined)
|
||||
onValueChange?.(null)
|
||||
} else {
|
||||
setActiveLegend(data.tooltipPayload?.[0]?.dataKey)
|
||||
setActiveBar({
|
||||
...data.payload,
|
||||
value: data.value,
|
||||
})
|
||||
onValueChange?.({
|
||||
eventType: "bar",
|
||||
categoryClicked: data.tooltipPayload?.[0]?.dataKey,
|
||||
...data.payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onCategoryClick(dataKey: string) {
|
||||
if (!hasOnValueChange) return
|
||||
if (dataKey === activeLegend && !activeBar) {
|
||||
setActiveLegend(undefined)
|
||||
onValueChange?.(null)
|
||||
} else {
|
||||
setActiveLegend(dataKey)
|
||||
onValueChange?.({
|
||||
eventType: "category",
|
||||
categoryClicked: dataKey,
|
||||
})
|
||||
}
|
||||
setActiveBar(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cx("h-80 w-full", className)}
|
||||
tremor-id="tremor-raw"
|
||||
{...other}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
onClick={
|
||||
hasOnValueChange && (activeLegend || activeBar)
|
||||
? () => {
|
||||
setActiveBar(undefined)
|
||||
setActiveLegend(undefined)
|
||||
onValueChange?.(null)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
margin={{
|
||||
bottom: xAxisLabel ? 30 : undefined,
|
||||
left: yAxisLabel ? 20 : undefined,
|
||||
right: yAxisLabel ? 5 : undefined,
|
||||
top: 5,
|
||||
}}
|
||||
stackOffset={type === "percent" ? "expand" : undefined}
|
||||
layout={layout}
|
||||
barCategoryGap={barCategoryGap}
|
||||
>
|
||||
{showGridLines ? (
|
||||
<CartesianGrid
|
||||
className={cx("stroke-gray-200 stroke-1 dark:stroke-gray-800")}
|
||||
horizontal={layout !== "vertical"}
|
||||
vertical={layout === "vertical"}
|
||||
/>
|
||||
) : null}
|
||||
<XAxis
|
||||
hide={!showXAxis}
|
||||
tick={{
|
||||
transform:
|
||||
layout !== "vertical" ? "translate(0, 6)" : undefined,
|
||||
}}
|
||||
fill=""
|
||||
stroke=""
|
||||
className={cx(
|
||||
"text-xs",
|
||||
"fill-gray-500 dark:fill-gray-500",
|
||||
{ "mt-4": layout !== "vertical" },
|
||||
)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={tickGap}
|
||||
{...(layout !== "vertical"
|
||||
? {
|
||||
padding: {
|
||||
left: paddingValue,
|
||||
right: paddingValue,
|
||||
},
|
||||
dataKey: index,
|
||||
interval: startEndOnly ? "preserveStartEnd" : intervalType,
|
||||
ticks: startEndOnly
|
||||
? [data[0][index], data[data.length - 1][index]]
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
type: "number",
|
||||
domain: yAxisDomain as AxisDomain,
|
||||
tickFormatter:
|
||||
type === "percent" ? valueToPercent : valueFormatter,
|
||||
allowDecimals: allowDecimals,
|
||||
})}
|
||||
>
|
||||
{xAxisLabel && (
|
||||
<Label
|
||||
position="insideBottom"
|
||||
offset={-20}
|
||||
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
|
||||
>
|
||||
{xAxisLabel}
|
||||
</Label>
|
||||
)}
|
||||
</XAxis>
|
||||
<YAxis
|
||||
width={yAxisWidth}
|
||||
hide={!showYAxis}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
fill=""
|
||||
stroke=""
|
||||
className={cx(
|
||||
"text-xs",
|
||||
"fill-gray-500 dark:fill-gray-500",
|
||||
)}
|
||||
tick={{
|
||||
transform:
|
||||
layout !== "vertical"
|
||||
? "translate(-3, 0)"
|
||||
: "translate(0, 0)",
|
||||
}}
|
||||
{...(layout !== "vertical"
|
||||
? {
|
||||
type: "number",
|
||||
domain: yAxisDomain as AxisDomain,
|
||||
tickFormatter:
|
||||
type === "percent" ? valueToPercent : valueFormatter,
|
||||
allowDecimals: allowDecimals,
|
||||
}
|
||||
: {
|
||||
dataKey: index,
|
||||
ticks: startEndOnly
|
||||
? [data[0][index], data[data.length - 1][index]]
|
||||
: undefined,
|
||||
type: "category",
|
||||
interval: "equidistantPreserveStart",
|
||||
})}
|
||||
>
|
||||
{yAxisLabel && (
|
||||
<Label
|
||||
position="insideLeft"
|
||||
style={{ textAnchor: "middle" }}
|
||||
angle={-90}
|
||||
offset={-15}
|
||||
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
|
||||
>
|
||||
{yAxisLabel}
|
||||
</Label>
|
||||
)}
|
||||
</YAxis>
|
||||
<Tooltip
|
||||
wrapperStyle={{ outline: "none" }}
|
||||
isAnimationActive={true}
|
||||
animationDuration={100}
|
||||
cursor={{ fill: "#d1d5db", opacity: "0.15" }}
|
||||
offset={20}
|
||||
position={{
|
||||
y: layout === "horizontal" ? 0 : undefined,
|
||||
x: layout === "horizontal" ? undefined : yAxisWidth + 20,
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
const cleanPayload: TooltipProps["payload"] = payload
|
||||
? payload.map((item: any) => ({
|
||||
category: item.dataKey,
|
||||
value: item.value,
|
||||
index: item.payload[index],
|
||||
color: categoryColors.get(
|
||||
item.dataKey,
|
||||
) as AvailableChartColorsKeys,
|
||||
type: item.type,
|
||||
payload: item.payload,
|
||||
}))
|
||||
: []
|
||||
|
||||
if (
|
||||
tooltipCallback &&
|
||||
(active !== prevActiveRef.current ||
|
||||
label !== prevLabelRef.current)
|
||||
) {
|
||||
tooltipCallback({ active, payload: cleanPayload, label })
|
||||
prevActiveRef.current = active
|
||||
prevLabelRef.current = label
|
||||
}
|
||||
|
||||
return showTooltip && active ? (
|
||||
CustomTooltip ? (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
payload={cleanPayload}
|
||||
label={label}
|
||||
/>
|
||||
) : (
|
||||
<ChartTooltip
|
||||
active={active}
|
||||
payload={cleanPayload}
|
||||
label={label}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
}}
|
||||
/>
|
||||
{showLegend ? (
|
||||
<RechartsLegend
|
||||
verticalAlign="top"
|
||||
height={legendHeight}
|
||||
content={({ payload }) =>
|
||||
ChartLegend(
|
||||
{ payload },
|
||||
categoryColors,
|
||||
setLegendHeight,
|
||||
activeLegend,
|
||||
hasOnValueChange
|
||||
? (clickedLegendItem: string) =>
|
||||
onCategoryClick(clickedLegendItem)
|
||||
: undefined,
|
||||
enableLegendSlider,
|
||||
legendPosition,
|
||||
yAxisWidth,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{categories.map((category) => (
|
||||
<Bar
|
||||
className={cx(
|
||||
getColorClassName(
|
||||
categoryColors.get(category) as AvailableChartColorsKeys,
|
||||
"fill",
|
||||
),
|
||||
onValueChange ? "cursor-pointer" : "",
|
||||
)}
|
||||
key={category}
|
||||
name={category}
|
||||
type="linear"
|
||||
dataKey={category}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
isAnimationActive={false}
|
||||
fill=""
|
||||
shape={(props: any) =>
|
||||
renderShape(props, activeBar, activeLegend, layout)
|
||||
}
|
||||
onClick={onBarClick}
|
||||
/>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BarChart.displayName = "BarChart"
|
||||
|
||||
export { BarChart, type BarChartEventProps, type BarChartProps, type TooltipProps }
|
||||
105
src/components/tremor/chart-colors.ts
Normal file
105
src/components/tremor/chart-colors.ts
Normal file
@ -0,0 +1,105 @@
|
||||
// ============================================================
|
||||
// Tremor chartColors [v0.1.0] — copied from tremorlabs/tremor.
|
||||
//
|
||||
// The Tremor charts source uses these helpers to map a category
|
||||
// name to a stable Tailwind color class (`bg-violet-500`,
|
||||
// `fill-blue-500`, …). Tremor's "Raw" distribution is copy-paste
|
||||
// — there is no `@tremor/raw` npm package — so the canonical
|
||||
// approach is to vendor the file unchanged and customise locally.
|
||||
//
|
||||
// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/chartColors.ts
|
||||
// License: Apache 2.0 (Tremor)
|
||||
// ============================================================
|
||||
|
||||
export type ColorUtility = "bg" | "stroke" | "fill" | "text"
|
||||
|
||||
export const chartColors = {
|
||||
blue: {
|
||||
bg: "bg-blue-500",
|
||||
stroke: "stroke-blue-500",
|
||||
fill: "fill-blue-500",
|
||||
text: "text-blue-500",
|
||||
},
|
||||
emerald: {
|
||||
bg: "bg-emerald-500",
|
||||
stroke: "stroke-emerald-500",
|
||||
fill: "fill-emerald-500",
|
||||
text: "text-emerald-500",
|
||||
},
|
||||
violet: {
|
||||
bg: "bg-violet-500",
|
||||
stroke: "stroke-violet-500",
|
||||
fill: "fill-violet-500",
|
||||
text: "text-violet-500",
|
||||
},
|
||||
amber: {
|
||||
bg: "bg-amber-500",
|
||||
stroke: "stroke-amber-500",
|
||||
fill: "fill-amber-500",
|
||||
text: "text-amber-500",
|
||||
},
|
||||
gray: {
|
||||
bg: "bg-gray-500",
|
||||
stroke: "stroke-gray-500",
|
||||
fill: "fill-gray-500",
|
||||
text: "text-gray-500",
|
||||
},
|
||||
cyan: {
|
||||
bg: "bg-cyan-500",
|
||||
stroke: "stroke-cyan-500",
|
||||
fill: "fill-cyan-500",
|
||||
text: "text-cyan-500",
|
||||
},
|
||||
pink: {
|
||||
bg: "bg-pink-500",
|
||||
stroke: "stroke-pink-500",
|
||||
fill: "fill-pink-500",
|
||||
text: "text-pink-500",
|
||||
},
|
||||
lime: {
|
||||
bg: "bg-lime-500",
|
||||
stroke: "stroke-lime-500",
|
||||
fill: "fill-lime-500",
|
||||
text: "text-lime-500",
|
||||
},
|
||||
fuchsia: {
|
||||
bg: "bg-fuchsia-500",
|
||||
stroke: "stroke-fuchsia-500",
|
||||
fill: "fill-fuchsia-500",
|
||||
text: "text-fuchsia-500",
|
||||
},
|
||||
} as const satisfies {
|
||||
[color: string]: {
|
||||
[key in ColorUtility]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type AvailableChartColorsKeys = keyof typeof chartColors
|
||||
|
||||
export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys(
|
||||
chartColors,
|
||||
) as Array<AvailableChartColorsKeys>
|
||||
|
||||
export const constructCategoryColors = (
|
||||
categories: string[],
|
||||
colors: AvailableChartColorsKeys[],
|
||||
): Map<string, AvailableChartColorsKeys> => {
|
||||
const categoryColors = new Map<string, AvailableChartColorsKeys>()
|
||||
categories.forEach((category, index) => {
|
||||
categoryColors.set(category, colors[index % colors.length])
|
||||
})
|
||||
return categoryColors
|
||||
}
|
||||
|
||||
export const getColorClassName = (
|
||||
color: AvailableChartColorsKeys,
|
||||
type: ColorUtility,
|
||||
): string => {
|
||||
const fallbackColor = {
|
||||
bg: "bg-gray-500",
|
||||
stroke: "stroke-gray-500",
|
||||
fill: "fill-gray-500",
|
||||
text: "text-gray-500",
|
||||
}
|
||||
return chartColors[color]?.[type] ?? fallbackColor[type]
|
||||
}
|
||||
13
src/components/tremor/get-y-axis-domain.ts
Normal file
13
src/components/tremor/get-y-axis-domain.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// Tremor getYAxisDomain — copied from tremorlabs/tremor.
|
||||
// License: Apache 2.0 (Tremor).
|
||||
// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/getYAxisDomain.ts
|
||||
|
||||
export const getYAxisDomain = (
|
||||
autoMinValue: boolean,
|
||||
minValue: number | undefined,
|
||||
maxValue: number | undefined,
|
||||
) => {
|
||||
const minDomain = autoMinValue ? "auto" : (minValue ?? 0)
|
||||
const maxDomain = maxValue ?? "auto"
|
||||
return [minDomain, maxDomain]
|
||||
}
|
||||
17
src/components/tremor/use-on-window-resize.ts
Normal file
17
src/components/tremor/use-on-window-resize.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// Tremor useOnWindowResize hook — copied from tremorlabs/tremor.
|
||||
// License: Apache 2.0 (Tremor).
|
||||
// Source: https://github.com/tremorlabs/tremor/blob/main/src/hooks/useOnWindowResize.ts
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
export const useOnWindowResize = (handler: () => void) => {
|
||||
React.useEffect(() => {
|
||||
const handleResize = () => {
|
||||
handler()
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [handler])
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user