banhaten 0.4.0 → 0.6.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 +78 -1
- package/CONTRIBUTING.md +6 -4
- package/README.md +4 -2
- package/docs/design-spacing.md +13 -0
- package/docs/design-system/README.md +2 -0
- package/docs/design-system/appearances/default.md +1 -1
- package/docs/design-system/appearances/rounded.md +1 -1
- package/docs/design-system/appearances/sharp.md +1 -1
- package/docs/design-system/component-showcase-consistency-report.md +5 -5
- package/docs/design-system/components/README.md +15 -0
- package/docs/design-system/components/accordion.md +1 -1
- package/docs/design-system/components/activity-feed.md +1 -1
- package/docs/design-system/components/activity-heatmap.md +1 -1
- package/docs/design-system/components/agent-input.md +85 -0
- package/docs/design-system/components/alert-dialog.md +1 -1
- package/docs/design-system/components/alert.md +10 -4
- package/docs/design-system/components/app-header.md +97 -0
- package/docs/design-system/components/approval-card.md +134 -0
- package/docs/design-system/components/attribute.md +14 -2
- package/docs/design-system/components/avatar.md +1 -1
- package/docs/design-system/components/badge.md +1 -1
- package/docs/design-system/components/banner.md +1 -1
- package/docs/design-system/components/breadcrumbs.md +8 -3
- package/docs/design-system/components/button-group.md +1 -1
- package/docs/design-system/components/button.md +8 -5
- package/docs/design-system/components/card.md +15 -1
- package/docs/design-system/components/catalog-components.md +1 -1
- package/docs/design-system/components/chart.md +95 -24
- package/docs/design-system/components/checkbox.md +1 -1
- package/docs/design-system/components/collapsible.md +20 -6
- package/docs/design-system/components/command.md +1 -1
- package/docs/design-system/components/context-menu.md +1 -1
- package/docs/design-system/components/date-picker.md +1 -1
- package/docs/design-system/components/file-upload.md +1 -1
- package/docs/design-system/components/hover-card.md +1 -1
- package/docs/design-system/components/inline-citations.md +93 -0
- package/docs/design-system/components/input-group.md +1 -1
- package/docs/design-system/components/input.md +59 -3
- package/docs/design-system/components/item.md +1 -1
- package/docs/design-system/components/kbd.md +6 -4
- package/docs/design-system/components/menu.md +35 -4
- package/docs/design-system/components/menubar.md +1 -1
- package/docs/design-system/components/modal.md +2 -2
- package/docs/design-system/components/native-select.md +1 -1
- package/docs/design-system/components/navigation-menu.md +1 -1
- package/docs/design-system/components/onboarding-step-list-item.md +1 -1
- package/docs/design-system/components/page-header.md +58 -17
- package/docs/design-system/components/pagination.md +2 -2
- package/docs/design-system/components/popover.md +1 -1
- package/docs/design-system/components/progress-slider.md +1 -1
- package/docs/design-system/components/radio-card.md +1 -1
- package/docs/design-system/components/radio-group.md +1 -1
- package/docs/design-system/components/resizable.md +1 -1
- package/docs/design-system/components/rolling-count.md +61 -0
- package/docs/design-system/components/scroll-area.md +1 -1
- package/docs/design-system/components/select.md +12 -2
- package/docs/design-system/components/sheet.md +1 -1
- package/docs/design-system/components/shimmer-text.md +67 -0
- package/docs/design-system/components/sidebar.md +75 -8
- package/docs/design-system/components/skeleton.md +2 -2
- package/docs/design-system/components/slideout.md +1 -1
- package/docs/design-system/components/slider.md +2 -2
- package/docs/design-system/components/social-button.md +2 -2
- package/docs/design-system/components/streaming-text.md +82 -0
- package/docs/design-system/components/table.md +103 -21
- package/docs/design-system/components/tabs.md +25 -6
- package/docs/design-system/components/tag.md +1 -1
- package/docs/design-system/components/task-list.md +95 -0
- package/docs/design-system/components/textarea.md +1 -1
- 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 +1 -1
- package/docs/design-system/components/toast.md +1 -1
- package/docs/design-system/components/toggle.md +2 -2
- package/docs/design-system/components/toolbar.md +11 -6
- package/docs/design-system/components/tooltip.md +6 -4
- package/docs/design-system/density-and-elevation.md +16 -16
- package/docs/design-system/design-system-test-missing-items.md +8 -8
- package/docs/design-system/installation.md +2 -0
- package/docs/design-system/tabs.md +2 -1
- 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 +15 -2
- package/package.json +2 -2
- package/registry/components/accordion.tsx +2 -2
- 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 +2 -2
- package/registry/components/alert.tsx +23 -7
- package/registry/components/app-header.tsx +151 -0
- 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/attribute.tsx +48 -13
- package/registry/components/autocomplete.tsx +15 -68
- package/registry/components/avatar.tsx +2 -2
- package/registry/components/badge.tsx +8 -7
- package/registry/components/button-group.tsx +2 -2
- package/registry/components/button.tsx +19 -14
- package/registry/components/card.tsx +18 -5
- 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 +1 -1
- package/registry/components/checkbox.tsx +6 -6
- package/registry/components/collapsible.tsx +43 -3
- package/registry/components/command.tsx +1 -1
- package/registry/components/context-menu.tsx +1 -1
- package/registry/components/data-chart.tsx +40 -0
- package/registry/components/date-picker.tsx +9 -9
- 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/Breadcrumbs.tsx +5 -1
- package/registry/components/expanded/PageHeader.tsx +67 -24
- package/registry/components/expanded/Table.tsx +60 -22
- package/registry/components/expanded/activityFeed.css +2 -2
- package/registry/components/expanded/activityHeatmap.css +1 -1
- package/registry/components/expanded/banner.css +2 -2
- package/registry/components/expanded/breadcrumbs.css +19 -2
- package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
- package/registry/components/expanded/fileUpload.css +7 -7
- package/registry/components/expanded/onboardingStepListItem.css +2 -2
- package/registry/components/expanded/pageHeader.css +38 -44
- package/registry/components/expanded/slideout.css +4 -4
- package/registry/components/expanded/table.css +58 -9
- package/registry/components/expanded/timeline.css +1 -1
- package/registry/components/hover-card.tsx +1 -1
- package/registry/components/inline-citations.css +126 -0
- package/registry/components/inline-citations.tsx +292 -0
- package/registry/components/input-group.tsx +4 -4
- package/registry/components/input.tsx +150 -137
- package/registry/components/item.tsx +1 -1
- package/registry/components/kbd.tsx +3 -2
- package/registry/components/menu.tsx +117 -12
- package/registry/components/menubar.tsx +2 -2
- package/registry/components/modal.tsx +3 -3
- package/registry/components/native-select.tsx +4 -4
- package/registry/components/navigation-menu.tsx +2 -2
- package/registry/components/pagination.tsx +4 -4
- package/registry/components/popover.tsx +3 -3
- package/registry/components/progress-slider.tsx +1 -1
- package/registry/components/radio-card.tsx +7 -7
- package/registry/components/radio-group.tsx +2 -2
- package/registry/components/resizable.tsx +2 -2
- package/registry/components/rolling-count.css +37 -0
- package/registry/components/rolling-count.tsx +110 -0
- package/registry/components/scroll-area.tsx +1 -1
- package/registry/components/select.tsx +15 -10
- package/registry/components/sheet.tsx +2 -2
- package/registry/components/shimmer-text.css +69 -0
- package/registry/components/shimmer-text.tsx +43 -0
- package/registry/components/sidebar.tsx +528 -223
- package/registry/components/skeleton.tsx +1 -1
- package/registry/components/slider.tsx +2 -2
- package/registry/components/social-button.tsx +1 -1
- package/registry/components/streaming-text.css +45 -0
- package/registry/components/streaming-text.tsx +165 -0
- package/registry/components/table-elements.tsx +164 -5
- package/registry/components/tabs.css +32 -4
- package/registry/components/tabs.tsx +5 -1
- package/registry/components/tag.tsx +1 -1
- package/registry/components/task-list.css +328 -0
- package/registry/components/task-list.tsx +429 -0
- package/registry/components/textarea.tsx +4 -4
- 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 +1 -1
- package/registry/components/toggle.tsx +2 -2
- package/registry/components/toolbar.tsx +6 -6
- package/registry/components/tooltip.tsx +6 -4
- package/registry/examples/agent-input-demo.tsx +90 -0
- package/registry/examples/app-header-demo.tsx +40 -0
- package/registry/examples/approval-card-demo.tsx +83 -0
- package/registry/examples/button-demo.tsx +3 -0
- package/registry/examples/chart-block-demo.tsx +5 -0
- package/registry/examples/chart-demo.tsx +6 -21
- package/registry/examples/collection-filter-bar-demo.tsx +1 -1
- package/registry/examples/control-density-contract-demo.tsx +5 -1
- package/registry/examples/inline-citations-demo.tsx +31 -0
- package/registry/examples/input-actions-demo.tsx +36 -0
- package/registry/examples/menu-demo.tsx +1 -1
- package/registry/examples/popover-demo.tsx +1 -0
- package/registry/examples/rolling-count-demo.tsx +39 -0
- package/registry/examples/shimmer-text-demo.tsx +20 -0
- package/registry/examples/streaming-text-demo.tsx +28 -0
- package/registry/examples/task-list-demo.tsx +74 -0
- package/registry/examples/thinking-reasoning-demo.tsx +74 -0
- package/registry/examples/thinking-state-demo.tsx +13 -0
- package/registry/guidance/charts.LICENSE.txt +24 -0
- package/registry/hooks/use-controllable-state.ts +33 -0
- 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 +1808 -190
- package/registry/styles/globals.css +992 -1088
- package/registry.json +385 -10
- package/schema/registry.schema.json +5 -1
- package/schema/tokens.schema.json +8 -4
- package/src/cli/file-transaction.js +15 -8
- package/src/cli/index.js +22 -29
- package/tokens/banhaten.tokens.json +490 -192
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { graphic, type ECharts } from "echarts/core"
|
|
2
|
+
import type { EChartsOption, SeriesOption } from "echarts"
|
|
3
|
+
import type { EChartTheme } from "./echart-theme"
|
|
4
|
+
import { createChartOption } from "./chart-options"
|
|
5
|
+
import { barPaint, chartAlpha, expandablePaint, seriesColors } from "./chart-options.paint"
|
|
6
|
+
import { chartSeries, type ChartSpec } from "./chart-options.types"
|
|
7
|
+
|
|
8
|
+
/** Attach optional interaction behavior. Every listener and animation has a cleanup. */
|
|
9
|
+
export function attachChartInteractions(chart: ECharts, theme: EChartTheme, spec: ChartSpec): () => void {
|
|
10
|
+
const entries = chartSeries(spec)
|
|
11
|
+
const animated = entries.filter((entry) => entry.stroke === "animated-dashed")
|
|
12
|
+
const expanding = entries.filter((entry) => (entry.variant ?? spec.variant) === "expandable")
|
|
13
|
+
const stripped = entries.filter((entry) => (entry.variant ?? spec.variant) === "stripped")
|
|
14
|
+
const reveal = Boolean(spec.hoverReveal && (spec.family === "line" || spec.family === "area"))
|
|
15
|
+
if (!animated.length && !expanding.length && !stripped.length && !reveal) return () => {}
|
|
16
|
+
const original = createChartOption(theme, spec)
|
|
17
|
+
const series = (Array.isArray(original.series) ? original.series : [original.series]).filter((item): item is SeriesOption => Boolean(item))
|
|
18
|
+
let frame: number | undefined
|
|
19
|
+
let hovered: number | null = null
|
|
20
|
+
let disposed = false
|
|
21
|
+
let dirty = false
|
|
22
|
+
const started = performance.now()
|
|
23
|
+
let capSignature = ""
|
|
24
|
+
|
|
25
|
+
// Keep the colored cap a token-sized strip after data changes, zoom, and resize.
|
|
26
|
+
const updateCaps = () => {
|
|
27
|
+
if (disposed || chart.isDisposed()) return
|
|
28
|
+
const ratios: number[] = []
|
|
29
|
+
const patches = stripped.flatMap((entry) => {
|
|
30
|
+
const originalSeries = series.find((item) => item.id === entry.key)
|
|
31
|
+
if (originalSeries?.type !== "bar") return []
|
|
32
|
+
const colors = seriesColors(theme, entry, entries.indexOf(entry))
|
|
33
|
+
const data = originalSeries.data?.map((datum, index) => {
|
|
34
|
+
if (!datum || typeof datum !== "object" || Array.isArray(datum) || !("value" in datum) || typeof datum.value !== "number") return datum
|
|
35
|
+
const point = chart.convertToPixel({ gridIndex: 0 }, spec.horizontal ? [datum.value, index] : [index, datum.value])
|
|
36
|
+
const origin = chart.convertToPixel({ gridIndex: 0 }, spec.horizontal ? [0, index] : [index, 0])
|
|
37
|
+
const axis = spec.horizontal ? 0 : 1
|
|
38
|
+
const span = Array.isArray(point) && Array.isArray(origin) ? Math.abs(point[axis] - origin[axis]) : 0
|
|
39
|
+
const ratio = span > 0 ? Math.min(1, theme.space.xs / span) : 0
|
|
40
|
+
ratios.push(ratio)
|
|
41
|
+
return { ...datum, itemStyle: { ...datum.itemStyle, color: barPaint(theme, colors, "stripped", Boolean(spec.horizontal), ratio) } }
|
|
42
|
+
})
|
|
43
|
+
return [{ id: entry.key, type: "bar" as const, data }]
|
|
44
|
+
})
|
|
45
|
+
const signature = ratios.join(",")
|
|
46
|
+
if (signature === capSignature) return
|
|
47
|
+
capSignature = signature
|
|
48
|
+
chart.setOption({ series: patches }, { silent: true, lazyUpdate: true })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const paint = (time: number) => {
|
|
52
|
+
frame = undefined
|
|
53
|
+
if (disposed || chart.isDisposed()) return
|
|
54
|
+
const patches: SeriesOption[] = []
|
|
55
|
+
entries.forEach((entry, seriesIndex) => {
|
|
56
|
+
const originalSeries = series.find((item) => item.id === entry.key)
|
|
57
|
+
if (!originalSeries) return
|
|
58
|
+
const colors = seriesColors(theme, entry, seriesIndex)
|
|
59
|
+
const patch: Record<string, unknown> = { id: entry.key }
|
|
60
|
+
let changed = false
|
|
61
|
+
if (entry.stroke === "animated-dashed" && !theme.reducedMotion) {
|
|
62
|
+
patch.lineStyle = { dashOffset: -((time - started) / Math.max(theme.duration, 1)) * theme.space.xl }
|
|
63
|
+
changed = true
|
|
64
|
+
}
|
|
65
|
+
if (reveal && dirty && originalSeries.type === "line") {
|
|
66
|
+
const cutoff = hovered === null ? 1 : hovered / Math.max(1, spec.data.length - 1)
|
|
67
|
+
const gradient = new graphic.LinearGradient(0, 0, 1, 0, [
|
|
68
|
+
{ offset: 0, color: colors[0] }, { offset: cutoff, color: colors.at(-1)! },
|
|
69
|
+
{ offset: cutoff, color: hovered === null ? colors.at(-1)! : theme.neutral },
|
|
70
|
+
{ offset: 1, color: hovered === null ? colors.at(-1)! : theme.neutral },
|
|
71
|
+
])
|
|
72
|
+
patch.lineStyle = { ...originalSeries.lineStyle, ...patch.lineStyle as object, color: gradient }
|
|
73
|
+
if (spec.family === "area") {
|
|
74
|
+
patch.areaStyle = hovered === null ? originalSeries.areaStyle : {
|
|
75
|
+
color: new graphic.LinearGradient(0, 0, 1, 0, gradient.colorStops.map((stop) => ({ ...stop, color: chartAlpha(stop.color, theme.opacity.area) }))),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
changed = true
|
|
79
|
+
}
|
|
80
|
+
if ((entry.variant ?? spec.variant) === "expandable" && dirty && originalSeries.type === "bar") {
|
|
81
|
+
patch.data = originalSeries.data?.map((datum, index) => ({
|
|
82
|
+
...(typeof datum === "object" && datum !== null && !Array.isArray(datum) ? datum : { value: datum }),
|
|
83
|
+
itemStyle: { color: expandablePaint(colors[0], hovered === index ? 1 : theme.opacity.soft, Boolean(spec.horizontal)) },
|
|
84
|
+
}))
|
|
85
|
+
changed = true
|
|
86
|
+
}
|
|
87
|
+
if (changed) patches.push(patch as SeriesOption)
|
|
88
|
+
})
|
|
89
|
+
if (patches.length) chart.setOption({ series: patches } satisfies EChartsOption, { silent: true, lazyUpdate: true })
|
|
90
|
+
dirty = false
|
|
91
|
+
if (animated.length && !theme.reducedMotion) frame = requestAnimationFrame(paint)
|
|
92
|
+
}
|
|
93
|
+
const schedule = () => { if (frame === undefined) frame = requestAnimationFrame(paint) }
|
|
94
|
+
const move = (event: { offsetX: number; offsetY: number }) => {
|
|
95
|
+
const point = [event.offsetX, event.offsetY]
|
|
96
|
+
const converted = chart.containPixel({ gridIndex: 0 }, point) ? chart.convertFromPixel({ gridIndex: 0 }, point) : null
|
|
97
|
+
const value = Array.isArray(converted) ? converted[spec.horizontal ? 1 : 0] : null
|
|
98
|
+
const next = typeof value === "number" ? Math.max(0, Math.min(spec.data.length - 1, Math.round(value))) : null
|
|
99
|
+
if (next === hovered) return
|
|
100
|
+
hovered = next
|
|
101
|
+
dirty = true
|
|
102
|
+
schedule()
|
|
103
|
+
}
|
|
104
|
+
const leave = () => {
|
|
105
|
+
hovered = null
|
|
106
|
+
dirty = true
|
|
107
|
+
schedule()
|
|
108
|
+
}
|
|
109
|
+
const renderer = chart.getZr()
|
|
110
|
+
if (expanding.length || reveal) {
|
|
111
|
+
renderer.on("mousemove", move)
|
|
112
|
+
renderer.on("globalout", leave)
|
|
113
|
+
}
|
|
114
|
+
if (animated.length && !theme.reducedMotion) schedule()
|
|
115
|
+
if (stripped.length) { updateCaps(); chart.on("finished", updateCaps) }
|
|
116
|
+
return () => {
|
|
117
|
+
disposed = true
|
|
118
|
+
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
119
|
+
renderer.off("mousemove", move)
|
|
120
|
+
renderer.off("globalout", leave)
|
|
121
|
+
if (stripped.length) chart.off("finished", updateCaps)
|
|
122
|
+
}
|
|
123
|
+
}
|