@linxiraos/pi-tui 1.1.26 → 1.2.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 (423) hide show
  1. package/CHANGELOG.md +51 -491
  2. package/README.md +13 -7
  3. package/dist/types/active-terminal.d.ts +30 -0
  4. package/dist/types/app-keybindings.d.ts +7 -2
  5. package/dist/types/apps/cleanse-board.d.ts +1 -1
  6. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  7. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  8. package/dist/types/apps/git/git-tui.d.ts +5 -4
  9. package/dist/types/apps/git/help.d.ts +3 -1
  10. package/dist/types/apps/if-bench-board.d.ts +2 -2
  11. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  12. package/dist/types/autocomplete.d.ts +5 -2
  13. package/dist/types/charts/chart-plan.d.ts +674 -0
  14. package/dist/types/charts/chart-svg.d.ts +59 -0
  15. package/dist/types/charts/table-data.d.ts +175 -0
  16. package/dist/types/chat/assistant-message.d.ts +11 -1
  17. package/dist/types/chat/chat-transcript-builder.d.ts +3 -0
  18. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  19. package/dist/types/chat/execution-shared.d.ts +6 -5
  20. package/dist/types/chat/fence-figure.d.ts +42 -0
  21. package/dist/types/chat/figure-markdown.d.ts +42 -0
  22. package/dist/types/chat/messages.d.ts +1 -1
  23. package/dist/types/chat/svg-figure.d.ts +33 -0
  24. package/dist/types/chat/svg-source.d.ts +49 -0
  25. package/dist/types/chat/table-chart.d.ts +89 -0
  26. package/dist/types/chat/tool-execution.d.ts +2 -0
  27. package/dist/types/chat/user-message.d.ts +17 -4
  28. package/dist/types/chrome/shared.d.ts +9 -0
  29. package/dist/types/chrome/status-notice.d.ts +15 -3
  30. package/dist/types/chrome/transcript-container.d.ts +6 -0
  31. package/dist/types/components/box.d.ts +1 -0
  32. package/dist/types/components/disclosure.d.ts +1 -0
  33. package/dist/types/components/editor.d.ts +23 -5
  34. package/dist/types/components/form.d.ts +1 -0
  35. package/dist/types/components/image.d.ts +26 -44
  36. package/dist/types/components/input.d.ts +2 -1
  37. package/dist/types/components/key-value-list.d.ts +1 -0
  38. package/dist/types/components/layout/row.d.ts +1 -0
  39. package/dist/types/components/layout/stack.d.ts +1 -0
  40. package/dist/types/components/loader.d.ts +5 -6
  41. package/dist/types/components/markdown.d.ts +47 -5
  42. package/dist/types/components/scroll-view.d.ts +1 -0
  43. package/dist/types/components/section.d.ts +1 -0
  44. package/dist/types/components/table.d.ts +1 -0
  45. package/dist/types/components/text.d.ts +1 -0
  46. package/dist/types/components/tree-view.d.ts +7 -0
  47. package/dist/types/components/truncated-text.d.ts +1 -0
  48. package/dist/types/fuzzy.d.ts +29 -1
  49. package/dist/types/key-hint-format.d.ts +9 -1
  50. package/dist/types/keybinding-matchers.d.ts +3 -0
  51. package/dist/types/keybindings.d.ts +1 -1
  52. package/dist/types/kitty-graphics.d.ts +2 -2
  53. package/dist/types/loop-watchdog.d.ts +8 -7
  54. package/dist/types/multiplexers/cmux.d.ts +16 -0
  55. package/dist/types/multiplexers/herdr.d.ts +26 -0
  56. package/dist/types/multiplexers/notify.d.ts +13 -0
  57. package/dist/types/multiplexers/orca.d.ts +12 -0
  58. package/dist/types/multiplexers/screen.d.ts +9 -0
  59. package/dist/types/multiplexers/tmux.d.ts +29 -0
  60. package/dist/types/multiplexers/types.d.ts +100 -0
  61. package/dist/types/multiplexers/wmux.d.ts +12 -0
  62. package/dist/types/multiplexers/zellij.d.ts +12 -0
  63. package/dist/types/native/apply.d.ts +4 -3
  64. package/dist/types/native/backend.d.ts +5 -4
  65. package/dist/types/native/blobs.d.ts +7 -3
  66. package/dist/types/native/encode.d.ts +8 -6
  67. package/dist/types/native/node.d.ts +36 -5
  68. package/dist/types/native/overlay.d.ts +8 -3
  69. package/dist/types/native/settle.d.ts +4 -3
  70. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  71. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  72. package/dist/types/overlays/agent-hub.d.ts +3 -0
  73. package/dist/types/overlays/agent-transcript-viewer.d.ts +12 -2
  74. package/dist/types/overlays/agents-hub.d.ts +9 -0
  75. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  76. package/dist/types/overlays/annotation-types.d.ts +2 -0
  77. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  78. package/dist/types/overlays/btw-history-panel.d.ts +13 -1
  79. package/dist/types/overlays/copy-selector.d.ts +1 -0
  80. package/dist/types/overlays/history-search.d.ts +10 -0
  81. package/dist/types/overlays/hook-selector.d.ts +9 -4
  82. package/dist/types/overlays/hub-frame.d.ts +4 -1
  83. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  84. package/dist/types/overlays/model-browser.d.ts +54 -7
  85. package/dist/types/overlays/model-hub.d.ts +37 -0
  86. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  87. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  88. package/dist/types/overlays/report-panel.d.ts +68 -0
  89. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  90. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  91. package/dist/types/overlays/session-selector.d.ts +6 -2
  92. package/dist/types/overlays/settings-defs.d.ts +1 -0
  93. package/dist/types/overlays/thinking-selector.d.ts +2 -2
  94. package/dist/types/overlays/usage-dashboard.d.ts +12 -8
  95. package/dist/types/overlays/usage-display.d.ts +3 -1
  96. package/dist/types/prompt/composer-cache.d.ts +0 -16
  97. package/dist/types/prompt/composer.d.ts +18 -3
  98. package/dist/types/prompt/custom-editor.d.ts +25 -13
  99. package/dist/types/prompt/image-source.d.ts +2 -1
  100. package/dist/types/prompt/interactive-context-helpers.d.ts +5 -1
  101. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  102. package/dist/types/prompt/welcome.d.ts +7 -2
  103. package/dist/types/render/composite.d.ts +2 -0
  104. package/dist/types/render/output-pane.d.ts +1 -0
  105. package/dist/types/render/render-utils.d.ts +1 -0
  106. package/dist/types/render/tool-card.d.ts +6 -0
  107. package/dist/types/render/width-aware-text.d.ts +2 -0
  108. package/dist/types/setup/scenes/types.d.ts +1 -0
  109. package/dist/types/space-hold.d.ts +40 -25
  110. package/dist/types/status-line/component.d.ts +3 -0
  111. package/dist/types/status-line/context-usage.d.ts +7 -5
  112. package/dist/types/status-line/host.d.ts +4 -1
  113. package/dist/types/status-line/segments.d.ts +2 -0
  114. package/dist/types/status-line/types.d.ts +14 -3
  115. package/dist/types/terminal-capabilities.d.ts +41 -36
  116. package/dist/types/terminal-multiplexer.d.ts +127 -14
  117. package/dist/types/terminal.d.ts +23 -16
  118. package/dist/types/theme/active-symbols.d.ts +1 -1
  119. package/dist/types/theme/schema.d.ts +11 -2
  120. package/dist/types/theme/symbols.d.ts +1 -1
  121. package/dist/types/theme/theme-class.d.ts +7 -0
  122. package/dist/types/theme/theme.d.ts +1 -1
  123. package/dist/types/theme/tui-adapters.d.ts +3 -0
  124. package/dist/types/thinking.d.ts +1 -1
  125. package/dist/types/tmux.d.ts +11 -4
  126. package/dist/types/tools/bash.d.ts +7 -2
  127. package/dist/types/tools/daemon.d.ts +1 -1
  128. package/dist/types/tools/eval.d.ts +22 -3
  129. package/dist/types/tools/glob.d.ts +4 -0
  130. package/dist/types/tools/native-view.d.ts +47 -0
  131. package/dist/types/tools/output-meta.d.ts +11 -1
  132. package/dist/types/tools/renderer.d.ts +37 -2
  133. package/dist/types/tools/streaming-output.d.ts +20 -1
  134. package/dist/types/tools/task.d.ts +2 -3
  135. package/dist/types/tools/write.d.ts +9 -2
  136. package/dist/types/tools/yield.d.ts +22 -0
  137. package/dist/types/tui.d.ts +36 -0
  138. package/dist/types/utils.d.ts +6 -0
  139. package/dist/types/vim.d.ts +19 -5
  140. package/dist/types/windows-input-mode.d.ts +33 -1
  141. package/package.json +13 -9
  142. package/src/active-terminal.d.ts +30 -0
  143. package/src/active-terminal.ts +80 -0
  144. package/src/app-keybindings.d.ts +7 -2
  145. package/src/app-keybindings.ts +7 -2
  146. package/src/apps/autoresearch-dashboard.ts +7 -3
  147. package/src/apps/cleanse-board.ts +2 -2
  148. package/src/apps/cleanse-picker.ts +1 -1
  149. package/src/apps/debug/log-viewer.ts +188 -72
  150. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  151. package/src/apps/debug/raw-sse.ts +39 -5
  152. package/src/apps/git/diff-pane.ts +96 -23
  153. package/src/apps/git/git-tui.ts +23 -16
  154. package/src/apps/git/help.ts +29 -20
  155. package/src/apps/git/sidebar.ts +8 -3
  156. package/src/apps/if-bench-board.ts +2 -2
  157. package/src/apps/ps-top.ts +2 -2
  158. package/src/apps/setup-model-picker.ts +1 -1
  159. package/src/autocomplete.d.ts +5 -2
  160. package/src/autocomplete.ts +67 -43
  161. package/src/bracketed-paste.ts +10 -3
  162. package/src/charts/chart-plan.d.ts +674 -0
  163. package/src/charts/chart-plan.ts +5081 -0
  164. package/src/charts/chart-svg.d.ts +59 -0
  165. package/src/charts/chart-svg.ts +4657 -0
  166. package/src/charts/table-data.d.ts +175 -0
  167. package/src/charts/table-data.ts +1478 -0
  168. package/src/chat/advisor-message.ts +11 -0
  169. package/src/chat/assistant-message.d.ts +11 -1
  170. package/src/chat/assistant-message.ts +245 -54
  171. package/src/chat/chat-transcript-builder.d.ts +3 -0
  172. package/src/chat/chat-transcript-builder.ts +9 -3
  173. package/src/chat/compaction-summary-message.d.ts +1 -0
  174. package/src/chat/compaction-summary-message.ts +8 -0
  175. package/src/chat/execution-shared.d.ts +6 -5
  176. package/src/chat/execution-shared.ts +42 -25
  177. package/src/chat/fence-figure.d.ts +42 -0
  178. package/src/chat/fence-figure.ts +80 -0
  179. package/src/chat/figure-markdown.d.ts +42 -0
  180. package/src/chat/figure-markdown.ts +174 -0
  181. package/src/chat/messages.d.ts +1 -1
  182. package/src/chat/messages.ts +1 -1
  183. package/src/chat/read-tool-group.ts +6 -4
  184. package/src/chat/skill-message.ts +6 -0
  185. package/src/chat/svg-figure.d.ts +33 -0
  186. package/src/chat/svg-figure.ts +399 -0
  187. package/src/chat/svg-source.d.ts +49 -0
  188. package/src/chat/svg-source.ts +188 -0
  189. package/src/chat/table-chart.d.ts +89 -0
  190. package/src/chat/table-chart.ts +294 -0
  191. package/src/chat/tool-execution.d.ts +2 -0
  192. package/src/chat/tool-execution.ts +86 -7
  193. package/src/chat/user-message.d.ts +17 -4
  194. package/src/chat/user-message.ts +71 -8
  195. package/src/chrome/shared.ts +53 -0
  196. package/src/chrome/status-notice.ts +25 -7
  197. package/src/chrome/transcript-container.d.ts +6 -0
  198. package/src/chrome/transcript-container.ts +162 -19
  199. package/src/components/box.d.ts +1 -0
  200. package/src/components/box.ts +5 -0
  201. package/src/components/disclosure.d.ts +1 -0
  202. package/src/components/disclosure.ts +8 -0
  203. package/src/components/editor.d.ts +23 -5
  204. package/src/components/editor.ts +316 -47
  205. package/src/components/form.d.ts +1 -0
  206. package/src/components/form.ts +8 -0
  207. package/src/components/image.d.ts +26 -44
  208. package/src/components/image.ts +115 -152
  209. package/src/components/input.d.ts +2 -1
  210. package/src/components/input.ts +17 -7
  211. package/src/components/key-value-list.d.ts +1 -0
  212. package/src/components/key-value-list.ts +4 -0
  213. package/src/components/layout/row.d.ts +1 -0
  214. package/src/components/layout/row.ts +7 -0
  215. package/src/components/layout/stack.d.ts +1 -0
  216. package/src/components/layout/stack.ts +7 -0
  217. package/src/components/loader.d.ts +5 -6
  218. package/src/components/loader.ts +10 -17
  219. package/src/components/markdown.d.ts +47 -5
  220. package/src/components/markdown.ts +786 -303
  221. package/src/components/scroll-view.d.ts +1 -0
  222. package/src/components/scroll-view.ts +5 -0
  223. package/src/components/section.d.ts +1 -0
  224. package/src/components/section.ts +5 -0
  225. package/src/components/settings-list.ts +2 -0
  226. package/src/components/table.d.ts +1 -0
  227. package/src/components/table.ts +4 -0
  228. package/src/components/text.d.ts +1 -0
  229. package/src/components/text.ts +7 -0
  230. package/src/components/tree-view.d.ts +7 -0
  231. package/src/components/tree-view.ts +38 -8
  232. package/src/components/truncated-text.d.ts +1 -0
  233. package/src/components/truncated-text.ts +5 -0
  234. package/src/desktop-notify.ts +3 -2
  235. package/src/fuzzy.d.ts +29 -1
  236. package/src/fuzzy.ts +115 -30
  237. package/src/hotkeys-markdown.ts +9 -2
  238. package/src/key-hint-format.d.ts +9 -1
  239. package/src/key-hint-format.ts +26 -11
  240. package/src/keybinding-matchers.d.ts +3 -0
  241. package/src/keybinding-matchers.ts +6 -2
  242. package/src/keybindings.d.ts +1 -1
  243. package/src/keybindings.ts +1 -1
  244. package/src/kitty-graphics.d.ts +2 -2
  245. package/src/kitty-graphics.ts +11 -5
  246. package/src/loop-watchdog.d.ts +8 -7
  247. package/src/loop-watchdog.ts +18 -13
  248. package/src/multiplexers/cmux.d.ts +16 -0
  249. package/src/multiplexers/cmux.ts +29 -0
  250. package/src/multiplexers/herdr.d.ts +26 -0
  251. package/src/multiplexers/herdr.ts +64 -0
  252. package/src/multiplexers/notify.d.ts +13 -0
  253. package/src/multiplexers/notify.ts +24 -0
  254. package/src/multiplexers/orca.d.ts +12 -0
  255. package/src/multiplexers/orca.ts +16 -0
  256. package/src/multiplexers/screen.d.ts +9 -0
  257. package/src/multiplexers/screen.ts +15 -0
  258. package/src/multiplexers/tmux.d.ts +29 -0
  259. package/src/multiplexers/tmux.ts +40 -0
  260. package/src/multiplexers/types.d.ts +100 -0
  261. package/src/multiplexers/types.ts +110 -0
  262. package/src/multiplexers/wmux.d.ts +12 -0
  263. package/src/multiplexers/wmux.ts +16 -0
  264. package/src/multiplexers/zellij.d.ts +12 -0
  265. package/src/multiplexers/zellij.ts +24 -0
  266. package/src/native/apply.d.ts +4 -3
  267. package/src/native/apply.ts +4 -3
  268. package/src/native/backend.d.ts +5 -4
  269. package/src/native/backend.ts +199 -22
  270. package/src/native/blobs.d.ts +7 -3
  271. package/src/native/blobs.ts +47 -12
  272. package/src/native/encode.d.ts +8 -6
  273. package/src/native/encode.ts +76 -34
  274. package/src/native/node.d.ts +36 -5
  275. package/src/native/node.ts +31 -5
  276. package/src/native/overlay.d.ts +8 -3
  277. package/src/native/overlay.ts +17 -4
  278. package/src/native/reconcile.ts +16 -4
  279. package/src/native/settle.d.ts +4 -3
  280. package/src/native/settle.ts +4 -3
  281. package/src/overlays/agent-hub-projection.d.ts +6 -5
  282. package/src/overlays/agent-hub-projection.ts +124 -13
  283. package/src/overlays/agent-hub-types.d.ts +9 -0
  284. package/src/overlays/agent-hub-types.ts +6 -0
  285. package/src/overlays/agent-hub.ts +48 -29
  286. package/src/overlays/agent-transcript-viewer.ts +93 -23
  287. package/src/overlays/agents-hub.ts +28 -8
  288. package/src/overlays/annotation-overlay.ts +293 -90
  289. package/src/overlays/annotation-types.ts +2 -0
  290. package/src/overlays/ask-dialog.ts +10 -17
  291. package/src/overlays/btw-history-panel.ts +136 -39
  292. package/src/overlays/copy-selector.d.ts +1 -0
  293. package/src/overlays/copy-selector.ts +32 -8
  294. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  295. package/src/overlays/extensions/state-manager.ts +52 -12
  296. package/src/overlays/history-search.ts +100 -7
  297. package/src/overlays/hook-selector.ts +12 -5
  298. package/src/overlays/hub-frame.ts +19 -1
  299. package/src/overlays/jobs-panel.ts +33 -52
  300. package/src/overlays/model-browser.ts +367 -122
  301. package/src/overlays/model-hub.ts +435 -59
  302. package/src/overlays/move-overlay.ts +28 -14
  303. package/src/overlays/oauth-selector.ts +54 -70
  304. package/src/overlays/pause-screen.ts +2 -2
  305. package/src/overlays/plan-review-overlay.ts +208 -70
  306. package/src/overlays/plugin-settings.ts +3 -3
  307. package/src/overlays/report-panel.ts +191 -0
  308. package/src/overlays/rewind-selector.ts +88 -17
  309. package/src/overlays/session-observer-registry.d.ts +2 -0
  310. package/src/overlays/session-observer-registry.ts +10 -2
  311. package/src/overlays/session-selector.ts +64 -26
  312. package/src/overlays/settings-defs.ts +5 -4
  313. package/src/overlays/settings-selector.ts +33 -8
  314. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  315. package/src/overlays/stats-notice.ts +1 -1
  316. package/src/overlays/thinking-selector.ts +11 -10
  317. package/src/overlays/tree-selector.ts +41 -20
  318. package/src/overlays/usage-dashboard.ts +117 -58
  319. package/src/overlays/usage-display.ts +10 -4
  320. package/src/overlays/usage-row.ts +22 -8
  321. package/src/prompt/attachment-chips.ts +15 -11
  322. package/src/prompt/composer-attachments.ts +6 -4
  323. package/src/prompt/composer-cache.ts +7 -86
  324. package/src/prompt/composer.ts +37 -26
  325. package/src/prompt/custom-editor.ts +282 -105
  326. package/src/prompt/image-source.ts +2 -1
  327. package/src/prompt/interactive-context-helpers.ts +32 -18
  328. package/src/prompt/macos-spelling.ts +7 -0
  329. package/src/prompt/magic-keywords.ts +11 -2
  330. package/src/prompt/model-mention-autocomplete.ts +14 -14
  331. package/src/prompt/queued-messages.ts +6 -4
  332. package/src/prompt/tips.txt +13 -9
  333. package/src/prompt/welcome.ts +64 -46
  334. package/src/prompt/word-completion.ts +38 -5
  335. package/src/render/composite.d.ts +2 -0
  336. package/src/render/composite.ts +64 -0
  337. package/src/render/output-pane.d.ts +1 -0
  338. package/src/render/output-pane.ts +6 -0
  339. package/src/render/render-utils.d.ts +1 -0
  340. package/src/render/render-utils.ts +16 -1
  341. package/src/render/tool-card.d.ts +6 -0
  342. package/src/render/tool-card.ts +15 -0
  343. package/src/render/width-aware-text.d.ts +2 -0
  344. package/src/render/width-aware-text.ts +5 -0
  345. package/src/setup/scenes/model.ts +2 -1
  346. package/src/setup/scenes/types.ts +1 -0
  347. package/src/space-hold.d.ts +40 -25
  348. package/src/space-hold.ts +156 -52
  349. package/src/status-line/component.ts +292 -78
  350. package/src/status-line/context-usage.ts +21 -46
  351. package/src/status-line/host.ts +4 -1
  352. package/src/status-line/segments.ts +40 -22
  353. package/src/status-line/types.ts +15 -3
  354. package/src/terminal-capabilities.d.ts +41 -36
  355. package/src/terminal-capabilities.ts +137 -213
  356. package/src/terminal-multiplexer.d.ts +127 -14
  357. package/src/terminal-multiplexer.ts +112 -33
  358. package/src/terminal.d.ts +23 -16
  359. package/src/terminal.ts +203 -99
  360. package/src/theme/active-symbols.d.ts +1 -1
  361. package/src/theme/active-symbols.ts +1 -1
  362. package/src/theme/schema-validation.ts +1 -0
  363. package/src/theme/schema.d.ts +11 -2
  364. package/src/theme/schema.ts +14 -1
  365. package/src/theme/session-color.ts +1 -7
  366. package/src/theme/symbols.d.ts +1 -1
  367. package/src/theme/symbols.ts +11 -4
  368. package/src/theme/theme-class.d.ts +7 -0
  369. package/src/theme/theme-class.ts +26 -1
  370. package/src/theme/theme-schema.json +5 -1
  371. package/src/theme/theme.d.ts +1 -1
  372. package/src/theme/theme.ts +8 -4
  373. package/src/theme/tui-adapters.d.ts +3 -0
  374. package/src/theme/tui-adapters.ts +68 -0
  375. package/src/thinking.d.ts +1 -1
  376. package/src/thinking.ts +1 -1
  377. package/src/tmux.d.ts +11 -4
  378. package/src/tmux.ts +47 -11
  379. package/src/tools/ast-edit.ts +59 -35
  380. package/src/tools/ast-grep.ts +35 -16
  381. package/src/tools/bash.d.ts +7 -2
  382. package/src/tools/bash.ts +116 -47
  383. package/src/tools/daemon.d.ts +1 -1
  384. package/src/tools/daemon.ts +1 -1
  385. package/src/tools/default-renderer.ts +35 -19
  386. package/src/tools/edit.ts +142 -120
  387. package/src/tools/eval-format/index.ts +28 -2
  388. package/src/tools/eval-format/javascript.ts +7 -5
  389. package/src/tools/eval.d.ts +22 -3
  390. package/src/tools/eval.ts +364 -208
  391. package/src/tools/fetch.ts +5 -6
  392. package/src/tools/glob.d.ts +4 -0
  393. package/src/tools/glob.ts +33 -65
  394. package/src/tools/grep.ts +81 -48
  395. package/src/tools/index.ts +2 -0
  396. package/src/tools/lsp.ts +81 -59
  397. package/src/tools/mcp.ts +111 -68
  398. package/src/tools/native-view.d.ts +47 -0
  399. package/src/tools/native-view.ts +102 -1
  400. package/src/tools/output-meta.d.ts +11 -1
  401. package/src/tools/output-meta.ts +23 -3
  402. package/src/tools/read.ts +7 -5
  403. package/src/tools/renderer.d.ts +37 -2
  404. package/src/tools/renderer.ts +42 -2
  405. package/src/tools/streaming-output.d.ts +20 -1
  406. package/src/tools/streaming-output.ts +154 -50
  407. package/src/tools/task.d.ts +2 -3
  408. package/src/tools/task.ts +115 -62
  409. package/src/tools/todo.ts +119 -30
  410. package/src/tools/wait.ts +34 -24
  411. package/src/tools/web-search.ts +1 -1
  412. package/src/tools/write.d.ts +9 -2
  413. package/src/tools/write.ts +147 -95
  414. package/src/tools/yield.d.ts +22 -0
  415. package/src/tools/yield.ts +225 -0
  416. package/src/tui.d.ts +36 -0
  417. package/src/tui.ts +666 -217
  418. package/src/utils.d.ts +6 -0
  419. package/src/utils.ts +19 -0
  420. package/src/vim.d.ts +19 -5
  421. package/src/vim.ts +333 -18
  422. package/src/windows-input-mode.d.ts +33 -1
  423. package/src/windows-input-mode.ts +212 -6
@@ -0,0 +1,4657 @@
1
+ /**
2
+ * Draws a {@link ChartSpec} as a standalone SVG document colored only with
3
+ * figure tokens — `var(--fg)`, `var(--muted)`, `var(--border)`,
4
+ * `var(--accent)`, `var(--surface)`, `var(--c1)`…`var(--c6)` — on a
5
+ * transparent background — the token vocabulary of assistant ```svg figures —
6
+ * so the host resolves them against the live theme (`prepareSvg`) for both
7
+ * the rasterized terminal image and the native TSP blob.
8
+ *
9
+ * Styled after Apple's charts and Tern's UI: a UI sans (Geist where Tern
10
+ * provides it), muted labels, hairline gridlines that recede behind the
11
+ * marks, bars and cells with rounded corners, smooth lines (over a soft area
12
+ * fill where the axis starts at zero), dot legends, one hue across small
13
+ * multiples' columns with gray for context. 13-unit labels land near the terminal's own glyph size
14
+ * at 16 user units per row; bolded table cells draw at full strength while
15
+ * their neighbours recede. Grouped categories ({@link ChartSpec.groups}) name
16
+ * each group once beside its members, the groups parted by a gap; a name too
17
+ * long for its column wraps to two lines ({@link categoryLabels}). A few
18
+ * outliers far above the rest break a bar axis instead of flattening it
19
+ * ({@link outlierTier}); reference rows ({@link ChartSpec.reference}) draw a
20
+ * dashed rule through their group.
21
+ *
22
+ * Every chart opens with a header ({@link drawHeader}): the semibold title a
23
+ * planner set ({@link ChartSpec.title}, the one fact worth stating; its key
24
+ * figure in the accent), a muted
25
+ * note on how the data is encoded, and the series key right-aligned on the
26
+ * title's line when it fits. Marks size to
27
+ * the row count ({@link barMetrics}); strong heat cells and share segments
28
+ * print their figures in a theme-adaptive dark {@link ink}. Reusable
29
+ * annotation primitives — {@link band}, {@link referenceRule},
30
+ * {@link callout} — draw the {@link ChartSpec.annotations} a planner lays on.
31
+ *
32
+ * Axes tick on round steps in one unit (`0, 2, 4` for counts, `50s, 100s`,
33
+ * `25 GiB` where the table wrote binary sizes, thousands separated). Meaning
34
+ * layers on top: each contest's best value carries a quiet `best` tag (an
35
+ * outline in a heatmap), estimates (`~15`) among measured figures draw as
36
+ * dashed, faint bars ({@link barRect}), moves of a measure with a polarity read in `success`
37
+ * or `error` (transitions, factors, signed deltas), and status cells badge
38
+ * their row's name with a colored dot keyed under the chart.
39
+ */
40
+ import {
41
+ type ChartAnnotation,
42
+ type ChartGroup,
43
+ type ChartPoint,
44
+ type ChartSeries,
45
+ type ChartSpec,
46
+ type ChartTarget,
47
+ intervalMiddle,
48
+ isConditionPair,
49
+ type Polarity,
50
+ percentText,
51
+ referenceFactors,
52
+ targetStanding,
53
+ } from "./chart-plan";
54
+ import { cellStatus, type Dimension, type Status } from "./table-data";
55
+
56
+ const WIDTH = 720;
57
+ const PAD = 12;
58
+ const LABEL_SIZE = 13;
59
+ const TICK_SIZE = 11;
60
+ /** Mean advance per em of the UI sans, for character budgets; {@link measure} estimates real widths. */
61
+ const ADVANCE = 0.55;
62
+ /**
63
+ * The UI sans, best first. `Helvetica Neue` comes after Arial: the SVG rasterizer sets any text node holding
64
+ * a glyph it lacks (`→`) in a regular-weight fallback, so semibold titles with arrows lost their weight on macOS.
65
+ */
66
+ const FONT =
67
+ "Geist, -apple-system, system-ui, 'SF Pro Text', Inter, 'Segoe UI', Roboto, 'Noto Sans', 'DejaVu Sans', Arial, 'Helvetica Neue', sans-serif";
68
+ /** Series colors in draw order. */
69
+ const SERIES = ["c1", "c2", "c3", "c4", "c5", "c6"] as const;
70
+ /** Corner radius of bars, tracks and cells. */
71
+ const RADIUS = 3;
72
+ /** Opacity of gridlines over the border color: hairlines behind the marks. */
73
+ const GRID_OPACITY = 0.5;
74
+ /** Ratio between line series' peaks from which they stop sharing an axis. */
75
+ const SPLIT_SPREAD = 8;
76
+ /** Most categories whose every sample draws as a dot on its line. */
77
+ const MAX_DOTS = 16;
78
+ /** Spread (max/min of positive values) from which an axis turns logarithmic. */
79
+ const LOG_SPREAD = 100;
80
+ /** Spread from which dots and intervals take a log axis: they mark positions, not lengths from zero, so a decade warrants one. */
81
+ const SPAN_LOG = 10;
82
+ /** Most decades a positional log axis ticks at 1, 2 and 5 times each power of ten. */
83
+ const FINE_DECADES = 2.5;
84
+ /** Typical ratio between two interval series of one unit from which they are different quantities, banded apart. */
85
+ const BAND_SPREAD = 30;
86
+ /** Log-axis ticks for durations from a second up, in seconds: clock steps (`30s`, `5m`, `2h`), not powers of ten. */
87
+ const CLOCK_TICKS = [
88
+ 1, 2, 5, 10, 20, 30, 60, 120, 300, 600, 1200, 1800, 3600, 7200, 18_000, 43_200, 86_400, 172_800, 604_800, 1_209_600,
89
+ 2_592_000,
90
+ ];
91
+ /**
92
+ * Smallest jump from the rest to an outlier tier that breaks the axis (one
93
+ * model at 60× the others); below it a linear axis still leaves the rest a
94
+ * readable eighth of its length, and keeps the outlier's size visible.
95
+ */
96
+ const TIER_GAP = 8;
97
+ /** Fewest values below an outlier tier that make breaking the axis worth it. */
98
+ const MIN_BELOW_TIER = 3;
99
+ /** Most of the values an outlier tier may hold; more is a second cluster, not outliers. */
100
+ const MAX_TIER_SHARE = 1 / 3;
101
+ /** Share of a broken axis' length the values below the break get. */
102
+ const CUT_AT = 0.7;
103
+ /** Width of the gap parting a broken axis' segments, in user units. */
104
+ const CUT_GAP = 10;
105
+ /** Share of its largest value a dumbbell's smallest must reach for its axis to leave zero out. */
106
+ const ZOOM_SHARE = 0.6;
107
+ /** How far past the rest a clipped diverging axis reaches, as a multiple of their largest magnitude. */
108
+ const CLIP_REACH = 1.25;
109
+ /** Length of a clipped bar between its break and its end. */
110
+ const CLIP_INSET = 16;
111
+ const MAX_LABEL_CHARS = 28;
112
+ /** Widest each line of a category name wrapped to two sets ({@link labelLines}). */
113
+ const LABEL_WIDTH = 200;
114
+ /** Size of a name wrapped over two lines, a step under {@link LABEL_SIZE} so the pair fits one row. */
115
+ const WRAP_SIZE = 12;
116
+ /** Baseline distance between a wrapped name's two lines. */
117
+ const WRAP_LINE = 13;
118
+ /** Least row height that holds a wrapped name. */
119
+ const WRAP_ROW = 30;
120
+ /**
121
+ * Most categories whose names wrap; a longer column keeps one line per row,
122
+ * up to {@link WIDE_LABEL_WIDTH}, so it stays a glance tall.
123
+ */
124
+ const MAX_WRAP_ROWS = 12;
125
+ /** Widest a category name sets on one line; a longer one wraps, or clips where names do not wrap. */
126
+ const WIDE_LABEL_WIDTH = 260;
127
+ /** Longest group name in a label column; its members beside it keep {@link MAX_LABEL_CHARS}. */
128
+ const MAX_GROUP_CHARS = 20;
129
+ /** Vertical space parting consecutive groups of category rows. */
130
+ const GROUP_GAP = 8;
131
+ /** Semibold text sets this much wider than {@link measure} estimates for regular text. */
132
+ const SEMIBOLD_WIDTH = 1.06;
133
+ /** Opacity of marks beside an emphasized one. */
134
+ const RECEDE = 0.4;
135
+ /** Categories beyond which grouped bars pack tighter ({@link barMetrics}). */
136
+ const DENSE_ROWS = 12;
137
+ /** Share of a stacked bar's whole its parts may miss by rounding before the rest draws as a track. */
138
+ const MIN_REST = 0.01;
139
+ /** Least length of a drawn bar: a zero (or near-zero) value still shows as a stub at the baseline. */
140
+ const MIN_BAR = 2.5;
141
+ /** Fill opacity of an estimated bar (`~15`) inside its outline, relative to a measured one. */
142
+ const ESTIMATE_OPACITY = 0.4;
143
+ /** Opacity of a range bar's span past its low end, relative to the bar. */
144
+ const RANGE_OPACITY = 0.35;
145
+ /** Most panels across one band of small multiples drawn as panels. */
146
+ const PANELS_ACROSS = 3;
147
+ /** Factors a change axis rounds its ends out to; past the last, whole powers of ten. */
148
+ const FACTOR_STOPS = [1.25, 1.5, 2, 3, 5, 10];
149
+ /** Height of the title line. */
150
+ const TITLE_LINE = 18;
151
+ /** Space between the header and the plot under it. */
152
+ const HEADER_GAP = 10;
153
+ /** Height of one wrapped legend line. */
154
+ const LEGEND_LINE = 18;
155
+ /** Heat level (0–1) from which a cell is strong enough to print its figure in {@link ink}. */
156
+ const INK_LEVEL = 0.58;
157
+ /** Fill opacity of the faintest heat cell; the strongest is opaque. */
158
+ const HEAT_FLOOR = 0.09;
159
+ /** Mask id behind {@link ink}; its definition is the same in every chart, so charts inlined in one page agree on it. */
160
+ const INK_MASK = "chart-ink";
161
+ /** Longest callout text. */
162
+ const MAX_CALLOUT_CHARS = 28;
163
+ /** Longest legend name. */
164
+ const MAX_LEGEND_CHARS = 32;
165
+ /** Longest name a line carries at its end instead of a legend. */
166
+ const MAX_DIRECT_LABEL = 130;
167
+ /** Width the status badge before a category name takes. */
168
+ const STATUS_WIDTH = 14;
169
+ /** Badge color of each status. */
170
+ const STATUS_COLORS: Record<Status, string> = { good: "success", bad: "error", warn: "warning", pending: "muted" };
171
+ /** Room a {@link bestTag} takes after its value. */
172
+ const BEST_WIDTH = 34;
173
+ /** Most of the width right of the label column a figures column (`a → b move`) grows to, so a long figure
174
+ * shows whole rather than clipped while the plot keeps the rest. */
175
+ const FIGURE_SHARE = 0.48;
176
+ /** Least share of a small-multiples column its bars keep; a `best` tag that would squeeze them further goes. */
177
+ const MIN_BAR_SHARE = 0.4;
178
+ /** Share of a dense grouped chart's cells at zero from which their figures drop: the stub at the baseline says it. */
179
+ const ZERO_QUIET = 0.2;
180
+ /** Factor within which a move reads as no change, toned neither way. */
181
+ const STILL = 1.03;
182
+
183
+ /**
184
+ * A drawn layer: its SVG elements and the height they span from `top`, with
185
+ * the key and encoding notes the header shows for it.
186
+ */
187
+ interface Layer {
188
+ readonly height: number;
189
+ readonly body: string;
190
+ /** Series keys the header draws beside the title (or under it when they do not fit). */
191
+ readonly legend?: readonly LegendItem[];
192
+ /** A heat scale key the header draws instead of a legend. */
193
+ readonly ramp?: RampKey;
194
+ /** How the chart encodes its data where that is not obvious (`log scale`); the derived subtitle. */
195
+ readonly notes?: readonly string[];
196
+ }
197
+
198
+ /** A legend entry: a name beside a filled dot of `color`, or a hollow ring for a transition's start. */
199
+ interface LegendItem {
200
+ readonly name: string;
201
+ readonly color: string;
202
+ readonly ring?: boolean;
203
+ /** Further dot colors drawn beside the first, for marks colored by meaning (a toned end: `success`, `error`). */
204
+ readonly tones?: readonly string[];
205
+ /** The dot's opacity, matching a fainter mark (a second part in one outcome color); opaque by default. */
206
+ readonly opacity?: number;
207
+ }
208
+
209
+ /** A heat scale key: the faintest and strongest cells' figures either side of a strip of swatches. */
210
+ interface RampKey {
211
+ readonly color: string;
212
+ readonly low: string;
213
+ readonly high: string;
214
+ }
215
+
216
+ /** Map a value to a coordinate, with tick values for the axis. */
217
+ interface Scale {
218
+ at(value: number): number;
219
+ readonly ticks: readonly number[];
220
+ readonly log: boolean;
221
+ /** A broken axis: values above `below` jump the gap from `start` to `end` onto a second, zoomed segment. */
222
+ readonly cut?: Cut;
223
+ }
224
+
225
+ /** Where a broken axis parts its segments: the first ends at `start` (value `below`), the second begins at `end`. */
226
+ interface Cut {
227
+ readonly below: number;
228
+ readonly start: number;
229
+ readonly end: number;
230
+ }
231
+
232
+ /** A few values standing far above the rest: the rest's largest and the tier's smallest and largest. */
233
+ interface Tier {
234
+ readonly rest: number;
235
+ readonly low: number;
236
+ readonly high: number;
237
+ }
238
+
239
+ /** A drawn chart: the SVG document and its size in user units. */
240
+ export interface ChartSvg {
241
+ readonly svg: string;
242
+ readonly width: number;
243
+ readonly height: number;
244
+ }
245
+
246
+ /** The SVG document for `spec`. */
247
+ export function renderChartSvg(spec: ChartSpec): ChartSvg {
248
+ const layer = drawChart(spec);
249
+ const header = drawHeader(spec, layer);
250
+ const head = matrixHead(spec);
251
+ const top = PAD + header.height + head.height;
252
+ let height = top + layer.height;
253
+ // The badges' key: one dot per status shown, then the column they come from.
254
+ let key = "";
255
+ if (spec.statusName && spec.status?.some(Boolean)) {
256
+ const shown = (["good", "warn", "bad", "pending"] as const).filter(state => spec.status!.includes(state));
257
+ const y = height + 14;
258
+ key = shown.map((state, at) => dot(PAD + 4 + at * 12, y, 4, STATUS_COLORS[state])).join("");
259
+ key += text(PAD + shown.length * 12 + 4, y, clip(spec.statusName, 60), { size: TICK_SIZE, fill: "muted" });
260
+ height += 20;
261
+ }
262
+ let caption = "";
263
+ if (spec.caption) {
264
+ caption = text(PAD, height + 14, clip(spec.caption, Math.floor((WIDTH - 2 * PAD) / (TICK_SIZE * ADVANCE))), {
265
+ size: TICK_SIZE,
266
+ fill: "muted",
267
+ });
268
+ height += 20;
269
+ }
270
+ height = Math.ceil(height + PAD);
271
+ const svg = [
272
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" font-family="${FONT}" font-size="${LABEL_SIZE}">`,
273
+ layer.body.includes(`url(#${INK_MASK})`) ? INK_DEFS : "",
274
+ `<g transform="translate(0 ${PAD})">${header.body}</g>`,
275
+ head.body && `<g transform="translate(0 ${num(PAD + header.height)})">${head.body}</g>`,
276
+ `<g transform="translate(0 ${num(top)})">${layer.body}</g>`,
277
+ key,
278
+ caption,
279
+ "</svg>",
280
+ ].join("");
281
+ return { svg, width: WIDTH, height };
282
+ }
283
+
284
+ /** One-line description of the chart, for screen readers and terminals that show the alt text. */
285
+ export function chartAlt(spec: ChartSpec): string {
286
+ const joint =
287
+ spec.kind === "stacked" ? " + " : spec.kind === "slope" ? " → " : spec.kind === "butterfly" ? " vs " : ", ";
288
+ const names = spec.series.map(entry => entry.name).join(joint);
289
+ const kind =
290
+ spec.kind === "multiples"
291
+ ? "small multiples"
292
+ : spec.normalized
293
+ ? "100% stacked"
294
+ : spec.kind === "dots"
295
+ ? "dot"
296
+ : spec.kind === "ladder"
297
+ ? "quantile"
298
+ : spec.kind === "stat"
299
+ ? "comparison"
300
+ : spec.kind;
301
+ const goals = new Set(spec.targets?.flatMap(target => (target ? [target.text] : [])));
302
+ const versus = spec.baseline
303
+ ? ` vs ${spec.baseline}`
304
+ : spec.kind === "bullet"
305
+ ? goals.size === 1
306
+ ? ` vs ${[...goals][0]} ${spec.targetName ?? "target"}`
307
+ : ` vs each row's ${spec.targetName ?? "target"}`
308
+ : spec.whole
309
+ ? ` = ${spec.whole.name}`
310
+ : spec.kind === "waterfall" && spec.total
311
+ ? ` to ${spec.total.name}`
312
+ : "";
313
+ const lead = spec.title ? `${spec.title}. ` : "";
314
+ return `${lead}${kind} chart of ${names}${versus}${spec.axis ? ` by ${spec.axis}` : ""}`;
315
+ }
316
+
317
+ /**
318
+ * The header over a chart: the title when the plan states one — its key
319
+ * figure in the accent color ({@link titleFigure}), wrapped to a second line
320
+ * rather than clipped — its muted encoding note after it (or on a line of its
321
+ * own when the title leaves no room), and the layer's key right-aligned on
322
+ * the first line, or wrapped under the rest when the line has no room.
323
+ * Nothing at all for a chart with neither title, note nor key.
324
+ */
325
+ function drawHeader(spec: ChartSpec, layer: Layer): Layer {
326
+ const span = WIDTH - 2 * PAD;
327
+ const items = layer.legend ?? [];
328
+ const keyWidth = layer.ramp ? rampWidth(layer.ramp) : items.length ? legendWidth(items) : 0;
329
+ const subtitle = spec.subtitle ?? (layer.notes ?? []).join(" · ");
330
+ const fullTitle = spec.title ?? "";
331
+ if (!fullTitle && !subtitle && keyWidth === 0) return { height: 0, body: "" };
332
+ const titleSize = LABEL_SIZE * SEMIBOLD_WIDTH;
333
+ const inline = keyWidth === 0 || !fullTitle || measure(fullTitle, titleSize) + 24 + keyWidth <= span;
334
+ const room = inline && keyWidth ? span - keyWidth - 24 : span;
335
+ const lines = fullTitle ? titleLines(fullTitle, room) : [];
336
+ const figure = titleFigure(fullTitle);
337
+ const parts = lines.map(({ content, start, kept }, at) => {
338
+ // The figure's stretch within this line, where the line holds any of it.
339
+ const [from, to] = figure ? [Math.max(0, figure[0] - start), Math.min(kept, figure[1] - start)] : [0, 0];
340
+ const runs =
341
+ to > from
342
+ ? [
343
+ { content: content.slice(0, from) },
344
+ { content: content.slice(from, to), fill: "accent" },
345
+ { content: content.slice(to) },
346
+ ]
347
+ : [{ content }];
348
+ return text(PAD, 8 + at * TITLE_LINE, runs, { fill: "fg", weight: 600 });
349
+ });
350
+ let height = Math.max(1, lines.length) * TITLE_LINE;
351
+ // The note follows the title's last line where it fits; else it takes a line of its own (and leads one without a title).
352
+ if (subtitle) {
353
+ const last = lines.at(-1);
354
+ const after = last ? measure(last.content, titleSize) + 12 : 0;
355
+ const lineRoom = lines.length > 1 ? span : room;
356
+ const beside = !last || after + measure(subtitle, TICK_SIZE) <= lineRoom;
357
+ const y = beside ? 8 + Math.max(0, lines.length - 1) * TITLE_LINE : 8 + lines.length * TITLE_LINE;
358
+ parts.push(
359
+ text(PAD + (beside ? after : 0), y, fitText(subtitle, beside ? lineRoom - after : span, TICK_SIZE), {
360
+ size: TICK_SIZE,
361
+ fill: "muted",
362
+ }),
363
+ );
364
+ if (!beside) height += TITLE_LINE - 2;
365
+ }
366
+ if (layer.ramp) {
367
+ const y = inline ? 8 : height + 8;
368
+ parts.push(drawRamp(layer.ramp, inline ? WIDTH - PAD - keyWidth : PAD, y));
369
+ if (!inline) height += LEGEND_LINE;
370
+ } else if (items.length) {
371
+ if (inline) {
372
+ parts.push(drawLegend(items, WIDTH - PAD - keyWidth, WIDTH - PAD, 8).body);
373
+ } else {
374
+ const legend = drawLegend(items, PAD, WIDTH - PAD, height + 8);
375
+ parts.push(legend.body);
376
+ height = legend.height;
377
+ }
378
+ }
379
+ return { height: height + HEADER_GAP, body: parts.join("") };
380
+ }
381
+
382
+ /**
383
+ * A title in at most two lines of `width`: whole when it fits, else broken
384
+ * at the last space that fits the first line, the second clipped if still
385
+ * too long. Each line keeps its `start` in the title and the count of its
386
+ * characters `kept` from it (a clipped line ends in `…`).
387
+ */
388
+ function titleLines(title: string, width: number): { content: string; start: number; kept: number }[] {
389
+ const size = LABEL_SIZE * SEMIBOLD_WIDTH;
390
+ if (measure(title, size) <= width) return [{ content: title, start: 0, kept: title.length }];
391
+ let at = title.lastIndexOf(" ");
392
+ while (at > 0 && measure(title.slice(0, at), size) > width) at = title.lastIndexOf(" ", at - 1);
393
+ if (at <= 0) {
394
+ const content = fitText(title, width, size);
395
+ return [{ content, start: 0, kept: content.endsWith("…") ? content.length - 1 : content.length }];
396
+ }
397
+ const rest = title.slice(at + 1);
398
+ const second = fitText(rest, width, size);
399
+ return [
400
+ { content: title.slice(0, at), start: 0, kept: at },
401
+ { content: second, start: at + 1, kept: second === rest ? rest.length : second.length - 1 },
402
+ ];
403
+ }
404
+
405
+ /**
406
+ * The stretch of `title` holding its key figure, as `[start, end)`: the first
407
+ * factor (`6.3× more`), else the first signed percent (`−45%`), else the
408
+ * first figure in parentheses (`(31.7s)`), else the first with a unit; a
409
+ * number inside a name (`gpt-5.5`, `Iter127`, `opt-level 0`) never counts.
410
+ */
411
+ function titleFigure(title: string): [number, number] | undefined {
412
+ const figure =
413
+ /(?<![\w.\-−])[+−-]?[$€£]?\d[\d,]*(?:\.\d+)?(?:\s?[–/-]\s?\d[\d,]*(?:\.\d+)?)?(?:×(?:\s(?:more|less))?|%|\s?(?:[nµm]?s|min|h|[KMGT]i?B|B|k|M|tok\/s)(?!\w))?/g;
414
+ const found = [...title.matchAll(figure)].map(match => ({
415
+ start: match.index,
416
+ end: match.index + match[0].length,
417
+ content: match[0],
418
+ }));
419
+ const pick =
420
+ found.find(match => match.content.includes("×")) ??
421
+ found.find(match => /^[+−-].*%$/.test(match.content)) ??
422
+ found.find(match => title[match.start - 1] === "(") ??
423
+ found.find(match => /[^\d,.\s–-]$/.test(match.content) || /^[$€£]/.test(match.content));
424
+ return pick && [pick.start, pick.end];
425
+ }
426
+
427
+ function drawChart(spec: ChartSpec): Layer {
428
+ switch (spec.kind) {
429
+ case "line":
430
+ return drawLine(spec);
431
+ case "heatmap":
432
+ return drawGrid(spec, "heat");
433
+ case "multiples":
434
+ // Many panels of a few colorable categories wrap into bands; otherwise one column per panel.
435
+ return spec.series.length > PANELS_ACROSS && spec.categories.length <= SERIES.length
436
+ ? drawPanels(spec)
437
+ : drawGrid(spec, "bars");
438
+ case "change":
439
+ return drawChange(spec);
440
+ case "share":
441
+ return drawShare(spec);
442
+ case "scatter":
443
+ return drawScatter(spec);
444
+ case "stacked":
445
+ return drawStacked(spec);
446
+ case "waterfall":
447
+ return drawWaterfall(spec);
448
+ case "timeline":
449
+ return drawTimeline(spec);
450
+ case "dots":
451
+ return drawDots(spec);
452
+ case "range":
453
+ return drawRange(spec);
454
+ case "slope":
455
+ return drawSlope(spec);
456
+ case "butterfly":
457
+ return drawButterfly(spec);
458
+ case "bullet":
459
+ return drawBullet(spec);
460
+ case "ladder":
461
+ return drawLadder(spec);
462
+ case "histogram":
463
+ return drawHistogram(spec);
464
+ case "stat":
465
+ return drawStat(spec);
466
+ default:
467
+ return drawBars(spec);
468
+ }
469
+ }
470
+
471
+ // ── Horizontal bars: bar, grouped, paired, diverging, progress ───────────────
472
+
473
+ /**
474
+ * Row and bar heights for `count` categories: few rows get thick, airy bars,
475
+ * many get thin ones so the chart stays a glance. Grouped bars stack one thin
476
+ * bar per series in each row.
477
+ */
478
+ function barMetrics(count: number, grouped: number): { row: number; bar: number; step: number } {
479
+ if (grouped > 1) {
480
+ // Past {@link DENSE_ROWS} categories grouped bars pack tighter, so a long table stays a glance tall.
481
+ if (count > DENSE_ROWS) return { row: grouped * 9 + 9, bar: 7, step: 9 };
482
+ const bar = count <= 6 ? 10 : 8;
483
+ return { row: grouped * (bar + 2) + 10, bar, step: bar + 2 };
484
+ }
485
+ const bar = count <= 5 ? 18 : count <= 10 ? 16 : count <= 20 ? 14 : 11;
486
+ return { row: bar + (count <= 10 ? 10 : 8), bar, step: 0 };
487
+ }
488
+
489
+ function drawBars(spec: ChartSpec): Layer {
490
+ const { kind, categories, series } = spec;
491
+ // Progress fills a 0–100% track per bar; grouped and multi-track progress stack thin bars per category.
492
+ const tracks = kind === "progress";
493
+ const grouped = kind === "grouped" || (tracks && series.length > 1);
494
+ const paired = kind === "paired";
495
+ const single = series.length === 1;
496
+ const diverging = kind === "diverging" && single;
497
+ const values = tracks
498
+ ? [0, 100]
499
+ : series.flatMap(entry =>
500
+ entry.points.flatMap(point =>
501
+ !point
502
+ ? []
503
+ : point.upper !== undefined
504
+ ? [point.value, point.upper]
505
+ : point.error
506
+ ? [point.value - point.error, point.value + point.error]
507
+ : [point.value],
508
+ ),
509
+ );
510
+ const labelWidth = labelColumnWidth(spec);
511
+ // Single bars beside a reference row print their change from it (`49.5k · −52%`).
512
+ const referenced = kind === "bar" && spec.reference ? spec.reference : [];
513
+ const versus = (at: number) => {
514
+ const factor = referenced.length ? referenceFactors(spec, series[0]!)[at] : undefined;
515
+ return factor === undefined ? "" : factorText({ ...series[0]!.points[at]!, value: factor });
516
+ };
517
+ // A transition prints each row's move after its figures (`8 → 2 −6`), one style for every row.
518
+ const moves = paired && spec.transition ? pairMoves(series[0]!, series[1]!) : undefined;
519
+ // Peers on a log axis (`pi-walker` vs `Go` from 2 to 462) part by their ratio: each row prints it (`4.1×`).
520
+ const ratios =
521
+ paired && !spec.transition && logSpread(values)
522
+ ? categories.map((_, at) => {
523
+ const [a, b] = [series[0]!.points[at], series[1]!.points[at]];
524
+ if (!a || !b || !(a.value > 0) || !(b.value > 0)) return "";
525
+ const ratio = Math.max(a.value, b.value) / Math.min(a.value, b.value);
526
+ // `235×`, `4.1×`: as the table's own ratio columns write them.
527
+ return `${ratio >= 10 ? Math.round(ratio).toLocaleString("en-US") : Number(ratio.toFixed(1))}×`;
528
+ })
529
+ : undefined;
530
+ const valueText = (at: number) =>
531
+ paired
532
+ ? [pointText(series[0]!, at), pointText(series[1]!, at)]
533
+ .filter(Boolean)
534
+ .join(spec.transition ? " → " : " · ") +
535
+ (moves?.[at] ? `${MOVE_GAP}${moves[at]}` : "") +
536
+ (ratios?.[at] ? `${MOVE_GAP}${ratios[at]}` : "")
537
+ : single
538
+ ? [pointText(series[0]!, at), versus(at)].filter(Boolean).join(" · ")
539
+ : "";
540
+ const widest = (labels: readonly string[], size: number) =>
541
+ Math.max(0, ...labels.map(label => measure(label, size)));
542
+ const callouts = calloutTexts(spec);
543
+ const calloutWidth = callouts.size ? widest([...callouts.values()], TICK_SIZE) * SEMIBOLD_WIDTH + 24 : 0;
544
+ // A diverging bar's figure sits past its end, away from zero: negative figures need room left of the plot.
545
+ const signed = (negative: boolean) =>
546
+ widest(
547
+ categories.map((_, at) => {
548
+ const point = series[0]!.points[at];
549
+ return point && point.value < 0 === negative ? valueText(at) : "";
550
+ }),
551
+ TICK_SIZE,
552
+ );
553
+ const leftRoom = diverging && values.some(value => value < 0) ? signed(true) + 8 : 0;
554
+ // A best tag follows its figure: room for it where a series (or the row leaders) holds one.
555
+ const tagged =
556
+ series.some(entry => entry.best !== undefined) || spec.leaders?.some(lead => lead !== undefined) ? BEST_WIDTH : 0;
557
+ const valueWidth =
558
+ (diverging
559
+ ? signed(false) + 8
560
+ : grouped
561
+ ? widest(
562
+ series.flatMap(entry => entry.points.map(point => clip(point?.text ?? "", 12))),
563
+ 10,
564
+ ) + 6
565
+ : Math.min(
566
+ // A transition's move closes its figures (`243–424 ms → 3154–3648 ms 13× more`); long ones
567
+ // take up to {@link FIGURE_SHARE} of the room right of the labels before the start figure clips.
568
+ moves ? Math.max(MAX_MOVE_COLUMN, (WIDTH - 2 * PAD - labelWidth - 10) * FIGURE_SHARE) : 180,
569
+ widest(
570
+ categories.map((_, at) => valueText(at)),
571
+ TICK_SIZE,
572
+ ) + 8,
573
+ )) + (paired ? 0 : tagged);
574
+ const left = PAD + labelWidth + 10 + leftRoom;
575
+ const right = WIDTH - PAD - valueWidth - calloutWidth;
576
+ // Signed changes beside one far larger (`−$158k` beside ±$7k): the axis ends past the rest, the outlier's
577
+ // bar runs to that end through a break and keeps its figure.
578
+ const tier = diverging ? outlierTier(values.map(Math.abs), Number.POSITIVE_INFINITY) : undefined;
579
+ const limit = tier ? tier.rest * CLIP_REACH : Number.POSITIVE_INFINITY;
580
+ const clamp = (value: number) => Math.max(-limit, Math.min(limit, value));
581
+ // A dumbbell's connector or a track cannot cross a gap; bars can. Dots mark positions, not lengths: a
582
+ // dumbbell's axis leaves zero out where every value lies far from it (5382 → 5467 ms), so close values part.
583
+ const clustered = paired && Math.min(...values) >= ZOOM_SHARE * Math.max(...values);
584
+ const axis = valueScale(tier ? values.map(clamp) : values, left, right, {
585
+ zero: !clustered,
586
+ breakable: kind === "bar" || kind === "grouped",
587
+ unit: tickUnit(series[0]!, values),
588
+ });
589
+ const scale: Scale = tier ? { ...axis, at: value => axis.at(clamp(value)) } : axis;
590
+ const { row, bar: barHeight, step } = barMetrics(categories.length, grouped ? series.length : 1);
591
+ const rowHeight = labelRow(spec, row);
592
+
593
+ const parts: string[] = [];
594
+ const references = referenceMarks(spec);
595
+ const top = references.length ? 16 : 0;
596
+ const tops = rowTops(spec, top, rowHeight);
597
+ const gridTop = top;
598
+ const gridBottom = tops.at(-1)!;
599
+ const strong = strongCategories(spec);
600
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
601
+ parts.push(drawValueAxis(scale, series[0]!, gridTop, gridBottom));
602
+ parts.push(categoryLabels(spec, tops, rowHeight, strong));
603
+ // Reference rules run under the marks and their figures; their labels sit above the plot.
604
+ parts.push(referenceLines(references, scale, left, right, gridTop, gridBottom));
605
+
606
+ const zero = scale.log ? left : scale.at(0);
607
+ const reference = referenced.length ? referenceRules(spec, series[0]!, tops, rowHeight, scale.at) : undefined;
608
+ const emphasized = series.some(entry => entry.points.some(point => point?.emphasis));
609
+ const estimates = series.map(estimatedPoints);
610
+ // In a dense grouped chart where zeros are common ({@link ZERO_QUIET}), a zero is its stub alone: twenty `0`
611
+ // figures are noise.
612
+ const plotted = series.flatMap(entry => entry.points.filter(point => point !== null));
613
+ const quietZeros =
614
+ grouped &&
615
+ categories.length > DENSE_ROWS &&
616
+ plotted.filter(point => point.value === 0).length >= ZERO_QUIET * plotted.length;
617
+ // A toned transition's end dots take their move's color, a move that went nowhere a neutral one; the key's
618
+ // end dot is neutral and the note says which way is better.
619
+ const polarity = paired && spec.transition ? (series[1]!.polarity ?? series[0]!.polarity) : undefined;
620
+ const toned =
621
+ polarity !== undefined &&
622
+ categories.some((_, at) => {
623
+ const [a, b] = [series[0]!.points[at], series[1]!.points[at]];
624
+ return a && b && pairTone(series[0]!, series[1]!, a, b);
625
+ });
626
+ categories.forEach((_, at) => {
627
+ const rowTop = tops[at]!;
628
+ const middle = rowTop + rowHeight / 2;
629
+ if (paired) {
630
+ const [a, b] = [series[0]!.points[at], series[1]!.points[at]];
631
+ const radius = barHeight >= 14 ? 5 : 4;
632
+ // A range (`5.7–9.6 ms`) draws as a capsule over its span; the connector joins the spans' middles.
633
+ const center = (point: ChartPoint) => (scale.at(point.value) + scale.at(point.upper ?? point.value)) / 2;
634
+ // A transition its measure's polarity calls better or worse takes that color at its end.
635
+ const tone = spec.transition && a && b ? pairTone(series[0]!, series[1]!, a, b) : undefined;
636
+ const end = tone ?? (toned ? "muted" : SERIES[0]);
637
+ if (a && b) {
638
+ const [from, to] = [center(a), center(b)];
639
+ // A transition's connector is solid in its end's color, so its length reads as the move.
640
+ parts.push(
641
+ !spec.transition
642
+ ? line(from, middle, to, middle, "border", 2.5)
643
+ : rect(
644
+ Math.min(from, to),
645
+ middle - 1.5,
646
+ Math.max(0.5, Math.abs(to - from)),
647
+ 3,
648
+ end,
649
+ MOVE_OPACITY,
650
+ 1.5,
651
+ ),
652
+ );
653
+ }
654
+ // A transition that went nowhere rings its end dot, so the start still shows.
655
+ const still = a && b && Math.abs(center(a) - center(b)) < radius;
656
+ if (a && !(spec.transition && still))
657
+ parts.push(
658
+ spec.transition
659
+ ? mark(scale, a, middle, radius, "muted", 1, true)
660
+ : mark(scale, a, middle, radius, SERIES[0], 1),
661
+ );
662
+ if (b) parts.push(mark(scale, b, middle, radius, spec.transition ? end : SERIES[1], 1));
663
+ if (a && spec.transition && still) parts.push(ring(center(a), middle, radius + 2.5, "muted"));
664
+ const strongPair = a?.emphasis || b?.emphasis;
665
+ const from = pointText(series[0]!, at);
666
+ const to = pointText(series[1]!, at);
667
+ if (spec.transition && from && to) {
668
+ // The end figure and the move always show; a start too long for what they leave gives way first.
669
+ const move = moves?.[at];
670
+ const kept = measure(`${to}${move ? `${MOVE_GAP}${move}` : ""}`, TICK_SIZE) * SEMIBOLD_WIDTH;
671
+ const room = valueWidth - 8 - kept - measure(" → ", TICK_SIZE);
672
+ const fits = measure(valueText(at), TICK_SIZE) + 8 <= valueWidth;
673
+ const head = `${fits ? from : fitText(from, room, TICK_SIZE)} → `;
674
+ const tail = right + 8 + measure(head, TICK_SIZE);
675
+ parts.push(text(right + 8, middle, head, { size: TICK_SIZE, fill: "muted" }));
676
+ parts.push(text(tail, middle, to, { size: TICK_SIZE, fill: "fg", weight: strongPair ? 600 : undefined }));
677
+ if (move)
678
+ parts.push(
679
+ text(tail + measure(`${to}${MOVE_GAP}`, TICK_SIZE), middle, move, {
680
+ size: TICK_SIZE,
681
+ fill: tone ?? "muted",
682
+ weight: tone ? 600 : undefined,
683
+ }),
684
+ );
685
+ } else {
686
+ // Each figure in its dot's color ties it to its series without a trip to the legend.
687
+ // A transition missing its end prints its start muted, as its ring is drawn.
688
+ const runs: TextRun[] = from ? [{ content: from, fill: spec.transition ? "muted" : SERIES[0] }] : [];
689
+ if (to) runs.push(...(from ? [{ content: " · " }] : []), { content: to, fill: SERIES[1] });
690
+ // An en space: runs share one text element, where plain spaces collapse.
691
+ if (ratios?.[at]) runs.push({ content: `\u2002${ratios[at]}`, fill: "fg" });
692
+ parts.push(
693
+ text(right + 8, middle, runs, {
694
+ size: TICK_SIZE,
695
+ fill: "muted",
696
+ weight: strongPair ? 600 : undefined,
697
+ }),
698
+ );
699
+ }
700
+ return;
701
+ }
702
+ series.forEach((entry, index) => {
703
+ const point = entry.points[at];
704
+ const y = grouped ? rowTop + 5 + index * step : middle - barHeight / 2;
705
+ const context = emphasized && !point?.emphasis;
706
+ // One series' context bars go gray so the bolded ones carry the color alone; several keep their hues.
707
+ // Signed changes of a measure with a polarity read as gains or losses.
708
+ const gain = diverging && point ? moveTone(entry.polarity, point.value < 0 ? 0.5 : 2) : undefined;
709
+ const fill =
710
+ single && context
711
+ ? "muted"
712
+ : (gain ?? (diverging && point && point.value < 0 ? SERIES[1] : SERIES[index % SERIES.length]!));
713
+ const opacity = context ? (single ? 0.55 : RECEDE) : 1;
714
+ if (tracks) parts.push(rect(left, y, scale.at(100) - left, barHeight, "border", 0.35));
715
+ if (!point) {
716
+ const note = entry.notes[at];
717
+ if (note) parts.push(noteText(left + 4, y + barHeight / 2, clip(note, 24)));
718
+ return;
719
+ }
720
+ const end = scale.at(point.value);
721
+ if (scale.log) {
722
+ parts.push(line(left, y + barHeight / 2, end, y + barHeight / 2, fill, 2, 0.4 * opacity));
723
+ parts.push(mark(scale, point, y + barHeight / 2, Math.min(6, barHeight / 2.6), fill, opacity));
724
+ } else if (scale.cut && point.value > scale.cut.below) {
725
+ parts.push(cutBar(scale.cut, zero, end, y, barHeight, fill, opacity));
726
+ } else if (Math.abs(point.value) > limit) {
727
+ // Clipped: the bar breaks just short of its end, which sits on the axis' limit.
728
+ const toward = Math.sign(zero - end);
729
+ const cut = end + toward * CLIP_INSET;
730
+ parts.push(
731
+ rect(Math.min(end, cut - toward * (CUT_GAP / 2)), y, CLIP_INSET - CUT_GAP / 2, barHeight, fill, opacity),
732
+ );
733
+ parts.push(
734
+ rect(
735
+ Math.min(zero, cut + toward * (CUT_GAP / 2)),
736
+ y,
737
+ Math.abs(zero - cut) - CUT_GAP / 2,
738
+ barHeight,
739
+ fill,
740
+ opacity,
741
+ ),
742
+ );
743
+ parts.push(breakMark(cut, y + barHeight / 2, barHeight / 2 + 1, "muted"));
744
+ } else {
745
+ parts.push(
746
+ barRect(Math.min(zero, end), y, Math.max(MIN_BAR, Math.abs(end - zero)), barHeight, fill, opacity, {
747
+ estimate: estimates[index]!.has(at),
748
+ }),
749
+ );
750
+ // A range's span past its low end, faint: at least the bar, possibly up to its end.
751
+ if (point.upper !== undefined && point.upper > point.value)
752
+ parts.push(rect(end, y, scale.at(point.upper) - end, barHeight, fill, RANGE_OPACITY * opacity));
753
+ }
754
+ if (point.error) parts.push(whisker(scale, point, y + barHeight / 2, Math.max(3, barHeight / 2 - 1)));
755
+ if (grouped) {
756
+ const labelX = tracks
757
+ ? scale.at(100) + 4
758
+ : Math.max(end, scale.at(point.upper ?? point.value + (point.error ?? 0))) + 4;
759
+ const best = entry.best === at || spec.leaders?.[at] === index;
760
+ const label = quietZeros && point.value === 0 && !best ? "" : clip(point.text, 12);
761
+ const size = step < 10 ? 9 : 10;
762
+ parts.push(
763
+ text(labelX, y + barHeight / 2, label, {
764
+ size,
765
+ fill: point.emphasis || best ? "fg" : "muted",
766
+ weight: point.emphasis || best ? 600 : undefined,
767
+ }),
768
+ );
769
+ if (best) parts.push(bestTag(labelX + measure(label, size) * SEMIBOLD_WIDTH + 4, y + barHeight / 2, size));
770
+ }
771
+ });
772
+ if (single) {
773
+ const point = series[0]!.points[at];
774
+ if (!point) return;
775
+ const end = Math.max(
776
+ scale.at(point.value),
777
+ point.upper === undefined ? 0 : scale.at(point.upper),
778
+ point.error ? scale.at(point.value + point.error) : 0,
779
+ );
780
+ const label = valueText(at);
781
+ const best = series[0]!.best === at;
782
+ const style = {
783
+ size: TICK_SIZE,
784
+ fill: point.emphasis || best ? "fg" : "muted",
785
+ weight: point.emphasis || best ? 600 : undefined,
786
+ } as const;
787
+ if (diverging && point.value < 0)
788
+ parts.push(text(scale.at(point.value) - 6, middle, label, { ...style, anchor: "end" }));
789
+ else {
790
+ // Clear the dot a log axis draws instead of a bar, and the group's reference rule.
791
+ const x = tracks ? right + 8 : Math.max(end, zero, reference?.at[at] ?? zero) + (scale.log ? 10 : 6);
792
+ parts.push(text(x, middle, label, style));
793
+ if (best) parts.push(bestTag(x + measure(label, TICK_SIZE) * SEMIBOLD_WIDTH + 6, middle, TICK_SIZE));
794
+ }
795
+ }
796
+ });
797
+ if (diverging && !scale.log) parts.push(line(zero, gridTop - 4, zero, gridBottom + 4, "fg", 1.25, 0.6));
798
+ if (reference) parts.push(reference.body);
799
+ for (const [at, note] of callouts) {
800
+ const middle = tops[at]! + rowHeight / 2;
801
+ const point = series[0]!.points[at];
802
+ const reach = paired
803
+ ? right + 8 + measure(valueText(at), TICK_SIZE)
804
+ : point && single && !tracks
805
+ ? Math.max(scale.at(point.value), zero, reference?.at[at] ?? zero) +
806
+ (scale.log ? 10 : 6) +
807
+ measure(valueText(at), TICK_SIZE)
808
+ : right + 8 + (single ? measure(valueText(at), TICK_SIZE) : 0);
809
+ parts.push(callout(reach + 6, middle, WIDTH - PAD - calloutWidth + 18, note));
810
+ }
811
+ // A toned end is keyed by the colors its dots take (better, worse, unmoved), not one the chart never draws.
812
+ const endTones = toned
813
+ ? (["success", "error", "muted"] as const).filter(color =>
814
+ categories.some((_, at) => {
815
+ const [a, b] = [series[0]!.points[at], series[1]!.points[at]];
816
+ return (
817
+ b &&
818
+ (a ? pairTone(series[0]!, series[1]!, a, b) : undefined) === (color === "muted" ? undefined : color)
819
+ );
820
+ }),
821
+ )
822
+ : [];
823
+ const legend: LegendItem[] =
824
+ single && !paired
825
+ ? []
826
+ : paired && spec.transition
827
+ ? [
828
+ { name: series[0]!.name, color: "muted", ring: true },
829
+ toned
830
+ ? { name: series[1]!.name, color: endTones[0] ?? "fg", tones: endTones.slice(1) }
831
+ : { name: series[1]!.name, color: SERIES[0] },
832
+ ]
833
+ : series.map((entry, at) => ({ name: entry.name, color: SERIES[at % SERIES.length]! }));
834
+ return {
835
+ height: gridBottom + 20,
836
+ body: parts.join(""),
837
+ legend,
838
+ notes: [
839
+ ...(toned ? [`${polarity} is better`] : []),
840
+ ...(scale.log && !tracks ? ["log scale"] : scale.cut || tier ? ["broken axis"] : []),
841
+ ],
842
+ };
843
+ }
844
+
845
+ /**
846
+ * A value as a dot, or a range (`upper` set) as a capsule spanning it, at
847
+ * `radius` thickness on `scale`; `hollow` outlines it instead (a transition's start).
848
+ */
849
+ function mark(
850
+ scale: Scale,
851
+ point: ChartPoint,
852
+ y: number,
853
+ radius: number,
854
+ fill: string,
855
+ opacity: number,
856
+ hollow = false,
857
+ ): string {
858
+ const from = scale.at(point.value);
859
+ const to = point.upper === undefined ? from : scale.at(point.upper);
860
+ if (Math.abs(to - from) < 1)
861
+ return hollow ? ring(from, y, radius - 0.75, fill) : dot(from, y, radius, fill, opacity);
862
+ const x = Math.min(from, to) - radius;
863
+ const width = Math.abs(to - from) + 2 * radius;
864
+ if (!hollow) return rect(x, y - radius, width, 2 * radius, fill, opacity, radius);
865
+ const inset = 0.875;
866
+ return `<rect x="${num(x + inset)}" y="${num(y - radius + inset)}" width="${num(width - 2 * inset)}" height="${num(2 * radius - 2 * inset)}" rx="${num(radius - inset)}" fill="none" stroke="${token(fill)}" stroke-width="1.75"/>`;
867
+ }
868
+
869
+ /**
870
+ * The spread a `± e` point states ({@link ChartPoint.error}): a hairline from `value − e` to
871
+ * `value + e` on `scale` at `y`, capped by ticks `half` tall either side, in `stroke` (`fg` by default,
872
+ * so it reads over a bar of any color).
873
+ */
874
+ function whisker(scale: Scale, point: ChartPoint, y: number, half: number, stroke = "fg"): string {
875
+ const error = point.error ?? 0;
876
+ const [from, to] = [scale.at(point.value - error), scale.at(point.value + error)];
877
+ if (to - from < 2) return "";
878
+ return [
879
+ line(from, y, to, y, stroke, 1.25, 0.8),
880
+ line(from, y - half, from, y + half, stroke, 1.25, 0.8),
881
+ line(to, y - half, to, y + half, stroke, 1.25, 0.8),
882
+ ].join("");
883
+ }
884
+
885
+ // ── Dots and intervals on a positional axis ──────────────────────────────────
886
+
887
+ /**
888
+ * A Cleveland dot plot: one row per category, one dot per series in its color
889
+ * on a shared axis (logarithmic once values span {@link SPAN_LOG}×), a faint
890
+ * line from the row's smallest value to its largest, ranges as capsules. Each
891
+ * dot's figure sits above it (else below) where it clears its neighbours; the
892
+ * best value of a contest wears a `success` ring.
893
+ */
894
+ function drawDots(spec: ChartSpec): Layer {
895
+ const { categories, series } = spec;
896
+ const references = referenceMarks(spec);
897
+ const values = series.flatMap(entry =>
898
+ entry.points.flatMap(point => {
899
+ if (!point) return [];
900
+ const spread = point.error ? [point.value - point.error, point.value + point.error] : [];
901
+ return [point.value, point.upper ?? point.value, ...spread];
902
+ }),
903
+ );
904
+ const left = PAD + labelColumnWidth(spec) + 10;
905
+ // Against a baseline column the dots are its factors, figured as moves (`+4%`, `1.3× less`).
906
+ const figureOf = (point: ChartPoint) => (spec.baseline ? factorText(point) : clip(point.text, 12));
907
+ // Figures centre on their dots: half the widest past the plot's last tick.
908
+ const figures = series.flatMap(entry => entry.points.map(point => (point ? figureOf(point) : "")));
909
+ const right = WIDTH - PAD - Math.max(8, ...figures.map(figure => measure(figure, 10) / 2));
910
+ const scale = spanScale(
911
+ values,
912
+ left,
913
+ right,
914
+ references.map(mark => mark.value),
915
+ series[0]!.dim,
916
+ tickUnit(series[0]!, values),
917
+ );
918
+ const rowHeight = labelRow(spec, categories.length <= 10 ? 36 : categories.length <= 16 ? 28 : 20);
919
+ // Below 28 units a row has no room for figures over its dots: the axis reads them.
920
+ const labelled = rowHeight >= 28;
921
+ const top = references.length ? 16 : 0;
922
+ const tops = rowTops(spec, top, rowHeight);
923
+ const bottom = tops.at(-1)!;
924
+ const parts: string[] = [];
925
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
926
+ parts.push(drawValueAxis(scale, series[0]!, top, bottom));
927
+ parts.push(categoryLabels(spec, tops, rowHeight, strongCategories(spec)));
928
+ parts.push(referenceLines(references, scale, left, right, top, bottom));
929
+ const emphasized = series.some(entry => entry.points.some(point => point?.emphasis));
930
+ const radius = rowHeight >= 28 ? 5 : 4;
931
+ categories.forEach((_, at) => {
932
+ // Dots sit low in a labelled row: their figures take the space above first.
933
+ const y = tops[at]! + rowHeight / 2 + (labelled ? 2 : 0);
934
+ const present = series.flatMap((entry, index) => {
935
+ const point = entry.points[at];
936
+ return point ? [{ entry, index, point }] : [];
937
+ });
938
+ if (present.length === 0) {
939
+ const note = series.map(entry => entry.notes[at]).find(Boolean);
940
+ if (note) parts.push(noteText(left + 4, y, clip(note, 24)));
941
+ return;
942
+ }
943
+ const xs = present.flatMap(({ point }) => [scale.at(point.value), scale.at(point.upper ?? point.value)]);
944
+ const [low, high] = [Math.min(...xs), Math.max(...xs)];
945
+ if (high - low >= 1) parts.push(line(low, y, high, y, "muted", 2, 0.35));
946
+ const best = (index: number, entry: ChartSeries) => entry.best === at || spec.leaders?.[at] === index;
947
+ // Ranges in a row stack as thinner capsules, each on its own line, so one never hides another lying over it.
948
+ const ranged = present.length > 1 && present.some(({ point }) => point.upper !== undefined);
949
+ const thin = ranged ? Math.min(radius, 3.5) : radius;
950
+ const stagger = ranged ? 2 * thin + 1.5 : 0;
951
+ const spread = (stagger * (present.length - 1)) / 2;
952
+ present.forEach(({ entry, index, point }, slot) => {
953
+ const context = emphasized && !point.emphasis;
954
+ const dotY = y - spread + slot * stagger;
955
+ const size = point.upper === undefined ? radius : thin;
956
+ if (point.error) parts.push(whisker(scale, point, dotY, radius + 1, SERIES[index % SERIES.length]!));
957
+ parts.push(mark(scale, point, dotY, size, SERIES[index % SERIES.length]!, context ? RECEDE : 1));
958
+ if (best(index, entry)) {
959
+ const [from, to] = [scale.at(point.value), scale.at(point.upper ?? point.value)];
960
+ parts.push(
961
+ from === to
962
+ ? ring(from, dotY, radius + 3, "success")
963
+ : mark(scale, point, dotY, size + 2.5, "success", 1, true),
964
+ );
965
+ }
966
+ });
967
+ if (!labelled) return;
968
+ // Figures: the best and bolded first, then the row's extremes, then the rest, each above its dot or below it.
969
+ const rank = ({ entry, index, point }: (typeof present)[number]) =>
970
+ best(index, entry) || point.emphasis ? 0 : point.value === highest || point.value === lowest ? 1 : 2;
971
+ const highest = Math.max(...present.map(({ point }) => point.value));
972
+ const lowest = Math.min(...present.map(({ point }) => point.value));
973
+ const taken: { from: number; to: number; level: number }[] = [];
974
+ for (const item of present.toSorted((a, b) => rank(a) - rank(b))) {
975
+ const label = figureOf(item.point);
976
+ const x = (scale.at(item.point.value) + scale.at(item.point.upper ?? item.point.value)) / 2;
977
+ const half = measure(label, 10) / 2 + 2;
978
+ const from = Math.max(left - 6, Math.min(x - half, WIDTH - PAD - 2 * half));
979
+ const level = [-1, 1].find(level =>
980
+ taken.every(box => box.level !== level || box.to <= from || box.from >= from + 2 * half),
981
+ );
982
+ if (level === undefined) continue;
983
+ taken.push({ from, to: from + 2 * half, level });
984
+ const strong = rank(item) === 0;
985
+ const tone = spec.baseline
986
+ ? moveTone(item.entry.polarity ?? spec.categoryPolarity?.[at], item.point.value)
987
+ : undefined;
988
+ parts.push(
989
+ text(from + half, y + level * ((ranged ? thin + 6 : radius + 7) + spread), label, {
990
+ size: 10,
991
+ fill: tone ?? (strong ? "fg" : "muted"),
992
+ weight: strong ? 600 : undefined,
993
+ anchor: "middle",
994
+ }),
995
+ );
996
+ }
997
+ });
998
+ // The success ring is keyed where any dot wears one.
999
+ const ringed = categories.some((_, at) =>
1000
+ series.some((entry, index) => entry.points[at] && (entry.best === at || spec.leaders?.[at] === index)),
1001
+ );
1002
+ return {
1003
+ height: bottom + 20,
1004
+ body: parts.join(""),
1005
+ legend: [
1006
+ ...series.map((entry, at) => ({ name: entry.name, color: SERIES[at % SERIES.length]! })),
1007
+ ...(ringed ? [{ name: "best", color: "success", ring: true }] : []),
1008
+ ],
1009
+ notes: [...(spec.baseline ? ["factor"] : []), ...(scale.log ? ["log scale"] : [])],
1010
+ };
1011
+ }
1012
+
1013
+ /**
1014
+ * Floating intervals from each value's low end to its high end (`5.7–9.6 ms`),
1015
+ * not from zero; single values draw as dots on the same row. Series of one
1016
+ * unit share a band (one thin interval each per category, a pair's midpoint
1017
+ * factor at the row's end); each other unit gets a band and axis of its own
1018
+ * (`reason tokens`, `cost`). The first series' {@link ChartSpec.estimate}
1019
+ * draws as a tick inside its interval; reference annotations as dashed rules
1020
+ * on their series' band. Axes are logarithmic once values span {@link SPAN_LOG}×.
1021
+ */
1022
+ function drawRange(spec: ChartSpec): Layer {
1023
+ const { categories, series, estimate } = spec;
1024
+ const references = referenceMarks(spec);
1025
+ // Bands by unit, in order of first appearance; a series typically {@link BAND_SPREAD}× another of its
1026
+ // unit (`prefix` tokens beside `turns`) is another quantity, on a band of its own.
1027
+ const typical = (a: ChartSeries, b: ChartSeries) => {
1028
+ const ratios = categories
1029
+ .map((_, at) => {
1030
+ const [x, y] = [a.points[at], b.points[at]];
1031
+ return x && y && intervalMiddle(x) > 0 ? intervalMiddle(y) / intervalMiddle(x) : Number.NaN;
1032
+ })
1033
+ .filter(Number.isFinite)
1034
+ .sort((p, q) => p - q);
1035
+ return ratios[ratios.length >> 1] ?? 1;
1036
+ };
1037
+ const bands: number[][] = [];
1038
+ series.forEach((entry, index) => {
1039
+ const band = bands.find(members => {
1040
+ const lead = series[members[0]!]!;
1041
+ const ratio = typical(lead, entry);
1042
+ return lead.dim === entry.dim && lead.unit === entry.unit && ratio < BAND_SPREAD && ratio > 1 / BAND_SPREAD;
1043
+ });
1044
+ if (band) band.push(index);
1045
+ else bands.push([index]);
1046
+ });
1047
+ const titled = bands.length > 1;
1048
+ // The pair of a two-series band: its factor between midpoints closes each row.
1049
+ const transition = spec.transition === true;
1050
+ // A transition's start (the first series) recedes in muted; the lead color passes to its end.
1051
+ const color = (index: number) =>
1052
+ transition ? (index === 0 ? "muted" : SERIES[(index - 1) % SERIES.length]!) : SERIES[index % SERIES.length]!;
1053
+ const figure = (index: number, at: number) => {
1054
+ const own = pointText(series[index]!, at);
1055
+ const central = index === 0 && estimate?.points[at] ? estimate.points[at]!.text : "";
1056
+ return [central, own].filter(Boolean).join(" · ");
1057
+ };
1058
+ const figureWidth = Math.max(
1059
+ 0,
1060
+ ...series.flatMap((_, index) => categories.map((_, at) => measure(figure(index, at), TICK_SIZE))),
1061
+ );
1062
+ // A two-series band compares by a factor when it is a transition, or one quantity under two
1063
+ // conditions (`Typical | Max Size`, one-word rivals like `xutf | std`); `~Rate | Est. cost` share a
1064
+ // unit, not a quantity.
1065
+ const paired = (members: readonly number[]) => {
1066
+ if (members.length !== 2) return false;
1067
+ const [first, second] = members.map(index => series[index]!.name.trim()) as [string, string];
1068
+ return (
1069
+ (transition && members[0] === 0) || isConditionPair(first, second) || (!/\s/.test(first) && !/\s/.test(second))
1070
+ );
1071
+ };
1072
+ // A lone series beside reference rows (`Current: 2000px PNG`) reads as factors of them, as single bars do.
1073
+ const referenced = (spec.reference?.length ?? 0) > 0 && bands.some(members => members.length === 1);
1074
+ const factorWidth = bands.some(paired) || referenced ? 84 : 0;
1075
+ const left = PAD + labelColumnWidth(spec) + 10;
1076
+ const right = WIDTH - PAD - figureWidth - 8 - factorWidth;
1077
+ const strong = strongCategories(spec);
1078
+ // A bolded estimate (`**≈ $6,700**`) singles its row out as a bolded interval would.
1079
+ const bold = (index: number, at: number) =>
1080
+ Boolean(series[index]!.points[at]?.emphasis || (index === 0 && estimate?.points[at]?.emphasis));
1081
+ const emphasized = series.some((_, index) => categories.some((_, at) => bold(index, at)));
1082
+ const parts: string[] = [];
1083
+ const notes: string[] = [];
1084
+ let y = 0;
1085
+ for (const members of bands) {
1086
+ const entries = members.map(index => series[index]!);
1087
+ const marks = references.filter(mark => members.includes(mark.series ?? 0));
1088
+ const values = [
1089
+ ...entries.flatMap(entry =>
1090
+ entry.points.flatMap(point => (point ? [point.value, point.upper ?? point.value] : [])),
1091
+ ),
1092
+ ...(members.includes(0) && estimate ? estimate.points.flatMap(point => (point ? [point.value] : [])) : []),
1093
+ ];
1094
+ const scale = spanScale(
1095
+ values,
1096
+ left,
1097
+ right,
1098
+ marks.map(mark => mark.value),
1099
+ entries[0]!.dim,
1100
+ tickUnit(entries[0]!, values),
1101
+ );
1102
+ if (scale.log && !notes.includes("log scale")) notes.push("log scale");
1103
+ if (titled) {
1104
+ parts.push(
1105
+ text(PAD, y + 8, clip(entries.map(entry => entry.name).join(" · "), 60), { fill: "fg", weight: 600 }),
1106
+ );
1107
+ y += TITLE_LINE + 4;
1108
+ }
1109
+ if (marks.length) y += 16;
1110
+ const { row, bar, step } = barMetrics(categories.length, members.length);
1111
+ const rowHeight = labelRow(spec, row);
1112
+ const tops = rowTops(spec, y, rowHeight);
1113
+ const bottom = tops.at(-1)!;
1114
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
1115
+ parts.push(drawValueAxis(scale, entries[0]!, y, bottom));
1116
+ parts.push(categoryLabels(spec, tops, rowHeight, strong));
1117
+ parts.push(referenceLines(marks, scale, left, right, y, bottom));
1118
+ // A pair's factor, first to second by midpoints (peers read it as a ratio, a transition as a move);
1119
+ // a lone series' factor of its group's reference row.
1120
+ const lone = members.length === 1 ? series[members[0]!]! : undefined;
1121
+ const base = (at: number) =>
1122
+ lone && spec.reference?.find(index => groupSpan(spec, index).join() === groupSpan(spec, at).join());
1123
+ const pair = paired(members);
1124
+ const factors = categories.map((_, at) => {
1125
+ const ref = base(at);
1126
+ const [a, b] = pair
1127
+ ? members.map(index => series[index]!.points[at])
1128
+ : [ref === undefined || ref === at ? undefined : lone?.points[ref], lone?.points[at]];
1129
+ if (!a || !b || intervalMiddle(a) <= 0 || intervalMiddle(b) <= 0) return undefined;
1130
+ return intervalMiddle(b) / intervalMiddle(a);
1131
+ });
1132
+ const moves = (transition && members[0] === 0) || lone !== undefined;
1133
+ const known = factors.filter((factor): factor is number => factor !== undefined);
1134
+ const oneWay = known.length > 0 && (known.every(factor => factor >= 1) || known.every(factor => factor < 1));
1135
+ if (pair && !moves && oneWay) {
1136
+ const [a, b] = members.map(index => series[index]!.name);
1137
+ notes.push(known[0]! >= 1 ? `${b} ÷ ${a}` : `${a} ÷ ${b}`);
1138
+ }
1139
+ // The reference row's middle as a dashed rule through its group.
1140
+ for (const index of lone ? (spec.reference ?? []) : []) {
1141
+ const point = lone?.points[index];
1142
+ if (!point) continue;
1143
+ const [first, last] = groupSpan(spec, index);
1144
+ const x = scale.at(intervalMiddle(point));
1145
+ parts.push(referenceRule(x, tops[first]! + 2, x, tops[last]! + rowHeight - 2));
1146
+ }
1147
+ categories.forEach((_, at) => {
1148
+ const rowTop = tops[at]!;
1149
+ const middle = rowTop + rowHeight / 2;
1150
+ members.forEach((index, slot) => {
1151
+ const entry = series[index]!;
1152
+ const point = entry.points[at];
1153
+ const centre = members.length > 1 ? rowTop + 5 + slot * step + bar / 2 : middle;
1154
+ if (!point) {
1155
+ const note = entry.notes[at];
1156
+ if (note) parts.push(noteText(left + 4, centre, clip(note, 24)));
1157
+ return;
1158
+ }
1159
+ const context = emphasized && !bold(index, at);
1160
+ const fill = color(index);
1161
+ const opacity = context ? RECEDE : transition && index === 0 ? 0.6 : 1;
1162
+ const [from, to] = [scale.at(point.value), scale.at(point.upper ?? point.value)];
1163
+ // A single value (or an interval thinner than a dot) is a point on the row.
1164
+ if (to - from < bar) parts.push(dot((from + to) / 2, centre, Math.min(bar / 2, 6), fill, opacity));
1165
+ else parts.push(rect(from, centre - bar / 2, to - from, bar, fill, opacity));
1166
+ const central = index === 0 ? estimate?.points[at] : undefined;
1167
+ if (central) {
1168
+ const x = scale.at(central.value);
1169
+ parts.push(line(x, centre - bar / 2 - 3, x, centre + bar / 2 + 3, "fg", 2));
1170
+ }
1171
+ const end = Math.max(to, from + bar / 2) + 6;
1172
+ const own = pointText(entry, at);
1173
+ const head = central ? `${central.text} · ` : "";
1174
+ const size = members.length > 1 && step < 12 ? 10 : TICK_SIZE;
1175
+ if (head) parts.push(text(end, centre, head, { size, fill: "fg", weight: 600 }));
1176
+ parts.push(
1177
+ text(end + (head ? measure(head, size) * SEMIBOLD_WIDTH : 0), centre, own, {
1178
+ size,
1179
+ fill: point.emphasis ? "fg" : "muted",
1180
+ weight: point.emphasis ? 600 : undefined,
1181
+ }),
1182
+ );
1183
+ });
1184
+ const factor = factors[at];
1185
+ if (factor === undefined) return;
1186
+ const polarity = series[members[1] ?? members[0]!]!.polarity ?? series[members[0]!]!.polarity;
1187
+ const tone = moves ? moveTone(polarity, factor) : undefined;
1188
+ const said = moves
1189
+ ? factorText({ value: factor, text: "", emphasis: false })
1190
+ : `${compactFactor(factor >= 1 ? factor : 1 / factor)}×`;
1191
+ // Peers leading by turns: a dot in the larger one's color before its factor.
1192
+ if (!moves && !oneWay)
1193
+ parts.push(
1194
+ dot(
1195
+ WIDTH - PAD - measure(said, TICK_SIZE) * SEMIBOLD_WIDTH - 8,
1196
+ middle,
1197
+ 3.5,
1198
+ color(members[factor >= 1 ? 1 : 0]!),
1199
+ ),
1200
+ );
1201
+ parts.push(
1202
+ text(WIDTH - PAD, middle, clip(said, 14), {
1203
+ size: TICK_SIZE,
1204
+ fill: tone ?? "fg",
1205
+ weight: 600,
1206
+ anchor: "end",
1207
+ }),
1208
+ );
1209
+ });
1210
+ y = bottom + 20 + (titled ? 12 : 0);
1211
+ }
1212
+ // Colors need a key once several series share a band, or an estimate tick stands beside the intervals.
1213
+ const legend: LegendItem[] =
1214
+ (series.length < 2 && !estimate) || (titled && bands.every(members => members.length === 1))
1215
+ ? []
1216
+ : series.map((entry, index) => ({ name: entry.name, color: color(index) }));
1217
+ if (estimate) legend.unshift({ name: estimate.name, color: "fg" });
1218
+ return { height: y - (titled ? 12 : 0), body: parts.join(""), legend, notes };
1219
+ }
1220
+
1221
+ /** The reference annotations of `spec` (a stated budget, price or 1×). */
1222
+ function referenceMarks(spec: ChartSpec): Extract<ChartAnnotation, { kind: "reference" }>[] {
1223
+ return (spec.annotations ?? []).filter(
1224
+ (mark): mark is Extract<ChartAnnotation, { kind: "reference" }> => mark.kind === "reference",
1225
+ );
1226
+ }
1227
+
1228
+ /** Dashed {@link referenceRule}s at `marks` across `top`…`bottom`, each labelled above the plot. */
1229
+ function referenceLines(
1230
+ marks: readonly Extract<ChartAnnotation, { kind: "reference" }>[],
1231
+ scale: Scale,
1232
+ left: number,
1233
+ right: number,
1234
+ top: number,
1235
+ bottom: number,
1236
+ ): string {
1237
+ const parts: string[] = [];
1238
+ for (const mark of marks) {
1239
+ const x = scale.at(mark.value);
1240
+ if (x < left - 0.5 || x > right + 0.5) continue;
1241
+ parts.push(referenceRule(x, top - 2, x, bottom));
1242
+ const label = clip(mark.label, MAX_CALLOUT_CHARS);
1243
+ const half = measure(label, 10) / 2;
1244
+ parts.push(
1245
+ text(Math.min(Math.max(x, left + half), right - half), top - 9, label, {
1246
+ size: 10,
1247
+ fill: "fg",
1248
+ anchor: "middle",
1249
+ }),
1250
+ );
1251
+ }
1252
+ return parts.join("");
1253
+ }
1254
+
1255
+ // ── Distributions: quantile ladders and histograms ───────────────────────────
1256
+
1257
+ /** Height of a ladder row: room for the marks and, over the row they label, the quantile names. */
1258
+ const LADDER_ROW = 34;
1259
+ /** Fill opacity of a ladder's body, from its middle quantile to the next one up. */
1260
+ const LADDER_BODY = 0.4;
1261
+
1262
+ /**
1263
+ * Quantiles of one measurement per category ({@link ChartSpec.ladders}) on one shared axis
1264
+ * (logarithmic once values span {@link SPAN_LOG}×): per ladder a hairline from its lowest quantile to
1265
+ * its highest, a body from its middle quantile (the median, else the lowest) to the next one up, a dot
1266
+ * at the middle and a tick at every other quantile. The quantile names label the marks once, over the
1267
+ * row whose marks stand furthest apart; several ladders a row (`Startup`, `Wall`) stack in their
1268
+ * colors with a legend. Each row closes with its figures (`0.65 / 0.80 / 0.87 / 3.7ms`).
1269
+ */
1270
+ function drawLadder(spec: ChartSpec): Layer {
1271
+ const { categories, series } = spec;
1272
+ const ladders = spec.ladders ?? [{ name: "", series: series.map((_, index) => index) }];
1273
+ const values = series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])));
1274
+ // Figures share their unit: written once, after the last (`0.65 / 0.87 / 3.7ms`).
1275
+ const figures = (members: readonly number[], at: number) => {
1276
+ const texts = members.flatMap(index => (series[index]!.points[at] ? [pointText(series[index]!, at)] : []));
1277
+ const unit = /[^\d\s.,]+$/.exec(texts.at(-1) ?? "")?.[0] ?? "";
1278
+ const bare = (figure: string) => figure.slice(0, -unit.length).trim();
1279
+ const shared = unit !== "" && texts.every(figure => figure.endsWith(unit) && /\d$/.test(bare(figure)));
1280
+ const written = texts.map((figure, at) => (shared && at < texts.length - 1 ? bare(figure) : figure));
1281
+ return clip(written.join(" / "), 32);
1282
+ };
1283
+ const figureWidth = Math.max(
1284
+ 0,
1285
+ ...ladders.flatMap(ladder => categories.map((_, at) => measure(figures(ladder.series, at), 10))),
1286
+ );
1287
+ const left = PAD + labelColumnWidth(spec) + 10;
1288
+ const right = WIDTH - PAD - figureWidth - 12;
1289
+ const scale = spanScale(values, left, right, [], series[0]!.dim, tickUnit(series[0]!, values));
1290
+ const stacked = ladders.length > 1;
1291
+ const metrics = barMetrics(categories.length, ladders.length);
1292
+ const rowHeight = labelRow(
1293
+ spec,
1294
+ stacked ? metrics.row + 6 : Math.max(metrics.row, categories.length <= 12 ? LADDER_ROW : 24),
1295
+ );
1296
+ const step = stacked ? metrics.step + 3 : 0;
1297
+ const thick = stacked ? metrics.bar : Math.min(12, metrics.bar);
1298
+ // The first row whose marks stand far enough apart for every name carries the quantile names (the first
1299
+ // ladder's), else the row whose marks stand furthest apart.
1300
+ const lead = ladders[0]!.series;
1301
+ const spacing = categories.map((_, at) => {
1302
+ const xs = lead.flatMap(index => {
1303
+ const point = series[index]!.points[at];
1304
+ return point ? [scale.at(point.value)] : [];
1305
+ });
1306
+ if (xs.length < lead.length) return -1;
1307
+ return Math.min(...xs.slice(1).map((x, slot) => x - xs[slot]!));
1308
+ });
1309
+ const room = Math.max(...lead.map(index => measure(clip(series[index]!.name, 8), 10))) + 4;
1310
+ const roomy = spacing.findIndex(gap => gap >= room);
1311
+ const named = roomy >= 0 ? roomy : spacing.indexOf(Math.max(...spacing));
1312
+ const top = 14;
1313
+ const tops = rowTops(spec, top, rowHeight);
1314
+ const bottom = tops.at(-1)!;
1315
+ const parts: string[] = [];
1316
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
1317
+ parts.push(drawValueAxis(scale, series[0]!, top, bottom));
1318
+ parts.push(categoryLabels(spec, tops, rowHeight, strongCategories(spec)));
1319
+ const strong = strongCategories(spec);
1320
+ // Before and after (`Baseline` | `Final`): the start recedes in muted, the lead color passes to the end.
1321
+ const colorOf = (slot: number) =>
1322
+ spec.transition ? (slot === 0 ? "muted" : SERIES[(slot - 1) % SERIES.length]!) : SERIES[slot % SERIES.length]!;
1323
+ categories.forEach((_, at) => {
1324
+ const rowTop = tops[at]!;
1325
+ ladders.forEach((ladder, slot) => {
1326
+ const color = colorOf(slot);
1327
+ const y = stacked ? rowTop + 6 + slot * step + thick / 2 : rowTop + rowHeight / 2 + 4;
1328
+ const marks = ladder.series.flatMap(index => {
1329
+ const point = series[index]!.points[at];
1330
+ return point ? [{ index, point, x: scale.at(point.value) }] : [];
1331
+ });
1332
+ if (marks.length === 0) return;
1333
+ // The middle quantile: the median (or a mean beside quantiles), else the lowest one shown.
1334
+ const middle = Math.max(
1335
+ 0,
1336
+ marks.findIndex(({ index }) => /^(?:p50|median|med|mean|avg|average)$/i.test(series[index]!.name)),
1337
+ );
1338
+ const [low, high] = [marks[0]!.x, marks.at(-1)!.x];
1339
+ if (high - low >= 1) parts.push(line(low, y, high, y, color, 1.5, 0.55));
1340
+ const centre = marks[middle]!;
1341
+ const body = marks[middle + 1];
1342
+ if (body && body.x - centre.x >= 1)
1343
+ parts.push(rect(centre.x, y - thick / 2, body.x - centre.x, thick, color, LADDER_BODY, 2));
1344
+ marks.forEach(({ x }, order) => {
1345
+ if (order === middle) return;
1346
+ parts.push(line(x, y - thick / 2 - 1, x, y + thick / 2 + 1, color, 2));
1347
+ });
1348
+ const radius = num(Math.min(5.5, thick / 2 + 1));
1349
+ parts.push(
1350
+ `<circle cx="${num(centre.x)}" cy="${num(y)}" r="${radius}" fill="${token(color)}" stroke="${token("surface")}" stroke-width="1.5"/>`,
1351
+ );
1352
+ const said = figures(ladder.series, at);
1353
+ const emphasis = strong.has(at) || marks.some(({ point }) => point.emphasis);
1354
+ parts.push(
1355
+ text(Math.max(high, centre.x + 6) + 8, y, said, {
1356
+ size: 10,
1357
+ fill: emphasis ? "fg" : "muted",
1358
+ weight: emphasis ? 600 : undefined,
1359
+ }),
1360
+ );
1361
+ if (at !== named || slot !== 0) return;
1362
+ // Quantile names over this row's marks, each where it clears the one before.
1363
+ let clear = Number.NEGATIVE_INFINITY;
1364
+ for (const { index, x } of marks) {
1365
+ const name = clip(series[index]!.name, 8);
1366
+ const half = measure(name, 10) / 2;
1367
+ if (x - half < clear) continue;
1368
+ parts.push(text(x, y - thick / 2 - 8, name, { size: 10, fill: "muted", anchor: "middle" }));
1369
+ clear = x + half + 4;
1370
+ }
1371
+ });
1372
+ });
1373
+ return {
1374
+ height: bottom + 20,
1375
+ body: parts.join(""),
1376
+ legend: stacked
1377
+ ? ladders.map((ladder, slot) => ({ name: ladder.name || `#${slot + 1}`, color: colorOf(slot) }))
1378
+ : [],
1379
+ notes: scale.log ? ["log scale"] : [],
1380
+ };
1381
+ }
1382
+
1383
+ /** Height of a histogram's plot. */
1384
+ const HISTOGRAM_HEIGHT = 170;
1385
+ /** Share of a bin's width its columns leave empty when several distributions share it. */
1386
+ const BIN_GAP = 0.28;
1387
+
1388
+ /**
1389
+ * Counts (or shares) over ordered bins as columns rising from zero in bin order, the bins named under
1390
+ * them and the bins' axis named under those. One distribution's columns nearly touch, as a histogram's
1391
+ * do; two to four stand side by side in each bin in their series colors, keyed by a legend. Each column
1392
+ * carries its figure; the focus bin gets a band.
1393
+ */
1394
+ function drawHistogram(spec: ChartSpec): Layer {
1395
+ const { categories, series } = spec;
1396
+ const values = [0, ...series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])))];
1397
+ const unit = tickUnit(series[0]!, values);
1398
+ const probe = valueScale(values, 0, 1, { zero: true, unit });
1399
+ const format = tickFormat(probe, series[0]!);
1400
+ const tickWidth = Math.max(...probe.ticks.map(tick => measure(format(tick), TICK_SIZE)));
1401
+ const left = PAD + tickWidth + 8;
1402
+ const right = WIDTH - PAD;
1403
+ const plotTop = 18;
1404
+ const plotBottom = plotTop + HISTOGRAM_HEIGHT;
1405
+ const scale = valueScale(values, plotBottom, plotTop, { zero: true, unit });
1406
+ const binWidth = (right - left) / categories.length;
1407
+ const single = series.length === 1;
1408
+ const inner = single ? binWidth - 3 : binWidth * (1 - BIN_GAP);
1409
+ const columnWidth = inner / series.length;
1410
+ const parts: string[] = [];
1411
+ for (const at of highlighted(spec))
1412
+ parts.push(band(left + at * binWidth, plotTop - 16, binWidth, plotBottom - plotTop + 36));
1413
+ scale.ticks.forEach(tick => {
1414
+ const y = scale.at(tick);
1415
+ parts.push(tick === 0 ? line(left, y, right, y, "border", 1) : rule(left, y, right, y));
1416
+ parts.push(text(left - 8, y, format(tick), { size: TICK_SIZE, fill: "muted", anchor: "end" }));
1417
+ });
1418
+ const emphasized = series.some(entry => entry.points.some(point => point?.emphasis));
1419
+ const strong = strongCategories(spec);
1420
+ const labelSize = columnWidth >= 34 ? TICK_SIZE : 10;
1421
+ categories.forEach((name, at) => {
1422
+ const start = left + at * binWidth + (binWidth - inner) / 2;
1423
+ series.forEach((entry, index) => {
1424
+ const point = entry.points[at];
1425
+ if (!point) return;
1426
+ const x = start + index * columnWidth;
1427
+ // A zero or near-zero bin stays visible as a stub.
1428
+ const y = Math.min(scale.at(Math.max(0, point.value)), plotBottom - MIN_BAR);
1429
+ const context = emphasized && !point.emphasis;
1430
+ const width = Math.max(1, columnWidth - (single ? 0 : 2));
1431
+ const color = SERIES[index % SERIES.length]!;
1432
+ parts.push(barRect(x, y, width, plotBottom - y, color, context ? RECEDE + 0.15 : 1));
1433
+ const figure = pointText(entry, at);
1434
+ if (measure(figure, labelSize) <= columnWidth + (single ? 0 : 4))
1435
+ parts.push(
1436
+ text(x + width / 2, y - 8, figure, {
1437
+ size: labelSize,
1438
+ fill: point.emphasis ? "fg" : "muted",
1439
+ weight: point.emphasis ? 600 : undefined,
1440
+ anchor: "middle",
1441
+ }),
1442
+ );
1443
+ });
1444
+ parts.push(
1445
+ text(left + (at + 0.5) * binWidth, plotBottom + 13, fitText(name, binWidth - 4, TICK_SIZE), {
1446
+ size: TICK_SIZE,
1447
+ fill: strong.has(at) ? "fg" : "muted",
1448
+ weight: strong.has(at) ? 600 : undefined,
1449
+ anchor: "middle",
1450
+ }),
1451
+ );
1452
+ });
1453
+ let height = plotBottom + 22;
1454
+ if (spec.axis) {
1455
+ const name = clip(spec.axis, 60);
1456
+ parts.push(text((left + right) / 2, height + 8, name, { size: TICK_SIZE, fill: "muted", anchor: "middle" }));
1457
+ height += 18;
1458
+ }
1459
+ return {
1460
+ height,
1461
+ body: parts.join(""),
1462
+ legend: single ? [] : series.map((entry, at) => ({ name: entry.name, color: SERIES[at % SERIES.length]! })),
1463
+ notes: [...(single ? [series[0]!.name] : []), ...(spec.normalized ? ["each as shares of its total"] : [])],
1464
+ };
1465
+ }
1466
+
1467
+ // ── Stacked parts of a whole ─────────────────────────────────────────────────
1468
+
1469
+ /**
1470
+ * Each category's parts as touching segments of one bar, in series order and
1471
+ * colors, its whole (else the parts' sum) written past its end. A whole the
1472
+ * parts fall short of draws the rest as a faint track. Groups whose bars
1473
+ * differ {@link SPLIT_SPREAD}× or more in size split into bands, each on its
1474
+ * own axis, so one model sixty times the others does not flatten them to
1475
+ * slivers; ungrouped rows share one axis. A {@link ChartSpec.normalized} chart
1476
+ * draws each bar as its category's mix on one 0–100% axis, segments wide
1477
+ * enough printing their percent (their figure, on a sum's thick bars); outcome parts take their status colors
1478
+ * ({@link ChartSpec.tones}).
1479
+ */
1480
+ function drawStacked(spec: ChartSpec): Layer {
1481
+ const { categories, series, whole, normalized } = spec;
1482
+ const colors = series.map((_, at) => partColor(spec, at));
1483
+ const shades = series.map((_, at) => partOpacity(spec, at));
1484
+ const sums = categories.map((_, at) => series.reduce((sum, entry) => sum + (entry.points[at]?.value ?? 0), 0));
1485
+ // A whole the parts reach within rounding (`48.2k` of `48.3k`) leaves no rest worth a track.
1486
+ const ends = sums.map((sum, at) => {
1487
+ const total = whole?.points[at]?.value ?? 0;
1488
+ return total > sum * (1 + MIN_REST) ? total : sum;
1489
+ });
1490
+ const totals = sums.map(
1491
+ (sum, at) =>
1492
+ (whole && pointText(whole, at)) ||
1493
+ formatValue(sum, series[0]!.dim, binaryBytes(series[0]!) ? "iB" : series[0]!.unit),
1494
+ );
1495
+ const rested = ends.some((end, at) => end > sums[at]!);
1496
+ const left = PAD + labelColumnWidth(spec) + 10;
1497
+ const right = WIDTH - PAD - Math.min(120, Math.max(...totals.map(label => measure(label, TICK_SIZE)))) - 8;
1498
+ const { row, bar: barHeight } = barMetrics(categories.length, 1);
1499
+ const rowHeight = labelRow(spec, row);
1500
+ const strong = strongCategories(spec);
1501
+ const emphasized = series.some(entry => entry.points.some(point => point?.emphasis));
1502
+ const bands = stackBands(spec, normalized ? ends.map(end => (end > 0 ? 100 : 0)) : ends);
1503
+ const parts: string[] = [];
1504
+ let top = 0;
1505
+ for (const stack of bands) {
1506
+ const [first, last] = stack.rows;
1507
+ const view: ChartSpec = {
1508
+ ...spec,
1509
+ categories: categories.slice(first, last),
1510
+ groups: spec.groups?.slice(stack.groups[0], stack.groups[1]),
1511
+ };
1512
+ const tops = rowTops(view, top, rowHeight);
1513
+ const bottom = tops.at(-1)!;
1514
+ const extent = normalized ? [0, 100] : [0, ...ends.slice(first, last)];
1515
+ const scale = valueScale(extent, left, right, {
1516
+ zero: true,
1517
+ unit: normalized ? 1 : tickUnit(series[0]!, extent),
1518
+ });
1519
+ // Category indices into this band's rows.
1520
+ const local = (set: Iterable<number>) =>
1521
+ new Set([...set].flatMap(at => (at >= first && at < last ? [at - first] : [])));
1522
+ for (const at of local(highlighted(spec))) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
1523
+ parts.push(
1524
+ drawValueAxis(scale, normalized ? { ...series[0]!, dim: "percent", unit: "" } : series[0]!, top, bottom),
1525
+ );
1526
+ parts.push(categoryLabels(view, tops, rowHeight, local(strong)));
1527
+ for (let at = first; at < last; at++) {
1528
+ const y = tops[at - first]! + (rowHeight - barHeight) / 2;
1529
+ const end = ends[at]!;
1530
+ // A mix places each part at its percent of the row's total.
1531
+ const place = (value: number) => scale.at(normalized ? (100 * value) / end : value);
1532
+ // A row holding none of the parts (cells in another unit, status words) draws nothing but its note.
1533
+ if (end === 0) {
1534
+ const note = series.map(entry => entry.notes[at]).find(Boolean);
1535
+ if (note) parts.push(noteText(left + 4, y + barHeight / 2, clip(note, 24)));
1536
+ continue;
1537
+ }
1538
+ let sum = 0;
1539
+ series.forEach((entry, index) => {
1540
+ const point = entry.points[at];
1541
+ if (!point || point.value <= 0) return;
1542
+ const from = place(sum);
1543
+ sum += point.value;
1544
+ // Segments part with a sliver of the surface behind them.
1545
+ const width = Math.max(1, place(sum) - from - 1.5);
1546
+ const opacity = (emphasized && !point.emphasis ? RECEDE : 1) * shades[index]!;
1547
+ parts.push(rect(from, y, width, barHeight, colors[index]!, opacity));
1548
+ // A mix prints each part's percent inside it, a sum each part's figure (on bars thick enough to
1549
+ // hold one), in ink to read on the solid color (plain text on a faint one); a part filling the
1550
+ // whole bar says so by its length alone.
1551
+ const label = normalized ? percentText((100 * point.value) / end) : barHeight >= 14 ? point.text : "";
1552
+ const style = { size: TICK_SIZE, fill: "fg", anchor: "middle", weight: 600 } as const;
1553
+ if (label && point.value < end && measure(label, TICK_SIZE) * SEMIBOLD_WIDTH + 10 <= width)
1554
+ parts.push((opacity >= INK_LEVEL ? ink : text)(from + width / 2, y + barHeight / 2, label, style));
1555
+ });
1556
+ if (end > sums[at]!) parts.push(rect(place(sum), y, place(end) - place(sum), barHeight, "border", 0.5));
1557
+ const bold = whole?.points[at]?.emphasis;
1558
+ parts.push(
1559
+ text(place(end) + 6, y + barHeight / 2, totals[at]!, {
1560
+ size: TICK_SIZE,
1561
+ fill: bold ? "fg" : "muted",
1562
+ weight: bold ? 600 : undefined,
1563
+ }),
1564
+ );
1565
+ }
1566
+ top = bottom + 26;
1567
+ }
1568
+ const legend: LegendItem[] = series.map((entry, at) => ({
1569
+ name: entry.name,
1570
+ color: colors[at]!,
1571
+ opacity: shades[at],
1572
+ }));
1573
+ if (rested && whole) legend.push({ name: `rest of ${whole.name}`, color: "border" });
1574
+ return {
1575
+ height: top - 6,
1576
+ body: parts.join(""),
1577
+ legend,
1578
+ notes: normalized ? ["% of row total"] : bands.length > 1 ? ["scaled per group"] : [],
1579
+ };
1580
+ }
1581
+
1582
+ /**
1583
+ * Fill of part `at` of a whole: its outcome's status color where the parts
1584
+ * are toned ({@link ChartSpec.tones}), `muted` for a part among them naming
1585
+ * no outcome (series hues follow the theme accent, so any could pass for a
1586
+ * status), else its series color.
1587
+ */
1588
+ function partColor(spec: ChartSpec, at: number): string {
1589
+ const { tones } = spec;
1590
+ if (!tones) return SERIES[at % SERIES.length]!;
1591
+ const tone = tones[at];
1592
+ return tone ? STATUS_COLORS[tone] : "muted";
1593
+ }
1594
+
1595
+ /** Opacity step of each further part sharing one outcome color (`fail`, then `error`), so they stay apart. */
1596
+ const TONE_STEP = 0.4;
1597
+
1598
+ /** Opacity of part `at` of a toned whole: full for the first of its color, a {@link TONE_STEP} fainter for each later one. */
1599
+ function partOpacity(spec: ChartSpec, at: number): number {
1600
+ const { tones } = spec;
1601
+ if (!tones) return 1;
1602
+ const repeats = tones.slice(0, at).filter(tone => tone === tones[at]).length;
1603
+ return Math.max(1 - TONE_STEP * repeats, 0.35);
1604
+ }
1605
+
1606
+ /** A run of categories on one axis: its rows and the groups covering them, each as a `[from, to)` index range. */
1607
+ interface StackBand {
1608
+ readonly rows: readonly [number, number];
1609
+ readonly groups: readonly [number, number];
1610
+ }
1611
+
1612
+ /**
1613
+ * Consecutive groups gathered while their bar ends stay within
1614
+ * {@link SPLIT_SPREAD}× of each other; more than three bands (sizes
1615
+ * alternating group by group) fall back to one shared axis, as do ungrouped
1616
+ * rows, whose bands would part rows the reader expects side by side.
1617
+ */
1618
+ function stackBands(spec: ChartSpec, ends: readonly number[]): StackBand[] {
1619
+ const runs = spec.groups?.map(group => group.members.length) ?? [ends.length];
1620
+ const bands: { rows: [number, number]; groups: [number, number]; low: number; high: number }[] = [];
1621
+ let row = 0;
1622
+ runs.forEach((size, index) => {
1623
+ const peak = Math.max(...ends.slice(row, row + size));
1624
+ const last = bands.at(-1);
1625
+ const low = Math.min(last?.low ?? peak, peak);
1626
+ const high = Math.max(last?.high ?? peak, peak);
1627
+ // A group holding no parts (only notes) rides along with the band before it.
1628
+ if (last && (peak === 0 || high <= low * SPLIT_SPREAD)) {
1629
+ last.rows[1] = row + size;
1630
+ last.groups[1] = index + 1;
1631
+ if (peak > 0) {
1632
+ last.low = low;
1633
+ last.high = high;
1634
+ }
1635
+ } else {
1636
+ bands.push({ rows: [row, row + size], groups: [index, index + 1], low: peak, high: peak });
1637
+ }
1638
+ row += size;
1639
+ });
1640
+ if (bands.length > 3) return [{ rows: [0, ends.length], groups: [0, runs.length] }];
1641
+ return bands;
1642
+ }
1643
+
1644
+ // ── Moves: how far and which way a value went ────────────────────────────────
1645
+
1646
+ /** Space between a transition's figures and its move (`8 → 2 −6`). */
1647
+ const MOVE_GAP = " ";
1648
+ /** Opacity of a transition's connector under its end dot. */
1649
+ const MOVE_OPACITY = 0.55;
1650
+ /** Widest figures column of a transition (`from → to move`). */
1651
+ const MAX_MOVE_COLUMN = 250;
1652
+ /** Factor from which a chart writes its moves as factors (`155× less`) rather than percents. */
1653
+ const FACTOR_MOVE = 10;
1654
+ /** Whole counts below this move by differences (`8 → 2` is −6) rather than percents. */
1655
+ const MAX_COUNTED = 100;
1656
+
1657
+ /**
1658
+ * How a chart writes its moves, one way for every row: `difference` for
1659
+ * percentages and small whole counts (`+21.7 pp`, `−6`), else `percent`
1660
+ * (`−31%`), or `factor` once any move reaches {@link FACTOR_MOVE}× either way
1661
+ * (`155× less`, then `1.2× more` beside it rather than `+20%`).
1662
+ */
1663
+ type MoveStyle = "difference" | "percent" | "factor";
1664
+
1665
+ /** The {@link MoveStyle} for `entry`'s moves from each pair's first point to its second. */
1666
+ function moveStyle(entry: ChartSeries, pairs: readonly (readonly [ChartPoint, ChartPoint])[]): MoveStyle {
1667
+ const values = pairs.flatMap(([a, b]) => [a.value, b.value]);
1668
+ if (entry.dim === "percent") return "difference";
1669
+ if (entry.dim === "count" && !entry.unit && values.every(value => Number.isInteger(value) && value < MAX_COUNTED))
1670
+ return "difference";
1671
+ const far = pairs.some(
1672
+ ([a, b]) => a.value > 0 && (b.value === 0 || Math.abs(Math.log10(b.value / a.value)) >= Math.log10(FACTOR_MOVE)),
1673
+ );
1674
+ return far ? "factor" : "percent";
1675
+ }
1676
+
1677
+ /**
1678
+ * The move from `a` to `b` of `entry`'s measure in `style` ({@link factorMove}
1679
+ * past a difference): `same` where nothing moved, empty for a factor from zero.
1680
+ */
1681
+ function moveText(a: ChartPoint, b: ChartPoint, entry: ChartSeries, style: MoveStyle): string {
1682
+ if (style === "difference") {
1683
+ const difference = b.value - a.value;
1684
+ const size = Math.abs(difference);
1685
+ if (Number(size.toPrecision(6)) === 0) return "same";
1686
+ const sign = difference > 0 ? "+" : "−";
1687
+ return entry.dim === "percent" ? `${sign}${trim(size)} pp` : `${sign}${formatValue(size, entry.dim, entry.unit)}`;
1688
+ }
1689
+ return a.value > 0 && b.value >= 0 ? factorMove(b.value / a.value, style) : "";
1690
+ }
1691
+
1692
+ /**
1693
+ * A factor as a move: `155× less`, `1.2× more` in the `factor` style, `−31%` in
1694
+ * `percent`; `same` within half a percent, or within {@link STILL} beside
1695
+ * factors (`1.01× more` is noise next to `155× less`). A drop to zero is
1696
+ * `−100%`, or `to zero` among factors, which no factor states.
1697
+ */
1698
+ function factorMove(factor: number, style: "percent" | "factor"): string {
1699
+ if (factor === 0) return style === "factor" ? "to zero" : "−100%";
1700
+ const percent = Math.round((factor - 1) * 100);
1701
+ if (percent === 0 || (style === "factor" && Math.abs(Math.log(factor)) < Math.log(STILL))) return "same";
1702
+ if (style === "factor") return factor > 1 ? `${compactFactor(factor)}× more` : `${compactFactor(1 / factor)}× less`;
1703
+ return `${percent > 0 ? "+" : "−"}${Math.abs(percent)}%`;
1704
+ }
1705
+
1706
+ /** Each category's move from `before` to `after` ({@link moveText}), all in the style their values call for. */
1707
+ function pairMoves(before: ChartSeries, after: ChartSeries): string[] {
1708
+ const pairs = before.points.flatMap((a, at): [ChartPoint, ChartPoint][] => {
1709
+ const b = after.points[at];
1710
+ return a && b ? [[a, b]] : [];
1711
+ });
1712
+ const style = moveStyle(after, pairs);
1713
+ return before.points.map((a, at) => {
1714
+ const b = after.points[at];
1715
+ return a && b ? moveText(a, b, after, style) : "";
1716
+ });
1717
+ }
1718
+
1719
+ /** The tone of a transition from `a` to `b` ({@link moveTone}) by its measure's polarity, the end's before the start's. */
1720
+ function pairTone(before: ChartSeries, after: ChartSeries, a: ChartPoint, b: ChartPoint): string | undefined {
1721
+ return a.value > 0 ? moveTone(after.polarity ?? before.polarity, b.value / a.value) : undefined;
1722
+ }
1723
+
1724
+ /** Categories a planner highlighted ({@link ChartSpec.annotations}), in range. */
1725
+ function highlighted(spec: ChartSpec): number[] {
1726
+ return (spec.annotations ?? []).flatMap(mark =>
1727
+ mark.kind === "highlight" && mark.category >= 0 && mark.category < spec.categories.length ? [mark.category] : [],
1728
+ );
1729
+ }
1730
+
1731
+ /**
1732
+ * Categories whose labels set semibold: highlighted ones, and rows the table
1733
+ * bolded a figure in — unless it bolded every row, which singles none out.
1734
+ */
1735
+ function strongCategories(spec: ChartSpec): Set<number> {
1736
+ const bold = spec.categories.flatMap((_, at) => (spec.series.some(entry => entry.points[at]?.emphasis) ? [at] : []));
1737
+ return new Set([...highlighted(spec), ...(bold.length * 2 <= spec.categories.length ? bold : [])]);
1738
+ }
1739
+
1740
+ /** Callout text per category from {@link ChartSpec.annotations} (the first per category wins). */
1741
+ function calloutTexts(spec: ChartSpec): Map<number, string> {
1742
+ const texts = new Map<number, string>();
1743
+ for (const mark of spec.annotations ?? [])
1744
+ if (
1745
+ mark.kind === "callout" &&
1746
+ mark.category >= 0 &&
1747
+ mark.category < spec.categories.length &&
1748
+ !texts.has(mark.category)
1749
+ )
1750
+ texts.set(mark.category, clip(mark.text, MAX_CALLOUT_CHARS));
1751
+ return texts;
1752
+ }
1753
+
1754
+ // ── Values against their targets ─────────────────────────────────────────────
1755
+
1756
+ /** Furthest past its target a bullet axis reaches (2 = twice it); a value beyond runs off the axis' end, the break marked. */
1757
+ const TARGET_REACH = 2;
1758
+ /** Least reach of a bullet axis (in targets) where a value runs off it, so the overrun shows past the target. */
1759
+ const OVERRUN_ROOM = 1.25;
1760
+ /** Opacity of a bullet row's track from zero to its target, over the border color. */
1761
+ const TRACK_OPACITY = 0.3;
1762
+
1763
+ /**
1764
+ * Each category's measured value against its own target ({@link ChartSpec.targets}): a bar from zero (a
1765
+ * `p50 / p99` spread faint past its low end) over a faint track up to the target, the target a dashed rule
1766
+ * labelled with what it is (`budget`); a window target (`60–100 ms`) a faint band. The bar is `success`
1767
+ * where it keeps the target's side ({@link targetStanding}), `warning` where only just, `error` where it
1768
+ * misses, the lead color where the target has no side. Rows held to different targets share one axis as
1769
+ * shares of them (each target at 100%), each row's share leading its figures; rows held to one target share
1770
+ * an axis in their unit, the rule at it. A value past {@link TARGET_REACH} times its target (or over a zero
1771
+ * target) runs off the axis' end, the break marked.
1772
+ */
1773
+ function drawBullet(spec: ChartSpec): Layer {
1774
+ const { categories } = spec;
1775
+ const entry = spec.series[0]!;
1776
+ const targets = spec.targets ?? [];
1777
+ const word = spec.targetName ?? "target";
1778
+ const known = targets.filter((target): target is ChartTarget => target !== null);
1779
+ const first = known[0];
1780
+ const shared =
1781
+ first !== undefined &&
1782
+ known.every(target => target.value === first.value && target.upper === first.upper && target.text === first.text);
1783
+ const goal = shared ? (first.upper ?? first.value) : 100;
1784
+ const reach = goal * TARGET_REACH;
1785
+ // Positions: in the unit when every row shares one target, else as a percent of each row's own.
1786
+ const position = (at: number, value: number) => {
1787
+ const target = targets[at];
1788
+ if (!target || shared) return value;
1789
+ const scale = target.upper ?? target.value;
1790
+ return scale > 0 ? (100 * value) / scale : value > 0 ? Number.POSITIVE_INFINITY : 0;
1791
+ };
1792
+ const standings = categories.map((_, at) => {
1793
+ const point = entry.points[at];
1794
+ const target = targets[at];
1795
+ return point && target ? targetStanding(point, target) : undefined;
1796
+ });
1797
+ const tone = (at: number) => {
1798
+ const standing = standings[at];
1799
+ return !standing ? SERIES[0] : !standing.met ? "error" : standing.close ? "warning" : "success";
1800
+ };
1801
+ // Each row's share of its target; over a zero target only whether it met it.
1802
+ const shareText = (at: number) => {
1803
+ const standing = standings[at];
1804
+ if (shared || !standing) return "";
1805
+ const { share } = standing;
1806
+ if (share === undefined) return standing.met ? "met" : "over";
1807
+ if (share >= TARGET_REACH) return `${compactFactor(share)}×`;
1808
+ return share < 0.01 ? percentText(share * 100) : `${Math.round(share * 100)}%`;
1809
+ };
1810
+ const figure = (at: number) => {
1811
+ const own = pointText(entry, at);
1812
+ const target = targets[at];
1813
+ return shared || !target ? own : `${own} ${target.text}`;
1814
+ };
1815
+ const shareWidth = Math.max(0, ...categories.map((_, at) => measure(shareText(at), TICK_SIZE) * SEMIBOLD_WIDTH));
1816
+ const figureWidth = Math.min(220, Math.max(0, ...categories.map((_, at) => measure(figure(at), TICK_SIZE))));
1817
+ const left = PAD + labelColumnWidth(spec) + 10;
1818
+ const right = WIDTH - PAD - figureWidth - 10 - (shareWidth ? shareWidth + 8 : 0);
1819
+ const ends = categories.flatMap((_, at) => {
1820
+ const point = entry.points[at];
1821
+ if (!point || !targets[at]) return [];
1822
+ return [point.value, point.upper ?? point.value].map(value => position(at, value));
1823
+ });
1824
+ // Values off the axis need only a stretch past the target to run off; the axis spans the rest.
1825
+ const within = ends.filter(value => value <= reach);
1826
+ const room = ends.length > within.length ? [goal * OVERRUN_ROOM] : [];
1827
+ const extent = [0, goal, ...within, ...room];
1828
+ const scale = valueScale(extent, left, right, { zero: true, unit: shared ? tickUnit(entry, extent) : 1 });
1829
+ const last = Math.min(reach, scale.ticks.at(-1) ?? reach);
1830
+ const end = scale.at(last);
1831
+ const x = (at: number, value: number) => scale.at(Math.min(position(at, value), last));
1832
+ const metrics = barMetrics(categories.length, 1);
1833
+ const rowHeight = labelRow(spec, metrics.row);
1834
+ const { bar } = metrics;
1835
+ const estimates = estimatedPoints(entry);
1836
+ const top = 16;
1837
+ const tops = rowTops(spec, top, rowHeight);
1838
+ const bottom = tops.at(-1)!;
1839
+ const strong = strongCategories(spec);
1840
+ const parts: string[] = [];
1841
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
1842
+ parts.push(drawValueAxis(scale, shared ? entry : { ...entry, dim: "percent", unit: "" }, top, bottom));
1843
+ parts.push(categoryLabels(spec, tops, rowHeight, strong));
1844
+ const rule = shared ? `${first.text} ${word}` : word;
1845
+ parts.push(referenceLines([{ kind: "reference", value: goal, label: rule }], scale, left, right, top, bottom));
1846
+ const emphasized = entry.points.some(point => point?.emphasis);
1847
+ categories.forEach((_, at) => {
1848
+ const middle = tops[at]! + rowHeight / 2;
1849
+ const y = middle - bar / 2;
1850
+ const point = entry.points[at];
1851
+ const target = targets[at];
1852
+ if (!point || !target) {
1853
+ const note = entry.notes[at];
1854
+ if (note) parts.push(noteText(left + 4, middle, clip(note, 40)));
1855
+ return;
1856
+ }
1857
+ const zero = scale.at(0);
1858
+ const scaleOf = target.upper ?? target.value;
1859
+ parts.push(rect(zero, y, x(at, scaleOf) - zero, bar, "border", TRACK_OPACITY));
1860
+ if (target.upper !== undefined)
1861
+ parts.push(
1862
+ rect(x(at, target.value), y - 2, x(at, target.upper) - x(at, target.value), bar + 4, "success", 0.18),
1863
+ );
1864
+ const fill = tone(at);
1865
+ const opacity = emphasized && !point.emphasis ? RECEDE : 1;
1866
+ const low = x(at, point.value);
1867
+ const high = x(at, point.upper ?? point.value);
1868
+ parts.push(barRect(zero, y, Math.max(MIN_BAR, low - zero), bar, fill, opacity, { estimate: estimates.has(at) }));
1869
+ if (high > low) parts.push(rect(low, y, high - low, bar, fill, RANGE_OPACITY * opacity));
1870
+ // Past the axis' end: the bar runs off it, the break marked where it leaves.
1871
+ if (position(at, point.upper ?? point.value) > last) parts.push(breakMark(end - 6, middle, bar / 2 + 2, "fg"));
1872
+ const own = pointText(entry, at);
1873
+ // One target: the figure follows its bar as on any bar chart, clear of the target's rule.
1874
+ const ruleX = scale.at(goal);
1875
+ const after = Math.max(high, low) + (position(at, point.upper ?? point.value) > last ? 10 : 6);
1876
+ let textX = !shared
1877
+ ? right + 10
1878
+ : after <= ruleX + 2 && after + measure(own, TICK_SIZE) >= ruleX - 2
1879
+ ? ruleX + 6
1880
+ : after;
1881
+ const share = shareText(at);
1882
+ if (shareWidth) {
1883
+ parts.push(
1884
+ text(textX + shareWidth, middle, share, {
1885
+ size: TICK_SIZE,
1886
+ fill: fill === SERIES[0] ? "fg" : fill,
1887
+ weight: 600,
1888
+ anchor: "end",
1889
+ }),
1890
+ );
1891
+ textX += shareWidth + 8;
1892
+ }
1893
+ // The figure, then (each row held to its own) its target, muted: one line of runs.
1894
+ const runs: TextRun[] = [
1895
+ {
1896
+ content: own,
1897
+ fill: point.emphasis || !standings[at]?.met ? "fg" : undefined,
1898
+ weight: point.emphasis ? 600 : undefined,
1899
+ },
1900
+ ];
1901
+ if (!shared) runs.push({ content: `\u2002${clip(target.text, 18)}` });
1902
+ parts.push(text(textX, middle, runs, { size: TICK_SIZE, fill: "muted" }));
1903
+ });
1904
+ const shown = new Set<string>(categories.map((_, at) => tone(at)));
1905
+ const legend: LegendItem[] = [
1906
+ { name: `within ${word}`, color: "success" },
1907
+ { name: "within 10%", color: "warning" },
1908
+ { name: `misses ${word}`, color: "error" },
1909
+ ].filter(item => shown.has(item.color));
1910
+ // A spread's two ends (`p50 / p99`), named under the figures they explain.
1911
+ const spread = entry.name.split(/\s*\/\s*/);
1912
+ if (spread.length === 2 && entry.points.some(point => point?.upper !== undefined))
1913
+ parts.push(
1914
+ text(WIDTH - PAD, bottom + 12, clip(`bar to ${spread[0]}, faint to ${spread[1]}`, 40), {
1915
+ size: TICK_SIZE,
1916
+ fill: "muted",
1917
+ anchor: "end",
1918
+ }),
1919
+ );
1920
+ return { height: bottom + 20, body: parts.join(""), legend: legend.length > 1 ? legend : [] };
1921
+ }
1922
+
1923
+ // ── Runs of steps: waterfall and timeline ────────────────────────────────────
1924
+
1925
+ /**
1926
+ * Each step a floating bar from the running sum before it to the one after
1927
+ * it, a hairline carrying each end down to the next step, and the total the
1928
+ * steps reach ({@link ChartSpec.total}) as a full bar under them. A cap (a
1929
+ * `reference` annotation) draws as a dashed rule, the total's row showing
1930
+ * what it leaves to spare as a faint `success` span up to the rule (or the
1931
+ * overrun past it in `error`), named beside the total's figure, or past the
1932
+ * rule where the span cannot hold it. Steps take the lead color; a
1933
+ * falling step takes the tone a drop has for its measure (else the second
1934
+ * series color). A run of rising steps prints each one's share of the total.
1935
+ */
1936
+ function drawWaterfall(spec: ChartSpec): Layer {
1937
+ const entry = spec.series[0]!;
1938
+ const { categories } = spec;
1939
+ const steps = categories.map((_, at) => entry.points[at]?.value ?? 0);
1940
+ let run = 0;
1941
+ const ends = steps.map(value => (run += value));
1942
+ const starts = ends.map((end, at) => end - steps[at]!);
1943
+ const total = spec.total ?? {
1944
+ name: "Total",
1945
+ point: { value: ends.at(-1) ?? 0, text: "", emphasis: false },
1946
+ };
1947
+ const references = (spec.annotations ?? []).filter(
1948
+ (mark): mark is Extract<ChartAnnotation, { kind: "reference" }> => mark.kind === "reference",
1949
+ );
1950
+ const cap = references[0];
1951
+ const format = (value: number) => formatValue(value, entry.dim, entry.unit);
1952
+ const totalText = total.point.text || format(total.point.value);
1953
+ const spare = cap ? cap.value - total.point.value : 0;
1954
+ const spareText = cap ? `${format(Math.abs(spare))} ${spare >= 0 ? "to spare" : "over"}` : "";
1955
+ // Shares of a sum only read when every step adds to it.
1956
+ const rising = steps.every(value => value >= 0) && total.point.value > 0;
1957
+ const share = (at: number) => {
1958
+ if (!rising) return "";
1959
+ const percent = (100 * steps[at]!) / total.point.value;
1960
+ return percent < 1 ? "<1%" : `${Math.round(percent)}%`;
1961
+ };
1962
+ const view: ChartSpec = {
1963
+ ...spec,
1964
+ categories: [...categories, total.name],
1965
+ groups: spec.groups && [...spec.groups, { name: "", members: [total.name] }],
1966
+ status: spec.status && [...spec.status, undefined],
1967
+ };
1968
+ const figureWidth = (at: number) => {
1969
+ const shared = share(at);
1970
+ return measure(pointText(entry, at), TICK_SIZE) + (shared ? measure(` · ${shared}`, TICK_SIZE) : 0);
1971
+ };
1972
+ const valueWidth = Math.min(
1973
+ 200,
1974
+ Math.max(
1975
+ ...categories.map((_, at) => figureWidth(at)),
1976
+ measure(totalText, TICK_SIZE) * SEMIBOLD_WIDTH + (spareText ? measure(` · ${spareText}`, TICK_SIZE) : 0),
1977
+ ) + 8,
1978
+ );
1979
+ const left = PAD + labelColumnWidth(view) + 10;
1980
+ const right = WIDTH - PAD - valueWidth;
1981
+ const scale = valueScale(
1982
+ [0, ...starts, ...ends, total.point.value, ...references.map(mark => mark.value)],
1983
+ left,
1984
+ right,
1985
+ { zero: true },
1986
+ );
1987
+ const count = categories.length + 1;
1988
+ const { row, bar: barHeight } = barMetrics(count, 1);
1989
+ const rowHeight = labelRow(view, row);
1990
+ const top = references.length ? 16 : 0;
1991
+ const tops = rowTops(view, top, rowHeight);
1992
+ const bottom = tops.at(-1)!;
1993
+ const barTop = (at: number) => tops[at]! + (rowHeight - barHeight) / 2;
1994
+ const strong = strongCategories(spec);
1995
+ strong.add(categories.length);
1996
+ const parts: string[] = [];
1997
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
1998
+ parts.push(drawValueAxis(scale, entry, top, bottom));
1999
+ parts.push(categoryLabels(view, tops, rowHeight, strong));
2000
+ for (const mark of references) {
2001
+ const x = scale.at(mark.value);
2002
+ parts.push(referenceRule(x, top - 2, x, bottom));
2003
+ const label = clip(mark.label, MAX_CALLOUT_CHARS);
2004
+ const half = measure(label, 10) / 2;
2005
+ parts.push(
2006
+ text(Math.min(Math.max(x, left + half), WIDTH - PAD - half), top - 9, label, {
2007
+ size: 10,
2008
+ fill: "fg",
2009
+ anchor: "middle",
2010
+ }),
2011
+ );
2012
+ }
2013
+ const emphasized = entry.points.some(point => point?.emphasis);
2014
+ const fall = moveTone(entry.polarity, 0.5) ?? SERIES[1];
2015
+ categories.forEach((_, at) => {
2016
+ const point = entry.points[at];
2017
+ const y = barTop(at);
2018
+ const [from, to] = [scale.at(Math.min(starts[at]!, ends[at]!)), scale.at(Math.max(starts[at]!, ends[at]!))];
2019
+ const context = emphasized && !point?.emphasis;
2020
+ parts.push(
2021
+ rect(
2022
+ from,
2023
+ y,
2024
+ Math.max(1, to - from),
2025
+ barHeight,
2026
+ context ? "muted" : steps[at]! < 0 ? fall : SERIES[0],
2027
+ context ? 0.55 : 1,
2028
+ ),
2029
+ );
2030
+ // The running sum carries down to where the next step (or the total) starts.
2031
+ const x = scale.at(ends[at]!);
2032
+ parts.push(connector(x, y + barHeight, barTop(at + 1)));
2033
+ const figure = pointText(entry, at);
2034
+ const style = {
2035
+ size: TICK_SIZE,
2036
+ fill: point?.emphasis ? "fg" : "muted",
2037
+ weight: point?.emphasis ? 600 : undefined,
2038
+ };
2039
+ parts.push(text(to + 6, y + barHeight / 2, figure, style));
2040
+ const shared = share(at);
2041
+ if (shared)
2042
+ parts.push(
2043
+ text(
2044
+ to + 6 + measure(figure, TICK_SIZE) * (point?.emphasis ? SEMIBOLD_WIDTH : 1) + 4,
2045
+ y + barHeight / 2,
2046
+ `· ${shared}`,
2047
+ { size: TICK_SIZE, fill: "muted" },
2048
+ ),
2049
+ );
2050
+ });
2051
+ const y = barTop(categories.length);
2052
+ const zero = scale.at(0);
2053
+ const end = scale.at(total.point.value);
2054
+ parts.push(rect(Math.min(zero, end), y, Math.max(1, Math.abs(end - zero)), barHeight, "muted", 1));
2055
+ // The headroom left under the cap, or the overrun past it, as a span of the total's row.
2056
+ const capX = cap ? scale.at(cap.value) : end;
2057
+ if (cap && spare > 0) parts.push(rect(end, y, capX - end, barHeight, "success", 0.18));
2058
+ if (cap && spare < 0) parts.push(rect(capX, y, end - capX, barHeight, "error", 0.85));
2059
+ const figureX = Math.max(zero, end) + 6;
2060
+ parts.push(text(figureX, y + barHeight / 2, totalText, { size: TICK_SIZE, fill: "fg", weight: 600 }));
2061
+ if (spareText) {
2062
+ const after = figureX + measure(totalText, TICK_SIZE) * SEMIBOLD_WIDTH + 6;
2063
+ // Inside the headroom span when it holds the words; else clear of the cap's rule.
2064
+ const inside = spare > 0 && after + measure(spareText, TICK_SIZE) <= capX - 4;
2065
+ parts.push(
2066
+ text(inside || spare < 0 ? after : capX + 6, y + barHeight / 2, spareText, {
2067
+ size: TICK_SIZE,
2068
+ fill: spare >= 0 ? "success" : "error",
2069
+ weight: 600,
2070
+ }),
2071
+ );
2072
+ }
2073
+ return { height: bottom + 20, body: parts.join("") };
2074
+ }
2075
+
2076
+ /** A dotted hairline down from a waterfall step's end at `x`, from `y1` to `y2`. */
2077
+ function connector(x: number, y1: number, y2: number): string {
2078
+ if (y2 <= y1) return "";
2079
+ return `<line x1="${num(x)}" y1="${num(y1)}" x2="${num(x)}" y2="${num(y2)}" stroke="${token("muted")}" stroke-width="1" stroke-dasharray="2 2" stroke-opacity="0.8"/>`;
2080
+ }
2081
+
2082
+ /** Height of a timeline's strip. */
2083
+ const STRIP = 30;
2084
+ /** Most label lanes under a timeline's strip. */
2085
+ const MAX_LANES = 4;
2086
+ /** Height of one label lane under a timeline's strip. */
2087
+ const LANE = 16;
2088
+ /** Share of a timeline's run from which a segment (or group) leads in the accent color, unless the table bolded some. */
2089
+ const LEADING_SEGMENT = 0.15;
2090
+ /** Most segments (or groups) a timeline leads with. */
2091
+ const MAX_LEADING = 3;
2092
+ /** Share of a timeline's run from which a stretch's label claims a lane before the smaller ones. */
2093
+ const LABEL_FIRST = 0.05;
2094
+
2095
+ /** A labelled stretch of a timeline: one row, or a group of consecutive rows sharing a name. */
2096
+ interface Stretch {
2097
+ readonly first: number;
2098
+ readonly last: number;
2099
+ readonly name: string;
2100
+ readonly value: number;
2101
+ readonly figure: string;
2102
+ readonly group: boolean;
2103
+ }
2104
+
2105
+ /**
2106
+ * Rows laid end to end on one axis — each at its start ({@link ChartSpec.x})
2107
+ * or after the one before — as touching segments of one strip, to scale, with
2108
+ * the run's end figure after it and the axis over it. The few stretches
2109
+ * (rows, or groups of rows sharing a name) making up most of the run, or
2110
+ * those the table bolded, lead in the accent color with their share; the rest
2111
+ * recede in alternating grays. A stretch is named inside its segment where
2112
+ * the name fits, else in a lane under the strip on a leader (groups on a
2113
+ * bracket spanning their rows); members of a group are named inside their
2114
+ * segments where they fit.
2115
+ */
2116
+ function drawTimeline(spec: ChartSpec): Layer {
2117
+ const entry = spec.series[0]!;
2118
+ const { categories } = spec;
2119
+ const sizes = categories.map((_, at) => Math.max(0, entry.points[at]?.value ?? 0));
2120
+ let run = 0;
2121
+ const starts =
2122
+ spec.x?.values ??
2123
+ sizes.map(size => {
2124
+ run += size;
2125
+ return run - size;
2126
+ });
2127
+ const ends = starts.map((start, at) => start + sizes[at]!);
2128
+ const from = Math.min(0, ...starts);
2129
+ const to = Math.max(...ends);
2130
+ const span = to - from || 1;
2131
+ const duration = entry.dim === "duration";
2132
+ const format = (value: number) => (duration ? clockText(value) : formatValue(value, entry.dim, entry.unit));
2133
+ // Parts at their offsets end where the last one does; a run of steps at its total.
2134
+ const endText = spec.x ? format(to) : spec.total?.point.text || format(spec.total?.point.value ?? to);
2135
+ const left = PAD;
2136
+ const right = WIDTH - PAD - measure(endText, TICK_SIZE) * SEMIBOLD_WIDTH - 8;
2137
+ const at = (value: number) => left + ((value - from) / span) * (right - left);
2138
+
2139
+ // Stretches: named groups as one, loose rows on their own.
2140
+ const stretches: Stretch[] = [];
2141
+ let row = 0;
2142
+ for (const group of spec.groups ?? [{ name: "", members: categories }]) {
2143
+ const first = row;
2144
+ row += group.members.length;
2145
+ if (group.name && group.members.length > 1) {
2146
+ const value = sizes.slice(first, row).reduce((sum, size) => sum + size, 0);
2147
+ stretches.push({ first, last: row - 1, name: group.name, value, figure: format(value), group: true });
2148
+ } else
2149
+ for (let index = first; index < row; index++)
2150
+ stretches.push({
2151
+ first: index,
2152
+ last: index,
2153
+ name: categories[index]!,
2154
+ value: sizes[index]!,
2155
+ figure: pointText(entry, index),
2156
+ group: false,
2157
+ });
2158
+ }
2159
+ const total = sizes.reduce((sum, size) => sum + size, 0) || 1;
2160
+ const share = (stretch: Stretch) => {
2161
+ const percent = (100 * stretch.value) / total;
2162
+ return percent < 1 ? "<1%" : `${Math.round(percent)}%`;
2163
+ };
2164
+ const strong = strongCategories(spec);
2165
+ const marked = stretches.filter(stretch =>
2166
+ [...strong].some(index => index >= stretch.first && index <= stretch.last),
2167
+ );
2168
+ const leading = new Set(
2169
+ marked.length
2170
+ ? marked
2171
+ : [...stretches]
2172
+ .sort((a, b) => b.value - a.value)
2173
+ .slice(0, MAX_LEADING)
2174
+ .filter(stretch => stretch.value / total >= LEADING_SEGMENT),
2175
+ );
2176
+
2177
+ const parts: string[] = [];
2178
+ const stripTop = 18;
2179
+ const stripBottom = stripTop + STRIP;
2180
+ const middle = stripTop + STRIP / 2;
2181
+ // Axis over the strip: tick labels and short ticks, the strip itself the baseline.
2182
+ const unit = duration ? (to >= 7200 ? 3600 : to >= 120 ? 60 : 1) : 1;
2183
+ const step = niceStep(span / unit / 5) * unit;
2184
+ for (let tick = Math.ceil(from / step) * step; tick <= to + 1e-9; tick += step) {
2185
+ const x = at(tick);
2186
+ const label = formatValue(Number(tick.toPrecision(12)), entry.dim, entry.unit, to);
2187
+ parts.push(rule(x, 12, x, stripTop - 2));
2188
+ // The first tick sits at the margin: its label starts there rather than hanging off the canvas.
2189
+ const edge = x - measure(label, TICK_SIZE) / 2 < 2;
2190
+ parts.push(
2191
+ text(edge ? x - 1 : x, 5, label, { size: TICK_SIZE, fill: "muted", anchor: edge ? "start" : "middle" }),
2192
+ );
2193
+ }
2194
+ // Segments: leading stretches in the lead color, the rest alternating grays — or, where none leads (an
2195
+ // even run), every stretch in alternating strengths of the lead color; a sliver parts neighbours.
2196
+ const even = leading.size === 0;
2197
+ const fill = (stretch: Stretch): [color: string, opacity: number] => {
2198
+ const odd = stretches.indexOf(stretch) % 2 === 1;
2199
+ if (leading.has(stretch)) return [SERIES[0], 1];
2200
+ return even ? [SERIES[0], odd ? 0.7 : 1] : ["muted", odd ? 0.3 : 0.5];
2201
+ };
2202
+ for (const stretch of stretches) {
2203
+ for (let member = stretch.first; member <= stretch.last; member++) {
2204
+ const x0 = at(starts[member]!);
2205
+ const width = Math.max(1, at(ends[member]!) - x0 - (sizes[member]! > 0 ? 1 : 0));
2206
+ parts.push(rect(x0, stripTop, width, STRIP, ...fill(stretch), 2));
2207
+ }
2208
+ }
2209
+ parts.push(text(at(to) + 8, middle, endText, { size: TICK_SIZE, fill: "fg", weight: 600 }));
2210
+
2211
+ // Names inside their segments where they fit (with the figure and share, or less); the rest go to lanes.
2212
+ const fit = (x0: number, x1: number, options: readonly string[], lead: boolean, size: number) =>
2213
+ options.find(option => measure(option, size) * (lead ? SEMIBOLD_WIDTH : 1) <= x1 - x0 - 10);
2214
+ const inside = (x0: number, content: string, lead: boolean, size: number) => {
2215
+ const style = { size, fill: "fg", weight: lead ? 600 : undefined } as const;
2216
+ parts.push(lead || even ? ink(x0 + 5, middle, content, style) : text(x0 + 5, middle, content, style));
2217
+ };
2218
+ const pending: Stretch[] = [];
2219
+ for (const stretch of stretches) {
2220
+ const lead = leading.has(stretch);
2221
+ const [x0, x1] = [at(starts[stretch.first]!), at(ends[stretch.last]!)];
2222
+ const named = `${stretch.name} ${stretch.figure}`;
2223
+ const options = lead ? [`${named} · ${share(stretch)}`, named, stretch.name] : [named, stretch.name];
2224
+ const size = lead ? TICK_SIZE + 1 : TICK_SIZE;
2225
+ if (stretch.group) {
2226
+ const members: [number, string][] = [];
2227
+ for (let member = stretch.first; member <= stretch.last; member++) {
2228
+ const name = fit(at(starts[member]!), at(ends[member]!), [memberName(spec, member)], lead, 10);
2229
+ if (name) members.push([member, name]);
2230
+ }
2231
+ // A group naming at most one member inside is better named itself, where its name fits.
2232
+ const own = members.length <= 1 ? fit(x0, x1, options, lead, size) : undefined;
2233
+ if (own) {
2234
+ // The slivers between its members close behind the group's name.
2235
+ for (let member = stretch.first; member < stretch.last; member++)
2236
+ parts.push(rect(at(ends[member]!) - 1, middle - 8, 1, 16, ...fill(stretch), 0));
2237
+ inside(x0, own, lead, size);
2238
+ } else {
2239
+ for (const [member, name] of members) inside(at(starts[member]!), name, lead, 10);
2240
+ pending.push(stretch);
2241
+ }
2242
+ continue;
2243
+ }
2244
+ const own = fit(x0, x1, options, lead, size);
2245
+ if (own) inside(x0, own, lead, size);
2246
+ else pending.push(stretch);
2247
+ }
2248
+
2249
+ // Groups span a bracket under their rows, whether or not their name finds a lane.
2250
+ for (const stretch of stretches.filter(stretch => stretch.group)) {
2251
+ const [x0, x1] = [at(starts[stretch.first]!), at(ends[stretch.last]!)];
2252
+ parts.push(
2253
+ line(x0 + 1, stripBottom + 4, x1 - 1, stripBottom + 4, leading.has(stretch) ? SERIES[0] : "muted", 1.5),
2254
+ );
2255
+ }
2256
+ const labels = placeLanes(
2257
+ pending.map(stretch => {
2258
+ const lead = leading.has(stretch);
2259
+ const [x0, x1] = [at(starts[stretch.first]!), at(ends[stretch.last]!)];
2260
+ return {
2261
+ leader: stretch.group ? (x0 + x1) / 2 : Math.max(x0 + 1, Math.min((x0 + x1) / 2, x0 + 6)),
2262
+ lead,
2263
+ value: stretch.value,
2264
+ group: stretch.group,
2265
+ variants: [
2266
+ [stretch.name, lead ? `${stretch.figure} · ${share(stretch)}` : stretch.figure],
2267
+ [stretch.name, ""],
2268
+ [clip(stretch.name, 12), ""],
2269
+ ],
2270
+ };
2271
+ }),
2272
+ total,
2273
+ );
2274
+ let lanes = 0;
2275
+ for (const { item, lane, x0, name, figure } of labels) {
2276
+ lanes = Math.max(lanes, lane + 1);
2277
+ const y = stripBottom + LANE * (lane + 1);
2278
+ parts.push(line(item.leader, stripBottom + (item.group ? 5 : 1), item.leader, y - 6, "muted", 1, 0.7));
2279
+ parts.push(text(x0, y, name, { size: TICK_SIZE, fill: "fg", weight: item.lead ? 600 : undefined }));
2280
+ if (figure)
2281
+ parts.push(
2282
+ text(x0 + measure(name, TICK_SIZE) * (item.lead ? SEMIBOLD_WIDTH : 1) + 6, y, figure, {
2283
+ size: TICK_SIZE,
2284
+ fill: "muted",
2285
+ }),
2286
+ );
2287
+ }
2288
+ const notes = spec.x ? [`at ${spec.x.name}`] : ["end to end, in table order"];
2289
+ return { height: stripBottom + LANE * lanes + (lanes ? 8 : 4), body: parts.join(""), notes };
2290
+ }
2291
+
2292
+ /** A label waiting for a lane under a timeline: where its leader drops, and its texts from fullest to shortest. */
2293
+ interface LaneItem {
2294
+ readonly leader: number;
2295
+ readonly lead: boolean;
2296
+ readonly value: number;
2297
+ readonly group: boolean;
2298
+ readonly variants: readonly (readonly [name: string, figure: string])[];
2299
+ }
2300
+
2301
+ /** A {@link LaneItem} placed: its lane, its box, and the text variant that fit. */
2302
+ interface LaneLabel {
2303
+ readonly item: LaneItem;
2304
+ readonly lane: number;
2305
+ readonly x0: number;
2306
+ readonly x1: number;
2307
+ readonly name: string;
2308
+ readonly figure: string;
2309
+ }
2310
+
2311
+ /**
2312
+ * Lanes for `items` of a run of `total`: each, in turn, in the shallowest of {@link MAX_LANES}
2313
+ * lanes where its box (reaching right of its leader, else left) overlaps no
2314
+ * label and its leader crosses no label above, nor its box a leader below —
2315
+ * with its fullest text that fits. Placement is greedy, so several orders are
2316
+ * tried (right to left, which stacks a dense run like stairs; leading and
2317
+ * {@link LABEL_FIRST}-sized ones first; left to right; largest first) and the
2318
+ * one naming the most items, then the most of the run, wins.
2319
+ */
2320
+ function placeLanes(items: readonly LaneItem[], total: number): LaneLabel[] {
2321
+ const first = new Set(items.filter(item => item.lead || item.value / total >= LABEL_FIRST));
2322
+ const orders = [
2323
+ [...items].sort((a, b) => b.leader - a.leader),
2324
+ [...items].sort((a, b) => Number(first.has(b)) - Number(first.has(a)) || b.leader - a.leader),
2325
+ [...items].sort((a, b) => a.leader - b.leader),
2326
+ [...items].sort((a, b) => b.value - a.value),
2327
+ ];
2328
+ let best: LaneLabel[] = [];
2329
+ let bestScore = [-1, -1, -1];
2330
+ // Each order with full labels or the small items named without their figure, and with labels on the
2331
+ // right half reaching right of their leader first or left of it first.
2332
+ const attempts = orders.flatMap(order =>
2333
+ [false, true].flatMap(terse => [false, true].map(inward => ({ order, terse, inward }))),
2334
+ );
2335
+ for (const { order, terse, inward } of attempts) {
2336
+ const placed: LaneLabel[] = [];
2337
+ for (const item of order) {
2338
+ const variants = terse && !first.has(item) ? item.variants.filter(([, figure]) => !figure) : item.variants;
2339
+ for (const [name, figure] of variants) {
2340
+ const width =
2341
+ measure(name, TICK_SIZE) * (item.lead ? SEMIBOLD_WIDTH : 1) +
2342
+ (figure ? 6 + measure(figure, TICK_SIZE) : 0);
2343
+ // Right of the leader (from the margin at the left edge), else left of it.
2344
+ const anchors = [Math.max(PAD, item.leader - 2), item.leader + 2 - width].filter(
2345
+ start => start >= PAD && start + width <= WIDTH - PAD,
2346
+ );
2347
+ if (inward && item.leader > WIDTH / 2) anchors.reverse();
2348
+ let label: LaneLabel | undefined;
2349
+ for (let lane = 0; lane < MAX_LANES && !label; lane++) {
2350
+ const x0 = anchors.find(start =>
2351
+ placed.every(other =>
2352
+ other.lane === lane
2353
+ ? start + width + 8 < other.x0 || start - 8 > other.x1
2354
+ : other.lane < lane
2355
+ ? item.leader < other.x0 - 3 || item.leader > other.x1 + 3
2356
+ : other.item.leader < start - 3 || other.item.leader > start + width + 3,
2357
+ ),
2358
+ );
2359
+ if (x0 !== undefined) label = { item, lane, x0, x1: x0 + width, name, figure };
2360
+ }
2361
+ if (label) {
2362
+ placed.push(label);
2363
+ break;
2364
+ }
2365
+ }
2366
+ }
2367
+ const score = [
2368
+ placed.length,
2369
+ placed.reduce((sum, label) => sum + label.item.value, 0),
2370
+ placed.filter(label => label.figure).length,
2371
+ ];
2372
+ const at = score.findIndex((value, index) => value !== bestScore[index]);
2373
+ if (at >= 0 && score[at]! > bestScore[at]!) {
2374
+ best = placed;
2375
+ bestScore = score;
2376
+ }
2377
+ }
2378
+ return best;
2379
+ }
2380
+
2381
+ /** A grouped category's own name: its member name, or the group's where the group alone names it. */
2382
+ function memberName(spec: ChartSpec, at: number): string {
2383
+ let first = 0;
2384
+ for (const group of spec.groups ?? []) {
2385
+ if (at < first + group.members.length) return group.members[at - first] || group.name;
2386
+ first += group.members.length;
2387
+ }
2388
+ return spec.categories[at] ?? "";
2389
+ }
2390
+
2391
+ /** A duration as people say it: `48s`, `6m 36s`, `2h 5m`. */
2392
+ function clockText(seconds: number): string {
2393
+ if (seconds < 120) return formatValue(seconds, "duration", "");
2394
+ const whole = Math.round(seconds);
2395
+ const [hours, minutes, rest] = [Math.floor(whole / 3600), Math.floor((whole % 3600) / 60), whole % 60];
2396
+ if (hours) return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
2397
+ return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
2398
+ }
2399
+
2400
+ // ── Grids: heatmap and small multiples ──────────────────────────────────────
2401
+
2402
+ /**
2403
+ * A matrix of rows by series. `heat`: tinted cells on one sequential ramp of
2404
+ * the lead color (each column on its own ramp, in its own color, when shaded
2405
+ * per series), faint for the least and opaque for the most; strong cells
2406
+ * print their figure in {@link ink}, zeros recede to muted. `bars`: one
2407
+ * column of bars per series, each scaled on its own.
2408
+ */
2409
+ function drawGrid(spec: ChartSpec, style: "heat" | "bars"): Layer {
2410
+ const { series } = spec;
2411
+ const heat = style === "heat";
2412
+ const labelWidth = labelColumnWidth(spec);
2413
+ const left = PAD + labelWidth + 10;
2414
+ const cellWidth = (WIDTH - PAD - left) / series.length;
2415
+ const count = spec.categories.length;
2416
+ const rowHeight = labelRow(spec, heat ? (count <= 8 ? 26 : count <= 16 ? 22 : 20) : count <= 8 ? 24 : 20);
2417
+ const headerChars = Math.max(4, Math.floor((cellWidth - 6) / (TICK_SIZE * ADVANCE)));
2418
+ const perSeries = spec.shading === "series";
2419
+ const parts: string[] = [];
2420
+ const headers = series.map(entry => wrapHeader(entry.name, cellWidth - 6, TICK_SIZE * SEMIBOLD_WIDTH));
2421
+ const headerLines = Math.max(...headers.map(lines => lines.length));
2422
+ series.forEach((entry, index) => {
2423
+ const x = left + index * cellWidth;
2424
+ headers[index]!.forEach((content, line) => {
2425
+ // A one-line header sits on the line nearest the cells.
2426
+ const y = 6 + (headerLines - headers[index]!.length + line) * 13;
2427
+ parts.push(
2428
+ heat
2429
+ ? text(x + cellWidth / 2, y, content, {
2430
+ size: TICK_SIZE,
2431
+ fill: perSeries ? "fg" : "muted",
2432
+ anchor: "middle",
2433
+ weight: perSeries ? 600 : undefined,
2434
+ })
2435
+ : text(x + 2, y, content, { size: TICK_SIZE, fill: "fg", weight: 600 }),
2436
+ );
2437
+ });
2438
+ });
2439
+ const top = 18 + (headerLines - 1) * 13;
2440
+ const tops = rowTops(spec, top, rowHeight);
2441
+ const bottom = tops.at(-1)!;
2442
+ // Heatmaps share one scale unless shaded per series or per row; small multiples scale each series on its own.
2443
+ const all = series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])));
2444
+ const shared = shade(all);
2445
+ const perRow = heat && spec.shading === "row";
2446
+ const byRow = spec.categories.map((_, row) =>
2447
+ shade(series.flatMap(entry => (entry.points[row] ? [entry.points[row]!.value] : []))),
2448
+ );
2449
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
2450
+ parts.push(categoryLabels(spec, tops, rowHeight, strongCategories(spec)));
2451
+ // Whether any column printed factors of a reference row, which the header then names.
2452
+ let factorNoted = false;
2453
+ series.forEach((entry, index) => {
2454
+ const x = left + index * cellWidth;
2455
+ const values = entry.points.flatMap(point => (point ? [point.value] : []));
2456
+ const own = shade(values);
2457
+ // Bars reach their stated spread (`32 ± 7`) too.
2458
+ const reach = entry.points.flatMap(point => (point ? [point.value + (point.error ?? 0)] : []));
2459
+ // Bars beside reference rows print their factor of their group's reference (`36.3k 2.2×`), toned by the
2460
+ // measure's polarity: the comparison a table of arms against a baseline is about ({@link referenceText}).
2461
+ const allFactors = heat ? [] : referenceFactors(spec, entry);
2462
+ const ratios = allFactors.some(factor => factor !== undefined && (factor >= 2 || factor <= 0.5));
2463
+ // Room past each bar for its row's own figure, factor and `best` tag (the widest row, not the widest of
2464
+ // each), and a clear gap before the next column's rule. What would squeeze the bars under
2465
+ // {@link MIN_BAR_SHARE} of the column goes: first the tag (its figure stays semibold), then the factors.
2466
+ const need = (best: boolean, factored: boolean) =>
2467
+ Math.max(
2468
+ 0,
2469
+ ...entry.points.map((point, row) => {
2470
+ const factor = allFactors[row];
2471
+ return (
2472
+ measure(point?.text ?? "", TICK_SIZE) * SEMIBOLD_WIDTH +
2473
+ (factored && factor !== undefined ? measure(referenceText(factor, ratios), TICK_SIZE - 1) + 6 : 0) +
2474
+ (best && (entry.best === row || spec.leaders?.[row] === index) ? BEST_WIDTH : 0)
2475
+ );
2476
+ }),
2477
+ ) + 18;
2478
+ const fits = (best: boolean, factored: boolean) => cellWidth - need(best, factored) >= MIN_BAR_SHARE * cellWidth;
2479
+ const factored = fits(false, true);
2480
+ const tagged = fits(true, factored);
2481
+ const factors = factored ? allFactors : [];
2482
+ const factorTexts = factors.map(factor => (factor === undefined ? "" : referenceText(factor, ratios)));
2483
+ if (factorTexts.some(Boolean)) factorNoted = true;
2484
+ const barSpace = Math.max(MIN_BAR_SHARE * cellWidth, cellWidth - need(tagged, factored));
2485
+ const bars = panelScale(heat ? values : reach, x + 2, x + 2 + barSpace);
2486
+ const reference = heat ? undefined : referenceRules(spec, entry, tops, rowHeight, bars.at);
2487
+ const emphasized = entry.points.some(point => point?.emphasis);
2488
+ // Columns are told apart by their headings: one hue for all, so color stays free for emphasis.
2489
+ const color = SERIES[0];
2490
+ const estimated = estimatedPoints(entry);
2491
+ entry.points.forEach((point, row) => {
2492
+ const y = tops[row]!;
2493
+ const middle = y + rowHeight / 2;
2494
+ if (!point) {
2495
+ const note = entry.notes[row];
2496
+ if (note) parts.push(noteText(heat ? x + cellWidth / 2 : x + 4, middle, clip(note, headerChars), heat));
2497
+ return;
2498
+ }
2499
+ const best = entry.best === row || spec.leaders?.[row] === index;
2500
+ if (heat) {
2501
+ const level = perSeries ? own(point.value) : perRow ? byRow[row]!(point.value) : shared(point.value);
2502
+ parts.push(
2503
+ rect(x + 1.5, y + 1.5, cellWidth - 3, rowHeight - 3, color, HEAT_FLOOR + (1 - HEAT_FLOOR) * level, 4),
2504
+ );
2505
+ // The best cell of a column is outlined in the success color.
2506
+ if (best) parts.push(outline(x + 1.5, y + 1.5, cellWidth - 3, rowHeight - 3, "success"));
2507
+ const style = {
2508
+ size: TICK_SIZE,
2509
+ fill: point.value === 0 ? "muted" : "fg",
2510
+ anchor: "middle",
2511
+ weight: point.emphasis || best || level >= INK_LEVEL ? 600 : undefined,
2512
+ } as const;
2513
+ const label = clip(point.text, headerChars);
2514
+ parts.push(
2515
+ level >= INK_LEVEL
2516
+ ? ink(x + cellWidth / 2, middle, label, style)
2517
+ : text(x + cellWidth / 2, middle, label, style),
2518
+ );
2519
+ return;
2520
+ }
2521
+ const end = Math.max(x + 2 + MIN_BAR, bars.at(point.value));
2522
+ const thick = Math.min(14, rowHeight - 10);
2523
+ // Rows beside the bolded ones recede to gray, as a lone series' context bars do.
2524
+ const context = emphasized && !point.emphasis;
2525
+ parts.push(
2526
+ bars.cut && point.value > bars.cut.below
2527
+ ? cutBar(bars.cut, x + 2, end, middle - thick / 2, thick, context ? "muted" : color, context ? 0.55 : 1)
2528
+ : barRect(x + 2, middle - thick / 2, end - x - 2, thick, context ? "muted" : color, context ? 0.55 : 1, {
2529
+ estimate: estimated.has(row),
2530
+ }),
2531
+ );
2532
+ const spread = point.error && !bars.cut ? whisker(bars, point, middle, thick / 2 - 1) : "";
2533
+ parts.push(spread);
2534
+ // A figure clears its group's reference rule and the spread's whisker.
2535
+ const figureX =
2536
+ Math.max(end, reference?.at[row] ?? end, spread ? bars.at(point.value + point.error!) : end) + 4;
2537
+ parts.push(
2538
+ text(figureX, middle, point.text, {
2539
+ size: TICK_SIZE,
2540
+ fill: point.emphasis || best ? "fg" : "muted",
2541
+ weight: point.emphasis || best ? 600 : undefined,
2542
+ }),
2543
+ );
2544
+ let after = figureX + measure(point.text, TICK_SIZE) * (point.emphasis || best ? SEMIBOLD_WIDTH : 1) + 5;
2545
+ if (factorTexts[row]) {
2546
+ const tone = moveTone(entry.polarity, factors[row]!) ?? "muted";
2547
+ parts.push(text(after, middle, factorTexts[row]!, { size: TICK_SIZE - 1, fill: tone }));
2548
+ after += measure(factorTexts[row]!, TICK_SIZE - 1) + 6;
2549
+ }
2550
+ if (best && tagged) parts.push(bestTag(after, middle, TICK_SIZE));
2551
+ });
2552
+ if (reference) parts.push(reference.body);
2553
+ });
2554
+ if (!heat) {
2555
+ for (let index = 1; index < series.length; index++) {
2556
+ const x = left + index * cellWidth - 3;
2557
+ parts.push(rule(x, top, x, bottom));
2558
+ }
2559
+ // Factors name what they are factors of, where every group compares with a row of one name (`vs bash`).
2560
+ const bases = new Set((spec.reference ?? []).map(at => clip(memberName(spec, at), 24)));
2561
+ const versus = factorNoted && bases.size === 1 ? [`vs ${[...bases][0]}`] : [];
2562
+ return { height: bottom + 4, body: parts.join(""), notes: ["scaled per column", ...versus] };
2563
+ }
2564
+ const logShade = logShaded(all);
2565
+ const outlined =
2566
+ series.some(entry => entry.best !== undefined) || (spec.leaders ?? []).some(lead => lead !== undefined);
2567
+ const notes = [
2568
+ ...(perSeries ? ["shaded per column"] : perRow ? ["shaded per row"] : logShade ? ["log shading"] : []),
2569
+ // The outline is the only mark of a column's best: the note keys it.
2570
+ ...(outlined ? ["best outlined"] : []),
2571
+ ];
2572
+ // The faintest swatch stands for where the shading starts: zero, the least positive value on a log ramp, or the least.
2573
+ const least = Math.min(...all);
2574
+ const ramp =
2575
+ perSeries || perRow || all.length === 0
2576
+ ? undefined
2577
+ : {
2578
+ color: SERIES[0],
2579
+ low: formatValue(
2580
+ logShade ? Math.min(...all.filter(value => value > 0)) : Math.min(least, heatFloor(all)),
2581
+ series[0]!.dim,
2582
+ series[0]!.unit,
2583
+ ),
2584
+ high: formatValue(Math.max(...all), series[0]!.dim, series[0]!.unit),
2585
+ };
2586
+ return { height: bottom + 4, body: parts.join(""), notes, ramp };
2587
+ }
2588
+
2589
+ /**
2590
+ * A column heading in one line of `width` at `size` (an em scale; pass the
2591
+ * semibold width for bold text), or two split where they balance
2592
+ * ({@link wrapLabel}: `Strict Recall` → `Strict`, `Recall`;
2593
+ * `gtTagRelevance` → `gtTag`, `Relevance`) when one would clip it.
2594
+ */
2595
+ function wrapHeader(name: string, width: number, size: number): string[] {
2596
+ return measure(name, size) <= width ? [name] : wrapLabel(name, width, size);
2597
+ }
2598
+
2599
+ /**
2600
+ * Small multiples as bands of panels, one per series, each scaling its own
2601
+ * bars; the few categories are told apart by color through a legend. Fits a
2602
+ * table whose rows are metrics: each metric gets a panel comparing the columns.
2603
+ */
2604
+ function drawPanels(spec: ChartSpec): Layer {
2605
+ const { categories, series } = spec;
2606
+ const parts: string[] = [];
2607
+ const bands = Math.ceil(series.length / PANELS_ACROSS);
2608
+ const across = Math.ceil(series.length / bands);
2609
+ const gap = 18;
2610
+ const panelWidth = (WIDTH - 2 * PAD - gap * (across - 1)) / across;
2611
+ const panelHeight = 20 + categories.length * 14 + 14;
2612
+ const top = 0;
2613
+ const titleChars = Math.floor(panelWidth / (TICK_SIZE * ADVANCE));
2614
+ series.forEach((entry, index) => {
2615
+ const x = PAD + (index % across) * (panelWidth + gap);
2616
+ const y = top + Math.floor(index / across) * panelHeight;
2617
+ parts.push(text(x, y + 6, clip(entry.name, titleChars), { size: TICK_SIZE, fill: "fg", weight: 600 }));
2618
+ const textWidth = Math.max(0, ...entry.points.map((_, at) => measure(pointText(entry, at), 10)));
2619
+ const barSpace = Math.max(0, panelWidth - textWidth - 6 - (entry.best === undefined ? 0 : BEST_WIDTH));
2620
+ const scale = panelScale(
2621
+ entry.points.flatMap(point => (point ? [point.value + (point.error ?? 0)] : [])),
2622
+ x,
2623
+ x + barSpace,
2624
+ );
2625
+ // Categories are colors here, never nested: a reference rule runs through the whole panel.
2626
+ const rows = categories.map((_, at) => y + 20 + at * 14);
2627
+ const reference = referenceRules({ ...spec, groups: undefined }, entry, rows, 14, scale.at);
2628
+ const emphasized = entry.points.some(point => point?.emphasis);
2629
+ entry.points.forEach((point, at) => {
2630
+ const middle = y + 27 + at * 14;
2631
+ if (!point) {
2632
+ const note = entry.notes[at];
2633
+ if (note) parts.push(noteText(x, middle, clip(note, titleChars)));
2634
+ return;
2635
+ }
2636
+ const end = Math.max(x + MIN_BAR, scale.at(point.value));
2637
+ const color = SERIES[at % SERIES.length]!;
2638
+ const opacity = emphasized && !point.emphasis ? RECEDE : 1;
2639
+ parts.push(
2640
+ scale.cut && point.value > scale.cut.below
2641
+ ? cutBar(scale.cut, x, end, middle - 5, 10, color, opacity)
2642
+ : rect(x, middle - 5, end - x, 10, color, opacity),
2643
+ );
2644
+ const spread = point.error && !scale.cut ? whisker(scale, point, middle, 4) : "";
2645
+ parts.push(spread);
2646
+ const best = entry.best === at;
2647
+ const figureX =
2648
+ Math.max(end, reference.at[at] ?? end, spread ? scale.at(point.value + point.error!) : end) + 4;
2649
+ parts.push(
2650
+ text(figureX, middle, pointText(entry, at), {
2651
+ size: 10,
2652
+ fill: point.emphasis || best ? "fg" : "muted",
2653
+ weight: point.emphasis || best ? 600 : undefined,
2654
+ }),
2655
+ );
2656
+ if (best) parts.push(bestTag(figureX + measure(pointText(entry, at), 10) * SEMIBOLD_WIDTH + 4, middle, 10));
2657
+ });
2658
+ parts.push(reference.body);
2659
+ });
2660
+ return {
2661
+ height: top + bands * panelHeight - 10,
2662
+ body: parts.join(""),
2663
+ legend: categories.map((name, at) => ({ name, color: SERIES[at % SERIES.length]! })),
2664
+ notes: ["scaled per panel"],
2665
+ };
2666
+ }
2667
+
2668
+ // ── Change against a baseline ───────────────────────────────────────────────
2669
+
2670
+ /** Factor within which every move of a `change` reads as a percent on a linear axis (−67% … +200%). */
2671
+ const PERCENT_REACH = 3;
2672
+ /** Least room between two factor tick labels (`3× less`, `1×`). */
2673
+ const FACTOR_TICK_GAP = 56;
2674
+ /** Least reach of a percent axis (in percent) toward each side its moves take, so a few percent never fill it. */
2675
+ const MIN_PERCENT_REACH = 10;
2676
+
2677
+ /**
2678
+ * Each metric's move from the baseline as a bar from no change, every row
2679
+ * written one way ({@link factorMove}): moves all within {@link PERCENT_REACH}×
2680
+ * as percents on a linear axis through 0% (`−4%`, `−56%`); farther ones as
2681
+ * factors on a log axis through 1×, where halving and doubling draw equally
2682
+ * long whatever the metric's unit (`155× less`, `1.1× more`). One series prints
2683
+ * the move and both figures beside its bar; bars take the move's tone where
2684
+ * the metric has a polarity, the rest one neutral color.
2685
+ */
2686
+ function drawChange(spec: ChartSpec): Layer {
2687
+ const { categories, series } = spec;
2688
+ const single = series.length === 1;
2689
+ const points = series.flatMap(entry => entry.points.filter(point => point !== null));
2690
+ const style = points.every(
2691
+ point => point.value <= PERCENT_REACH && (point.value === 0 || point.value >= 1 / PERCENT_REACH),
2692
+ )
2693
+ ? "percent"
2694
+ : "factor";
2695
+ const logs = points.flatMap(point => (point.value > 0 ? [Math.log10(point.value)] : []));
2696
+ let low = -roundFactor(-Math.min(0, ...logs));
2697
+ let high = roundFactor(Math.max(0, ...logs));
2698
+ // A drop to zero has no factor: on the log axis its bar runs off the axis' end through a break, its move
2699
+ // written `to zero`; on the percent axis it runs to −100%.
2700
+ if (points.some(point => point.value === 0)) low = Math.min(low, -Math.log10(2));
2701
+ if (low === high) high = Math.log10(FACTOR_STOPS[0]!);
2702
+
2703
+ const parts: string[] = [];
2704
+ const top = 0;
2705
+ const factors = categories.map((_, at) => {
2706
+ const point = series[0]!.points[at];
2707
+ return single && point ? factorMove(point.value, style) : "";
2708
+ });
2709
+ const factorWidth = Math.max(0, ...factors.map(label => measure(label, TICK_SIZE)));
2710
+ const figures = categories.map((_, at) => (single ? (series[0]!.points[at]?.text ?? "") : ""));
2711
+ const figureWidth = Math.max(0, ...figures.map(label => measure(label, TICK_SIZE)));
2712
+ const labelWidth = labelColumnWidth(spec);
2713
+ // Long figures (`0.138 / 0.537 → 0.057 / 0.154`) take up to {@link FIGURE_SHARE} of the room right of the labels.
2714
+ const valueWidth = single
2715
+ ? Math.min(Math.max(280, (WIDTH - 2 * PAD - labelWidth - 10) * FIGURE_SHARE), factorWidth + 10 + figureWidth) + 8
2716
+ : Math.max(0, ...points.map(point => measure(factorMove(point.value, style), 10))) + 8;
2717
+ const left = PAD + labelWidth + 10;
2718
+ const right = WIDTH - PAD - valueWidth;
2719
+ const metrics = barMetrics(categories.length, series.length);
2720
+ const rowHeight = labelRow(spec, metrics.row);
2721
+ const barHeight = metrics.bar;
2722
+ const tops = rowTops(spec, top, rowHeight);
2723
+ const gridBottom = tops.at(-1)!;
2724
+
2725
+ // Where a factor lands, and the ticks: percents on nice linear steps through 0%; on the log axis 1× always,
2726
+ // the ends and whole decades between wherever their labels stay clear of the rest.
2727
+ let x: (factor: number) => number;
2728
+ const ticks = new Map<number, string>();
2729
+ if (style === "percent") {
2730
+ // Moves as written (whole percents): a `same` never stretches the axis to a side of its own.
2731
+ const percents = points.map(point => Math.round((point.value - 1) * 100));
2732
+ const reach = [
2733
+ ...(percents.some(percent => percent < 0) ? [-MIN_PERCENT_REACH] : []),
2734
+ ...(percents.some(percent => percent > 0) ? [MIN_PERCENT_REACH] : []),
2735
+ ];
2736
+ const scale = valueScale([...percents, ...reach], left, right, { zero: true });
2737
+ x = factor => scale.at((factor - 1) * 100);
2738
+ for (const tick of scale.ticks)
2739
+ ticks.set(tick / 100 + 1, tick === 0 ? "0%" : `${tick > 0 ? "+" : "−"}${compact(Math.abs(tick))}%`);
2740
+ } else {
2741
+ const at = (log: number) => left + ((log - low) / (high - low)) * (right - left);
2742
+ x = factor => at(factor > 0 ? Math.log10(factor) : low);
2743
+ const decade = (right - left) / (high - low);
2744
+ const logs = [0];
2745
+ for (const end of [low, high]) if (Math.abs(end) * decade >= FACTOR_TICK_GAP) logs.push(end);
2746
+ for (let power = Math.ceil(low); power <= Math.floor(high); power++) {
2747
+ if (logs.every(tick => Math.abs(power - tick) * decade >= FACTOR_TICK_GAP)) logs.push(power);
2748
+ }
2749
+ for (const log of logs)
2750
+ ticks.set(10 ** log, log === 0 ? "1×" : `${compactFactor(10 ** Math.abs(log))}× ${log < 0 ? "less" : "more"}`);
2751
+ }
2752
+ for (const [tick, label] of ticks) {
2753
+ parts.push(rule(x(tick), top, x(tick), gridBottom));
2754
+ parts.push(text(x(tick), gridBottom + 12, label, { size: TICK_SIZE, fill: "muted", anchor: "middle" }));
2755
+ }
2756
+ // Neutral bars share one color; beside toned ones they step back.
2757
+ const toned = categories.some((_, row) =>
2758
+ series.some(entry => {
2759
+ const point = entry.points[row];
2760
+ return point && moveTone(spec.categoryPolarity?.[row] ?? entry.polarity, point.value);
2761
+ }),
2762
+ );
2763
+
2764
+ const emphasized = points.some(point => point.emphasis);
2765
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
2766
+ parts.push(categoryLabels(spec, tops, rowHeight, strongCategories(spec)));
2767
+ categories.forEach((_, row) => {
2768
+ const rowTop = tops[row]!;
2769
+ const middle = rowTop + rowHeight / 2;
2770
+ series.forEach((entry, index) => {
2771
+ const point = entry.points[row];
2772
+ if (!point) return;
2773
+ const y = single ? middle - barHeight / 2 : rowTop + 5 + index * metrics.step;
2774
+ const end = x(point.value);
2775
+ // A metric with a polarity reads its move as an improvement or a regression.
2776
+ const tone = moveTone(spec.categoryPolarity?.[row] ?? entry.polarity, point.value);
2777
+ // One series takes the move's tone (neutral otherwise); several keep their own colors.
2778
+ const color = single ? (tone ?? (toned ? "muted" : SERIES[0])) : SERIES[index % SERIES.length]!;
2779
+ const opacity = emphasized && !point.emphasis ? RECEDE : 1;
2780
+ if (style === "factor" && point.value === 0) {
2781
+ const cut = end + CLIP_INSET;
2782
+ parts.push(rect(end, y, CLIP_INSET - CUT_GAP / 2, barHeight, color, opacity));
2783
+ parts.push(rect(cut + CUT_GAP / 2, y, x(1) - cut - CUT_GAP / 2, barHeight, color, opacity));
2784
+ parts.push(breakMark(cut, y + barHeight / 2, barHeight / 2 + 1, "muted"));
2785
+ } else parts.push(rect(Math.min(x(1), end), y, Math.max(1, Math.abs(end - x(1))), barHeight, color, opacity));
2786
+ if (!single)
2787
+ parts.push(
2788
+ text(right + 8, y + barHeight / 2, factorMove(point.value, style), { size: 10, fill: tone ?? "muted" }),
2789
+ );
2790
+ });
2791
+ if (single) {
2792
+ const point = series[0]!.points[row];
2793
+ const strong = point?.emphasis ? 600 : undefined;
2794
+ const tone = point ? moveTone(spec.categoryPolarity?.[row] ?? series[0]!.polarity, point.value) : undefined;
2795
+ parts.push(text(right + 8, middle, factors[row]!, { size: TICK_SIZE, fill: tone ?? "fg", weight: strong }));
2796
+ parts.push(
2797
+ text(right + 18 + factorWidth, middle, fitText(figures[row]!, valueWidth - 17 - factorWidth, TICK_SIZE), {
2798
+ size: TICK_SIZE,
2799
+ fill: "muted",
2800
+ }),
2801
+ );
2802
+ }
2803
+ });
2804
+ parts.push(line(x(1), top - 2, x(1), gridBottom + 2, "muted", 1.5));
2805
+ const versus = `change vs ${spec.baseline ?? "baseline"}`;
2806
+ return {
2807
+ height: gridBottom + 20,
2808
+ body: parts.join(""),
2809
+ legend: single ? [] : series.map((entry, at) => ({ name: entry.name, color: SERIES[at % SERIES.length]! })),
2810
+ notes: style === "percent" ? [versus] : [versus, "log scale"],
2811
+ };
2812
+ }
2813
+
2814
+ /** `log10` of the first {@link FACTOR_STOPS} entry (else power of ten) at or past `10^log`; 0 stays 0. */
2815
+ function roundFactor(log: number): number {
2816
+ if (log <= 0) return 0;
2817
+ const stop = FACTOR_STOPS.find(factor => Math.log10(factor) >= log - 1e-9);
2818
+ return stop === undefined ? Math.ceil(log - 1e-9) : Math.log10(stop);
2819
+ }
2820
+
2821
+ /** A factor as people say it: `48× less`, `2.1× more`, or a percent within 2× (`−12%`, `+27%`). */
2822
+ function factorText(point: ChartPoint | null | undefined): string {
2823
+ if (!point) return "";
2824
+ return factorMove(point.value, point.value >= 2 || (point.value > 0 && point.value <= 0.5) ? "factor" : "percent");
2825
+ }
2826
+
2827
+ /**
2828
+ * A value's factor of its reference row, one way for a whole column: a percent
2829
+ * (`−7%`, `+57%`) where every factor stays within 2× either way, else the
2830
+ * factor itself (`2.2×`, `1.6×`, `0.45×`: one decimal from 1×, two under it,
2831
+ * whole from 10×) once any reaches past — shorter than `2.2× more`, so the
2832
+ * figures leave the bars their width; `same` within {@link STILL}.
2833
+ */
2834
+ function referenceText(factor: number, ratio: boolean): string {
2835
+ if (!ratio) return factorMove(factor, "percent");
2836
+ if (factor > 0 && Math.abs(Math.log(factor)) < Math.log(STILL)) return "same";
2837
+ return `${factor >= 10 ? compact(Math.round(factor)) : Number(factor.toFixed(factor >= 1 ? 1 : 2))}×`;
2838
+ }
2839
+
2840
+ /** `48`, `2.1`, `1.25`, `35k`: whole above 10, one decimal above 2, two below. */
2841
+ function compactFactor(factor: number): string {
2842
+ if (factor >= 10) return compact(Math.round(factor));
2843
+ return Number(factor.toFixed(factor >= 2 ? 1 : 2)).toString();
2844
+ }
2845
+
2846
+ // ── Slope: values across ordered stages ──────────────────────────────────────
2847
+
2848
+ /** Plot height a slope chart gives each category, and the least and most it takes in all. */
2849
+ const SLOPE_ROW = 26;
2850
+ const MIN_SLOPE_HEIGHT = 150;
2851
+ const MAX_SLOPE_HEIGHT = 300;
2852
+ /** Widest end label column of a slope chart, either side. */
2853
+ const MAX_SLOPE_LABEL = 230;
2854
+
2855
+ /** Categories ending on one figure at a stage, labelled once (`haiku, gemini, glm 2`). */
2856
+ interface StageLabel {
2857
+ y: number;
2858
+ readonly text: string;
2859
+ readonly names: readonly number[];
2860
+ }
2861
+
2862
+ /**
2863
+ * Each category as a line through the stages (the series, in order), the
2864
+ * stages as columns headed by their names. The first stage's values are hollow
2865
+ * rings, later ones dots; each step takes the tone its move has for the
2866
+ * measure ({@link pairTone}), else the category's color (or a neutral one past
2867
+ * the series colors). Both ends carry labels: names and first values on the
2868
+ * left, last values, names and the net move ({@link moveText}) on the right;
2869
+ * categories sharing a figure share a label. Middle stages print their values
2870
+ * small beside the dots.
2871
+ */
2872
+ function drawSlope(spec: ChartSpec): Layer {
2873
+ const { categories, series } = spec;
2874
+ const first = series[0]!;
2875
+ const last = series.at(-1)!;
2876
+ const strong = strongCategories(spec);
2877
+ const steps = categories.map((_, at) =>
2878
+ series.slice(1).map((entry, index) => {
2879
+ const [a, b] = [series[index]!.points[at], entry.points[at]];
2880
+ return a && b ? pairTone(series[index]!, entry, a, b) : undefined;
2881
+ }),
2882
+ );
2883
+ const toned = steps.some(tones => tones.some(Boolean));
2884
+ const own = (at: number) =>
2885
+ toned ? "muted" : categories.length <= SERIES.length ? SERIES[at]! : strong.has(at) ? SERIES[0] : "muted";
2886
+ const ends = categories.map((_, at): [ChartPoint, ChartPoint] | undefined => {
2887
+ const [a, b] = [first.points[at], last.points[at]];
2888
+ return a && b ? [a, b] : undefined;
2889
+ });
2890
+ const style = moveStyle(
2891
+ last,
2892
+ ends.filter(pair => pair !== undefined),
2893
+ );
2894
+ const nets = ends.map(pair => (pair ? moveText(pair[0], pair[1], last, style) : ""));
2895
+ const netTone = ends.map(pair => (pair ? pairTone(first, last, pair[0], pair[1]) : undefined));
2896
+ // Categories sharing a figure at a stage share one label.
2897
+ const labelsAt = (entry: ChartSeries): StageLabel[] => {
2898
+ const byText = new Map<string, number[]>();
2899
+ entry.points.forEach((point, at) => {
2900
+ if (point) byText.set(point.text, [...(byText.get(point.text) ?? []), at]);
2901
+ });
2902
+ return [...byText].map(([figure, names]) => ({ y: 0, text: figure, names }));
2903
+ };
2904
+ const nameList = (names: readonly number[]) => names.map(at => categories[at]!).join(", ");
2905
+ const netOf = (names: readonly number[]) =>
2906
+ names.every(at => nets[at] === nets[names[0]!]) ? nets[names[0]!]! : "";
2907
+ const lefts = labelsAt(first);
2908
+ const rights = labelsAt(last);
2909
+ // A little slack past the estimate keeps a name that just fits from clipping.
2910
+ const leftWidth = Math.min(
2911
+ MAX_SLOPE_LABEL,
2912
+ Math.max(...lefts.map(label => measure(nameList(label.names), TICK_SIZE) + 6 + measure(label.text, TICK_SIZE))) +
2913
+ 6,
2914
+ );
2915
+ const rightWidth = Math.min(
2916
+ MAX_SLOPE_LABEL,
2917
+ Math.max(
2918
+ ...rights.map(
2919
+ label =>
2920
+ measure(label.text, TICK_SIZE) * SEMIBOLD_WIDTH +
2921
+ 6 +
2922
+ measure(nameList(label.names), TICK_SIZE) +
2923
+ (netOf(label.names) ? 8 + measure(netOf(label.names), TICK_SIZE) * SEMIBOLD_WIDTH : 0),
2924
+ ),
2925
+ ),
2926
+ );
2927
+ const x0 = PAD + leftWidth + 12;
2928
+ const x1 = WIDTH - PAD - rightWidth - 12;
2929
+ const xs = series.map((_, index) => x0 + (index / (series.length - 1)) * (x1 - x0));
2930
+ const plotTop = 30;
2931
+ const plotHeight = Math.min(MAX_SLOPE_HEIGHT, Math.max(MIN_SLOPE_HEIGHT, categories.length * SLOPE_ROW));
2932
+ const plotBottom = plotTop + plotHeight;
2933
+ const values = series.flatMap(entry => entry.points.flatMap(point => (point ? [point.value] : [])));
2934
+ const scale = valueScale(values, plotBottom, plotTop, { zero: false });
2935
+
2936
+ const parts: string[] = [];
2937
+ series.forEach((entry, index) => {
2938
+ parts.push(rule(xs[index]!, plotTop - 10, xs[index]!, plotBottom + 6));
2939
+ parts.push(text(xs[index]!, 8, clip(entry.name, 24), { size: TICK_SIZE, fill: "muted", anchor: "middle" }));
2940
+ });
2941
+ // Lines first, emphasized ones last so they cross over the rest; dots over every line.
2942
+ const order = categories.map((_, at) => at).sort((a, b) => Number(strong.has(a)) - Number(strong.has(b)));
2943
+ const dots: string[] = [];
2944
+ for (const at of order) {
2945
+ const width = strong.has(at) ? 3 : 2.25;
2946
+ series.forEach((entry, index) => {
2947
+ const point = entry.points[at];
2948
+ if (!point) return;
2949
+ const [x, y] = [xs[index]!, scale.at(point.value)];
2950
+ const before = index > 0 ? series[index - 1]!.points[at] : null;
2951
+ const color = steps[at]![index - 1] ?? own(at);
2952
+ if (before) parts.push(line(xs[index - 1]!, scale.at(before.value), x, y, color, width, 0.85));
2953
+ dots.push(index === 0 ? ring(x, y, 3.5, toned ? "muted" : own(at)) : dot(x, y, 3.5, color));
2954
+ });
2955
+ }
2956
+ parts.push(...dots);
2957
+ // Middle stages print their figures small, above their dots.
2958
+ for (const [index, entry] of series.entries()) {
2959
+ if (index === 0 || index === series.length - 1) continue;
2960
+ // Figures whose dots nearly meet join one label over the topmost, top to bottom (`17.10 ms · 16.02 ms`):
2961
+ // pushed apart, the lower would sit on the lines.
2962
+ const labels: StageLabel[] = [];
2963
+ const natural = labelsAt(entry)
2964
+ .map(label => ({ ...label, y: scale.at(entry.points[label.names[0]!]!.value) - 9 }))
2965
+ .sort((a, b) => a.y - b.y);
2966
+ for (const label of natural) {
2967
+ const above = labels.at(-1);
2968
+ if (above && label.y - above.y < 11)
2969
+ labels[labels.length - 1] = {
2970
+ ...above,
2971
+ text: `${above.text} · ${label.text}`,
2972
+ names: [...above.names, ...label.names],
2973
+ };
2974
+ else labels.push(label);
2975
+ }
2976
+ for (const label of spreadApart(labels, plotTop - 8, plotBottom, 11))
2977
+ parts.push(text(xs[index]!, label.y, label.text, { size: 10, fill: "muted", anchor: "middle" }));
2978
+ }
2979
+ const place = (labels: StageLabel[], entry: ChartSeries) => {
2980
+ for (const label of labels) label.y = scale.at(entry.points[label.names[0]!]!.value);
2981
+ return spreadApart(labels, plotTop - 6, plotBottom + 6, TICK_SIZE + 3);
2982
+ };
2983
+ const nameColor = (names: readonly number[]) => (toned || names.length > 1 ? "fg" : own(names[0]!));
2984
+ for (const label of place(lefts, first)) {
2985
+ const figureWidth = measure(label.text, TICK_SIZE);
2986
+ parts.push(text(x0 - 12, label.y, label.text, { size: TICK_SIZE, fill: "muted", anchor: "end" }));
2987
+ parts.push(
2988
+ text(x0 - 18 - figureWidth, label.y, fitText(nameList(label.names), x0 - 18 - figureWidth - PAD, TICK_SIZE), {
2989
+ size: TICK_SIZE,
2990
+ fill: nameColor(label.names),
2991
+ anchor: "end",
2992
+ weight: label.names.some(at => strong.has(at)) ? 600 : undefined,
2993
+ }),
2994
+ );
2995
+ }
2996
+ for (const label of place(rights, last)) {
2997
+ const net = netOf(label.names);
2998
+ const tone = label.names.every(at => netTone[at] === netTone[label.names[0]!])
2999
+ ? netTone[label.names[0]!]
3000
+ : undefined;
3001
+ let x = x1 + 12;
3002
+ parts.push(text(x, label.y, label.text, { size: TICK_SIZE, fill: "fg", weight: 600 }));
3003
+ x += measure(label.text, TICK_SIZE) * SEMIBOLD_WIDTH + 6;
3004
+ const netWidth = net ? 8 + measure(net, TICK_SIZE) * SEMIBOLD_WIDTH : 0;
3005
+ const names = fitText(nameList(label.names), WIDTH - PAD - x - netWidth, TICK_SIZE);
3006
+ parts.push(
3007
+ text(x, label.y, names, {
3008
+ size: TICK_SIZE,
3009
+ fill: nameColor(label.names),
3010
+ weight: label.names.some(at => strong.has(at)) ? 600 : undefined,
3011
+ }),
3012
+ );
3013
+ if (net)
3014
+ parts.push(
3015
+ text(x + measure(names, TICK_SIZE) + 8, label.y, net, {
3016
+ size: TICK_SIZE,
3017
+ fill: tone ?? "muted",
3018
+ weight: tone ? 600 : undefined,
3019
+ }),
3020
+ );
3021
+ }
3022
+ const polarity = last.polarity ?? first.polarity;
3023
+ return {
3024
+ height: plotBottom + 10,
3025
+ body: parts.join(""),
3026
+ notes: [...(toned ? [`${polarity} is better`] : []), ...(scale.log ? ["log scale"] : [])],
3027
+ };
3028
+ }
3029
+
3030
+ // ── Butterfly: opposing amounts back to back ─────────────────────────────────
3031
+
3032
+ /**
3033
+ * Two opposing amounts per category as bars from one spine: the first series
3034
+ * (the losing side: removed, losses) leftward in the error tone, the second
3035
+ * (added, wins) rightward in the success tone, on one scale either way, each
3036
+ * figure at its bar's end and the series named over its half. Each row's net
3037
+ * closes the line in the tone of the side that outweighs.
3038
+ */
3039
+ function drawButterfly(spec: ChartSpec): Layer {
3040
+ const { categories, series } = spec;
3041
+ const loss = series[0]!;
3042
+ const gain = series[1]!;
3043
+ const sides = [loss, gain];
3044
+ const size = (point: ChartPoint | null | undefined) => Math.abs(point?.value ?? 0);
3045
+ const nets = categories.map((_, at) => size(gain.points[at]) - size(loss.points[at]));
3046
+ const whole = sides.every(entry => entry.points.every(point => !point || Number.isInteger(point.value)));
3047
+ const netText = (net: number) =>
3048
+ net === 0
3049
+ ? "±0"
3050
+ : `${net > 0 ? "+" : "−"}${whole ? Math.abs(net).toLocaleString("en-US") : formatValue(Math.abs(net), gain.dim, gain.unit)}`;
3051
+ const figures = sides.map(entry => categories.map((_, at) => pointText(entry, at)));
3052
+ const widest = (labels: readonly string[]) => Math.max(0, ...labels.map(label => measure(label, TICK_SIZE)));
3053
+ const netWidth = Math.max(measure("net", TICK_SIZE), widest(nets.map(netText)) * SEMIBOLD_WIDTH) + 12;
3054
+ const left = PAD + labelColumnWidth(spec) + 10 + widest(figures[0]!) + 8;
3055
+ const right = WIDTH - PAD - netWidth - widest(figures[1]!) - 8;
3056
+ const spine = (left + right) / 2;
3057
+ const reach = Math.max(...sides.flatMap(entry => entry.points.map(size)), 1);
3058
+ const span = (value: number) => (Math.abs(value) / reach) * (right - spine);
3059
+ const { row, bar: barHeight } = barMetrics(categories.length, 1);
3060
+ const rowHeight = labelRow(spec, row);
3061
+ const top = 18;
3062
+ const tops = rowTops(spec, top, rowHeight);
3063
+ const bottom = tops.at(-1)!;
3064
+ const estimates = sides.map(estimatedPoints);
3065
+
3066
+ const parts: string[] = [];
3067
+ for (const at of highlighted(spec)) parts.push(band(PAD - 4, tops[at]!, WIDTH - 2 * PAD + 8, rowHeight));
3068
+ parts.push(categoryLabels(spec, tops, rowHeight, strongCategories(spec)));
3069
+ parts.push(text(spine - 8, 6, clip(loss.name, 24), { size: TICK_SIZE, fill: "muted", anchor: "end" }));
3070
+ parts.push(text(spine + 8, 6, clip(gain.name, 24), { size: TICK_SIZE, fill: "muted" }));
3071
+ parts.push(text(WIDTH - PAD, 6, "net", { size: TICK_SIZE, fill: "muted", anchor: "end" }));
3072
+ const emphasized = sides.some(entry => entry.points.some(point => point?.emphasis));
3073
+ categories.forEach((_, at) => {
3074
+ const middle = tops[at]! + rowHeight / 2;
3075
+ const y = middle - barHeight / 2;
3076
+ sides.forEach((entry, index) => {
3077
+ const point = entry.points[at];
3078
+ if (!point) return;
3079
+ const length = Math.max(MIN_BAR, span(point.value));
3080
+ const opacity = emphasized && !point.emphasis ? RECEDE : 1;
3081
+ const strong = point.emphasis ? 600 : undefined;
3082
+ const estimate = estimates[index]!.has(at);
3083
+ if (index === 0) {
3084
+ parts.push(barRect(spine - length, y, length, barHeight, "error", opacity, { estimate }));
3085
+ parts.push(
3086
+ text(spine - length - 6, middle, figures[0]![at]!, {
3087
+ size: TICK_SIZE,
3088
+ fill: "muted",
3089
+ anchor: "end",
3090
+ weight: strong,
3091
+ }),
3092
+ );
3093
+ } else {
3094
+ parts.push(barRect(spine, y, length, barHeight, "success", opacity, { estimate }));
3095
+ parts.push(
3096
+ text(spine + length + 6, middle, figures[1]![at]!, { size: TICK_SIZE, fill: "muted", weight: strong }),
3097
+ );
3098
+ }
3099
+ });
3100
+ const net = nets[at]!;
3101
+ parts.push(
3102
+ text(WIDTH - PAD, middle, netText(net), {
3103
+ size: TICK_SIZE,
3104
+ fill: net > 0 ? "success" : net < 0 ? "error" : "muted",
3105
+ anchor: "end",
3106
+ weight: 600,
3107
+ }),
3108
+ );
3109
+ });
3110
+ parts.push(line(spine, top - 4, spine, bottom + 4, "fg", 1.25, 0.6));
3111
+ return { height: bottom + 8, body: parts.join("") };
3112
+ }
3113
+
3114
+ // ── Two conditions, metric by metric ────────────────────────────────────────
3115
+
3116
+ /** Widest a stat tile grows, so one or two metrics stay a compact card rather than a banner. */
3117
+ const MAX_TILE = 300;
3118
+ /** Space between stat tiles, a hairline divider in its middle. */
3119
+ const TILE_GAP = 24;
3120
+ /** Size of a stat tile's factor. */
3121
+ const FACTOR_SIZE = 24;
3122
+
3123
+ /**
3124
+ * Two conditions compared metric by metric (`stat`): a tile per series, each
3125
+ * naming its metric over the factor from the baseline condition
3126
+ * ({@link ChartSpec.reference}) to the other in large figures — toned when the
3127
+ * metric improves or regresses — over one short bar per condition on the
3128
+ * tile's own scale, the baseline muted and the other in the lead color, each
3129
+ * with its written figure. The header keys the two conditions.
3130
+ */
3131
+ function drawStat(spec: ChartSpec): Layer {
3132
+ const { series, categories } = spec;
3133
+ const base = spec.reference?.[0] ?? 0;
3134
+ const other = 1 - base;
3135
+ const tile = Math.min(MAX_TILE, (WIDTH - 2 * PAD - TILE_GAP * (series.length - 1)) / series.length);
3136
+ const parts: string[] = [];
3137
+ const barTop = 50;
3138
+ const barHeight = 10;
3139
+ const barStep = 18;
3140
+ const height = barTop + barStep + barHeight;
3141
+ series.forEach((entry, index) => {
3142
+ const left = PAD + index * (tile + TILE_GAP);
3143
+ if (index > 0) parts.push(rule(left - TILE_GAP / 2, 0, left - TILE_GAP / 2, height));
3144
+ parts.push(text(left, 7, fitText(entry.name, tile, TICK_SIZE), { size: TICK_SIZE, fill: "muted" }));
3145
+ const [from, to] = [entry.points[base], entry.points[other]];
3146
+ if (!from || !to) return;
3147
+ const move = from.value > 0 ? { value: to.value / from.value, text: "", emphasis: false } : undefined;
3148
+ const words = factorText(move);
3149
+ const size = Math.min(
3150
+ FACTOR_SIZE,
3151
+ (FACTOR_SIZE * tile) / Math.max(1, measure(words, FACTOR_SIZE) * SEMIBOLD_WIDTH),
3152
+ );
3153
+ const tone = move ? moveTone(entry.polarity, move.value) : undefined;
3154
+ parts.push(text(left, 30, words, { size, fill: tone ?? "fg", weight: 600 }));
3155
+ // Figures show whole (`2,426 / 2,368 ms`) up to half the tile; the bars keep the rest.
3156
+ const figures = [base, other].map(at => fitText(pointText(entry, at), tile / 2, TICK_SIZE * SEMIBOLD_WIDTH));
3157
+ const figureWidth = Math.max(...figures.map(figure => measure(figure, TICK_SIZE) * SEMIBOLD_WIDTH)) + 8;
3158
+ const span = Math.max(24, tile - figureWidth);
3159
+ const peak = Math.max(from.value, to.value) || 1;
3160
+ const estimated = estimatedPoints(entry);
3161
+ [from, to].forEach((point, at) => {
3162
+ const y = barTop + at * barStep;
3163
+ const length = Math.max(MIN_BAR, (Math.max(0, point.value) / peak) * span);
3164
+ parts.push(
3165
+ barRect(left, y, length, barHeight, at === 0 ? "muted" : SERIES[0], at === 0 ? 0.55 : 1, {
3166
+ estimate: estimated.has(at === 0 ? base : other),
3167
+ }),
3168
+ );
3169
+ parts.push(
3170
+ text(left + length + 6, y + barHeight / 2, figures[at]!, {
3171
+ size: TICK_SIZE,
3172
+ fill: at === 0 ? "muted" : "fg",
3173
+ weight: at === 0 ? undefined : 600,
3174
+ }),
3175
+ );
3176
+ });
3177
+ });
3178
+ return {
3179
+ height,
3180
+ body: parts.join(""),
3181
+ legend: [
3182
+ { name: categories[base] ?? "", color: "muted", opacity: 0.55 },
3183
+ { name: categories[other] ?? "", color: SERIES[0] },
3184
+ ],
3185
+ };
3186
+ }
3187
+
3188
+ // ── Share of a whole ─────────────────────────────────────────────────────────
3189
+
3190
+ /**
3191
+ * One bar split into the categories' shares, in table order, keyed below with
3192
+ * each name and figure. Percentages draw as written; counts splitting a total
3193
+ * draw as their percent of it, keyed `248 · 16%`, with the whole they make
3194
+ * named in the header (`100% = 1,543`). Outcome categories take their status
3195
+ * colors ({@link ChartSpec.tones}).
3196
+ */
3197
+ function drawShare(spec: ChartSpec): Layer {
3198
+ const entry = spec.series[0]!;
3199
+ const total = entry.points.reduce((sum, point) => sum + Math.max(0, point?.value ?? 0), 0) || 1;
3200
+ const counts = entry.dim !== "percent";
3201
+ const shares = entry.points.map(point =>
3202
+ point && counts ? percentText((100 * Math.max(0, point.value)) / total) : (point?.text ?? ""),
3203
+ );
3204
+ const parts: string[] = [];
3205
+ const barWidth = WIDTH - 2 * PAD;
3206
+ let x = PAD;
3207
+ entry.points.forEach((point, at) => {
3208
+ if (!point || point.value <= 0) return;
3209
+ const width = (point.value / total) * barWidth;
3210
+ // Segments part with a sliver of the canvas behind them.
3211
+ const opacity = partOpacity(spec, at);
3212
+ parts.push(rect(x, 0, Math.max(1, width - 2), 24, partColor(spec, at), opacity, 4));
3213
+ // A segment wide enough carries its figure inside, in ink to read on the solid color (plain text on a faint one).
3214
+ const figure = shares[at]!;
3215
+ const style = { size: TICK_SIZE, fill: "fg", anchor: "middle", weight: 600 } as const;
3216
+ if (figure && measure(figure, TICK_SIZE) * SEMIBOLD_WIDTH + 12 <= width - 2)
3217
+ parts.push((opacity >= INK_LEVEL ? ink : text)(x + (width - 2) / 2, 12, figure, style));
3218
+ x += width;
3219
+ });
3220
+ const columns = spec.categories.length > 5 ? 2 : 1;
3221
+ const columnWidth = barWidth / columns;
3222
+ const perColumn = Math.ceil(spec.categories.length / columns);
3223
+ const names = clipNames(spec.categories, Math.floor((columnWidth - 90) / (LABEL_SIZE * ADVANCE)));
3224
+ names.forEach((name, at) => {
3225
+ const column = Math.floor(at / perColumn);
3226
+ const lx = PAD + column * columnWidth;
3227
+ const ly = 42 + (at % perColumn) * 20;
3228
+ const point = entry.points[at];
3229
+ parts.push(dot(lx + 5, ly, 4.5, partColor(spec, at), partOpacity(spec, at)));
3230
+ parts.push(text(lx + 16, ly, name, { fill: "fg" }));
3231
+ parts.push(
3232
+ text(lx + columnWidth - 12, ly, point && counts ? `${point.text} · ${shares[at]}` : (point?.text ?? ""), {
3233
+ size: TICK_SIZE,
3234
+ fill: point?.emphasis ? "fg" : "muted",
3235
+ anchor: "end",
3236
+ weight: point?.emphasis ? 600 : undefined,
3237
+ }),
3238
+ );
3239
+ });
3240
+ // The whole the counts split, unless the caption already prints the table's total row.
3241
+ const notes = counts && !spec.caption ? [`100% = ${total.toLocaleString("en-US")}`] : [];
3242
+ return { height: 42 + perColumn * 20 - 6, body: parts.join(""), notes };
3243
+ }
3244
+
3245
+ // ── Line over an ordered axis ────────────────────────────────────────────────
3246
+
3247
+ /** A line's value label at its end, nudged clear of its neighbours by {@link spreadLabels}. */
3248
+ interface EndLabel {
3249
+ readonly x: number;
3250
+ y: number;
3251
+ readonly text: string;
3252
+ readonly color: string;
3253
+ /** The line's name, set before its value when lines are labelled directly instead of through a legend. */
3254
+ readonly name?: string;
3255
+ }
3256
+
3257
+ /**
3258
+ * Lines across the categories (or the numeric x), smoothed without
3259
+ * overshooting a sample. Series whose peaks differ {@link SPLIT_SPREAD}× or
3260
+ * more stack in panels on their own axes, so one large series never flattens
3261
+ * the rest; the panels share the x labels under the last. A lone line on an
3262
+ * axis from zero draws over a soft area fill, and every line ends in its value — and its name,
3263
+ * when there are several and their names are short, instead of a legend.
3264
+ */
3265
+ function drawLine(spec: ChartSpec): Layer {
3266
+ const { categories, series, x } = spec;
3267
+ const panels = magnitudePanels(series);
3268
+ const values = panels.map(panel =>
3269
+ panel.flatMap(index => series[index]!.points.flatMap(point => (point ? [point.value] : []))),
3270
+ );
3271
+ const tickWidth = Math.max(
3272
+ ...values.flatMap(panelValues => {
3273
+ const scale = valueScale(panelValues, 0, 1, { zero: false });
3274
+ const format = tickFormat(scale, series[0]!);
3275
+ return scale.ticks.map(tick => measure(format(tick), TICK_SIZE));
3276
+ }),
3277
+ );
3278
+ const position = (at: number) => (x ? x.values[at]! : at);
3279
+ const order = categories.map((_, at) => at).sort((a, b) => position(a) - position(b));
3280
+ // The rightmost sample of each line carries its value label.
3281
+ const lasts = series.map(entry => order.findLast(at => entry.points[at]) ?? -1);
3282
+ const direct =
3283
+ series.length > 1 && series.every(entry => measure(entry.name, TICK_SIZE) * SEMIBOLD_WIDTH <= MAX_DIRECT_LABEL);
3284
+ const endWidth = Math.max(
3285
+ 0,
3286
+ ...lasts.map((last, index) => {
3287
+ const name = direct ? measure(series[index]!.name, TICK_SIZE) * SEMIBOLD_WIDTH + 6 : 0;
3288
+ return name + measure(series[index]!.points[last]?.text ?? "", TICK_SIZE);
3289
+ }),
3290
+ );
3291
+ const left = PAD + tickWidth + 8;
3292
+ const right = WIDTH - PAD - Math.min(direct ? 230 : 120, endWidth + 10);
3293
+ const xScale = x
3294
+ ? valueScale(x.values, left, right, { zero: false })
3295
+ : {
3296
+ at: (index: number) =>
3297
+ left + (categories.length === 1 ? 0 : (index / (categories.length - 1)) * (right - left)),
3298
+ };
3299
+ const xAt = (at: number) => xScale.at(position(at));
3300
+ const plotHeight = panels.length === 1 ? 180 : Math.max(72, Math.round(200 / panels.length));
3301
+ const dotted = categories.length <= MAX_DOTS;
3302
+
3303
+ const parts: string[] = [];
3304
+ const references = (spec.annotations ?? []).filter(
3305
+ (mark): mark is Extract<ChartAnnotation, { kind: "reference" }> => mark.kind === "reference",
3306
+ );
3307
+ const callouts = (spec.annotations ?? []).filter(
3308
+ (mark): mark is Extract<ChartAnnotation, { kind: "callout" }> => mark.kind === "callout",
3309
+ );
3310
+ let top = 0;
3311
+ let plotBottom = 0;
3312
+ panels.forEach((panel, panelIndex) => {
3313
+ // Lines without direct labels need a legend in each of several panels (one panel's goes in the header).
3314
+ if (series.length > 1 && !direct && panels.length > 1) {
3315
+ const legend = drawLegend(
3316
+ panel.map(index => ({ name: series[index]!.name, color: SERIES[index % SERIES.length]! })),
3317
+ PAD,
3318
+ WIDTH - PAD,
3319
+ 6,
3320
+ );
3321
+ parts.push(`<g transform="translate(0 ${num(top)})">${legend.body}</g>`);
3322
+ top += legend.height + 4;
3323
+ }
3324
+ // A callout over a line of this panel sets its text above the plot, clear of the panel's legend.
3325
+ const room = callouts.some(mark => panel.includes(mark.series ?? 0)) ? 24 : 0;
3326
+ const plotTop = top + 6 + room;
3327
+ plotBottom = plotTop + plotHeight;
3328
+ const scale = valueScale(values[panelIndex]!, plotBottom, plotTop, { zero: false });
3329
+ const format = tickFormat(scale, series[0]!);
3330
+ // Short panels label every other gridline.
3331
+ const labelEvery = plotHeight / Math.max(1, scale.ticks.length - 1) < 20 ? 2 : 1;
3332
+ scale.ticks.forEach((tick, at) => {
3333
+ const y = scale.at(tick);
3334
+ parts.push(rule(left, y, right, y));
3335
+ if (at % labelEvery === 0)
3336
+ parts.push(text(left - 8, y, format(tick), { size: TICK_SIZE, fill: "muted", anchor: "end" }));
3337
+ });
3338
+ const ends: EndLabel[] = [];
3339
+ for (const index of panel) {
3340
+ const entry = series[index]!;
3341
+ const color = SERIES[index % SERIES.length]!;
3342
+ // Runs of consecutive samples; a gap in the table breaks the line.
3343
+ const runs: [number, number][][] = [[]];
3344
+ for (const at of order) {
3345
+ const point = entry.points[at];
3346
+ if (point) runs.at(-1)!.push([xAt(at), scale.at(point.value)]);
3347
+ else if (runs.at(-1)!.length > 0) runs.push([]);
3348
+ }
3349
+ // The area reads as an amount, so it shades only down to a zero baseline, never to a cut-off axis.
3350
+ if (panel.length === 1 && scale.ticks[0] === 0) {
3351
+ // The fade's id names its color, so two charts inlined in one page agree on it.
3352
+ parts.push(
3353
+ `<defs><linearGradient id="fade-${color}" x1="0" y1="0" x2="0" y2="1">`,
3354
+ `<stop offset="0" stop-color="${token(color)}" stop-opacity="0.26"/>`,
3355
+ `<stop offset="1" stop-color="${token(color)}" stop-opacity="0"/>`,
3356
+ "</linearGradient></defs>",
3357
+ );
3358
+ for (const run of runs) {
3359
+ if (run.length < 2) continue;
3360
+ const floor = `L${num(run.at(-1)![0])} ${num(plotBottom)}L${num(run[0]![0])} ${num(plotBottom)}Z`;
3361
+ parts.push(`<path d="${smoothPath(run)}${floor}" fill="url(#fade-${color})"/>`);
3362
+ }
3363
+ }
3364
+ for (const run of runs) {
3365
+ if (run.length < 2) continue;
3366
+ parts.push(
3367
+ `<path d="${smoothPath(run)}" fill="none" stroke="${token(color)}" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/>`,
3368
+ );
3369
+ }
3370
+ const last = lasts[index]!;
3371
+ entry.points.forEach((point, at) => {
3372
+ if (!point || !(dotted || point.emphasis || at === last)) return;
3373
+ parts.push(dot(xAt(at), scale.at(point.value), point.emphasis ? 4.5 : at === last ? 3.5 : 2.5, color));
3374
+ });
3375
+ const end = entry.points[last];
3376
+ if (end)
3377
+ ends.push({
3378
+ x: xAt(last) + 8,
3379
+ y: scale.at(end.value),
3380
+ text: end.text,
3381
+ color,
3382
+ name: direct ? entry.name : undefined,
3383
+ });
3384
+ for (const mark of callouts) {
3385
+ const point = (mark.series ?? 0) === index ? entry.points[mark.category] : undefined;
3386
+ if (!point) continue;
3387
+ const label = clip(mark.text, MAX_CALLOUT_CHARS);
3388
+ const half = (measure(label, TICK_SIZE) * SEMIBOLD_WIDTH) / 2;
3389
+ const px = xAt(mark.category);
3390
+ const py = scale.at(point.value);
3391
+ parts.push(line(px, py - 6, px, py - 16, "muted", 1));
3392
+ parts.push(
3393
+ text(Math.min(Math.max(px, left + half), right - half), py - 23, label, {
3394
+ size: TICK_SIZE,
3395
+ fill: "fg",
3396
+ anchor: "middle",
3397
+ weight: 600,
3398
+ }),
3399
+ );
3400
+ }
3401
+ }
3402
+ for (const mark of panelIndex === 0 ? references : []) {
3403
+ const y = scale.at(mark.value);
3404
+ if (y < plotTop - 0.5 || y > plotBottom + 0.5) continue;
3405
+ parts.push(referenceRule(left, y, right, y));
3406
+ parts.push(text(right, y - 7, clip(mark.label, MAX_CALLOUT_CHARS), { size: 10, fill: "fg", anchor: "end" }));
3407
+ }
3408
+ parts.push(spreadLabels(ends, plotTop - 4, plotBottom + 4));
3409
+ top = plotBottom + 22;
3410
+ });
3411
+
3412
+ // Category labels left to right, skipping any that would touch the last one drawn.
3413
+ // A label too long for its slot wraps to a second line before it clips.
3414
+ const slot = (right - left) / Math.max(1, categories.length - 1);
3415
+ const labels = categories.map(name => wrapHeader(name, Math.max(slot, 60) - 6, TICK_SIZE));
3416
+ let drawnEnd = Number.NEGATIVE_INFINITY;
3417
+ let wrapped = false;
3418
+ for (const at of order) {
3419
+ const lines = labels[at]!;
3420
+ const width = Math.max(...lines.map(label => measure(label, TICK_SIZE)));
3421
+ const center = Math.min(Math.max(xAt(at), left + width / 2), WIDTH - PAD - width / 2);
3422
+ if (center - width / 2 < drawnEnd + 8) continue;
3423
+ lines.forEach((label, line) =>
3424
+ parts.push(
3425
+ text(center, plotBottom + 14 + line * 12, label, { size: TICK_SIZE, fill: "muted", anchor: "middle" }),
3426
+ ),
3427
+ );
3428
+ wrapped ||= lines.length > 1;
3429
+ drawnEnd = center + width / 2;
3430
+ }
3431
+ return {
3432
+ height: plotBottom + (wrapped ? 36 : 24),
3433
+ body: parts.join(""),
3434
+ legend:
3435
+ series.length > 1 && !direct && panels.length === 1
3436
+ ? series.map((entry, at) => ({ name: entry.name, color: SERIES[at % SERIES.length]! }))
3437
+ : [],
3438
+ notes: panels.length > 1 ? ["own axis per panel"] : [],
3439
+ };
3440
+ }
3441
+
3442
+ /**
3443
+ * Series indices grouped into panels whose peaks stay within
3444
+ * {@link SPLIT_SPREAD}× of the panel's largest, panels and their series in
3445
+ * table order. An all-zero series joins the smallest panel.
3446
+ */
3447
+ function magnitudePanels(series: readonly ChartSeries[]): number[][] {
3448
+ const peaks = series.map(entry => Math.max(0, ...entry.points.map(point => (point ? Math.abs(point.value) : 0))));
3449
+ const panels: number[][] = [];
3450
+ let panelPeak = 0;
3451
+ for (const index of series.map((_, at) => at).sort((a, b) => peaks[b]! - peaks[a]!)) {
3452
+ const peak = peaks[index]!;
3453
+ if (panels.length > 0 && (peak === 0 || panelPeak < peak * SPLIT_SPREAD)) {
3454
+ panels.at(-1)!.push(index);
3455
+ } else {
3456
+ panels.push([index]);
3457
+ panelPeak = peak;
3458
+ }
3459
+ }
3460
+ for (const panel of panels) panel.sort((a, b) => a - b);
3461
+ return panels.sort((a, b) => a[0]! - b[0]!);
3462
+ }
3463
+
3464
+ /**
3465
+ * A path through `points` (x ascending) as a monotone cubic (Steffen's
3466
+ * method): smooth, yet never overshooting a sample the way a Catmull-Rom
3467
+ * curve would between a peak and a flat stretch.
3468
+ */
3469
+ function smoothPath(points: readonly (readonly [number, number])[]): string {
3470
+ const n = points.length;
3471
+ let path = `M${num(points[0]![0])} ${num(points[0]![1])}`;
3472
+ if (n < 3) return n === 2 ? `${path}L${num(points[1]![0])} ${num(points[1]![1])}` : path;
3473
+ const secants: number[] = [];
3474
+ for (let at = 0; at < n - 1; at++) {
3475
+ const h = points[at + 1]![0] - points[at]![0];
3476
+ secants.push(h === 0 ? 0 : (points[at + 1]![1] - points[at]![1]) / h);
3477
+ }
3478
+ const tangents: number[] = [0];
3479
+ for (let at = 1; at < n - 1; at++) {
3480
+ const h0 = points[at]![0] - points[at - 1]![0];
3481
+ const h1 = points[at + 1]![0] - points[at]![0];
3482
+ const [s0, s1] = [secants[at - 1]!, secants[at]!];
3483
+ const weighted = (s0 * h1 + s1 * h0) / (h0 + h1 || 1);
3484
+ tangents.push((Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(weighted)));
3485
+ }
3486
+ tangents[0] = (3 * secants[0]! - tangents[1]!) / 2;
3487
+ tangents.push((3 * secants[n - 2]! - tangents[n - 2]!) / 2);
3488
+ for (let at = 0; at < n - 1; at++) {
3489
+ const [x0, y0] = points[at]!;
3490
+ const [x1, y1] = points[at + 1]!;
3491
+ const third = (x1 - x0) / 3;
3492
+ path += `C${num(x0 + third)} ${num(y0 + third * tangents[at]!)} ${num(x1 - third)} ${num(y1 - third * tangents[at + 1]!)} ${num(x1)} ${num(y1)}`;
3493
+ }
3494
+ return path;
3495
+ }
3496
+
3497
+ /** `items` sorted by `y` and nudged at least `gap` apart, kept between `top` and `bottom` where they fit. */
3498
+ function spreadApart<T extends { y: number }>(items: T[], top: number, bottom: number, gap: number): T[] {
3499
+ items.sort((a, b) => a.y - b.y);
3500
+ for (let at = 1; at < items.length; at++) items[at]!.y = Math.max(items[at]!.y, items[at - 1]!.y + gap);
3501
+ for (let at = items.length - 1; at >= 0; at--) {
3502
+ const limit = at === items.length - 1 ? bottom : items[at + 1]!.y - gap;
3503
+ items[at]!.y = Math.max(top, Math.min(items[at]!.y, limit));
3504
+ }
3505
+ return items;
3506
+ }
3507
+
3508
+ /** End labels in their lines' colors, nudged apart so none overlap, kept between `top` and `bottom` where they fit. */
3509
+ function spreadLabels(labels: EndLabel[], top: number, bottom: number): string {
3510
+ return spreadApart(labels, top, bottom, TICK_SIZE + 2)
3511
+ .map(label => {
3512
+ if (!label.name)
3513
+ return text(label.x, label.y, label.text, { size: TICK_SIZE, fill: label.color, weight: 600 });
3514
+ const name = text(label.x, label.y, label.name, { size: TICK_SIZE, fill: label.color, weight: 600 });
3515
+ const offset = measure(label.name, TICK_SIZE) * SEMIBOLD_WIDTH + 6;
3516
+ return name + text(label.x + offset, label.y, label.text, { size: TICK_SIZE, fill: "muted" });
3517
+ })
3518
+ .join("");
3519
+ }
3520
+
3521
+ // ── Scatter ──────────────────────────────────────────────────────────────────
3522
+
3523
+ function drawScatter(spec: ChartSpec): Layer {
3524
+ const entry = spec.series[0]!;
3525
+ const xs = spec.x!;
3526
+ const ys = entry.points.map(point => point?.value ?? Number.NaN);
3527
+ const finite = (values: readonly number[]) => values.filter(Number.isFinite);
3528
+ const yAxis = valueScale(finite(ys), 0, 1, { zero: false });
3529
+ const yFormat = tickFormat(yAxis, entry);
3530
+ const left = PAD + Math.max(...yAxis.ticks.map(tick => measure(yFormat(tick), TICK_SIZE))) + 8;
3531
+ const right = WIDTH - PAD - 24;
3532
+ const top = 18;
3533
+ const bottom = top + 200;
3534
+ // Ends inset from the plot's edges, so a dot on an outer tick stays clear of the axis labels.
3535
+ const xScale = valueScale(finite(xs.values), left + 8, right - 8, { zero: false });
3536
+ const yScale = valueScale(finite(ys), bottom - 6, top + 6, { zero: false });
3537
+ // Axis titles in the text color: they name what the plot means, unlike the muted tick figures.
3538
+ const parts: string[] = [text(left, 6, entry.name, { size: TICK_SIZE, fill: "fg" })];
3539
+ for (const tick of yScale.ticks) {
3540
+ parts.push(rule(left, yScale.at(tick), right, yScale.at(tick)));
3541
+ parts.push(
3542
+ text(left - 8, yScale.at(tick), yFormat(tick), {
3543
+ size: TICK_SIZE,
3544
+ fill: "muted",
3545
+ anchor: "end",
3546
+ }),
3547
+ );
3548
+ }
3549
+ const xFormat = tickFormat(xScale, xs);
3550
+ for (const tick of xScale.ticks) {
3551
+ parts.push(
3552
+ text(xScale.at(tick), bottom + 14, xFormat(tick), {
3553
+ size: TICK_SIZE,
3554
+ fill: "muted",
3555
+ anchor: "middle",
3556
+ }),
3557
+ );
3558
+ }
3559
+ parts.push(text(right, bottom + 30, xs.name, { size: TICK_SIZE, fill: "fg", anchor: "end" }));
3560
+ const marks = entry.points.flatMap((point, at) => {
3561
+ const xv = xs.values[at]!;
3562
+ return point && Number.isFinite(xv) ? [{ at, point, x: xScale.at(xv), y: yScale.at(point.value) }] : [];
3563
+ });
3564
+ const emphasized = marks.some(mark => mark.point.emphasis);
3565
+ // A highlighted item (the row the table is about) is ringed in the accent and named in full strength.
3566
+ const focus = new Set(highlighted(spec));
3567
+ for (const mark of marks) {
3568
+ const strong = !emphasized || mark.point.emphasis || focus.has(mark.at);
3569
+ if (focus.has(mark.at)) parts.push(ring(mark.x, mark.y, 8.5, "accent"));
3570
+ parts.push(dot(mark.x, mark.y, mark.point.emphasis ? 5.5 : 4, SERIES[0], strong ? 0.9 : RECEDE));
3571
+ }
3572
+ // Each name takes the first spot around its dot clear of every dot and name placed so far; crowded ones go unnamed.
3573
+ const taken: Box[] = marks.map(mark => ({ x0: mark.x - 5, y0: mark.y - 5, x1: mark.x + 5, y1: mark.y + 5 }));
3574
+ const lead = (mark: (typeof marks)[number]) => Number(focus.has(mark.at)) * 2 + Number(mark.point.emphasis);
3575
+ const order = marks.toSorted((a, b) => lead(b) - lead(a));
3576
+ const names = clipNames(spec.categories, 26);
3577
+ for (const mark of order.length <= 40 ? order : []) {
3578
+ const label = names[mark.at]!;
3579
+ const strong = mark.point.emphasis || focus.has(mark.at);
3580
+ const width = measure(label, TICK_SIZE) * (strong ? SEMIBOLD_WIDTH : 1);
3581
+ // A name clears its dot, and the accent ring around a highlighted one.
3582
+ const gap = focus.has(mark.at) ? 13 : 8;
3583
+ const spots: [number, number, "start" | "end" | "middle"][] = [
3584
+ [mark.x + gap, mark.y, "start"],
3585
+ [mark.x - gap, mark.y, "end"],
3586
+ [mark.x, mark.y - gap - 5, "middle"],
3587
+ [mark.x, mark.y + gap + 5, "middle"],
3588
+ ];
3589
+ for (const [x, y, anchor] of spots) {
3590
+ const x0 = anchor === "start" ? x : anchor === "end" ? x - width : x - width / 2;
3591
+ const box = { x0: x0 - 1, y0: y - 6, x1: x0 + width + 1, y1: y + 6 };
3592
+ if (box.x0 < PAD || box.x1 > WIDTH - PAD || box.y0 < 0 || box.y1 > bottom + 4) continue;
3593
+ if (taken.some(other => box.x0 < other.x1 && other.x0 < box.x1 && box.y0 < other.y1 && other.y0 < box.y1))
3594
+ continue;
3595
+ taken.push(box);
3596
+ // Names read in the text color unless bolded points single some out; then the rest recede with their dots.
3597
+ parts.push(
3598
+ text(x, y, label, {
3599
+ size: TICK_SIZE,
3600
+ fill: focus.has(mark.at) ? "accent" : strong || !emphasized ? "fg" : "muted",
3601
+ anchor,
3602
+ weight: strong ? 600 : undefined,
3603
+ }),
3604
+ );
3605
+ break;
3606
+ }
3607
+ }
3608
+ return { height: bottom + 36, body: parts.join("") };
3609
+ }
3610
+
3611
+ // ── Shared pieces ────────────────────────────────────────────────────────────
3612
+
3613
+ /**
3614
+ * A value axis over `values` from `from` to `to`: logarithmic when every
3615
+ * value is positive and they span {@link LOG_SPREAD}× or more, else linear on
3616
+ * nice ticks, including zero when `zero` asks for a baseline.
3617
+ */
3618
+ function valueScale(
3619
+ values: readonly number[],
3620
+ from: number,
3621
+ to: number,
3622
+ options: {
3623
+ zero: boolean;
3624
+ breakable?: boolean;
3625
+ /** One tick unit in base units ({@link tickUnit}): a linear axis ticks on nice multiples of it. */
3626
+ unit?: number;
3627
+ },
3628
+ ): Scale {
3629
+ let min = Math.min(...values);
3630
+ let max = Math.max(...values);
3631
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
3632
+ min = 0;
3633
+ max = 1;
3634
+ }
3635
+ const tier = options.breakable ? outlierTier(values) : undefined;
3636
+ if (tier) return brokenScale(tier, from, to, true);
3637
+ if (logSpread(values)) {
3638
+ const low = Math.floor(Math.log10(min));
3639
+ const high = Math.ceil(Math.log10(max));
3640
+ const ticks: number[] = [];
3641
+ const stride = Math.max(1, Math.ceil((high - low) / 6));
3642
+ for (let power = low; power <= high; power += stride) ticks.push(10 ** power);
3643
+ const span = high - low || 1;
3644
+ return {
3645
+ at: value => from + ((Math.log10(Math.max(value, 10 ** low)) - low) / span) * (to - from),
3646
+ ticks,
3647
+ log: true,
3648
+ };
3649
+ }
3650
+ if (options.zero) {
3651
+ min = Math.min(0, min);
3652
+ max = Math.max(0, max);
3653
+ } else if (min > 0 && min < max * 0.35) {
3654
+ min = 0;
3655
+ }
3656
+ if (min === max) {
3657
+ max = min === 0 ? 1 : min + Math.abs(min) * 0.5;
3658
+ if (!options.zero) min -= Math.abs(min) * 0.5;
3659
+ }
3660
+ const unit = options.unit ?? 1;
3661
+ const nice = linearTicks(
3662
+ min / unit,
3663
+ max / unit,
3664
+ values.every(value => Number.isInteger(value / unit)),
3665
+ );
3666
+ const [low, high, step] = [nice.low * unit, nice.high * unit, nice.step * unit];
3667
+ const ticks: number[] = [];
3668
+ for (let tick = low; tick <= high + step / 2; tick += step) ticks.push(Number(tick.toPrecision(12)));
3669
+ return { at: value => from + ((value - low) / (high - low || 1)) * (to - from), ticks, log: false };
3670
+ }
3671
+
3672
+ /** Whether `values` are all positive and span {@link LOG_SPREAD}× or more: a value axis over them is logarithmic. */
3673
+ function logSpread(values: readonly number[]): boolean {
3674
+ const min = Math.min(...values);
3675
+ return min > 0 && Math.max(...values) / min >= LOG_SPREAD;
3676
+ }
3677
+
3678
+ /**
3679
+ * Nice tick spacing for a linear axis over `min`…`max`: of the
3680
+ * {@link niceStep}s giving three to six intervals, the one whose outer ticks
3681
+ * waste the least length past the values (`0–120`, not `0–150`, for 113),
3682
+ * four or five intervals preferred; whole steps only for `integral` values
3683
+ * (counts tick `0, 2, 4`, never `2.5`).
3684
+ */
3685
+ function linearTicks(min: number, max: number, integral: boolean): { low: number; high: number; step: number } {
3686
+ let best: { low: number; high: number; step: number; cost: number } | undefined;
3687
+ for (const intervals of [4, 5, 3, 6]) {
3688
+ const step = niceStep((max - min) / intervals);
3689
+ if (integral && !Number.isInteger(step)) continue;
3690
+ const low = Math.floor(min / step) * step;
3691
+ const high = Math.ceil(max / step) * step;
3692
+ const count = Math.round((high - low) / step);
3693
+ if (count < 2 || count > 7) continue;
3694
+ const waste = (high - low - (max - min)) / (high - low || 1);
3695
+ // Steps of 2.5 tick at `7.5`: a last resort beside the rounder 1, 2 and 5.
3696
+ const quarter = Math.abs(step / 10 ** Math.floor(Math.log10(step)) - 2.5) < 1e-9;
3697
+ const cost = waste + 0.03 * Math.max(0, Math.abs(count - 4.5) - 0.5) + (quarter ? 0.08 : 0);
3698
+ if (!best || cost < best.cost - 1e-9) best = { low, high, step, cost };
3699
+ }
3700
+ if (best) return best;
3701
+ const step = integral ? Math.max(1, Math.ceil(niceStep((max - min) / 4))) : niceStep((max - min) / 4);
3702
+ return { low: Math.floor(min / step) * step, high: Math.ceil(max / step) * step, step };
3703
+ }
3704
+
3705
+ /**
3706
+ * A positional axis for dots and intervals, which mark where values lie rather
3707
+ * than how far they reach from zero: logarithmic once positive values span
3708
+ * {@link SPAN_LOG}×, on ticks at 1, 2 and 5 times powers of ten while it spans
3709
+ * at most {@link FINE_DECADES} decades (powers of ten past that), its ends on
3710
+ * the ticks just outside the values; else {@link valueScale}'s linear axis.
3711
+ * Durations from a second up tick on the clock ({@link CLOCK_TICKS}: `1m`,
3712
+ * `5m`, `1h`) rather than at `16.7m`. The axis reaches `marks` too (reference
3713
+ * rules), which never make it logarithmic.
3714
+ */
3715
+ function spanScale(
3716
+ values: readonly number[],
3717
+ from: number,
3718
+ to: number,
3719
+ marks: readonly number[] = [],
3720
+ dim?: Dimension,
3721
+ unit = 1,
3722
+ ): Scale {
3723
+ const reach = [...values, ...marks];
3724
+ const [min, max] = [Math.min(...reach), Math.max(...reach)];
3725
+ if (!(Math.min(...values) > 0) || !(min > 0) || Math.max(...values) / Math.min(...values) < SPAN_LOG)
3726
+ return valueScale(reach, from, to, { zero: false, unit });
3727
+ const fine = Math.log10(max / min) <= FINE_DECADES;
3728
+ const steps = fine ? [1, 2, 5] : [1];
3729
+ const grid: number[] = [];
3730
+ for (let power = Math.floor(Math.log10(min)); power <= Math.ceil(Math.log10(max)); power++)
3731
+ for (const step of steps) {
3732
+ const tick = Number((step * 10 ** power).toPrecision(6));
3733
+ if (dim !== "duration" || tick < 1 || tick > CLOCK_TICKS.at(-1)!) grid.push(tick);
3734
+ }
3735
+ if (dim === "duration") grid.push(...(fine ? CLOCK_TICKS : CLOCK_TICKS.filter((_, at) => at % 3 === 0)));
3736
+ grid.sort((a, b) => a - b);
3737
+ const low = Math.max(...grid.filter(tick => tick <= min * (1 + 1e-9)));
3738
+ const high = Math.min(...grid.filter(tick => tick >= max * (1 - 1e-9)));
3739
+ const all = grid.filter(tick => tick >= low && tick <= high);
3740
+ // Past six decades every other power keeps the labels apart.
3741
+ const stride = Math.max(1, Math.ceil(all.length / 7));
3742
+ const ticks = all.filter((_, at) => at % stride === 0 || at === all.length - 1);
3743
+ const [a, b] = [Math.log10(low), Math.log10(high)];
3744
+ return {
3745
+ at: value => from + ((Math.log10(Math.max(value, low)) - a) / (b - a || 1)) * (to - from),
3746
+ ticks,
3747
+ log: true,
3748
+ };
3749
+ }
3750
+
3751
+ /**
3752
+ * The few values (at most {@link MAX_TIER_SHARE} of them) standing
3753
+ * {@link TIER_GAP}× or more above the largest of the rest, at the widest such
3754
+ * jump — when the rest still read on a linear axis (spread under
3755
+ * `restSpread`, {@link LOG_SPREAD} by default), so breaking the axis beats a
3756
+ * logarithmic one; signed bars, never logarithmic, pass no limit.
3757
+ */
3758
+ function outlierTier(values: readonly number[], restSpread = LOG_SPREAD): Tier | undefined {
3759
+ if (values.some(value => value < 0)) return undefined;
3760
+ const sorted = values.filter(value => value > 0).sort((a, b) => b - a);
3761
+ const most = Math.floor(values.length * MAX_TIER_SHARE);
3762
+ let best: Tier | undefined;
3763
+ let gap = TIER_GAP;
3764
+ for (let k = 1; k <= most && k + MIN_BELOW_TIER <= sorted.length; k++) {
3765
+ const ratio = sorted[k - 1]! / sorted[k]!;
3766
+ if (ratio >= gap) {
3767
+ gap = ratio;
3768
+ best = { rest: sorted[k]!, low: sorted[k - 1]!, high: sorted[0]! };
3769
+ }
3770
+ }
3771
+ if (!best || best.rest / sorted.at(-1)! >= restSpread) return undefined;
3772
+ return best;
3773
+ }
3774
+
3775
+ /**
3776
+ * A linear axis from zero over the rest of `tier` up to {@link CUT_AT} of its
3777
+ * length, then a gap, then a second linear segment zoomed on the tier, so the
3778
+ * outliers neither flatten the rest nor lose their own differences. `nice`
3779
+ * rounds the segments' ends out to tick values; without, the largest value of
3780
+ * each segment reaches its end (bars in a panel without an axis).
3781
+ */
3782
+ function brokenScale(tier: Tier, from: number, to: number, nice: boolean): Scale {
3783
+ const middle = from + (to - from) * CUT_AT;
3784
+ const start = middle - (Math.sign(to - from) * CUT_GAP) / 2;
3785
+ const end = middle + (Math.sign(to - from) * CUT_GAP) / 2;
3786
+ const step = niceStep(tier.rest / 4);
3787
+ const top = nice ? Math.ceil(tier.rest / step) * step : tier.rest;
3788
+ const spread = tier.high - tier.low;
3789
+ const zoom = niceStep(Math.max(spread, tier.high * 0.1) / 2);
3790
+ // Zoomed no further than half the tier's peak, so an 8% difference never reads as 80%.
3791
+ const floor = Math.min(tier.low - Math.max(spread * 0.25, tier.high * 0.05), tier.high / 2);
3792
+ const low = Math.max(top, nice ? Math.floor(floor / zoom) * zoom : floor);
3793
+ const high = nice ? Math.ceil(tier.high / zoom) * zoom : tier.high;
3794
+ const ticks: number[] = [];
3795
+ for (let tick = 0; tick <= top + step / 2; tick += step) ticks.push(Number(tick.toPrecision(12)));
3796
+ ticks.push(high);
3797
+ return {
3798
+ at: value =>
3799
+ value <= top
3800
+ ? from + (Math.max(0, value) / top) * (start - from)
3801
+ : end + ((Math.max(value, low) - low) / (high - low || 1)) * (to - end),
3802
+ ticks,
3803
+ log: false,
3804
+ cut: { below: top, start, end },
3805
+ };
3806
+ }
3807
+
3808
+ /**
3809
+ * Bar ends from `from` to `to` for a panel without an axis: broken over an
3810
+ * outlier tier ({@link outlierTier}), else linear from zero (from the
3811
+ * smallest value when one is negative). No axis would tell a reader a bar is
3812
+ * logarithmic, so lengths stay proportional.
3813
+ */
3814
+ function panelScale(values: readonly number[], from: number, to: number): Scale {
3815
+ const tier = outlierTier(values);
3816
+ if (tier) return brokenScale(tier, from, to, false);
3817
+ const max = Math.max(...values);
3818
+ const min = Math.min(0, ...values);
3819
+ const span = Number.isFinite(max) && max > min ? max - min : 0;
3820
+ return { at: value => from + (span ? (value - min) / span : 1) * (to - from), ticks: [], log: false };
3821
+ }
3822
+
3823
+ /**
3824
+ * Fill strength 0–1 of heat cells: from zero, logarithmic over the positive
3825
+ * values once they span {@link LOG_SPREAD}× or hold an outlier tier — a zero
3826
+ * cell (no reasoning tokens) neither blocks that nor takes a shade — min–max
3827
+ * when values go negative.
3828
+ */
3829
+ function shade(values: readonly number[]): (value: number) => number {
3830
+ const lowest = Math.min(...values);
3831
+ if (lowest < 0) {
3832
+ const span = Math.max(...values) - lowest;
3833
+ return value => (span ? (value - lowest) / span : 1);
3834
+ }
3835
+ const positive = values.filter(value => value > 0);
3836
+ const max = Math.max(...positive);
3837
+ const min = Math.min(...positive);
3838
+ if (positive.length < 2 || max === min) return value => (value > 0 ? 1 : 0);
3839
+ if (logShaded(values)) {
3840
+ const low = Math.log10(min);
3841
+ const span = Math.log10(max) - low;
3842
+ return value => (value > 0 ? 0.1 + (0.9 * (Math.log10(value) - low)) / span : 0);
3843
+ }
3844
+ const floor = heatFloor(values);
3845
+ return value => Math.max(0, value - floor) / (max - floor);
3846
+ }
3847
+
3848
+ /**
3849
+ * Where a linear heat ramp starts: zero, or for values all in a narrow band
3850
+ * well above it (scores of 1.8–2.9) a third of the band's width below its
3851
+ * least, so the cells spread over the ramp instead of all reading strong.
3852
+ */
3853
+ function heatFloor(values: readonly number[]): number {
3854
+ const [min, max] = [Math.min(...values), Math.max(...values)];
3855
+ if (!(min > 0 && min >= max * 0.5 && max > min)) return 0;
3856
+ // Rounded down to a nice figure, which the ramp's key prints.
3857
+ const step = niceStep((max - min) / 4);
3858
+ return Math.max(0, Math.floor((min - (max - min) / 3) / step) * step);
3859
+ }
3860
+
3861
+ /** Whether {@link shade} maps `values` logarithmically: non-negative, positive ones spanning decades or an outlier tier. */
3862
+ function logShaded(values: readonly number[]): boolean {
3863
+ const positive = values.filter(value => value > 0);
3864
+ if (positive.length < 2 || values.some(value => value < 0)) return false;
3865
+ return Math.max(...positive) / Math.min(...positive) >= LOG_SPREAD || outlierTier(values) !== undefined;
3866
+ }
3867
+
3868
+ /** 1, 2, 2.5 or 5 times a power of ten, at least `raw`. */
3869
+ function niceStep(raw: number): number {
3870
+ const power = 10 ** Math.floor(Math.log10(raw || 1));
3871
+ const fraction = raw / power;
3872
+ return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10) * power;
3873
+ }
3874
+
3875
+ /** Gridlines and tick labels under a horizontal value axis; zero, where bars start, draws as a firmer baseline. */
3876
+ function drawValueAxis(scale: Scale, series: ChartSeries, top: number, bottom: number): string {
3877
+ const parts: string[] = [];
3878
+ const format = tickFormat(scale, series);
3879
+ for (const tick of scale.ticks) {
3880
+ const x = scale.at(tick);
3881
+ parts.push(tick === 0 && !scale.log ? line(x, top, x, bottom, "border", 1) : rule(x, top, x, bottom));
3882
+ // The break mark stands where the first segment's last label would.
3883
+ if (tick === scale.cut?.below) continue;
3884
+ parts.push(
3885
+ text(x, bottom + 12, format(tick), {
3886
+ size: TICK_SIZE,
3887
+ fill: "muted",
3888
+ anchor: "middle",
3889
+ }),
3890
+ );
3891
+ }
3892
+ if (scale.cut) parts.push(breakMark((scale.cut.start + scale.cut.end) / 2, bottom + 12, 5, "muted"));
3893
+ return parts.join("");
3894
+ }
3895
+
3896
+ /** A bar from `zero` to `end` crossing a broken axis: both segments, the gap between them marked. */
3897
+ function cutBar(cut: Cut, zero: number, end: number, y: number, height: number, fill: string, opacity: number): string {
3898
+ return [
3899
+ rect(zero, y, cut.start - zero, height, fill, opacity),
3900
+ rect(cut.end, y, Math.max(1, end - cut.end), height, fill, opacity),
3901
+ breakMark((cut.start + cut.end) / 2, y + height / 2, height / 2 + 1, "muted"),
3902
+ ].join("");
3903
+ }
3904
+
3905
+ /** Two slanted strokes centred on `x`, `y`: the sign of a broken axis or a bar crossing its gap. */
3906
+ function breakMark(x: number, y: number, half: number, stroke: string): string {
3907
+ const slant = half * 0.55;
3908
+ return [-2.5, 2.5]
3909
+ .map(offset => line(x + offset - slant, y + half, x + offset + slant, y - half, stroke, 1.25))
3910
+ .join("");
3911
+ }
3912
+
3913
+ /**
3914
+ * A {@link referenceRule} at each reference category's value of `entry`
3915
+ * ({@link ChartSpec.reference}) through the rows of its group (all rows
3916
+ * without groups), so the others read as shorter or longer than it; with,
3917
+ * per category, the x of its group's rule, which its figure clears.
3918
+ */
3919
+ function referenceRules(
3920
+ spec: ChartSpec,
3921
+ entry: ChartSeries,
3922
+ tops: readonly number[],
3923
+ rowHeight: number,
3924
+ at: (value: number) => number,
3925
+ ): { readonly body: string; readonly at: readonly (number | undefined)[] } {
3926
+ const parts: string[] = [];
3927
+ const xs: (number | undefined)[] = spec.categories.map(() => undefined);
3928
+ for (const index of spec.reference ?? []) {
3929
+ const point = entry.points[index];
3930
+ if (!point) continue;
3931
+ const [first, last] = groupSpan(spec, index);
3932
+ const x = at(point.value);
3933
+ xs.fill(x, first, last + 1);
3934
+ parts.push(referenceRule(x, tops[first]! + 2, x, tops[last]! + rowHeight - 2));
3935
+ }
3936
+ return { body: parts.join(""), at: xs };
3937
+ }
3938
+
3939
+ /** First and last category index of the group holding category `at`; every category without groups. */
3940
+ function groupSpan(spec: ChartSpec, at: number): [number, number] {
3941
+ let first = 0;
3942
+ for (const group of spec.groups ?? []) {
3943
+ const last = first + group.members.length - 1;
3944
+ if (at <= last) return [first, last];
3945
+ first = last + 1;
3946
+ }
3947
+ return [0, spec.categories.length - 1];
3948
+ }
3949
+
3950
+ /** Width of one legend entry: its mark (and tone dots), name, and the gap before the next. */
3951
+ function legendItemWidth(item: LegendItem): number {
3952
+ return 12 + 9 * (item.tones?.length ?? 0) + measure(clip(item.name, MAX_LEGEND_CHARS), TICK_SIZE) + 16;
3953
+ }
3954
+
3955
+ /** Width of `items` on one line, without the trailing gap. */
3956
+ function legendWidth(items: readonly LegendItem[]): number {
3957
+ return items.reduce((sum, item) => sum + legendItemWidth(item), 0) - 16;
3958
+ }
3959
+
3960
+ /** Dot-and-name legend wrapping across `left`…`right`, its first line centred on `y`. */
3961
+ function drawLegend(items: readonly LegendItem[], left: number, right: number, y: number): Layer {
3962
+ const parts: string[] = [];
3963
+ const labels = clipNames(
3964
+ items.map(item => item.name),
3965
+ MAX_LEGEND_CHARS,
3966
+ );
3967
+ let x = left;
3968
+ items.forEach((item, at) => {
3969
+ const width = legendItemWidth(item);
3970
+ if (x + width - 16 > right + 0.5 && x > left) {
3971
+ x = left;
3972
+ y += LEGEND_LINE;
3973
+ }
3974
+ parts.push(item.ring ? ring(x + 4, y, 3.25, item.color) : dot(x + 4, y, 4, item.color, item.opacity));
3975
+ const tones = item.tones ?? [];
3976
+ tones.forEach((tone, slot) => parts.push(dot(x + 13 + slot * 9, y, 4, tone, item.opacity)));
3977
+ parts.push(text(x + 12 + 9 * tones.length, y, labels[at]!, { size: TICK_SIZE, fill: "muted" }));
3978
+ x += width;
3979
+ });
3980
+ return { height: y + 8, body: parts.join("") };
3981
+ }
3982
+
3983
+ /** Width of a heat {@link RampKey}: low figure, five swatches, high figure. */
3984
+ function rampWidth(ramp: RampKey): number {
3985
+ return measure(ramp.low, 10) + 6 + 5 * 13 + 4 + measure(ramp.high, 10);
3986
+ }
3987
+
3988
+ /** A heat scale key from `x`, centred on `y`: the low figure, swatches from faint to opaque, the high figure. */
3989
+ function drawRamp(ramp: RampKey, x: number, y: number): string {
3990
+ const parts = [text(x, y, ramp.low, { size: 10, fill: "muted" })];
3991
+ let at = x + measure(ramp.low, 10) + 6;
3992
+ for (let step = 0; step < 5; step++, at += 13)
3993
+ parts.push(rect(at, y - 5, 11, 10, ramp.color, HEAT_FLOOR + ((1 - HEAT_FLOOR) * step) / 4, 2));
3994
+ parts.push(text(at + 4, y, ramp.high, { size: 10, fill: "muted" }));
3995
+ return parts.join("");
3996
+ }
3997
+
3998
+ /**
3999
+ * Each name as the lines its label sets in: whole on one line when it is
4000
+ * short or fits {@link WIDE_LABEL_WIDTH}; else, with `wrap`, two lines of
4001
+ * {@link LABEL_WIDTH} split where they balance best ({@link wrapLabel});
4002
+ * without, one line clipped to {@link WIDE_LABEL_WIDTH} ({@link clipNames}).
4003
+ */
4004
+ function labelLines(names: readonly string[], wrap: boolean): string[][] {
4005
+ const wide = Math.floor(WIDE_LABEL_WIDTH / (LABEL_SIZE * ADVANCE));
4006
+ const clipped = clipNames(names, wide);
4007
+ return names.map((name, at) => {
4008
+ if (name.length <= MAX_LABEL_CHARS || measure(name, LABEL_SIZE) <= WIDE_LABEL_WIDTH) return [name];
4009
+ if (!wrap) return [fitText(clipped[at]!, WIDE_LABEL_WIDTH, LABEL_SIZE)];
4010
+ const lines = wrapLabel(name, LABEL_WIDTH, WRAP_SIZE);
4011
+ return lines.length > 1 ? lines : [fitText(name, LABEL_WIDTH, LABEL_SIZE)];
4012
+ });
4013
+ }
4014
+
4015
+ /**
4016
+ * `name` over two lines of at most `width` at `size`: split after the space
4017
+ * (or, in an identifier or path, the `/ . _ - , : +`, before the `(`, or
4018
+ * between camel-cased words) that balances the lines best, the second
4019
+ * clipped if still too long. A name with no such break clips on one line.
4020
+ * Category names ({@link labelLines}) and column headings
4021
+ * ({@link wrapHeader}) wrap through it.
4022
+ */
4023
+ function wrapLabel(name: string, width: number, size: number): string[] {
4024
+ let best: { lines: string[]; cost: number } | undefined;
4025
+ for (let at = 1; at < name.length; at++) {
4026
+ const [before, after] = [name[at - 1]!, name[at]!];
4027
+ const space = before === " ";
4028
+ const camel = /[a-z]/.test(before) && /[A-Z]/.test(after);
4029
+ if (!space && !camel && !"/._-,:+".includes(before) && after !== "(") continue;
4030
+ const first = name.slice(0, at).trimEnd();
4031
+ const second = name.slice(at).trimStart();
4032
+ const firstWidth = measure(first, size);
4033
+ if (!first || !second || firstWidth > width) continue;
4034
+ // The longer line sets the cost (a first line filled as far as it goes where the rest overflows anyway);
4035
+ // a break inside an identifier costs a little, so words part at spaces where they can.
4036
+ const cost = Math.max(firstWidth, measure(second, size)) + (space ? 0 : 8);
4037
+ if (!best || cost < best.cost) best = { lines: [first, fitText(second, width, size)], cost };
4038
+ }
4039
+ return best?.lines ?? [fitText(name, width, size)];
4040
+ }
4041
+
4042
+ /** Each category's label lines ({@link labelLines}): its own name, or its member name beside a group. */
4043
+ function categoryLines(spec: ChartSpec): string[][] {
4044
+ const names = spec.groups ? spec.groups.flatMap(group => group.members) : spec.categories;
4045
+ return labelLines(names, spec.categories.length <= MAX_WRAP_ROWS);
4046
+ }
4047
+
4048
+ /** `row` grown to {@link WRAP_ROW} where a category's name wraps, so both lines fit its row. */
4049
+ function labelRow(spec: ChartSpec, row: number): number {
4050
+ return row < WRAP_ROW && categoryLines(spec).some(lines => lines.length > 1) ? WRAP_ROW : row;
4051
+ }
4052
+
4053
+ /** A label's lines from `x`, centred on `y` as a block: one at {@link LABEL_SIZE}, two at {@link WRAP_SIZE}. */
4054
+ function labelText(x: number, y: number, lines: readonly string[], style: TextStyle): string {
4055
+ if (lines.length === 1) return text(x, y, lines[0]!, style);
4056
+ return lines
4057
+ .map((line, at) => text(x, y + (at - (lines.length - 1) / 2) * WRAP_LINE, line, { ...style, size: WRAP_SIZE }))
4058
+ .join("");
4059
+ }
4060
+
4061
+ /**
4062
+ * Category names down the label column, centred on their rows from `tops`;
4063
+ * a group's name once, semibold, beside its first member. Names start at the
4064
+ * column's left, so one set wider than {@link measure} estimates (another
4065
+ * font, a larger size) runs toward the plot instead of off the canvas. Long
4066
+ * names wrap to two lines ({@link categoryLines}); the renderer grows its rows
4067
+ * to hold them ({@link labelRow}).
4068
+ */
4069
+ function categoryLabels(
4070
+ spec: ChartSpec,
4071
+ tops: readonly number[],
4072
+ rowHeight: number,
4073
+ strong: ReadonlySet<number> = new Set(),
4074
+ ): string {
4075
+ const middle = (at: number) => tops[at]! + rowHeight / 2;
4076
+ const { groups, status } = spec;
4077
+ const matrix = matrixDots(spec, middle);
4078
+ // A status badge leads the name: a dot in the status color.
4079
+ const badges = status?.some(Boolean) ? STATUS_WIDTH : 0;
4080
+ const badge = (x: number, at: number) => {
4081
+ const state = status?.[at];
4082
+ return state ? dot(x + 4, middle(at), 4, STATUS_COLORS[state]) : "";
4083
+ };
4084
+ const lines = categoryLines(spec);
4085
+ if (!groups)
4086
+ return (
4087
+ matrix +
4088
+ lines
4089
+ .map(
4090
+ (name, at) =>
4091
+ badge(PAD, at) +
4092
+ labelText(PAD + badges, middle(at), name, {
4093
+ fill: "fg",
4094
+ weight: strong.has(at) ? 600 : undefined,
4095
+ }),
4096
+ )
4097
+ .join("")
4098
+ );
4099
+ const memberX = PAD + groupNameWidth(groups) + 10;
4100
+ const heads = clipNames(
4101
+ groups.map(group => group.name),
4102
+ groupChars(groups),
4103
+ );
4104
+ const parts: string[] = [matrix];
4105
+ let at = 0;
4106
+ groups.forEach((group, index) => {
4107
+ parts.push(text(PAD, middle(at), heads[index]!, { fill: "fg", weight: 600 }));
4108
+ for (const end = at + group.members.length; at < end; at++)
4109
+ parts.push(
4110
+ badge(memberX, at) +
4111
+ labelText(memberX + badges, middle(at), lines[at]!, {
4112
+ fill: "fg",
4113
+ weight: strong.has(at) ? 600 : undefined,
4114
+ }),
4115
+ );
4116
+ });
4117
+ return parts.join("");
4118
+ }
4119
+
4120
+ /** Characters a matrix column's heading takes per line, wrapped to two lines at most. */
4121
+ const MATRIX_HEAD_CHARS = 11;
4122
+ /** Height of one matrix heading line. */
4123
+ const MATRIX_LINE = 12;
4124
+
4125
+ /** A {@link ChartSpec.matrix} column as drawn: its heading lines and its width. */
4126
+ interface MatrixColumn {
4127
+ readonly lines: readonly string[];
4128
+ readonly width: number;
4129
+ }
4130
+
4131
+ /** Each matrix column's heading, wrapped, and the width it takes (the heading's, or a dot's). */
4132
+ function matrixColumns(spec: ChartSpec): MatrixColumn[] {
4133
+ return (spec.matrix ?? []).map(column => {
4134
+ const lines = wrapHeader(column.name, MATRIX_HEAD_CHARS * TICK_SIZE * ADVANCE, TICK_SIZE);
4135
+ return { lines, width: Math.max(STATUS_WIDTH, ...lines.map(line => measure(line, TICK_SIZE))) + 12 };
4136
+ });
4137
+ }
4138
+
4139
+ /** Each matrix column's centre; the matrix closes the label column. */
4140
+ function matrixCentres(spec: ChartSpec, columns: readonly MatrixColumn[]): number[] {
4141
+ let x = PAD + labelColumnWidth(spec) - columns.reduce((sum, column) => sum + column.width, 0);
4142
+ return columns.map(column => {
4143
+ x += column.width;
4144
+ return x - column.width / 2;
4145
+ });
4146
+ }
4147
+
4148
+ /**
4149
+ * The matrix columns' headings, a muted row between the header and the plot,
4150
+ * each centred over its column of dots and bottom-aligned when one wraps; a
4151
+ * single plotted series is headed too, at the plot's start, so the bars read
4152
+ * as the matrix's last column (no legend names it).
4153
+ */
4154
+ function matrixHead(spec: ChartSpec): { height: number; body: string } {
4155
+ const columns = matrixColumns(spec);
4156
+ if (columns.length === 0) return { height: 0, body: "" };
4157
+ const lines = Math.max(...columns.map(column => column.lines.length));
4158
+ const centres = matrixCentres(spec, columns);
4159
+ const body = columns.flatMap((column, index) =>
4160
+ column.lines.map((line, at) =>
4161
+ text(centres[index]!, (lines - column.lines.length + at) * MATRIX_LINE + 6, line, {
4162
+ size: TICK_SIZE,
4163
+ fill: "muted",
4164
+ anchor: "middle",
4165
+ }),
4166
+ ),
4167
+ );
4168
+ if (spec.series.length === 1)
4169
+ body.push(
4170
+ text(PAD + labelColumnWidth(spec) + 10, (lines - 1) * MATRIX_LINE + 6, clip(spec.series[0]!.name, 40), {
4171
+ size: TICK_SIZE,
4172
+ fill: "muted",
4173
+ }),
4174
+ );
4175
+ return { height: lines * MATRIX_LINE + 4, body: body.join("") };
4176
+ }
4177
+
4178
+ /** The matrix's dots, closing the label column: a status color per cell, a faint speck where a cell says none. */
4179
+ function matrixDots(spec: ChartSpec, middle: (at: number) => number): string {
4180
+ const columns = matrixColumns(spec);
4181
+ const centres = matrixCentres(spec, columns);
4182
+ return (spec.matrix ?? [])
4183
+ .flatMap((column, index) =>
4184
+ spec.categories.map((_, at) => {
4185
+ const state = column.statuses[at];
4186
+ return state
4187
+ ? dot(centres[index]!, middle(at), 4.5, STATUS_COLORS[state])
4188
+ : dot(centres[index]!, middle(at), 1.5, "muted", 0.6);
4189
+ }),
4190
+ )
4191
+ .join("");
4192
+ }
4193
+
4194
+ /**
4195
+ * Characters a group name may take: {@link MAX_GROUP_CHARS}, or what short
4196
+ * members leave of the two budgets together (`canto-identity-protocol` beside `gpt-5.5`).
4197
+ */
4198
+ function groupChars(groups: readonly ChartGroup[]): number {
4199
+ const widest = Math.max(
4200
+ 0,
4201
+ ...groups.flatMap(group => group.members.map(member => Math.min(member.length, MAX_LABEL_CHARS))),
4202
+ );
4203
+ return Math.max(MAX_GROUP_CHARS, MAX_LABEL_CHARS + MAX_GROUP_CHARS - widest);
4204
+ }
4205
+
4206
+ /** Top of each category's row from `top`, a {@link GROUP_GAP} parting consecutive groups, then the bottom of the last row. */
4207
+ function rowTops(spec: ChartSpec, top: number, rowHeight: number): number[] {
4208
+ const sizes = spec.groups?.map(group => group.members.length) ?? [spec.categories.length];
4209
+ const tops: number[] = [];
4210
+ let y = top;
4211
+ sizes.forEach((size, index) => {
4212
+ if (index > 0) y += GROUP_GAP;
4213
+ for (let at = 0; at < size; at++, y += rowHeight) tops.push(y);
4214
+ });
4215
+ tops.push(y);
4216
+ return tops;
4217
+ }
4218
+
4219
+ /** Width of the category label column: the widest name, or the widest group name beside the widest member. */
4220
+ function labelColumnWidth(spec: ChartSpec): number {
4221
+ // One line sets at the label size, a wrapped pair a step smaller.
4222
+ const widest = Math.max(
4223
+ 0,
4224
+ ...categoryLines(spec).flatMap(lines =>
4225
+ lines.map(line => measure(line, lines.length > 1 ? WRAP_SIZE : LABEL_SIZE)),
4226
+ ),
4227
+ );
4228
+ const { groups } = spec;
4229
+ const width = groups ? groupNameWidth(groups) + 10 + widest : widest;
4230
+ const matrix = matrixColumns(spec).reduce((sum, column) => sum + column.width, 0);
4231
+ return Math.max(4 * LABEL_SIZE * ADVANCE, width) + (spec.status?.some(Boolean) ? STATUS_WIDTH : 0) + matrix;
4232
+ }
4233
+
4234
+ /** Width of the widest semibold group name. */
4235
+ function groupNameWidth(groups: readonly ChartGroup[]): number {
4236
+ const heads = clipNames(
4237
+ groups.map(group => group.name),
4238
+ groupChars(groups),
4239
+ );
4240
+ return Math.max(0, ...heads.map(name => measure(name, LABEL_SIZE) * SEMIBOLD_WIDTH));
4241
+ }
4242
+
4243
+ /**
4244
+ * Labels for a linear axis' ticks in one unit, so it reads `0, 5k, 10k`
4245
+ * rather than `0, 5000, 10k`; a log axis spans units, each tick in its own.
4246
+ * A duration reads in the largest unit its ticks are whole (or half) steps
4247
+ * of (`50s, 100s, 150s`, not `0.833m, 1.67m`); sizes the table wrote in
4248
+ * binary units (`GiB`) tick in them too, so bars and labels agree.
4249
+ */
4250
+ function tickFormat(
4251
+ scale: Scale,
4252
+ axis: { readonly dim: Dimension; readonly unit: string; readonly points?: readonly (ChartPoint | null)[] },
4253
+ ): (tick: number) => string {
4254
+ const unit = binaryBytes(axis) ? "iB" : axis.unit;
4255
+ const format = (ticks: readonly number[], magnitude: number) =>
4256
+ formatValue(ticks[0]!, axis.dim, unit, axis.dim === "duration" ? durationMagnitude(ticks, magnitude) : magnitude);
4257
+ // A log axis' round thousand reads `1k` beside `10k`, not `1,000`.
4258
+ const counted = axis.dim !== "duration" && axis.dim !== "bytes";
4259
+ if (scale.log)
4260
+ return tick => format([tick], counted && Math.abs(tick) >= 1000 ? Math.max(1e4, Math.abs(tick)) : Math.abs(tick));
4261
+ const magnitude = Math.max(0, ...scale.ticks.map(Math.abs));
4262
+ const { cut } = scale;
4263
+ // Each segment of a broken axis reads in its own unit (`100k` before the break, `7M` after).
4264
+ if (cut) return tick => format([tick, ...scale.ticks], tick <= cut.below ? cut.below : magnitude);
4265
+ return tick => format([tick, ...scale.ticks], magnitude);
4266
+ }
4267
+
4268
+ /** Whether `axis` holds sizes the table wrote in binary units (`GiB`, `MiB`), which its ticks then read in too. */
4269
+ function binaryBytes(axis: { readonly dim: Dimension; readonly points?: readonly (ChartPoint | null)[] }): boolean {
4270
+ return axis.dim === "bytes" && !!axis.points?.some(point => point && /[KMGTP]iB/i.test(point.text));
4271
+ }
4272
+
4273
+ /**
4274
+ * One tick unit for an axis of `series` over `values`, in base units: the
4275
+ * binary size unit the largest value reads in (1 GiB) where the table wrote
4276
+ * binary sizes, so ticks fall on `25 GiB` rather than `23.3 GiB`; else 1.
4277
+ */
4278
+ function tickUnit(series: ChartSeries, values: readonly number[]): number {
4279
+ if (!binaryBytes(series)) return 1;
4280
+ const max = Math.max(0, ...values.map(Math.abs));
4281
+ let unit = 1;
4282
+ while (unit * 1024 <= max) unit *= 1024;
4283
+ return unit;
4284
+ }
4285
+
4286
+ /** Durations' units: each one's length in seconds and the least magnitude that reads in it. */
4287
+ const DURATION_UNITS: readonly (readonly [seconds: number, suffix: string, from: number])[] = [
4288
+ [1e-9, "ns", 0],
4289
+ [1e-6, "µs", 1e-6],
4290
+ [1e-3, "ms", 1e-3],
4291
+ [1, "s", 1],
4292
+ [60, "m", 120],
4293
+ [3600, "h", 7200],
4294
+ [86_400, "d", 172_800],
4295
+ ];
4296
+
4297
+ /** The {@link DURATION_UNITS} entry `magnitude` reads in; zero reads in seconds. */
4298
+ function durationUnit(magnitude: number): readonly [number, string, number] {
4299
+ if (magnitude === 0) return DURATION_UNITS[3]!;
4300
+ return DURATION_UNITS.findLast(([, , from]) => magnitude >= from) ?? DURATION_UNITS[0]!;
4301
+ }
4302
+
4303
+ /**
4304
+ * A magnitude for {@link formatValue} that reads `ticks` in the largest unit
4305
+ * up to `magnitude`'s in which each is a whole or half step (`150s`, not `2.5m`
4306
+ * beside `0.833m`).
4307
+ */
4308
+ function durationMagnitude(ticks: readonly number[], magnitude: number): number {
4309
+ const clean = (seconds: number) =>
4310
+ ticks.every(tick => {
4311
+ const count = tick / seconds;
4312
+ return Math.abs(count * 2 - Math.round(count * 2)) < 1e-6 && Math.abs(count) < 10_000;
4313
+ });
4314
+ const own = DURATION_UNITS.indexOf(durationUnit(magnitude));
4315
+ // Down to seconds at most (smaller units only ever read sub-second axes); where no unit reads clean, the own one.
4316
+ for (let index = own; index >= 3; index--) {
4317
+ const [seconds, , from] = DURATION_UNITS[index]!;
4318
+ if (clean(seconds)) return Math.max(seconds, from);
4319
+ }
4320
+ return magnitude;
4321
+ }
4322
+
4323
+ /** A point's written figure, or its value formatted when the table wrote none. */
4324
+ function pointText(series: ChartSeries, at: number): string {
4325
+ const point: ChartPoint | null | undefined = series.points[at];
4326
+ if (!point) return "";
4327
+ return point.text || formatValue(point.value, series.dim, binaryBytes(series) ? "iB" : series.unit);
4328
+ }
4329
+
4330
+ /**
4331
+ * A value in base units as an axis label: durations and sizes in the unit,
4332
+ * counts compacted under the suffix (`12k`, `3.4M`), that `magnitude` reads
4333
+ * best in — the value's own size by default, an axis' largest tick to keep
4334
+ * its labels in one unit. Sizes read in decimal units (`20 GB`), or binary
4335
+ * ones (`16 GiB`) when `unit` is `iB`.
4336
+ */
4337
+ export function formatValue(value: number, dim: Dimension, unit: string, magnitude = Math.abs(value)): string {
4338
+ switch (dim) {
4339
+ case "percent":
4340
+ return `${compact(value, magnitude)}%`;
4341
+ case "ratio":
4342
+ return `${compact(value, magnitude)}×`;
4343
+ case "currency":
4344
+ return `${value < 0 ? "-" : ""}${unit}${compact(Math.abs(value), magnitude)}`;
4345
+ case "duration": {
4346
+ const [divisor, suffix] = durationUnit(magnitude);
4347
+ return `${compact(value / divisor)}${suffix}`;
4348
+ }
4349
+ case "bytes": {
4350
+ const binary = unit === "iB";
4351
+ const base = binary ? 1024 : 1000;
4352
+ const units = binary ? ["B", "KiB", "MiB", "GiB", "TiB"] : ["B", "KB", "MB", "GB", "TB"];
4353
+ let index = 0;
4354
+ while (magnitude >= base ** (index + 1) && index < units.length - 1) index++;
4355
+ return `${compact(value / base ** index)} ${units[index]}`;
4356
+ }
4357
+ default:
4358
+ return compact(value, magnitude);
4359
+ }
4360
+ }
4361
+
4362
+ /** `1234567` → `1.2M`, `0.0123` → `0.012`; the suffix follows `magnitude` (`5000` → `5k` beside `10k`). */
4363
+ function compact(value: number, magnitude = Math.abs(value)): string {
4364
+ if (value === 0) return "0";
4365
+ if (magnitude >= 1e9) return `${trim(value / 1e9)}B`;
4366
+ if (magnitude >= 1e6) return `${trim(value / 1e6)}M`;
4367
+ if (magnitude >= 1e4) return `${trim(value / 1e3)}k`;
4368
+ return trim(value);
4369
+ }
4370
+
4371
+ /** `value` to three significant digits or so; thousands take a separator (`2,500`). */
4372
+ function trim(value: number): string {
4373
+ const magnitude = Math.abs(value);
4374
+ if (magnitude >= 1000) return Math.round(value).toLocaleString("en-US");
4375
+ const digits = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : magnitude >= 1 ? 2 : 3;
4376
+ return Number(value.toFixed(digits)).toString();
4377
+ }
4378
+
4379
+ /** Narrow and wide glyphs of a UI sans (Geist, SF Pro, Helvetica Neue), for {@link measure}. */
4380
+ const NARROW = "iljtfrI.,:;'!|()[] ";
4381
+ const WIDE = "mwMW%@—";
4382
+
4383
+ /** Estimated width of `content` set in the chart's sans at `size`. */
4384
+ function measure(content: string, size: number): number {
4385
+ let em = 0;
4386
+ for (const char of content) {
4387
+ if (NARROW.includes(char)) em += 0.3;
4388
+ else if (WIDE.includes(char)) em += 0.85;
4389
+ else if (char >= "0" && char <= "9") em += 0.58;
4390
+ else if (char >= "A" && char <= "Z") em += 0.66;
4391
+ else if (char.codePointAt(0)! >= 0x1100) em += 1;
4392
+ else em += 0.53;
4393
+ }
4394
+ return em * size;
4395
+ }
4396
+
4397
+ function clip(content: string, chars: number): string {
4398
+ return content.length <= chars ? content : `${content.slice(0, Math.max(1, chars - 1))}…`;
4399
+ }
4400
+
4401
+ /** `content` clipped to fit `width` user units at `size` (an em scale; pass the semibold width for bold text). */
4402
+ function fitText(content: string, width: number, size: number): string {
4403
+ let chars = content.length;
4404
+ while (chars > 1 && measure(clip(content, chars), size) > width) chars--;
4405
+ return clip(content, chars);
4406
+ }
4407
+
4408
+ /**
4409
+ * Category names clipped to `chars`, keeping their start; where two clip to
4410
+ * one label, an identifier or path (no spaces) keeps both ends instead
4411
+ * (`V12X_DAEM…TENV_DEV`), the end being where such names differ.
4412
+ */
4413
+ function clipNames(names: readonly string[], chars: number): string[] {
4414
+ const clipped = names.map(name => clip(name, chars));
4415
+ const owners = new Map<string, string>();
4416
+ const clashing = new Set<string>();
4417
+ clipped.forEach((label, at) => {
4418
+ const owner = owners.get(label);
4419
+ if (owner === undefined) owners.set(label, names[at]!);
4420
+ else if (owner !== names[at]) clashing.add(label);
4421
+ });
4422
+ if (clashing.size === 0 || chars < 7) return clipped;
4423
+ const head = Math.ceil((chars - 1) / 2);
4424
+ return clipped.map((label, at) => {
4425
+ const name = names[at]!;
4426
+ if (!clashing.has(label) || /\s/.test(name)) return label;
4427
+ return `${name.slice(0, head)}…${name.slice(name.length - (chars - 1 - head))}`;
4428
+ });
4429
+ }
4430
+
4431
+ function token(name: string): string {
4432
+ return `var(--${name})`;
4433
+ }
4434
+
4435
+ function num(value: number): string {
4436
+ return Number(value.toFixed(1)).toString();
4437
+ }
4438
+
4439
+ interface TextStyle {
4440
+ size?: number;
4441
+ fill: string;
4442
+ anchor?: "start" | "middle" | "end";
4443
+ weight?: number;
4444
+ italic?: boolean;
4445
+ }
4446
+
4447
+ /** A stretch of one {@link text} set apart from the rest: its own color and/or weight. */
4448
+ interface TextRun {
4449
+ readonly content: string;
4450
+ readonly fill?: string;
4451
+ readonly weight?: number;
4452
+ }
4453
+
4454
+ /**
4455
+ * Text vertically centred on `y`: one string, or {@link TextRun}s set in a
4456
+ * line, each in its own color or weight where it states one (a title's key
4457
+ * figure, each figure of `a · b` in its series' color).
4458
+ */
4459
+ function text(x: number, y: number, content: string | readonly TextRun[], style: TextStyle): string {
4460
+ const runs: readonly TextRun[] = typeof content === "string" ? [{ content }] : content;
4461
+ if (!runs.some(run => run.content)) return "";
4462
+ const size = style.size ?? LABEL_SIZE;
4463
+ const attrs = [
4464
+ `x="${num(x)}"`,
4465
+ `y="${num(y + size * 0.35)}"`,
4466
+ `fill="${token(style.fill)}"`,
4467
+ size === LABEL_SIZE ? "" : `font-size="${size}"`,
4468
+ style.anchor && style.anchor !== "start" ? `text-anchor="${style.anchor}"` : "",
4469
+ style.weight ? `font-weight="${style.weight}"` : "",
4470
+ style.italic ? `font-style="italic"` : "",
4471
+ ].filter(Boolean);
4472
+ const escape = (raw: string) =>
4473
+ raw.replace(/[&<>"]/g, char =>
4474
+ char === "&" ? "&amp;" : char === "<" ? "&lt;" : char === ">" ? "&gt;" : "&quot;",
4475
+ );
4476
+ const body = runs
4477
+ .filter(run => run.content)
4478
+ .map(run => {
4479
+ const own = [
4480
+ run.fill ? ` fill="${token(run.fill)}"` : "",
4481
+ run.weight ? ` font-weight="${run.weight}"` : "",
4482
+ ].join("");
4483
+ return own ? `<tspan${own}>${escape(run.content)}</tspan>` : escape(run.content);
4484
+ })
4485
+ .join("");
4486
+ return `<text ${attrs.join(" ")}>${body}</text>`;
4487
+ }
4488
+
4489
+ /**
4490
+ * Text in a dark ink that reads on a strong fill of a series color in either
4491
+ * theme: series colors are bright on dark themes and mid-tone on light ones,
4492
+ * so the ink must be dark in both, yet neither `fg` (light on dark themes)
4493
+ * nor `surface` (light on light themes) is. The text draws in `fg`, then
4494
+ * twice in `surface` through a mask of `fg`'s own luminance: a light `fg`
4495
+ * (dark theme) lets the dark `surface` cover it, a dark `fg` (light theme)
4496
+ * masks it away and leaves `fg` itself. Tokens only, no theme branch.
4497
+ */
4498
+ function ink(x: number, y: number, content: string, style: TextStyle): string {
4499
+ if (!content) return "";
4500
+ const cover = text(x, y, content, { ...style, fill: "surface" }).replace(
4501
+ "<text ",
4502
+ `<text mask="url(#${INK_MASK})" `,
4503
+ );
4504
+ return text(x, y, content, { ...style, fill: "fg" }) + cover + cover;
4505
+ }
4506
+
4507
+ /** The mask {@link ink} draws through: `fg` everywhere, so its luminance decides how much `surface` covers. */
4508
+ const INK_DEFS = `<defs><mask id="${INK_MASK}"><rect x="-10000" y="-10000" width="20000" height="20000" fill="var(--fg)"/></mask></defs>`;
4509
+
4510
+ /**
4511
+ * Annotation primitive: a soft `surface` band behind a highlighted row (or
4512
+ * column), under the marks and labels drawn after it.
4513
+ */
4514
+ function band(x: number, y: number, width: number, height: number): string {
4515
+ return rect(x, y, width, height, "surface", 0.55, 4);
4516
+ }
4517
+
4518
+ /** Annotation primitive: a dashed reference rule (a budget, a baseline value) over the gridlines. */
4519
+ function referenceRule(x1: number, y1: number, x2: number, y2: number): string {
4520
+ return `<line x1="${num(x1)}" y1="${num(y1)}" x2="${num(x2)}" y2="${num(y2)}" stroke="${token("fg")}" stroke-width="1" stroke-dasharray="3 3" stroke-opacity="0.6"/>`;
4521
+ }
4522
+
4523
+ /**
4524
+ * Annotation primitive: a callout — semibold `note` at `x`, joined to the mark
4525
+ * it explains by a dotted leader from `from` (both centred on `y`).
4526
+ */
4527
+ function callout(from: number, y: number, x: number, note: string): string {
4528
+ const leader =
4529
+ x - 4 > from + 2
4530
+ ? `<line x1="${num(from)}" y1="${num(y)}" x2="${num(x - 4)}" y2="${num(y)}" stroke="${token("muted")}" stroke-width="1" stroke-dasharray="1 2"/>`
4531
+ : "";
4532
+ return leader + text(x, y, note, { size: TICK_SIZE, fill: "fg", weight: 600 });
4533
+ }
4534
+
4535
+ /**
4536
+ * A status word or prose standing where a cell holds no number: known
4537
+ * statuses ({@link cellStatus}) take their color, other short all-caps words
4538
+ * (`MISS`) warn, prose (`Binary blob churn`) recedes in muted italics.
4539
+ */
4540
+ function noteText(x: number, y: number, note: string, centred = false): string {
4541
+ // A status word (`FAIL`, `ok`, `pending`) takes its status color.
4542
+ const said = cellStatus(note);
4543
+ const status = said !== undefined || /^[A-Z0-9 _/!-]{1,12}$/.test(note);
4544
+ return text(x, y, note, {
4545
+ size: TICK_SIZE,
4546
+ fill: said ? STATUS_COLORS[said] : status ? "warning" : "muted",
4547
+ italic: !status,
4548
+ anchor: centred ? "middle" : undefined,
4549
+ });
4550
+ }
4551
+
4552
+ /** An occupied rectangle, for keeping labels apart. */
4553
+ interface Box {
4554
+ readonly x0: number;
4555
+ readonly y0: number;
4556
+ readonly x1: number;
4557
+ readonly y1: number;
4558
+ }
4559
+
4560
+ /** A rectangle with corners rounded by `corner` ({@link RADIUS} by default), less for one too small to hold it. */
4561
+ function rect(
4562
+ x: number,
4563
+ y: number,
4564
+ width: number,
4565
+ height: number,
4566
+ fill: string,
4567
+ opacity: number,
4568
+ corner = RADIUS,
4569
+ ): string {
4570
+ const w = Math.max(0, width);
4571
+ const radius = Math.min(corner, w / 2, height / 2);
4572
+ const rounded = radius >= 0.5 ? ` rx="${num(radius)}"` : "";
4573
+ const alpha = opacity < 1 ? ` fill-opacity="${Number(opacity.toFixed(2))}"` : "";
4574
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(height)}"${rounded} fill="${token(fill)}"${alpha}/>`;
4575
+ }
4576
+
4577
+ /**
4578
+ * Indices of `entry`'s estimated figures (`~15`, `≈2k`) — only where they
4579
+ * are the exceptions among measured ones; a series of mostly estimates marks
4580
+ * none, since its few measured figures would then look like the odd ones.
4581
+ * A bolded figure keeps its solid bar: the table's emphasis outranks its doubt.
4582
+ */
4583
+ function estimatedPoints(entry: ChartSeries): Set<number> {
4584
+ const estimated = (point: ChartPoint | null | undefined) => !!point && /^\s*[~≈]/.test(point.text);
4585
+ const marked = entry.points.flatMap((point, at) => (estimated(point) && !point?.emphasis ? [at] : []));
4586
+ const measured = entry.points.filter(point => point && !estimated(point)).length;
4587
+ return new Set(marked.length <= measured ? marked : []);
4588
+ }
4589
+
4590
+ /**
4591
+ * A value's bar: a {@link rect}, or with `estimate` a faint fill
4592
+ * ({@link ESTIMATE_OPACITY}) inside a dashed outline of its color, so an
4593
+ * estimate reads as less certain than the measured bars beside it.
4594
+ */
4595
+ function barRect(
4596
+ x: number,
4597
+ y: number,
4598
+ width: number,
4599
+ height: number,
4600
+ fill: string,
4601
+ opacity: number,
4602
+ options: { estimate?: boolean } = {},
4603
+ ): string {
4604
+ if (!options.estimate) return rect(x, y, width, height, fill, opacity);
4605
+ const radius = Math.min(RADIUS, width / 2, height / 2);
4606
+ return (
4607
+ rect(x, y, width, height, fill, opacity * ESTIMATE_OPACITY) +
4608
+ `<rect x="${num(x + 0.5)}" y="${num(y + 0.5)}" width="${num(Math.max(0, width - 1))}" height="${num(height - 1)}" rx="${num(radius)}" fill="none" stroke="${token(fill)}" stroke-opacity="${Number(opacity.toFixed(2))}" stroke-width="1" stroke-dasharray="3 2"/>`
4609
+ );
4610
+ }
4611
+
4612
+ /** A round-capped line. */
4613
+ function line(x1: number, y1: number, x2: number, y2: number, stroke: string, width: number, opacity = 1): string {
4614
+ const alpha = opacity < 1 ? ` stroke-opacity="${Number(opacity.toFixed(2))}"` : "";
4615
+ return `<line x1="${num(x1)}" y1="${num(y1)}" x2="${num(x2)}" y2="${num(y2)}" stroke="${token(stroke)}" stroke-width="${width}" stroke-linecap="round"${alpha}/>`;
4616
+ }
4617
+
4618
+ /** A gridline: a hairline that recedes behind the marks. */
4619
+ function rule(x1: number, y1: number, x2: number, y2: number): string {
4620
+ return line(x1, y1, x2, y2, "border", 1, GRID_OPACITY);
4621
+ }
4622
+
4623
+ /** A rectangle's outline with corners rounded by {@link RADIUS}: the best cell of a heatmap column. */
4624
+ function outline(x: number, y: number, width: number, height: number, stroke: string): string {
4625
+ return `<rect x="${num(x)}" y="${num(y)}" width="${num(width)}" height="${num(height)}" rx="4" fill="none" stroke="${token(stroke)}" stroke-width="1.5"/>`;
4626
+ }
4627
+
4628
+ /**
4629
+ * The `best` mark beside a series' best value: small regular type in the
4630
+ * success color, a footnote to the figure (already semibold) rather than a
4631
+ * second headline competing with the title and the bolded rows.
4632
+ */
4633
+ function bestTag(x: number, y: number, size: number): string {
4634
+ return text(x, y, "best", { size: size - 1.5, fill: "success" });
4635
+ }
4636
+
4637
+ /**
4638
+ * The color a move by `factor` takes for a measure of `polarity`: `success`
4639
+ * when it improves, `error` when it regresses; none for a neutral measure or
4640
+ * a move within {@link STILL}.
4641
+ */
4642
+ function moveTone(polarity: Polarity | undefined, factor: number): string | undefined {
4643
+ if (!polarity || !Number.isFinite(factor) || factor < 0) return undefined;
4644
+ // A drop to zero has no log, but it is a move all the same.
4645
+ if (factor > 0 && Math.abs(Math.log(factor)) < Math.log(STILL)) return undefined;
4646
+ return factor > 1 === (polarity === "higher") ? "success" : "error";
4647
+ }
4648
+
4649
+ /** A hollow circle: a transition's starting value, or its legend mark. */
4650
+ function ring(x: number, y: number, radius: number, stroke: string): string {
4651
+ return `<circle cx="${num(x)}" cy="${num(y)}" r="${radius}" fill="none" stroke="${token(stroke)}" stroke-width="1.75"/>`;
4652
+ }
4653
+
4654
+ function dot(x: number, y: number, radius: number, fill: string, opacity = 1): string {
4655
+ const alpha = opacity < 1 ? ` fill-opacity="${Number(opacity.toFixed(2))}"` : "";
4656
+ return `<circle cx="${num(x)}" cy="${num(y)}" r="${radius}" fill="${token(fill)}"${alpha}/>`;
4657
+ }