banhaten 0.4.0 → 0.5.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 +139 -100
- package/CONTRIBUTING.md +46 -44
- package/README.md +192 -190
- package/SECURITY.md +15 -15
- package/banhaten.config.example.json +11 -11
- package/docs/design-spacing.md +13 -0
- package/docs/design-system/README.md +22 -20
- package/docs/design-system/adoption-quality-remediation.md +87 -87
- package/docs/design-system/agent-usage.md +13 -13
- package/docs/design-system/appearance-presets.md +189 -189
- package/docs/design-system/appearances/default.md +94 -94
- package/docs/design-system/appearances/rounded.md +95 -95
- package/docs/design-system/appearances/sharp.md +95 -95
- package/docs/design-system/component-docs-pattern.md +81 -81
- package/docs/design-system/component-implementation-ownership-audit.md +245 -245
- package/docs/design-system/component-quality-audit-2026-08-20.md +225 -225
- package/docs/design-system/component-showcase-consistency-report.md +219 -219
- package/docs/design-system/component-token-consistency-audit.md +173 -173
- package/docs/design-system/components/README.md +90 -76
- package/docs/design-system/components/accordion.md +53 -53
- package/docs/design-system/components/activity-feed.md +94 -94
- package/docs/design-system/components/activity-heatmap.md +57 -57
- package/docs/design-system/components/agent-input.md +85 -0
- package/docs/design-system/components/alert-dialog.md +72 -72
- package/docs/design-system/components/alert.md +97 -97
- package/docs/design-system/components/approval-card.md +134 -0
- package/docs/design-system/components/aspect-ratio.md +45 -45
- package/docs/design-system/components/attribute.md +88 -88
- package/docs/design-system/components/autocomplete.md +75 -75
- package/docs/design-system/components/avatar.md +54 -54
- package/docs/design-system/components/badge.md +54 -54
- package/docs/design-system/components/banner.md +87 -87
- package/docs/design-system/components/breadcrumbs.md +176 -175
- package/docs/design-system/components/button-group.md +98 -98
- package/docs/design-system/components/button.md +83 -80
- package/docs/design-system/components/card.md +88 -88
- package/docs/design-system/components/carousel.md +46 -46
- package/docs/design-system/components/catalog-components.md +47 -47
- package/docs/design-system/components/chart.md +95 -24
- package/docs/design-system/components/checkbox.md +54 -54
- package/docs/design-system/components/collapsible.md +67 -53
- package/docs/design-system/components/command.md +62 -62
- package/docs/design-system/components/context-menu.md +46 -46
- package/docs/design-system/components/date-picker.md +81 -81
- package/docs/design-system/components/divider.md +102 -102
- package/docs/design-system/components/empty-state.md +56 -56
- package/docs/design-system/components/field.md +70 -70
- package/docs/design-system/components/file-upload.md +187 -187
- package/docs/design-system/components/hover-card.md +48 -48
- package/docs/design-system/components/icons.md +48 -48
- package/docs/design-system/components/inline-citations.md +93 -0
- package/docs/design-system/components/input-group.md +58 -58
- package/docs/design-system/components/input-otp.md +56 -56
- package/docs/design-system/components/input.md +114 -60
- package/docs/design-system/components/item.md +59 -59
- package/docs/design-system/components/kbd.md +51 -49
- package/docs/design-system/components/label.md +49 -49
- package/docs/design-system/components/menu.md +71 -61
- package/docs/design-system/components/menubar.md +47 -47
- package/docs/design-system/components/modal.md +105 -105
- package/docs/design-system/components/native-select.md +54 -54
- package/docs/design-system/components/navigation-menu.md +50 -50
- package/docs/design-system/components/onboarding-step-list-item.md +82 -82
- package/docs/design-system/components/page-header.md +93 -86
- package/docs/design-system/components/pagination.md +57 -57
- package/docs/design-system/components/popover.md +60 -60
- package/docs/design-system/components/progress-slider.md +50 -50
- package/docs/design-system/components/progress.md +76 -76
- package/docs/design-system/components/radio-card.md +51 -51
- package/docs/design-system/components/radio-group.md +57 -57
- package/docs/design-system/components/resizable.md +44 -44
- package/docs/design-system/components/rolling-count.md +61 -0
- package/docs/design-system/components/scroll-area.md +51 -51
- package/docs/design-system/components/segmented-control.md +54 -54
- package/docs/design-system/components/select.md +68 -62
- package/docs/design-system/components/sheet.md +67 -67
- package/docs/design-system/components/shimmer-text.md +67 -0
- package/docs/design-system/components/sidebar.md +110 -99
- package/docs/design-system/components/skeleton.md +75 -75
- package/docs/design-system/components/slideout.md +65 -65
- package/docs/design-system/components/slider.md +63 -63
- package/docs/design-system/components/social-button.md +50 -50
- package/docs/design-system/components/spinner.md +62 -62
- package/docs/design-system/components/steps.md +65 -65
- package/docs/design-system/components/streaming-text.md +82 -0
- package/docs/design-system/components/table.md +419 -414
- package/docs/design-system/components/tabs.md +59 -55
- package/docs/design-system/components/tag.md +80 -80
- package/docs/design-system/components/task-list.md +95 -0
- package/docs/design-system/components/textarea.md +50 -50
- package/docs/design-system/components/thinking-reasoning.md +72 -0
- package/docs/design-system/components/thinking-state.md +67 -0
- package/docs/design-system/components/timeline.md +83 -83
- package/docs/design-system/components/toast.md +58 -58
- package/docs/design-system/components/toggle.md +81 -81
- package/docs/design-system/components/toolbar.md +92 -87
- package/docs/design-system/components/tooltip.md +94 -92
- package/docs/design-system/components/typography.md +18 -18
- package/docs/design-system/density-and-elevation.md +65 -65
- package/docs/design-system/design-system-test-missing-items.md +368 -368
- package/docs/design-system/direction-and-localization.md +23 -23
- package/docs/design-system/form-recipes.md +73 -73
- package/docs/design-system/gap-promotion-policy.md +21 -21
- package/docs/design-system/icons.md +74 -74
- package/docs/design-system/installation.md +70 -68
- package/docs/design-system/launch-comparison-shadcn.md +98 -98
- package/docs/design-system/registry-and-cli.md +64 -64
- package/docs/design-system/release-policy.md +30 -30
- package/docs/design-system/roadmap.md +14 -14
- package/docs/design-system/support-matrix.md +23 -23
- package/docs/design-system/tabs.md +54 -53
- package/docs/design-system/template-gap-audit.md +32 -0
- package/docs/design-system/templates.md +59 -0
- package/docs/design-system/token-governance.md +72 -59
- package/package.json +98 -98
- package/registry/component-contract.json +40 -40
- package/registry/components/accordion.tsx +155 -155
- package/registry/components/agent-input-parts.tsx +552 -0
- package/registry/components/agent-input-state.ts +156 -0
- package/registry/components/agent-input.css +307 -0
- package/registry/components/agent-input.tsx +382 -0
- package/registry/components/alert-dialog.tsx +297 -297
- package/registry/components/alert.tsx +293 -293
- package/registry/components/approval-card.css +409 -0
- package/registry/components/approval-card.tsx +401 -0
- package/registry/components/approval-frame.tsx +108 -0
- package/registry/components/approval-questions.tsx +559 -0
- package/registry/components/aspect-ratio.tsx +30 -30
- package/registry/components/attribute.tsx +454 -454
- package/registry/components/autocomplete.tsx +598 -651
- package/registry/components/avatar.tsx +385 -385
- package/registry/components/badge.tsx +629 -629
- package/registry/components/button-group.tsx +565 -565
- package/registry/components/button.tsx +127 -123
- package/registry/components/card.tsx +638 -638
- package/registry/components/carousel.tsx +296 -296
- package/registry/components/chart-block-data.ts +177 -0
- package/registry/components/chart-block.css +171 -0
- package/registry/components/chart-block.tsx +260 -0
- package/registry/components/chart-interactions.ts +123 -0
- package/registry/components/chart-options.cartesian.ts +162 -0
- package/registry/components/chart-options.paint.ts +99 -0
- package/registry/components/chart-options.parts.ts +72 -0
- package/registry/components/chart-options.radial.ts +109 -0
- package/registry/components/chart-options.ts +44 -0
- package/registry/components/chart-options.types.ts +67 -0
- package/registry/components/chart.tsx +203 -203
- package/registry/components/checkbox.tsx +247 -247
- package/registry/components/collapsible.tsx +109 -69
- package/registry/components/command.tsx +208 -208
- package/registry/components/context-menu.tsx +236 -236
- package/registry/components/data-chart.tsx +40 -0
- package/registry/components/date-picker.tsx +1289 -1289
- package/registry/components/echart-theme.ts +246 -0
- package/registry/components/echart.css +49 -0
- package/registry/components/echart.tsx +178 -0
- package/registry/components/expanded/ActivityFeed.tsx +227 -227
- package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
- package/registry/components/expanded/Banner.tsx +179 -179
- package/registry/components/expanded/Breadcrumbs.tsx +131 -131
- package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
- package/registry/components/expanded/CatalogDivider.tsx +49 -49
- package/registry/components/expanded/EmptyState.tsx +155 -155
- package/registry/components/expanded/FileUpload.tsx +543 -543
- package/registry/components/expanded/OnboardingStepListItem.tsx +60 -60
- package/registry/components/expanded/PageHeader.tsx +179 -166
- package/registry/components/expanded/Slideout.tsx +108 -108
- package/registry/components/expanded/Steps.tsx +402 -402
- package/registry/components/expanded/Table.tsx +1092 -1071
- package/registry/components/expanded/Timeline.tsx +203 -203
- package/registry/components/expanded/activityFeed.css +248 -248
- package/registry/components/expanded/activityHeatmap.css +66 -66
- package/registry/components/expanded/banner.css +260 -260
- package/registry/components/expanded/breadcrumbs.css +140 -140
- package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
- package/registry/components/expanded/divider.css +75 -75
- package/registry/components/expanded/emptyState.css +111 -111
- package/registry/components/expanded/fileUpload.css +457 -457
- package/registry/components/expanded/onboardingStepListItem.css +135 -135
- package/registry/components/expanded/pageHeader.css +248 -290
- package/registry/components/expanded/slideout.css +276 -276
- package/registry/components/expanded/steps.css +356 -356
- package/registry/components/expanded/table.css +606 -606
- package/registry/components/expanded/timeline.css +286 -286
- package/registry/components/field.tsx +283 -283
- package/registry/components/hover-card.tsx +48 -48
- package/registry/components/inline-citations.css +126 -0
- package/registry/components/inline-citations.tsx +292 -0
- package/registry/components/input-content.ts +107 -107
- package/registry/components/input-group.tsx +130 -130
- package/registry/components/input-otp.tsx +584 -584
- package/registry/components/input.tsx +990 -979
- package/registry/components/item.tsx +189 -189
- package/registry/components/kbd.tsx +246 -245
- package/registry/components/label.tsx +78 -78
- package/registry/components/menu.tsx +978 -986
- package/registry/components/menubar.tsx +226 -226
- package/registry/components/modal.tsx +872 -872
- package/registry/components/native-select.tsx +205 -205
- package/registry/components/navigation-menu.tsx +171 -171
- package/registry/components/pagination.css +36 -36
- package/registry/components/pagination.tsx +560 -560
- package/registry/components/popover.tsx +338 -338
- package/registry/components/progress-slider.tsx +216 -216
- package/registry/components/radio-card.tsx +676 -676
- package/registry/components/radio-group.tsx +570 -570
- package/registry/components/resizable.tsx +108 -108
- package/registry/components/rolling-count.css +37 -0
- package/registry/components/rolling-count.tsx +110 -0
- package/registry/components/scroll-area.tsx +91 -91
- package/registry/components/segmented-control.tsx +87 -87
- package/registry/components/select-content.tsx +218 -218
- package/registry/components/select.tsx +937 -932
- package/registry/components/sheet.tsx +305 -305
- package/registry/components/shimmer-text.css +69 -0
- package/registry/components/shimmer-text.tsx +43 -0
- package/registry/components/sidebar.tsx +1433 -1230
- package/registry/components/skeleton.css +54 -54
- package/registry/components/skeleton.tsx +482 -482
- package/registry/components/slider.tsx +380 -380
- package/registry/components/social-button.tsx +387 -387
- package/registry/components/spinner.tsx +78 -78
- package/registry/components/streaming-text.css +45 -0
- package/registry/components/streaming-text.tsx +165 -0
- package/registry/components/table-elements.tsx +128 -128
- package/registry/components/tabs.css +222 -218
- package/registry/components/tabs.tsx +241 -241
- package/registry/components/tag.tsx +423 -423
- package/registry/components/task-list.css +328 -0
- package/registry/components/task-list.tsx +429 -0
- package/registry/components/textarea.tsx +610 -610
- package/registry/components/thinking-reasoning.css +178 -0
- package/registry/components/thinking-reasoning.tsx +253 -0
- package/registry/components/thinking-state.css +8 -0
- package/registry/components/thinking-state.tsx +46 -0
- package/registry/components/toast.tsx +136 -136
- package/registry/components/toggle.tsx +256 -256
- package/registry/components/toolbar.tsx +539 -539
- package/registry/components/tooltip.tsx +433 -431
- package/registry/examples/agent-input-demo.tsx +90 -0
- package/registry/examples/alert-demo.tsx +34 -34
- package/registry/examples/alert-dialog-demo.tsx +42 -42
- package/registry/examples/approval-card-demo.tsx +83 -0
- package/registry/examples/aspect-ratio-demo.tsx +11 -11
- package/registry/examples/attribute-demo.tsx +65 -65
- package/registry/examples/autocomplete-demo.tsx +109 -109
- package/registry/examples/avatar-demo.tsx +132 -132
- package/registry/examples/badge-demo.tsx +69 -69
- package/registry/examples/button-demo.tsx +86 -83
- package/registry/examples/button-group-demo.tsx +63 -63
- package/registry/examples/card-demo.tsx +86 -86
- package/registry/examples/carousel-demo.tsx +43 -43
- package/registry/examples/chart-block-demo.tsx +5 -0
- package/registry/examples/chart-demo.tsx +18 -33
- package/registry/examples/checkbox-demo.tsx +62 -62
- package/registry/examples/collapsible-demo.tsx +16 -16
- package/registry/examples/collection-filter-bar-demo.tsx +135 -135
- package/registry/examples/command-demo.tsx +42 -42
- package/registry/examples/command-palette-demo.tsx +235 -235
- package/registry/examples/context-menu-demo.tsx +29 -29
- package/registry/examples/control-density-contract-demo.tsx +65 -61
- package/registry/examples/date-picker-demo.tsx +127 -127
- package/registry/examples/divider-demo.tsx +17 -17
- package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
- package/registry/examples/expanded/banner-demo.tsx +48 -48
- package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
- package/registry/examples/expanded/empty-state-demo.tsx +39 -39
- package/registry/examples/expanded/file-upload-demo.tsx +60 -60
- package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
- package/registry/examples/expanded/slideout-demo.tsx +15 -15
- package/registry/examples/expanded/steps-demo.tsx +29 -29
- package/registry/examples/expanded/tabs-demo.tsx +13 -13
- package/registry/examples/expanded/timeline-demo.tsx +18 -18
- package/registry/examples/field-demo.tsx +51 -51
- package/registry/examples/hover-card-demo.tsx +23 -23
- package/registry/examples/inline-citations-demo.tsx +31 -0
- package/registry/examples/input-actions-demo.tsx +36 -0
- package/registry/examples/input-demo.tsx +87 -87
- package/registry/examples/input-group-demo.tsx +16 -16
- package/registry/examples/input-otp-demo.tsx +72 -72
- package/registry/examples/item-demo.tsx +46 -46
- package/registry/examples/kbd-demo.tsx +12 -12
- package/registry/examples/label-demo.tsx +20 -20
- package/registry/examples/menu-demo.tsx +122 -122
- package/registry/examples/menubar-demo.tsx +34 -34
- package/registry/examples/modal-demo.tsx +74 -74
- package/registry/examples/native-select-demo.tsx +16 -16
- package/registry/examples/navigation-menu-demo.tsx +29 -29
- package/registry/examples/pagination-demo.tsx +20 -20
- package/registry/examples/popover-demo.tsx +547 -546
- package/registry/examples/progress-demo.tsx +37 -37
- package/registry/examples/progress-slider-demo.tsx +29 -29
- package/registry/examples/radio-card-demo.tsx +51 -51
- package/registry/examples/radio-group-demo.tsx +62 -62
- package/registry/examples/resizable-demo.tsx +22 -22
- package/registry/examples/results-toolbar-demo.tsx +44 -44
- package/registry/examples/rolling-count-demo.tsx +39 -0
- package/registry/examples/scroll-area-demo.tsx +15 -15
- package/registry/examples/segmented-control-demo.tsx +39 -39
- package/registry/examples/select-demo.tsx +87 -87
- package/registry/examples/sheet-demo.tsx +47 -47
- package/registry/examples/shimmer-text-demo.tsx +20 -0
- package/registry/examples/sidebar-demo.tsx +101 -101
- package/registry/examples/slider-demo.tsx +31 -31
- package/registry/examples/social-button-demo.tsx +61 -61
- package/registry/examples/spinner-demo.tsx +59 -59
- package/registry/examples/streaming-text-demo.tsx +28 -0
- package/registry/examples/tabs-demo.tsx +13 -13
- package/registry/examples/tag-demo.tsx +29 -29
- package/registry/examples/task-list-demo.tsx +74 -0
- package/registry/examples/textarea-demo.tsx +79 -79
- package/registry/examples/thinking-reasoning-demo.tsx +74 -0
- package/registry/examples/thinking-state-demo.tsx +13 -0
- package/registry/examples/toast-demo.tsx +35 -35
- package/registry/examples/toggle-demo.tsx +59 -59
- package/registry/examples/tooltip-demo.tsx +115 -115
- package/registry/guidance/USAGE.md +27 -27
- package/registry/guidance/charts.LICENSE.txt +24 -0
- package/registry/hooks/use-controllable-state.ts +33 -0
- package/registry/hooks/use-direction.ts +207 -207
- package/registry/hooks/use-listbox-navigation.ts +73 -0
- package/registry/hooks/use-motion-end.ts +55 -0
- package/registry/hooks/use-reduced-motion.ts +28 -0
- package/registry/index.json +1403 -155
- package/registry/styles/globals.css +946 -1088
- package/registry.json +361 -8
- package/schema/config.schema.json +51 -51
- package/schema/registry.schema.json +194 -190
- package/schema/tokens.schema.json +167 -163
- package/src/cli/command-line.js +194 -194
- package/src/cli/config-editors.js +9 -9
- package/src/cli/doctor.js +399 -399
- package/src/cli/file-transaction.js +302 -295
- package/src/cli/global-css.js +153 -153
- package/src/cli/index.js +1002 -1009
- package/src/cli/install-plan.js +592 -592
- package/src/cli/install-receipt.js +171 -171
- package/src/cli/jsonc.js +203 -203
- package/src/cli/package-install-state.js +254 -254
- package/src/cli/package-manager.js +238 -238
- package/src/cli/package-plan.js +185 -185
- package/src/cli/package-version.js +97 -97
- package/src/cli/registry-query.js +207 -207
- package/src/cli/reporting.js +55 -55
- package/src/cli/schema-validation.js +19 -19
- package/src/cli/source-scanner.js +686 -686
- package/src/cli/vite-config.js +478 -478
- package/tokens/banhaten.tokens.json +306 -192
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { ChartDatum, ChartSpec } from "./chart-options"
|
|
2
|
+
|
|
3
|
+
export type ChartBlockDefinition = {
|
|
4
|
+
title: string
|
|
5
|
+
description: string
|
|
6
|
+
spec: ChartSpec
|
|
7
|
+
periods?: { label: string; count: number }[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
11
|
+
const monthly = (keys: string[], values: number[][]): ChartDatum[] => values.map((row, index) => ({
|
|
12
|
+
name: months[index % 12] + (values.length > 12 ? ` '${24 + Math.floor(index / 12)}` : ""),
|
|
13
|
+
...Object.fromEntries(keys.map((key, column) => [key, row[column]])),
|
|
14
|
+
}))
|
|
15
|
+
const monthPeriods = [{ label: "3 months", count: 3 }, { label: "6 months", count: 6 }, { label: "All", count: Infinity }]
|
|
16
|
+
const series = (key: string, label: string, colorIndex: number) => ({ key, label, colorIndex })
|
|
17
|
+
|
|
18
|
+
// Illustrative source datasets, adapted from EvilCharts' MIT-licensed ECharts blocks.
|
|
19
|
+
// All visual decisions live in the shared token-aware option builders and block CSS.
|
|
20
|
+
export const chartBlockDefinitions = {
|
|
21
|
+
latency: {
|
|
22
|
+
title: "Latency Percentiles",
|
|
23
|
+
description: "Request latency by percentile, in milliseconds.",
|
|
24
|
+
periods: [{ label: "5 minutes", count: 5 }, { label: "15 minutes", count: 15 }, { label: "All", count: Infinity }],
|
|
25
|
+
spec: {
|
|
26
|
+
family: "area", curve: "linear", variant: "gradient", hoverHighlight: true,
|
|
27
|
+
series: [series("p99", "P99 (ms)", 3), series("p95", "P95 (ms)", 2), series("p75", "P75 (ms)", 4), series("p50", "P50 (ms)", 0)],
|
|
28
|
+
data: [
|
|
29
|
+
[188, 92, 44, 20], [196, 95, 46, 21], [181, 89, 43, 20], [192, 94, 47, 22],
|
|
30
|
+
[205, 99, 45, 21], [187, 91, 44, 20], [179, 88, 42, 19], [198, 96, 46, 21],
|
|
31
|
+
[210, 101, 48, 22], [194, 93, 45, 21], [202, 97, 47, 22], [215, 104, 49, 23],
|
|
32
|
+
[231, 112, 52, 23], [278, 131, 57, 24], [306, 142, 61, 25], [289, 135, 58, 24],
|
|
33
|
+
[247, 118, 53, 23], [216, 105, 49, 22], [201, 97, 46, 21], [193, 94, 45, 21],
|
|
34
|
+
[186, 90, 44, 20], [199, 96, 46, 21], [207, 100, 47, 22], [191, 92, 45, 20],
|
|
35
|
+
[184, 89, 43, 20], [196, 95, 46, 21], [209, 101, 48, 22], [198, 96, 46, 21], [204, 98, 46, 21],
|
|
36
|
+
].map(([p99, p95, p75, p50], index) => ({ name: `13:${String(index + 6).padStart(2, "0")}`, p99, p95, p75, p50 })),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
portfolio: {
|
|
40
|
+
title: "Portfolio Comparison", description: "Compare the change in two example portfolios, in USD.",
|
|
41
|
+
periods: [{ label: "7 days", count: 7 }, { label: "14 days", count: 14 }, { label: "All", count: Infinity }],
|
|
42
|
+
spec: {
|
|
43
|
+
family: "area", curve: "step", variant: "dotted", hoverReveal: true,
|
|
44
|
+
series: [series("portfolioA", "Portfolio A (USD)", 0), series("portfolioB", "Portfolio B (USD)", 1)],
|
|
45
|
+
data: [
|
|
46
|
+
[53916, 53670], [54380, 53080], [54760, 52460], [54480, 51880], [54060, 51340],
|
|
47
|
+
[53820, 50900], [53520, 50520], [53240, 50200], [53080, 49960], [52840, 49760],
|
|
48
|
+
[52700, 49640], [52520, 49600], [52420, 49720], [52340, 49980], [52240, 50360],
|
|
49
|
+
[52140, 50820], [52080, 51340], [52020, 51900], [51960, 52440], [51900, 52960],
|
|
50
|
+
[51860, 53420], [51800, 53780], [51740, 54040], [51700, 54200], [51640, 54300],
|
|
51
|
+
[51580, 54320], [51538, 54287],
|
|
52
|
+
].map(([portfolioA, portfolioB], index) => ({ name: index < 10 ? `Dec ${index + 22}` : `Jan ${index - 9}`, portfolioA, portfolioB })),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
benchmark: {
|
|
56
|
+
title: "Growth Benchmark", description: "Weekly signups compared with the target.",
|
|
57
|
+
periods: [{ label: "Recent 4", count: 4 }, { label: "Recent 12", count: 12 }, { label: "All", count: Infinity }],
|
|
58
|
+
spec: {
|
|
59
|
+
family: "area", curve: "step", legend: true,
|
|
60
|
+
series: [{ ...series("actual", "Actual", 0), variant: "hatched" }, { ...series("target", "Target", 4), type: "line", stroke: "dashed" }],
|
|
61
|
+
data: [
|
|
62
|
+
["Jan 1", 240, 125], ["Jan 8", 240, 125], ["Jan 15", 175, 200], ["Jan 22", 175, 200],
|
|
63
|
+
["Feb 1", 175, 200], ["Feb 8", 260, 125], ["Feb 15", 260, 125], ["Feb 22", 260, 125],
|
|
64
|
+
["Mar 1", 260, 190], ["Mar 8", 335, 190], ["Mar 15", 335, 190], ["Mar 22", 335, 390],
|
|
65
|
+
["Apr 1", 335, 390], ["Apr 8", 335, 390], ["Apr 15", 190, 390], ["Apr 22", 215, 305],
|
|
66
|
+
["May 1", 215, 305], ["May 8", 215, 175], ["May 15", 215, 175], ["May 22", 275, 175], ["Jun 1", 275, 245],
|
|
67
|
+
].map(([name, actual, target]) => ({ name, actual, target })),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
audience: {
|
|
71
|
+
title: "Audience Growth", description: "Monthly listeners across every show and episode.", periods: monthPeriods,
|
|
72
|
+
spec: { family: "area", curve: "monotone", variant: "gradient", series: [{ key: "listeners", label: "Listeners", colorIndices: [1, 4, 5] }],
|
|
73
|
+
data: monthly(["listeners"], [[2980], [3120], [3460], [3380], [3720], [4180], [4560]]) },
|
|
74
|
+
},
|
|
75
|
+
payouts: {
|
|
76
|
+
title: "Payout Trend", description: "Paid and pending amounts by month, in USD.", periods: monthPeriods,
|
|
77
|
+
spec: { family: "line", curve: "monotone", legend: true,
|
|
78
|
+
series: [{ key: "payouts", label: "Paid (USD)", colorIndices: [2, 7] }, { key: "pending", label: "Pending (USD)", colorIndices: [4, 5] }],
|
|
79
|
+
data: monthly(["payouts", "pending"], [[312, 548], [388, 502], [342, 561], [455, 470], [521, 398], [486, 441], [573, 372], [640, 316], [598, 358], [662, 284], [617, 331], [690, 262]]) },
|
|
80
|
+
},
|
|
81
|
+
shipments: {
|
|
82
|
+
title: "Shipments", description: "Orders shipped in corresponding slots this week and last week.",
|
|
83
|
+
periods: [{ label: "2 days", count: 8 }, { label: "4 days", count: 16 }, { label: "All", count: Infinity }],
|
|
84
|
+
spec: { family: "line", curve: "linear", legend: true,
|
|
85
|
+
series: [series("current", "This week", 0), { ...series("previous", "Last week", 4), stroke: "dashed" }],
|
|
86
|
+
data: [[14, 34], [9, 41], [18, 37], [26, 29], [31, 24], [27, 33], [19, 45], [12, 52], [16, 47], [22, 39], [15, 44], [11, 50], [17, 43], [24, 31], [20, 26], [13, 22], [21, 28], [29, 36], [34, 42], [28, 55], [23, 49], [30, 40], [36, 35], [32, 44]]
|
|
87
|
+
.map(([current, previous], index) => ({ name: `${["Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][Math.floor(index / 4)]} ${index % 4 + 1}`, current, previous })) },
|
|
88
|
+
},
|
|
89
|
+
peak: {
|
|
90
|
+
title: "Peak Week", description: "Organic and paid signups, with the best week highlighted.",
|
|
91
|
+
periods: [{ label: "4 weeks", count: 4 }, { label: "8 weeks", count: 8 }, { label: "All", count: Infinity }],
|
|
92
|
+
spec: { family: "bar", stack: "stacked", highlightMax: true, legend: true,
|
|
93
|
+
series: [series("organic", "Organic", 0), series("paid", "Paid", 4)],
|
|
94
|
+
data: [[128, 74], [164, 91], [142, 66], [199, 108], [176, 84], [231, 167], [208, 96], [287, 158], [244, 112], [196, 88], [221, 103], [173, 79]]
|
|
95
|
+
.map(([organic, paid], index) => ({ name: `W${String(index + 1).padStart(2, "0")}`, organic, paid })) },
|
|
96
|
+
},
|
|
97
|
+
grid: {
|
|
98
|
+
title: "Grid Bar Chart", description: "Hourly sessions shown as columns of cells.",
|
|
99
|
+
periods: [{ label: "6 hours", count: 6 }, { label: "12 hours", count: 12 }, { label: "All", count: Infinity }],
|
|
100
|
+
spec: { family: "bar", variant: "blocks", series: [series("sessions", "Sessions", 0)],
|
|
101
|
+
data: [42, 28, 19, 14, 12, 18, 34, 66, 98, 124, 147, 163, 158, 171, 186, 174, 152, 138, 119, 96, 84, 71, 58, 47]
|
|
102
|
+
.map((sessions, index) => ({ name: `${String(index).padStart(2, "0")}:00`, sessions })) },
|
|
103
|
+
},
|
|
104
|
+
monospace: {
|
|
105
|
+
title: "Monospace Bar Chart", description: "Monthly sales with bars that expand on hover.", periods: monthPeriods,
|
|
106
|
+
spec: { family: "bar", variant: "expandable", series: [series("sales", "Sales (USD)", 0)],
|
|
107
|
+
data: monthly(["sales"], [342, 876, 512, 629, 458, 781, 394, 925, 647, 532, 803, 271, 388, 912, 564, 671, 499, 838, 427, 968, 702, 585, 861, 314].map((value) => [value])) },
|
|
108
|
+
},
|
|
109
|
+
"market-share": {
|
|
110
|
+
title: "Market Share", description: "Illustrative ecosystem value by company, in billions of USD.",
|
|
111
|
+
spec: { family: "pie", variant: "padded", series: [series("value", "Value (USD billions)", 0)],
|
|
112
|
+
data: [{ name: "Skyline", value: 27 }, { name: "Datawell", value: 21 }, { name: "Cloudpeak", value: 13 }, { name: "Taskbridge", value: 21 }, { name: "Insightloop", value: 6 }, { name: "Streamforge", value: 12 }] },
|
|
113
|
+
},
|
|
114
|
+
"progress-rings": {
|
|
115
|
+
title: "Progress Rings", description: "Where the workday leaks: illustrative findings from 1,240 survey responses.",
|
|
116
|
+
spec: { family: "pie", series: [series("value", "Respondents (%)", 0)], data: [
|
|
117
|
+
{ name: "Additional support requests", value: 48 }, { name: "Inaccurate forecasts", value: 67 },
|
|
118
|
+
] },
|
|
119
|
+
},
|
|
120
|
+
"revenue-mix": {
|
|
121
|
+
title: "Revenue Mix", description: "Revenue by sales channel, in USD.",
|
|
122
|
+
spec: { family: "pie", variant: "padded", series: [series("value", "Revenue (USD)", 0)], data: [
|
|
123
|
+
{ name: "Direct", value: 52400 }, { name: "Marketplace", value: 38900 }, { name: "Wholesale", value: 24150 }, { name: "Affiliate", value: 16300 },
|
|
124
|
+
] },
|
|
125
|
+
},
|
|
126
|
+
"reliability-score": {
|
|
127
|
+
title: "Reliability Score", description: "Delivery reliability: 842 out of 1,000, in the excellent band.",
|
|
128
|
+
spec: { family: "pie", variant: "padded", data: [
|
|
129
|
+
{ name: "At risk", from: 0, value: 450 }, { name: "Fair", from: 450, value: 200 }, { name: "Good", from: 650, value: 170 }, { name: "Excellent", from: 820, value: 180 },
|
|
130
|
+
], series: [series("value", "Band width", 0)] },
|
|
131
|
+
},
|
|
132
|
+
budget: {
|
|
133
|
+
title: "Monthly Budget", description: "The share and amount allocated to each monthly budget category.",
|
|
134
|
+
spec: { family: "radial", max: 100, series: [series("value", "Share (%)", 0)], data: [
|
|
135
|
+
{ name: "Payroll", value: 46, amount: 920000 }, { name: "Infrastructure", value: 21, amount: 420000 }, { name: "Marketing", value: 14, amount: 280000 }, { name: "Tooling", value: 10, amount: 200000 }, { name: "Support", value: 9, amount: 180000 },
|
|
136
|
+
] },
|
|
137
|
+
},
|
|
138
|
+
ride: {
|
|
139
|
+
title: "Ride Summary", description: "Distance, elevation, energy and cadence against their goals.",
|
|
140
|
+
spec: { family: "radial", data: [
|
|
141
|
+
{ name: "Distance", value: 18.4, goal: 25, unit: "km" }, { name: "Elevation", value: 312, goal: 450, unit: "m" },
|
|
142
|
+
{ name: "Energy", value: 684, goal: 1200, unit: "kJ" }, { name: "Cadence", value: 82, goal: 95, unit: "rpm" },
|
|
143
|
+
] },
|
|
144
|
+
},
|
|
145
|
+
"cache-tiers": {
|
|
146
|
+
title: "Cache Tiers", description: "Request routing across four cache tiers, from a sample of 1,000 requests.",
|
|
147
|
+
spec: { family: "radial", variant: "semi", max: 100, series: [series("value", "Share (%)", 0)], data: [
|
|
148
|
+
{ name: "L1 Memory", value: 61, count: 610 }, { name: "L2 Regional", value: 24, count: 240 },
|
|
149
|
+
{ name: "Edge Overflow", value: 10, count: 100 }, { name: "Origin Fetch", value: 5, count: 50 },
|
|
150
|
+
] },
|
|
151
|
+
},
|
|
152
|
+
allocation: {
|
|
153
|
+
title: "Fund Allocation", description: "Where the fund flows, in thousands of USD.",
|
|
154
|
+
spec: { family: "sankey", variant: "gradient", labels: "inside", data: [],
|
|
155
|
+
nodes: ["Inflow", "Equities", "Bonds", "Cash", "Growth", "Income", "Reserve"].map((name) => ({ name })),
|
|
156
|
+
links: [[0, 1, 78], [0, 2, 46], [0, 3, 24], [1, 4, 52], [1, 5, 26], [2, 5, 19], [2, 6, 27], [3, 4, 9], [3, 6, 15]]
|
|
157
|
+
.map(([source, target, value]) => ({ source, target, value })) },
|
|
158
|
+
},
|
|
159
|
+
pipeline: {
|
|
160
|
+
title: "Revenue Pipeline", description: "Four revenue sources flow into four operating allocations, in USD.",
|
|
161
|
+
spec: { family: "sankey", variant: "gradient", labels: "outside", data: [],
|
|
162
|
+
nodes: ["Retail", "Wholesale", "Licensing", "Services", "Pipeline", "Expansion", "Tooling", "Support", "Reserve"].map((name) => ({ name })),
|
|
163
|
+
links: [[0, 4, 31480], [1, 4, 46220], [2, 4, 14960], [3, 4, 28340], [4, 5, 52640], [4, 6, 9180], [4, 7, 12470], [4, 8, 46710]]
|
|
164
|
+
.map(([source, target, value]) => ({ source, target, value })) },
|
|
165
|
+
},
|
|
166
|
+
} satisfies Record<string, ChartBlockDefinition>
|
|
167
|
+
|
|
168
|
+
export type ChartBlockId = keyof typeof chartBlockDefinitions
|
|
169
|
+
|
|
170
|
+
export const chartBlocks = (Object.entries(chartBlockDefinitions) as [ChartBlockId, ChartBlockDefinition][])
|
|
171
|
+
.map(([id, definition]) => ({ id, title: definition.title, description: definition.description, family: definition.spec.family }))
|
|
172
|
+
|
|
173
|
+
export function getChartBlockSpec(block: ChartBlockId, period = "All"): ChartSpec {
|
|
174
|
+
const definition: ChartBlockDefinition = chartBlockDefinitions[block]
|
|
175
|
+
const count = definition.periods?.find((entry) => entry.label === period)?.count ?? Infinity
|
|
176
|
+
return { ...definition.spec, data: definition.spec.data.slice(-count) }
|
|
177
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
.bh-chart-block {
|
|
2
|
+
container-type: inline-size;
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-inline-size: 0;
|
|
6
|
+
inline-size: 100%;
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
border: var(--bh-border-width-default) solid var(--bh-border-subtle);
|
|
9
|
+
border-radius: var(--bh-radius-3xl-16);
|
|
10
|
+
background: var(--bh-bg-raised);
|
|
11
|
+
color: var(--bh-content-default);
|
|
12
|
+
font-family: var(--bh-font-family);
|
|
13
|
+
font-size: var(--bh-text-body-xs-regular-font-size);
|
|
14
|
+
font-weight: var(--bh-text-body-xs-regular-font-weight);
|
|
15
|
+
line-height: var(--bh-text-body-xs-regular-line-height);
|
|
16
|
+
letter-spacing: var(--bh-text-body-xs-regular-letter-spacing);
|
|
17
|
+
text-align: start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.bh-chart-block-header {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
align-items: flex-start;
|
|
24
|
+
justify-content: space-between;
|
|
25
|
+
gap: var(--bh-space-3xl-16);
|
|
26
|
+
padding: var(--bh-space-5xl-24);
|
|
27
|
+
border-block-end: var(--bh-border-width-default) solid var(--bh-border-subtle);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.bh-chart-block-header > div:first-child { flex: 1 1 var(--bh-space-15xl-224); }
|
|
31
|
+
.bh-chart-block-header h3 {
|
|
32
|
+
margin: 0;
|
|
33
|
+
font-size: var(--bh-text-heading-xs-semibold-font-size);
|
|
34
|
+
font-weight: var(--bh-text-heading-xs-semibold-font-weight);
|
|
35
|
+
line-height: var(--bh-text-heading-xs-semibold-line-height);
|
|
36
|
+
letter-spacing: var(--bh-text-heading-xs-semibold-letter-spacing);
|
|
37
|
+
}
|
|
38
|
+
.bh-chart-block-header p { margin: var(--bh-space-xs-4) 0 0; color: var(--bh-content-subtle); }
|
|
39
|
+
.bh-chart-block-periods { display: flex; flex-wrap: wrap; gap: var(--bh-space-xxs-2); }
|
|
40
|
+
.bh-chart-block-body { display: flex; flex-direction: column; gap: var(--bh-space-5xl-24); padding: var(--bh-space-5xl-24); }
|
|
41
|
+
.bh-chart-block-chart { height: var(--bh-space-17xl-288); }
|
|
42
|
+
|
|
43
|
+
.bh-chart-block-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--bh-space-4xl-20); }
|
|
44
|
+
.bh-chart-block-stats[data-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
45
|
+
.bh-chart-block-stats[data-columns="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
46
|
+
.bh-chart-block-stat { display: flex; flex-direction: column; gap: var(--bh-space-sm-6); min-inline-size: 0; }
|
|
47
|
+
.bh-chart-block-stat-label { display: flex; align-items: center; gap: var(--bh-space-sm-6); color: var(--bh-content-subtle); }
|
|
48
|
+
.bh-chart-block-value {
|
|
49
|
+
display: flex;
|
|
50
|
+
flex-wrap: wrap;
|
|
51
|
+
align-items: baseline;
|
|
52
|
+
gap: var(--bh-space-xs-4);
|
|
53
|
+
font-size: var(--bh-text-heading-lg-semibold-font-size);
|
|
54
|
+
font-weight: var(--bh-text-heading-lg-semibold-font-weight);
|
|
55
|
+
line-height: var(--bh-text-heading-lg-semibold-line-height);
|
|
56
|
+
letter-spacing: var(--bh-text-heading-lg-semibold-letter-spacing);
|
|
57
|
+
font-variant-numeric: tabular-nums;
|
|
58
|
+
}
|
|
59
|
+
.bh-chart-block-value small {
|
|
60
|
+
color: var(--bh-content-subtle);
|
|
61
|
+
font-size: var(--bh-text-body-sm-regular-font-size);
|
|
62
|
+
font-weight: var(--bh-text-body-sm-regular-font-weight);
|
|
63
|
+
line-height: var(--bh-text-body-sm-regular-line-height);
|
|
64
|
+
}
|
|
65
|
+
.bh-chart-block-detail { color: var(--bh-content-subtle); }
|
|
66
|
+
.bh-chart-block-swatch { display: inline-block; inline-size: var(--bh-space-md-8); block-size: var(--bh-space-md-8); border-radius: var(--bh-radius-xs-2); flex: none; }
|
|
67
|
+
|
|
68
|
+
.bh-chart-block-stat-button, button.bh-chart-block-list-row {
|
|
69
|
+
margin: 0;
|
|
70
|
+
border: 0;
|
|
71
|
+
background: transparent;
|
|
72
|
+
color: inherit;
|
|
73
|
+
font: inherit;
|
|
74
|
+
letter-spacing: inherit;
|
|
75
|
+
text-align: inherit;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
outline: none;
|
|
78
|
+
}
|
|
79
|
+
.bh-chart-block-stat-button { padding: var(--bh-space-md-8); border-radius: var(--bh-radius-lg-8); }
|
|
80
|
+
.bh-chart-block-stat-button:hover, button.bh-chart-block-list-row:hover { background: var(--bh-interactive-ghost-hover); }
|
|
81
|
+
.bh-chart-block-stat-button[aria-pressed="true"] { background: var(--bh-bg-neutral-subtle); }
|
|
82
|
+
.bh-chart-block [data-muted="true"] { opacity: var(--bh-opacity-muted); }
|
|
83
|
+
.bh-chart-block-stat-button:focus-visible, button.bh-chart-block-list-row:focus-visible,
|
|
84
|
+
.bh-chart-block-data summary:focus-visible, .bh-chart-block-table-scroll:focus-visible, .bh-chart-block-flow-viewport:focus-visible {
|
|
85
|
+
outline: var(--bh-focus-ring-width) solid var(--bh-border-focus);
|
|
86
|
+
outline-offset: var(--bh-space-xxs-2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.bh-chart-block-list { display: grid; min-inline-size: 0; gap: var(--bh-space-xs-4); }
|
|
90
|
+
.bh-chart-block-list[data-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--bh-space-5xl-24); }
|
|
91
|
+
.bh-chart-block-list-row { display: flex; align-items: center; gap: var(--bh-space-md-8); min-inline-size: 0; padding: var(--bh-space-lg-10) var(--bh-space-md-8); border-radius: var(--bh-radius-md-6); }
|
|
92
|
+
.bh-chart-block-list-row > span:not(.bh-chart-block-swatch) { min-inline-size: 0; overflow-wrap: anywhere; color: var(--bh-content-subtle); }
|
|
93
|
+
.bh-chart-block-list-row strong { margin-inline-start: auto; font-weight: var(--bh-text-body-xs-medium-font-weight); font-variant-numeric: tabular-nums; text-align: end; }
|
|
94
|
+
.bh-chart-block[data-block="budget"] .bh-chart-block-list-row:nth-child(odd) { background: var(--bh-bg-neutral-subtle); }
|
|
95
|
+
.bh-chart-block-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: var(--bh-space-5xl-24); }
|
|
96
|
+
.bh-chart-block-donut-layout { display: flex; flex-direction: column; gap: var(--bh-space-3xl-16); }
|
|
97
|
+
.bh-chart-block-donut-layout > .bh-chart-block-list { padding-block-start: var(--bh-space-3xl-16); border-block-start: var(--bh-border-width-default) solid var(--bh-border-subtle); }
|
|
98
|
+
.bh-chart-block-donut { position: relative; min-inline-size: 0; }
|
|
99
|
+
.bh-chart-block-donut .bh-chart-block-chart { height: var(--bh-space-17xl-288); }
|
|
100
|
+
.bh-chart-block-ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--bh-space-xs-4); padding-inline: 23%; pointer-events: none; text-align: center; }
|
|
101
|
+
.bh-chart-block-ring-center strong {
|
|
102
|
+
font-size: var(--bh-text-heading-md-semibold-font-size);
|
|
103
|
+
font-weight: var(--bh-text-heading-md-semibold-font-weight);
|
|
104
|
+
line-height: var(--bh-text-heading-md-semibold-line-height);
|
|
105
|
+
letter-spacing: var(--bh-text-heading-md-semibold-letter-spacing);
|
|
106
|
+
font-variant-numeric: tabular-nums;
|
|
107
|
+
}
|
|
108
|
+
.bh-chart-block-ring-center span { color: var(--bh-content-subtle); font-size: var(--bh-text-body-2xs-regular-font-size); line-height: var(--bh-text-body-2xs-regular-line-height); }
|
|
109
|
+
.bh-chart-block-ring { position: relative; min-inline-size: 0; inline-size: 100%; aspect-ratio: 1; }
|
|
110
|
+
.bh-chart-block-ring > .bh-echart { height: 100%; }
|
|
111
|
+
.bh-chart-block-research-rings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--bh-space-5xl-24); }
|
|
112
|
+
.bh-chart-block-research-rings > div { inline-size: 100%; max-inline-size: var(--bh-space-17xl-288); justify-self: center; }
|
|
113
|
+
.bh-chart-block-research-rings .bh-chart-block-ring-center strong { font-size: var(--bh-text-heading-lg-semibold-font-size); line-height: var(--bh-text-heading-lg-semibold-line-height); }
|
|
114
|
+
.bh-chart-block-budget-rings { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--bh-space-md-8); }
|
|
115
|
+
.bh-chart-block-budget-rings > div { display: flex; flex-direction: column; align-items: center; min-inline-size: 0; gap: var(--bh-space-xs-4); text-align: center; }
|
|
116
|
+
.bh-chart-block-budget-rings .bh-chart-block-ring { max-inline-size: var(--bh-space-11xl-96); }
|
|
117
|
+
.bh-chart-block-budget-rings .bh-chart-block-ring-center { padding: 0; }
|
|
118
|
+
.bh-chart-block-budget-rings strong { font-size: var(--bh-text-body-sm-medium-font-size); line-height: var(--bh-text-body-sm-medium-line-height); }
|
|
119
|
+
.bh-chart-block-budget-rings > div > span { color: var(--bh-content-subtle); overflow-wrap: anywhere; font-size: var(--bh-text-body-2xs-regular-font-size); line-height: var(--bh-text-body-2xs-regular-line-height); }
|
|
120
|
+
.bh-chart-block-score { position: relative; inline-size: 100%; max-inline-size: var(--bh-space-17xl-288); align-self: center; }
|
|
121
|
+
.bh-chart-block-score-caption { margin: calc(-1 * var(--bh-space-6xl-32)) 0 0; text-align: center; }
|
|
122
|
+
.bh-chart-block-range { display: flex; gap: var(--bh-space-xs-4); color: var(--bh-content-subtle); font-size: var(--bh-text-body-2xs-regular-font-size); line-height: var(--bh-text-body-2xs-regular-line-height); }
|
|
123
|
+
.bh-chart-block-range > div { display: flex; flex-direction: column; gap: var(--bh-space-xs-4); min-inline-size: 0; flex-basis: 0; }
|
|
124
|
+
.bh-chart-block-range i { block-size: var(--bh-space-sm-6); border-radius: var(--bh-radius-full); }
|
|
125
|
+
.bh-chart-block-range > div > span:last-child { overflow-wrap: anywhere; }
|
|
126
|
+
|
|
127
|
+
.bh-chart-block-ride { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); gap: var(--bh-space-5xl-24); }
|
|
128
|
+
.bh-chart-block-ride-main { display: flex; flex-direction: column; gap: var(--bh-space-3xl-16); }
|
|
129
|
+
.bh-chart-block-ride-distance { display: flex; align-items: center; gap: var(--bh-space-3xl-16); }
|
|
130
|
+
.bh-chart-block-ride-distance > svg { inline-size: var(--bh-space-6xl-32); block-size: var(--bh-space-6xl-32); color: var(--bh-content-brand-default); flex: none; }
|
|
131
|
+
.bh-chart-block-metric-rings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--bh-space-md-8); }
|
|
132
|
+
.bh-chart-block-metric-rings > div { display: flex; flex-direction: column; align-items: center; gap: var(--bh-space-xs-4); text-align: center; }
|
|
133
|
+
.bh-chart-block-metric-rings .bh-chart-block-ring { max-inline-size: var(--bh-space-12xl-128); }
|
|
134
|
+
.bh-chart-block-metric-rings svg { inline-size: var(--bh-space-5xl-24); block-size: var(--bh-space-5xl-24); stroke-width: var(--bh-icon-stroke-200); }
|
|
135
|
+
.bh-chart-block-metric-rings strong { font-weight: var(--bh-text-body-xs-medium-font-weight); }
|
|
136
|
+
.bh-chart-block-metric-rings > div > span, .bh-chart-block-metric-rings small { color: var(--bh-content-subtle); font-size: var(--bh-text-body-2xs-regular-font-size); line-height: var(--bh-text-body-2xs-regular-line-height); }
|
|
137
|
+
.bh-chart-block-ride-splits { display: flex; flex-direction: column; justify-content: space-around; gap: var(--bh-space-3xl-16); border-inline-start: var(--bh-border-width-default) solid var(--bh-border-subtle); padding-inline-start: var(--bh-space-5xl-24); }
|
|
138
|
+
.bh-chart-block-flow-viewport { min-inline-size: 0; overflow-x: auto; }
|
|
139
|
+
.bh-chart-block-flow-canvas { min-inline-size: calc(var(--bh-space-19xl-384) + var(--bh-space-9xl-64)); }
|
|
140
|
+
.bh-chart-block-pipeline { position: relative; }
|
|
141
|
+
.bh-chart-block-flow-total { position: absolute; inset-block-start: 50%; inset-inline-start: 50%; transform: translate(-50%, -50%); padding: var(--bh-space-md-8); border-radius: var(--bh-radius-lg-8); background: var(--bh-bg-raised); text-align: center; pointer-events: none; }
|
|
142
|
+
.bh-chart-block:dir(rtl) .bh-chart-block-flow-total { transform: translate(50%, -50%); }
|
|
143
|
+
.bh-chart-block-flow-total .bh-chart-block-stat { align-items: center; }
|
|
144
|
+
.bh-chart-block[data-block="grid"] .bh-chart-block-stats, .bh-chart-block[data-block="monospace"] .bh-chart-block-stats { padding-block-end: var(--bh-space-4xl-20); border-block-end: var(--bh-border-width-default) dashed var(--bh-border-default); font-family: var(--bh-font-family-mono); }
|
|
145
|
+
|
|
146
|
+
.bh-chart-block-footer { padding: var(--bh-space-3xl-16) var(--bh-space-5xl-24); border-block-start: var(--bh-border-width-default) solid var(--bh-border-subtle); color: var(--bh-content-subtle); }
|
|
147
|
+
.bh-chart-block-footer > span { display: block; margin-block-end: var(--bh-space-md-8); font-size: var(--bh-text-body-2xs-regular-font-size); line-height: var(--bh-text-body-2xs-regular-line-height); }
|
|
148
|
+
.bh-chart-block-data summary { inline-size: fit-content; cursor: pointer; border-radius: var(--bh-radius-xs-2); color: var(--bh-content-default); }
|
|
149
|
+
.bh-chart-block-table-scroll { max-block-size: var(--bh-space-17xl-288); overflow: auto; margin-block-start: var(--bh-space-3xl-16); }
|
|
150
|
+
.bh-chart-block-data table { inline-size: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
|
151
|
+
.bh-chart-block-data caption { padding-block-end: var(--bh-space-xl-12); text-align: start; }
|
|
152
|
+
.bh-chart-block-data th, .bh-chart-block-data td { padding: var(--bh-space-md-8) var(--bh-space-xl-12); border-block-end: var(--bh-border-width-default) solid var(--bh-border-subtle); text-align: start; white-space: nowrap; }
|
|
153
|
+
.bh-chart-block-data th { font-weight: var(--bh-text-body-xs-medium-font-weight); color: var(--bh-content-default); }
|
|
154
|
+
.bh-chart-block-data thead { background: var(--bh-bg-neutral-subtle); }
|
|
155
|
+
|
|
156
|
+
@container (max-width: 560px) {
|
|
157
|
+
.bh-chart-block-header, .bh-chart-block-body { padding: var(--bh-space-3xl-16); }
|
|
158
|
+
.bh-chart-block-stats[data-columns="4"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
159
|
+
.bh-chart-block-split { grid-template-columns: minmax(0, 1fr); gap: var(--bh-space-3xl-16); }
|
|
160
|
+
.bh-chart-block-split .bh-chart-block-donut { max-inline-size: var(--bh-space-17xl-288); inline-size: 100%; justify-self: center; }
|
|
161
|
+
.bh-chart-block-list[data-columns="2"] { grid-template-columns: minmax(0, 1fr); }
|
|
162
|
+
.bh-chart-block-value { font-size: var(--bh-text-heading-md-semibold-font-size); line-height: var(--bh-text-heading-md-semibold-line-height); }
|
|
163
|
+
.bh-chart-block-ride { grid-template-columns: minmax(0, 1fr); }
|
|
164
|
+
.bh-chart-block-ride-splits { flex-direction: row; justify-content: flex-start; border-inline-start: 0; border-block-start: var(--bh-border-width-default) solid var(--bh-border-subtle); padding-inline-start: 0; padding-block-start: var(--bh-space-3xl-16); }
|
|
165
|
+
.bh-chart-block-ride-splits > div { flex: 1; }
|
|
166
|
+
.bh-chart-block-budget-rings { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: var(--bh-space-3xl-16); }
|
|
167
|
+
.bh-chart-block-research-rings { gap: var(--bh-space-md-8); }
|
|
168
|
+
.bh-chart-block-research-rings .bh-chart-block-ring-center span { font-size: var(--bh-text-body-3xs-regular-font-size); line-height: var(--bh-text-body-3xs-regular-line-height); }
|
|
169
|
+
.bh-chart-block[data-block="cache-tiers"] .bh-chart-block-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
170
|
+
.bh-chart-block-footer { padding: var(--bh-space-3xl-16); }
|
|
171
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import type { EChartsOption, PieSeriesOption } from "echarts"
|
|
5
|
+
import { Bike, Gauge, Mountain, Zap } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
import { Button } from "./button"
|
|
9
|
+
import { Progress } from "./progress"
|
|
10
|
+
import { EChart, type EChartTheme } from "./echart"
|
|
11
|
+
import { createChartOption, type ChartDatum, type ChartSpec } from "./chart-options"
|
|
12
|
+
import { attachChartInteractions } from "./chart-interactions"
|
|
13
|
+
import { chartBlockDefinitions, getChartBlockSpec, type ChartBlockDefinition, type ChartBlockId } from "./chart-block-data"
|
|
14
|
+
import "./chart-block.css"
|
|
15
|
+
|
|
16
|
+
export { chartBlocks, type ChartBlockId } from "./chart-block-data"
|
|
17
|
+
export type ChartBlockProps = React.ComponentProps<"article"> & { block: ChartBlockId }
|
|
18
|
+
|
|
19
|
+
const number = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 1 })
|
|
20
|
+
const money = (value: number) => `$${number(value)}`
|
|
21
|
+
const numeric = (row: ChartDatum, key: string) => Number(row[key] ?? 0)
|
|
22
|
+
const total = (rows: ChartDatum[], key: string) => rows.reduce((sum, row) => sum + numeric(row, key), 0)
|
|
23
|
+
const delta = (current: number, previous: number) => previous ? (current - previous) / previous * 100 : 0
|
|
24
|
+
const signed = (value: number) => `${value >= 0 ? "+" : ""}${number(value)}%`
|
|
25
|
+
const colorTokens = [
|
|
26
|
+
"--bh-chart-categorical-brand", "--bh-chart-categorical-success", "--bh-chart-categorical-warning",
|
|
27
|
+
"--bh-chart-categorical-danger", "--bh-chart-categorical-information", "--bh-chart-categorical-purple",
|
|
28
|
+
"--bh-chart-categorical-aqua", "--bh-chart-categorical-fuchsia",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function Swatch({ index }: { index: number }) {
|
|
32
|
+
return <span className="bh-chart-block-swatch" aria-hidden="true" style={{ backgroundColor: `var(${colorTokens[index % colorTokens.length]})` }} />
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Stat({ label, value, detail, colorIndex }: { label: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; colorIndex?: number }) {
|
|
36
|
+
return <div className="bh-chart-block-stat">
|
|
37
|
+
<span className="bh-chart-block-stat-label">{colorIndex !== undefined && <Swatch index={colorIndex} />}{label}</span>
|
|
38
|
+
<strong className="bh-chart-block-value">{value}</strong>
|
|
39
|
+
{detail && <span className="bh-chart-block-detail">{detail}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function BlockChart({ block, spec, selected, onSelect, className }: { block: ChartBlockId; spec: ChartSpec; selected?: string | null; onSelect?: (key: string) => void; className?: string }) {
|
|
44
|
+
const option = React.useCallback((theme: EChartTheme): EChartsOption => {
|
|
45
|
+
const visible = block === "latency" && selected ? { ...spec, series: spec.series?.filter((item) => item.key === selected) } : spec
|
|
46
|
+
const result = createChartOption(theme, { ...visible, legend: visible.legend ?? false })
|
|
47
|
+
if (block === "market-share") {
|
|
48
|
+
const pie = (result.series as PieSeriesOption[])[0]
|
|
49
|
+
result.series = [{ ...pie, selectedMode: "single", data: spec.data.map((row, index) => ({
|
|
50
|
+
name: String(row.name), value: numeric(row, "value"), selected: row.name === selected,
|
|
51
|
+
itemStyle: { color: theme.palette[index % theme.palette.length], opacity: !selected || row.name === selected ? 1 : theme.opacity.muted },
|
|
52
|
+
})) }]
|
|
53
|
+
}
|
|
54
|
+
return result
|
|
55
|
+
}, [block, selected, spec])
|
|
56
|
+
const onReady = React.useCallback<NonNullable<React.ComponentProps<typeof EChart>["onReady"]>>((chart, theme) => {
|
|
57
|
+
const cleanup = attachChartInteractions(chart, theme, spec)
|
|
58
|
+
const click = (event: { name?: string; seriesId?: string }) => {
|
|
59
|
+
const key = block === "latency" ? event.seriesId : event.name
|
|
60
|
+
if (key) onSelect?.(key)
|
|
61
|
+
}
|
|
62
|
+
if (onSelect) chart.on("click", click)
|
|
63
|
+
return () => { cleanup(); if (onSelect) chart.off("click", click) }
|
|
64
|
+
}, [block, onSelect, spec])
|
|
65
|
+
return <EChart className={cn("bh-chart-block-chart", className)} label={chartBlockDefinitions[block].title} option={option} onReady={onReady} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function TimeSeriesBlock({ block, spec, selected, onSelect }: {
|
|
69
|
+
block: ChartBlockId; spec: ChartSpec; selected: string | null; onSelect: (key: string) => void
|
|
70
|
+
}) {
|
|
71
|
+
const data = spec.data
|
|
72
|
+
const latest = data.at(-1)!
|
|
73
|
+
let statistics: React.ReactNode
|
|
74
|
+
if (block === "latency") {
|
|
75
|
+
statistics = <div className="bh-chart-block-stats" data-columns="4">{spec.series?.map((item) => <button
|
|
76
|
+
key={item.key} className="bh-chart-block-stat-button" type="button" aria-pressed={selected === item.key}
|
|
77
|
+
data-muted={selected !== null && selected !== item.key} onClick={() => onSelect(item.key)}
|
|
78
|
+
><Stat label={item.label?.replace(" (ms)", "")} value={<>{number(numeric(latest, item.key))}<small>ms</small></>} colorIndex={item.colorIndex} detail="Latest sample" /></button>)}</div>
|
|
79
|
+
} else if (block === "portfolio") {
|
|
80
|
+
statistics = <div className="bh-chart-block-stats">{spec.series?.map((item) => {
|
|
81
|
+
const first = numeric(data[0], item.key)
|
|
82
|
+
const last = numeric(latest, item.key)
|
|
83
|
+
return <Stat key={item.key} label={item.label?.replace(" (USD)", "")} colorIndex={item.colorIndex}
|
|
84
|
+
value={signed(delta(last, first))} detail={`${last >= first ? "+" : "−"}${money(Math.abs(last - first))} over selected period`} />
|
|
85
|
+
})}</div>
|
|
86
|
+
} else if (block === "benchmark") {
|
|
87
|
+
statistics = <Stat label="Latest weekly signups" value={number(numeric(latest, "actual"))}
|
|
88
|
+
detail={`${signed(delta(numeric(latest, "actual"), numeric(latest, "target")))} vs target of ${number(numeric(latest, "target"))}`} />
|
|
89
|
+
} else if (block === "audience") {
|
|
90
|
+
statistics = <div className="bh-chart-block-stats"><Stat label="Total listener-months" value={number(total(data, "listeners"))} detail="Sum across the selected months" />
|
|
91
|
+
<Stat label="Latest month" value={number(numeric(latest, "listeners"))} detail={`${signed(delta(numeric(latest, "listeners"), numeric(data[0], "listeners")))} over selected period`} /></div>
|
|
92
|
+
} else if (block === "payouts") {
|
|
93
|
+
statistics = <div className="bh-chart-block-stats"><Stat label="Paid" colorIndex={2} value={money(total(data, "payouts"))} detail="Selected period" />
|
|
94
|
+
<Stat label="Pending" colorIndex={4} value={money(total(data, "pending"))} detail="Selected period" /></div>
|
|
95
|
+
} else if (block === "shipments") {
|
|
96
|
+
statistics = <Stat label="Orders shipped" value={number(total(data, "current"))}
|
|
97
|
+
detail={`${signed(delta(total(data, "current"), total(data, "previous")))} vs the same slots last week`} />
|
|
98
|
+
} else if (block === "peak") {
|
|
99
|
+
const peak = data.reduce((best, row) => numeric(row, "organic") + numeric(row, "paid") > numeric(best, "organic") + numeric(best, "paid") ? row : best)
|
|
100
|
+
statistics = <Stat label="Best week" value={number(numeric(peak, "organic") + numeric(peak, "paid"))} detail={`Signups in ${peak.name}`} />
|
|
101
|
+
} else {
|
|
102
|
+
const key = block === "grid" ? "sessions" : "sales"
|
|
103
|
+
const peak = data.reduce((best, row) => numeric(row, key) > numeric(best, key) ? row : best)
|
|
104
|
+
statistics = <div className="bh-chart-block-stats"><Stat label={block === "grid" ? "Total sessions" : "Total sales"}
|
|
105
|
+
value={block === "grid" ? number(total(data, key)) : money(total(data, key))} />
|
|
106
|
+
<Stat label={block === "grid" ? "Peak hour" : "Top month"} value={String(peak.name)} detail={`${number(numeric(peak, key))} ${key}`} /></div>
|
|
107
|
+
}
|
|
108
|
+
const chart = <BlockChart block={block} spec={spec} selected={selected} onSelect={block === "latency" ? onSelect : undefined} />
|
|
109
|
+
return <>{block === "payouts" ? <>{chart}{statistics}</> : <>{statistics}{chart}</>}</>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ValueList({ data, format, columns = false, selected, onSelect }: {
|
|
113
|
+
data: ChartDatum[]; format: (row: ChartDatum) => string; columns?: boolean; selected?: string | null; onSelect?: (key: string) => void
|
|
114
|
+
}) {
|
|
115
|
+
return <div className="bh-chart-block-list" data-columns={columns ? "2" : undefined}>{data.map((row, index) => {
|
|
116
|
+
const content = <><Swatch index={index} /><span>{String(row.name)}</span><strong>{format(row)}</strong></>
|
|
117
|
+
return onSelect ? <button key={String(row.name)} className="bh-chart-block-list-row" type="button" aria-pressed={selected === row.name}
|
|
118
|
+
data-muted={Boolean(selected && selected !== row.name)} onClick={() => onSelect(String(row.name))}>{content}</button>
|
|
119
|
+
: <div key={String(row.name)} className="bh-chart-block-list-row">{content}</div>
|
|
120
|
+
})}</div>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Ring({ label, value, max = 100, index, dotted = false, children }: {
|
|
124
|
+
label: string; value: number; max?: number; index: number; dotted?: boolean; children?: React.ReactNode
|
|
125
|
+
}) {
|
|
126
|
+
const option = React.useCallback((theme: EChartTheme): EChartsOption => {
|
|
127
|
+
const result = createChartOption(theme, { family: "radial", data: [{ name: label, value }], max, legend: false, series: [{ key: "value", colorIndex: index }] })
|
|
128
|
+
if (!dotted) return { ...result, polar: (Array.isArray(result.polar) ? result.polar : [result.polar]).map((polar) => ({ ...polar, radius: ["65%", "87%"], center: ["50%", "50%"] })) }
|
|
129
|
+
const filled = Math.round(value / max * 40)
|
|
130
|
+
return { ...result, tooltip: { show: false }, series: [{
|
|
131
|
+
type: "pie", radius: ["83%", "91%"], center: ["50%", "50%"], silent: true, animation: false,
|
|
132
|
+
label: { show: false }, labelLine: { show: false }, emphasis: { disabled: true },
|
|
133
|
+
data: Array.from({ length: 80 }, (_, position) => ({ value: 1, itemStyle: {
|
|
134
|
+
color: position % 2 ? "transparent" : position / 2 < filled ? theme.palette[index] : theme.neutral,
|
|
135
|
+
borderWidth: 0, borderRadius: theme.markRadius, shadowBlur: 0,
|
|
136
|
+
} })),
|
|
137
|
+
}] }
|
|
138
|
+
}, [dotted, index, label, max, value])
|
|
139
|
+
return <div className="bh-chart-block-ring"><EChart option={option} label={`${label}: ${number(value)} of ${number(max)}`} />
|
|
140
|
+
<div className="bh-chart-block-ring-center">{children ?? <strong>{number(value)}%</strong>}</div></div>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function PieBlock({ block, spec, selected, onSelect }: { block: ChartBlockId; spec: ChartSpec; selected: string | null; onSelect: (key: string) => void }) {
|
|
144
|
+
if (block === "progress-rings") return <div className="bh-chart-block-research-rings">{spec.data.map((row, index) => <div key={String(row.name)}>
|
|
145
|
+
<Ring label={String(row.name)} value={numeric(row, "value")} index={index} dotted><strong>{row.value}%</strong><span>{row.name}</span></Ring>
|
|
146
|
+
</div>)}</div>
|
|
147
|
+
const revenue = block === "revenue-mix"
|
|
148
|
+
const amount = total(spec.data, "value")
|
|
149
|
+
return <div className={revenue ? "bh-chart-block-split" : "bh-chart-block-donut-layout"}>
|
|
150
|
+
<div className="bh-chart-block-donut"><BlockChart block={block} spec={spec} selected={selected} onSelect={revenue ? undefined : onSelect} />
|
|
151
|
+
<div className="bh-chart-block-ring-center"><strong>{revenue ? money(amount / 1000) + "k" : money(amount) + "B"}</strong><span>{revenue ? "Total revenue" : "Ecosystem value"}</span></div>
|
|
152
|
+
</div>
|
|
153
|
+
<ValueList data={spec.data} columns={!revenue} selected={selected} onSelect={revenue ? undefined : onSelect}
|
|
154
|
+
format={(row) => revenue ? money(numeric(row, "value")) : `${money(numeric(row, "value"))}B · ${number(numeric(row, "value") / amount * 100)}%`} />
|
|
155
|
+
</div>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ReliabilityBlock({ spec }: { spec: ChartSpec }) {
|
|
159
|
+
const bandColors = [3, 2, 4, 1]
|
|
160
|
+
const option = React.useCallback((theme: EChartTheme): EChartsOption => ({
|
|
161
|
+
...createChartOption(theme, { ...spec, legend: false }), tooltip: { show: false }, series: [{
|
|
162
|
+
type: "pie", radius: ["69%", "87%"], startAngle: 210, endAngle: -30, clockwise: true,
|
|
163
|
+
padAngle: 4, silent: true, label: { show: false }, labelLine: { show: false },
|
|
164
|
+
itemStyle: { borderRadius: theme.markRadius, shadowBlur: 0 },
|
|
165
|
+
data: spec.data.map((row, index) => ({ name: String(row.name), value: numeric(row, "value"), itemStyle: { color: theme.palette[bandColors[index]] } })),
|
|
166
|
+
}],
|
|
167
|
+
}), [spec])
|
|
168
|
+
return <><div className="bh-chart-block-score"><EChart option={option} label="Reliability score 842 of 1000, excellent" />
|
|
169
|
+
<div className="bh-chart-block-ring-center"><strong>842</strong><span>out of 1,000</span></div></div>
|
|
170
|
+
<p className="bh-chart-block-score-caption">Reliability is <strong>excellent</strong></p>
|
|
171
|
+
<div className="bh-chart-block-range">{spec.data.map((row, index) => <div key={String(row.name)} style={{ flexGrow: numeric(row, "value") }}>
|
|
172
|
+
<span>{row.from}</span><i style={{ backgroundColor: `var(${colorTokens[bandColors[index]]})` }} /><span>{row.name}</span>
|
|
173
|
+
</div>)}<span>1,000</span></div>
|
|
174
|
+
</>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function BudgetBlock({ spec }: { spec: ChartSpec }) {
|
|
178
|
+
return <><Stat label="Monthly allocation" value={money(total(spec.data, "amount"))} />
|
|
179
|
+
<div className="bh-chart-block-budget-rings">{spec.data.map((row, index) => <div key={String(row.name)}>
|
|
180
|
+
<Ring label={String(row.name)} value={numeric(row, "value")} index={index} /><span>{row.name}</span>
|
|
181
|
+
</div>)}</div><ValueList data={spec.data} format={(row) => `${row.value}% · ${money(numeric(row, "amount"))}`} /></>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function RideBlock({ spec }: { spec: ChartSpec }) {
|
|
185
|
+
const [distance, ...metrics] = spec.data
|
|
186
|
+
const icons = [Mountain, Zap, Gauge]
|
|
187
|
+
return <div className="bh-chart-block-ride"><div className="bh-chart-block-ride-main">
|
|
188
|
+
<div className="bh-chart-block-ride-distance"><Bike aria-hidden="true" /><Stat label="Distance ridden" value={<>{distance.value}<small>km</small></>} detail={`Goal: ${distance.goal} km`} /></div>
|
|
189
|
+
<Progress aria-label="Distance goal" value={numeric(distance, "value")} max={numeric(distance, "goal")} labelPosition="none" showIndicator={false} />
|
|
190
|
+
<div className="bh-chart-block-metric-rings">{metrics.map((row, index) => {
|
|
191
|
+
const Icon = icons[index]
|
|
192
|
+
return <div key={String(row.name)}><Ring label={String(row.name)} value={numeric(row, "value")} max={numeric(row, "goal")} index={index + 1}><Icon aria-hidden="true" /></Ring>
|
|
193
|
+
<strong>{row.value} <span>{row.unit}</span></strong><span>{row.name}</span><small>Goal {row.goal} {row.unit}</small></div>
|
|
194
|
+
})}</div>
|
|
195
|
+
</div><div className="bh-chart-block-ride-splits"><Stat label="Sprints" value="7" detail="efforts" /><Stat label="Recovery" value="46" detail="minutes" /></div></div>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function CacheBlock({ spec }: { spec: ChartSpec }) {
|
|
199
|
+
return <><div className="bh-chart-block-split"><BlockChart block="cache-tiers" spec={spec} />
|
|
200
|
+
<div className="bh-chart-block-stats" data-columns="2"><Stat label="Served warm" value="9,150" /><Stat label="Revalidated" value="1,280" /><Stat label="Evictions" value="412" /><Stat label="Purges" value="96" /></div>
|
|
201
|
+
</div><ValueList data={spec.data} columns format={(row) => `${number(numeric(row, "count"))}/1,000 · ${row.value}%`} /></>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function FlowBlock({ block, spec }: { block: "allocation" | "pipeline"; spec: ChartSpec }) {
|
|
205
|
+
const amount = spec.links!.filter((link) => block === "pipeline" ? link.target === 4 : link.source === 0).reduce((sum, link) => sum + link.value, 0)
|
|
206
|
+
return <><div className="bh-chart-block-flow-viewport" tabIndex={0} role="region" aria-label={`${chartBlockDefinitions[block].title} flow diagram`}>
|
|
207
|
+
<div className={cn("bh-chart-block-flow-canvas", block === "pipeline" && "bh-chart-block-pipeline")}>
|
|
208
|
+
<BlockChart block={block} spec={spec} />
|
|
209
|
+
{block === "pipeline" && <div className="bh-chart-block-flow-total"><Stat label="Total booked" value={money(amount)} detail="4 sources · 4 routes" /></div>}
|
|
210
|
+
</div></div>
|
|
211
|
+
{block === "allocation" && <div className="bh-chart-block-stats" data-columns="3"><Stat label="Total inflow" value={money(amount) + "k"} />
|
|
212
|
+
<Stat label="Asset classes" value="3" /><Stat label="Destinations" value="3" /></div>}
|
|
213
|
+
</>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function BlockDataTable({ block, spec, period }: { block: ChartBlockId; spec: ChartSpec; period: string }) {
|
|
217
|
+
const rows: ChartDatum[] = spec.family === "sankey" ? spec.links!.map((link) => ({
|
|
218
|
+
source: typeof link.source === "number" ? spec.nodes![link.source].name : link.source,
|
|
219
|
+
target: typeof link.target === "number" ? spec.nodes![link.target].name : link.target,
|
|
220
|
+
value: link.value,
|
|
221
|
+
})) : spec.data
|
|
222
|
+
const keys = Object.keys(rows[0] ?? {})
|
|
223
|
+
const labels: Record<string, string> = { name: "Category / time", value: "Value", goal: "Goal", unit: "Unit", from: "Band starts at", amount: "Amount (USD)", count: "Requests", source: "Source", target: "Target" }
|
|
224
|
+
spec.series?.forEach((item) => { labels[item.key] = item.label ?? item.key })
|
|
225
|
+
if (spec.family === "sankey") labels.value = block === "allocation" ? "Amount (USD thousands)" : "Amount (USD)"
|
|
226
|
+
return <details className="bh-chart-block-data"><summary>View data table</summary><div className="bh-chart-block-table-scroll" tabIndex={0} role="region" aria-label={`${chartBlockDefinitions[block].title} data`}>
|
|
227
|
+
<table><caption>{chartBlockDefinitions[block].title} · {period === "All" ? "All available samples" : period} · Illustrative data</caption>
|
|
228
|
+
<thead><tr>{keys.map((key) => <th key={key} scope="col">{labels[key] ?? key}</th>)}</tr></thead>
|
|
229
|
+
<tbody>{rows.map((row, index) => <tr key={index}>{keys.map((key, column) => column === 0 ? <th scope="row" key={key}>{row[key]}</th> : <td key={key}>{typeof row[key] === "number" ? number(row[key]) : row[key]}</td>)}</tr>)}</tbody>
|
|
230
|
+
</table>
|
|
231
|
+
</div></details>
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Ready-made examples: shared chart behavior, Banhaten tokens, and inspectable data. */
|
|
235
|
+
export function ChartBlock({ block, className, ...props }: ChartBlockProps) {
|
|
236
|
+
const definition: ChartBlockDefinition = chartBlockDefinitions[block]
|
|
237
|
+
const [period, setPeriod] = React.useState("All")
|
|
238
|
+
const [selection, setSelection] = React.useState<{ block: ChartBlockId; key: string } | null>(null)
|
|
239
|
+
const selected = selection?.block === block ? selection.key : null
|
|
240
|
+
const effectivePeriod = definition.periods?.some((entry) => entry.label === period) ? period : "All"
|
|
241
|
+
const spec = React.useMemo(() => getChartBlockSpec(block, effectivePeriod), [block, effectivePeriod])
|
|
242
|
+
const titleId = React.useId()
|
|
243
|
+
const select = (key: string) => setSelection(selected === key ? null : { block, key })
|
|
244
|
+
let content: React.ReactNode
|
|
245
|
+
if (definition.periods) content = <TimeSeriesBlock block={block} spec={spec} selected={selected} onSelect={select} />
|
|
246
|
+
else if (block === "reliability-score") content = <ReliabilityBlock spec={spec} />
|
|
247
|
+
else if (spec.family === "pie") content = <PieBlock block={block} spec={spec} selected={selected} onSelect={select} />
|
|
248
|
+
else if (block === "budget") content = <BudgetBlock spec={spec} />
|
|
249
|
+
else if (block === "ride") content = <RideBlock spec={spec} />
|
|
250
|
+
else if (block === "cache-tiers") content = <CacheBlock spec={spec} />
|
|
251
|
+
else if (block === "allocation" || block === "pipeline") content = <FlowBlock block={block} spec={spec} />
|
|
252
|
+
return <article className={cn("bh-chart-block", className)} data-slot="chart-block" data-block={block} aria-labelledby={titleId} {...props}>
|
|
253
|
+
<header className="bh-chart-block-header"><div><h3 id={titleId}>{definition.title}</h3><p>{definition.description}</p></div>
|
|
254
|
+
{definition.periods && <div className="bh-chart-block-periods" role="group" aria-label={`${definition.title} period`}>{definition.periods.map((entry) =>
|
|
255
|
+
<Button key={entry.label} type="button" size="xs" variant={effectivePeriod === entry.label ? "soft" : "ghost"} aria-pressed={effectivePeriod === entry.label} onClick={() => setPeriod(entry.label)}>{entry.label}</Button>)}</div>}
|
|
256
|
+
</header>
|
|
257
|
+
<div className="bh-chart-block-body">{content}</div>
|
|
258
|
+
<footer className="bh-chart-block-footer"><span>Illustrative data</span><BlockDataTable block={block} spec={spec} period={effectivePeriod} /></footer>
|
|
259
|
+
</article>
|
|
260
|
+
}
|