banhaten 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/CHANGELOG.md +139 -100
  2. package/CONTRIBUTING.md +46 -44
  3. package/README.md +192 -190
  4. package/SECURITY.md +15 -15
  5. package/banhaten.config.example.json +11 -11
  6. package/docs/design-spacing.md +13 -0
  7. package/docs/design-system/README.md +22 -20
  8. package/docs/design-system/adoption-quality-remediation.md +87 -87
  9. package/docs/design-system/agent-usage.md +13 -13
  10. package/docs/design-system/appearance-presets.md +189 -189
  11. package/docs/design-system/appearances/default.md +94 -94
  12. package/docs/design-system/appearances/rounded.md +95 -95
  13. package/docs/design-system/appearances/sharp.md +95 -95
  14. package/docs/design-system/component-docs-pattern.md +81 -81
  15. package/docs/design-system/component-implementation-ownership-audit.md +245 -245
  16. package/docs/design-system/component-quality-audit-2026-08-20.md +225 -225
  17. package/docs/design-system/component-showcase-consistency-report.md +219 -219
  18. package/docs/design-system/component-token-consistency-audit.md +173 -173
  19. package/docs/design-system/components/README.md +90 -76
  20. package/docs/design-system/components/accordion.md +53 -53
  21. package/docs/design-system/components/activity-feed.md +94 -94
  22. package/docs/design-system/components/activity-heatmap.md +57 -57
  23. package/docs/design-system/components/agent-input.md +85 -0
  24. package/docs/design-system/components/alert-dialog.md +72 -72
  25. package/docs/design-system/components/alert.md +97 -97
  26. package/docs/design-system/components/approval-card.md +134 -0
  27. package/docs/design-system/components/aspect-ratio.md +45 -45
  28. package/docs/design-system/components/attribute.md +88 -88
  29. package/docs/design-system/components/autocomplete.md +75 -75
  30. package/docs/design-system/components/avatar.md +54 -54
  31. package/docs/design-system/components/badge.md +54 -54
  32. package/docs/design-system/components/banner.md +87 -87
  33. package/docs/design-system/components/breadcrumbs.md +176 -175
  34. package/docs/design-system/components/button-group.md +98 -98
  35. package/docs/design-system/components/button.md +83 -80
  36. package/docs/design-system/components/card.md +88 -88
  37. package/docs/design-system/components/carousel.md +46 -46
  38. package/docs/design-system/components/catalog-components.md +47 -47
  39. package/docs/design-system/components/chart.md +95 -24
  40. package/docs/design-system/components/checkbox.md +54 -54
  41. package/docs/design-system/components/collapsible.md +67 -53
  42. package/docs/design-system/components/command.md +62 -62
  43. package/docs/design-system/components/context-menu.md +46 -46
  44. package/docs/design-system/components/date-picker.md +81 -81
  45. package/docs/design-system/components/divider.md +102 -102
  46. package/docs/design-system/components/empty-state.md +56 -56
  47. package/docs/design-system/components/field.md +70 -70
  48. package/docs/design-system/components/file-upload.md +187 -187
  49. package/docs/design-system/components/hover-card.md +48 -48
  50. package/docs/design-system/components/icons.md +48 -48
  51. package/docs/design-system/components/inline-citations.md +93 -0
  52. package/docs/design-system/components/input-group.md +58 -58
  53. package/docs/design-system/components/input-otp.md +56 -56
  54. package/docs/design-system/components/input.md +114 -60
  55. package/docs/design-system/components/item.md +59 -59
  56. package/docs/design-system/components/kbd.md +51 -49
  57. package/docs/design-system/components/label.md +49 -49
  58. package/docs/design-system/components/menu.md +71 -61
  59. package/docs/design-system/components/menubar.md +47 -47
  60. package/docs/design-system/components/modal.md +105 -105
  61. package/docs/design-system/components/native-select.md +54 -54
  62. package/docs/design-system/components/navigation-menu.md +50 -50
  63. package/docs/design-system/components/onboarding-step-list-item.md +82 -82
  64. package/docs/design-system/components/page-header.md +93 -86
  65. package/docs/design-system/components/pagination.md +57 -57
  66. package/docs/design-system/components/popover.md +60 -60
  67. package/docs/design-system/components/progress-slider.md +50 -50
  68. package/docs/design-system/components/progress.md +76 -76
  69. package/docs/design-system/components/radio-card.md +51 -51
  70. package/docs/design-system/components/radio-group.md +57 -57
  71. package/docs/design-system/components/resizable.md +44 -44
  72. package/docs/design-system/components/rolling-count.md +61 -0
  73. package/docs/design-system/components/scroll-area.md +51 -51
  74. package/docs/design-system/components/segmented-control.md +54 -54
  75. package/docs/design-system/components/select.md +68 -62
  76. package/docs/design-system/components/sheet.md +67 -67
  77. package/docs/design-system/components/shimmer-text.md +67 -0
  78. package/docs/design-system/components/sidebar.md +110 -99
  79. package/docs/design-system/components/skeleton.md +75 -75
  80. package/docs/design-system/components/slideout.md +65 -65
  81. package/docs/design-system/components/slider.md +63 -63
  82. package/docs/design-system/components/social-button.md +50 -50
  83. package/docs/design-system/components/spinner.md +62 -62
  84. package/docs/design-system/components/steps.md +65 -65
  85. package/docs/design-system/components/streaming-text.md +82 -0
  86. package/docs/design-system/components/table.md +419 -414
  87. package/docs/design-system/components/tabs.md +59 -55
  88. package/docs/design-system/components/tag.md +80 -80
  89. package/docs/design-system/components/task-list.md +95 -0
  90. package/docs/design-system/components/textarea.md +50 -50
  91. package/docs/design-system/components/thinking-reasoning.md +72 -0
  92. package/docs/design-system/components/thinking-state.md +67 -0
  93. package/docs/design-system/components/timeline.md +83 -83
  94. package/docs/design-system/components/toast.md +58 -58
  95. package/docs/design-system/components/toggle.md +81 -81
  96. package/docs/design-system/components/toolbar.md +92 -87
  97. package/docs/design-system/components/tooltip.md +94 -92
  98. package/docs/design-system/components/typography.md +18 -18
  99. package/docs/design-system/density-and-elevation.md +65 -65
  100. package/docs/design-system/design-system-test-missing-items.md +368 -368
  101. package/docs/design-system/direction-and-localization.md +23 -23
  102. package/docs/design-system/form-recipes.md +73 -73
  103. package/docs/design-system/gap-promotion-policy.md +21 -21
  104. package/docs/design-system/icons.md +74 -74
  105. package/docs/design-system/installation.md +70 -68
  106. package/docs/design-system/launch-comparison-shadcn.md +98 -98
  107. package/docs/design-system/registry-and-cli.md +64 -64
  108. package/docs/design-system/release-policy.md +30 -30
  109. package/docs/design-system/roadmap.md +14 -14
  110. package/docs/design-system/support-matrix.md +23 -23
  111. package/docs/design-system/tabs.md +54 -53
  112. package/docs/design-system/template-gap-audit.md +32 -0
  113. package/docs/design-system/templates.md +59 -0
  114. package/docs/design-system/token-governance.md +72 -59
  115. package/package.json +98 -98
  116. package/registry/component-contract.json +40 -40
  117. package/registry/components/accordion.tsx +155 -155
  118. package/registry/components/agent-input-parts.tsx +552 -0
  119. package/registry/components/agent-input-state.ts +156 -0
  120. package/registry/components/agent-input.css +307 -0
  121. package/registry/components/agent-input.tsx +382 -0
  122. package/registry/components/alert-dialog.tsx +297 -297
  123. package/registry/components/alert.tsx +293 -293
  124. package/registry/components/approval-card.css +409 -0
  125. package/registry/components/approval-card.tsx +401 -0
  126. package/registry/components/approval-frame.tsx +108 -0
  127. package/registry/components/approval-questions.tsx +559 -0
  128. package/registry/components/aspect-ratio.tsx +30 -30
  129. package/registry/components/attribute.tsx +454 -454
  130. package/registry/components/autocomplete.tsx +598 -651
  131. package/registry/components/avatar.tsx +385 -385
  132. package/registry/components/badge.tsx +629 -629
  133. package/registry/components/button-group.tsx +565 -565
  134. package/registry/components/button.tsx +127 -123
  135. package/registry/components/card.tsx +638 -638
  136. package/registry/components/carousel.tsx +296 -296
  137. package/registry/components/chart-block-data.ts +177 -0
  138. package/registry/components/chart-block.css +171 -0
  139. package/registry/components/chart-block.tsx +260 -0
  140. package/registry/components/chart-interactions.ts +123 -0
  141. package/registry/components/chart-options.cartesian.ts +162 -0
  142. package/registry/components/chart-options.paint.ts +99 -0
  143. package/registry/components/chart-options.parts.ts +72 -0
  144. package/registry/components/chart-options.radial.ts +109 -0
  145. package/registry/components/chart-options.ts +44 -0
  146. package/registry/components/chart-options.types.ts +67 -0
  147. package/registry/components/chart.tsx +203 -203
  148. package/registry/components/checkbox.tsx +247 -247
  149. package/registry/components/collapsible.tsx +109 -69
  150. package/registry/components/command.tsx +208 -208
  151. package/registry/components/context-menu.tsx +236 -236
  152. package/registry/components/data-chart.tsx +40 -0
  153. package/registry/components/date-picker.tsx +1289 -1289
  154. package/registry/components/echart-theme.ts +246 -0
  155. package/registry/components/echart.css +49 -0
  156. package/registry/components/echart.tsx +178 -0
  157. package/registry/components/expanded/ActivityFeed.tsx +227 -227
  158. package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
  159. package/registry/components/expanded/Banner.tsx +179 -179
  160. package/registry/components/expanded/Breadcrumbs.tsx +131 -131
  161. package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
  162. package/registry/components/expanded/CatalogDivider.tsx +49 -49
  163. package/registry/components/expanded/EmptyState.tsx +155 -155
  164. package/registry/components/expanded/FileUpload.tsx +543 -543
  165. package/registry/components/expanded/OnboardingStepListItem.tsx +60 -60
  166. package/registry/components/expanded/PageHeader.tsx +179 -166
  167. package/registry/components/expanded/Slideout.tsx +108 -108
  168. package/registry/components/expanded/Steps.tsx +402 -402
  169. package/registry/components/expanded/Table.tsx +1092 -1071
  170. package/registry/components/expanded/Timeline.tsx +203 -203
  171. package/registry/components/expanded/activityFeed.css +248 -248
  172. package/registry/components/expanded/activityHeatmap.css +66 -66
  173. package/registry/components/expanded/banner.css +260 -260
  174. package/registry/components/expanded/breadcrumbs.css +140 -140
  175. package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
  176. package/registry/components/expanded/divider.css +75 -75
  177. package/registry/components/expanded/emptyState.css +111 -111
  178. package/registry/components/expanded/fileUpload.css +457 -457
  179. package/registry/components/expanded/onboardingStepListItem.css +135 -135
  180. package/registry/components/expanded/pageHeader.css +248 -290
  181. package/registry/components/expanded/slideout.css +276 -276
  182. package/registry/components/expanded/steps.css +356 -356
  183. package/registry/components/expanded/table.css +606 -606
  184. package/registry/components/expanded/timeline.css +286 -286
  185. package/registry/components/field.tsx +283 -283
  186. package/registry/components/hover-card.tsx +48 -48
  187. package/registry/components/inline-citations.css +126 -0
  188. package/registry/components/inline-citations.tsx +292 -0
  189. package/registry/components/input-content.ts +107 -107
  190. package/registry/components/input-group.tsx +130 -130
  191. package/registry/components/input-otp.tsx +584 -584
  192. package/registry/components/input.tsx +990 -979
  193. package/registry/components/item.tsx +189 -189
  194. package/registry/components/kbd.tsx +246 -245
  195. package/registry/components/label.tsx +78 -78
  196. package/registry/components/menu.tsx +978 -986
  197. package/registry/components/menubar.tsx +226 -226
  198. package/registry/components/modal.tsx +872 -872
  199. package/registry/components/native-select.tsx +205 -205
  200. package/registry/components/navigation-menu.tsx +171 -171
  201. package/registry/components/pagination.css +36 -36
  202. package/registry/components/pagination.tsx +560 -560
  203. package/registry/components/popover.tsx +338 -338
  204. package/registry/components/progress-slider.tsx +216 -216
  205. package/registry/components/radio-card.tsx +676 -676
  206. package/registry/components/radio-group.tsx +570 -570
  207. package/registry/components/resizable.tsx +108 -108
  208. package/registry/components/rolling-count.css +37 -0
  209. package/registry/components/rolling-count.tsx +110 -0
  210. package/registry/components/scroll-area.tsx +91 -91
  211. package/registry/components/segmented-control.tsx +87 -87
  212. package/registry/components/select-content.tsx +218 -218
  213. package/registry/components/select.tsx +937 -932
  214. package/registry/components/sheet.tsx +305 -305
  215. package/registry/components/shimmer-text.css +69 -0
  216. package/registry/components/shimmer-text.tsx +43 -0
  217. package/registry/components/sidebar.tsx +1433 -1230
  218. package/registry/components/skeleton.css +54 -54
  219. package/registry/components/skeleton.tsx +482 -482
  220. package/registry/components/slider.tsx +380 -380
  221. package/registry/components/social-button.tsx +387 -387
  222. package/registry/components/spinner.tsx +78 -78
  223. package/registry/components/streaming-text.css +45 -0
  224. package/registry/components/streaming-text.tsx +165 -0
  225. package/registry/components/table-elements.tsx +128 -128
  226. package/registry/components/tabs.css +222 -218
  227. package/registry/components/tabs.tsx +241 -241
  228. package/registry/components/tag.tsx +423 -423
  229. package/registry/components/task-list.css +328 -0
  230. package/registry/components/task-list.tsx +429 -0
  231. package/registry/components/textarea.tsx +610 -610
  232. package/registry/components/thinking-reasoning.css +178 -0
  233. package/registry/components/thinking-reasoning.tsx +253 -0
  234. package/registry/components/thinking-state.css +8 -0
  235. package/registry/components/thinking-state.tsx +46 -0
  236. package/registry/components/toast.tsx +136 -136
  237. package/registry/components/toggle.tsx +256 -256
  238. package/registry/components/toolbar.tsx +539 -539
  239. package/registry/components/tooltip.tsx +433 -431
  240. package/registry/examples/agent-input-demo.tsx +90 -0
  241. package/registry/examples/alert-demo.tsx +34 -34
  242. package/registry/examples/alert-dialog-demo.tsx +42 -42
  243. package/registry/examples/approval-card-demo.tsx +83 -0
  244. package/registry/examples/aspect-ratio-demo.tsx +11 -11
  245. package/registry/examples/attribute-demo.tsx +65 -65
  246. package/registry/examples/autocomplete-demo.tsx +109 -109
  247. package/registry/examples/avatar-demo.tsx +132 -132
  248. package/registry/examples/badge-demo.tsx +69 -69
  249. package/registry/examples/button-demo.tsx +86 -83
  250. package/registry/examples/button-group-demo.tsx +63 -63
  251. package/registry/examples/card-demo.tsx +86 -86
  252. package/registry/examples/carousel-demo.tsx +43 -43
  253. package/registry/examples/chart-block-demo.tsx +5 -0
  254. package/registry/examples/chart-demo.tsx +18 -33
  255. package/registry/examples/checkbox-demo.tsx +62 -62
  256. package/registry/examples/collapsible-demo.tsx +16 -16
  257. package/registry/examples/collection-filter-bar-demo.tsx +135 -135
  258. package/registry/examples/command-demo.tsx +42 -42
  259. package/registry/examples/command-palette-demo.tsx +235 -235
  260. package/registry/examples/context-menu-demo.tsx +29 -29
  261. package/registry/examples/control-density-contract-demo.tsx +65 -61
  262. package/registry/examples/date-picker-demo.tsx +127 -127
  263. package/registry/examples/divider-demo.tsx +17 -17
  264. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
  265. package/registry/examples/expanded/banner-demo.tsx +48 -48
  266. package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
  267. package/registry/examples/expanded/empty-state-demo.tsx +39 -39
  268. package/registry/examples/expanded/file-upload-demo.tsx +60 -60
  269. package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
  270. package/registry/examples/expanded/slideout-demo.tsx +15 -15
  271. package/registry/examples/expanded/steps-demo.tsx +29 -29
  272. package/registry/examples/expanded/tabs-demo.tsx +13 -13
  273. package/registry/examples/expanded/timeline-demo.tsx +18 -18
  274. package/registry/examples/field-demo.tsx +51 -51
  275. package/registry/examples/hover-card-demo.tsx +23 -23
  276. package/registry/examples/inline-citations-demo.tsx +31 -0
  277. package/registry/examples/input-actions-demo.tsx +36 -0
  278. package/registry/examples/input-demo.tsx +87 -87
  279. package/registry/examples/input-group-demo.tsx +16 -16
  280. package/registry/examples/input-otp-demo.tsx +72 -72
  281. package/registry/examples/item-demo.tsx +46 -46
  282. package/registry/examples/kbd-demo.tsx +12 -12
  283. package/registry/examples/label-demo.tsx +20 -20
  284. package/registry/examples/menu-demo.tsx +122 -122
  285. package/registry/examples/menubar-demo.tsx +34 -34
  286. package/registry/examples/modal-demo.tsx +74 -74
  287. package/registry/examples/native-select-demo.tsx +16 -16
  288. package/registry/examples/navigation-menu-demo.tsx +29 -29
  289. package/registry/examples/pagination-demo.tsx +20 -20
  290. package/registry/examples/popover-demo.tsx +547 -546
  291. package/registry/examples/progress-demo.tsx +37 -37
  292. package/registry/examples/progress-slider-demo.tsx +29 -29
  293. package/registry/examples/radio-card-demo.tsx +51 -51
  294. package/registry/examples/radio-group-demo.tsx +62 -62
  295. package/registry/examples/resizable-demo.tsx +22 -22
  296. package/registry/examples/results-toolbar-demo.tsx +44 -44
  297. package/registry/examples/rolling-count-demo.tsx +39 -0
  298. package/registry/examples/scroll-area-demo.tsx +15 -15
  299. package/registry/examples/segmented-control-demo.tsx +39 -39
  300. package/registry/examples/select-demo.tsx +87 -87
  301. package/registry/examples/sheet-demo.tsx +47 -47
  302. package/registry/examples/shimmer-text-demo.tsx +20 -0
  303. package/registry/examples/sidebar-demo.tsx +101 -101
  304. package/registry/examples/slider-demo.tsx +31 -31
  305. package/registry/examples/social-button-demo.tsx +61 -61
  306. package/registry/examples/spinner-demo.tsx +59 -59
  307. package/registry/examples/streaming-text-demo.tsx +28 -0
  308. package/registry/examples/tabs-demo.tsx +13 -13
  309. package/registry/examples/tag-demo.tsx +29 -29
  310. package/registry/examples/task-list-demo.tsx +74 -0
  311. package/registry/examples/textarea-demo.tsx +79 -79
  312. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  313. package/registry/examples/thinking-state-demo.tsx +13 -0
  314. package/registry/examples/toast-demo.tsx +35 -35
  315. package/registry/examples/toggle-demo.tsx +59 -59
  316. package/registry/examples/tooltip-demo.tsx +115 -115
  317. package/registry/guidance/USAGE.md +27 -27
  318. package/registry/guidance/charts.LICENSE.txt +24 -0
  319. package/registry/hooks/use-controllable-state.ts +33 -0
  320. package/registry/hooks/use-direction.ts +207 -207
  321. package/registry/hooks/use-listbox-navigation.ts +73 -0
  322. package/registry/hooks/use-motion-end.ts +55 -0
  323. package/registry/hooks/use-reduced-motion.ts +28 -0
  324. package/registry/index.json +1403 -155
  325. package/registry/styles/globals.css +946 -1088
  326. package/registry.json +361 -8
  327. package/schema/config.schema.json +51 -51
  328. package/schema/registry.schema.json +194 -190
  329. package/schema/tokens.schema.json +167 -163
  330. package/src/cli/command-line.js +194 -194
  331. package/src/cli/config-editors.js +9 -9
  332. package/src/cli/doctor.js +399 -399
  333. package/src/cli/file-transaction.js +302 -295
  334. package/src/cli/global-css.js +153 -153
  335. package/src/cli/index.js +1002 -1009
  336. package/src/cli/install-plan.js +592 -592
  337. package/src/cli/install-receipt.js +171 -171
  338. package/src/cli/jsonc.js +203 -203
  339. package/src/cli/package-install-state.js +254 -254
  340. package/src/cli/package-manager.js +238 -238
  341. package/src/cli/package-plan.js +185 -185
  342. package/src/cli/package-version.js +97 -97
  343. package/src/cli/registry-query.js +207 -207
  344. package/src/cli/reporting.js +55 -55
  345. package/src/cli/schema-validation.js +19 -19
  346. package/src/cli/source-scanner.js +686 -686
  347. package/src/cli/vite-config.js +478 -478
  348. package/tokens/banhaten.tokens.json +306 -192
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ import type { BarSeriesOption, LineSeriesOption, PictorialBarSeriesOption } from "echarts/charts"
2
+ import type { EChartsOption } from "echarts"
3
+ import type { EChartTheme } from "./echart-theme"
4
+ import { areaPaint, barPaint, chartDecal, chartGradient, seriesColors } from "./chart-options.paint"
5
+ import { chartNumber, chartSeries, type ChartCurve, type ChartSeries, type ChartSpec } from "./chart-options.types"
6
+ import { cartesianTooltip, dotItemStyle } from "./chart-options.parts"
7
+
8
+ function curveOption(curve: ChartCurve | undefined): Pick<LineSeriesOption, "smooth" | "smoothMonotone" | "step"> {
9
+ if (curve === "step") return { step: "middle" }
10
+ if (!curve || curve === "linear") return { smooth: false }
11
+ return { smooth: curve === "bump" ? 0.5 : true, smoothMonotone: curve === "monotoneY" ? "y" : "x" }
12
+ }
13
+
14
+ function valuesFor(spec: ChartSpec, series: ChartSeries, all: ChartSeries[]) {
15
+ const percent = spec.stack === "percent" || spec.stack === "expanded"
16
+ return spec.data.map((row) => {
17
+ const value = chartNumber(row[series.key])
18
+ if (value === null || !percent) return value
19
+ const sum = all.reduce((total, entry) => total + Math.max(0, chartNumber(row[entry.key]) ?? 0), 0)
20
+ return sum === 0 ? 0 : Math.max(0, value) / sum * 100
21
+ })
22
+ }
23
+
24
+ function isBarSeries(spec: ChartSpec, entry: ChartSeries) {
25
+ return entry.type === "bar" || (spec.family === "bar" && !entry.type)
26
+ }
27
+
28
+ function barEndRadius(theme: EChartTheme, spec: ChartSpec, all: ChartSeries[], index: number, point: number, value: number | null): number | number[] {
29
+ if (!value) return 0
30
+ // Positive and negative stacks have independent exposed ends; missing values leave no join.
31
+ if (spec.stack && spec.stack !== "default" && all.slice(index + 1).some((entry) => {
32
+ const next = chartNumber(spec.data[point][entry.key])
33
+ return isBarSeries(spec, entry) && next !== null && next * value > 0
34
+ })) return 0
35
+ const radius = theme.markRadius
36
+ if (spec.horizontal) return (value > 0) !== theme.rtl ? [0, radius, radius, 0] : [radius, 0, 0, radius]
37
+ return value > 0 ? [radius, radius, 0, 0] : [0, 0, radius, radius]
38
+ }
39
+
40
+ function lineSeries(theme: EChartTheme, spec: ChartSpec, entry: ChartSeries, index: number, all: ChartSeries[]): LineSeriesOption[] {
41
+ const values = valuesFor(spec, entry, all)
42
+ const colors = seriesColors(theme, entry, index)
43
+ const isArea = entry.type === "area" || spec.family === "area"
44
+ // Forecast tails must not enter a stack as a duplicate series and change totals.
45
+ const hasBuffer = entry.buffer && values.length > 1 && (!spec.stack || spec.stack === "default")
46
+ const base: LineSeriesOption = {
47
+ id: entry.key,
48
+ name: entry.label ?? entry.key,
49
+ type: "line",
50
+ ...curveOption(entry.curve ?? spec.curve),
51
+ data: hasBuffer ? values.map((value, point) => point === values.length - 1 ? null : value) : values,
52
+ stack: spec.stack && spec.stack !== "default" ? "total" : undefined,
53
+ connectNulls: false,
54
+ showSymbol: entry.dots ?? Boolean(entry.dotStyle),
55
+ symbol: "circle",
56
+ symbolSize: entry.dotStyle === "border" ? theme.space.md : theme.space.sm,
57
+ lineStyle: { color: chartGradient(colors), width: theme.lineWidth, type: entry.stroke === "solid" || !entry.stroke ? "solid" : "dashed", cap: "round" },
58
+ itemStyle: dotItemStyle(theme, colors[0], entry.dotStyle),
59
+ areaStyle: isArea ? { color: areaPaint(theme, colors, entry.variant ?? spec.variant ?? "gradient"), opacity: 1 } : undefined,
60
+ emphasis: { focus: spec.hoverHighlight ? "series" : "none", scale: true, lineStyle: { width: theme.lineWidth }, itemStyle: dotItemStyle(theme, colors[0], entry.activeDotStyle ?? entry.dotStyle) },
61
+ blur: { lineStyle: { opacity: theme.opacity.muted }, areaStyle: { opacity: theme.opacity.muted }, itemStyle: { opacity: theme.opacity.muted } },
62
+ selectedMode: "single",
63
+ select: { itemStyle: { color: colors[0] } },
64
+ }
65
+ if (!hasBuffer) return [base]
66
+ return [base, {
67
+ ...base, id: `${entry.key}-buffer`,
68
+ data: values.map((value, point) => point >= values.length - 2 ? value : null),
69
+ stack: undefined, silent: true,
70
+ lineStyle: { ...base.lineStyle, type: "dashed" },
71
+ }]
72
+ }
73
+
74
+ function barSeries(theme: EChartTheme, spec: ChartSpec, entry: ChartSeries, index: number, all: ChartSeries[]): (BarSeriesOption | PictorialBarSeriesOption)[] {
75
+ const values = valuesFor(spec, entry, all)
76
+ const colors = seriesColors(theme, entry, index)
77
+ const variant = entry.variant ?? spec.variant ?? "default"
78
+ const totals = spec.data.map((row) => all.reduce((total, series) => total + (chartNumber(row[series.key]) ?? 0), 0))
79
+ const maximumIndex = totals.indexOf(Math.max(...totals))
80
+ const fill = barPaint(theme, colors, variant, Boolean(spec.horizontal))
81
+ const data = values.map((value, point) => ({
82
+ value,
83
+ itemStyle: {
84
+ color: spec.highlightMax && point !== maximumIndex ? theme.neutral : fill,
85
+ decal: chartDecal(theme, colors[0], entry.buffer && point === values.length - 1 ? "hatched" : variant),
86
+ borderWidth: entry.buffer && point === values.length - 1 ? theme.borderWidth : 0,
87
+ borderColor: colors[0],
88
+ borderRadius: variant === "blocks" || variant === "expandable" ? 0 : barEndRadius(theme, spec, all, index, point, value),
89
+ },
90
+ }))
91
+ const base: BarSeriesOption = {
92
+ id: entry.key, name: entry.label ?? entry.key, type: "bar", data,
93
+ stack: spec.stack && spec.stack !== "default" ? "total" : undefined,
94
+ barMaxWidth: theme.space.xxl,
95
+ barCategoryGap: "35%",
96
+ itemStyle: { color: fill, borderRadius: 0 },
97
+ emphasis: { focus: spec.hoverHighlight ? "series" : "none", itemStyle: { color: colors[0] }, label: { show: variant === "expandable", position: spec.horizontal ? "right" : "top", color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.fontSize, fontWeight: theme.mediumWeight } },
98
+ blur: { itemStyle: { opacity: theme.opacity.muted } },
99
+ selectedMode: "single",
100
+ select: { itemStyle: { color: colors[0] } },
101
+ }
102
+ if (variant !== "blocks") return [base]
103
+ const max = spec.max ?? Math.max(1, ...values.map((value) => value ?? 0))
104
+ const blocks: PictorialBarSeriesOption = {
105
+ id: entry.key, name: base.name, type: "pictorialBar", data,
106
+ symbol: "rect", symbolRepeat: true, symbolClip: true,
107
+ symbolSize: spec.horizontal ? [theme.space.sm, theme.space.lg] : [theme.space.lg, theme.space.sm],
108
+ symbolMargin: theme.borderWidth, symbolBoundingData: max,
109
+ itemStyle: { color: colors[0] }, z: 2,
110
+ emphasis: { itemStyle: { color: colors[0] } },
111
+ }
112
+ const background: PictorialBarSeriesOption = {
113
+ ...blocks, id: `${entry.key}-track`, name: "", data: values.map(() => max),
114
+ symbolClip: false, silent: true, tooltip: { show: false },
115
+ itemStyle: { color: theme.neutral }, z: 0,
116
+ }
117
+ return [background, blocks]
118
+ }
119
+
120
+ export function cartesianOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
121
+ const all = chartSeries(spec)
122
+ const percent = spec.stack === "percent" || spec.stack === "expanded"
123
+ const category = {
124
+ type: "category" as const,
125
+ data: spec.data.map((row) => String(row[spec.categoryKey ?? "name"] ?? "")),
126
+ boundaryGap: spec.family === "bar" || spec.family === "composed",
127
+ inverse: spec.horizontal ? true : theme.rtl,
128
+ position: spec.horizontal && theme.rtl ? "right" as const : undefined,
129
+ axisTick: { show: false }, axisLine: { show: false },
130
+ axisLabel: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, margin: theme.space.md },
131
+ }
132
+ const value = {
133
+ type: "value" as const,
134
+ inverse: Boolean(spec.horizontal && theme.rtl),
135
+ position: !spec.horizontal && theme.rtl ? "right" as const : undefined,
136
+ max: percent ? 100 : spec.max,
137
+ axisLine: { show: false }, axisTick: { show: false },
138
+ axisLabel: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, formatter: percent ? "{value}%" : undefined },
139
+ splitLine: { lineStyle: { color: theme.border, width: theme.borderWidth, type: "dashed" as const } },
140
+ }
141
+ const series = all.flatMap<LineSeriesOption | BarSeriesOption | PictorialBarSeriesOption>((entry, index) => isBarSeries(spec, entry)
142
+ ? barSeries(theme, spec, entry, index, all)
143
+ : lineSeries(theme, spec, entry, index, all))
144
+ return {
145
+ grid: { left: theme.rtl ? theme.space.lg : theme.space.axis, right: theme.rtl ? theme.space.axis : theme.space.lg, top: spec.legend === false ? theme.space.lg : theme.space.axis, bottom: spec.brush ? theme.space.axis * 2 : theme.space.axis },
146
+ xAxis: spec.horizontal ? value : category,
147
+ yAxis: spec.horizontal ? category : value,
148
+ legend: { show: spec.legend !== false, data: all.map((entry) => entry.label ?? entry.key), top: 0, right: 0 },
149
+ tooltip: { trigger: "axis", formatter: cartesianTooltip(theme, spec), axisPointer: { type: "line", lineStyle: { color: theme.border, width: theme.borderWidth } } },
150
+ dataZoom: spec.brush ? [{
151
+ type: "slider", xAxisIndex: spec.horizontal ? undefined : 0, yAxisIndex: spec.horizontal ? 0 : undefined,
152
+ bottom: theme.space.xs, height: theme.space.xxl, start: 0, end: 100,
153
+ borderColor: theme.border, backgroundColor: theme.surface, fillerColor: theme.neutral,
154
+ handleStyle: { color: theme.surface, borderColor: theme.border },
155
+ moveHandleStyle: { color: theme.border },
156
+ textStyle: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize },
157
+ dataBackground: { lineStyle: { color: theme.muted }, areaStyle: { color: theme.neutral } },
158
+ selectedDataBackground: { lineStyle: { color: theme.palette[0] }, areaStyle: { color: theme.neutral } },
159
+ }] : [],
160
+ series,
161
+ }
162
+ }
@@ -0,0 +1,99 @@
1
+ import { color, graphic } from "echarts/core"
2
+ import type { EChartTheme } from "./echart-theme"
3
+ import { chartColor, type ChartSeries, type ChartVariant } from "./chart-options.types"
4
+
5
+ export function chartAlpha(paint: string, opacity: number): string {
6
+ return color.modifyAlpha(paint, opacity) ?? paint
7
+ }
8
+
9
+ export function seriesColors(theme: EChartTheme, series: ChartSeries, index: number): string[] {
10
+ return (series.colorIndices?.length ? series.colorIndices : [series.colorIndex ?? index]).map((slot) => chartColor(theme, slot))
11
+ }
12
+
13
+ export function chartGradient(colors: string[], vertical = false) {
14
+ if (colors.length === 1) return colors[0]
15
+ return new graphic.LinearGradient(0, 0, vertical ? 0 : 1, vertical ? 1 : 0,
16
+ colors.map((paint, index) => ({ offset: index / (colors.length - 1), color: paint })))
17
+ }
18
+
19
+ export function areaPaint(theme: EChartTheme, colors: string[], variant: ChartVariant) {
20
+ if (["dotted", "lines", "hatched"].includes(variant) && typeof document !== "undefined") {
21
+ const tile = document.createElement("canvas")
22
+ tile.width = Math.max(1, Math.round(theme.space.md))
23
+ tile.height = tile.width
24
+ const context = tile.getContext("2d")
25
+ if (context) {
26
+ context.fillStyle = chartAlpha(colors[0], theme.opacity.soft)
27
+ context.fillRect(0, 0, tile.width, tile.height)
28
+ context.fillStyle = chartAlpha(colors[0], theme.opacity.area)
29
+ context.strokeStyle = context.fillStyle
30
+ context.lineWidth = theme.borderWidth
31
+ if (variant === "dotted") {
32
+ context.beginPath()
33
+ context.arc(tile.width / 2, tile.height / 2, theme.borderWidth, 0, Math.PI * 2)
34
+ context.fill()
35
+ } else if (variant === "lines") {
36
+ context.fillRect(0, 0, tile.width, theme.borderWidth)
37
+ } else {
38
+ for (const offset of [-tile.width, 0, tile.width]) {
39
+ context.beginPath()
40
+ context.moveTo(offset, tile.height)
41
+ context.lineTo(offset + tile.width, 0)
42
+ context.stroke()
43
+ }
44
+ }
45
+ return { image: tile, repeat: "repeat" as const }
46
+ }
47
+ }
48
+ if (variant === "gradient" || variant === "gradient-reverse") {
49
+ const reverse = variant === "gradient-reverse"
50
+ return new graphic.LinearGradient(0, 0, 0, 1, [
51
+ { offset: 0, color: chartAlpha(colors[0], reverse ? 0 : theme.opacity.area) },
52
+ { offset: 1, color: chartAlpha(colors.at(-1)!, reverse ? theme.opacity.area : 0) },
53
+ ])
54
+ }
55
+ return chartGradient(colors.map((paint) => chartAlpha(paint, theme.opacity.area)))
56
+ }
57
+
58
+ export function chartDecal(theme: EChartTheme, paint: string, variant: ChartVariant) {
59
+ if (!["hatched", "lines", "dotted"].includes(variant)) return undefined
60
+ return {
61
+ symbol: variant === "dotted" ? "circle" : "rect",
62
+ symbolSize: variant === "dotted" ? theme.opacity.area : 1,
63
+ color: paint,
64
+ backgroundColor: chartAlpha(paint, theme.opacity.soft),
65
+ dashArrayX: variant === "dotted" ? [theme.space.xs, theme.space.xs] : [1, 0],
66
+ dashArrayY: [theme.borderWidth, theme.space.xs],
67
+ rotation: variant === "hatched" ? -Math.PI / 4 : 0,
68
+ }
69
+ }
70
+
71
+ export function expandablePaint(paint: string, fraction: number, horizontal: boolean) {
72
+ const low = (1 - fraction) / 2
73
+ const high = 1 - low
74
+ const clear = chartAlpha(paint, 0)
75
+ return new graphic.LinearGradient(0, 0, horizontal ? 0 : 1, horizontal ? 1 : 0, [
76
+ { offset: 0, color: clear }, { offset: low, color: clear },
77
+ { offset: low, color: paint }, { offset: high, color: paint },
78
+ { offset: high, color: clear }, { offset: 1, color: clear },
79
+ ])
80
+ }
81
+
82
+ export function barPaint(theme: EChartTheme, colors: string[], variant: ChartVariant, horizontal: boolean, capFraction = theme.space.xs / theme.space.axis) {
83
+ if (variant === "expandable") return expandablePaint(colors[0], theme.opacity.soft, horizontal)
84
+ if (variant === "gradient") return new graphic.LinearGradient(0, 0, horizontal ? 1 : 0, horizontal ? 0 : 1, [
85
+ { offset: 0, color: colors[0] }, { offset: 1, color: chartAlpha(colors.at(-1)!, theme.opacity.soft) },
86
+ ])
87
+ if (variant === "duotone" || variant === "duotone-reverse" || variant === "stripped") {
88
+ const muted = chartAlpha(colors[0], theme.opacity.area)
89
+ const reverse = variant === "duotone-reverse" || (variant === "stripped" && horizontal)
90
+ const first = reverse ? muted : colors[0]
91
+ const second = reverse ? colors[0] : muted
92
+ const split = variant === "stripped" ? horizontal ? 1 - capFraction : capFraction : 0.5
93
+ return new graphic.LinearGradient(0, 0, horizontal ? 1 : 0, horizontal ? 0 : 1, [
94
+ { offset: 0, color: first }, { offset: split, color: first },
95
+ { offset: split, color: second }, { offset: 1, color: second },
96
+ ])
97
+ }
98
+ return chartGradient(colors, !horizontal)
99
+ }
@@ -0,0 +1,72 @@
1
+ import type { EChartsOption, LegendComponentOption, TooltipComponentOption } from "echarts"
2
+ import type { EChartTheme } from "./echart-theme"
3
+ import { chartAlpha } from "./chart-options.paint"
4
+ import { chartColor, chartNumber, chartSeries, type ChartDotStyle, type ChartSpec } from "./chart-options.types"
5
+
6
+ export function dotItemStyle(theme: EChartTheme, paint: string, style: ChartDotStyle = "default") {
7
+ return {
8
+ color: style === "colored-border" ? theme.surface : paint,
9
+ borderColor: style === "border" ? theme.surface : paint,
10
+ borderWidth: style === "border" ? theme.lineWidth : style === "colored-border" ? theme.borderWidth : 0,
11
+ }
12
+ }
13
+
14
+ function escapeHtml(value: string) {
15
+ return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!)
16
+ }
17
+
18
+ /** Read the original row so split forecast series don't duplicate or hide tooltip values. */
19
+ export function cartesianTooltip(theme: EChartTheme, spec: ChartSpec): NonNullable<TooltipComponentOption["formatter"]> {
20
+ const entries = chartSeries(spec)
21
+ return (params) => {
22
+ const point = Array.isArray(params) ? params[0] : params
23
+ const row = spec.data[point.dataIndex]
24
+ if (!row) return ""
25
+ const title = escapeHtml(String(row[spec.categoryKey ?? "name"] ?? ""))
26
+ const rows = entries.map((entry) => {
27
+ const value = chartNumber(row[entry.key])
28
+ return `<div>${escapeHtml(entry.label ?? entry.key)}: <span style="font-weight:${theme.mediumWeight}">${value === null ? "—" : value.toLocaleString()}</span></div>`
29
+ })
30
+ return `<div>${title}</div>${rows.join("")}`
31
+ }
32
+ }
33
+
34
+ function styleLegend(theme: EChartTheme, spec: ChartSpec, legend: LegendComponentOption): LegendComponentOption {
35
+ const variant = spec.legendIndicator ?? "rounded-square"
36
+ const all = chartSeries(spec)
37
+ const data = legend.data ?? (spec.family === "pie" || spec.family === "radial"
38
+ ? spec.data.map((row) => String(row[spec.categoryKey ?? "name"] ?? ""))
39
+ : all.map((series) => series.label ?? series.key))
40
+ const outlined = variant.endsWith("outline")
41
+ const icon = variant.startsWith("circle") ? "circle" : variant.startsWith("rounded") ? "roundRect" : "rect"
42
+ return {
43
+ ...legend, icon, align: theme.rtl ? "right" : "left",
44
+ itemWidth: variant === "vertical-bar" ? theme.space.xs : variant === "horizontal-bar" ? theme.space.lg : theme.space.md,
45
+ itemHeight: variant === "horizontal-bar" ? theme.space.xs : variant === "vertical-bar" ? theme.space.lg : theme.space.md,
46
+ data: data.map((item, index) => {
47
+ const paint = chartColor(theme, all[index]?.colorIndex ?? index)
48
+ return { ...(typeof item === "string" ? { name: item } : item), icon, itemStyle: { color: outlined ? theme.surface : paint, borderColor: paint, borderWidth: outlined ? theme.borderWidth : 0 } }
49
+ }),
50
+ }
51
+ }
52
+
53
+ export function styleChartParts(theme: EChartTheme, spec: ChartSpec, option: EChartsOption): EChartsOption {
54
+ const originalTooltip = !Array.isArray(option.tooltip) ? option.tooltip ?? {} : {}
55
+ const tooltip: TooltipComponentOption = {
56
+ ...originalTooltip,
57
+ backgroundColor: spec.tooltipSurface === "translucent" ? chartAlpha(theme.surface, theme.translucentOpacity ?? theme.opacity.muted) : theme.surface,
58
+ borderColor: theme.border, borderWidth: theme.borderWidth,
59
+ borderRadius: theme.radii?.[spec.tooltipRadius ?? "lg"] ?? theme.radius,
60
+ textStyle: { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.fontSize, fontWeight: theme.fontWeight, lineHeight: theme.lineHeight },
61
+ axisPointer: { ...originalTooltip.axisPointer, show: spec.tooltipCursor !== false },
62
+ }
63
+ if (spec.tooltipPosition === "fixed") {
64
+ tooltip.position = (point, _params, _element, _rect, size) => [Math.max(0, Math.min(point[0] - size.contentSize[0] / 2, size.viewSize[0] - size.contentSize[0])), theme.space.lg]
65
+ }
66
+ return {
67
+ ...option, tooltip,
68
+ legend: Array.isArray(option.legend)
69
+ ? option.legend.map((legend) => styleLegend(theme, spec, legend))
70
+ : option.legend ? styleLegend(theme, spec, option.legend) : undefined,
71
+ }
72
+ }
@@ -0,0 +1,109 @@
1
+ import type { EChartsOption } from "echarts"
2
+ import type { BarSeriesOption, PieSeriesOption, RadarSeriesOption } from "echarts/charts"
3
+ import type { EChartTheme } from "./echart-theme"
4
+ import { chartAlpha, chartGradient, seriesColors } from "./chart-options.paint"
5
+ import { chartColor, chartNumber, chartSeries, type ChartSpec } from "./chart-options.types"
6
+ import { dotItemStyle } from "./chart-options.parts"
7
+
8
+ function labelStyle(theme: EChartTheme) {
9
+ return { color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, fontWeight: theme.mediumWeight, lineHeight: theme.lineHeight }
10
+ }
11
+
12
+ export function pieOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
13
+ const entry = chartSeries(spec)[0]
14
+ const category = spec.categoryKey ?? "name"
15
+ const donut = ["donut", "padded", "petal"].includes(spec.variant ?? "")
16
+ const data = spec.data.flatMap((row, index) => {
17
+ const value = chartNumber(row[entry.key])
18
+ if (value === null || value < 0) return []
19
+ const colors = entry.colorIndices?.length
20
+ ? [chartColor(theme, index), chartColor(theme, index + 1)]
21
+ : [chartColor(theme, index + (entry.colorIndex ?? 0))]
22
+ return [{ name: String(row[category] ?? ""), value, itemStyle: { color: chartGradient(colors) } }]
23
+ })
24
+ const series: PieSeriesOption = {
25
+ id: entry.key, type: "pie", data,
26
+ radius: [donut ? "48%" : "0%", spec.labels === "outside" ? "58%" : "74%"],
27
+ center: ["50%", "50%"],
28
+ padAngle: spec.variant === "padded" ? theme.borderWidth * 2 : 0,
29
+ itemStyle: {
30
+ borderRadius: spec.variant === "petal" || spec.variant === "padded" ? theme.markRadius : 0,
31
+ borderColor: theme.surface,
32
+ borderWidth: spec.variant === "petal" ? theme.space.xs : spec.variant === "padded" ? theme.borderWidth : 0,
33
+ },
34
+ selectedMode: "single", selectedOffset: theme.space.md,
35
+ emphasis: { scale: true, scaleSize: theme.space.xs, label: labelStyle(theme) },
36
+ label: { ...labelStyle(theme), show: spec.labels === "inside" || spec.labels === "outside", position: spec.labels === "inside" ? "inside" : "outside", formatter: spec.labels === "inside" ? "{d}%" : "{b}", backgroundColor: spec.labels === "inside" ? theme.surface : undefined, padding: spec.labels === "inside" ? theme.space.xs : 0, borderRadius: theme.radius },
37
+ labelLine: { show: spec.labels === "outside", lineStyle: { color: theme.border, width: theme.borderWidth }, length: theme.space.md, length2: theme.space.md },
38
+ }
39
+ return { legend: { show: spec.legend !== false, bottom: 0 }, tooltip: { trigger: "item" }, series: [series] }
40
+ }
41
+
42
+ export function radialOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
43
+ const entry = chartSeries(spec)[0]
44
+ const rows = spec.data.filter((row) => chartNumber(row[entry.key]) !== null && Number(row[entry.key]) >= 0)
45
+ const categories = rows.map((row) => String(row[spec.categoryKey ?? "name"] ?? ""))
46
+ const max = spec.max && spec.max > 0 ? spec.max : Math.max(1, ...rows.map((row) => Number(row[entry.key])))
47
+ const semi = spec.variant === "semi"
48
+ const series: BarSeriesOption[] = rows.map((row, index) => ({
49
+ id: `${entry.key}-${index}`, name: categories[index], type: "bar", coordinateSystem: "polar", polarIndex: 0,
50
+ data: [{ name: categories[index], value: [Number(row[entry.key]), index] }],
51
+ encode: { angle: 0, radius: 1 },
52
+ // Every series occupies a different category. Sharing the stack keeps ECharts
53
+ // from allocating a progressively smaller grouped bar band to each ring.
54
+ stack: "rings", barWidth: theme.space.lg, roundCap: true,
55
+ showBackground: false, z: 3,
56
+ itemStyle: { color: chartGradient(entry.colorIndices?.length ? [chartColor(theme, index), chartColor(theme, index + 1)] : [chartColor(theme, index + (entry.colorIndex ?? 0))]) },
57
+ emphasis: { focus: "series" },
58
+ selectedMode: "single",
59
+ label: { ...labelStyle(theme), show: spec.labels === "inside", position: "middle", backgroundColor: theme.surface, padding: theme.space.xs, borderRadius: theme.radius },
60
+ }))
61
+ // Native polar backgrounds always draw 360 degrees. A second identical polar
62
+ // gives the explicit track its own bar band and respects the semi-circle sweep.
63
+ series.push({
64
+ id: "ring-tracks", type: "bar", coordinateSystem: "polar", polarIndex: 1,
65
+ data: rows.map((_row, index) => ({ value: [max, index] })), encode: { angle: 0, radius: 1 },
66
+ barWidth: theme.space.lg, roundCap: true, silent: true, animation: false,
67
+ itemStyle: { color: theme.neutral }, emphasis: { disabled: true },
68
+ tooltip: { show: false }, z: 1,
69
+ })
70
+ return {
71
+ polar: [0, 1].map(() => ({ radius: ["28%", "78%"], center: ["50%", semi ? "68%" : "46%"] })),
72
+ angleAxis: [0, 1].map((polarIndex) => ({ polarIndex, type: "value", min: 0, max, startAngle: semi ? 180 : 90, endAngle: semi ? 0 : -270, clockwise: true, show: false })),
73
+ radiusAxis: [0, 1].map((polarIndex) => ({ polarIndex, type: "category", data: categories, inverse: true, show: false })),
74
+ legend: { show: spec.legend !== false, bottom: 0, data: categories },
75
+ tooltip: { trigger: "item", valueFormatter: (value) => String(Array.isArray(value) ? value[0] : value) },
76
+ series,
77
+ }
78
+ }
79
+
80
+ export function radarOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
81
+ const all = chartSeries(spec)
82
+ const max = spec.max && spec.max > 0 ? spec.max : Math.max(1, ...spec.data.flatMap((row) => all.map((entry) => chartNumber(row[entry.key]) ?? 0)))
83
+ const series: RadarSeriesOption[] = all.flatMap((entry, index) => {
84
+ const values = spec.data.map((row) => chartNumber(row[entry.key]))
85
+ if (values.some((value) => value === null)) return []
86
+ const colors = seriesColors(theme, entry, index)
87
+ return [{
88
+ id: entry.key, name: entry.label ?? entry.key, type: "radar",
89
+ data: [{ name: entry.label ?? entry.key, value: values as number[] }],
90
+ lineStyle: { color: chartGradient(colors), width: theme.lineWidth },
91
+ areaStyle: entry.variant === "lines" || spec.variant === "lines" ? { opacity: 0 } : { color: chartGradient(colors.map((paint) => chartAlpha(paint, theme.opacity.area))), opacity: 1 },
92
+ symbol: entry.dots || entry.dotStyle ? "circle" : "none", symbolSize: entry.dotStyle === "border" ? theme.space.md : theme.space.sm,
93
+ itemStyle: dotItemStyle(theme, colors[0], entry.dotStyle),
94
+ emphasis: { focus: "self", itemStyle: dotItemStyle(theme, colors[0], entry.activeDotStyle ?? entry.dotStyle) },
95
+ }]
96
+ })
97
+ return {
98
+ radar: {
99
+ indicator: spec.data.map((row) => ({ name: String(row[spec.categoryKey ?? "name"] ?? ""), max })),
100
+ shape: spec.grid === "circle" ? "circle" : "polygon", radius: "65%",
101
+ axisName: { color: theme.muted, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize },
102
+ axisLine: { lineStyle: { color: theme.border, width: theme.borderWidth } },
103
+ splitLine: { lineStyle: { color: theme.border, width: theme.borderWidth } },
104
+ splitArea: { show: false },
105
+ },
106
+ legend: { show: spec.legend !== false, bottom: 0, data: all.map((entry) => entry.label ?? entry.key) },
107
+ tooltip: { trigger: "item" }, series,
108
+ }
109
+ }
@@ -0,0 +1,44 @@
1
+ import type { EChartsOption } from "echarts"
2
+ import type { EChartTheme } from "./echart-theme"
3
+ import { cartesianOption } from "./chart-options.cartesian"
4
+ import { pieOption, radarOption, radialOption } from "./chart-options.radial"
5
+ import { chartColor, chartNumber, type ChartSpec } from "./chart-options.types"
6
+ import { styleChartParts } from "./chart-options.parts"
7
+
8
+ export type { ChartCurve, ChartDatum, ChartDotStyle, ChartFamily, ChartLegendIndicator, ChartSeries, ChartSpec, ChartStroke, ChartVariant } from "./chart-options.types"
9
+
10
+ /** Build native ECharts options using only resolved Banhaten tokens. */
11
+ export function createChartOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
12
+ return styleChartParts(theme, spec, createFamilyOption(theme, spec))
13
+ }
14
+
15
+ function createFamilyOption(theme: EChartTheme, spec: ChartSpec): EChartsOption {
16
+ if (["area", "line", "bar", "composed"].includes(spec.family)) return cartesianOption(theme, spec)
17
+ if (spec.family === "pie") return pieOption(theme, spec)
18
+ if (spec.family === "radial") return radialOption(theme, spec)
19
+ if (spec.family === "radar") return radarOption(theme, spec)
20
+ const nodes = spec.nodes ?? spec.data.map((row) => ({ name: String(row[spec.categoryKey ?? "name"] ?? "") }))
21
+ const names = new Set(nodes.map((node) => node.name))
22
+ const resolveName = (value: string | number) => typeof value === "number" ? nodes[value]?.name : value
23
+ const links = (spec.links ?? []).flatMap((link) => {
24
+ const source = resolveName(link.source)
25
+ const target = resolveName(link.target)
26
+ const value = chartNumber(link.value)
27
+ return source && target && names.has(source) && names.has(target) && value !== null && value > 0
28
+ ? [{ source, target, value }]
29
+ : []
30
+ })
31
+ return {
32
+ tooltip: { trigger: "item" },
33
+ series: [{
34
+ id: "flow", type: "sankey", orient: spec.horizontal === false ? "vertical" : "horizontal",
35
+ left: theme.space.lg, right: theme.space.axis * 2, top: theme.space.lg, bottom: theme.space.lg,
36
+ data: nodes.map((node, index) => ({ name: node.name, itemStyle: { color: chartColor(theme, "colorIndex" in node ? Number(node.colorIndex) : index), borderWidth: 0 } })),
37
+ links, nodeWidth: spec.labels === "inside" ? theme.space.axis * 2 : theme.space.lg,
38
+ nodeGap: theme.space.xl, draggable: false,
39
+ lineStyle: { color: spec.variant === "solid" ? theme.palette[0] : spec.variant === "target" ? "target" : spec.variant === "gradient" ? "gradient" : "source", opacity: theme.opacity.area, curveness: 0.5 },
40
+ emphasis: { focus: "adjacency", lineStyle: { opacity: theme.opacity.muted } },
41
+ label: { show: spec.labels !== "none", position: spec.labels === "inside" ? "inside" : "right", color: theme.foreground, fontFamily: theme.fontFamily, fontSize: theme.labelFontSize, fontWeight: theme.fontWeight, distance: theme.space.sm, backgroundColor: spec.labels === "inside" ? theme.surface : undefined, padding: spec.labels === "inside" ? theme.space.xs : 0, borderRadius: theme.radius },
42
+ }],
43
+ }
44
+ }