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.
Files changed (214) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/CONTRIBUTING.md +6 -4
  3. package/README.md +4 -2
  4. package/docs/design-spacing.md +13 -0
  5. package/docs/design-system/README.md +2 -0
  6. package/docs/design-system/appearances/default.md +1 -1
  7. package/docs/design-system/appearances/rounded.md +1 -1
  8. package/docs/design-system/appearances/sharp.md +1 -1
  9. package/docs/design-system/component-showcase-consistency-report.md +5 -5
  10. package/docs/design-system/components/README.md +15 -0
  11. package/docs/design-system/components/accordion.md +1 -1
  12. package/docs/design-system/components/activity-feed.md +1 -1
  13. package/docs/design-system/components/activity-heatmap.md +1 -1
  14. package/docs/design-system/components/agent-input.md +85 -0
  15. package/docs/design-system/components/alert-dialog.md +1 -1
  16. package/docs/design-system/components/alert.md +10 -4
  17. package/docs/design-system/components/app-header.md +97 -0
  18. package/docs/design-system/components/approval-card.md +134 -0
  19. package/docs/design-system/components/attribute.md +14 -2
  20. package/docs/design-system/components/avatar.md +1 -1
  21. package/docs/design-system/components/badge.md +1 -1
  22. package/docs/design-system/components/banner.md +1 -1
  23. package/docs/design-system/components/breadcrumbs.md +8 -3
  24. package/docs/design-system/components/button-group.md +1 -1
  25. package/docs/design-system/components/button.md +8 -5
  26. package/docs/design-system/components/card.md +15 -1
  27. package/docs/design-system/components/catalog-components.md +1 -1
  28. package/docs/design-system/components/chart.md +95 -24
  29. package/docs/design-system/components/checkbox.md +1 -1
  30. package/docs/design-system/components/collapsible.md +20 -6
  31. package/docs/design-system/components/command.md +1 -1
  32. package/docs/design-system/components/context-menu.md +1 -1
  33. package/docs/design-system/components/date-picker.md +1 -1
  34. package/docs/design-system/components/file-upload.md +1 -1
  35. package/docs/design-system/components/hover-card.md +1 -1
  36. package/docs/design-system/components/inline-citations.md +93 -0
  37. package/docs/design-system/components/input-group.md +1 -1
  38. package/docs/design-system/components/input.md +59 -3
  39. package/docs/design-system/components/item.md +1 -1
  40. package/docs/design-system/components/kbd.md +6 -4
  41. package/docs/design-system/components/menu.md +35 -4
  42. package/docs/design-system/components/menubar.md +1 -1
  43. package/docs/design-system/components/modal.md +2 -2
  44. package/docs/design-system/components/native-select.md +1 -1
  45. package/docs/design-system/components/navigation-menu.md +1 -1
  46. package/docs/design-system/components/onboarding-step-list-item.md +1 -1
  47. package/docs/design-system/components/page-header.md +58 -17
  48. package/docs/design-system/components/pagination.md +2 -2
  49. package/docs/design-system/components/popover.md +1 -1
  50. package/docs/design-system/components/progress-slider.md +1 -1
  51. package/docs/design-system/components/radio-card.md +1 -1
  52. package/docs/design-system/components/radio-group.md +1 -1
  53. package/docs/design-system/components/resizable.md +1 -1
  54. package/docs/design-system/components/rolling-count.md +61 -0
  55. package/docs/design-system/components/scroll-area.md +1 -1
  56. package/docs/design-system/components/select.md +12 -2
  57. package/docs/design-system/components/sheet.md +1 -1
  58. package/docs/design-system/components/shimmer-text.md +67 -0
  59. package/docs/design-system/components/sidebar.md +75 -8
  60. package/docs/design-system/components/skeleton.md +2 -2
  61. package/docs/design-system/components/slideout.md +1 -1
  62. package/docs/design-system/components/slider.md +2 -2
  63. package/docs/design-system/components/social-button.md +2 -2
  64. package/docs/design-system/components/streaming-text.md +82 -0
  65. package/docs/design-system/components/table.md +103 -21
  66. package/docs/design-system/components/tabs.md +25 -6
  67. package/docs/design-system/components/tag.md +1 -1
  68. package/docs/design-system/components/task-list.md +95 -0
  69. package/docs/design-system/components/textarea.md +1 -1
  70. package/docs/design-system/components/thinking-reasoning.md +72 -0
  71. package/docs/design-system/components/thinking-state.md +67 -0
  72. package/docs/design-system/components/timeline.md +1 -1
  73. package/docs/design-system/components/toast.md +1 -1
  74. package/docs/design-system/components/toggle.md +2 -2
  75. package/docs/design-system/components/toolbar.md +11 -6
  76. package/docs/design-system/components/tooltip.md +6 -4
  77. package/docs/design-system/density-and-elevation.md +16 -16
  78. package/docs/design-system/design-system-test-missing-items.md +8 -8
  79. package/docs/design-system/installation.md +2 -0
  80. package/docs/design-system/tabs.md +2 -1
  81. package/docs/design-system/template-gap-audit.md +32 -0
  82. package/docs/design-system/templates.md +59 -0
  83. package/docs/design-system/token-governance.md +15 -2
  84. package/package.json +2 -2
  85. package/registry/components/accordion.tsx +2 -2
  86. package/registry/components/agent-input-parts.tsx +552 -0
  87. package/registry/components/agent-input-state.ts +156 -0
  88. package/registry/components/agent-input.css +307 -0
  89. package/registry/components/agent-input.tsx +382 -0
  90. package/registry/components/alert-dialog.tsx +2 -2
  91. package/registry/components/alert.tsx +23 -7
  92. package/registry/components/app-header.tsx +151 -0
  93. package/registry/components/approval-card.css +409 -0
  94. package/registry/components/approval-card.tsx +401 -0
  95. package/registry/components/approval-frame.tsx +108 -0
  96. package/registry/components/approval-questions.tsx +559 -0
  97. package/registry/components/attribute.tsx +48 -13
  98. package/registry/components/autocomplete.tsx +15 -68
  99. package/registry/components/avatar.tsx +2 -2
  100. package/registry/components/badge.tsx +8 -7
  101. package/registry/components/button-group.tsx +2 -2
  102. package/registry/components/button.tsx +19 -14
  103. package/registry/components/card.tsx +18 -5
  104. package/registry/components/chart-block-data.ts +177 -0
  105. package/registry/components/chart-block.css +171 -0
  106. package/registry/components/chart-block.tsx +260 -0
  107. package/registry/components/chart-interactions.ts +123 -0
  108. package/registry/components/chart-options.cartesian.ts +162 -0
  109. package/registry/components/chart-options.paint.ts +99 -0
  110. package/registry/components/chart-options.parts.ts +72 -0
  111. package/registry/components/chart-options.radial.ts +109 -0
  112. package/registry/components/chart-options.ts +44 -0
  113. package/registry/components/chart-options.types.ts +67 -0
  114. package/registry/components/chart.tsx +1 -1
  115. package/registry/components/checkbox.tsx +6 -6
  116. package/registry/components/collapsible.tsx +43 -3
  117. package/registry/components/command.tsx +1 -1
  118. package/registry/components/context-menu.tsx +1 -1
  119. package/registry/components/data-chart.tsx +40 -0
  120. package/registry/components/date-picker.tsx +9 -9
  121. package/registry/components/echart-theme.ts +246 -0
  122. package/registry/components/echart.css +49 -0
  123. package/registry/components/echart.tsx +178 -0
  124. package/registry/components/expanded/Breadcrumbs.tsx +5 -1
  125. package/registry/components/expanded/PageHeader.tsx +67 -24
  126. package/registry/components/expanded/Table.tsx +60 -22
  127. package/registry/components/expanded/activityFeed.css +2 -2
  128. package/registry/components/expanded/activityHeatmap.css +1 -1
  129. package/registry/components/expanded/banner.css +2 -2
  130. package/registry/components/expanded/breadcrumbs.css +19 -2
  131. package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
  132. package/registry/components/expanded/fileUpload.css +7 -7
  133. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  134. package/registry/components/expanded/pageHeader.css +38 -44
  135. package/registry/components/expanded/slideout.css +4 -4
  136. package/registry/components/expanded/table.css +58 -9
  137. package/registry/components/expanded/timeline.css +1 -1
  138. package/registry/components/hover-card.tsx +1 -1
  139. package/registry/components/inline-citations.css +126 -0
  140. package/registry/components/inline-citations.tsx +292 -0
  141. package/registry/components/input-group.tsx +4 -4
  142. package/registry/components/input.tsx +150 -137
  143. package/registry/components/item.tsx +1 -1
  144. package/registry/components/kbd.tsx +3 -2
  145. package/registry/components/menu.tsx +117 -12
  146. package/registry/components/menubar.tsx +2 -2
  147. package/registry/components/modal.tsx +3 -3
  148. package/registry/components/native-select.tsx +4 -4
  149. package/registry/components/navigation-menu.tsx +2 -2
  150. package/registry/components/pagination.tsx +4 -4
  151. package/registry/components/popover.tsx +3 -3
  152. package/registry/components/progress-slider.tsx +1 -1
  153. package/registry/components/radio-card.tsx +7 -7
  154. package/registry/components/radio-group.tsx +2 -2
  155. package/registry/components/resizable.tsx +2 -2
  156. package/registry/components/rolling-count.css +37 -0
  157. package/registry/components/rolling-count.tsx +110 -0
  158. package/registry/components/scroll-area.tsx +1 -1
  159. package/registry/components/select.tsx +15 -10
  160. package/registry/components/sheet.tsx +2 -2
  161. package/registry/components/shimmer-text.css +69 -0
  162. package/registry/components/shimmer-text.tsx +43 -0
  163. package/registry/components/sidebar.tsx +528 -223
  164. package/registry/components/skeleton.tsx +1 -1
  165. package/registry/components/slider.tsx +2 -2
  166. package/registry/components/social-button.tsx +1 -1
  167. package/registry/components/streaming-text.css +45 -0
  168. package/registry/components/streaming-text.tsx +165 -0
  169. package/registry/components/table-elements.tsx +164 -5
  170. package/registry/components/tabs.css +32 -4
  171. package/registry/components/tabs.tsx +5 -1
  172. package/registry/components/tag.tsx +1 -1
  173. package/registry/components/task-list.css +328 -0
  174. package/registry/components/task-list.tsx +429 -0
  175. package/registry/components/textarea.tsx +4 -4
  176. package/registry/components/thinking-reasoning.css +178 -0
  177. package/registry/components/thinking-reasoning.tsx +253 -0
  178. package/registry/components/thinking-state.css +8 -0
  179. package/registry/components/thinking-state.tsx +46 -0
  180. package/registry/components/toast.tsx +1 -1
  181. package/registry/components/toggle.tsx +2 -2
  182. package/registry/components/toolbar.tsx +6 -6
  183. package/registry/components/tooltip.tsx +6 -4
  184. package/registry/examples/agent-input-demo.tsx +90 -0
  185. package/registry/examples/app-header-demo.tsx +40 -0
  186. package/registry/examples/approval-card-demo.tsx +83 -0
  187. package/registry/examples/button-demo.tsx +3 -0
  188. package/registry/examples/chart-block-demo.tsx +5 -0
  189. package/registry/examples/chart-demo.tsx +6 -21
  190. package/registry/examples/collection-filter-bar-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +5 -1
  192. package/registry/examples/inline-citations-demo.tsx +31 -0
  193. package/registry/examples/input-actions-demo.tsx +36 -0
  194. package/registry/examples/menu-demo.tsx +1 -1
  195. package/registry/examples/popover-demo.tsx +1 -0
  196. package/registry/examples/rolling-count-demo.tsx +39 -0
  197. package/registry/examples/shimmer-text-demo.tsx +20 -0
  198. package/registry/examples/streaming-text-demo.tsx +28 -0
  199. package/registry/examples/task-list-demo.tsx +74 -0
  200. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  201. package/registry/examples/thinking-state-demo.tsx +13 -0
  202. package/registry/guidance/charts.LICENSE.txt +24 -0
  203. package/registry/hooks/use-controllable-state.ts +33 -0
  204. package/registry/hooks/use-listbox-navigation.ts +73 -0
  205. package/registry/hooks/use-motion-end.ts +55 -0
  206. package/registry/hooks/use-reduced-motion.ts +28 -0
  207. package/registry/index.json +1808 -190
  208. package/registry/styles/globals.css +992 -1088
  209. package/registry.json +385 -10
  210. package/schema/registry.schema.json +5 -1
  211. package/schema/tokens.schema.json +8 -4
  212. package/src/cli/file-transaction.js +15 -8
  213. package/src/cli/index.js +22 -29
  214. 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
+ }