xertica-ui 2.2.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +564 -525
- package/README.md +417 -382
- package/bin/cli.ts +1244 -748
- package/bin/generate-tokens.ts +262 -262
- package/bin/language-config.ts +5 -8
- package/components/assets/xertica-orbe-animation.ts +1162 -1162
- package/components/assistant/code-block/CodeBlock.tsx +268 -268
- package/components/assistant/code-block/code-block.stories.tsx +57 -57
- package/components/assistant/code-block/code-block.test.tsx +44 -44
- package/components/assistant/code-block/index.ts +1 -1
- package/components/assistant/formatted-document/FormattedDocument.tsx +147 -147
- package/components/assistant/formatted-document/formatted-document.stories.tsx +51 -51
- package/components/assistant/formatted-document/formatted-document.test.tsx +42 -42
- package/components/assistant/formatted-document/index.ts +1 -1
- package/components/assistant/index.ts +6 -6
- package/components/assistant/markdown-message/MarkdownMessage.tsx +152 -152
- package/components/assistant/markdown-message/index.ts +1 -1
- package/components/assistant/markdown-message/markdown-message.stories.tsx +50 -50
- package/components/assistant/markdown-message/markdown-message.test.tsx +33 -33
- package/components/assistant/modern-chat-input/ModernChatInput.tsx +17 -7
- package/components/assistant/modern-chat-input/index.ts +1 -1
- package/components/assistant/modern-chat-input/modern-chat-input.stories.tsx +131 -131
- package/components/assistant/modern-chat-input/modern-chat-input.test.tsx +79 -79
- package/components/assistant/xertica-assistant/index.ts +3 -3
- package/components/assistant/xertica-assistant/parts/AssistantCollapsedView.tsx +99 -99
- package/components/assistant/xertica-assistant/parts/AssistantConversationList.tsx +104 -106
- package/components/assistant/xertica-assistant/parts/AssistantDocumentEditor.tsx +81 -81
- package/components/assistant/xertica-assistant/parts/AssistantFeedbackDialog.tsx +88 -78
- package/components/assistant/xertica-assistant/parts/AssistantHeader.tsx +75 -75
- package/components/assistant/xertica-assistant/parts/AssistantMessageBubble.tsx +564 -560
- package/components/assistant/xertica-assistant/parts/AssistantTabBar.tsx +67 -67
- package/components/assistant/xertica-assistant/parts/AssistantTypingIndicator.tsx +41 -41
- package/components/assistant/xertica-assistant/parts/AssistantWelcomeScreen.tsx +103 -103
- package/components/assistant/xertica-assistant/parts/index.ts +16 -16
- package/components/assistant/xertica-assistant/types.ts +134 -134
- package/components/assistant/xertica-assistant/use-assistant.ts +615 -615
- package/components/assistant/xertica-assistant/xertica-assistant.stories.tsx +407 -407
- package/components/assistant/xertica-assistant/xertica-assistant.test.tsx +65 -65
- package/components/assistant/xertica-assistant/xertica-assistant.tsx +611 -613
- package/components/blocks/card-patterns/ActivityCard.tsx +100 -100
- package/components/blocks/card-patterns/FeatureCard.tsx +109 -109
- package/components/blocks/card-patterns/FeatureCardSkeleton.tsx +1 -6
- package/components/blocks/card-patterns/NotificationCard.tsx +140 -140
- package/components/blocks/card-patterns/ProfileCard.tsx +112 -114
- package/components/blocks/card-patterns/ProjectCard.tsx +123 -123
- package/components/blocks/card-patterns/ProjectCardSkeleton.tsx +1 -6
- package/components/blocks/card-patterns/QuickActionCard.tsx +68 -68
- package/components/blocks/card-patterns/card-patterns.mdx +123 -123
- package/components/blocks/card-patterns/card-patterns.stories.tsx +594 -594
- package/components/blocks/card-patterns/index.ts +29 -29
- package/components/blocks/index.ts +1 -1
- package/components/brand/branding/branding.stories.tsx +57 -57
- package/components/brand/index.ts +6 -6
- package/components/brand/language-selector/index.ts +1 -1
- package/components/brand/language-selector/language-selector.mdx +126 -126
- package/components/brand/language-selector/language-selector.stories.tsx +1 -4
- package/components/brand/theme-toggle/ThemeToggle.tsx +74 -70
- package/components/brand/theme-toggle/index.ts +1 -1
- package/components/brand/theme-toggle/theme-toggle.stories.tsx +34 -34
- package/components/brand/theme-toggle/theme-toggle.test.tsx +34 -34
- package/components/brand/xertica-logo/XerticaLogo.stories.tsx +82 -82
- package/components/brand/xertica-logo/XerticaLogo.tsx +104 -104
- package/components/brand/xertica-logo/index.ts +1 -1
- package/components/brand/xertica-logo/xertica-logo.test.tsx +26 -26
- package/components/brand/xertica-orbe/XerticaOrbe.tsx +1927 -1927
- package/components/brand/xertica-orbe/index.ts +1 -1
- package/components/brand/xertica-orbe/xertica-orbe.stories.tsx +40 -40
- package/components/brand/xertica-orbe/xertica-orbe.test.tsx +19 -19
- package/components/brand/xertica-provider/XerticaProvider.tsx +1 -4
- package/components/brand/xertica-provider/index.ts +1 -1
- package/components/brand/xertica-provider/xertica-provider.test.tsx +74 -74
- package/components/brand/xertica-xlogo/XerticaXLogo.stories.tsx +79 -79
- package/components/brand/xertica-xlogo/XerticaXLogo.tsx +65 -65
- package/components/brand/xertica-xlogo/index.ts +1 -1
- package/components/brand/xertica-xlogo/xertica-xlogo.test.tsx +16 -16
- package/components/examples/ApiKeyMapExample.tsx +71 -71
- package/components/examples/DrawingMapExample.tsx +565 -565
- package/components/examples/FilterableMapExample.tsx +393 -393
- package/components/examples/LocationPickerExample.tsx +348 -348
- package/components/examples/MapExamples.tsx +268 -268
- package/components/examples/MapGmpExample.tsx +169 -169
- package/components/examples/MapShowcase.tsx +471 -471
- package/components/examples/RouteMapExamples.tsx +329 -329
- package/components/examples/SidebarLogoExample.tsx +65 -65
- package/components/examples/SimpleFilterableMap.tsx +219 -219
- package/components/examples/index.ts +45 -45
- package/components/figma/ImageWithFallback.tsx +27 -27
- package/components/hooks/index.ts +13 -13
- package/components/hooks/use-layout-shortcuts.ts +43 -43
- package/components/index.ts +86 -90
- package/components/layout/header/header.stories.tsx +204 -204
- package/components/layout/header/header.test.tsx +75 -75
- package/components/layout/header/header.tsx +349 -349
- package/components/layout/header/index.ts +1 -1
- package/components/layout/index.ts +2 -2
- package/components/layout/sidebar/index.ts +3 -3
- package/components/layout/sidebar/sidebar.stories.tsx +586 -586
- package/components/layout/sidebar/sidebar.test.tsx +76 -76
- package/components/layout/sidebar/sidebar.tsx +1079 -1073
- package/components/layout/sidebar/use-sidebar.ts +104 -104
- package/components/media/FloatingMediaWrapper.tsx +371 -371
- package/components/media/audio-player/AudioPlayer.stories.tsx +124 -124
- package/components/media/audio-player/AudioPlayer.test.tsx +106 -106
- package/components/media/audio-player/AudioPlayer.tsx +767 -765
- package/components/media/audio-player/index.ts +1 -1
- package/components/media/audio-player/use-audio-player.ts +312 -312
- package/components/media/index.ts +3 -3
- package/components/media/video-player/VideoPlayer.stories.tsx +98 -98
- package/components/media/video-player/VideoPlayer.test.tsx +73 -73
- package/components/media/video-player/VideoPlayer.tsx +310 -310
- package/components/media/video-player/index.ts +1 -1
- package/components/pages/forgot-password-page/ForgotPasswordPage.stories.tsx +24 -24
- package/components/pages/forgot-password-page/ForgotPasswordPage.tsx +188 -188
- package/components/pages/forgot-password-page/forgot-password-page.test.tsx +45 -45
- package/components/pages/forgot-password-page/index.ts +1 -1
- package/components/pages/home-content/HomeContent.stories.tsx +43 -43
- package/components/pages/home-content/HomeContent.tsx +120 -120
- package/components/pages/home-content/index.ts +1 -1
- package/components/pages/home-page/HomePage.stories.tsx +39 -39
- package/components/pages/home-page/HomePage.tsx +78 -74
- package/components/pages/home-page/home-page.test.tsx +53 -53
- package/components/pages/home-page/index.ts +1 -1
- package/components/pages/index.ts +8 -8
- package/components/pages/login-page/LoginPage.stories.tsx +39 -39
- package/components/pages/login-page/LoginPage.tsx +218 -216
- package/components/pages/login-page/index.ts +1 -1
- package/components/pages/login-page/login-page.test.tsx +63 -63
- package/components/pages/reset-password-page/ResetPasswordPage.stories.tsx +24 -24
- package/components/pages/reset-password-page/ResetPasswordPage.tsx +243 -239
- package/components/pages/reset-password-page/index.ts +1 -1
- package/components/pages/template-content/TemplateContent.stories.tsx +43 -43
- package/components/pages/template-content/TemplateContent.tsx +1354 -1235
- package/components/pages/template-content/index.ts +1 -1
- package/components/pages/template-page/TemplatePage.stories.tsx +39 -39
- package/components/pages/template-page/TemplatePage.tsx +62 -62
- package/components/pages/template-page/index.ts +1 -1
- package/components/pages/template-page/template-page.test.tsx +52 -52
- package/components/pages/verify-email-page/VerifyEmailPage.stories.tsx +41 -41
- package/components/pages/verify-email-page/VerifyEmailPage.tsx +206 -206
- package/components/pages/verify-email-page/index.ts +1 -1
- package/components/public-api-smoke.test.tsx +52 -52
- package/components/shared/CustomTooltipContent.tsx +48 -48
- package/components/shared/assistant-utils.test.ts +16 -16
- package/components/shared/assistant-utils.ts +225 -225
- package/components/shared/error-boundary.stories.tsx +114 -132
- package/components/shared/error-boundary.tsx +150 -154
- package/components/shared/error-fallbacks.tsx +222 -226
- package/components/shared/layout-constants.ts +8 -8
- package/components/shared/navigation.ts +35 -35
- package/components/shared/use-mobile.test.ts +16 -16
- package/components/shared/use-mobile.ts +36 -36
- package/components/shared/utils.test.ts +14 -14
- package/components/shared/utils.ts +6 -6
- package/components/ui/accordion/accordion.stories.tsx +105 -105
- package/components/ui/accordion/accordion.test.tsx +59 -59
- package/components/ui/accordion/accordion.tsx +77 -77
- package/components/ui/accordion/index.ts +1 -1
- package/components/ui/alert/alert.stories.tsx +86 -86
- package/components/ui/alert/alert.test.tsx +53 -53
- package/components/ui/alert/alert.tsx +93 -93
- package/components/ui/alert/index.ts +1 -1
- package/components/ui/alert-dialog/alert-dialog.stories.tsx +84 -84
- package/components/ui/alert-dialog/alert-dialog.test.tsx +70 -70
- package/components/ui/alert-dialog/alert-dialog.tsx +149 -149
- package/components/ui/alert-dialog/index.ts +1 -1
- package/components/ui/aspect-ratio/aspect-ratio.stories.tsx +46 -46
- package/components/ui/aspect-ratio/aspect-ratio.test.tsx +28 -28
- package/components/ui/aspect-ratio/aspect-ratio.tsx +20 -20
- package/components/ui/aspect-ratio/index.ts +1 -1
- package/components/ui/assistant-chart/AssistantChart.tsx +64 -64
- package/components/ui/assistant-chart/assistant-chart.stories.tsx +44 -44
- package/components/ui/assistant-chart/assistant-chart.test.tsx +46 -46
- package/components/ui/assistant-chart/index.ts +1 -1
- package/components/ui/avatar/avatar.stories.tsx +86 -86
- package/components/ui/avatar/avatar.test.tsx +55 -55
- package/components/ui/avatar/avatar.tsx +71 -71
- package/components/ui/avatar/index.ts +1 -1
- package/components/ui/badge/badge.stories.tsx +72 -72
- package/components/ui/badge/badge.test.tsx +40 -40
- package/components/ui/badge/badge.tsx +58 -58
- package/components/ui/badge/index.ts +1 -1
- package/components/ui/breadcrumb/breadcrumb.stories.tsx +123 -123
- package/components/ui/breadcrumb/breadcrumb.test.tsx +70 -70
- package/components/ui/breadcrumb/breadcrumb.tsx +114 -114
- package/components/ui/breadcrumb/index.ts +1 -1
- package/components/ui/button/button.stories.tsx +183 -183
- package/components/ui/button/button.test.tsx +64 -64
- package/components/ui/button/button.tsx +98 -98
- package/components/ui/button/index.ts +1 -1
- package/components/ui/calendar/calendar.stories.tsx +108 -108
- package/components/ui/calendar/calendar.test.tsx +53 -53
- package/components/ui/calendar/calendar.tsx +230 -230
- package/components/ui/calendar/index.ts +1 -1
- package/components/ui/card/card.stories.tsx +301 -301
- package/components/ui/card/card.test.tsx +55 -55
- package/components/ui/card/card.tsx +83 -83
- package/components/ui/card/index.ts +1 -1
- package/components/ui/carousel/carousel.stories.tsx +80 -80
- package/components/ui/carousel/carousel.test.tsx +75 -75
- package/components/ui/carousel/carousel.tsx +242 -242
- package/components/ui/carousel/index.ts +1 -1
- package/components/ui/chart/chart.stories.tsx +1328 -1328
- package/components/ui/chart/chart.test.tsx +178 -178
- package/components/ui/chart/chart.tsx +2232 -2232
- package/components/ui/chart/index.ts +1 -1
- package/components/ui/checkbox/checkbox.stories.tsx +109 -109
- package/components/ui/checkbox/checkbox.test.tsx +49 -49
- package/components/ui/checkbox/checkbox.tsx +68 -68
- package/components/ui/checkbox/index.ts +1 -1
- package/components/ui/collapsible/collapsible.stories.tsx +45 -45
- package/components/ui/collapsible/collapsible.test.tsx +51 -51
- package/components/ui/collapsible/collapsible.tsx +32 -32
- package/components/ui/collapsible/index.ts +1 -1
- package/components/ui/command/command.stories.tsx +134 -134
- package/components/ui/command/command.test.tsx +48 -48
- package/components/ui/command/command.tsx +163 -163
- package/components/ui/command/index.ts +1 -1
- package/components/ui/context-menu/context-menu.stories.tsx +76 -76
- package/components/ui/context-menu/context-menu.test.tsx +61 -61
- package/components/ui/context-menu/context-menu.tsx +236 -236
- package/components/ui/context-menu/index.ts +1 -1
- package/components/ui/dialog/dialog.stories.tsx +174 -174
- package/components/ui/dialog/dialog.test.tsx +78 -78
- package/components/ui/dialog/dialog.tsx +189 -189
- package/components/ui/dialog/index.ts +1 -1
- package/components/ui/drawer/drawer.stories.tsx +71 -71
- package/components/ui/drawer/drawer.test.tsx +67 -67
- package/components/ui/drawer/drawer.tsx +146 -146
- package/components/ui/drawer/index.ts +1 -1
- package/components/ui/dropdown-menu/dropdown-menu.stories.tsx +156 -156
- package/components/ui/dropdown-menu/dropdown-menu.test.tsx +62 -62
- package/components/ui/dropdown-menu/dropdown-menu.tsx +240 -240
- package/components/ui/dropdown-menu/index.ts +1 -1
- package/components/ui/empty/empty.stories.tsx +85 -85
- package/components/ui/empty/empty.test.tsx +31 -31
- package/components/ui/empty/empty.tsx +88 -88
- package/components/ui/empty/index.ts +1 -1
- package/components/ui/file-upload/file-upload.stories.tsx +144 -144
- package/components/ui/file-upload/file-upload.test.tsx +65 -65
- package/components/ui/file-upload/file-upload.tsx +142 -142
- package/components/ui/file-upload/index.ts +2 -2
- package/components/ui/file-upload/use-file-upload.ts +177 -177
- package/components/ui/form/form.stories.tsx +85 -85
- package/components/ui/form/form.test.tsx +75 -75
- package/components/ui/form/form.tsx +163 -163
- package/components/ui/form/index.ts +1 -1
- package/components/ui/google-maps-loader/google-maps-loader.test.tsx +35 -35
- package/components/ui/google-maps-loader/google-maps-loader.tsx +465 -465
- package/components/ui/google-maps-loader/index.ts +1 -1
- package/components/ui/hover-card/hover-card.stories.tsx +61 -61
- package/components/ui/hover-card/hover-card.test.tsx +48 -48
- package/components/ui/hover-card/hover-card.tsx +50 -50
- package/components/ui/hover-card/index.ts +1 -1
- package/components/ui/index.ts +400 -400
- package/components/ui/input/index.ts +1 -1
- package/components/ui/input/input.stories.tsx +153 -153
- package/components/ui/input/input.test.tsx +47 -47
- package/components/ui/input/input.tsx +57 -57
- package/components/ui/input-otp/index.ts +1 -1
- package/components/ui/input-otp/input-otp.stories.tsx +120 -120
- package/components/ui/input-otp/input-otp.test.tsx +74 -74
- package/components/ui/input-otp/input-otp.tsx +101 -101
- package/components/ui/label/index.ts +1 -1
- package/components/ui/label/label.stories.tsx +74 -74
- package/components/ui/label/label.test.tsx +45 -45
- package/components/ui/label/label.tsx +53 -53
- package/components/ui/map/index.ts +1 -1
- package/components/ui/map/map.stories.tsx +86 -86
- package/components/ui/map/map.test.tsx +82 -82
- package/components/ui/map/map.tsx +506 -506
- package/components/ui/map/mock.test.tsx +13 -13
- package/components/ui/map-config/index.ts +1 -1
- package/components/ui/map-config/map-config.ts +18 -18
- package/components/ui/map-layers/index.ts +1 -1
- package/components/ui/map-layers/map-layers.test.tsx +48 -48
- package/components/ui/map-layers/map-layers.tsx +126 -126
- package/components/ui/map.exports/index.ts +1 -1
- package/components/ui/map.exports/map.exports.ts +31 -31
- package/components/ui/menubar/index.ts +1 -1
- package/components/ui/menubar/menubar.stories.tsx +130 -130
- package/components/ui/menubar/menubar.test.tsx +53 -53
- package/components/ui/menubar/menubar.tsx +265 -265
- package/components/ui/navigation-menu/index.ts +1 -1
- package/components/ui/navigation-menu/navigation-menu.stories.tsx +126 -126
- package/components/ui/navigation-menu/navigation-menu.test.tsx +47 -47
- package/components/ui/navigation-menu/navigation-menu.tsx +165 -165
- package/components/ui/notification-badge/index.ts +1 -1
- package/components/ui/notification-badge/notification-badge.stories.tsx +66 -66
- package/components/ui/notification-badge/notification-badge.test.tsx +61 -61
- package/components/ui/notification-badge/notification-badge.tsx +91 -91
- package/components/ui/page-header/index.ts +1 -1
- package/components/ui/page-header/page-header.stories.tsx +69 -69
- package/components/ui/page-header/page-header.test.tsx +37 -37
- package/components/ui/page-header/page-header.tsx +124 -124
- package/components/ui/pagination/index.ts +3 -3
- package/components/ui/pagination/pagination.stories.tsx +210 -210
- package/components/ui/pagination/pagination.test.tsx +63 -63
- package/components/ui/pagination/pagination.tsx +140 -140
- package/components/ui/pagination/use-pagination.ts +173 -173
- package/components/ui/popover/index.ts +1 -1
- package/components/ui/popover/popover.stories.tsx +73 -73
- package/components/ui/popover/popover.test.tsx +48 -48
- package/components/ui/popover/popover.tsx +54 -54
- package/components/ui/progress/index.ts +1 -1
- package/components/ui/progress/progress.stories.tsx +55 -55
- package/components/ui/progress/progress.test.tsx +23 -23
- package/components/ui/progress/progress.tsx +68 -68
- package/components/ui/radio-group/index.ts +1 -1
- package/components/ui/radio-group/radio-group.stories.tsx +114 -114
- package/components/ui/radio-group/radio-group.test.tsx +78 -78
- package/components/ui/radio-group/radio-group.tsx +93 -93
- package/components/ui/rating/index.ts +1 -1
- package/components/ui/rating/rating.stories.tsx +50 -50
- package/components/ui/rating/rating.test.tsx +48 -48
- package/components/ui/rating/rating.tsx +145 -145
- package/components/ui/resizable/index.ts +1 -1
- package/components/ui/resizable/resizable.stories.tsx +88 -88
- package/components/ui/resizable/resizable.test.tsx +61 -61
- package/components/ui/resizable/resizable.tsx +452 -452
- package/components/ui/rich-text-editor/index.ts +7 -7
- package/components/ui/rich-text-editor/rich-text-editor.stories.tsx +290 -290
- package/components/ui/rich-text-editor/rich-text-editor.test.tsx +86 -86
- package/components/ui/rich-text-editor/rich-text-editor.tsx +634 -634
- package/components/ui/rich-text-editor/use-rich-text-editor.ts +453 -453
- package/components/ui/route-map/index.ts +1 -1
- package/components/ui/route-map/route-map.stories.tsx +48 -48
- package/components/ui/route-map/route-map.test.tsx +108 -108
- package/components/ui/route-map/route-map.tsx +349 -349
- package/components/ui/scroll-area/index.ts +1 -1
- package/components/ui/scroll-area/scroll-area.stories.tsx +31 -31
- package/components/ui/scroll-area/scroll-area.test.tsx +27 -27
- package/components/ui/scroll-area/scroll-area.tsx +70 -70
- package/components/ui/search/index.ts +1 -1
- package/components/ui/search/search.stories.tsx +107 -107
- package/components/ui/search/search.test.tsx +67 -67
- package/components/ui/search/search.tsx +141 -141
- package/components/ui/select/index.ts +1 -1
- package/components/ui/select/select.stories.tsx +163 -163
- package/components/ui/select/select.test.tsx +99 -99
- package/components/ui/select/select.tsx +195 -195
- package/components/ui/separator/index.ts +1 -1
- package/components/ui/separator/separator.stories.tsx +55 -55
- package/components/ui/separator/separator.test.tsx +23 -23
- package/components/ui/separator/separator.tsx +39 -39
- package/components/ui/sheet/index.ts +1 -1
- package/components/ui/sheet/sheet.stories.tsx +93 -93
- package/components/ui/sheet/sheet.test.tsx +62 -62
- package/components/ui/sheet/sheet.tsx +149 -149
- package/components/ui/simple-map/index.ts +1 -1
- package/components/ui/simple-map/simple-map.stories.tsx +44 -44
- package/components/ui/simple-map/simple-map.test.tsx +36 -36
- package/components/ui/simple-map/simple-map.tsx +92 -92
- package/components/ui/skeleton/index.ts +1 -1
- package/components/ui/skeleton/skeleton.stories.tsx +36 -36
- package/components/ui/skeleton/skeleton.test.tsx +19 -19
- package/components/ui/skeleton/skeleton.tsx +25 -25
- package/components/ui/slider/index.ts +1 -1
- package/components/ui/slider/slider.stories.tsx +44 -44
- package/components/ui/slider/slider.test.tsx +25 -25
- package/components/ui/slider/slider.tsx +66 -66
- package/components/ui/sonner/index.ts +1 -1
- package/components/ui/sonner/sonner.stories.tsx +41 -41
- package/components/ui/sonner/sonner.test.tsx +24 -24
- package/components/ui/sonner/sonner.tsx +74 -74
- package/components/ui/stats-card/index.ts +2 -2
- package/components/ui/stats-card/stats-card-skeleton.tsx +1 -3
- package/components/ui/stats-card/stats-card.stories.tsx +99 -99
- package/components/ui/stats-card/stats-card.test.tsx +34 -34
- package/components/ui/stats-card/stats-card.tsx +93 -93
- package/components/ui/stepper/index.ts +3 -3
- package/components/ui/stepper/stepper.stories.tsx +171 -171
- package/components/ui/stepper/stepper.test.tsx +47 -47
- package/components/ui/stepper/stepper.tsx +190 -190
- package/components/ui/stepper/use-stepper.ts +139 -139
- package/components/ui/switch/index.ts +1 -1
- package/components/ui/switch/switch.stories.tsx +93 -93
- package/components/ui/switch/switch.test.tsx +44 -44
- package/components/ui/switch/switch.tsx +70 -70
- package/components/ui/table/index.ts +1 -1
- package/components/ui/table/table.stories.tsx +114 -114
- package/components/ui/table/table.test.tsx +43 -43
- package/components/ui/table/table.tsx +104 -104
- package/components/ui/tabs/index.ts +1 -1
- package/components/ui/tabs/tabs.stories.tsx +140 -140
- package/components/ui/tabs/tabs.test.tsx +50 -50
- package/components/ui/tabs/tabs.tsx +66 -66
- package/components/ui/textarea/index.ts +1 -1
- package/components/ui/textarea/textarea.stories.tsx +69 -69
- package/components/ui/textarea/textarea.test.tsx +41 -41
- package/components/ui/textarea/textarea.tsx +61 -61
- package/components/ui/timeline/index.ts +1 -1
- package/components/ui/timeline/timeline.stories.tsx +97 -97
- package/components/ui/timeline/timeline.test.tsx +53 -53
- package/components/ui/timeline/timeline.tsx +124 -124
- package/components/ui/toggle/index.ts +1 -1
- package/components/ui/toggle/toggle.stories.tsx +56 -56
- package/components/ui/toggle/toggle.test.tsx +32 -32
- package/components/ui/toggle/toggle.tsx +55 -55
- package/components/ui/toggle-group/index.ts +1 -1
- package/components/ui/toggle-group/toggle-group.stories.tsx +66 -66
- package/components/ui/toggle-group/toggle-group.test.tsx +47 -47
- package/components/ui/toggle-group/toggle-group.tsx +79 -79
- package/components/ui/tooltip/index.ts +1 -1
- package/components/ui/tooltip/tooltip.stories.tsx +83 -83
- package/components/ui/tooltip/tooltip.test.tsx +39 -39
- package/components/ui/tooltip/tooltip.tsx +69 -69
- package/components/ui/tree-view/index.ts +4 -4
- package/components/ui/tree-view/tree-view.stories.tsx +154 -154
- package/components/ui/tree-view/tree-view.test.tsx +58 -58
- package/components/ui/tree-view/tree-view.tsx +171 -171
- package/components/ui/tree-view/use-tree-view.ts +237 -237
- package/components.json +892 -892
- package/contexts/ApiKeyContext.test.tsx +26 -26
- package/contexts/ApiKeyContext.tsx +196 -196
- package/contexts/AssistenteContext.test.tsx +17 -17
- package/contexts/AssistenteContext.tsx +113 -113
- package/contexts/AuthContext.tsx +121 -118
- package/contexts/BrandColorsContext.test.tsx +21 -21
- package/contexts/BrandColorsContext.tsx +251 -251
- package/contexts/LanguageContext.tsx +1 -2
- package/contexts/LayoutContext.test.tsx +29 -29
- package/contexts/LayoutContext.tsx +140 -140
- package/contexts/ThemeContext.test.tsx +38 -38
- package/contexts/ThemeContext.tsx +111 -111
- package/contexts/index.ts +8 -8
- package/contexts/theme-data.ts +340 -340
- package/dist/AssistantChart-COGiOV-g.cjs +3541 -0
- package/dist/AssistantChart-CWX1OWNM.js +3373 -0
- package/dist/AudioPlayer-9psiEucT.cjs +1282 -0
- package/dist/AudioPlayer-Dp2bD1Gk.js +1278 -0
- package/dist/BrandColorsContext-DZT7JjeD.js +659 -0
- package/dist/BrandColorsContext-awnBCmC4.cjs +666 -0
- package/dist/CodeBlock-DYkTfR0f.js +221 -0
- package/dist/CodeBlock-EOvp9cVu.cjs +223 -0
- package/dist/CustomTooltipContent-BhdIeBEg.cjs +54 -0
- package/dist/CustomTooltipContent-CNbVB2NS.js +33 -0
- package/dist/FeatureCard-BZ4CYxFf.cjs +497 -0
- package/dist/FeatureCard-DNycVGwT.js +485 -0
- package/dist/FeatureCardSkeleton-DZqc96mt.js +27 -0
- package/dist/FeatureCardSkeleton-pTa0YNKP.cjs +29 -0
- package/dist/LayoutContext-BEq_-n98.cjs +96 -0
- package/dist/LayoutContext-DNl1xSoX.js +92 -0
- package/dist/ThemeContext-CMD3z2Dz.cjs +1930 -0
- package/dist/ThemeContext-x_F2zsnv.js +1923 -0
- package/dist/VerifyEmailPage-BJjAMUTW.js +3223 -0
- package/dist/VerifyEmailPage-Bv8Ah_TK.cjs +3235 -0
- package/dist/VerifyEmailPage-CkBYfsNy.cjs +3232 -0
- package/dist/VerifyEmailPage-Cyl55sJb.js +3226 -0
- package/dist/VerifyEmailPage-X14vhdyl.js +3296 -0
- package/dist/VerifyEmailPage-u_Dn7t1U.cjs +3305 -0
- package/dist/XerticaOrbe-Uk2JML1-.cjs +1927 -0
- package/dist/XerticaOrbe-jA5T2iOk.js +1925 -0
- package/dist/XerticaProvider-BErr83Bg.js +42 -0
- package/dist/XerticaProvider-CwOkHxiT.cjs +44 -0
- package/dist/XerticaProvider-DUOJg9iX.js +49 -0
- package/dist/XerticaProvider-Dl_b72_l.cjs +51 -0
- package/dist/XerticaXLogo-BX3ueACh.js +255 -0
- package/dist/XerticaXLogo-mqjoBiLI.js +252 -0
- package/dist/XerticaXLogo-qBPhwK3g.cjs +260 -0
- package/dist/XerticaXLogo-uQgwns_E.cjs +257 -0
- package/dist/alert-dialog-DhwPioBa.cjs +885 -0
- package/dist/alert-dialog-DqlRW_An.js +831 -0
- package/dist/assistant.cjs.js +8 -4
- package/dist/assistant.es.js +5 -11
- package/dist/avatar-3kO2Anrp.js +54 -0
- package/dist/avatar-BCM7YQRC.cjs +77 -0
- package/dist/blocks.cjs.js +9 -4
- package/dist/blocks.es.js +2 -16
- package/dist/brand.cjs.js +10 -5
- package/dist/brand.es.js +3 -11
- package/dist/breadcrumb-BKtHF4gk.cjs +98 -0
- package/dist/breadcrumb-ifNsA7Zl.js +90 -0
- package/dist/button-0BlA47It.cjs +85 -0
- package/dist/button-DZHzN1Gd.js +62 -0
- package/dist/cli.js +471 -93
- package/dist/components/brand/theme-toggle/ThemeToggle.d.ts +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/dropdown-menu-BMcykFDf.cjs +225 -0
- package/dist/dropdown-menu-Dn_eV2Xb.js +190 -0
- package/dist/google-maps-loader-BCe58h9D.js +308 -0
- package/dist/google-maps-loader-casMyxlo.cjs +316 -0
- package/dist/hooks.cjs.js +12 -8
- package/dist/hooks.es.js +10 -27
- package/dist/index-9GWd0qxq.cjs +12 -0
- package/dist/index-BabBx2pa.js +6 -0
- package/dist/index.cjs.js +37 -32
- package/dist/index.es.js +30 -363
- package/dist/input-C_UiS2Py.cjs +152 -0
- package/dist/input-cc-PTD4R.js +123 -0
- package/dist/layout.cjs.js +10 -6
- package/dist/layout.es.js +7 -9
- package/dist/media.cjs.js +8 -3
- package/dist/media.es.js +1 -6
- package/dist/pages.cjs.js +8 -3
- package/dist/pages.es.js +1 -11
- package/dist/progress-C7Lti5wo.js +80 -0
- package/dist/progress-Cqwxbqs1.cjs +103 -0
- package/dist/rich-text-editor-DqLICivI.js +2832 -0
- package/dist/rich-text-editor-DxO1Hz3a.cjs +2903 -0
- package/dist/select-CH6v_KcQ.cjs +161 -0
- package/dist/select-D-xvCZK2.js +130 -0
- package/dist/sidebar-3XyzjVBw.js +792 -0
- package/dist/sidebar-B4ZWaMrE.js +792 -0
- package/dist/sidebar-BS1p2V7t.cjs +795 -0
- package/dist/sidebar-DyYvgyBj.cjs +795 -0
- package/dist/skeleton-DjiHerJn.cjs +87 -0
- package/dist/skeleton-DtR5tkYe.js +78 -0
- package/dist/slider-B00b9SVK.cjs +78 -0
- package/dist/slider-DQCNUUMj.js +56 -0
- package/dist/sonner-B-jWlik1.cjs +68 -0
- package/dist/sonner-C9tiqj4f.js +47 -0
- package/dist/tooltip-D8n9UYoU.cjs +72 -0
- package/dist/tooltip-RtbSmPYJ.js +48 -0
- package/dist/ui.cjs.js +23 -18
- package/dist/ui.es.js +16 -303
- package/dist/use-audio-player-B78fd2ct.js +188 -0
- package/dist/use-audio-player-DGvhPrgR.cjs +190 -0
- package/dist/use-mobile-BdXTRb0Z.cjs +51 -0
- package/dist/use-mobile-Ce2cBAQe.js +29 -0
- package/dist/xertica-assistant-B1NaSFFj.js +2173 -0
- package/dist/xertica-assistant-B687qEPU.js +2165 -0
- package/dist/xertica-assistant-CIaUlbIt.cjs +2180 -0
- package/dist/xertica-assistant-sOHwTgIP.cjs +2172 -0
- package/dist/xertica-ui.css +1 -1
- package/docs/ai-usage.md +195 -195
- package/docs/architecture-improvements.md +456 -456
- package/docs/architecture.md +312 -306
- package/docs/components/accordion.md +109 -109
- package/docs/components/alert-dialog.md +127 -127
- package/docs/components/alert.md +106 -106
- package/docs/components/aspect-ratio.md +58 -58
- package/docs/components/assistant-chart.md +47 -47
- package/docs/components/assistant.md +428 -426
- package/docs/components/audio-player.md +167 -167
- package/docs/components/avatar.md +101 -101
- package/docs/components/badge.md +84 -84
- package/docs/components/branding.md +252 -252
- package/docs/components/breadcrumb.md +104 -104
- package/docs/components/button.md +156 -156
- package/docs/components/calendar.md +141 -141
- package/docs/components/card-patterns.md +447 -445
- package/docs/components/card.md +245 -245
- package/docs/components/carousel.md +100 -100
- package/docs/components/chart.md +638 -638
- package/docs/components/checkbox.md +88 -88
- package/docs/components/code-block.md +105 -105
- package/docs/components/collapsible.md +86 -86
- package/docs/components/command.md +113 -113
- package/docs/components/context-menu.md +81 -81
- package/docs/components/dialog.md +198 -198
- package/docs/components/drawer.md +105 -105
- package/docs/components/dropdown-menu.md +127 -127
- package/docs/components/empty.md +127 -127
- package/docs/components/error-boundary.md +201 -191
- package/docs/components/file-upload.md +189 -189
- package/docs/components/floating-media-wrapper.md +63 -63
- package/docs/components/form.md +177 -177
- package/docs/components/formatted-document.md +105 -105
- package/docs/components/google-maps-loader.md +44 -44
- package/docs/components/header.md +177 -177
- package/docs/components/hooks.md +432 -430
- package/docs/components/hover-card.md +86 -86
- package/docs/components/image-with-fallback.md +107 -107
- package/docs/components/input-otp.md +95 -95
- package/docs/components/input.md +130 -130
- package/docs/components/label.md +69 -69
- package/docs/components/language-selector.md +20 -16
- package/docs/components/map-layers.md +138 -138
- package/docs/components/map.md +84 -84
- package/docs/components/markdown-message.md +47 -47
- package/docs/components/menubar.md +89 -89
- package/docs/components/modern-chat-input.md +164 -164
- package/docs/components/navigation-menu.md +83 -83
- package/docs/components/notification-badge.md +78 -78
- package/docs/components/page-header.md +93 -93
- package/docs/components/pages.md +323 -309
- package/docs/components/pagination.md +334 -334
- package/docs/components/popover.md +116 -116
- package/docs/components/progress.md +103 -103
- package/docs/components/radio-group.md +133 -133
- package/docs/components/rating.md +77 -77
- package/docs/components/resizable.md +84 -84
- package/docs/components/rich-text-editor.md +255 -255
- package/docs/components/route-map.md +124 -124
- package/docs/components/scroll-area.md +58 -58
- package/docs/components/search.md +87 -87
- package/docs/components/select.md +144 -144
- package/docs/components/separator.md +58 -58
- package/docs/components/sheet.md +122 -122
- package/docs/components/sidebar.md +314 -314
- package/docs/components/simple-map.md +51 -51
- package/docs/components/skeleton.md +99 -99
- package/docs/components/slider.md +84 -84
- package/docs/components/sonner.md +115 -115
- package/docs/components/stats-card.md +120 -120
- package/docs/components/stepper.md +268 -268
- package/docs/components/switch.md +106 -106
- package/docs/components/table.md +138 -138
- package/docs/components/tabs.md +117 -117
- package/docs/components/textarea.md +86 -86
- package/docs/components/theme-toggle.md +73 -73
- package/docs/components/timeline.md +121 -121
- package/docs/components/toggle-group.md +68 -68
- package/docs/components/toggle.md +62 -62
- package/docs/components/tooltip.md +116 -116
- package/docs/components/tree-view.md +238 -238
- package/docs/components/use-mobile.md +96 -96
- package/docs/components/video-player.md +68 -68
- package/docs/components/xertica-logo.md +36 -36
- package/docs/components/xertica-orbe.md +35 -35
- package/docs/components/xertica-provider.md +65 -65
- package/docs/components/xertica-xlogo.md +35 -35
- package/docs/decision-tree.md +293 -293
- package/docs/doc-audit.md +244 -243
- package/docs/form-sizing.md +162 -162
- package/docs/getting-started.md +616 -591
- package/docs/guidelines.md +330 -328
- package/docs/i18n.md +61 -57
- package/docs/installation.md +268 -267
- package/docs/layout.md +143 -143
- package/docs/llms.md +295 -295
- package/docs/patterns/analytics.md +194 -194
- package/docs/patterns/crud.md +149 -149
- package/docs/patterns/dashboard.md +138 -138
- package/docs/patterns/detail-page.md +296 -296
- package/docs/patterns/form.md +241 -241
- package/docs/patterns/login.md +156 -156
- package/docs/patterns/settings.md +368 -368
- package/docs/patterns/wizard.md +213 -213
- package/docs/state-management.md +289 -289
- package/guidelines/Guidelines.md +409 -406
- package/hooks/useTheme.test.tsx +16 -16
- package/hooks/useTheme.ts +4 -4
- package/imports/Podcast.tsx +540 -540
- package/imports/XerticaAi.tsx +46 -46
- package/imports/XerticaX.tsx +15 -15
- package/imports/svg-aueiaqngck.ts +20 -20
- package/imports/svg-v9krss1ozd.ts +23 -23
- package/imports/svg-vhrdofe3qe.ts +6 -6
- package/llms-compact.txt +2 -1
- package/llms.txt +2 -1
- package/mcp/resources.json +22 -22
- package/mcp/tools.json +35 -35
- package/package.json +219 -213
- package/scripts/ai-validator.ts +91 -91
- package/scripts/cleanup-case-dupes.ts +62 -62
- package/scripts/generate-ai-manifests.ts +107 -107
- package/styles/globals.css +13 -13
- package/styles/xertica/app-overrides/chat.css +61 -61
- package/styles/xertica/app-overrides/scrollbar.css +33 -33
- package/styles/xertica/base.css +90 -71
- package/styles/xertica/integrations/google-maps.css +76 -76
- package/styles/xertica/integrations/sonner.css +73 -73
- package/styles/xertica/theme-map.css +102 -99
- package/styles/xertica/tokens.css +240 -236
- package/templates/CLAUDE.md +16 -1
- package/templates/eslint.config.js +26 -26
- package/templates/guidelines/Guidelines.md +577 -553
- package/templates/package.json +69 -69
- package/templates/postcss.config.js +6 -6
- package/templates/src/app/App.tsx +46 -46
- package/templates/src/app/components/AppLayout.tsx +55 -55
- package/templates/src/app/components/AuthGuard.tsx +131 -82
- package/templates/src/app/context/AuthContext.tsx +108 -108
- package/templates/src/features/assistant/index.ts +5 -5
- package/templates/src/features/auth/index.ts +4 -4
- package/templates/src/features/auth/ui/AuthPageShell.tsx +32 -32
- package/templates/src/features/auth/ui/ForgotPasswordContent.tsx +70 -72
- package/templates/src/features/auth/ui/LoginContent.tsx +92 -92
- package/templates/src/features/auth/ui/ResetPasswordContent.tsx +6 -2
- package/templates/src/features/auth/ui/SocialLoginButtons.tsx +78 -78
- package/templates/src/features/auth/ui/VerifyEmailContent.tsx +2 -6
- package/templates/src/features/home/data/mock.ts +41 -35
- package/templates/src/features/home/index.ts +11 -11
- package/templates/src/features/home/store/dashboardStore.ts +25 -25
- package/templates/src/features/home/ui/HomeContent.tsx +117 -119
- package/templates/src/features/template/index.ts +5 -5
- package/templates/src/features/template/ui/CrudTemplate.tsx +1 -4
- package/templates/src/features/template/ui/LoginTemplate.tsx +1 -1
- package/templates/src/features/template/ui/TemplateContent.tsx +29 -21
- package/templates/src/locales/en/pages/templates.json +17 -17
- package/templates/src/locales/es/pages/templates.json +17 -17
- package/templates/src/locales/pt-BR/pages/templates.json +17 -17
- package/templates/src/main.tsx +11 -11
- package/templates/src/pages/AssistantPage.tsx +26 -20
- package/templates/src/pages/ForgotPasswordPage.tsx +6 -6
- package/templates/src/pages/HomePage.tsx +53 -49
- package/templates/src/pages/LoginPage.tsx +10 -10
- package/templates/src/pages/ResetPasswordPage.tsx +6 -6
- package/templates/src/pages/TemplatePage.tsx +28 -28
- package/templates/src/pages/VerifyEmailPage.tsx +6 -6
- package/templates/src/shared/config/navigation.ts +19 -19
- package/templates/src/shared/error-boundary.tsx +150 -154
- package/templates/src/shared/error-fallbacks.tsx +222 -226
- package/templates/src/shared/lib/auth.ts +20 -20
- package/templates/src/shared/types/auth.ts +3 -3
- package/templates/src/styles/index.css +95 -95
- package/templates/src/styles/xertica/tokens.css +240 -236
- package/templates/tsconfig.json +25 -25
- package/templates/tsconfig.node.json +12 -12
- package/templates/vite-env.d.ts +1 -1
- package/templates/vite.config.js +20 -20
- package/templates/vite.config.ts +54 -51
- package/utils/color-utils.ts +72 -72
- package/utils/demo-responses.test.ts +10 -10
- package/utils/demo-responses.ts +151 -151
- package/utils/gemini.test.ts +25 -25
- package/utils/gemini.ts +155 -155
|
@@ -1,2232 +1,2232 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import * as React from 'react';
|
|
4
|
-
import * as RechartsPrimitive from 'recharts';
|
|
5
|
-
|
|
6
|
-
import { cn } from '../../shared/utils';
|
|
7
|
-
import { Alert, AlertDescription, AlertTitle } from '../alert';
|
|
8
|
-
import { Button } from '../button';
|
|
9
|
-
import {
|
|
10
|
-
Card,
|
|
11
|
-
CardAction,
|
|
12
|
-
CardContent,
|
|
13
|
-
CardDescription,
|
|
14
|
-
CardFooter,
|
|
15
|
-
CardHeader,
|
|
16
|
-
CardTitle,
|
|
17
|
-
} from '../card';
|
|
18
|
-
import { Empty, EmptyAction, EmptyDescription, EmptyIcon, EmptyTitle } from '../empty';
|
|
19
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
|
20
|
-
import { Skeleton } from '../skeleton';
|
|
21
|
-
import { BarChart3, RefreshCw, WifiOff } from 'lucide-react';
|
|
22
|
-
|
|
23
|
-
// Format: { THEME_NAME: CSS_SELECTOR }
|
|
24
|
-
const THEMES = { light: '', dark: '.dark' } as const;
|
|
25
|
-
|
|
26
|
-
export type ChartConfig = {
|
|
27
|
-
[k in string]: {
|
|
28
|
-
label?: React.ReactNode;
|
|
29
|
-
icon?: React.ComponentType;
|
|
30
|
-
} & (
|
|
31
|
-
| { color?: string; theme?: never }
|
|
32
|
-
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
33
|
-
);
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type ChartContextProps = {
|
|
37
|
-
config: ChartConfig;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type DashboardChartDatum = Record<string, string | number | null | undefined>;
|
|
41
|
-
|
|
42
|
-
export type DashboardChartSeries = {
|
|
43
|
-
key: string;
|
|
44
|
-
label?: React.ReactNode;
|
|
45
|
-
type?: 'bar' | 'line' | 'area';
|
|
46
|
-
stackId?: string;
|
|
47
|
-
yAxisId?: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type DashboardChartColors = string[] | Record<string, string>;
|
|
51
|
-
|
|
52
|
-
export type ChartPeriod = {
|
|
53
|
-
value: string;
|
|
54
|
-
label: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export type ChartBarSize = 'sm' | 'md' | 'lg' | 'xl' | number;
|
|
58
|
-
|
|
59
|
-
/** Curve interpolation type for line and area charts */
|
|
60
|
-
export type ChartCurveType =
|
|
61
|
-
| 'monotone'
|
|
62
|
-
| 'linear'
|
|
63
|
-
| 'step'
|
|
64
|
-
| 'stepBefore'
|
|
65
|
-
| 'stepAfter'
|
|
66
|
-
| 'natural'
|
|
67
|
-
| 'basis';
|
|
68
|
-
|
|
69
|
-
type ChartValueFormatter = (value: number | string) => string;
|
|
70
|
-
|
|
71
|
-
export type ChartErrorState = boolean | string | Error | React.ReactNode;
|
|
72
|
-
|
|
73
|
-
export type ChartStateProps = {
|
|
74
|
-
isLoading?: boolean;
|
|
75
|
-
error?: ChartErrorState;
|
|
76
|
-
onRetry?: () => void;
|
|
77
|
-
retryLabel?: React.ReactNode;
|
|
78
|
-
emptyTitle?: React.ReactNode;
|
|
79
|
-
emptyDescription?: React.ReactNode;
|
|
80
|
-
errorTitle?: React.ReactNode;
|
|
81
|
-
errorDescription?: React.ReactNode;
|
|
82
|
-
loadingLabel?: React.ReactNode;
|
|
83
|
-
stateClassName?: string;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const defaultPeriods: ChartPeriod[] = [
|
|
87
|
-
{ value: '7d', label: '7 days' },
|
|
88
|
-
{ value: '30d', label: '30 days' },
|
|
89
|
-
{ value: '90d', label: '90 days' },
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
const defaultChartColors = [
|
|
93
|
-
'var(--chart-1)',
|
|
94
|
-
'var(--chart-2)',
|
|
95
|
-
'var(--chart-3)',
|
|
96
|
-
'var(--chart-4)',
|
|
97
|
-
'var(--chart-5)',
|
|
98
|
-
'var(--chart-6)',
|
|
99
|
-
'var(--chart-7)',
|
|
100
|
-
'var(--chart-8)',
|
|
101
|
-
];
|
|
102
|
-
|
|
103
|
-
const chartBarSizes: Record<Exclude<ChartBarSize, number>, number> = {
|
|
104
|
-
sm: 8,
|
|
105
|
-
md: 14,
|
|
106
|
-
lg: 22,
|
|
107
|
-
xl: 32,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
|
111
|
-
|
|
112
|
-
export function useChart() {
|
|
113
|
-
const context = React.useContext(ChartContext);
|
|
114
|
-
|
|
115
|
-
if (!context) {
|
|
116
|
-
throw new Error('useChart must be used within a <ChartContainer />');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return context;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Root container for Recharts-based charts with theme-aware color injection.
|
|
124
|
-
*
|
|
125
|
-
* @description
|
|
126
|
-
* Wraps Recharts' `ResponsiveContainer` and injects CSS custom properties
|
|
127
|
-
* (`--color-*`) from a `ChartConfig` object, enabling full dark-mode
|
|
128
|
-
* support without hard-coded hex values in chart elements.
|
|
129
|
-
*
|
|
130
|
-
* @ai-rules
|
|
131
|
-
* 1. NEVER pass hex colors directly to Recharts elements (e.g., `fill="#4F46E5"`).
|
|
132
|
-
* Always use `fill="var(--color-keyName)"` referencing the injected CSS variables.
|
|
133
|
-
* 2. This wrapper is REQUIRED to use `ChartTooltipContent` and `ChartLegendContent`.
|
|
134
|
-
* 3. Set height via `className="h-[300px]"` on `ChartContainer`, not on Recharts components.
|
|
135
|
-
* 4. Do NOT add another `<ResponsiveContainer>` — it is already included inside.
|
|
136
|
-
*/
|
|
137
|
-
function ChartContainer({
|
|
138
|
-
id,
|
|
139
|
-
className,
|
|
140
|
-
children,
|
|
141
|
-
config,
|
|
142
|
-
...props
|
|
143
|
-
}: React.ComponentProps<'div'> & {
|
|
144
|
-
config: ChartConfig;
|
|
145
|
-
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
|
146
|
-
}) {
|
|
147
|
-
const uniqueId = React.useId();
|
|
148
|
-
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<ChartContext.Provider value={{ config }}>
|
|
152
|
-
<div
|
|
153
|
-
data-slot="chart"
|
|
154
|
-
data-chart={chartId}
|
|
155
|
-
className={cn(
|
|
156
|
-
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border relative h-[300px] min-h-[200px] w-full min-w-0 overflow-hidden text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
|
157
|
-
className
|
|
158
|
-
)}
|
|
159
|
-
{...props}
|
|
160
|
-
>
|
|
161
|
-
<ChartStyle id={chartId} config={config} />
|
|
162
|
-
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
|
163
|
-
{children}
|
|
164
|
-
</RechartsPrimitive.ResponsiveContainer>
|
|
165
|
-
</div>
|
|
166
|
-
</ChartContext.Provider>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
171
|
-
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
|
172
|
-
|
|
173
|
-
if (!colorConfig.length) {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return (
|
|
178
|
-
<style
|
|
179
|
-
dangerouslySetInnerHTML={{
|
|
180
|
-
__html: Object.entries(THEMES)
|
|
181
|
-
.map(
|
|
182
|
-
([theme, prefix]) => `
|
|
183
|
-
${prefix} [data-chart=${id}] {
|
|
184
|
-
${colorConfig
|
|
185
|
-
.map(([key, itemConfig]) => {
|
|
186
|
-
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
|
187
|
-
return color ? ` --color-${key}: ${color};` : null;
|
|
188
|
-
})
|
|
189
|
-
.join('\n')}
|
|
190
|
-
}
|
|
191
|
-
`
|
|
192
|
-
)
|
|
193
|
-
.join('\n'),
|
|
194
|
-
}}
|
|
195
|
-
/>
|
|
196
|
-
);
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const ChartTooltip = RechartsPrimitive.Tooltip;
|
|
200
|
-
|
|
201
|
-
function ChartTooltipContent({
|
|
202
|
-
active,
|
|
203
|
-
payload,
|
|
204
|
-
className,
|
|
205
|
-
indicator = 'dot',
|
|
206
|
-
hideLabel = false,
|
|
207
|
-
hideIndicator = false,
|
|
208
|
-
label,
|
|
209
|
-
labelFormatter,
|
|
210
|
-
labelClassName,
|
|
211
|
-
formatter,
|
|
212
|
-
color,
|
|
213
|
-
nameKey,
|
|
214
|
-
labelKey,
|
|
215
|
-
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
216
|
-
React.ComponentProps<'div'> & {
|
|
217
|
-
hideLabel?: boolean;
|
|
218
|
-
hideIndicator?: boolean;
|
|
219
|
-
indicator?: 'line' | 'dot' | 'dashed';
|
|
220
|
-
nameKey?: string;
|
|
221
|
-
labelKey?: string;
|
|
222
|
-
}) {
|
|
223
|
-
const { config } = useChart();
|
|
224
|
-
|
|
225
|
-
const tooltipLabel = React.useMemo(() => {
|
|
226
|
-
if (hideLabel || !payload?.length) {
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const [item] = payload;
|
|
231
|
-
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
|
232
|
-
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
233
|
-
const value =
|
|
234
|
-
!labelKey && typeof label === 'string'
|
|
235
|
-
? config[label as keyof typeof config]?.label || label
|
|
236
|
-
: itemConfig?.label;
|
|
237
|
-
|
|
238
|
-
if (labelFormatter) {
|
|
239
|
-
return (
|
|
240
|
-
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!value) {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
|
249
|
-
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
|
250
|
-
|
|
251
|
-
if (!active || !payload?.length) {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
|
256
|
-
|
|
257
|
-
return (
|
|
258
|
-
<div
|
|
259
|
-
className={cn(
|
|
260
|
-
'border-border/50 bg-background/95 backdrop-blur-sm grid min-w-[8rem] items-start gap-1.5 rounded-xl border px-3 py-2 text-xs shadow-xl',
|
|
261
|
-
className
|
|
262
|
-
)}
|
|
263
|
-
>
|
|
264
|
-
{!nestLabel ? tooltipLabel : null}
|
|
265
|
-
<div className="grid gap-1.5">
|
|
266
|
-
{payload.map((item, index) => {
|
|
267
|
-
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
|
268
|
-
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
269
|
-
const indicatorColor = color || item.payload?.fill || item.color;
|
|
270
|
-
|
|
271
|
-
return (
|
|
272
|
-
<div
|
|
273
|
-
key={item.dataKey}
|
|
274
|
-
className={cn(
|
|
275
|
-
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
|
276
|
-
indicator === 'dot' && 'items-center'
|
|
277
|
-
)}
|
|
278
|
-
>
|
|
279
|
-
{formatter && item?.value !== undefined && item.name ? (
|
|
280
|
-
formatter(item.value, item.name, item, index, item.payload)
|
|
281
|
-
) : (
|
|
282
|
-
<>
|
|
283
|
-
{itemConfig?.icon ? (
|
|
284
|
-
<itemConfig.icon />
|
|
285
|
-
) : (
|
|
286
|
-
!hideIndicator && (
|
|
287
|
-
<div
|
|
288
|
-
className={cn(
|
|
289
|
-
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
|
290
|
-
{
|
|
291
|
-
'h-2.5 w-2.5': indicator === 'dot',
|
|
292
|
-
'w-1': indicator === 'line',
|
|
293
|
-
'w-0 border-[1.5px] border-dashed bg-transparent':
|
|
294
|
-
indicator === 'dashed',
|
|
295
|
-
'my-0.5': nestLabel && indicator === 'dashed',
|
|
296
|
-
}
|
|
297
|
-
)}
|
|
298
|
-
style={
|
|
299
|
-
{
|
|
300
|
-
'--color-bg': indicatorColor,
|
|
301
|
-
'--color-border': indicatorColor,
|
|
302
|
-
} as React.CSSProperties
|
|
303
|
-
}
|
|
304
|
-
/>
|
|
305
|
-
)
|
|
306
|
-
)}
|
|
307
|
-
<div
|
|
308
|
-
className={cn(
|
|
309
|
-
'flex flex-1 justify-between leading-none',
|
|
310
|
-
nestLabel ? 'items-end' : 'items-center'
|
|
311
|
-
)}
|
|
312
|
-
>
|
|
313
|
-
<div className="grid gap-1.5">
|
|
314
|
-
{nestLabel ? tooltipLabel : null}
|
|
315
|
-
<span className="text-muted-foreground">
|
|
316
|
-
{itemConfig?.label || item.name}
|
|
317
|
-
</span>
|
|
318
|
-
</div>
|
|
319
|
-
{item.value && (
|
|
320
|
-
<span className="text-foreground font-mono font-semibold tabular-nums">
|
|
321
|
-
{item.value.toLocaleString()}
|
|
322
|
-
</span>
|
|
323
|
-
)}
|
|
324
|
-
</div>
|
|
325
|
-
</>
|
|
326
|
-
)}
|
|
327
|
-
</div>
|
|
328
|
-
);
|
|
329
|
-
})}
|
|
330
|
-
</div>
|
|
331
|
-
</div>
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const ChartLegend = RechartsPrimitive.Legend;
|
|
336
|
-
|
|
337
|
-
function ChartLegendContent({
|
|
338
|
-
className,
|
|
339
|
-
hideIcon = false,
|
|
340
|
-
payload,
|
|
341
|
-
verticalAlign = 'bottom',
|
|
342
|
-
nameKey,
|
|
343
|
-
}: React.ComponentProps<'div'> &
|
|
344
|
-
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
|
345
|
-
hideIcon?: boolean;
|
|
346
|
-
nameKey?: string;
|
|
347
|
-
}) {
|
|
348
|
-
const { config } = useChart();
|
|
349
|
-
|
|
350
|
-
if (!payload?.length) {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return (
|
|
355
|
-
<div
|
|
356
|
-
className={cn(
|
|
357
|
-
'flex items-center justify-center gap-4',
|
|
358
|
-
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
|
359
|
-
className
|
|
360
|
-
)}
|
|
361
|
-
>
|
|
362
|
-
{payload.map(item => {
|
|
363
|
-
const key = `${nameKey ? item.value : item.dataKey || item.value || 'value'}`;
|
|
364
|
-
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
365
|
-
|
|
366
|
-
return (
|
|
367
|
-
<div
|
|
368
|
-
key={item.value}
|
|
369
|
-
className={cn(
|
|
370
|
-
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
|
371
|
-
)}
|
|
372
|
-
>
|
|
373
|
-
{itemConfig?.icon && !hideIcon ? (
|
|
374
|
-
<itemConfig.icon />
|
|
375
|
-
) : (
|
|
376
|
-
<div
|
|
377
|
-
className="h-2 w-2 shrink-0 rounded-full bg-(--color-bg)"
|
|
378
|
-
style={
|
|
379
|
-
{
|
|
380
|
-
'--color-bg': item.color || `var(--color-${key})`,
|
|
381
|
-
} as React.CSSProperties
|
|
382
|
-
}
|
|
383
|
-
/>
|
|
384
|
-
)}
|
|
385
|
-
<span className="text-muted-foreground text-xs">{itemConfig?.label || item.value}</span>
|
|
386
|
-
</div>
|
|
387
|
-
);
|
|
388
|
-
})}
|
|
389
|
-
</div>
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Helper to extract item config from a payload.
|
|
394
|
-
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
|
395
|
-
if (typeof payload !== 'object' || payload === null) {
|
|
396
|
-
return undefined;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const payloadPayload =
|
|
400
|
-
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
|
401
|
-
? payload.payload
|
|
402
|
-
: undefined;
|
|
403
|
-
|
|
404
|
-
let configLabelKey: string = key;
|
|
405
|
-
|
|
406
|
-
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
|
407
|
-
configLabelKey = payload[key as keyof typeof payload] as string;
|
|
408
|
-
} else if (
|
|
409
|
-
payloadPayload &&
|
|
410
|
-
key in payloadPayload &&
|
|
411
|
-
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
|
412
|
-
) {
|
|
413
|
-
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function getChartSeries(
|
|
420
|
-
config: ChartConfig,
|
|
421
|
-
series?: DashboardChartSeries[]
|
|
422
|
-
): DashboardChartSeries[] {
|
|
423
|
-
if (series?.length) {
|
|
424
|
-
return series;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
return Object.entries(config).map(([key, item]) => ({
|
|
428
|
-
key,
|
|
429
|
-
label: item.label,
|
|
430
|
-
}));
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function getSeriesColor(key: string, index: number, colors?: DashboardChartColors) {
|
|
434
|
-
if (Array.isArray(colors)) {
|
|
435
|
-
return colors[index] || defaultChartColors[index % defaultChartColors.length];
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
return colors?.[key] || defaultChartColors[index % defaultChartColors.length];
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function getChartConfigWithColors(
|
|
442
|
-
config: ChartConfig,
|
|
443
|
-
keys: string[],
|
|
444
|
-
colors?: DashboardChartColors
|
|
445
|
-
): ChartConfig {
|
|
446
|
-
return keys.reduce<ChartConfig>((nextConfig, key, index) => {
|
|
447
|
-
const item = config[key];
|
|
448
|
-
|
|
449
|
-
if (item?.theme && !colors) {
|
|
450
|
-
nextConfig[key] = item;
|
|
451
|
-
return nextConfig;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
nextConfig[key] = {
|
|
455
|
-
label: item?.label || key,
|
|
456
|
-
icon: item?.icon,
|
|
457
|
-
color: colors
|
|
458
|
-
? getSeriesColor(key, index, colors)
|
|
459
|
-
: item?.color || getSeriesColor(key, index),
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
return nextConfig;
|
|
463
|
-
}, {});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** Build a ChartConfig from a list of keys (no pre-existing config needed). */
|
|
467
|
-
function buildChartConfig(keys: string[], colors?: DashboardChartColors): ChartConfig {
|
|
468
|
-
return keys.reduce<ChartConfig>((cfg, key, index) => {
|
|
469
|
-
cfg[key] = {
|
|
470
|
-
label: key,
|
|
471
|
-
color: getSeriesColor(key, index, colors),
|
|
472
|
-
};
|
|
473
|
-
return cfg;
|
|
474
|
-
}, {});
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function formatTick(value: number | string) {
|
|
478
|
-
if (typeof value !== 'number') {
|
|
479
|
-
return value;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return Intl.NumberFormat('en', {
|
|
483
|
-
notation: 'compact',
|
|
484
|
-
maximumFractionDigits: 1,
|
|
485
|
-
}).format(value);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
function defaultFilterData(data: DashboardChartDatum[], period: string) {
|
|
489
|
-
const match = period.match(/^(\d+)/);
|
|
490
|
-
|
|
491
|
-
if (!match) {
|
|
492
|
-
return data;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const limit = Number(match[1]);
|
|
496
|
-
return data.slice(Math.max(data.length - limit, 0));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function getErrorDescription(error: ChartErrorState) {
|
|
500
|
-
if (typeof error === 'string') {
|
|
501
|
-
return error;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (error instanceof Error) {
|
|
505
|
-
return error.message;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return undefined;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function getBarSize(barSize: ChartBarSize = 'md') {
|
|
512
|
-
return typeof barSize === 'number' ? barSize : chartBarSizes[barSize];
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function hasChartData(data: DashboardChartDatum[], series: DashboardChartSeries[]) {
|
|
516
|
-
if (!data.length || !series.length) {
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
return data.some(item =>
|
|
521
|
-
series.some(serie => {
|
|
522
|
-
const value = item[serie.key];
|
|
523
|
-
return value !== null && value !== undefined && value !== '';
|
|
524
|
-
})
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function hasPieData(data: DashboardChartDatum[], nameKey: string, valueKey: string) {
|
|
529
|
-
return data.some(item => {
|
|
530
|
-
const name = item[nameKey];
|
|
531
|
-
const value = item[valueKey];
|
|
532
|
-
return (
|
|
533
|
-
name !== null &&
|
|
534
|
-
name !== undefined &&
|
|
535
|
-
name !== '' &&
|
|
536
|
-
value !== null &&
|
|
537
|
-
value !== undefined &&
|
|
538
|
-
value !== ''
|
|
539
|
-
);
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function ChartState({
|
|
544
|
-
type,
|
|
545
|
-
className,
|
|
546
|
-
error,
|
|
547
|
-
onRetry,
|
|
548
|
-
retryLabel = 'Try again',
|
|
549
|
-
emptyTitle = 'No data available',
|
|
550
|
-
emptyDescription = 'There is no data available for this chart yet.',
|
|
551
|
-
errorTitle = 'Connection error',
|
|
552
|
-
errorDescription,
|
|
553
|
-
loadingLabel = 'Loading chart data',
|
|
554
|
-
}: ChartStateProps & {
|
|
555
|
-
type: 'empty' | 'error' | 'loading';
|
|
556
|
-
className?: string;
|
|
557
|
-
}) {
|
|
558
|
-
if (type === 'loading') {
|
|
559
|
-
return (
|
|
560
|
-
<div
|
|
561
|
-
className={cn(
|
|
562
|
-
'flex min-h-[240px] flex-col justify-end gap-3 rounded-[var(--radius-card)] border border-border p-6',
|
|
563
|
-
className
|
|
564
|
-
)}
|
|
565
|
-
aria-label={typeof loadingLabel === 'string' ? loadingLabel : undefined}
|
|
566
|
-
>
|
|
567
|
-
<Skeleton className="h-8 w-2/5" />
|
|
568
|
-
<Skeleton className="h-14 w-3/5" />
|
|
569
|
-
<Skeleton className="h-24 w-4/5" />
|
|
570
|
-
<Skeleton className="h-36 w-full" />
|
|
571
|
-
</div>
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
if (type === 'error') {
|
|
576
|
-
return (
|
|
577
|
-
<div
|
|
578
|
-
className={cn(
|
|
579
|
-
'flex min-h-[240px] items-center justify-center rounded-[var(--radius-card)] border border-border p-6',
|
|
580
|
-
className
|
|
581
|
-
)}
|
|
582
|
-
>
|
|
583
|
-
<Alert variant="destructive" className="max-w-xl">
|
|
584
|
-
<AlertTitle>{errorTitle}</AlertTitle>
|
|
585
|
-
<AlertDescription>
|
|
586
|
-
{errorDescription ||
|
|
587
|
-
getErrorDescription(error) ||
|
|
588
|
-
'Unable to load chart data. Check your connection and try again.'}
|
|
589
|
-
</AlertDescription>
|
|
590
|
-
{onRetry ? (
|
|
591
|
-
<div className="mt-3">
|
|
592
|
-
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
593
|
-
<RefreshCw className="size-4" />
|
|
594
|
-
{retryLabel}
|
|
595
|
-
</Button>
|
|
596
|
-
</div>
|
|
597
|
-
) : null}
|
|
598
|
-
</Alert>
|
|
599
|
-
</div>
|
|
600
|
-
);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return (
|
|
604
|
-
<Empty className={cn('min-h-[240px]', className)}>
|
|
605
|
-
<EmptyIcon>
|
|
606
|
-
<BarChart3 className="size-10 text-muted-foreground" />
|
|
607
|
-
</EmptyIcon>
|
|
608
|
-
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
|
609
|
-
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
|
610
|
-
{onRetry ? (
|
|
611
|
-
<EmptyAction>
|
|
612
|
-
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
613
|
-
<WifiOff className="size-4" />
|
|
614
|
-
{retryLabel}
|
|
615
|
-
</Button>
|
|
616
|
-
</EmptyAction>
|
|
617
|
-
) : null}
|
|
618
|
-
</Empty>
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
function getChartState(state: ChartStateProps, hasData: boolean, className?: string) {
|
|
623
|
-
if (state.isLoading) {
|
|
624
|
-
return <ChartState {...state} type="loading" className={className} />;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
if (state.error) {
|
|
628
|
-
return <ChartState {...state} type="error" className={className} />;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
if (!hasData) {
|
|
632
|
-
return <ChartState {...state} type="empty" className={className} />;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
return null;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
export interface ChartCardProps extends Omit<React.ComponentProps<typeof Card>, 'title'> {
|
|
639
|
-
title: React.ReactNode;
|
|
640
|
-
description?: React.ReactNode;
|
|
641
|
-
action?: React.ReactNode;
|
|
642
|
-
footer?: React.ReactNode;
|
|
643
|
-
contentClassName?: string;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
function ChartCard({
|
|
647
|
-
title,
|
|
648
|
-
description,
|
|
649
|
-
action,
|
|
650
|
-
footer,
|
|
651
|
-
children,
|
|
652
|
-
className,
|
|
653
|
-
contentClassName,
|
|
654
|
-
...props
|
|
655
|
-
}: ChartCardProps) {
|
|
656
|
-
return (
|
|
657
|
-
<Card className={cn('w-full min-w-0 overflow-hidden', className)} {...props}>
|
|
658
|
-
<CardHeader>
|
|
659
|
-
<CardTitle>{title}</CardTitle>
|
|
660
|
-
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
661
|
-
{action ? <CardAction>{action}</CardAction> : null}
|
|
662
|
-
</CardHeader>
|
|
663
|
-
<CardContent className={contentClassName}>{children}</CardContent>
|
|
664
|
-
{footer ? <CardFooter>{footer}</CardFooter> : null}
|
|
665
|
-
</Card>
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// ─── Gradient defs helper ────────────────────────────────────────────────────
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Renders SVG `<defs>` with linear gradients for each series key.
|
|
673
|
-
* Used internally by area charts when `gradientFill` is enabled.
|
|
674
|
-
*/
|
|
675
|
-
function AreaGradientDefs({
|
|
676
|
-
seriesKeys,
|
|
677
|
-
opacity = 0.3,
|
|
678
|
-
}: {
|
|
679
|
-
seriesKeys: string[];
|
|
680
|
-
opacity?: number;
|
|
681
|
-
}) {
|
|
682
|
-
return (
|
|
683
|
-
<defs>
|
|
684
|
-
{seriesKeys.map(key => (
|
|
685
|
-
<linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
|
|
686
|
-
<stop offset="5%" stopColor={`var(--color-${key})`} stopOpacity={opacity} />
|
|
687
|
-
<stop offset="95%" stopColor={`var(--color-${key})`} stopOpacity={0} />
|
|
688
|
-
</linearGradient>
|
|
689
|
-
))}
|
|
690
|
-
</defs>
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// ─── DashboardBarChart ────────────────────────────────────────────────────────
|
|
695
|
-
|
|
696
|
-
export interface DashboardBarChartProps
|
|
697
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
698
|
-
data: DashboardChartDatum[];
|
|
699
|
-
indexKey?: string;
|
|
700
|
-
series?: DashboardChartSeries[];
|
|
701
|
-
colors?: DashboardChartColors;
|
|
702
|
-
barSize?: ChartBarSize;
|
|
703
|
-
stacked?: boolean;
|
|
704
|
-
showGrid?: boolean;
|
|
705
|
-
showLegend?: boolean;
|
|
706
|
-
valueFormatter?: ChartValueFormatter;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
function DashboardBarChart({
|
|
710
|
-
data,
|
|
711
|
-
config,
|
|
712
|
-
indexKey = 'name',
|
|
713
|
-
series,
|
|
714
|
-
colors,
|
|
715
|
-
barSize = 'md',
|
|
716
|
-
stacked = false,
|
|
717
|
-
showGrid = true,
|
|
718
|
-
showLegend = true,
|
|
719
|
-
valueFormatter = formatTick,
|
|
720
|
-
isLoading,
|
|
721
|
-
error,
|
|
722
|
-
onRetry,
|
|
723
|
-
retryLabel,
|
|
724
|
-
emptyTitle,
|
|
725
|
-
emptyDescription,
|
|
726
|
-
errorTitle,
|
|
727
|
-
errorDescription,
|
|
728
|
-
loadingLabel,
|
|
729
|
-
stateClassName,
|
|
730
|
-
className,
|
|
731
|
-
...props
|
|
732
|
-
}: DashboardBarChartProps) {
|
|
733
|
-
const chartSeries = getChartSeries(config, series);
|
|
734
|
-
const chartConfig = getChartConfigWithColors(
|
|
735
|
-
config,
|
|
736
|
-
chartSeries.map(item => item.key),
|
|
737
|
-
colors
|
|
738
|
-
);
|
|
739
|
-
const chartState = getChartState(
|
|
740
|
-
{
|
|
741
|
-
isLoading,
|
|
742
|
-
error,
|
|
743
|
-
onRetry,
|
|
744
|
-
retryLabel,
|
|
745
|
-
emptyTitle,
|
|
746
|
-
emptyDescription,
|
|
747
|
-
errorTitle,
|
|
748
|
-
errorDescription,
|
|
749
|
-
loadingLabel,
|
|
750
|
-
},
|
|
751
|
-
hasChartData(data, chartSeries),
|
|
752
|
-
cn('h-[320px] w-full', stateClassName)
|
|
753
|
-
);
|
|
754
|
-
|
|
755
|
-
const barElements = React.useMemo(() => {
|
|
756
|
-
const topOfStack = new Set<string>();
|
|
757
|
-
if (stacked) {
|
|
758
|
-
const lastByStack = new Map<string, string>();
|
|
759
|
-
chartSeries.forEach(s => lastByStack.set(s.stackId || 'total', s.key));
|
|
760
|
-
lastByStack.forEach(key => topOfStack.add(key));
|
|
761
|
-
}
|
|
762
|
-
return chartSeries.map(item => {
|
|
763
|
-
const isTop = !stacked || topOfStack.has(item.key);
|
|
764
|
-
return (
|
|
765
|
-
<RechartsPrimitive.Bar
|
|
766
|
-
key={item.key}
|
|
767
|
-
dataKey={item.key}
|
|
768
|
-
fill={`var(--color-${item.key})`}
|
|
769
|
-
radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]}
|
|
770
|
-
barSize={getBarSize(barSize)}
|
|
771
|
-
stackId={stacked ? item.stackId || 'total' : item.stackId}
|
|
772
|
-
isAnimationActive
|
|
773
|
-
animationDuration={600}
|
|
774
|
-
animationEasing="ease-out"
|
|
775
|
-
/>
|
|
776
|
-
);
|
|
777
|
-
});
|
|
778
|
-
}, [stacked, chartSeries]);
|
|
779
|
-
|
|
780
|
-
if (chartState) {
|
|
781
|
-
return chartState;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
return (
|
|
785
|
-
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
786
|
-
<RechartsPrimitive.BarChart data={data} accessibilityLayer barGap={4}>
|
|
787
|
-
{showGrid ? (
|
|
788
|
-
<RechartsPrimitive.CartesianGrid
|
|
789
|
-
vertical={false}
|
|
790
|
-
strokeDasharray="3 3"
|
|
791
|
-
stroke="var(--border)"
|
|
792
|
-
strokeOpacity={0.5}
|
|
793
|
-
/>
|
|
794
|
-
) : null}
|
|
795
|
-
<RechartsPrimitive.XAxis
|
|
796
|
-
dataKey={indexKey}
|
|
797
|
-
tickLine={false}
|
|
798
|
-
axisLine={false}
|
|
799
|
-
tickMargin={8}
|
|
800
|
-
/>
|
|
801
|
-
<RechartsPrimitive.YAxis
|
|
802
|
-
tickLine={false}
|
|
803
|
-
axisLine={false}
|
|
804
|
-
tickMargin={8}
|
|
805
|
-
tickFormatter={valueFormatter}
|
|
806
|
-
/>
|
|
807
|
-
<ChartTooltip
|
|
808
|
-
cursor={{ fill: 'var(--muted)', opacity: 0.4, radius: 4 }}
|
|
809
|
-
content={<ChartTooltipContent />}
|
|
810
|
-
/>
|
|
811
|
-
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
812
|
-
{barElements}
|
|
813
|
-
</RechartsPrimitive.BarChart>
|
|
814
|
-
</ChartContainer>
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// ─── DashboardLineChart ───────────────────────────────────────────────────────
|
|
819
|
-
|
|
820
|
-
export interface DashboardLineChartProps
|
|
821
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
822
|
-
data: DashboardChartDatum[];
|
|
823
|
-
indexKey?: string;
|
|
824
|
-
series?: DashboardChartSeries[];
|
|
825
|
-
colors?: DashboardChartColors;
|
|
826
|
-
showDots?: boolean;
|
|
827
|
-
showGrid?: boolean;
|
|
828
|
-
showLegend?: boolean;
|
|
829
|
-
curveType?: ChartCurveType;
|
|
830
|
-
strokeWidth?: number;
|
|
831
|
-
valueFormatter?: ChartValueFormatter;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function DashboardLineChart({
|
|
835
|
-
data,
|
|
836
|
-
config,
|
|
837
|
-
indexKey = 'name',
|
|
838
|
-
series,
|
|
839
|
-
colors,
|
|
840
|
-
showDots = false,
|
|
841
|
-
showGrid = true,
|
|
842
|
-
showLegend = true,
|
|
843
|
-
curveType = 'monotone',
|
|
844
|
-
strokeWidth = 2,
|
|
845
|
-
valueFormatter = formatTick,
|
|
846
|
-
isLoading,
|
|
847
|
-
error,
|
|
848
|
-
onRetry,
|
|
849
|
-
retryLabel,
|
|
850
|
-
emptyTitle,
|
|
851
|
-
emptyDescription,
|
|
852
|
-
errorTitle,
|
|
853
|
-
errorDescription,
|
|
854
|
-
loadingLabel,
|
|
855
|
-
stateClassName,
|
|
856
|
-
className,
|
|
857
|
-
...props
|
|
858
|
-
}: DashboardLineChartProps) {
|
|
859
|
-
const chartSeries = getChartSeries(config, series);
|
|
860
|
-
const chartConfig = getChartConfigWithColors(
|
|
861
|
-
config,
|
|
862
|
-
chartSeries.map(item => item.key),
|
|
863
|
-
colors
|
|
864
|
-
);
|
|
865
|
-
const chartState = getChartState(
|
|
866
|
-
{
|
|
867
|
-
isLoading,
|
|
868
|
-
error,
|
|
869
|
-
onRetry,
|
|
870
|
-
retryLabel,
|
|
871
|
-
emptyTitle,
|
|
872
|
-
emptyDescription,
|
|
873
|
-
errorTitle,
|
|
874
|
-
errorDescription,
|
|
875
|
-
loadingLabel,
|
|
876
|
-
},
|
|
877
|
-
hasChartData(data, chartSeries),
|
|
878
|
-
cn('h-[320px] w-full', stateClassName)
|
|
879
|
-
);
|
|
880
|
-
|
|
881
|
-
if (chartState) {
|
|
882
|
-
return chartState;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
return (
|
|
886
|
-
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
887
|
-
<RechartsPrimitive.LineChart data={data} accessibilityLayer>
|
|
888
|
-
{showGrid ? (
|
|
889
|
-
<RechartsPrimitive.CartesianGrid
|
|
890
|
-
vertical={false}
|
|
891
|
-
strokeDasharray="3 3"
|
|
892
|
-
stroke="var(--border)"
|
|
893
|
-
strokeOpacity={0.5}
|
|
894
|
-
/>
|
|
895
|
-
) : null}
|
|
896
|
-
<RechartsPrimitive.XAxis
|
|
897
|
-
dataKey={indexKey}
|
|
898
|
-
tickLine={false}
|
|
899
|
-
axisLine={false}
|
|
900
|
-
tickMargin={8}
|
|
901
|
-
/>
|
|
902
|
-
<RechartsPrimitive.YAxis
|
|
903
|
-
tickLine={false}
|
|
904
|
-
axisLine={false}
|
|
905
|
-
tickMargin={8}
|
|
906
|
-
tickFormatter={valueFormatter}
|
|
907
|
-
/>
|
|
908
|
-
<ChartTooltip content={<ChartTooltipContent indicator="line" />} />
|
|
909
|
-
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
910
|
-
{chartSeries.map(item => (
|
|
911
|
-
<RechartsPrimitive.Line
|
|
912
|
-
key={item.key}
|
|
913
|
-
type={curveType}
|
|
914
|
-
dataKey={item.key}
|
|
915
|
-
stroke={`var(--color-${item.key})`}
|
|
916
|
-
strokeWidth={strokeWidth}
|
|
917
|
-
dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
|
|
918
|
-
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
919
|
-
yAxisId={item.yAxisId}
|
|
920
|
-
isAnimationActive
|
|
921
|
-
animationDuration={600}
|
|
922
|
-
animationEasing="ease-out"
|
|
923
|
-
/>
|
|
924
|
-
))}
|
|
925
|
-
</RechartsPrimitive.LineChart>
|
|
926
|
-
</ChartContainer>
|
|
927
|
-
);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// ─── HorizontalBarChart ───────────────────────────────────────────────────────
|
|
931
|
-
|
|
932
|
-
export interface HorizontalBarChartProps
|
|
933
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
934
|
-
data: DashboardChartDatum[];
|
|
935
|
-
indexKey?: string;
|
|
936
|
-
series?: DashboardChartSeries[];
|
|
937
|
-
colors?: DashboardChartColors;
|
|
938
|
-
barSize?: ChartBarSize;
|
|
939
|
-
stacked?: boolean;
|
|
940
|
-
categoryWidth?: number;
|
|
941
|
-
showGrid?: boolean;
|
|
942
|
-
showLegend?: boolean;
|
|
943
|
-
valueFormatter?: ChartValueFormatter;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function HorizontalBarChart({
|
|
947
|
-
data,
|
|
948
|
-
config,
|
|
949
|
-
indexKey = 'name',
|
|
950
|
-
series,
|
|
951
|
-
colors,
|
|
952
|
-
barSize = 'md',
|
|
953
|
-
stacked = false,
|
|
954
|
-
categoryWidth = 96,
|
|
955
|
-
showGrid = true,
|
|
956
|
-
showLegend = true,
|
|
957
|
-
valueFormatter = formatTick,
|
|
958
|
-
isLoading,
|
|
959
|
-
error,
|
|
960
|
-
onRetry,
|
|
961
|
-
retryLabel,
|
|
962
|
-
emptyTitle,
|
|
963
|
-
emptyDescription,
|
|
964
|
-
errorTitle,
|
|
965
|
-
errorDescription,
|
|
966
|
-
loadingLabel,
|
|
967
|
-
stateClassName,
|
|
968
|
-
className,
|
|
969
|
-
...props
|
|
970
|
-
}: HorizontalBarChartProps) {
|
|
971
|
-
const chartSeries = getChartSeries(config, series);
|
|
972
|
-
const chartConfig = getChartConfigWithColors(
|
|
973
|
-
config,
|
|
974
|
-
chartSeries.map(item => item.key),
|
|
975
|
-
colors
|
|
976
|
-
);
|
|
977
|
-
const chartState = getChartState(
|
|
978
|
-
{
|
|
979
|
-
isLoading,
|
|
980
|
-
error,
|
|
981
|
-
onRetry,
|
|
982
|
-
retryLabel,
|
|
983
|
-
emptyTitle,
|
|
984
|
-
emptyDescription,
|
|
985
|
-
errorTitle,
|
|
986
|
-
errorDescription,
|
|
987
|
-
loadingLabel,
|
|
988
|
-
},
|
|
989
|
-
hasChartData(data, chartSeries),
|
|
990
|
-
cn('h-[320px] w-full', stateClassName)
|
|
991
|
-
);
|
|
992
|
-
|
|
993
|
-
if (chartState) {
|
|
994
|
-
return chartState;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
return (
|
|
998
|
-
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
999
|
-
<RechartsPrimitive.BarChart
|
|
1000
|
-
data={data}
|
|
1001
|
-
layout="vertical"
|
|
1002
|
-
accessibilityLayer
|
|
1003
|
-
margin={{ left: 8, right: 16 }}
|
|
1004
|
-
>
|
|
1005
|
-
{showGrid ? (
|
|
1006
|
-
<RechartsPrimitive.CartesianGrid
|
|
1007
|
-
horizontal={false}
|
|
1008
|
-
strokeDasharray="3 3"
|
|
1009
|
-
stroke="var(--border)"
|
|
1010
|
-
strokeOpacity={0.5}
|
|
1011
|
-
/>
|
|
1012
|
-
) : null}
|
|
1013
|
-
<RechartsPrimitive.XAxis
|
|
1014
|
-
type="number"
|
|
1015
|
-
tickLine={false}
|
|
1016
|
-
axisLine={false}
|
|
1017
|
-
tickMargin={8}
|
|
1018
|
-
tickFormatter={valueFormatter}
|
|
1019
|
-
/>
|
|
1020
|
-
<RechartsPrimitive.YAxis
|
|
1021
|
-
dataKey={indexKey}
|
|
1022
|
-
type="category"
|
|
1023
|
-
tickLine={false}
|
|
1024
|
-
axisLine={false}
|
|
1025
|
-
tickMargin={8}
|
|
1026
|
-
width={categoryWidth}
|
|
1027
|
-
/>
|
|
1028
|
-
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1029
|
-
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1030
|
-
{chartSeries.map(item => (
|
|
1031
|
-
<RechartsPrimitive.Bar
|
|
1032
|
-
key={item.key}
|
|
1033
|
-
dataKey={item.key}
|
|
1034
|
-
fill={`var(--color-${item.key})`}
|
|
1035
|
-
radius={[0, 4, 4, 0]}
|
|
1036
|
-
barSize={getBarSize(barSize)}
|
|
1037
|
-
stackId={stacked ? item.stackId || 'total' : item.stackId}
|
|
1038
|
-
isAnimationActive
|
|
1039
|
-
animationDuration={600}
|
|
1040
|
-
animationEasing="ease-out"
|
|
1041
|
-
/>
|
|
1042
|
-
))}
|
|
1043
|
-
</RechartsPrimitive.BarChart>
|
|
1044
|
-
</ChartContainer>
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
// ─── InteractiveTimeSeriesChart ───────────────────────────────────────────────
|
|
1049
|
-
|
|
1050
|
-
export interface InteractiveTimeSeriesChartProps
|
|
1051
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1052
|
-
data: DashboardChartDatum[];
|
|
1053
|
-
indexKey?: string;
|
|
1054
|
-
series?: DashboardChartSeries[];
|
|
1055
|
-
colors?: DashboardChartColors;
|
|
1056
|
-
periods?: ChartPeriod[];
|
|
1057
|
-
defaultPeriod?: string;
|
|
1058
|
-
defaultSeries?: string;
|
|
1059
|
-
filterData?: (data: DashboardChartDatum[], period: string) => DashboardChartDatum[];
|
|
1060
|
-
showGrid?: boolean;
|
|
1061
|
-
showLegend?: boolean;
|
|
1062
|
-
showDots?: boolean;
|
|
1063
|
-
gradientFill?: boolean;
|
|
1064
|
-
curveType?: ChartCurveType;
|
|
1065
|
-
strokeWidth?: number;
|
|
1066
|
-
valueFormatter?: ChartValueFormatter;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function InteractiveTimeSeriesChart({
|
|
1070
|
-
data,
|
|
1071
|
-
config,
|
|
1072
|
-
indexKey = 'date',
|
|
1073
|
-
series,
|
|
1074
|
-
colors,
|
|
1075
|
-
periods = defaultPeriods,
|
|
1076
|
-
defaultPeriod = periods[1]?.value || periods[0]?.value || '30d',
|
|
1077
|
-
defaultSeries,
|
|
1078
|
-
filterData = defaultFilterData,
|
|
1079
|
-
showGrid = true,
|
|
1080
|
-
showLegend = false,
|
|
1081
|
-
showDots = false,
|
|
1082
|
-
gradientFill = true,
|
|
1083
|
-
curveType = 'monotone',
|
|
1084
|
-
strokeWidth = 2,
|
|
1085
|
-
valueFormatter = formatTick,
|
|
1086
|
-
isLoading,
|
|
1087
|
-
error,
|
|
1088
|
-
onRetry,
|
|
1089
|
-
retryLabel,
|
|
1090
|
-
emptyTitle,
|
|
1091
|
-
emptyDescription,
|
|
1092
|
-
errorTitle,
|
|
1093
|
-
errorDescription,
|
|
1094
|
-
loadingLabel,
|
|
1095
|
-
stateClassName,
|
|
1096
|
-
className,
|
|
1097
|
-
...props
|
|
1098
|
-
}: InteractiveTimeSeriesChartProps) {
|
|
1099
|
-
const chartSeries = getChartSeries(config, series);
|
|
1100
|
-
const chartConfig = getChartConfigWithColors(
|
|
1101
|
-
config,
|
|
1102
|
-
chartSeries.map(item => item.key),
|
|
1103
|
-
colors
|
|
1104
|
-
);
|
|
1105
|
-
const [period, setPeriod] = React.useState(defaultPeriod);
|
|
1106
|
-
const [activeSeries, setActiveSeries] = React.useState(
|
|
1107
|
-
defaultSeries || chartSeries[0]?.key || ''
|
|
1108
|
-
);
|
|
1109
|
-
const filteredData = React.useMemo(() => filterData(data, period), [data, filterData, period]);
|
|
1110
|
-
const visibleSeries =
|
|
1111
|
-
chartSeries.length > 1 ? chartSeries.filter(item => item.key === activeSeries) : chartSeries;
|
|
1112
|
-
const chartState = getChartState(
|
|
1113
|
-
{
|
|
1114
|
-
isLoading,
|
|
1115
|
-
error,
|
|
1116
|
-
onRetry,
|
|
1117
|
-
retryLabel,
|
|
1118
|
-
emptyTitle,
|
|
1119
|
-
emptyDescription,
|
|
1120
|
-
errorTitle,
|
|
1121
|
-
errorDescription,
|
|
1122
|
-
loadingLabel,
|
|
1123
|
-
},
|
|
1124
|
-
hasChartData(filteredData, visibleSeries),
|
|
1125
|
-
cn('h-[320px] w-full', stateClassName)
|
|
1126
|
-
);
|
|
1127
|
-
|
|
1128
|
-
return (
|
|
1129
|
-
<div className="w-full space-y-4">
|
|
1130
|
-
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
1131
|
-
{chartSeries.length > 1 ? (
|
|
1132
|
-
<div
|
|
1133
|
-
className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"
|
|
1134
|
-
role="group"
|
|
1135
|
-
aria-label="Selecionar metrica do grafico"
|
|
1136
|
-
>
|
|
1137
|
-
{chartSeries.map(item => {
|
|
1138
|
-
const label = item.label || config[item.key]?.label || item.key;
|
|
1139
|
-
|
|
1140
|
-
return (
|
|
1141
|
-
<Button
|
|
1142
|
-
key={item.key}
|
|
1143
|
-
type="button"
|
|
1144
|
-
variant="ghost"
|
|
1145
|
-
size="sm"
|
|
1146
|
-
aria-pressed={activeSeries === item.key}
|
|
1147
|
-
onClick={() => setActiveSeries(item.key)}
|
|
1148
|
-
className={cn(
|
|
1149
|
-
'h-8 px-3 text-sm font-medium transition-all',
|
|
1150
|
-
activeSeries === item.key
|
|
1151
|
-
? 'bg-background text-foreground shadow-sm'
|
|
1152
|
-
: 'text-muted-foreground hover:text-foreground'
|
|
1153
|
-
)}
|
|
1154
|
-
>
|
|
1155
|
-
{label}
|
|
1156
|
-
</Button>
|
|
1157
|
-
);
|
|
1158
|
-
})}
|
|
1159
|
-
</div>
|
|
1160
|
-
) : (
|
|
1161
|
-
<div />
|
|
1162
|
-
)}
|
|
1163
|
-
<Select value={period} onValueChange={setPeriod}>
|
|
1164
|
-
<SelectTrigger
|
|
1165
|
-
className="w-full sm:w-[160px]"
|
|
1166
|
-
size="sm"
|
|
1167
|
-
aria-label="Selecionar periodo do grafico"
|
|
1168
|
-
>
|
|
1169
|
-
<SelectValue placeholder="Period" />
|
|
1170
|
-
</SelectTrigger>
|
|
1171
|
-
<SelectContent>
|
|
1172
|
-
{periods.map(item => (
|
|
1173
|
-
<SelectItem key={item.value} value={item.value}>
|
|
1174
|
-
{item.label}
|
|
1175
|
-
</SelectItem>
|
|
1176
|
-
))}
|
|
1177
|
-
</SelectContent>
|
|
1178
|
-
</Select>
|
|
1179
|
-
</div>
|
|
1180
|
-
{chartState || (
|
|
1181
|
-
<ChartContainer
|
|
1182
|
-
config={chartConfig}
|
|
1183
|
-
className={cn('h-[320px] w-full', className)}
|
|
1184
|
-
{...props}
|
|
1185
|
-
>
|
|
1186
|
-
<RechartsPrimitive.AreaChart data={filteredData} accessibilityLayer>
|
|
1187
|
-
{gradientFill && (
|
|
1188
|
-
<AreaGradientDefs seriesKeys={visibleSeries.map(s => s.key)} opacity={0.4} />
|
|
1189
|
-
)}
|
|
1190
|
-
{showGrid ? (
|
|
1191
|
-
<RechartsPrimitive.CartesianGrid
|
|
1192
|
-
vertical={false}
|
|
1193
|
-
strokeDasharray="3 3"
|
|
1194
|
-
stroke="var(--border)"
|
|
1195
|
-
strokeOpacity={0.5}
|
|
1196
|
-
/>
|
|
1197
|
-
) : null}
|
|
1198
|
-
<RechartsPrimitive.XAxis
|
|
1199
|
-
dataKey={indexKey}
|
|
1200
|
-
tickLine={false}
|
|
1201
|
-
axisLine={false}
|
|
1202
|
-
tickMargin={8}
|
|
1203
|
-
/>
|
|
1204
|
-
<RechartsPrimitive.YAxis
|
|
1205
|
-
tickLine={false}
|
|
1206
|
-
axisLine={false}
|
|
1207
|
-
tickMargin={8}
|
|
1208
|
-
tickFormatter={valueFormatter}
|
|
1209
|
-
/>
|
|
1210
|
-
<ChartTooltip content={<ChartTooltipContent indicator="line" />} />
|
|
1211
|
-
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1212
|
-
{visibleSeries.map(item => (
|
|
1213
|
-
<RechartsPrimitive.Area
|
|
1214
|
-
key={item.key}
|
|
1215
|
-
type={curveType}
|
|
1216
|
-
dataKey={item.key}
|
|
1217
|
-
stroke={`var(--color-${item.key})`}
|
|
1218
|
-
fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
|
|
1219
|
-
fillOpacity={gradientFill ? 1 : 0.16}
|
|
1220
|
-
strokeWidth={strokeWidth}
|
|
1221
|
-
dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
|
|
1222
|
-
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1223
|
-
isAnimationActive
|
|
1224
|
-
animationDuration={600}
|
|
1225
|
-
animationEasing="ease-out"
|
|
1226
|
-
/>
|
|
1227
|
-
))}
|
|
1228
|
-
</RechartsPrimitive.AreaChart>
|
|
1229
|
-
</ChartContainer>
|
|
1230
|
-
)}
|
|
1231
|
-
</div>
|
|
1232
|
-
);
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
// ─── ComboMetricChart ─────────────────────────────────────────────────────────
|
|
1236
|
-
|
|
1237
|
-
export interface ComboMetricChartProps
|
|
1238
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1239
|
-
data: DashboardChartDatum[];
|
|
1240
|
-
indexKey?: string;
|
|
1241
|
-
series?: DashboardChartSeries[];
|
|
1242
|
-
colors?: DashboardChartColors;
|
|
1243
|
-
barSize?: ChartBarSize;
|
|
1244
|
-
showGrid?: boolean;
|
|
1245
|
-
showLegend?: boolean;
|
|
1246
|
-
gradientFill?: boolean;
|
|
1247
|
-
curveType?: ChartCurveType;
|
|
1248
|
-
valueFormatter?: ChartValueFormatter;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
function ComboMetricChart({
|
|
1252
|
-
data,
|
|
1253
|
-
config,
|
|
1254
|
-
indexKey = 'name',
|
|
1255
|
-
series,
|
|
1256
|
-
colors,
|
|
1257
|
-
barSize = 'md',
|
|
1258
|
-
showGrid = true,
|
|
1259
|
-
showLegend = true,
|
|
1260
|
-
gradientFill = false,
|
|
1261
|
-
curveType = 'monotone',
|
|
1262
|
-
valueFormatter = formatTick,
|
|
1263
|
-
isLoading,
|
|
1264
|
-
error,
|
|
1265
|
-
onRetry,
|
|
1266
|
-
retryLabel,
|
|
1267
|
-
emptyTitle,
|
|
1268
|
-
emptyDescription,
|
|
1269
|
-
errorTitle,
|
|
1270
|
-
errorDescription,
|
|
1271
|
-
loadingLabel,
|
|
1272
|
-
stateClassName,
|
|
1273
|
-
className,
|
|
1274
|
-
...props
|
|
1275
|
-
}: ComboMetricChartProps) {
|
|
1276
|
-
const chartSeries = getChartSeries(config, series);
|
|
1277
|
-
const chartConfig = getChartConfigWithColors(
|
|
1278
|
-
config,
|
|
1279
|
-
chartSeries.map(item => item.key),
|
|
1280
|
-
colors
|
|
1281
|
-
);
|
|
1282
|
-
const chartState = getChartState(
|
|
1283
|
-
{
|
|
1284
|
-
isLoading,
|
|
1285
|
-
error,
|
|
1286
|
-
onRetry,
|
|
1287
|
-
retryLabel,
|
|
1288
|
-
emptyTitle,
|
|
1289
|
-
emptyDescription,
|
|
1290
|
-
errorTitle,
|
|
1291
|
-
errorDescription,
|
|
1292
|
-
loadingLabel,
|
|
1293
|
-
},
|
|
1294
|
-
hasChartData(data, chartSeries),
|
|
1295
|
-
cn('h-[340px] w-full', stateClassName)
|
|
1296
|
-
);
|
|
1297
|
-
|
|
1298
|
-
if (chartState) {
|
|
1299
|
-
return chartState;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
const areaSeries = gradientFill ? chartSeries.filter(s => s.type === 'area').map(s => s.key) : [];
|
|
1303
|
-
|
|
1304
|
-
return (
|
|
1305
|
-
<ChartContainer config={chartConfig} className={cn('h-[340px] w-full', className)} {...props}>
|
|
1306
|
-
<RechartsPrimitive.ComposedChart data={data} accessibilityLayer>
|
|
1307
|
-
{gradientFill && areaSeries.length > 0 && (
|
|
1308
|
-
<AreaGradientDefs seriesKeys={areaSeries} opacity={0.35} />
|
|
1309
|
-
)}
|
|
1310
|
-
{showGrid ? (
|
|
1311
|
-
<RechartsPrimitive.CartesianGrid
|
|
1312
|
-
vertical={false}
|
|
1313
|
-
strokeDasharray="3 3"
|
|
1314
|
-
stroke="var(--border)"
|
|
1315
|
-
strokeOpacity={0.5}
|
|
1316
|
-
/>
|
|
1317
|
-
) : null}
|
|
1318
|
-
<RechartsPrimitive.XAxis
|
|
1319
|
-
dataKey={indexKey}
|
|
1320
|
-
tickLine={false}
|
|
1321
|
-
axisLine={false}
|
|
1322
|
-
tickMargin={8}
|
|
1323
|
-
/>
|
|
1324
|
-
<RechartsPrimitive.YAxis
|
|
1325
|
-
tickLine={false}
|
|
1326
|
-
axisLine={false}
|
|
1327
|
-
tickMargin={8}
|
|
1328
|
-
tickFormatter={valueFormatter}
|
|
1329
|
-
/>
|
|
1330
|
-
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1331
|
-
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1332
|
-
{chartSeries.map(item =>
|
|
1333
|
-
item.type === 'line' ? (
|
|
1334
|
-
<RechartsPrimitive.Line
|
|
1335
|
-
key={item.key}
|
|
1336
|
-
type={curveType}
|
|
1337
|
-
dataKey={item.key}
|
|
1338
|
-
stroke={`var(--color-${item.key})`}
|
|
1339
|
-
strokeWidth={2}
|
|
1340
|
-
dot={false}
|
|
1341
|
-
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1342
|
-
yAxisId={item.yAxisId}
|
|
1343
|
-
isAnimationActive
|
|
1344
|
-
animationDuration={600}
|
|
1345
|
-
animationEasing="ease-out"
|
|
1346
|
-
/>
|
|
1347
|
-
) : item.type === 'area' ? (
|
|
1348
|
-
<RechartsPrimitive.Area
|
|
1349
|
-
key={item.key}
|
|
1350
|
-
type={curveType}
|
|
1351
|
-
dataKey={item.key}
|
|
1352
|
-
stroke={`var(--color-${item.key})`}
|
|
1353
|
-
fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
|
|
1354
|
-
fillOpacity={gradientFill ? 1 : 0.12}
|
|
1355
|
-
strokeWidth={2}
|
|
1356
|
-
dot={false}
|
|
1357
|
-
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1358
|
-
yAxisId={item.yAxisId}
|
|
1359
|
-
isAnimationActive
|
|
1360
|
-
animationDuration={600}
|
|
1361
|
-
animationEasing="ease-out"
|
|
1362
|
-
/>
|
|
1363
|
-
) : (
|
|
1364
|
-
<RechartsPrimitive.Bar
|
|
1365
|
-
key={item.key}
|
|
1366
|
-
dataKey={item.key}
|
|
1367
|
-
fill={`var(--color-${item.key})`}
|
|
1368
|
-
radius={[4, 4, 0, 0]}
|
|
1369
|
-
barSize={getBarSize(barSize)}
|
|
1370
|
-
yAxisId={item.yAxisId}
|
|
1371
|
-
isAnimationActive
|
|
1372
|
-
animationDuration={600}
|
|
1373
|
-
animationEasing="ease-out"
|
|
1374
|
-
/>
|
|
1375
|
-
)
|
|
1376
|
-
)}
|
|
1377
|
-
</RechartsPrimitive.ComposedChart>
|
|
1378
|
-
</ChartContainer>
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// ─── DonutBreakdownChart ──────────────────────────────────────────────────────
|
|
1383
|
-
|
|
1384
|
-
export interface DonutBreakdownChartProps
|
|
1385
|
-
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1386
|
-
data: DashboardChartDatum[];
|
|
1387
|
-
nameKey?: string;
|
|
1388
|
-
valueKey?: string;
|
|
1389
|
-
colors?: DashboardChartColors;
|
|
1390
|
-
centerLabel?: React.ReactNode;
|
|
1391
|
-
centerValue?: React.ReactNode;
|
|
1392
|
-
showLegend?: boolean;
|
|
1393
|
-
/** Inner radius as percentage string (e.g. "58%") or number */
|
|
1394
|
-
innerRadius?: string | number;
|
|
1395
|
-
/** Outer radius as percentage string (e.g. "82%") or number */
|
|
1396
|
-
outerRadius?: string | number;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
function DonutBreakdownChart({
|
|
1400
|
-
data,
|
|
1401
|
-
config,
|
|
1402
|
-
nameKey = 'name',
|
|
1403
|
-
valueKey = 'value',
|
|
1404
|
-
colors,
|
|
1405
|
-
centerLabel,
|
|
1406
|
-
centerValue,
|
|
1407
|
-
showLegend = true,
|
|
1408
|
-
innerRadius = '58%',
|
|
1409
|
-
outerRadius = '82%',
|
|
1410
|
-
isLoading,
|
|
1411
|
-
error,
|
|
1412
|
-
onRetry,
|
|
1413
|
-
retryLabel,
|
|
1414
|
-
emptyTitle,
|
|
1415
|
-
emptyDescription,
|
|
1416
|
-
errorTitle,
|
|
1417
|
-
errorDescription,
|
|
1418
|
-
loadingLabel,
|
|
1419
|
-
stateClassName,
|
|
1420
|
-
className,
|
|
1421
|
-
...props
|
|
1422
|
-
}: DonutBreakdownChartProps) {
|
|
1423
|
-
const [activeIndex, setActiveIndex] = React.useState(0);
|
|
1424
|
-
const chartKeys = data.map((entry, index) => String(entry[nameKey] || `segment-${index}`));
|
|
1425
|
-
const chartConfig = getChartConfigWithColors(config, chartKeys, colors);
|
|
1426
|
-
const chartState = getChartState(
|
|
1427
|
-
{
|
|
1428
|
-
isLoading,
|
|
1429
|
-
error,
|
|
1430
|
-
onRetry,
|
|
1431
|
-
retryLabel,
|
|
1432
|
-
emptyTitle,
|
|
1433
|
-
emptyDescription,
|
|
1434
|
-
errorTitle,
|
|
1435
|
-
errorDescription,
|
|
1436
|
-
loadingLabel,
|
|
1437
|
-
},
|
|
1438
|
-
hasPieData(data, nameKey, valueKey),
|
|
1439
|
-
cn('h-[320px] w-full', stateClassName)
|
|
1440
|
-
);
|
|
1441
|
-
|
|
1442
|
-
if (chartState) {
|
|
1443
|
-
return chartState;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
return (
|
|
1447
|
-
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
1448
|
-
<RechartsPrimitive.PieChart accessibilityLayer>
|
|
1449
|
-
<ChartTooltip
|
|
1450
|
-
cursor={false}
|
|
1451
|
-
content={<ChartTooltipContent hideLabel nameKey={nameKey} />}
|
|
1452
|
-
/>
|
|
1453
|
-
{showLegend ? (
|
|
1454
|
-
<ChartLegend content={<ChartLegendContent nameKey={nameKey} />} verticalAlign="bottom" />
|
|
1455
|
-
) : null}
|
|
1456
|
-
<RechartsPrimitive.Pie
|
|
1457
|
-
data={data}
|
|
1458
|
-
dataKey={valueKey}
|
|
1459
|
-
nameKey={nameKey}
|
|
1460
|
-
innerRadius={innerRadius}
|
|
1461
|
-
outerRadius={outerRadius}
|
|
1462
|
-
paddingAngle={3}
|
|
1463
|
-
activeIndex={activeIndex}
|
|
1464
|
-
onMouseEnter={(_, index) => setActiveIndex(index)}
|
|
1465
|
-
isAnimationActive
|
|
1466
|
-
animationDuration={600}
|
|
1467
|
-
animationEasing="ease-out"
|
|
1468
|
-
>
|
|
1469
|
-
{data.map((entry, index) => {
|
|
1470
|
-
const key = String(entry[nameKey] || `segment-${index}`);
|
|
1471
|
-
|
|
1472
|
-
return (
|
|
1473
|
-
<RechartsPrimitive.Cell
|
|
1474
|
-
key={key}
|
|
1475
|
-
fill={`var(--color-${key})`}
|
|
1476
|
-
opacity={index === activeIndex ? 1 : 0.7}
|
|
1477
|
-
stroke="transparent"
|
|
1478
|
-
/>
|
|
1479
|
-
);
|
|
1480
|
-
})}
|
|
1481
|
-
{centerValue || centerLabel ? (
|
|
1482
|
-
<RechartsPrimitive.Label
|
|
1483
|
-
position="center"
|
|
1484
|
-
content={({ viewBox }) => {
|
|
1485
|
-
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) {
|
|
1486
|
-
return null;
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
return (
|
|
1490
|
-
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
|
1491
|
-
{centerValue ? (
|
|
1492
|
-
<tspan
|
|
1493
|
-
x={viewBox.cx}
|
|
1494
|
-
y={viewBox.cy}
|
|
1495
|
-
className="fill-foreground text-2xl font-semibold"
|
|
1496
|
-
>
|
|
1497
|
-
{centerValue}
|
|
1498
|
-
</tspan>
|
|
1499
|
-
) : null}
|
|
1500
|
-
{centerLabel ? (
|
|
1501
|
-
<tspan
|
|
1502
|
-
x={viewBox.cx}
|
|
1503
|
-
y={Number(viewBox.cy) + 22}
|
|
1504
|
-
className="fill-muted-foreground text-xs"
|
|
1505
|
-
>
|
|
1506
|
-
{centerLabel}
|
|
1507
|
-
</tspan>
|
|
1508
|
-
) : null}
|
|
1509
|
-
</text>
|
|
1510
|
-
);
|
|
1511
|
-
}}
|
|
1512
|
-
/>
|
|
1513
|
-
) : null}
|
|
1514
|
-
</RechartsPrimitive.Pie>
|
|
1515
|
-
</RechartsPrimitive.PieChart>
|
|
1516
|
-
</ChartContainer>
|
|
1517
|
-
);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// ─── SparklineChart ───────────────────────────────────────────────────────────
|
|
1521
|
-
|
|
1522
|
-
export interface SparklineChartProps {
|
|
1523
|
-
/** Data array — each item must have the `dataKey` field */
|
|
1524
|
-
data: DashboardChartDatum[];
|
|
1525
|
-
/** Key to plot on the Y axis */
|
|
1526
|
-
dataKey: string;
|
|
1527
|
-
/** Color for the line/area — defaults to `var(--chart-1)` */
|
|
1528
|
-
color?: string;
|
|
1529
|
-
/** Show filled area under the line */
|
|
1530
|
-
filled?: boolean;
|
|
1531
|
-
/** Show the last data point as a dot */
|
|
1532
|
-
showEndDot?: boolean;
|
|
1533
|
-
/** Curve interpolation type */
|
|
1534
|
-
curveType?: ChartCurveType;
|
|
1535
|
-
/** Stroke width */
|
|
1536
|
-
strokeWidth?: number;
|
|
1537
|
-
className?: string;
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
/**
|
|
1541
|
-
* Minimal inline sparkline chart — no axes, no grid, no tooltip.
|
|
1542
|
-
* Ideal for embedding inside stat cards or table cells.
|
|
1543
|
-
*
|
|
1544
|
-
* @ai-rules
|
|
1545
|
-
* - Use `filled` for area-style sparklines.
|
|
1546
|
-
* - Keep `className` height small (e.g. `h-12` or `h-16`).
|
|
1547
|
-
* - Do NOT wrap in `ChartCard` — it is designed to be inline.
|
|
1548
|
-
*/
|
|
1549
|
-
function SparklineChart({
|
|
1550
|
-
data,
|
|
1551
|
-
dataKey,
|
|
1552
|
-
color = 'var(--chart-1)',
|
|
1553
|
-
filled = true,
|
|
1554
|
-
showEndDot = true,
|
|
1555
|
-
curveType = 'monotone',
|
|
1556
|
-
strokeWidth = 2,
|
|
1557
|
-
className,
|
|
1558
|
-
}: SparklineChartProps) {
|
|
1559
|
-
const sparkId = React.useId().replace(/:/g, '');
|
|
1560
|
-
const lastPoint = data[data.length - 1];
|
|
1561
|
-
const lastValue = lastPoint ? lastPoint[dataKey] : undefined;
|
|
1562
|
-
|
|
1563
|
-
return (
|
|
1564
|
-
<div className={cn('relative h-12 w-full min-w-0', className)}>
|
|
1565
|
-
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
|
1566
|
-
<RechartsPrimitive.AreaChart
|
|
1567
|
-
data={data}
|
|
1568
|
-
margin={{ top: 4, right: showEndDot ? 8 : 0, bottom: 0, left: 0 }}
|
|
1569
|
-
>
|
|
1570
|
-
{filled && (
|
|
1571
|
-
<defs>
|
|
1572
|
-
<linearGradient id={`spark-gradient-${sparkId}`} x1="0" y1="0" x2="0" y2="1">
|
|
1573
|
-
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
|
1574
|
-
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
|
1575
|
-
</linearGradient>
|
|
1576
|
-
</defs>
|
|
1577
|
-
)}
|
|
1578
|
-
<RechartsPrimitive.Area
|
|
1579
|
-
type={curveType}
|
|
1580
|
-
dataKey={dataKey}
|
|
1581
|
-
stroke={color}
|
|
1582
|
-
strokeWidth={strokeWidth}
|
|
1583
|
-
fill={filled ? `url(#spark-gradient-${sparkId})` : 'none'}
|
|
1584
|
-
fillOpacity={1}
|
|
1585
|
-
dot={false}
|
|
1586
|
-
activeDot={false}
|
|
1587
|
-
isAnimationActive
|
|
1588
|
-
animationDuration={600}
|
|
1589
|
-
animationEasing="ease-out"
|
|
1590
|
-
/>
|
|
1591
|
-
</RechartsPrimitive.AreaChart>
|
|
1592
|
-
</RechartsPrimitive.ResponsiveContainer>
|
|
1593
|
-
{showEndDot && lastValue !== undefined && lastValue !== null && (
|
|
1594
|
-
<div
|
|
1595
|
-
className="pointer-events-none absolute right-0 top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full ring-2 ring-background"
|
|
1596
|
-
style={{ backgroundColor: color }}
|
|
1597
|
-
/>
|
|
1598
|
-
)}
|
|
1599
|
-
</div>
|
|
1600
|
-
);
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
// ─── RadarChart ───────────────────────────────────────────────────────────────
|
|
1604
|
-
|
|
1605
|
-
export interface RadarMetricChartProps extends ChartStateProps {
|
|
1606
|
-
/** Data array — each item is one axis point on the radar */
|
|
1607
|
-
data: DashboardChartDatum[];
|
|
1608
|
-
/** Key in each datum used as the axis label */
|
|
1609
|
-
labelKey: string;
|
|
1610
|
-
/**
|
|
1611
|
-
* Series to render. Each entry maps to one `<Radar>` element.
|
|
1612
|
-
* Use a single entry for a simple radar, multiple for comparison.
|
|
1613
|
-
*/
|
|
1614
|
-
series: DashboardChartSeries[];
|
|
1615
|
-
/** Override colors per series key or as an ordered array */
|
|
1616
|
-
colors?: DashboardChartColors;
|
|
1617
|
-
/** Fill the radar polygon (default: true) */
|
|
1618
|
-
filled?: boolean;
|
|
1619
|
-
/** Fill opacity when `filled` is true (default: 0.25) */
|
|
1620
|
-
fillOpacity?: number;
|
|
1621
|
-
/** Show dots on each axis point (default: false) */
|
|
1622
|
-
showDots?: boolean;
|
|
1623
|
-
/** Show the polar grid lines (default: true) */
|
|
1624
|
-
showGrid?: boolean;
|
|
1625
|
-
/** Show the legend (default: true when multiple series) */
|
|
1626
|
-
showLegend?: boolean;
|
|
1627
|
-
/** Format axis tick values */
|
|
1628
|
-
valueFormatter?: ChartValueFormatter;
|
|
1629
|
-
className?: string;
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
function RadarMetricChart({
|
|
1633
|
-
data,
|
|
1634
|
-
labelKey,
|
|
1635
|
-
series,
|
|
1636
|
-
colors,
|
|
1637
|
-
filled = true,
|
|
1638
|
-
fillOpacity = 0.25,
|
|
1639
|
-
showDots = false,
|
|
1640
|
-
showGrid = true,
|
|
1641
|
-
showLegend,
|
|
1642
|
-
valueFormatter,
|
|
1643
|
-
isLoading,
|
|
1644
|
-
error,
|
|
1645
|
-
onRetry,
|
|
1646
|
-
retryLabel,
|
|
1647
|
-
emptyTitle,
|
|
1648
|
-
emptyDescription,
|
|
1649
|
-
errorTitle,
|
|
1650
|
-
errorDescription,
|
|
1651
|
-
loadingLabel,
|
|
1652
|
-
stateClassName,
|
|
1653
|
-
className,
|
|
1654
|
-
}: RadarMetricChartProps) {
|
|
1655
|
-
const chartConfig = React.useMemo(
|
|
1656
|
-
() =>
|
|
1657
|
-
buildChartConfig(
|
|
1658
|
-
series.map(s => s.key),
|
|
1659
|
-
colors
|
|
1660
|
-
),
|
|
1661
|
-
[series, colors]
|
|
1662
|
-
);
|
|
1663
|
-
|
|
1664
|
-
const hasData = data.length > 0 && series.length > 0;
|
|
1665
|
-
const chartState = getChartState(
|
|
1666
|
-
{
|
|
1667
|
-
isLoading,
|
|
1668
|
-
error,
|
|
1669
|
-
emptyTitle,
|
|
1670
|
-
emptyDescription,
|
|
1671
|
-
errorTitle,
|
|
1672
|
-
errorDescription,
|
|
1673
|
-
loadingLabel,
|
|
1674
|
-
stateClassName,
|
|
1675
|
-
onRetry,
|
|
1676
|
-
retryLabel,
|
|
1677
|
-
},
|
|
1678
|
-
hasData
|
|
1679
|
-
);
|
|
1680
|
-
|
|
1681
|
-
const displayLegend = showLegend ?? series.length > 1;
|
|
1682
|
-
|
|
1683
|
-
return (
|
|
1684
|
-
<ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
|
|
1685
|
-
{chartState ?? (
|
|
1686
|
-
<RechartsPrimitive.RadarChart data={data} accessibilityLayer>
|
|
1687
|
-
{showGrid && <RechartsPrimitive.PolarGrid stroke="var(--border)" strokeOpacity={0.6} />}
|
|
1688
|
-
<RechartsPrimitive.PolarAngleAxis
|
|
1689
|
-
dataKey={labelKey}
|
|
1690
|
-
tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
|
|
1691
|
-
/>
|
|
1692
|
-
<RechartsPrimitive.PolarRadiusAxis
|
|
1693
|
-
tick={false}
|
|
1694
|
-
axisLine={false}
|
|
1695
|
-
tickFormatter={valueFormatter}
|
|
1696
|
-
/>
|
|
1697
|
-
<ChartTooltip
|
|
1698
|
-
content={
|
|
1699
|
-
<ChartTooltipContent
|
|
1700
|
-
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1701
|
-
/>
|
|
1702
|
-
}
|
|
1703
|
-
/>
|
|
1704
|
-
{displayLegend && <ChartLegend content={<ChartLegendContent />} />}
|
|
1705
|
-
{series.map(s => (
|
|
1706
|
-
<RechartsPrimitive.Radar
|
|
1707
|
-
key={s.key}
|
|
1708
|
-
name={(s.label as string) ?? s.key}
|
|
1709
|
-
dataKey={s.key}
|
|
1710
|
-
stroke={`var(--color-${s.key})`}
|
|
1711
|
-
fill={filled ? `var(--color-${s.key})` : 'none'}
|
|
1712
|
-
fillOpacity={filled ? fillOpacity : 0}
|
|
1713
|
-
dot={showDots ? { r: 3, fill: `var(--color-${s.key})` } : false}
|
|
1714
|
-
isAnimationActive
|
|
1715
|
-
animationDuration={600}
|
|
1716
|
-
animationEasing="ease-out"
|
|
1717
|
-
/>
|
|
1718
|
-
))}
|
|
1719
|
-
</RechartsPrimitive.RadarChart>
|
|
1720
|
-
)}
|
|
1721
|
-
</ChartContainer>
|
|
1722
|
-
);
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
// ─── PieMetricChart ───────────────────────────────────────────────────────────
|
|
1726
|
-
|
|
1727
|
-
export interface PieMetricChartProps extends ChartStateProps {
|
|
1728
|
-
/** Data array — each item is one slice */
|
|
1729
|
-
data: DashboardChartDatum[];
|
|
1730
|
-
/** Key in each datum used as the slice name/label */
|
|
1731
|
-
nameKey: string;
|
|
1732
|
-
/** Key in each datum used as the slice value */
|
|
1733
|
-
valueKey: string;
|
|
1734
|
-
/** Override colors as an ordered array or per-name map */
|
|
1735
|
-
colors?: DashboardChartColors;
|
|
1736
|
-
/** Outer radius of the pie (default: "80%") */
|
|
1737
|
-
outerRadius?: number | string;
|
|
1738
|
-
/** Inner radius — set > 0 to make a donut (default: 0) */
|
|
1739
|
-
innerRadius?: number | string;
|
|
1740
|
-
/** Show percentage labels inside/outside each slice (default: false) */
|
|
1741
|
-
showLabels?: boolean;
|
|
1742
|
-
/** Show the legend (default: true) */
|
|
1743
|
-
showLegend?: boolean;
|
|
1744
|
-
/**
|
|
1745
|
-
* Index of the slice to "explode" (offset outward).
|
|
1746
|
-
* Pass -1 or undefined to disable.
|
|
1747
|
-
*/
|
|
1748
|
-
explodeIndex?: number;
|
|
1749
|
-
/** Offset distance for the exploded slice in px (default: 12) */
|
|
1750
|
-
explodeOffset?: number;
|
|
1751
|
-
/** Format tooltip values */
|
|
1752
|
-
valueFormatter?: ChartValueFormatter;
|
|
1753
|
-
className?: string;
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
function PieMetricChart({
|
|
1757
|
-
data,
|
|
1758
|
-
nameKey,
|
|
1759
|
-
valueKey,
|
|
1760
|
-
colors,
|
|
1761
|
-
outerRadius = '80%',
|
|
1762
|
-
innerRadius = 0,
|
|
1763
|
-
showLabels = false,
|
|
1764
|
-
showLegend = true,
|
|
1765
|
-
explodeIndex,
|
|
1766
|
-
explodeOffset = 12,
|
|
1767
|
-
valueFormatter,
|
|
1768
|
-
isLoading,
|
|
1769
|
-
error,
|
|
1770
|
-
onRetry,
|
|
1771
|
-
retryLabel,
|
|
1772
|
-
emptyTitle,
|
|
1773
|
-
emptyDescription,
|
|
1774
|
-
errorTitle,
|
|
1775
|
-
errorDescription,
|
|
1776
|
-
loadingLabel,
|
|
1777
|
-
stateClassName,
|
|
1778
|
-
className,
|
|
1779
|
-
}: PieMetricChartProps) {
|
|
1780
|
-
// Build config from unique name values
|
|
1781
|
-
const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
|
|
1782
|
-
|
|
1783
|
-
const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
|
|
1784
|
-
|
|
1785
|
-
const hasData = hasPieData(data, nameKey, valueKey);
|
|
1786
|
-
const chartState = getChartState(
|
|
1787
|
-
{
|
|
1788
|
-
isLoading,
|
|
1789
|
-
error,
|
|
1790
|
-
emptyTitle,
|
|
1791
|
-
emptyDescription,
|
|
1792
|
-
errorTitle,
|
|
1793
|
-
errorDescription,
|
|
1794
|
-
loadingLabel,
|
|
1795
|
-
stateClassName,
|
|
1796
|
-
onRetry,
|
|
1797
|
-
retryLabel,
|
|
1798
|
-
},
|
|
1799
|
-
hasData
|
|
1800
|
-
);
|
|
1801
|
-
|
|
1802
|
-
return (
|
|
1803
|
-
<ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
|
|
1804
|
-
{chartState ?? (
|
|
1805
|
-
<RechartsPrimitive.PieChart accessibilityLayer>
|
|
1806
|
-
<ChartTooltip
|
|
1807
|
-
content={
|
|
1808
|
-
<ChartTooltipContent
|
|
1809
|
-
nameKey={nameKey}
|
|
1810
|
-
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1811
|
-
/>
|
|
1812
|
-
}
|
|
1813
|
-
/>
|
|
1814
|
-
{showLegend && <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} />}
|
|
1815
|
-
<RechartsPrimitive.Pie
|
|
1816
|
-
data={data}
|
|
1817
|
-
dataKey={valueKey}
|
|
1818
|
-
nameKey={nameKey}
|
|
1819
|
-
outerRadius={outerRadius}
|
|
1820
|
-
innerRadius={innerRadius}
|
|
1821
|
-
paddingAngle={2}
|
|
1822
|
-
label={
|
|
1823
|
-
showLabels
|
|
1824
|
-
? ({ cx, cy, midAngle, innerRadius: ir, outerRadius: or, percent }) => {
|
|
1825
|
-
const RADIAN = Math.PI / 180;
|
|
1826
|
-
const radius = Number(ir) + (Number(or) - Number(ir)) * 1.35;
|
|
1827
|
-
const x = Number(cx) + radius * Math.cos(-midAngle * RADIAN);
|
|
1828
|
-
const y = Number(cy) + radius * Math.sin(-midAngle * RADIAN);
|
|
1829
|
-
return percent > 0.04 ? (
|
|
1830
|
-
<text
|
|
1831
|
-
x={x}
|
|
1832
|
-
y={y}
|
|
1833
|
-
fill="var(--foreground)"
|
|
1834
|
-
textAnchor={x > Number(cx) ? 'start' : 'end'}
|
|
1835
|
-
dominantBaseline="central"
|
|
1836
|
-
fontSize={12}
|
|
1837
|
-
fontWeight={500}
|
|
1838
|
-
>
|
|
1839
|
-
{`${(percent * 100).toFixed(0)}%`}
|
|
1840
|
-
</text>
|
|
1841
|
-
) : null;
|
|
1842
|
-
}
|
|
1843
|
-
: false
|
|
1844
|
-
}
|
|
1845
|
-
labelLine={false}
|
|
1846
|
-
isAnimationActive
|
|
1847
|
-
animationDuration={600}
|
|
1848
|
-
animationEasing="ease-out"
|
|
1849
|
-
>
|
|
1850
|
-
{data.map((entry, index) => {
|
|
1851
|
-
const name = String(entry[nameKey] ?? '');
|
|
1852
|
-
const isExploded = index === explodeIndex;
|
|
1853
|
-
return (
|
|
1854
|
-
<RechartsPrimitive.Cell
|
|
1855
|
-
key={`cell-${index}`}
|
|
1856
|
-
fill={chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`}
|
|
1857
|
-
stroke="var(--background)"
|
|
1858
|
-
strokeWidth={2}
|
|
1859
|
-
style={
|
|
1860
|
-
isExploded
|
|
1861
|
-
? {
|
|
1862
|
-
filter: `drop-shadow(0 4px 8px color-mix(in srgb, ${chartConfig[name]?.color ?? 'var(--chart-1)'} 40%, transparent))`,
|
|
1863
|
-
}
|
|
1864
|
-
: undefined
|
|
1865
|
-
}
|
|
1866
|
-
/>
|
|
1867
|
-
);
|
|
1868
|
-
})}
|
|
1869
|
-
</RechartsPrimitive.Pie>
|
|
1870
|
-
</RechartsPrimitive.PieChart>
|
|
1871
|
-
)}
|
|
1872
|
-
</ChartContainer>
|
|
1873
|
-
);
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
// ─── RadialBarMetricChart ─────────────────────────────────────────────────────
|
|
1877
|
-
|
|
1878
|
-
export interface RadialBarMetricChartProps extends ChartStateProps {
|
|
1879
|
-
/**
|
|
1880
|
-
* Data array. Each item should have a `name` field (for labels) and
|
|
1881
|
-
* the `dataKey` field (for values, 0–100 for percentage-based display).
|
|
1882
|
-
*/
|
|
1883
|
-
data: DashboardChartDatum[];
|
|
1884
|
-
/** Key in each datum used as the bar value (default: "value") */
|
|
1885
|
-
dataKey?: string;
|
|
1886
|
-
/** Key in each datum used as the bar label (default: "name") */
|
|
1887
|
-
nameKey?: string;
|
|
1888
|
-
/** Override colors as an ordered array or per-name map */
|
|
1889
|
-
colors?: DashboardChartColors;
|
|
1890
|
-
/** Inner radius of the radial bar (default: "30%") */
|
|
1891
|
-
innerRadius?: number | string;
|
|
1892
|
-
/** Outer radius of the radial bar (default: "100%") */
|
|
1893
|
-
outerRadius?: number | string;
|
|
1894
|
-
/** Start angle in degrees (default: 90 — top) */
|
|
1895
|
-
startAngle?: number;
|
|
1896
|
-
/** End angle in degrees (default: -270 — full circle) */
|
|
1897
|
-
endAngle?: number;
|
|
1898
|
-
/** Show background track behind each bar (default: true) */
|
|
1899
|
-
showBackground?: boolean;
|
|
1900
|
-
/** Show the legend (default: true) */
|
|
1901
|
-
showLegend?: boolean;
|
|
1902
|
-
/** Format tooltip values */
|
|
1903
|
-
valueFormatter?: ChartValueFormatter;
|
|
1904
|
-
className?: string;
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
function RadialBarMetricChart({
|
|
1908
|
-
data,
|
|
1909
|
-
dataKey = 'value',
|
|
1910
|
-
nameKey = 'name',
|
|
1911
|
-
colors,
|
|
1912
|
-
innerRadius = '30%',
|
|
1913
|
-
outerRadius = '100%',
|
|
1914
|
-
startAngle = 90,
|
|
1915
|
-
endAngle = -270,
|
|
1916
|
-
showBackground = true,
|
|
1917
|
-
showLegend = true,
|
|
1918
|
-
valueFormatter,
|
|
1919
|
-
isLoading,
|
|
1920
|
-
error,
|
|
1921
|
-
onRetry,
|
|
1922
|
-
retryLabel,
|
|
1923
|
-
emptyTitle,
|
|
1924
|
-
emptyDescription,
|
|
1925
|
-
errorTitle,
|
|
1926
|
-
errorDescription,
|
|
1927
|
-
loadingLabel,
|
|
1928
|
-
stateClassName,
|
|
1929
|
-
className,
|
|
1930
|
-
}: RadialBarMetricChartProps) {
|
|
1931
|
-
const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
|
|
1932
|
-
|
|
1933
|
-
const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
|
|
1934
|
-
|
|
1935
|
-
const hasData = data.length > 0;
|
|
1936
|
-
const chartState = getChartState(
|
|
1937
|
-
{
|
|
1938
|
-
isLoading,
|
|
1939
|
-
error,
|
|
1940
|
-
emptyTitle,
|
|
1941
|
-
emptyDescription,
|
|
1942
|
-
errorTitle,
|
|
1943
|
-
errorDescription,
|
|
1944
|
-
loadingLabel,
|
|
1945
|
-
stateClassName,
|
|
1946
|
-
onRetry,
|
|
1947
|
-
retryLabel,
|
|
1948
|
-
},
|
|
1949
|
-
hasData
|
|
1950
|
-
);
|
|
1951
|
-
|
|
1952
|
-
// Inject per-item fill color
|
|
1953
|
-
const coloredData = React.useMemo(
|
|
1954
|
-
() =>
|
|
1955
|
-
data.map((item, index) => {
|
|
1956
|
-
const name = String(item[nameKey] ?? '');
|
|
1957
|
-
return {
|
|
1958
|
-
...item,
|
|
1959
|
-
fill: chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`,
|
|
1960
|
-
};
|
|
1961
|
-
}),
|
|
1962
|
-
[data, nameKey, chartConfig]
|
|
1963
|
-
);
|
|
1964
|
-
|
|
1965
|
-
return (
|
|
1966
|
-
<div className={cn('flex w-full flex-col gap-3', className)}>
|
|
1967
|
-
<ChartContainer config={chartConfig} className="h-[260px] w-full">
|
|
1968
|
-
{chartState ?? (
|
|
1969
|
-
<RechartsPrimitive.RadialBarChart
|
|
1970
|
-
data={coloredData}
|
|
1971
|
-
innerRadius={innerRadius}
|
|
1972
|
-
outerRadius={outerRadius}
|
|
1973
|
-
startAngle={startAngle}
|
|
1974
|
-
endAngle={endAngle}
|
|
1975
|
-
accessibilityLayer
|
|
1976
|
-
>
|
|
1977
|
-
<ChartTooltip
|
|
1978
|
-
cursor={false}
|
|
1979
|
-
content={
|
|
1980
|
-
<ChartTooltipContent
|
|
1981
|
-
nameKey={nameKey}
|
|
1982
|
-
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1983
|
-
/>
|
|
1984
|
-
}
|
|
1985
|
-
/>
|
|
1986
|
-
<RechartsPrimitive.RadialBar
|
|
1987
|
-
dataKey={dataKey}
|
|
1988
|
-
background={showBackground ? { fill: 'var(--muted)' } : false}
|
|
1989
|
-
cornerRadius={6}
|
|
1990
|
-
isAnimationActive
|
|
1991
|
-
animationDuration={700}
|
|
1992
|
-
animationEasing="ease-out"
|
|
1993
|
-
/>
|
|
1994
|
-
</RechartsPrimitive.RadialBarChart>
|
|
1995
|
-
)}
|
|
1996
|
-
</ChartContainer>
|
|
1997
|
-
{showLegend && !chartState && (
|
|
1998
|
-
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1.5 px-2">
|
|
1999
|
-
{coloredData.map((item, index) => {
|
|
2000
|
-
const d = item as DashboardChartDatum & { fill: string };
|
|
2001
|
-
const name = String(d[nameKey] ?? '');
|
|
2002
|
-
const fillColor = d.fill ?? `var(--chart-${(index % 8) + 1})`;
|
|
2003
|
-
const val = d[dataKey];
|
|
2004
|
-
return (
|
|
2005
|
-
<div key={name} className="flex items-center gap-1.5">
|
|
2006
|
-
<span
|
|
2007
|
-
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
|
2008
|
-
style={{ backgroundColor: fillColor }}
|
|
2009
|
-
/>
|
|
2010
|
-
<span className="text-xs text-muted-foreground">
|
|
2011
|
-
{name}
|
|
2012
|
-
{val !== undefined && val !== null && (
|
|
2013
|
-
<span className="ml-1 font-medium text-foreground">
|
|
2014
|
-
{valueFormatter ? valueFormatter(val as number) : String(val)}
|
|
2015
|
-
</span>
|
|
2016
|
-
)}
|
|
2017
|
-
</span>
|
|
2018
|
-
</div>
|
|
2019
|
-
);
|
|
2020
|
-
})}
|
|
2021
|
-
</div>
|
|
2022
|
-
)}
|
|
2023
|
-
</div>
|
|
2024
|
-
);
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
// ─── GaugeChart ───────────────────────────────────────────────────────────────
|
|
2028
|
-
|
|
2029
|
-
export interface GaugeChartThreshold {
|
|
2030
|
-
/** Upper bound of this zone (0–100) */
|
|
2031
|
-
value: number;
|
|
2032
|
-
/** Color for this zone */
|
|
2033
|
-
color: string;
|
|
2034
|
-
/** Optional label for this zone */
|
|
2035
|
-
label?: string;
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
export interface GaugeChartProps {
|
|
2039
|
-
/** Current value (must be within [min, max]) */
|
|
2040
|
-
value: number;
|
|
2041
|
-
/** Minimum value (default: 0) */
|
|
2042
|
-
min?: number;
|
|
2043
|
-
/** Maximum value (default: 100) */
|
|
2044
|
-
max?: number;
|
|
2045
|
-
/**
|
|
2046
|
-
* Color zones. Each threshold defines the upper bound of a zone.
|
|
2047
|
-
* Zones are evaluated in order; the first zone whose `value >= current %`
|
|
2048
|
-
* determines the active color.
|
|
2049
|
-
* If omitted, uses `--chart-1`.
|
|
2050
|
-
*/
|
|
2051
|
-
thresholds?: GaugeChartThreshold[];
|
|
2052
|
-
/** Label shown below the value (e.g. "CPU Usage") */
|
|
2053
|
-
label?: React.ReactNode;
|
|
2054
|
-
/** Format the center value text (default: shows percentage) */
|
|
2055
|
-
valueFormatter?: (value: number, percent: number) => string;
|
|
2056
|
-
/** Show the needle indicator (default: true) */
|
|
2057
|
-
showNeedle?: boolean;
|
|
2058
|
-
className?: string;
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
function GaugeChart({
|
|
2062
|
-
value,
|
|
2063
|
-
min = 0,
|
|
2064
|
-
max = 100,
|
|
2065
|
-
thresholds,
|
|
2066
|
-
label,
|
|
2067
|
-
valueFormatter,
|
|
2068
|
-
showNeedle = true,
|
|
2069
|
-
className,
|
|
2070
|
-
}: GaugeChartProps) {
|
|
2071
|
-
const percent = Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
2072
|
-
const percentInt = Math.round(percent * 100);
|
|
2073
|
-
|
|
2074
|
-
// Determine active color from thresholds
|
|
2075
|
-
const activeColor = React.useMemo(() => {
|
|
2076
|
-
if (!thresholds || thresholds.length === 0) return 'var(--chart-1)';
|
|
2077
|
-
for (const t of thresholds) {
|
|
2078
|
-
if (percentInt <= t.value) return t.color;
|
|
2079
|
-
}
|
|
2080
|
-
return thresholds[thresholds.length - 1].color;
|
|
2081
|
-
}, [thresholds, percentInt]);
|
|
2082
|
-
|
|
2083
|
-
const displayText = valueFormatter ? valueFormatter(value, percentInt) : `${percentInt}%`;
|
|
2084
|
-
|
|
2085
|
-
// SVG gauge constants — viewBox is 200×110, arc center at (100, 100)
|
|
2086
|
-
const cx = 100;
|
|
2087
|
-
const cy = 100;
|
|
2088
|
-
const R = 80; // outer radius
|
|
2089
|
-
const r = 54; // inner radius (track width = 26)
|
|
2090
|
-
|
|
2091
|
-
// Helper: polar → cartesian (angle in degrees, 0° = right, CCW)
|
|
2092
|
-
const polar = (angleDeg: number, radius: number) => {
|
|
2093
|
-
const rad = (angleDeg * Math.PI) / 180;
|
|
2094
|
-
return {
|
|
2095
|
-
x: cx + radius * Math.cos(rad),
|
|
2096
|
-
y: cy - radius * Math.sin(rad),
|
|
2097
|
-
};
|
|
2098
|
-
};
|
|
2099
|
-
|
|
2100
|
-
// Semicircle: from 180° (left) to 0° (right)
|
|
2101
|
-
// Track background arc path
|
|
2102
|
-
const trackStart = polar(180, R);
|
|
2103
|
-
const trackEnd = polar(0, R);
|
|
2104
|
-
const trackStartI = polar(180, r);
|
|
2105
|
-
const trackEndI = polar(0, r);
|
|
2106
|
-
const trackPath = [
|
|
2107
|
-
`M ${trackStart.x} ${trackStart.y}`,
|
|
2108
|
-
`A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`,
|
|
2109
|
-
`L ${trackEndI.x} ${trackEndI.y}`,
|
|
2110
|
-
`A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
|
|
2111
|
-
'Z',
|
|
2112
|
-
].join(' ');
|
|
2113
|
-
|
|
2114
|
-
// Value arc: from 180° (left) clockwise to valueAngle
|
|
2115
|
-
// The gauge is a semicircle (max 180°), so largeArc is always 0.
|
|
2116
|
-
// largeArc=1 would sweep the reflex arc (>180°) which creates the filled-blob bug.
|
|
2117
|
-
const valueAngle = 180 - percent * 180;
|
|
2118
|
-
const valueEnd = polar(valueAngle, R);
|
|
2119
|
-
const valueEndI = polar(valueAngle, r);
|
|
2120
|
-
const valuePath =
|
|
2121
|
-
percent <= 0
|
|
2122
|
-
? ''
|
|
2123
|
-
: percent >= 1
|
|
2124
|
-
? trackPath // full arc = same as track
|
|
2125
|
-
: [
|
|
2126
|
-
`M ${trackStart.x} ${trackStart.y}`,
|
|
2127
|
-
`A ${R} ${R} 0 0 1 ${valueEnd.x} ${valueEnd.y}`,
|
|
2128
|
-
`L ${valueEndI.x} ${valueEndI.y}`,
|
|
2129
|
-
`A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
|
|
2130
|
-
'Z',
|
|
2131
|
-
].join(' ');
|
|
2132
|
-
|
|
2133
|
-
// Needle: rotates from -90° (left, 0%) to +90° (right, 100%)
|
|
2134
|
-
// In our coordinate system: 180° at 0%, 0° at 100%
|
|
2135
|
-
const needleAngle = 180 - percent * 180;
|
|
2136
|
-
const needleTip = polar(needleAngle, r - 6);
|
|
2137
|
-
const needleBase1 = polar(needleAngle + 90, 5);
|
|
2138
|
-
const needleBase2 = polar(needleAngle - 90, 5);
|
|
2139
|
-
|
|
2140
|
-
return (
|
|
2141
|
-
<div className={cn('flex flex-col items-center gap-2', className)}>
|
|
2142
|
-
<div className="relative w-full max-w-[260px]">
|
|
2143
|
-
<svg viewBox="0 0 200 130" className="w-full" aria-label={`Gauge: ${displayText}`}>
|
|
2144
|
-
{/* Background track */}
|
|
2145
|
-
<path d={trackPath} fill="var(--muted)" />
|
|
2146
|
-
|
|
2147
|
-
{/* Value arc */}
|
|
2148
|
-
{valuePath && <path d={valuePath} fill={activeColor} />}
|
|
2149
|
-
|
|
2150
|
-
{/* Needle */}
|
|
2151
|
-
{showNeedle && (
|
|
2152
|
-
<g>
|
|
2153
|
-
<polygon
|
|
2154
|
-
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
|
|
2155
|
-
fill="var(--foreground)"
|
|
2156
|
-
opacity={0.85}
|
|
2157
|
-
/>
|
|
2158
|
-
<circle cx={cx} cy={cy} r={5} fill="var(--foreground)" />
|
|
2159
|
-
</g>
|
|
2160
|
-
)}
|
|
2161
|
-
|
|
2162
|
-
{/* Value text — placed below the arc baseline (cy=100) to avoid needle overlap */}
|
|
2163
|
-
<text
|
|
2164
|
-
x={cx}
|
|
2165
|
-
y={cy + 18}
|
|
2166
|
-
textAnchor="middle"
|
|
2167
|
-
dominantBaseline="middle"
|
|
2168
|
-
fontSize={22}
|
|
2169
|
-
fontWeight={700}
|
|
2170
|
-
fill="currentColor"
|
|
2171
|
-
className="fill-foreground"
|
|
2172
|
-
>
|
|
2173
|
-
{displayText}
|
|
2174
|
-
</text>
|
|
2175
|
-
|
|
2176
|
-
{/* Label below value */}
|
|
2177
|
-
{label && (
|
|
2178
|
-
<text
|
|
2179
|
-
x={cx}
|
|
2180
|
-
y={cy + 36}
|
|
2181
|
-
textAnchor="middle"
|
|
2182
|
-
dominantBaseline="middle"
|
|
2183
|
-
fontSize={10}
|
|
2184
|
-
fill="currentColor"
|
|
2185
|
-
className="fill-muted-foreground"
|
|
2186
|
-
>
|
|
2187
|
-
{label}
|
|
2188
|
-
</text>
|
|
2189
|
-
)}
|
|
2190
|
-
</svg>
|
|
2191
|
-
</div>
|
|
2192
|
-
|
|
2193
|
-
{/* Threshold legend */}
|
|
2194
|
-
{thresholds && thresholds.length > 0 && (
|
|
2195
|
-
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
|
|
2196
|
-
{thresholds.map((t, i) => (
|
|
2197
|
-
<div key={i} className="flex items-center gap-1.5">
|
|
2198
|
-
<span
|
|
2199
|
-
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
2200
|
-
style={{ backgroundColor: t.color }}
|
|
2201
|
-
/>
|
|
2202
|
-
<span className="text-xs text-muted-foreground">{t.label}</span>
|
|
2203
|
-
</div>
|
|
2204
|
-
))}
|
|
2205
|
-
</div>
|
|
2206
|
-
)}
|
|
2207
|
-
</div>
|
|
2208
|
-
);
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
2212
|
-
|
|
2213
|
-
export {
|
|
2214
|
-
ChartContainer,
|
|
2215
|
-
ChartTooltip,
|
|
2216
|
-
ChartTooltipContent,
|
|
2217
|
-
ChartLegend,
|
|
2218
|
-
ChartLegendContent,
|
|
2219
|
-
ChartStyle,
|
|
2220
|
-
ChartCard,
|
|
2221
|
-
DashboardBarChart,
|
|
2222
|
-
DashboardLineChart,
|
|
2223
|
-
HorizontalBarChart,
|
|
2224
|
-
InteractiveTimeSeriesChart,
|
|
2225
|
-
ComboMetricChart,
|
|
2226
|
-
DonutBreakdownChart,
|
|
2227
|
-
SparklineChart,
|
|
2228
|
-
RadarMetricChart,
|
|
2229
|
-
PieMetricChart,
|
|
2230
|
-
RadialBarMetricChart,
|
|
2231
|
-
GaugeChart,
|
|
2232
|
-
};
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import * as RechartsPrimitive from 'recharts';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../shared/utils';
|
|
7
|
+
import { Alert, AlertDescription, AlertTitle } from '../alert';
|
|
8
|
+
import { Button } from '../button';
|
|
9
|
+
import {
|
|
10
|
+
Card,
|
|
11
|
+
CardAction,
|
|
12
|
+
CardContent,
|
|
13
|
+
CardDescription,
|
|
14
|
+
CardFooter,
|
|
15
|
+
CardHeader,
|
|
16
|
+
CardTitle,
|
|
17
|
+
} from '../card';
|
|
18
|
+
import { Empty, EmptyAction, EmptyDescription, EmptyIcon, EmptyTitle } from '../empty';
|
|
19
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
|
20
|
+
import { Skeleton } from '../skeleton';
|
|
21
|
+
import { BarChart3, RefreshCw, WifiOff } from 'lucide-react';
|
|
22
|
+
|
|
23
|
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
|
24
|
+
const THEMES = { light: '', dark: '.dark' } as const;
|
|
25
|
+
|
|
26
|
+
export type ChartConfig = {
|
|
27
|
+
[k in string]: {
|
|
28
|
+
label?: React.ReactNode;
|
|
29
|
+
icon?: React.ComponentType;
|
|
30
|
+
} & (
|
|
31
|
+
| { color?: string; theme?: never }
|
|
32
|
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ChartContextProps = {
|
|
37
|
+
config: ChartConfig;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type DashboardChartDatum = Record<string, string | number | null | undefined>;
|
|
41
|
+
|
|
42
|
+
export type DashboardChartSeries = {
|
|
43
|
+
key: string;
|
|
44
|
+
label?: React.ReactNode;
|
|
45
|
+
type?: 'bar' | 'line' | 'area';
|
|
46
|
+
stackId?: string;
|
|
47
|
+
yAxisId?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type DashboardChartColors = string[] | Record<string, string>;
|
|
51
|
+
|
|
52
|
+
export type ChartPeriod = {
|
|
53
|
+
value: string;
|
|
54
|
+
label: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ChartBarSize = 'sm' | 'md' | 'lg' | 'xl' | number;
|
|
58
|
+
|
|
59
|
+
/** Curve interpolation type for line and area charts */
|
|
60
|
+
export type ChartCurveType =
|
|
61
|
+
| 'monotone'
|
|
62
|
+
| 'linear'
|
|
63
|
+
| 'step'
|
|
64
|
+
| 'stepBefore'
|
|
65
|
+
| 'stepAfter'
|
|
66
|
+
| 'natural'
|
|
67
|
+
| 'basis';
|
|
68
|
+
|
|
69
|
+
type ChartValueFormatter = (value: number | string) => string;
|
|
70
|
+
|
|
71
|
+
export type ChartErrorState = boolean | string | Error | React.ReactNode;
|
|
72
|
+
|
|
73
|
+
export type ChartStateProps = {
|
|
74
|
+
isLoading?: boolean;
|
|
75
|
+
error?: ChartErrorState;
|
|
76
|
+
onRetry?: () => void;
|
|
77
|
+
retryLabel?: React.ReactNode;
|
|
78
|
+
emptyTitle?: React.ReactNode;
|
|
79
|
+
emptyDescription?: React.ReactNode;
|
|
80
|
+
errorTitle?: React.ReactNode;
|
|
81
|
+
errorDescription?: React.ReactNode;
|
|
82
|
+
loadingLabel?: React.ReactNode;
|
|
83
|
+
stateClassName?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const defaultPeriods: ChartPeriod[] = [
|
|
87
|
+
{ value: '7d', label: '7 days' },
|
|
88
|
+
{ value: '30d', label: '30 days' },
|
|
89
|
+
{ value: '90d', label: '90 days' },
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const defaultChartColors = [
|
|
93
|
+
'var(--chart-1)',
|
|
94
|
+
'var(--chart-2)',
|
|
95
|
+
'var(--chart-3)',
|
|
96
|
+
'var(--chart-4)',
|
|
97
|
+
'var(--chart-5)',
|
|
98
|
+
'var(--chart-6)',
|
|
99
|
+
'var(--chart-7)',
|
|
100
|
+
'var(--chart-8)',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const chartBarSizes: Record<Exclude<ChartBarSize, number>, number> = {
|
|
104
|
+
sm: 8,
|
|
105
|
+
md: 14,
|
|
106
|
+
lg: 22,
|
|
107
|
+
xl: 32,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
|
111
|
+
|
|
112
|
+
export function useChart() {
|
|
113
|
+
const context = React.useContext(ChartContext);
|
|
114
|
+
|
|
115
|
+
if (!context) {
|
|
116
|
+
throw new Error('useChart must be used within a <ChartContainer />');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return context;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Root container for Recharts-based charts with theme-aware color injection.
|
|
124
|
+
*
|
|
125
|
+
* @description
|
|
126
|
+
* Wraps Recharts' `ResponsiveContainer` and injects CSS custom properties
|
|
127
|
+
* (`--color-*`) from a `ChartConfig` object, enabling full dark-mode
|
|
128
|
+
* support without hard-coded hex values in chart elements.
|
|
129
|
+
*
|
|
130
|
+
* @ai-rules
|
|
131
|
+
* 1. NEVER pass hex colors directly to Recharts elements (e.g., `fill="#4F46E5"`).
|
|
132
|
+
* Always use `fill="var(--color-keyName)"` referencing the injected CSS variables.
|
|
133
|
+
* 2. This wrapper is REQUIRED to use `ChartTooltipContent` and `ChartLegendContent`.
|
|
134
|
+
* 3. Set height via `className="h-[300px]"` on `ChartContainer`, not on Recharts components.
|
|
135
|
+
* 4. Do NOT add another `<ResponsiveContainer>` — it is already included inside.
|
|
136
|
+
*/
|
|
137
|
+
function ChartContainer({
|
|
138
|
+
id,
|
|
139
|
+
className,
|
|
140
|
+
children,
|
|
141
|
+
config,
|
|
142
|
+
...props
|
|
143
|
+
}: React.ComponentProps<'div'> & {
|
|
144
|
+
config: ChartConfig;
|
|
145
|
+
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
|
146
|
+
}) {
|
|
147
|
+
const uniqueId = React.useId();
|
|
148
|
+
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<ChartContext.Provider value={{ config }}>
|
|
152
|
+
<div
|
|
153
|
+
data-slot="chart"
|
|
154
|
+
data-chart={chartId}
|
|
155
|
+
className={cn(
|
|
156
|
+
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border relative h-[300px] min-h-[200px] w-full min-w-0 overflow-hidden text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
|
157
|
+
className
|
|
158
|
+
)}
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
<ChartStyle id={chartId} config={config} />
|
|
162
|
+
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
|
163
|
+
{children}
|
|
164
|
+
</RechartsPrimitive.ResponsiveContainer>
|
|
165
|
+
</div>
|
|
166
|
+
</ChartContext.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
171
|
+
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
|
172
|
+
|
|
173
|
+
if (!colorConfig.length) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<style
|
|
179
|
+
dangerouslySetInnerHTML={{
|
|
180
|
+
__html: Object.entries(THEMES)
|
|
181
|
+
.map(
|
|
182
|
+
([theme, prefix]) => `
|
|
183
|
+
${prefix} [data-chart=${id}] {
|
|
184
|
+
${colorConfig
|
|
185
|
+
.map(([key, itemConfig]) => {
|
|
186
|
+
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
|
187
|
+
return color ? ` --color-${key}: ${color};` : null;
|
|
188
|
+
})
|
|
189
|
+
.join('\n')}
|
|
190
|
+
}
|
|
191
|
+
`
|
|
192
|
+
)
|
|
193
|
+
.join('\n'),
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const ChartTooltip = RechartsPrimitive.Tooltip;
|
|
200
|
+
|
|
201
|
+
function ChartTooltipContent({
|
|
202
|
+
active,
|
|
203
|
+
payload,
|
|
204
|
+
className,
|
|
205
|
+
indicator = 'dot',
|
|
206
|
+
hideLabel = false,
|
|
207
|
+
hideIndicator = false,
|
|
208
|
+
label,
|
|
209
|
+
labelFormatter,
|
|
210
|
+
labelClassName,
|
|
211
|
+
formatter,
|
|
212
|
+
color,
|
|
213
|
+
nameKey,
|
|
214
|
+
labelKey,
|
|
215
|
+
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
216
|
+
React.ComponentProps<'div'> & {
|
|
217
|
+
hideLabel?: boolean;
|
|
218
|
+
hideIndicator?: boolean;
|
|
219
|
+
indicator?: 'line' | 'dot' | 'dashed';
|
|
220
|
+
nameKey?: string;
|
|
221
|
+
labelKey?: string;
|
|
222
|
+
}) {
|
|
223
|
+
const { config } = useChart();
|
|
224
|
+
|
|
225
|
+
const tooltipLabel = React.useMemo(() => {
|
|
226
|
+
if (hideLabel || !payload?.length) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const [item] = payload;
|
|
231
|
+
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
|
|
232
|
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
233
|
+
const value =
|
|
234
|
+
!labelKey && typeof label === 'string'
|
|
235
|
+
? config[label as keyof typeof config]?.label || label
|
|
236
|
+
: itemConfig?.label;
|
|
237
|
+
|
|
238
|
+
if (labelFormatter) {
|
|
239
|
+
return (
|
|
240
|
+
<div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!value) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
|
249
|
+
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
|
250
|
+
|
|
251
|
+
if (!active || !payload?.length) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<div
|
|
259
|
+
className={cn(
|
|
260
|
+
'border-border/50 bg-background/95 backdrop-blur-sm grid min-w-[8rem] items-start gap-1.5 rounded-xl border px-3 py-2 text-xs shadow-xl',
|
|
261
|
+
className
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
{!nestLabel ? tooltipLabel : null}
|
|
265
|
+
<div className="grid gap-1.5">
|
|
266
|
+
{payload.map((item, index) => {
|
|
267
|
+
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
|
|
268
|
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
269
|
+
const indicatorColor = color || item.payload?.fill || item.color;
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
key={item.dataKey}
|
|
274
|
+
className={cn(
|
|
275
|
+
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
|
276
|
+
indicator === 'dot' && 'items-center'
|
|
277
|
+
)}
|
|
278
|
+
>
|
|
279
|
+
{formatter && item?.value !== undefined && item.name ? (
|
|
280
|
+
formatter(item.value, item.name, item, index, item.payload)
|
|
281
|
+
) : (
|
|
282
|
+
<>
|
|
283
|
+
{itemConfig?.icon ? (
|
|
284
|
+
<itemConfig.icon />
|
|
285
|
+
) : (
|
|
286
|
+
!hideIndicator && (
|
|
287
|
+
<div
|
|
288
|
+
className={cn(
|
|
289
|
+
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
|
290
|
+
{
|
|
291
|
+
'h-2.5 w-2.5': indicator === 'dot',
|
|
292
|
+
'w-1': indicator === 'line',
|
|
293
|
+
'w-0 border-[1.5px] border-dashed bg-transparent':
|
|
294
|
+
indicator === 'dashed',
|
|
295
|
+
'my-0.5': nestLabel && indicator === 'dashed',
|
|
296
|
+
}
|
|
297
|
+
)}
|
|
298
|
+
style={
|
|
299
|
+
{
|
|
300
|
+
'--color-bg': indicatorColor,
|
|
301
|
+
'--color-border': indicatorColor,
|
|
302
|
+
} as React.CSSProperties
|
|
303
|
+
}
|
|
304
|
+
/>
|
|
305
|
+
)
|
|
306
|
+
)}
|
|
307
|
+
<div
|
|
308
|
+
className={cn(
|
|
309
|
+
'flex flex-1 justify-between leading-none',
|
|
310
|
+
nestLabel ? 'items-end' : 'items-center'
|
|
311
|
+
)}
|
|
312
|
+
>
|
|
313
|
+
<div className="grid gap-1.5">
|
|
314
|
+
{nestLabel ? tooltipLabel : null}
|
|
315
|
+
<span className="text-muted-foreground">
|
|
316
|
+
{itemConfig?.label || item.name}
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
{item.value && (
|
|
320
|
+
<span className="text-foreground font-mono font-semibold tabular-nums">
|
|
321
|
+
{item.value.toLocaleString()}
|
|
322
|
+
</span>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
</>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
);
|
|
329
|
+
})}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const ChartLegend = RechartsPrimitive.Legend;
|
|
336
|
+
|
|
337
|
+
function ChartLegendContent({
|
|
338
|
+
className,
|
|
339
|
+
hideIcon = false,
|
|
340
|
+
payload,
|
|
341
|
+
verticalAlign = 'bottom',
|
|
342
|
+
nameKey,
|
|
343
|
+
}: React.ComponentProps<'div'> &
|
|
344
|
+
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
|
345
|
+
hideIcon?: boolean;
|
|
346
|
+
nameKey?: string;
|
|
347
|
+
}) {
|
|
348
|
+
const { config } = useChart();
|
|
349
|
+
|
|
350
|
+
if (!payload?.length) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div
|
|
356
|
+
className={cn(
|
|
357
|
+
'flex items-center justify-center gap-4',
|
|
358
|
+
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
|
359
|
+
className
|
|
360
|
+
)}
|
|
361
|
+
>
|
|
362
|
+
{payload.map(item => {
|
|
363
|
+
const key = `${nameKey ? item.value : item.dataKey || item.value || 'value'}`;
|
|
364
|
+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
key={item.value}
|
|
369
|
+
className={cn(
|
|
370
|
+
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
|
|
371
|
+
)}
|
|
372
|
+
>
|
|
373
|
+
{itemConfig?.icon && !hideIcon ? (
|
|
374
|
+
<itemConfig.icon />
|
|
375
|
+
) : (
|
|
376
|
+
<div
|
|
377
|
+
className="h-2 w-2 shrink-0 rounded-full bg-(--color-bg)"
|
|
378
|
+
style={
|
|
379
|
+
{
|
|
380
|
+
'--color-bg': item.color || `var(--color-${key})`,
|
|
381
|
+
} as React.CSSProperties
|
|
382
|
+
}
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
<span className="text-muted-foreground text-xs">{itemConfig?.label || item.value}</span>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
})}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Helper to extract item config from a payload.
|
|
394
|
+
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
|
395
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const payloadPayload =
|
|
400
|
+
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
|
401
|
+
? payload.payload
|
|
402
|
+
: undefined;
|
|
403
|
+
|
|
404
|
+
let configLabelKey: string = key;
|
|
405
|
+
|
|
406
|
+
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
|
407
|
+
configLabelKey = payload[key as keyof typeof payload] as string;
|
|
408
|
+
} else if (
|
|
409
|
+
payloadPayload &&
|
|
410
|
+
key in payloadPayload &&
|
|
411
|
+
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
|
412
|
+
) {
|
|
413
|
+
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getChartSeries(
|
|
420
|
+
config: ChartConfig,
|
|
421
|
+
series?: DashboardChartSeries[]
|
|
422
|
+
): DashboardChartSeries[] {
|
|
423
|
+
if (series?.length) {
|
|
424
|
+
return series;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return Object.entries(config).map(([key, item]) => ({
|
|
428
|
+
key,
|
|
429
|
+
label: item.label,
|
|
430
|
+
}));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function getSeriesColor(key: string, index: number, colors?: DashboardChartColors) {
|
|
434
|
+
if (Array.isArray(colors)) {
|
|
435
|
+
return colors[index] || defaultChartColors[index % defaultChartColors.length];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return colors?.[key] || defaultChartColors[index % defaultChartColors.length];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function getChartConfigWithColors(
|
|
442
|
+
config: ChartConfig,
|
|
443
|
+
keys: string[],
|
|
444
|
+
colors?: DashboardChartColors
|
|
445
|
+
): ChartConfig {
|
|
446
|
+
return keys.reduce<ChartConfig>((nextConfig, key, index) => {
|
|
447
|
+
const item = config[key];
|
|
448
|
+
|
|
449
|
+
if (item?.theme && !colors) {
|
|
450
|
+
nextConfig[key] = item;
|
|
451
|
+
return nextConfig;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
nextConfig[key] = {
|
|
455
|
+
label: item?.label || key,
|
|
456
|
+
icon: item?.icon,
|
|
457
|
+
color: colors
|
|
458
|
+
? getSeriesColor(key, index, colors)
|
|
459
|
+
: item?.color || getSeriesColor(key, index),
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return nextConfig;
|
|
463
|
+
}, {});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Build a ChartConfig from a list of keys (no pre-existing config needed). */
|
|
467
|
+
function buildChartConfig(keys: string[], colors?: DashboardChartColors): ChartConfig {
|
|
468
|
+
return keys.reduce<ChartConfig>((cfg, key, index) => {
|
|
469
|
+
cfg[key] = {
|
|
470
|
+
label: key,
|
|
471
|
+
color: getSeriesColor(key, index, colors),
|
|
472
|
+
};
|
|
473
|
+
return cfg;
|
|
474
|
+
}, {});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function formatTick(value: number | string) {
|
|
478
|
+
if (typeof value !== 'number') {
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return Intl.NumberFormat('en', {
|
|
483
|
+
notation: 'compact',
|
|
484
|
+
maximumFractionDigits: 1,
|
|
485
|
+
}).format(value);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function defaultFilterData(data: DashboardChartDatum[], period: string) {
|
|
489
|
+
const match = period.match(/^(\d+)/);
|
|
490
|
+
|
|
491
|
+
if (!match) {
|
|
492
|
+
return data;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const limit = Number(match[1]);
|
|
496
|
+
return data.slice(Math.max(data.length - limit, 0));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function getErrorDescription(error: ChartErrorState) {
|
|
500
|
+
if (typeof error === 'string') {
|
|
501
|
+
return error;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (error instanceof Error) {
|
|
505
|
+
return error.message;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getBarSize(barSize: ChartBarSize = 'md') {
|
|
512
|
+
return typeof barSize === 'number' ? barSize : chartBarSizes[barSize];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function hasChartData(data: DashboardChartDatum[], series: DashboardChartSeries[]) {
|
|
516
|
+
if (!data.length || !series.length) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return data.some(item =>
|
|
521
|
+
series.some(serie => {
|
|
522
|
+
const value = item[serie.key];
|
|
523
|
+
return value !== null && value !== undefined && value !== '';
|
|
524
|
+
})
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function hasPieData(data: DashboardChartDatum[], nameKey: string, valueKey: string) {
|
|
529
|
+
return data.some(item => {
|
|
530
|
+
const name = item[nameKey];
|
|
531
|
+
const value = item[valueKey];
|
|
532
|
+
return (
|
|
533
|
+
name !== null &&
|
|
534
|
+
name !== undefined &&
|
|
535
|
+
name !== '' &&
|
|
536
|
+
value !== null &&
|
|
537
|
+
value !== undefined &&
|
|
538
|
+
value !== ''
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function ChartState({
|
|
544
|
+
type,
|
|
545
|
+
className,
|
|
546
|
+
error,
|
|
547
|
+
onRetry,
|
|
548
|
+
retryLabel = 'Try again',
|
|
549
|
+
emptyTitle = 'No data available',
|
|
550
|
+
emptyDescription = 'There is no data available for this chart yet.',
|
|
551
|
+
errorTitle = 'Connection error',
|
|
552
|
+
errorDescription,
|
|
553
|
+
loadingLabel = 'Loading chart data',
|
|
554
|
+
}: ChartStateProps & {
|
|
555
|
+
type: 'empty' | 'error' | 'loading';
|
|
556
|
+
className?: string;
|
|
557
|
+
}) {
|
|
558
|
+
if (type === 'loading') {
|
|
559
|
+
return (
|
|
560
|
+
<div
|
|
561
|
+
className={cn(
|
|
562
|
+
'flex min-h-[240px] flex-col justify-end gap-3 rounded-[var(--radius-card)] border border-border p-6',
|
|
563
|
+
className
|
|
564
|
+
)}
|
|
565
|
+
aria-label={typeof loadingLabel === 'string' ? loadingLabel : undefined}
|
|
566
|
+
>
|
|
567
|
+
<Skeleton className="h-8 w-2/5" />
|
|
568
|
+
<Skeleton className="h-14 w-3/5" />
|
|
569
|
+
<Skeleton className="h-24 w-4/5" />
|
|
570
|
+
<Skeleton className="h-36 w-full" />
|
|
571
|
+
</div>
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (type === 'error') {
|
|
576
|
+
return (
|
|
577
|
+
<div
|
|
578
|
+
className={cn(
|
|
579
|
+
'flex min-h-[240px] items-center justify-center rounded-[var(--radius-card)] border border-border p-6',
|
|
580
|
+
className
|
|
581
|
+
)}
|
|
582
|
+
>
|
|
583
|
+
<Alert variant="destructive" className="max-w-xl">
|
|
584
|
+
<AlertTitle>{errorTitle}</AlertTitle>
|
|
585
|
+
<AlertDescription>
|
|
586
|
+
{errorDescription ||
|
|
587
|
+
getErrorDescription(error) ||
|
|
588
|
+
'Unable to load chart data. Check your connection and try again.'}
|
|
589
|
+
</AlertDescription>
|
|
590
|
+
{onRetry ? (
|
|
591
|
+
<div className="mt-3">
|
|
592
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
593
|
+
<RefreshCw className="size-4" />
|
|
594
|
+
{retryLabel}
|
|
595
|
+
</Button>
|
|
596
|
+
</div>
|
|
597
|
+
) : null}
|
|
598
|
+
</Alert>
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<Empty className={cn('min-h-[240px]', className)}>
|
|
605
|
+
<EmptyIcon>
|
|
606
|
+
<BarChart3 className="size-10 text-muted-foreground" />
|
|
607
|
+
</EmptyIcon>
|
|
608
|
+
<EmptyTitle>{emptyTitle}</EmptyTitle>
|
|
609
|
+
<EmptyDescription>{emptyDescription}</EmptyDescription>
|
|
610
|
+
{onRetry ? (
|
|
611
|
+
<EmptyAction>
|
|
612
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
613
|
+
<WifiOff className="size-4" />
|
|
614
|
+
{retryLabel}
|
|
615
|
+
</Button>
|
|
616
|
+
</EmptyAction>
|
|
617
|
+
) : null}
|
|
618
|
+
</Empty>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function getChartState(state: ChartStateProps, hasData: boolean, className?: string) {
|
|
623
|
+
if (state.isLoading) {
|
|
624
|
+
return <ChartState {...state} type="loading" className={className} />;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (state.error) {
|
|
628
|
+
return <ChartState {...state} type="error" className={className} />;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!hasData) {
|
|
632
|
+
return <ChartState {...state} type="empty" className={className} />;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export interface ChartCardProps extends Omit<React.ComponentProps<typeof Card>, 'title'> {
|
|
639
|
+
title: React.ReactNode;
|
|
640
|
+
description?: React.ReactNode;
|
|
641
|
+
action?: React.ReactNode;
|
|
642
|
+
footer?: React.ReactNode;
|
|
643
|
+
contentClassName?: string;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function ChartCard({
|
|
647
|
+
title,
|
|
648
|
+
description,
|
|
649
|
+
action,
|
|
650
|
+
footer,
|
|
651
|
+
children,
|
|
652
|
+
className,
|
|
653
|
+
contentClassName,
|
|
654
|
+
...props
|
|
655
|
+
}: ChartCardProps) {
|
|
656
|
+
return (
|
|
657
|
+
<Card className={cn('w-full min-w-0 overflow-hidden', className)} {...props}>
|
|
658
|
+
<CardHeader>
|
|
659
|
+
<CardTitle>{title}</CardTitle>
|
|
660
|
+
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
661
|
+
{action ? <CardAction>{action}</CardAction> : null}
|
|
662
|
+
</CardHeader>
|
|
663
|
+
<CardContent className={contentClassName}>{children}</CardContent>
|
|
664
|
+
{footer ? <CardFooter>{footer}</CardFooter> : null}
|
|
665
|
+
</Card>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ─── Gradient defs helper ────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Renders SVG `<defs>` with linear gradients for each series key.
|
|
673
|
+
* Used internally by area charts when `gradientFill` is enabled.
|
|
674
|
+
*/
|
|
675
|
+
function AreaGradientDefs({
|
|
676
|
+
seriesKeys,
|
|
677
|
+
opacity = 0.3,
|
|
678
|
+
}: {
|
|
679
|
+
seriesKeys: string[];
|
|
680
|
+
opacity?: number;
|
|
681
|
+
}) {
|
|
682
|
+
return (
|
|
683
|
+
<defs>
|
|
684
|
+
{seriesKeys.map(key => (
|
|
685
|
+
<linearGradient key={key} id={`gradient-${key}`} x1="0" y1="0" x2="0" y2="1">
|
|
686
|
+
<stop offset="5%" stopColor={`var(--color-${key})`} stopOpacity={opacity} />
|
|
687
|
+
<stop offset="95%" stopColor={`var(--color-${key})`} stopOpacity={0} />
|
|
688
|
+
</linearGradient>
|
|
689
|
+
))}
|
|
690
|
+
</defs>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ─── DashboardBarChart ────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
export interface DashboardBarChartProps
|
|
697
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
698
|
+
data: DashboardChartDatum[];
|
|
699
|
+
indexKey?: string;
|
|
700
|
+
series?: DashboardChartSeries[];
|
|
701
|
+
colors?: DashboardChartColors;
|
|
702
|
+
barSize?: ChartBarSize;
|
|
703
|
+
stacked?: boolean;
|
|
704
|
+
showGrid?: boolean;
|
|
705
|
+
showLegend?: boolean;
|
|
706
|
+
valueFormatter?: ChartValueFormatter;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function DashboardBarChart({
|
|
710
|
+
data,
|
|
711
|
+
config,
|
|
712
|
+
indexKey = 'name',
|
|
713
|
+
series,
|
|
714
|
+
colors,
|
|
715
|
+
barSize = 'md',
|
|
716
|
+
stacked = false,
|
|
717
|
+
showGrid = true,
|
|
718
|
+
showLegend = true,
|
|
719
|
+
valueFormatter = formatTick,
|
|
720
|
+
isLoading,
|
|
721
|
+
error,
|
|
722
|
+
onRetry,
|
|
723
|
+
retryLabel,
|
|
724
|
+
emptyTitle,
|
|
725
|
+
emptyDescription,
|
|
726
|
+
errorTitle,
|
|
727
|
+
errorDescription,
|
|
728
|
+
loadingLabel,
|
|
729
|
+
stateClassName,
|
|
730
|
+
className,
|
|
731
|
+
...props
|
|
732
|
+
}: DashboardBarChartProps) {
|
|
733
|
+
const chartSeries = getChartSeries(config, series);
|
|
734
|
+
const chartConfig = getChartConfigWithColors(
|
|
735
|
+
config,
|
|
736
|
+
chartSeries.map(item => item.key),
|
|
737
|
+
colors
|
|
738
|
+
);
|
|
739
|
+
const chartState = getChartState(
|
|
740
|
+
{
|
|
741
|
+
isLoading,
|
|
742
|
+
error,
|
|
743
|
+
onRetry,
|
|
744
|
+
retryLabel,
|
|
745
|
+
emptyTitle,
|
|
746
|
+
emptyDescription,
|
|
747
|
+
errorTitle,
|
|
748
|
+
errorDescription,
|
|
749
|
+
loadingLabel,
|
|
750
|
+
},
|
|
751
|
+
hasChartData(data, chartSeries),
|
|
752
|
+
cn('h-[320px] w-full', stateClassName)
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
const barElements = React.useMemo(() => {
|
|
756
|
+
const topOfStack = new Set<string>();
|
|
757
|
+
if (stacked) {
|
|
758
|
+
const lastByStack = new Map<string, string>();
|
|
759
|
+
chartSeries.forEach(s => lastByStack.set(s.stackId || 'total', s.key));
|
|
760
|
+
lastByStack.forEach(key => topOfStack.add(key));
|
|
761
|
+
}
|
|
762
|
+
return chartSeries.map(item => {
|
|
763
|
+
const isTop = !stacked || topOfStack.has(item.key);
|
|
764
|
+
return (
|
|
765
|
+
<RechartsPrimitive.Bar
|
|
766
|
+
key={item.key}
|
|
767
|
+
dataKey={item.key}
|
|
768
|
+
fill={`var(--color-${item.key})`}
|
|
769
|
+
radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]}
|
|
770
|
+
barSize={getBarSize(barSize)}
|
|
771
|
+
stackId={stacked ? item.stackId || 'total' : item.stackId}
|
|
772
|
+
isAnimationActive
|
|
773
|
+
animationDuration={600}
|
|
774
|
+
animationEasing="ease-out"
|
|
775
|
+
/>
|
|
776
|
+
);
|
|
777
|
+
});
|
|
778
|
+
}, [stacked, chartSeries]);
|
|
779
|
+
|
|
780
|
+
if (chartState) {
|
|
781
|
+
return chartState;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return (
|
|
785
|
+
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
786
|
+
<RechartsPrimitive.BarChart data={data} accessibilityLayer barGap={4}>
|
|
787
|
+
{showGrid ? (
|
|
788
|
+
<RechartsPrimitive.CartesianGrid
|
|
789
|
+
vertical={false}
|
|
790
|
+
strokeDasharray="3 3"
|
|
791
|
+
stroke="var(--border)"
|
|
792
|
+
strokeOpacity={0.5}
|
|
793
|
+
/>
|
|
794
|
+
) : null}
|
|
795
|
+
<RechartsPrimitive.XAxis
|
|
796
|
+
dataKey={indexKey}
|
|
797
|
+
tickLine={false}
|
|
798
|
+
axisLine={false}
|
|
799
|
+
tickMargin={8}
|
|
800
|
+
/>
|
|
801
|
+
<RechartsPrimitive.YAxis
|
|
802
|
+
tickLine={false}
|
|
803
|
+
axisLine={false}
|
|
804
|
+
tickMargin={8}
|
|
805
|
+
tickFormatter={valueFormatter}
|
|
806
|
+
/>
|
|
807
|
+
<ChartTooltip
|
|
808
|
+
cursor={{ fill: 'var(--muted)', opacity: 0.4, radius: 4 }}
|
|
809
|
+
content={<ChartTooltipContent />}
|
|
810
|
+
/>
|
|
811
|
+
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
812
|
+
{barElements}
|
|
813
|
+
</RechartsPrimitive.BarChart>
|
|
814
|
+
</ChartContainer>
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ─── DashboardLineChart ───────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
export interface DashboardLineChartProps
|
|
821
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
822
|
+
data: DashboardChartDatum[];
|
|
823
|
+
indexKey?: string;
|
|
824
|
+
series?: DashboardChartSeries[];
|
|
825
|
+
colors?: DashboardChartColors;
|
|
826
|
+
showDots?: boolean;
|
|
827
|
+
showGrid?: boolean;
|
|
828
|
+
showLegend?: boolean;
|
|
829
|
+
curveType?: ChartCurveType;
|
|
830
|
+
strokeWidth?: number;
|
|
831
|
+
valueFormatter?: ChartValueFormatter;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function DashboardLineChart({
|
|
835
|
+
data,
|
|
836
|
+
config,
|
|
837
|
+
indexKey = 'name',
|
|
838
|
+
series,
|
|
839
|
+
colors,
|
|
840
|
+
showDots = false,
|
|
841
|
+
showGrid = true,
|
|
842
|
+
showLegend = true,
|
|
843
|
+
curveType = 'monotone',
|
|
844
|
+
strokeWidth = 2,
|
|
845
|
+
valueFormatter = formatTick,
|
|
846
|
+
isLoading,
|
|
847
|
+
error,
|
|
848
|
+
onRetry,
|
|
849
|
+
retryLabel,
|
|
850
|
+
emptyTitle,
|
|
851
|
+
emptyDescription,
|
|
852
|
+
errorTitle,
|
|
853
|
+
errorDescription,
|
|
854
|
+
loadingLabel,
|
|
855
|
+
stateClassName,
|
|
856
|
+
className,
|
|
857
|
+
...props
|
|
858
|
+
}: DashboardLineChartProps) {
|
|
859
|
+
const chartSeries = getChartSeries(config, series);
|
|
860
|
+
const chartConfig = getChartConfigWithColors(
|
|
861
|
+
config,
|
|
862
|
+
chartSeries.map(item => item.key),
|
|
863
|
+
colors
|
|
864
|
+
);
|
|
865
|
+
const chartState = getChartState(
|
|
866
|
+
{
|
|
867
|
+
isLoading,
|
|
868
|
+
error,
|
|
869
|
+
onRetry,
|
|
870
|
+
retryLabel,
|
|
871
|
+
emptyTitle,
|
|
872
|
+
emptyDescription,
|
|
873
|
+
errorTitle,
|
|
874
|
+
errorDescription,
|
|
875
|
+
loadingLabel,
|
|
876
|
+
},
|
|
877
|
+
hasChartData(data, chartSeries),
|
|
878
|
+
cn('h-[320px] w-full', stateClassName)
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
if (chartState) {
|
|
882
|
+
return chartState;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return (
|
|
886
|
+
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
887
|
+
<RechartsPrimitive.LineChart data={data} accessibilityLayer>
|
|
888
|
+
{showGrid ? (
|
|
889
|
+
<RechartsPrimitive.CartesianGrid
|
|
890
|
+
vertical={false}
|
|
891
|
+
strokeDasharray="3 3"
|
|
892
|
+
stroke="var(--border)"
|
|
893
|
+
strokeOpacity={0.5}
|
|
894
|
+
/>
|
|
895
|
+
) : null}
|
|
896
|
+
<RechartsPrimitive.XAxis
|
|
897
|
+
dataKey={indexKey}
|
|
898
|
+
tickLine={false}
|
|
899
|
+
axisLine={false}
|
|
900
|
+
tickMargin={8}
|
|
901
|
+
/>
|
|
902
|
+
<RechartsPrimitive.YAxis
|
|
903
|
+
tickLine={false}
|
|
904
|
+
axisLine={false}
|
|
905
|
+
tickMargin={8}
|
|
906
|
+
tickFormatter={valueFormatter}
|
|
907
|
+
/>
|
|
908
|
+
<ChartTooltip content={<ChartTooltipContent indicator="line" />} />
|
|
909
|
+
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
910
|
+
{chartSeries.map(item => (
|
|
911
|
+
<RechartsPrimitive.Line
|
|
912
|
+
key={item.key}
|
|
913
|
+
type={curveType}
|
|
914
|
+
dataKey={item.key}
|
|
915
|
+
stroke={`var(--color-${item.key})`}
|
|
916
|
+
strokeWidth={strokeWidth}
|
|
917
|
+
dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
|
|
918
|
+
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
919
|
+
yAxisId={item.yAxisId}
|
|
920
|
+
isAnimationActive
|
|
921
|
+
animationDuration={600}
|
|
922
|
+
animationEasing="ease-out"
|
|
923
|
+
/>
|
|
924
|
+
))}
|
|
925
|
+
</RechartsPrimitive.LineChart>
|
|
926
|
+
</ChartContainer>
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ─── HorizontalBarChart ───────────────────────────────────────────────────────
|
|
931
|
+
|
|
932
|
+
export interface HorizontalBarChartProps
|
|
933
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
934
|
+
data: DashboardChartDatum[];
|
|
935
|
+
indexKey?: string;
|
|
936
|
+
series?: DashboardChartSeries[];
|
|
937
|
+
colors?: DashboardChartColors;
|
|
938
|
+
barSize?: ChartBarSize;
|
|
939
|
+
stacked?: boolean;
|
|
940
|
+
categoryWidth?: number;
|
|
941
|
+
showGrid?: boolean;
|
|
942
|
+
showLegend?: boolean;
|
|
943
|
+
valueFormatter?: ChartValueFormatter;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function HorizontalBarChart({
|
|
947
|
+
data,
|
|
948
|
+
config,
|
|
949
|
+
indexKey = 'name',
|
|
950
|
+
series,
|
|
951
|
+
colors,
|
|
952
|
+
barSize = 'md',
|
|
953
|
+
stacked = false,
|
|
954
|
+
categoryWidth = 96,
|
|
955
|
+
showGrid = true,
|
|
956
|
+
showLegend = true,
|
|
957
|
+
valueFormatter = formatTick,
|
|
958
|
+
isLoading,
|
|
959
|
+
error,
|
|
960
|
+
onRetry,
|
|
961
|
+
retryLabel,
|
|
962
|
+
emptyTitle,
|
|
963
|
+
emptyDescription,
|
|
964
|
+
errorTitle,
|
|
965
|
+
errorDescription,
|
|
966
|
+
loadingLabel,
|
|
967
|
+
stateClassName,
|
|
968
|
+
className,
|
|
969
|
+
...props
|
|
970
|
+
}: HorizontalBarChartProps) {
|
|
971
|
+
const chartSeries = getChartSeries(config, series);
|
|
972
|
+
const chartConfig = getChartConfigWithColors(
|
|
973
|
+
config,
|
|
974
|
+
chartSeries.map(item => item.key),
|
|
975
|
+
colors
|
|
976
|
+
);
|
|
977
|
+
const chartState = getChartState(
|
|
978
|
+
{
|
|
979
|
+
isLoading,
|
|
980
|
+
error,
|
|
981
|
+
onRetry,
|
|
982
|
+
retryLabel,
|
|
983
|
+
emptyTitle,
|
|
984
|
+
emptyDescription,
|
|
985
|
+
errorTitle,
|
|
986
|
+
errorDescription,
|
|
987
|
+
loadingLabel,
|
|
988
|
+
},
|
|
989
|
+
hasChartData(data, chartSeries),
|
|
990
|
+
cn('h-[320px] w-full', stateClassName)
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
if (chartState) {
|
|
994
|
+
return chartState;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return (
|
|
998
|
+
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
999
|
+
<RechartsPrimitive.BarChart
|
|
1000
|
+
data={data}
|
|
1001
|
+
layout="vertical"
|
|
1002
|
+
accessibilityLayer
|
|
1003
|
+
margin={{ left: 8, right: 16 }}
|
|
1004
|
+
>
|
|
1005
|
+
{showGrid ? (
|
|
1006
|
+
<RechartsPrimitive.CartesianGrid
|
|
1007
|
+
horizontal={false}
|
|
1008
|
+
strokeDasharray="3 3"
|
|
1009
|
+
stroke="var(--border)"
|
|
1010
|
+
strokeOpacity={0.5}
|
|
1011
|
+
/>
|
|
1012
|
+
) : null}
|
|
1013
|
+
<RechartsPrimitive.XAxis
|
|
1014
|
+
type="number"
|
|
1015
|
+
tickLine={false}
|
|
1016
|
+
axisLine={false}
|
|
1017
|
+
tickMargin={8}
|
|
1018
|
+
tickFormatter={valueFormatter}
|
|
1019
|
+
/>
|
|
1020
|
+
<RechartsPrimitive.YAxis
|
|
1021
|
+
dataKey={indexKey}
|
|
1022
|
+
type="category"
|
|
1023
|
+
tickLine={false}
|
|
1024
|
+
axisLine={false}
|
|
1025
|
+
tickMargin={8}
|
|
1026
|
+
width={categoryWidth}
|
|
1027
|
+
/>
|
|
1028
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1029
|
+
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1030
|
+
{chartSeries.map(item => (
|
|
1031
|
+
<RechartsPrimitive.Bar
|
|
1032
|
+
key={item.key}
|
|
1033
|
+
dataKey={item.key}
|
|
1034
|
+
fill={`var(--color-${item.key})`}
|
|
1035
|
+
radius={[0, 4, 4, 0]}
|
|
1036
|
+
barSize={getBarSize(barSize)}
|
|
1037
|
+
stackId={stacked ? item.stackId || 'total' : item.stackId}
|
|
1038
|
+
isAnimationActive
|
|
1039
|
+
animationDuration={600}
|
|
1040
|
+
animationEasing="ease-out"
|
|
1041
|
+
/>
|
|
1042
|
+
))}
|
|
1043
|
+
</RechartsPrimitive.BarChart>
|
|
1044
|
+
</ChartContainer>
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ─── InteractiveTimeSeriesChart ───────────────────────────────────────────────
|
|
1049
|
+
|
|
1050
|
+
export interface InteractiveTimeSeriesChartProps
|
|
1051
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1052
|
+
data: DashboardChartDatum[];
|
|
1053
|
+
indexKey?: string;
|
|
1054
|
+
series?: DashboardChartSeries[];
|
|
1055
|
+
colors?: DashboardChartColors;
|
|
1056
|
+
periods?: ChartPeriod[];
|
|
1057
|
+
defaultPeriod?: string;
|
|
1058
|
+
defaultSeries?: string;
|
|
1059
|
+
filterData?: (data: DashboardChartDatum[], period: string) => DashboardChartDatum[];
|
|
1060
|
+
showGrid?: boolean;
|
|
1061
|
+
showLegend?: boolean;
|
|
1062
|
+
showDots?: boolean;
|
|
1063
|
+
gradientFill?: boolean;
|
|
1064
|
+
curveType?: ChartCurveType;
|
|
1065
|
+
strokeWidth?: number;
|
|
1066
|
+
valueFormatter?: ChartValueFormatter;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function InteractiveTimeSeriesChart({
|
|
1070
|
+
data,
|
|
1071
|
+
config,
|
|
1072
|
+
indexKey = 'date',
|
|
1073
|
+
series,
|
|
1074
|
+
colors,
|
|
1075
|
+
periods = defaultPeriods,
|
|
1076
|
+
defaultPeriod = periods[1]?.value || periods[0]?.value || '30d',
|
|
1077
|
+
defaultSeries,
|
|
1078
|
+
filterData = defaultFilterData,
|
|
1079
|
+
showGrid = true,
|
|
1080
|
+
showLegend = false,
|
|
1081
|
+
showDots = false,
|
|
1082
|
+
gradientFill = true,
|
|
1083
|
+
curveType = 'monotone',
|
|
1084
|
+
strokeWidth = 2,
|
|
1085
|
+
valueFormatter = formatTick,
|
|
1086
|
+
isLoading,
|
|
1087
|
+
error,
|
|
1088
|
+
onRetry,
|
|
1089
|
+
retryLabel,
|
|
1090
|
+
emptyTitle,
|
|
1091
|
+
emptyDescription,
|
|
1092
|
+
errorTitle,
|
|
1093
|
+
errorDescription,
|
|
1094
|
+
loadingLabel,
|
|
1095
|
+
stateClassName,
|
|
1096
|
+
className,
|
|
1097
|
+
...props
|
|
1098
|
+
}: InteractiveTimeSeriesChartProps) {
|
|
1099
|
+
const chartSeries = getChartSeries(config, series);
|
|
1100
|
+
const chartConfig = getChartConfigWithColors(
|
|
1101
|
+
config,
|
|
1102
|
+
chartSeries.map(item => item.key),
|
|
1103
|
+
colors
|
|
1104
|
+
);
|
|
1105
|
+
const [period, setPeriod] = React.useState(defaultPeriod);
|
|
1106
|
+
const [activeSeries, setActiveSeries] = React.useState(
|
|
1107
|
+
defaultSeries || chartSeries[0]?.key || ''
|
|
1108
|
+
);
|
|
1109
|
+
const filteredData = React.useMemo(() => filterData(data, period), [data, filterData, period]);
|
|
1110
|
+
const visibleSeries =
|
|
1111
|
+
chartSeries.length > 1 ? chartSeries.filter(item => item.key === activeSeries) : chartSeries;
|
|
1112
|
+
const chartState = getChartState(
|
|
1113
|
+
{
|
|
1114
|
+
isLoading,
|
|
1115
|
+
error,
|
|
1116
|
+
onRetry,
|
|
1117
|
+
retryLabel,
|
|
1118
|
+
emptyTitle,
|
|
1119
|
+
emptyDescription,
|
|
1120
|
+
errorTitle,
|
|
1121
|
+
errorDescription,
|
|
1122
|
+
loadingLabel,
|
|
1123
|
+
},
|
|
1124
|
+
hasChartData(filteredData, visibleSeries),
|
|
1125
|
+
cn('h-[320px] w-full', stateClassName)
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
return (
|
|
1129
|
+
<div className="w-full space-y-4">
|
|
1130
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
1131
|
+
{chartSeries.length > 1 ? (
|
|
1132
|
+
<div
|
|
1133
|
+
className="inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground"
|
|
1134
|
+
role="group"
|
|
1135
|
+
aria-label="Selecionar metrica do grafico"
|
|
1136
|
+
>
|
|
1137
|
+
{chartSeries.map(item => {
|
|
1138
|
+
const label = item.label || config[item.key]?.label || item.key;
|
|
1139
|
+
|
|
1140
|
+
return (
|
|
1141
|
+
<Button
|
|
1142
|
+
key={item.key}
|
|
1143
|
+
type="button"
|
|
1144
|
+
variant="ghost"
|
|
1145
|
+
size="sm"
|
|
1146
|
+
aria-pressed={activeSeries === item.key}
|
|
1147
|
+
onClick={() => setActiveSeries(item.key)}
|
|
1148
|
+
className={cn(
|
|
1149
|
+
'h-8 px-3 text-sm font-medium transition-all',
|
|
1150
|
+
activeSeries === item.key
|
|
1151
|
+
? 'bg-background text-foreground shadow-sm'
|
|
1152
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
1153
|
+
)}
|
|
1154
|
+
>
|
|
1155
|
+
{label}
|
|
1156
|
+
</Button>
|
|
1157
|
+
);
|
|
1158
|
+
})}
|
|
1159
|
+
</div>
|
|
1160
|
+
) : (
|
|
1161
|
+
<div />
|
|
1162
|
+
)}
|
|
1163
|
+
<Select value={period} onValueChange={setPeriod}>
|
|
1164
|
+
<SelectTrigger
|
|
1165
|
+
className="w-full sm:w-[160px]"
|
|
1166
|
+
size="sm"
|
|
1167
|
+
aria-label="Selecionar periodo do grafico"
|
|
1168
|
+
>
|
|
1169
|
+
<SelectValue placeholder="Period" />
|
|
1170
|
+
</SelectTrigger>
|
|
1171
|
+
<SelectContent>
|
|
1172
|
+
{periods.map(item => (
|
|
1173
|
+
<SelectItem key={item.value} value={item.value}>
|
|
1174
|
+
{item.label}
|
|
1175
|
+
</SelectItem>
|
|
1176
|
+
))}
|
|
1177
|
+
</SelectContent>
|
|
1178
|
+
</Select>
|
|
1179
|
+
</div>
|
|
1180
|
+
{chartState || (
|
|
1181
|
+
<ChartContainer
|
|
1182
|
+
config={chartConfig}
|
|
1183
|
+
className={cn('h-[320px] w-full', className)}
|
|
1184
|
+
{...props}
|
|
1185
|
+
>
|
|
1186
|
+
<RechartsPrimitive.AreaChart data={filteredData} accessibilityLayer>
|
|
1187
|
+
{gradientFill && (
|
|
1188
|
+
<AreaGradientDefs seriesKeys={visibleSeries.map(s => s.key)} opacity={0.4} />
|
|
1189
|
+
)}
|
|
1190
|
+
{showGrid ? (
|
|
1191
|
+
<RechartsPrimitive.CartesianGrid
|
|
1192
|
+
vertical={false}
|
|
1193
|
+
strokeDasharray="3 3"
|
|
1194
|
+
stroke="var(--border)"
|
|
1195
|
+
strokeOpacity={0.5}
|
|
1196
|
+
/>
|
|
1197
|
+
) : null}
|
|
1198
|
+
<RechartsPrimitive.XAxis
|
|
1199
|
+
dataKey={indexKey}
|
|
1200
|
+
tickLine={false}
|
|
1201
|
+
axisLine={false}
|
|
1202
|
+
tickMargin={8}
|
|
1203
|
+
/>
|
|
1204
|
+
<RechartsPrimitive.YAxis
|
|
1205
|
+
tickLine={false}
|
|
1206
|
+
axisLine={false}
|
|
1207
|
+
tickMargin={8}
|
|
1208
|
+
tickFormatter={valueFormatter}
|
|
1209
|
+
/>
|
|
1210
|
+
<ChartTooltip content={<ChartTooltipContent indicator="line" />} />
|
|
1211
|
+
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1212
|
+
{visibleSeries.map(item => (
|
|
1213
|
+
<RechartsPrimitive.Area
|
|
1214
|
+
key={item.key}
|
|
1215
|
+
type={curveType}
|
|
1216
|
+
dataKey={item.key}
|
|
1217
|
+
stroke={`var(--color-${item.key})`}
|
|
1218
|
+
fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
|
|
1219
|
+
fillOpacity={gradientFill ? 1 : 0.16}
|
|
1220
|
+
strokeWidth={strokeWidth}
|
|
1221
|
+
dot={showDots ? { r: 3, fill: `var(--color-${item.key})`, strokeWidth: 0 } : false}
|
|
1222
|
+
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1223
|
+
isAnimationActive
|
|
1224
|
+
animationDuration={600}
|
|
1225
|
+
animationEasing="ease-out"
|
|
1226
|
+
/>
|
|
1227
|
+
))}
|
|
1228
|
+
</RechartsPrimitive.AreaChart>
|
|
1229
|
+
</ChartContainer>
|
|
1230
|
+
)}
|
|
1231
|
+
</div>
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// ─── ComboMetricChart ─────────────────────────────────────────────────────────
|
|
1236
|
+
|
|
1237
|
+
export interface ComboMetricChartProps
|
|
1238
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1239
|
+
data: DashboardChartDatum[];
|
|
1240
|
+
indexKey?: string;
|
|
1241
|
+
series?: DashboardChartSeries[];
|
|
1242
|
+
colors?: DashboardChartColors;
|
|
1243
|
+
barSize?: ChartBarSize;
|
|
1244
|
+
showGrid?: boolean;
|
|
1245
|
+
showLegend?: boolean;
|
|
1246
|
+
gradientFill?: boolean;
|
|
1247
|
+
curveType?: ChartCurveType;
|
|
1248
|
+
valueFormatter?: ChartValueFormatter;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function ComboMetricChart({
|
|
1252
|
+
data,
|
|
1253
|
+
config,
|
|
1254
|
+
indexKey = 'name',
|
|
1255
|
+
series,
|
|
1256
|
+
colors,
|
|
1257
|
+
barSize = 'md',
|
|
1258
|
+
showGrid = true,
|
|
1259
|
+
showLegend = true,
|
|
1260
|
+
gradientFill = false,
|
|
1261
|
+
curveType = 'monotone',
|
|
1262
|
+
valueFormatter = formatTick,
|
|
1263
|
+
isLoading,
|
|
1264
|
+
error,
|
|
1265
|
+
onRetry,
|
|
1266
|
+
retryLabel,
|
|
1267
|
+
emptyTitle,
|
|
1268
|
+
emptyDescription,
|
|
1269
|
+
errorTitle,
|
|
1270
|
+
errorDescription,
|
|
1271
|
+
loadingLabel,
|
|
1272
|
+
stateClassName,
|
|
1273
|
+
className,
|
|
1274
|
+
...props
|
|
1275
|
+
}: ComboMetricChartProps) {
|
|
1276
|
+
const chartSeries = getChartSeries(config, series);
|
|
1277
|
+
const chartConfig = getChartConfigWithColors(
|
|
1278
|
+
config,
|
|
1279
|
+
chartSeries.map(item => item.key),
|
|
1280
|
+
colors
|
|
1281
|
+
);
|
|
1282
|
+
const chartState = getChartState(
|
|
1283
|
+
{
|
|
1284
|
+
isLoading,
|
|
1285
|
+
error,
|
|
1286
|
+
onRetry,
|
|
1287
|
+
retryLabel,
|
|
1288
|
+
emptyTitle,
|
|
1289
|
+
emptyDescription,
|
|
1290
|
+
errorTitle,
|
|
1291
|
+
errorDescription,
|
|
1292
|
+
loadingLabel,
|
|
1293
|
+
},
|
|
1294
|
+
hasChartData(data, chartSeries),
|
|
1295
|
+
cn('h-[340px] w-full', stateClassName)
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
if (chartState) {
|
|
1299
|
+
return chartState;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const areaSeries = gradientFill ? chartSeries.filter(s => s.type === 'area').map(s => s.key) : [];
|
|
1303
|
+
|
|
1304
|
+
return (
|
|
1305
|
+
<ChartContainer config={chartConfig} className={cn('h-[340px] w-full', className)} {...props}>
|
|
1306
|
+
<RechartsPrimitive.ComposedChart data={data} accessibilityLayer>
|
|
1307
|
+
{gradientFill && areaSeries.length > 0 && (
|
|
1308
|
+
<AreaGradientDefs seriesKeys={areaSeries} opacity={0.35} />
|
|
1309
|
+
)}
|
|
1310
|
+
{showGrid ? (
|
|
1311
|
+
<RechartsPrimitive.CartesianGrid
|
|
1312
|
+
vertical={false}
|
|
1313
|
+
strokeDasharray="3 3"
|
|
1314
|
+
stroke="var(--border)"
|
|
1315
|
+
strokeOpacity={0.5}
|
|
1316
|
+
/>
|
|
1317
|
+
) : null}
|
|
1318
|
+
<RechartsPrimitive.XAxis
|
|
1319
|
+
dataKey={indexKey}
|
|
1320
|
+
tickLine={false}
|
|
1321
|
+
axisLine={false}
|
|
1322
|
+
tickMargin={8}
|
|
1323
|
+
/>
|
|
1324
|
+
<RechartsPrimitive.YAxis
|
|
1325
|
+
tickLine={false}
|
|
1326
|
+
axisLine={false}
|
|
1327
|
+
tickMargin={8}
|
|
1328
|
+
tickFormatter={valueFormatter}
|
|
1329
|
+
/>
|
|
1330
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
1331
|
+
{showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
|
|
1332
|
+
{chartSeries.map(item =>
|
|
1333
|
+
item.type === 'line' ? (
|
|
1334
|
+
<RechartsPrimitive.Line
|
|
1335
|
+
key={item.key}
|
|
1336
|
+
type={curveType}
|
|
1337
|
+
dataKey={item.key}
|
|
1338
|
+
stroke={`var(--color-${item.key})`}
|
|
1339
|
+
strokeWidth={2}
|
|
1340
|
+
dot={false}
|
|
1341
|
+
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1342
|
+
yAxisId={item.yAxisId}
|
|
1343
|
+
isAnimationActive
|
|
1344
|
+
animationDuration={600}
|
|
1345
|
+
animationEasing="ease-out"
|
|
1346
|
+
/>
|
|
1347
|
+
) : item.type === 'area' ? (
|
|
1348
|
+
<RechartsPrimitive.Area
|
|
1349
|
+
key={item.key}
|
|
1350
|
+
type={curveType}
|
|
1351
|
+
dataKey={item.key}
|
|
1352
|
+
stroke={`var(--color-${item.key})`}
|
|
1353
|
+
fill={gradientFill ? `url(#gradient-${item.key})` : `var(--color-${item.key})`}
|
|
1354
|
+
fillOpacity={gradientFill ? 1 : 0.12}
|
|
1355
|
+
strokeWidth={2}
|
|
1356
|
+
dot={false}
|
|
1357
|
+
activeDot={{ r: 5, strokeWidth: 0 }}
|
|
1358
|
+
yAxisId={item.yAxisId}
|
|
1359
|
+
isAnimationActive
|
|
1360
|
+
animationDuration={600}
|
|
1361
|
+
animationEasing="ease-out"
|
|
1362
|
+
/>
|
|
1363
|
+
) : (
|
|
1364
|
+
<RechartsPrimitive.Bar
|
|
1365
|
+
key={item.key}
|
|
1366
|
+
dataKey={item.key}
|
|
1367
|
+
fill={`var(--color-${item.key})`}
|
|
1368
|
+
radius={[4, 4, 0, 0]}
|
|
1369
|
+
barSize={getBarSize(barSize)}
|
|
1370
|
+
yAxisId={item.yAxisId}
|
|
1371
|
+
isAnimationActive
|
|
1372
|
+
animationDuration={600}
|
|
1373
|
+
animationEasing="ease-out"
|
|
1374
|
+
/>
|
|
1375
|
+
)
|
|
1376
|
+
)}
|
|
1377
|
+
</RechartsPrimitive.ComposedChart>
|
|
1378
|
+
</ChartContainer>
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ─── DonutBreakdownChart ──────────────────────────────────────────────────────
|
|
1383
|
+
|
|
1384
|
+
export interface DonutBreakdownChartProps
|
|
1385
|
+
extends Omit<React.ComponentProps<typeof ChartContainer>, 'children'>, ChartStateProps {
|
|
1386
|
+
data: DashboardChartDatum[];
|
|
1387
|
+
nameKey?: string;
|
|
1388
|
+
valueKey?: string;
|
|
1389
|
+
colors?: DashboardChartColors;
|
|
1390
|
+
centerLabel?: React.ReactNode;
|
|
1391
|
+
centerValue?: React.ReactNode;
|
|
1392
|
+
showLegend?: boolean;
|
|
1393
|
+
/** Inner radius as percentage string (e.g. "58%") or number */
|
|
1394
|
+
innerRadius?: string | number;
|
|
1395
|
+
/** Outer radius as percentage string (e.g. "82%") or number */
|
|
1396
|
+
outerRadius?: string | number;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function DonutBreakdownChart({
|
|
1400
|
+
data,
|
|
1401
|
+
config,
|
|
1402
|
+
nameKey = 'name',
|
|
1403
|
+
valueKey = 'value',
|
|
1404
|
+
colors,
|
|
1405
|
+
centerLabel,
|
|
1406
|
+
centerValue,
|
|
1407
|
+
showLegend = true,
|
|
1408
|
+
innerRadius = '58%',
|
|
1409
|
+
outerRadius = '82%',
|
|
1410
|
+
isLoading,
|
|
1411
|
+
error,
|
|
1412
|
+
onRetry,
|
|
1413
|
+
retryLabel,
|
|
1414
|
+
emptyTitle,
|
|
1415
|
+
emptyDescription,
|
|
1416
|
+
errorTitle,
|
|
1417
|
+
errorDescription,
|
|
1418
|
+
loadingLabel,
|
|
1419
|
+
stateClassName,
|
|
1420
|
+
className,
|
|
1421
|
+
...props
|
|
1422
|
+
}: DonutBreakdownChartProps) {
|
|
1423
|
+
const [activeIndex, setActiveIndex] = React.useState(0);
|
|
1424
|
+
const chartKeys = data.map((entry, index) => String(entry[nameKey] || `segment-${index}`));
|
|
1425
|
+
const chartConfig = getChartConfigWithColors(config, chartKeys, colors);
|
|
1426
|
+
const chartState = getChartState(
|
|
1427
|
+
{
|
|
1428
|
+
isLoading,
|
|
1429
|
+
error,
|
|
1430
|
+
onRetry,
|
|
1431
|
+
retryLabel,
|
|
1432
|
+
emptyTitle,
|
|
1433
|
+
emptyDescription,
|
|
1434
|
+
errorTitle,
|
|
1435
|
+
errorDescription,
|
|
1436
|
+
loadingLabel,
|
|
1437
|
+
},
|
|
1438
|
+
hasPieData(data, nameKey, valueKey),
|
|
1439
|
+
cn('h-[320px] w-full', stateClassName)
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
if (chartState) {
|
|
1443
|
+
return chartState;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return (
|
|
1447
|
+
<ChartContainer config={chartConfig} className={cn('h-[320px] w-full', className)} {...props}>
|
|
1448
|
+
<RechartsPrimitive.PieChart accessibilityLayer>
|
|
1449
|
+
<ChartTooltip
|
|
1450
|
+
cursor={false}
|
|
1451
|
+
content={<ChartTooltipContent hideLabel nameKey={nameKey} />}
|
|
1452
|
+
/>
|
|
1453
|
+
{showLegend ? (
|
|
1454
|
+
<ChartLegend content={<ChartLegendContent nameKey={nameKey} />} verticalAlign="bottom" />
|
|
1455
|
+
) : null}
|
|
1456
|
+
<RechartsPrimitive.Pie
|
|
1457
|
+
data={data}
|
|
1458
|
+
dataKey={valueKey}
|
|
1459
|
+
nameKey={nameKey}
|
|
1460
|
+
innerRadius={innerRadius}
|
|
1461
|
+
outerRadius={outerRadius}
|
|
1462
|
+
paddingAngle={3}
|
|
1463
|
+
activeIndex={activeIndex}
|
|
1464
|
+
onMouseEnter={(_, index) => setActiveIndex(index)}
|
|
1465
|
+
isAnimationActive
|
|
1466
|
+
animationDuration={600}
|
|
1467
|
+
animationEasing="ease-out"
|
|
1468
|
+
>
|
|
1469
|
+
{data.map((entry, index) => {
|
|
1470
|
+
const key = String(entry[nameKey] || `segment-${index}`);
|
|
1471
|
+
|
|
1472
|
+
return (
|
|
1473
|
+
<RechartsPrimitive.Cell
|
|
1474
|
+
key={key}
|
|
1475
|
+
fill={`var(--color-${key})`}
|
|
1476
|
+
opacity={index === activeIndex ? 1 : 0.7}
|
|
1477
|
+
stroke="transparent"
|
|
1478
|
+
/>
|
|
1479
|
+
);
|
|
1480
|
+
})}
|
|
1481
|
+
{centerValue || centerLabel ? (
|
|
1482
|
+
<RechartsPrimitive.Label
|
|
1483
|
+
position="center"
|
|
1484
|
+
content={({ viewBox }) => {
|
|
1485
|
+
if (!viewBox || !('cx' in viewBox) || !('cy' in viewBox)) {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
return (
|
|
1490
|
+
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
|
1491
|
+
{centerValue ? (
|
|
1492
|
+
<tspan
|
|
1493
|
+
x={viewBox.cx}
|
|
1494
|
+
y={viewBox.cy}
|
|
1495
|
+
className="fill-foreground text-2xl font-semibold"
|
|
1496
|
+
>
|
|
1497
|
+
{centerValue}
|
|
1498
|
+
</tspan>
|
|
1499
|
+
) : null}
|
|
1500
|
+
{centerLabel ? (
|
|
1501
|
+
<tspan
|
|
1502
|
+
x={viewBox.cx}
|
|
1503
|
+
y={Number(viewBox.cy) + 22}
|
|
1504
|
+
className="fill-muted-foreground text-xs"
|
|
1505
|
+
>
|
|
1506
|
+
{centerLabel}
|
|
1507
|
+
</tspan>
|
|
1508
|
+
) : null}
|
|
1509
|
+
</text>
|
|
1510
|
+
);
|
|
1511
|
+
}}
|
|
1512
|
+
/>
|
|
1513
|
+
) : null}
|
|
1514
|
+
</RechartsPrimitive.Pie>
|
|
1515
|
+
</RechartsPrimitive.PieChart>
|
|
1516
|
+
</ChartContainer>
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// ─── SparklineChart ───────────────────────────────────────────────────────────
|
|
1521
|
+
|
|
1522
|
+
export interface SparklineChartProps {
|
|
1523
|
+
/** Data array — each item must have the `dataKey` field */
|
|
1524
|
+
data: DashboardChartDatum[];
|
|
1525
|
+
/** Key to plot on the Y axis */
|
|
1526
|
+
dataKey: string;
|
|
1527
|
+
/** Color for the line/area — defaults to `var(--chart-1)` */
|
|
1528
|
+
color?: string;
|
|
1529
|
+
/** Show filled area under the line */
|
|
1530
|
+
filled?: boolean;
|
|
1531
|
+
/** Show the last data point as a dot */
|
|
1532
|
+
showEndDot?: boolean;
|
|
1533
|
+
/** Curve interpolation type */
|
|
1534
|
+
curveType?: ChartCurveType;
|
|
1535
|
+
/** Stroke width */
|
|
1536
|
+
strokeWidth?: number;
|
|
1537
|
+
className?: string;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Minimal inline sparkline chart — no axes, no grid, no tooltip.
|
|
1542
|
+
* Ideal for embedding inside stat cards or table cells.
|
|
1543
|
+
*
|
|
1544
|
+
* @ai-rules
|
|
1545
|
+
* - Use `filled` for area-style sparklines.
|
|
1546
|
+
* - Keep `className` height small (e.g. `h-12` or `h-16`).
|
|
1547
|
+
* - Do NOT wrap in `ChartCard` — it is designed to be inline.
|
|
1548
|
+
*/
|
|
1549
|
+
function SparklineChart({
|
|
1550
|
+
data,
|
|
1551
|
+
dataKey,
|
|
1552
|
+
color = 'var(--chart-1)',
|
|
1553
|
+
filled = true,
|
|
1554
|
+
showEndDot = true,
|
|
1555
|
+
curveType = 'monotone',
|
|
1556
|
+
strokeWidth = 2,
|
|
1557
|
+
className,
|
|
1558
|
+
}: SparklineChartProps) {
|
|
1559
|
+
const sparkId = React.useId().replace(/:/g, '');
|
|
1560
|
+
const lastPoint = data[data.length - 1];
|
|
1561
|
+
const lastValue = lastPoint ? lastPoint[dataKey] : undefined;
|
|
1562
|
+
|
|
1563
|
+
return (
|
|
1564
|
+
<div className={cn('relative h-12 w-full min-w-0', className)}>
|
|
1565
|
+
<RechartsPrimitive.ResponsiveContainer width="100%" height="100%">
|
|
1566
|
+
<RechartsPrimitive.AreaChart
|
|
1567
|
+
data={data}
|
|
1568
|
+
margin={{ top: 4, right: showEndDot ? 8 : 0, bottom: 0, left: 0 }}
|
|
1569
|
+
>
|
|
1570
|
+
{filled && (
|
|
1571
|
+
<defs>
|
|
1572
|
+
<linearGradient id={`spark-gradient-${sparkId}`} x1="0" y1="0" x2="0" y2="1">
|
|
1573
|
+
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
|
1574
|
+
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
|
1575
|
+
</linearGradient>
|
|
1576
|
+
</defs>
|
|
1577
|
+
)}
|
|
1578
|
+
<RechartsPrimitive.Area
|
|
1579
|
+
type={curveType}
|
|
1580
|
+
dataKey={dataKey}
|
|
1581
|
+
stroke={color}
|
|
1582
|
+
strokeWidth={strokeWidth}
|
|
1583
|
+
fill={filled ? `url(#spark-gradient-${sparkId})` : 'none'}
|
|
1584
|
+
fillOpacity={1}
|
|
1585
|
+
dot={false}
|
|
1586
|
+
activeDot={false}
|
|
1587
|
+
isAnimationActive
|
|
1588
|
+
animationDuration={600}
|
|
1589
|
+
animationEasing="ease-out"
|
|
1590
|
+
/>
|
|
1591
|
+
</RechartsPrimitive.AreaChart>
|
|
1592
|
+
</RechartsPrimitive.ResponsiveContainer>
|
|
1593
|
+
{showEndDot && lastValue !== undefined && lastValue !== null && (
|
|
1594
|
+
<div
|
|
1595
|
+
className="pointer-events-none absolute right-0 top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full ring-2 ring-background"
|
|
1596
|
+
style={{ backgroundColor: color }}
|
|
1597
|
+
/>
|
|
1598
|
+
)}
|
|
1599
|
+
</div>
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ─── RadarChart ───────────────────────────────────────────────────────────────
|
|
1604
|
+
|
|
1605
|
+
export interface RadarMetricChartProps extends ChartStateProps {
|
|
1606
|
+
/** Data array — each item is one axis point on the radar */
|
|
1607
|
+
data: DashboardChartDatum[];
|
|
1608
|
+
/** Key in each datum used as the axis label */
|
|
1609
|
+
labelKey: string;
|
|
1610
|
+
/**
|
|
1611
|
+
* Series to render. Each entry maps to one `<Radar>` element.
|
|
1612
|
+
* Use a single entry for a simple radar, multiple for comparison.
|
|
1613
|
+
*/
|
|
1614
|
+
series: DashboardChartSeries[];
|
|
1615
|
+
/** Override colors per series key or as an ordered array */
|
|
1616
|
+
colors?: DashboardChartColors;
|
|
1617
|
+
/** Fill the radar polygon (default: true) */
|
|
1618
|
+
filled?: boolean;
|
|
1619
|
+
/** Fill opacity when `filled` is true (default: 0.25) */
|
|
1620
|
+
fillOpacity?: number;
|
|
1621
|
+
/** Show dots on each axis point (default: false) */
|
|
1622
|
+
showDots?: boolean;
|
|
1623
|
+
/** Show the polar grid lines (default: true) */
|
|
1624
|
+
showGrid?: boolean;
|
|
1625
|
+
/** Show the legend (default: true when multiple series) */
|
|
1626
|
+
showLegend?: boolean;
|
|
1627
|
+
/** Format axis tick values */
|
|
1628
|
+
valueFormatter?: ChartValueFormatter;
|
|
1629
|
+
className?: string;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function RadarMetricChart({
|
|
1633
|
+
data,
|
|
1634
|
+
labelKey,
|
|
1635
|
+
series,
|
|
1636
|
+
colors,
|
|
1637
|
+
filled = true,
|
|
1638
|
+
fillOpacity = 0.25,
|
|
1639
|
+
showDots = false,
|
|
1640
|
+
showGrid = true,
|
|
1641
|
+
showLegend,
|
|
1642
|
+
valueFormatter,
|
|
1643
|
+
isLoading,
|
|
1644
|
+
error,
|
|
1645
|
+
onRetry,
|
|
1646
|
+
retryLabel,
|
|
1647
|
+
emptyTitle,
|
|
1648
|
+
emptyDescription,
|
|
1649
|
+
errorTitle,
|
|
1650
|
+
errorDescription,
|
|
1651
|
+
loadingLabel,
|
|
1652
|
+
stateClassName,
|
|
1653
|
+
className,
|
|
1654
|
+
}: RadarMetricChartProps) {
|
|
1655
|
+
const chartConfig = React.useMemo(
|
|
1656
|
+
() =>
|
|
1657
|
+
buildChartConfig(
|
|
1658
|
+
series.map(s => s.key),
|
|
1659
|
+
colors
|
|
1660
|
+
),
|
|
1661
|
+
[series, colors]
|
|
1662
|
+
);
|
|
1663
|
+
|
|
1664
|
+
const hasData = data.length > 0 && series.length > 0;
|
|
1665
|
+
const chartState = getChartState(
|
|
1666
|
+
{
|
|
1667
|
+
isLoading,
|
|
1668
|
+
error,
|
|
1669
|
+
emptyTitle,
|
|
1670
|
+
emptyDescription,
|
|
1671
|
+
errorTitle,
|
|
1672
|
+
errorDescription,
|
|
1673
|
+
loadingLabel,
|
|
1674
|
+
stateClassName,
|
|
1675
|
+
onRetry,
|
|
1676
|
+
retryLabel,
|
|
1677
|
+
},
|
|
1678
|
+
hasData
|
|
1679
|
+
);
|
|
1680
|
+
|
|
1681
|
+
const displayLegend = showLegend ?? series.length > 1;
|
|
1682
|
+
|
|
1683
|
+
return (
|
|
1684
|
+
<ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
|
|
1685
|
+
{chartState ?? (
|
|
1686
|
+
<RechartsPrimitive.RadarChart data={data} accessibilityLayer>
|
|
1687
|
+
{showGrid && <RechartsPrimitive.PolarGrid stroke="var(--border)" strokeOpacity={0.6} />}
|
|
1688
|
+
<RechartsPrimitive.PolarAngleAxis
|
|
1689
|
+
dataKey={labelKey}
|
|
1690
|
+
tick={{ fill: 'var(--muted-foreground)', fontSize: 12 }}
|
|
1691
|
+
/>
|
|
1692
|
+
<RechartsPrimitive.PolarRadiusAxis
|
|
1693
|
+
tick={false}
|
|
1694
|
+
axisLine={false}
|
|
1695
|
+
tickFormatter={valueFormatter}
|
|
1696
|
+
/>
|
|
1697
|
+
<ChartTooltip
|
|
1698
|
+
content={
|
|
1699
|
+
<ChartTooltipContent
|
|
1700
|
+
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1701
|
+
/>
|
|
1702
|
+
}
|
|
1703
|
+
/>
|
|
1704
|
+
{displayLegend && <ChartLegend content={<ChartLegendContent />} />}
|
|
1705
|
+
{series.map(s => (
|
|
1706
|
+
<RechartsPrimitive.Radar
|
|
1707
|
+
key={s.key}
|
|
1708
|
+
name={(s.label as string) ?? s.key}
|
|
1709
|
+
dataKey={s.key}
|
|
1710
|
+
stroke={`var(--color-${s.key})`}
|
|
1711
|
+
fill={filled ? `var(--color-${s.key})` : 'none'}
|
|
1712
|
+
fillOpacity={filled ? fillOpacity : 0}
|
|
1713
|
+
dot={showDots ? { r: 3, fill: `var(--color-${s.key})` } : false}
|
|
1714
|
+
isAnimationActive
|
|
1715
|
+
animationDuration={600}
|
|
1716
|
+
animationEasing="ease-out"
|
|
1717
|
+
/>
|
|
1718
|
+
))}
|
|
1719
|
+
</RechartsPrimitive.RadarChart>
|
|
1720
|
+
)}
|
|
1721
|
+
</ChartContainer>
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ─── PieMetricChart ───────────────────────────────────────────────────────────
|
|
1726
|
+
|
|
1727
|
+
export interface PieMetricChartProps extends ChartStateProps {
|
|
1728
|
+
/** Data array — each item is one slice */
|
|
1729
|
+
data: DashboardChartDatum[];
|
|
1730
|
+
/** Key in each datum used as the slice name/label */
|
|
1731
|
+
nameKey: string;
|
|
1732
|
+
/** Key in each datum used as the slice value */
|
|
1733
|
+
valueKey: string;
|
|
1734
|
+
/** Override colors as an ordered array or per-name map */
|
|
1735
|
+
colors?: DashboardChartColors;
|
|
1736
|
+
/** Outer radius of the pie (default: "80%") */
|
|
1737
|
+
outerRadius?: number | string;
|
|
1738
|
+
/** Inner radius — set > 0 to make a donut (default: 0) */
|
|
1739
|
+
innerRadius?: number | string;
|
|
1740
|
+
/** Show percentage labels inside/outside each slice (default: false) */
|
|
1741
|
+
showLabels?: boolean;
|
|
1742
|
+
/** Show the legend (default: true) */
|
|
1743
|
+
showLegend?: boolean;
|
|
1744
|
+
/**
|
|
1745
|
+
* Index of the slice to "explode" (offset outward).
|
|
1746
|
+
* Pass -1 or undefined to disable.
|
|
1747
|
+
*/
|
|
1748
|
+
explodeIndex?: number;
|
|
1749
|
+
/** Offset distance for the exploded slice in px (default: 12) */
|
|
1750
|
+
explodeOffset?: number;
|
|
1751
|
+
/** Format tooltip values */
|
|
1752
|
+
valueFormatter?: ChartValueFormatter;
|
|
1753
|
+
className?: string;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function PieMetricChart({
|
|
1757
|
+
data,
|
|
1758
|
+
nameKey,
|
|
1759
|
+
valueKey,
|
|
1760
|
+
colors,
|
|
1761
|
+
outerRadius = '80%',
|
|
1762
|
+
innerRadius = 0,
|
|
1763
|
+
showLabels = false,
|
|
1764
|
+
showLegend = true,
|
|
1765
|
+
explodeIndex,
|
|
1766
|
+
explodeOffset = 12,
|
|
1767
|
+
valueFormatter,
|
|
1768
|
+
isLoading,
|
|
1769
|
+
error,
|
|
1770
|
+
onRetry,
|
|
1771
|
+
retryLabel,
|
|
1772
|
+
emptyTitle,
|
|
1773
|
+
emptyDescription,
|
|
1774
|
+
errorTitle,
|
|
1775
|
+
errorDescription,
|
|
1776
|
+
loadingLabel,
|
|
1777
|
+
stateClassName,
|
|
1778
|
+
className,
|
|
1779
|
+
}: PieMetricChartProps) {
|
|
1780
|
+
// Build config from unique name values
|
|
1781
|
+
const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
|
|
1782
|
+
|
|
1783
|
+
const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
|
|
1784
|
+
|
|
1785
|
+
const hasData = hasPieData(data, nameKey, valueKey);
|
|
1786
|
+
const chartState = getChartState(
|
|
1787
|
+
{
|
|
1788
|
+
isLoading,
|
|
1789
|
+
error,
|
|
1790
|
+
emptyTitle,
|
|
1791
|
+
emptyDescription,
|
|
1792
|
+
errorTitle,
|
|
1793
|
+
errorDescription,
|
|
1794
|
+
loadingLabel,
|
|
1795
|
+
stateClassName,
|
|
1796
|
+
onRetry,
|
|
1797
|
+
retryLabel,
|
|
1798
|
+
},
|
|
1799
|
+
hasData
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
return (
|
|
1803
|
+
<ChartContainer config={chartConfig} className={cn('h-[300px] w-full', className)}>
|
|
1804
|
+
{chartState ?? (
|
|
1805
|
+
<RechartsPrimitive.PieChart accessibilityLayer>
|
|
1806
|
+
<ChartTooltip
|
|
1807
|
+
content={
|
|
1808
|
+
<ChartTooltipContent
|
|
1809
|
+
nameKey={nameKey}
|
|
1810
|
+
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1811
|
+
/>
|
|
1812
|
+
}
|
|
1813
|
+
/>
|
|
1814
|
+
{showLegend && <ChartLegend content={<ChartLegendContent nameKey={nameKey} />} />}
|
|
1815
|
+
<RechartsPrimitive.Pie
|
|
1816
|
+
data={data}
|
|
1817
|
+
dataKey={valueKey}
|
|
1818
|
+
nameKey={nameKey}
|
|
1819
|
+
outerRadius={outerRadius}
|
|
1820
|
+
innerRadius={innerRadius}
|
|
1821
|
+
paddingAngle={2}
|
|
1822
|
+
label={
|
|
1823
|
+
showLabels
|
|
1824
|
+
? ({ cx, cy, midAngle, innerRadius: ir, outerRadius: or, percent }) => {
|
|
1825
|
+
const RADIAN = Math.PI / 180;
|
|
1826
|
+
const radius = Number(ir) + (Number(or) - Number(ir)) * 1.35;
|
|
1827
|
+
const x = Number(cx) + radius * Math.cos(-midAngle * RADIAN);
|
|
1828
|
+
const y = Number(cy) + radius * Math.sin(-midAngle * RADIAN);
|
|
1829
|
+
return percent > 0.04 ? (
|
|
1830
|
+
<text
|
|
1831
|
+
x={x}
|
|
1832
|
+
y={y}
|
|
1833
|
+
fill="var(--foreground)"
|
|
1834
|
+
textAnchor={x > Number(cx) ? 'start' : 'end'}
|
|
1835
|
+
dominantBaseline="central"
|
|
1836
|
+
fontSize={12}
|
|
1837
|
+
fontWeight={500}
|
|
1838
|
+
>
|
|
1839
|
+
{`${(percent * 100).toFixed(0)}%`}
|
|
1840
|
+
</text>
|
|
1841
|
+
) : null;
|
|
1842
|
+
}
|
|
1843
|
+
: false
|
|
1844
|
+
}
|
|
1845
|
+
labelLine={false}
|
|
1846
|
+
isAnimationActive
|
|
1847
|
+
animationDuration={600}
|
|
1848
|
+
animationEasing="ease-out"
|
|
1849
|
+
>
|
|
1850
|
+
{data.map((entry, index) => {
|
|
1851
|
+
const name = String(entry[nameKey] ?? '');
|
|
1852
|
+
const isExploded = index === explodeIndex;
|
|
1853
|
+
return (
|
|
1854
|
+
<RechartsPrimitive.Cell
|
|
1855
|
+
key={`cell-${index}`}
|
|
1856
|
+
fill={chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`}
|
|
1857
|
+
stroke="var(--background)"
|
|
1858
|
+
strokeWidth={2}
|
|
1859
|
+
style={
|
|
1860
|
+
isExploded
|
|
1861
|
+
? {
|
|
1862
|
+
filter: `drop-shadow(0 4px 8px color-mix(in srgb, ${chartConfig[name]?.color ?? 'var(--chart-1)'} 40%, transparent))`,
|
|
1863
|
+
}
|
|
1864
|
+
: undefined
|
|
1865
|
+
}
|
|
1866
|
+
/>
|
|
1867
|
+
);
|
|
1868
|
+
})}
|
|
1869
|
+
</RechartsPrimitive.Pie>
|
|
1870
|
+
</RechartsPrimitive.PieChart>
|
|
1871
|
+
)}
|
|
1872
|
+
</ChartContainer>
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ─── RadialBarMetricChart ─────────────────────────────────────────────────────
|
|
1877
|
+
|
|
1878
|
+
export interface RadialBarMetricChartProps extends ChartStateProps {
|
|
1879
|
+
/**
|
|
1880
|
+
* Data array. Each item should have a `name` field (for labels) and
|
|
1881
|
+
* the `dataKey` field (for values, 0–100 for percentage-based display).
|
|
1882
|
+
*/
|
|
1883
|
+
data: DashboardChartDatum[];
|
|
1884
|
+
/** Key in each datum used as the bar value (default: "value") */
|
|
1885
|
+
dataKey?: string;
|
|
1886
|
+
/** Key in each datum used as the bar label (default: "name") */
|
|
1887
|
+
nameKey?: string;
|
|
1888
|
+
/** Override colors as an ordered array or per-name map */
|
|
1889
|
+
colors?: DashboardChartColors;
|
|
1890
|
+
/** Inner radius of the radial bar (default: "30%") */
|
|
1891
|
+
innerRadius?: number | string;
|
|
1892
|
+
/** Outer radius of the radial bar (default: "100%") */
|
|
1893
|
+
outerRadius?: number | string;
|
|
1894
|
+
/** Start angle in degrees (default: 90 — top) */
|
|
1895
|
+
startAngle?: number;
|
|
1896
|
+
/** End angle in degrees (default: -270 — full circle) */
|
|
1897
|
+
endAngle?: number;
|
|
1898
|
+
/** Show background track behind each bar (default: true) */
|
|
1899
|
+
showBackground?: boolean;
|
|
1900
|
+
/** Show the legend (default: true) */
|
|
1901
|
+
showLegend?: boolean;
|
|
1902
|
+
/** Format tooltip values */
|
|
1903
|
+
valueFormatter?: ChartValueFormatter;
|
|
1904
|
+
className?: string;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function RadialBarMetricChart({
|
|
1908
|
+
data,
|
|
1909
|
+
dataKey = 'value',
|
|
1910
|
+
nameKey = 'name',
|
|
1911
|
+
colors,
|
|
1912
|
+
innerRadius = '30%',
|
|
1913
|
+
outerRadius = '100%',
|
|
1914
|
+
startAngle = 90,
|
|
1915
|
+
endAngle = -270,
|
|
1916
|
+
showBackground = true,
|
|
1917
|
+
showLegend = true,
|
|
1918
|
+
valueFormatter,
|
|
1919
|
+
isLoading,
|
|
1920
|
+
error,
|
|
1921
|
+
onRetry,
|
|
1922
|
+
retryLabel,
|
|
1923
|
+
emptyTitle,
|
|
1924
|
+
emptyDescription,
|
|
1925
|
+
errorTitle,
|
|
1926
|
+
errorDescription,
|
|
1927
|
+
loadingLabel,
|
|
1928
|
+
stateClassName,
|
|
1929
|
+
className,
|
|
1930
|
+
}: RadialBarMetricChartProps) {
|
|
1931
|
+
const names = React.useMemo(() => data.map(d => String(d[nameKey] ?? '')), [data, nameKey]);
|
|
1932
|
+
|
|
1933
|
+
const chartConfig = React.useMemo(() => buildChartConfig(names, colors), [names, colors]);
|
|
1934
|
+
|
|
1935
|
+
const hasData = data.length > 0;
|
|
1936
|
+
const chartState = getChartState(
|
|
1937
|
+
{
|
|
1938
|
+
isLoading,
|
|
1939
|
+
error,
|
|
1940
|
+
emptyTitle,
|
|
1941
|
+
emptyDescription,
|
|
1942
|
+
errorTitle,
|
|
1943
|
+
errorDescription,
|
|
1944
|
+
loadingLabel,
|
|
1945
|
+
stateClassName,
|
|
1946
|
+
onRetry,
|
|
1947
|
+
retryLabel,
|
|
1948
|
+
},
|
|
1949
|
+
hasData
|
|
1950
|
+
);
|
|
1951
|
+
|
|
1952
|
+
// Inject per-item fill color
|
|
1953
|
+
const coloredData = React.useMemo(
|
|
1954
|
+
() =>
|
|
1955
|
+
data.map((item, index) => {
|
|
1956
|
+
const name = String(item[nameKey] ?? '');
|
|
1957
|
+
return {
|
|
1958
|
+
...item,
|
|
1959
|
+
fill: chartConfig[name]?.color ?? `var(--chart-${(index % 8) + 1})`,
|
|
1960
|
+
};
|
|
1961
|
+
}),
|
|
1962
|
+
[data, nameKey, chartConfig]
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
return (
|
|
1966
|
+
<div className={cn('flex w-full flex-col gap-3', className)}>
|
|
1967
|
+
<ChartContainer config={chartConfig} className="h-[260px] w-full">
|
|
1968
|
+
{chartState ?? (
|
|
1969
|
+
<RechartsPrimitive.RadialBarChart
|
|
1970
|
+
data={coloredData}
|
|
1971
|
+
innerRadius={innerRadius}
|
|
1972
|
+
outerRadius={outerRadius}
|
|
1973
|
+
startAngle={startAngle}
|
|
1974
|
+
endAngle={endAngle}
|
|
1975
|
+
accessibilityLayer
|
|
1976
|
+
>
|
|
1977
|
+
<ChartTooltip
|
|
1978
|
+
cursor={false}
|
|
1979
|
+
content={
|
|
1980
|
+
<ChartTooltipContent
|
|
1981
|
+
nameKey={nameKey}
|
|
1982
|
+
formatter={valueFormatter ? v => valueFormatter(v as number) : undefined}
|
|
1983
|
+
/>
|
|
1984
|
+
}
|
|
1985
|
+
/>
|
|
1986
|
+
<RechartsPrimitive.RadialBar
|
|
1987
|
+
dataKey={dataKey}
|
|
1988
|
+
background={showBackground ? { fill: 'var(--muted)' } : false}
|
|
1989
|
+
cornerRadius={6}
|
|
1990
|
+
isAnimationActive
|
|
1991
|
+
animationDuration={700}
|
|
1992
|
+
animationEasing="ease-out"
|
|
1993
|
+
/>
|
|
1994
|
+
</RechartsPrimitive.RadialBarChart>
|
|
1995
|
+
)}
|
|
1996
|
+
</ChartContainer>
|
|
1997
|
+
{showLegend && !chartState && (
|
|
1998
|
+
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1.5 px-2">
|
|
1999
|
+
{coloredData.map((item, index) => {
|
|
2000
|
+
const d = item as DashboardChartDatum & { fill: string };
|
|
2001
|
+
const name = String(d[nameKey] ?? '');
|
|
2002
|
+
const fillColor = d.fill ?? `var(--chart-${(index % 8) + 1})`;
|
|
2003
|
+
const val = d[dataKey];
|
|
2004
|
+
return (
|
|
2005
|
+
<div key={name} className="flex items-center gap-1.5">
|
|
2006
|
+
<span
|
|
2007
|
+
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
|
2008
|
+
style={{ backgroundColor: fillColor }}
|
|
2009
|
+
/>
|
|
2010
|
+
<span className="text-xs text-muted-foreground">
|
|
2011
|
+
{name}
|
|
2012
|
+
{val !== undefined && val !== null && (
|
|
2013
|
+
<span className="ml-1 font-medium text-foreground">
|
|
2014
|
+
{valueFormatter ? valueFormatter(val as number) : String(val)}
|
|
2015
|
+
</span>
|
|
2016
|
+
)}
|
|
2017
|
+
</span>
|
|
2018
|
+
</div>
|
|
2019
|
+
);
|
|
2020
|
+
})}
|
|
2021
|
+
</div>
|
|
2022
|
+
)}
|
|
2023
|
+
</div>
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// ─── GaugeChart ───────────────────────────────────────────────────────────────
|
|
2028
|
+
|
|
2029
|
+
export interface GaugeChartThreshold {
|
|
2030
|
+
/** Upper bound of this zone (0–100) */
|
|
2031
|
+
value: number;
|
|
2032
|
+
/** Color for this zone */
|
|
2033
|
+
color: string;
|
|
2034
|
+
/** Optional label for this zone */
|
|
2035
|
+
label?: string;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
export interface GaugeChartProps {
|
|
2039
|
+
/** Current value (must be within [min, max]) */
|
|
2040
|
+
value: number;
|
|
2041
|
+
/** Minimum value (default: 0) */
|
|
2042
|
+
min?: number;
|
|
2043
|
+
/** Maximum value (default: 100) */
|
|
2044
|
+
max?: number;
|
|
2045
|
+
/**
|
|
2046
|
+
* Color zones. Each threshold defines the upper bound of a zone.
|
|
2047
|
+
* Zones are evaluated in order; the first zone whose `value >= current %`
|
|
2048
|
+
* determines the active color.
|
|
2049
|
+
* If omitted, uses `--chart-1`.
|
|
2050
|
+
*/
|
|
2051
|
+
thresholds?: GaugeChartThreshold[];
|
|
2052
|
+
/** Label shown below the value (e.g. "CPU Usage") */
|
|
2053
|
+
label?: React.ReactNode;
|
|
2054
|
+
/** Format the center value text (default: shows percentage) */
|
|
2055
|
+
valueFormatter?: (value: number, percent: number) => string;
|
|
2056
|
+
/** Show the needle indicator (default: true) */
|
|
2057
|
+
showNeedle?: boolean;
|
|
2058
|
+
className?: string;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function GaugeChart({
|
|
2062
|
+
value,
|
|
2063
|
+
min = 0,
|
|
2064
|
+
max = 100,
|
|
2065
|
+
thresholds,
|
|
2066
|
+
label,
|
|
2067
|
+
valueFormatter,
|
|
2068
|
+
showNeedle = true,
|
|
2069
|
+
className,
|
|
2070
|
+
}: GaugeChartProps) {
|
|
2071
|
+
const percent = Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
2072
|
+
const percentInt = Math.round(percent * 100);
|
|
2073
|
+
|
|
2074
|
+
// Determine active color from thresholds
|
|
2075
|
+
const activeColor = React.useMemo(() => {
|
|
2076
|
+
if (!thresholds || thresholds.length === 0) return 'var(--chart-1)';
|
|
2077
|
+
for (const t of thresholds) {
|
|
2078
|
+
if (percentInt <= t.value) return t.color;
|
|
2079
|
+
}
|
|
2080
|
+
return thresholds[thresholds.length - 1].color;
|
|
2081
|
+
}, [thresholds, percentInt]);
|
|
2082
|
+
|
|
2083
|
+
const displayText = valueFormatter ? valueFormatter(value, percentInt) : `${percentInt}%`;
|
|
2084
|
+
|
|
2085
|
+
// SVG gauge constants — viewBox is 200×110, arc center at (100, 100)
|
|
2086
|
+
const cx = 100;
|
|
2087
|
+
const cy = 100;
|
|
2088
|
+
const R = 80; // outer radius
|
|
2089
|
+
const r = 54; // inner radius (track width = 26)
|
|
2090
|
+
|
|
2091
|
+
// Helper: polar → cartesian (angle in degrees, 0° = right, CCW)
|
|
2092
|
+
const polar = (angleDeg: number, radius: number) => {
|
|
2093
|
+
const rad = (angleDeg * Math.PI) / 180;
|
|
2094
|
+
return {
|
|
2095
|
+
x: cx + radius * Math.cos(rad),
|
|
2096
|
+
y: cy - radius * Math.sin(rad),
|
|
2097
|
+
};
|
|
2098
|
+
};
|
|
2099
|
+
|
|
2100
|
+
// Semicircle: from 180° (left) to 0° (right)
|
|
2101
|
+
// Track background arc path
|
|
2102
|
+
const trackStart = polar(180, R);
|
|
2103
|
+
const trackEnd = polar(0, R);
|
|
2104
|
+
const trackStartI = polar(180, r);
|
|
2105
|
+
const trackEndI = polar(0, r);
|
|
2106
|
+
const trackPath = [
|
|
2107
|
+
`M ${trackStart.x} ${trackStart.y}`,
|
|
2108
|
+
`A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`,
|
|
2109
|
+
`L ${trackEndI.x} ${trackEndI.y}`,
|
|
2110
|
+
`A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
|
|
2111
|
+
'Z',
|
|
2112
|
+
].join(' ');
|
|
2113
|
+
|
|
2114
|
+
// Value arc: from 180° (left) clockwise to valueAngle
|
|
2115
|
+
// The gauge is a semicircle (max 180°), so largeArc is always 0.
|
|
2116
|
+
// largeArc=1 would sweep the reflex arc (>180°) which creates the filled-blob bug.
|
|
2117
|
+
const valueAngle = 180 - percent * 180;
|
|
2118
|
+
const valueEnd = polar(valueAngle, R);
|
|
2119
|
+
const valueEndI = polar(valueAngle, r);
|
|
2120
|
+
const valuePath =
|
|
2121
|
+
percent <= 0
|
|
2122
|
+
? ''
|
|
2123
|
+
: percent >= 1
|
|
2124
|
+
? trackPath // full arc = same as track
|
|
2125
|
+
: [
|
|
2126
|
+
`M ${trackStart.x} ${trackStart.y}`,
|
|
2127
|
+
`A ${R} ${R} 0 0 1 ${valueEnd.x} ${valueEnd.y}`,
|
|
2128
|
+
`L ${valueEndI.x} ${valueEndI.y}`,
|
|
2129
|
+
`A ${r} ${r} 0 0 0 ${trackStartI.x} ${trackStartI.y}`,
|
|
2130
|
+
'Z',
|
|
2131
|
+
].join(' ');
|
|
2132
|
+
|
|
2133
|
+
// Needle: rotates from -90° (left, 0%) to +90° (right, 100%)
|
|
2134
|
+
// In our coordinate system: 180° at 0%, 0° at 100%
|
|
2135
|
+
const needleAngle = 180 - percent * 180;
|
|
2136
|
+
const needleTip = polar(needleAngle, r - 6);
|
|
2137
|
+
const needleBase1 = polar(needleAngle + 90, 5);
|
|
2138
|
+
const needleBase2 = polar(needleAngle - 90, 5);
|
|
2139
|
+
|
|
2140
|
+
return (
|
|
2141
|
+
<div className={cn('flex flex-col items-center gap-2', className)}>
|
|
2142
|
+
<div className="relative w-full max-w-[260px]">
|
|
2143
|
+
<svg viewBox="0 0 200 130" className="w-full" aria-label={`Gauge: ${displayText}`}>
|
|
2144
|
+
{/* Background track */}
|
|
2145
|
+
<path d={trackPath} fill="var(--muted)" />
|
|
2146
|
+
|
|
2147
|
+
{/* Value arc */}
|
|
2148
|
+
{valuePath && <path d={valuePath} fill={activeColor} />}
|
|
2149
|
+
|
|
2150
|
+
{/* Needle */}
|
|
2151
|
+
{showNeedle && (
|
|
2152
|
+
<g>
|
|
2153
|
+
<polygon
|
|
2154
|
+
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
|
|
2155
|
+
fill="var(--foreground)"
|
|
2156
|
+
opacity={0.85}
|
|
2157
|
+
/>
|
|
2158
|
+
<circle cx={cx} cy={cy} r={5} fill="var(--foreground)" />
|
|
2159
|
+
</g>
|
|
2160
|
+
)}
|
|
2161
|
+
|
|
2162
|
+
{/* Value text — placed below the arc baseline (cy=100) to avoid needle overlap */}
|
|
2163
|
+
<text
|
|
2164
|
+
x={cx}
|
|
2165
|
+
y={cy + 18}
|
|
2166
|
+
textAnchor="middle"
|
|
2167
|
+
dominantBaseline="middle"
|
|
2168
|
+
fontSize={22}
|
|
2169
|
+
fontWeight={700}
|
|
2170
|
+
fill="currentColor"
|
|
2171
|
+
className="fill-foreground"
|
|
2172
|
+
>
|
|
2173
|
+
{displayText}
|
|
2174
|
+
</text>
|
|
2175
|
+
|
|
2176
|
+
{/* Label below value */}
|
|
2177
|
+
{label && (
|
|
2178
|
+
<text
|
|
2179
|
+
x={cx}
|
|
2180
|
+
y={cy + 36}
|
|
2181
|
+
textAnchor="middle"
|
|
2182
|
+
dominantBaseline="middle"
|
|
2183
|
+
fontSize={10}
|
|
2184
|
+
fill="currentColor"
|
|
2185
|
+
className="fill-muted-foreground"
|
|
2186
|
+
>
|
|
2187
|
+
{label}
|
|
2188
|
+
</text>
|
|
2189
|
+
)}
|
|
2190
|
+
</svg>
|
|
2191
|
+
</div>
|
|
2192
|
+
|
|
2193
|
+
{/* Threshold legend */}
|
|
2194
|
+
{thresholds && thresholds.length > 0 && (
|
|
2195
|
+
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1">
|
|
2196
|
+
{thresholds.map((t, i) => (
|
|
2197
|
+
<div key={i} className="flex items-center gap-1.5">
|
|
2198
|
+
<span
|
|
2199
|
+
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
2200
|
+
style={{ backgroundColor: t.color }}
|
|
2201
|
+
/>
|
|
2202
|
+
<span className="text-xs text-muted-foreground">{t.label}</span>
|
|
2203
|
+
</div>
|
|
2204
|
+
))}
|
|
2205
|
+
</div>
|
|
2206
|
+
)}
|
|
2207
|
+
</div>
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
2212
|
+
|
|
2213
|
+
export {
|
|
2214
|
+
ChartContainer,
|
|
2215
|
+
ChartTooltip,
|
|
2216
|
+
ChartTooltipContent,
|
|
2217
|
+
ChartLegend,
|
|
2218
|
+
ChartLegendContent,
|
|
2219
|
+
ChartStyle,
|
|
2220
|
+
ChartCard,
|
|
2221
|
+
DashboardBarChart,
|
|
2222
|
+
DashboardLineChart,
|
|
2223
|
+
HorizontalBarChart,
|
|
2224
|
+
InteractiveTimeSeriesChart,
|
|
2225
|
+
ComboMetricChart,
|
|
2226
|
+
DonutBreakdownChart,
|
|
2227
|
+
SparklineChart,
|
|
2228
|
+
RadarMetricChart,
|
|
2229
|
+
PieMetricChart,
|
|
2230
|
+
RadialBarMetricChart,
|
|
2231
|
+
GaugeChart,
|
|
2232
|
+
};
|