@digithings-ai/ui 0.0.0-stage → 0.1.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 (424) hide show
  1. package/README.md +48 -2
  2. package/package.json +143 -4
  3. package/src/components/NavShell.tsx +776 -0
  4. package/src/components/SocialRow.tsx +87 -0
  5. package/src/components/Spinner.tsx +27 -0
  6. package/src/components/StackLogo.tsx +40 -0
  7. package/src/components/Terminal.tsx +110 -0
  8. package/src/components/TerminalManifest.tsx +212 -0
  9. package/src/components/ThemeProvider.tsx +271 -0
  10. package/src/components/account/AuthCard.tsx +309 -0
  11. package/src/components/assistant-ui/elements/mermaid-diagram.aui.tsx +63 -0
  12. package/src/components/assistant-ui/elements/mermaid-diagram.tsx +340 -0
  13. package/src/components/brand/BrandWordmark.tsx +32 -0
  14. package/src/components/changelog/ReleaseRail.tsx +81 -0
  15. package/src/components/changelog/index.ts +1 -0
  16. package/src/components/chat/ChatCodeBlock.tsx +67 -0
  17. package/src/components/chat/ChatMarkdown.tsx +66 -0
  18. package/src/components/chat/ChatMarkdownSource.tsx +203 -0
  19. package/src/components/chat/ChatMermaidBlock.tsx +202 -0
  20. package/src/components/chat/ChatMessage.tsx +77 -0
  21. package/src/components/chat/ChatPlayback.tsx +422 -0
  22. package/src/components/chat/ChatStreamCursor.tsx +25 -0
  23. package/src/components/chat/ChatThinking.tsx +113 -0
  24. package/src/components/chat/ChatToolCall.tsx +190 -0
  25. package/src/components/chat/ChatTranscript.tsx +60 -0
  26. package/src/components/chat/ChatWidgetFrame.tsx +107 -0
  27. package/src/components/chat/DigichatBootLoader.tsx +683 -0
  28. package/src/components/chat/DigichatLauncher.tsx +361 -0
  29. package/src/components/chat/DigichatThread.tsx +118 -0
  30. package/src/components/chat/DigichatThreadList.tsx +54 -0
  31. package/src/components/chat/DigichatWordmark.tsx +24 -0
  32. package/src/components/chat/DotMatrix.tsx +650 -0
  33. package/src/components/chat/boot-lab.tsx +766 -0
  34. package/src/components/chat/boot-signal.ts +144 -0
  35. package/src/components/chat/chat-surfaces.ts +52 -0
  36. package/src/components/chat/connection-status.ts +40 -0
  37. package/src/components/chat/digichat-wordmark.ts +355 -0
  38. package/src/components/chat/disclosure-chevron.tsx +49 -0
  39. package/src/components/chat/followable-href.ts +9 -0
  40. package/src/components/chat/gallery-thread/action-icons.tsx +25 -0
  41. package/src/components/chat/gallery-thread/attachment.aui.tsx +262 -0
  42. package/src/components/chat/gallery-thread/block-caret.tsx +439 -0
  43. package/src/components/chat/gallery-thread/cn.ts +1 -0
  44. package/src/components/chat/gallery-thread/composer-keyboard.ts +106 -0
  45. package/src/components/chat/gallery-thread/composer-trigger-popover.aui.tsx +235 -0
  46. package/src/components/chat/gallery-thread/css.d.ts +6 -0
  47. package/src/components/chat/gallery-thread/file.tsx +238 -0
  48. package/src/components/chat/gallery-thread/follow-up-suggestions.aui.tsx +92 -0
  49. package/src/components/chat/gallery-thread/format-json-dump.ts +112 -0
  50. package/src/components/chat/gallery-thread/hooks/use-attachment-src.ts +46 -0
  51. package/src/components/chat/gallery-thread/hooks/use-copy-to-clipboard.ts +9 -0
  52. package/src/components/chat/gallery-thread/image.tsx +519 -0
  53. package/src/components/chat/gallery-thread/markdown-text.tsx +278 -0
  54. package/src/components/chat/gallery-thread/message-error.aui.tsx +105 -0
  55. package/src/components/chat/gallery-thread/message-timing.aui.tsx +143 -0
  56. package/src/components/chat/gallery-thread/private-work-step.tsx +188 -0
  57. package/src/components/chat/gallery-thread/reasoning.tsx +332 -0
  58. package/src/components/chat/gallery-thread/scroll-mode.ts +174 -0
  59. package/src/components/chat/gallery-thread/source.tsx +76 -0
  60. package/src/components/chat/gallery-thread/thread.aui.tsx +1619 -0
  61. package/src/components/chat/gallery-thread/tooltip-icon-button.tsx +53 -0
  62. package/src/components/chat/gallery-thread/typed-welcome-copy.tsx +18 -0
  63. package/src/components/chat/gallery-thread/ui/tooltip.tsx +22 -0
  64. package/src/components/chat/gallery-thread/work-chain-summary.tsx +48 -0
  65. package/src/components/chat/gallery-thread/work-chain.tsx +110 -0
  66. package/src/components/chat/gallery-thread/work-status.ts +66 -0
  67. package/src/components/chat/overlay-scrollbar.tsx +86 -0
  68. package/src/components/chat/sanitize-mermaid-svg.ts +38 -0
  69. package/src/components/chat/skins/SOURCE.md +36 -0
  70. package/src/components/chat/skins/base/attachment.aui.tsx +287 -0
  71. package/src/components/chat/skins/base/aui-default/README.md +45 -0
  72. package/src/components/chat/skins/base/aui-default/collapsible-root.tsx +19 -0
  73. package/src/components/chat/skins/base/aui-default/collapsible.tsx +21 -0
  74. package/src/components/chat/skins/base/aui-default/reasoning.aui.tsx +120 -0
  75. package/src/components/chat/skins/base/aui-default/reasoning.tsx +331 -0
  76. package/src/components/chat/skins/base/aui-default/surfaces.ts +7 -0
  77. package/src/components/chat/skins/base/aui-default/tool-fallback.aui.tsx +772 -0
  78. package/src/components/chat/skins/base/aui-default/tool-group.aui.tsx +241 -0
  79. package/src/components/chat/skins/base/docs-model-options.ts +6 -0
  80. package/src/components/chat/skins/base/elements/assistant-trace.aui.tsx +169 -0
  81. package/src/components/chat/skins/base/elements/composer-trigger-popover.aui.tsx +271 -0
  82. package/src/components/chat/skins/base/elements/directive-text.aui.tsx +29 -0
  83. package/src/components/chat/skins/base/elements/directive-text.tsx +76 -0
  84. package/src/components/chat/skins/base/elements/message-timing.aui.tsx +104 -0
  85. package/src/components/chat/skins/base/elements/model-selector.aui.tsx +140 -0
  86. package/src/components/chat/skins/base/elements/model-selector.tsx +720 -0
  87. package/src/components/chat/skins/base/elements/quote.aui.tsx +290 -0
  88. package/src/components/chat/skins/base/elements/work-chain.tsx +100 -0
  89. package/src/components/chat/skins/base/markdown-text.tsx +294 -0
  90. package/src/components/chat/skins/base/model.ts +1 -0
  91. package/src/components/chat/skins/base/thread.tsx +1050 -0
  92. package/src/components/chat/skins/base/tooltip-icon-button.tsx +56 -0
  93. package/src/components/chat/skins/base/ui/avatar.tsx +115 -0
  94. package/src/components/chat/skins/base/ui/badge.tsx +51 -0
  95. package/src/components/chat/skins/base/ui/button.tsx +67 -0
  96. package/src/components/chat/skins/base/ui/command.tsx +185 -0
  97. package/src/components/chat/skins/base/ui/dialog.tsx +168 -0
  98. package/src/components/chat/skins/base/ui/dot-matrix.tsx +267 -0
  99. package/src/components/chat/skins/base/ui/popover.tsx +97 -0
  100. package/src/components/chat/skins/base/ui/skeleton.tsx +20 -0
  101. package/src/components/chat/skins/base/ui/textarea.tsx +24 -0
  102. package/src/components/chat/skins/base/ui/tooltip.tsx +73 -0
  103. package/src/components/chat/skins/base-assistant-ui/config-provider.tsx +90 -0
  104. package/src/components/chat/skins/base-assistant-ui/defaults.ts +419 -0
  105. package/src/components/chat/skins/base-assistant-ui/index.tsx +44 -0
  106. package/src/components/chat/skins/chatgpt.tsx +448 -0
  107. package/src/components/chat/skins/claude.tsx +380 -0
  108. package/src/components/chat/skins/digichat-composer-toolbar.tsx +151 -0
  109. package/src/components/chat/skins/digichat-context-meter-shares.ts +158 -0
  110. package/src/components/chat/skins/digichat-context-meter.tsx +243 -0
  111. package/src/components/chat/skins/digichat-meter-tone.ts +26 -0
  112. package/src/components/chat/skins/digichat-usage-meter.tsx +103 -0
  113. package/src/components/chat/skins/digichat.tsx +144 -0
  114. package/src/components/chat/skins/expo-react-native.tsx +29 -0
  115. package/src/components/chat/skins/gemini.tsx +322 -0
  116. package/src/components/chat/skins/grok-icon.tsx +34 -0
  117. package/src/components/chat/skins/grok.tsx +369 -0
  118. package/src/components/chat/skins/index.ts +46 -0
  119. package/src/components/chat/skins/perplexity.tsx +462 -0
  120. package/src/components/chat/skins/product-page-assistant/assistant-modal.tsx +73 -0
  121. package/src/components/chat/skins/product-page-assistant/index.tsx +19 -0
  122. package/src/components/chat/skins/product-page-assistant/product-dashboard.tsx +164 -0
  123. package/src/components/chat/skins/react-ink.tsx +97 -0
  124. package/src/components/chat/skins/thread-skin-host-contract.ts +102 -0
  125. package/src/components/chat/skins/thread-skin-view.tsx +156 -0
  126. package/src/components/chat/skins/thread-skins.ts +143 -0
  127. package/src/components/chat/skins/tooltip-icon-button.tsx +35 -0
  128. package/src/components/chat/skins/ui/dropdown-menu.tsx +272 -0
  129. package/src/components/chat/skins/webpage-assistant/assistant-config.ts +238 -0
  130. package/src/components/chat/skins/webpage-assistant/assistant-modal.tsx +78 -0
  131. package/src/components/chat/skins/webpage-assistant/config-provider.tsx +132 -0
  132. package/src/components/chat/skins/webpage-assistant/docs-app.tsx +106 -0
  133. package/src/components/chat/skins/webpage-assistant/docs-article.tsx +109 -0
  134. package/src/components/chat/skins/webpage-assistant/docs-assistant.tsx +73 -0
  135. package/src/components/chat/skins/webpage-assistant/docs-nav.tsx +81 -0
  136. package/src/components/chat/skins/webpage-assistant/host-ui.ts +79 -0
  137. package/src/components/chat/skins/webpage-assistant/index.tsx +12 -0
  138. package/src/components/chat/skins/webpage-assistant/markdown-page.tsx +11 -0
  139. package/src/components/chat/skins/webpage-assistant/pages.ts +9 -0
  140. package/src/components/chat/skins/webpage-assistant/types.ts +178 -0
  141. package/src/components/chat/skins/webpage-assistant/use-mobile.ts +19 -0
  142. package/src/components/chat/stock/SOURCE.md +90 -0
  143. package/src/components/chat/stock/artifacts-panel.tsx +305 -0
  144. package/src/components/chat/stock/attachment.aui.tsx +280 -0
  145. package/src/components/chat/stock/chat-notice.tsx +143 -0
  146. package/src/components/chat/stock/chat-private-mode.tsx +64 -0
  147. package/src/components/chat/stock/deploy-ui-context.tsx +89 -0
  148. package/src/components/chat/stock/embed-chat-prefs.tsx +471 -0
  149. package/src/components/chat/stock/file.tsx +265 -0
  150. package/src/components/chat/stock/follow-up-suggestions.aui.tsx +83 -0
  151. package/src/components/chat/stock/image.tsx +531 -0
  152. package/src/components/chat/stock/index.ts +129 -0
  153. package/src/components/chat/stock/json-view.tsx +140 -0
  154. package/src/components/chat/stock/markdown-text.tsx +319 -0
  155. package/src/components/chat/stock/message-error.aui.tsx +96 -0
  156. package/src/components/chat/stock/message-export-menu.tsx +156 -0
  157. package/src/components/chat/stock/palette-row-preview.tsx +26 -0
  158. package/src/components/chat/stock/reasoning.aui.tsx +120 -0
  159. package/src/components/chat/stock/reasoning.tsx +335 -0
  160. package/src/components/chat/stock/skin-chrome.tsx +81 -0
  161. package/src/components/chat/stock/skin-credit.tsx +63 -0
  162. package/src/components/chat/stock/skin-palette-bridge.tsx +235 -0
  163. package/src/components/chat/stock/skin-palette-context.ts +16 -0
  164. package/src/components/chat/stock/skin-runtime.tsx +52 -0
  165. package/src/components/chat/stock/skin-slash-palette.tsx +393 -0
  166. package/src/components/chat/stock/skin-types.ts +217 -0
  167. package/src/components/chat/stock/stock-chat-prefs-host.tsx +501 -0
  168. package/src/components/chat/stock/stock-send-gate.tsx +94 -0
  169. package/src/components/chat/stock/stock-shape.tsx +39 -0
  170. package/src/components/chat/stock/thread.aui.tsx +645 -0
  171. package/src/components/chat/stock/tool-display.ts +6 -0
  172. package/src/components/chat/stock/tool-fallback.aui.tsx +866 -0
  173. package/src/components/chat/stock/tool-group.aui.tsx +244 -0
  174. package/src/components/chat/stock/tool-surface.ts +120 -0
  175. package/src/components/chat/stock/tooltip-icon-button.tsx +49 -0
  176. package/src/components/chat/stock/ui/avatar.tsx +108 -0
  177. package/src/components/chat/stock/ui/button.tsx +81 -0
  178. package/src/components/chat/stock/ui/collapsible.tsx +21 -0
  179. package/src/components/chat/stock/ui/dialog.tsx +163 -0
  180. package/src/components/chat/stock/ui/skeleton.tsx +13 -0
  181. package/src/components/chat/stock/ui/textarea.tsx +20 -0
  182. package/src/components/chat/stock/ui/tooltip.tsx +68 -0
  183. package/src/components/chat/stock/use-attachment-src.ts +42 -0
  184. package/src/components/chat/stock/use-copy-to-clipboard.ts +29 -0
  185. package/src/components/chat/strip-model-boilerplate.ts +42 -0
  186. package/src/components/chat/suggestion-cycle.tsx +230 -0
  187. package/src/components/chat/suggestion-scroll-row.tsx +81 -0
  188. package/src/components/chat/trace-grouping/chain-group-by.ts +44 -0
  189. package/src/components/chat/trace-grouping/chain-state.ts +141 -0
  190. package/src/components/chat/trace-grouping/index.ts +32 -0
  191. package/src/components/chat/trace-grouping/tool-row.ts +74 -0
  192. package/src/components/chat/trace-grouping/use-deploy-work-disclosure.ts +35 -0
  193. package/src/components/chat/trace-grouping/use-work-chain.ts +103 -0
  194. package/src/components/chat/trace-i18n/core.ts +48 -0
  195. package/src/components/chat/trace-i18n/index.tsx +23 -0
  196. package/src/components/chat/trace-i18n/locales/en.ts +24 -0
  197. package/src/components/chat/trace-i18n/locales/fr.ts +28 -0
  198. package/src/components/chat/trace-i18n/types.ts +35 -0
  199. package/src/components/chat/trace-spacing.ts +249 -0
  200. package/src/components/chat/transcript/activity-view.ts +375 -0
  201. package/src/components/chat/transcript/chat-export.ts +133 -0
  202. package/src/components/chat/transcript/index.ts +10 -0
  203. package/src/components/chat/transcript/save-file.ts +209 -0
  204. package/src/components/chat/transcript/transcript-markdown.ts +428 -0
  205. package/src/components/chat/transcript/types.ts +81 -0
  206. package/src/components/chat/turn-outcome.ts +52 -0
  207. package/src/components/chat/use-warmup-phase.ts +18 -0
  208. package/src/components/chat/work-status-lead.tsx +49 -0
  209. package/src/components/chrome.tsx +264 -0
  210. package/src/components/command-palette/CommandPalette.tsx +404 -0
  211. package/src/components/command-palette/index.ts +6 -0
  212. package/src/components/contact/ContactMailto.tsx +112 -0
  213. package/src/components/contact/index.ts +5 -0
  214. package/src/components/conviction/ConvictionMeter.tsx +40 -0
  215. package/src/components/conviction/SignedConvictionBadge.tsx +26 -0
  216. package/src/components/conviction/index.ts +7 -0
  217. package/src/components/data-layout/BentoGrid.tsx +52 -0
  218. package/src/components/data-layout/CardRail.tsx +150 -0
  219. package/src/components/data-layout/DotMatrixStat.tsx +65 -0
  220. package/src/components/data-layout/FeatureCell.tsx +80 -0
  221. package/src/components/data-layout/MediaFrame.tsx +125 -0
  222. package/src/components/data-layout/ModuleGrid.tsx +735 -0
  223. package/src/components/data-layout/Odometer.tsx +149 -0
  224. package/src/components/data-layout/ProductFrame.tsx +79 -0
  225. package/src/components/data-layout/TestimonialWall.tsx +139 -0
  226. package/src/components/data-layout/index.ts +19 -0
  227. package/src/components/data-layout/treemap.ts +368 -0
  228. package/src/components/deck/DeckStack.tsx +243 -0
  229. package/src/components/desk/DataTable.tsx +373 -0
  230. package/src/components/desk/KpiGrid.tsx +80 -0
  231. package/src/components/desk/Sparkline.tsx +63 -0
  232. package/src/components/desk/StateBlock.tsx +132 -0
  233. package/src/components/desk/index.ts +18 -0
  234. package/src/components/diagrams/ArchitectureDiagram.tsx +350 -0
  235. package/src/components/diagrams/ArchitectureTour.tsx +912 -0
  236. package/src/components/diagrams/TerminalSchematic.tsx +192 -0
  237. package/src/components/diagrams/architecture-svg.tsx +384 -0
  238. package/src/components/diagrams/index.ts +30 -0
  239. package/src/components/diagrams/mermaid-theme.ts +115 -0
  240. package/src/components/diagrams/tour-camera.ts +171 -0
  241. package/src/components/docs/CodeTabs.tsx +90 -0
  242. package/src/components/docs/CopyCommand.tsx +151 -0
  243. package/src/components/docs/DocsLayout.tsx +197 -0
  244. package/src/components/docs/DocsSearch.tsx +97 -0
  245. package/src/components/docs/Endpoint.tsx +135 -0
  246. package/src/components/document/DocumentFrame.tsx +43 -0
  247. package/src/components/document/GlyphList.tsx +51 -0
  248. package/src/components/document/PageTitle.tsx +44 -0
  249. package/src/components/document/Prose.tsx +38 -0
  250. package/src/components/document/Section.tsx +55 -0
  251. package/src/components/document/index.ts +13 -0
  252. package/src/components/effects-chrome/AnnouncementBar.tsx +88 -0
  253. package/src/components/effects-chrome/HorizontalScrollTrack.tsx +461 -0
  254. package/src/components/effects-chrome/Pipeline.tsx +226 -0
  255. package/src/components/effects-chrome/RotatingPrompts.tsx +116 -0
  256. package/src/components/effects-chrome/StackingPanels.tsx +101 -0
  257. package/src/components/effects-chrome/TabStrip.tsx +232 -0
  258. package/src/components/effects-chrome/ToastStack.tsx +201 -0
  259. package/src/components/effects-chrome/horizontal-track-core.ts +70 -0
  260. package/src/components/effects-chrome/index.ts +35 -0
  261. package/src/components/emblems.tsx +188 -0
  262. package/src/components/faviconTheme.ts +23 -0
  263. package/src/components/figure/Figure.tsx +38 -0
  264. package/src/components/figure/index.ts +2 -0
  265. package/src/components/finance-charts/DrawdownPlot.tsx +76 -0
  266. package/src/components/finance-charts/EquityCurve.tsx +71 -0
  267. package/src/components/finance-charts/PriceChart.tsx +119 -0
  268. package/src/components/finance-charts/chart-host.tsx +236 -0
  269. package/src/components/finance-charts/chart-scale.ts +93 -0
  270. package/src/components/finance-charts/demo-data.ts +88 -0
  271. package/src/components/finance-charts/index.ts +46 -0
  272. package/src/components/finance-charts/lw-chart.tsx +292 -0
  273. package/src/components/finance-composites/MarketBar.tsx +243 -0
  274. package/src/components/finance-composites/OrderBook.tsx +133 -0
  275. package/src/components/finance-composites/PerformanceDashboard.tsx +185 -0
  276. package/src/components/finance-composites/SortableTable.tsx +144 -0
  277. package/src/components/finance-composites/StockTicker.tsx +65 -0
  278. package/src/components/finance-composites/SyncedTearsheet.tsx +226 -0
  279. package/src/components/finance-composites/index.ts +28 -0
  280. package/src/components/finance-tearsheet/KpiStrip.tsx +49 -0
  281. package/src/components/finance-tearsheet/LiveBadge.tsx +29 -0
  282. package/src/components/finance-tearsheet/ReturnsMatrix.tsx +321 -0
  283. package/src/components/finance-tearsheet/StrategyTearsheet.tsx +99 -0
  284. package/src/components/finance-tearsheet/TearsheetCard.tsx +54 -0
  285. package/src/components/finance-tearsheet/TradeLogTable.tsx +73 -0
  286. package/src/components/finance-tearsheet/charts.tsx +2624 -0
  287. package/src/components/finance-tearsheet/demo-data.ts +177 -0
  288. package/src/components/finance-tearsheet/format.ts +41 -0
  289. package/src/components/finance-tearsheet/index.ts +120 -0
  290. package/src/components/finance-tearsheet/print.ts +52 -0
  291. package/src/components/finance-tearsheet/risk-bands.ts +48 -0
  292. package/src/components/finance-tearsheet/stats.ts +35 -0
  293. package/src/components/finance-tearsheet/strategy-sheet-model.ts +238 -0
  294. package/src/components/finance-tearsheet/types.ts +58 -0
  295. package/src/components/footer/FooterCells.tsx +109 -0
  296. package/src/components/footer/index.ts +6 -0
  297. package/src/components/graph.tsx +158 -0
  298. package/src/components/icons.tsx +98 -0
  299. package/src/components/landing/PixelWordmark.tsx +36 -0
  300. package/src/components/logos.ts +39 -0
  301. package/src/components/marquee/Marquee.tsx +204 -0
  302. package/src/components/metrics/PerfMetrics.tsx +68 -0
  303. package/src/components/metrics/StatCounter.tsx +104 -0
  304. package/src/components/page-geometry.tsx +177 -0
  305. package/src/components/pricing/Pricing.tsx +174 -0
  306. package/src/components/pricing/PricingMatrix.tsx +219 -0
  307. package/src/components/prose/index.ts +2 -0
  308. package/src/components/prose/prose.tsx +86 -0
  309. package/src/components/repo-activity/RepoActivity.tsx +599 -0
  310. package/src/components/repo-activity/RepoHeatmap.tsx +225 -0
  311. package/src/components/repo-activity/demo.ts +201 -0
  312. package/src/components/repo-activity/fetch.ts +211 -0
  313. package/src/components/repo-activity/heatmap.ts +123 -0
  314. package/src/components/repo-activity/index.ts +28 -0
  315. package/src/components/repo-activity/types.ts +99 -0
  316. package/src/components/stages/NumberedStages.tsx +116 -0
  317. package/src/components/symbols/marks.tsx +116 -0
  318. package/src/components/symbols/terminal-lockup.tsx +136 -0
  319. package/src/components/symbols/terminal-marks.tsx +226 -0
  320. package/src/components/terminal/ChatResponseLoader.tsx +67 -0
  321. package/src/components/terminal/ContainerBootLoader.tsx +82 -0
  322. package/src/components/terminal/TerminalStepCaret.tsx +202 -0
  323. package/src/components/themeInit.ts +24 -0
  324. package/src/components/typography/MotionReveal.tsx +108 -0
  325. package/src/components/typography/TypewriterReveal.tsx +101 -0
  326. package/src/components/typography/WordReveal.tsx +143 -0
  327. package/src/data/modules.ts +378 -0
  328. package/src/data/subsystems.ts +101 -0
  329. package/src/index.ts +587 -0
  330. package/src/lib/base-path.ts +60 -0
  331. package/src/lib/cx.ts +25 -0
  332. package/src/lib/gloomberb.ts +69 -0
  333. package/src/lib/record.ts +19 -0
  334. package/src/lib/stub-payload.ts +183 -0
  335. package/src/lib/useBodyScrollLock.ts +44 -0
  336. package/src/lib/utils.ts +6 -0
  337. package/src/motion/primitives.tsx +105 -0
  338. package/src/motion/scroll-glide.ts +62 -0
  339. package/src/motion/scrolly-core.ts +38 -0
  340. package/src/motion/scrolly.tsx +95 -0
  341. package/src/navigation/HashScrollManager.tsx +167 -0
  342. package/src/navigation/hashScroll.ts +78 -0
  343. package/src/replay/browser.ts +280 -0
  344. package/src/replay/chat-export-run.ts +121 -0
  345. package/src/replay/export-format.ts +25 -0
  346. package/src/replay/export-mount.ts +32 -0
  347. package/src/replay/filename.ts +54 -0
  348. package/src/replay/index.ts +21 -0
  349. package/src/replay/redact-dom.ts +178 -0
  350. package/src/replay/redact.ts +113 -0
  351. package/src/replay/snapshot.ts +659 -0
  352. package/src/styles/account-auth.css +339 -0
  353. package/src/styles/brand-wordmark.css +11 -0
  354. package/src/styles/card-rail.css +120 -0
  355. package/src/styles/chat-aui.css +1143 -0
  356. package/src/styles/chat-core.css +469 -0
  357. package/src/styles/chat-digichat.css +2531 -0
  358. package/src/styles/chat-math.css +70 -0
  359. package/src/styles/chat-skin-palette.css +138 -0
  360. package/src/styles/chat-stock-menu.css +37 -0
  361. package/src/styles/chat-widgets.css +190 -0
  362. package/src/styles/command-palette.css +372 -0
  363. package/src/styles/controls-core.css +2261 -0
  364. package/src/styles/controls-overlay.css +603 -0
  365. package/src/styles/data-layout.css +288 -0
  366. package/src/styles/deck.css +141 -0
  367. package/src/styles/diagrams.css +1860 -0
  368. package/src/styles/digichat-app-theme.css +250 -0
  369. package/src/styles/digichat-boot-loader.css +487 -0
  370. package/src/styles/digichat-launcher.css +377 -0
  371. package/src/styles/docs.css +165 -0
  372. package/src/styles/effects-chrome.css +888 -0
  373. package/src/styles/finance-composites.css +214 -0
  374. package/src/styles/finance-tearsheet-overlay.css +90 -0
  375. package/src/styles/finance-tearsheet.css +945 -0
  376. package/src/styles/marquee.css +168 -0
  377. package/src/styles/metrics.css +82 -0
  378. package/src/styles/nav-shell.css +551 -0
  379. package/src/styles/pricing.css +79 -0
  380. package/src/styles/repo-activity.css +575 -0
  381. package/src/styles/stages.css +69 -0
  382. package/src/styles/terminal-loaders.css +122 -0
  383. package/src/styles/terminal-manifest.css +228 -0
  384. package/src/styles/web-theme.css +1007 -0
  385. package/src/styles/word-reveal.css +204 -0
  386. package/src/theme/apply-theme.ts +214 -0
  387. package/src/theme/index.ts +27 -0
  388. package/src/theme/palette-registry.ts +855 -0
  389. package/src/theme/tui-prefs.ts +64 -0
  390. package/src/ui/alert.tsx +78 -0
  391. package/src/ui/avatar.tsx +104 -0
  392. package/src/ui/badge.tsx +75 -0
  393. package/src/ui/breadcrumb.tsx +61 -0
  394. package/src/ui/button.tsx +67 -0
  395. package/src/ui/card.tsx +139 -0
  396. package/src/ui/checkbox.tsx +27 -0
  397. package/src/ui/collapsible.tsx +32 -0
  398. package/src/ui/date-pager.tsx +325 -0
  399. package/src/ui/dialog.tsx +157 -0
  400. package/src/ui/dropdown-menu.tsx +267 -0
  401. package/src/ui/empty-state.tsx +165 -0
  402. package/src/ui/field.tsx +104 -0
  403. package/src/ui/form.tsx +36 -0
  404. package/src/ui/icon-button.tsx +36 -0
  405. package/src/ui/index.ts +34 -0
  406. package/src/ui/input.tsx +33 -0
  407. package/src/ui/kbd.tsx +30 -0
  408. package/src/ui/label.tsx +32 -0
  409. package/src/ui/pager.tsx +119 -0
  410. package/src/ui/pagination.tsx +126 -0
  411. package/src/ui/radio-group.tsx +51 -0
  412. package/src/ui/search-bar.tsx +85 -0
  413. package/src/ui/segmented-control.tsx +76 -0
  414. package/src/ui/select.tsx +257 -0
  415. package/src/ui/separator.tsx +24 -0
  416. package/src/ui/sheet.tsx +141 -0
  417. package/src/ui/skeleton.tsx +76 -0
  418. package/src/ui/slider.tsx +76 -0
  419. package/src/ui/switch.tsx +31 -0
  420. package/src/ui/table.tsx +179 -0
  421. package/src/ui/tabs.tsx +89 -0
  422. package/src/ui/tags-input.tsx +168 -0
  423. package/src/ui/textarea.tsx +17 -0
  424. package/src/ui/tooltip.tsx +71 -0
@@ -0,0 +1,2624 @@
1
+ "use client";
2
+ /**
3
+ * Dependency-free SVG charts for finance tearsheets (#1463) — promoted
4
+ * verbatim from apps/digiquant-web/components/tearsheet/charts.tsx.
5
+ * Pure vector output (no canvas, no libs) so the tearsheet prints to PDF
6
+ * crisply: the PDF pipeline flushSync-re-renders these same component
7
+ * instances at full span and calls window.print(), so print-first pure-SVG
8
+ * is a hard constraint here. Canvas engines are the DASHBOARD grammar
9
+ * (finance-charts / finance-composites) — see packages/ui/CHARTS.md
10
+ * before swapping engines on any surface.
11
+ *
12
+ * Charts render into a 0 0 1000 H viewBox and scale to the container width.
13
+ * Colours come from CSS custom properties on the chart classes (theme-aware
14
+ * via [data-theme]). Supports linear / log / symlog y scales — symlog
15
+ * handles series that cross zero (cumulative P&L). The series surfaces
16
+ * (CandlestickChart, TimeSeries, TradeReturnChart) share one normalized
17
+ * ViewWindow: wheel-zoom / drag-pan / double-click reset stay synced across
18
+ * charts, with lookback presets matched back via `matchLookbackPreset`.
19
+ */
20
+ import { type ReactNode, type RefObject, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
21
+ import { fmtCompact, fmtMoney, fmtNum, fmtPct, toneClass } from "./format";
22
+ import { RISK_BANDS } from "./risk-bands";
23
+ import {
24
+ isOpenTrade,
25
+ type TearsheetOhlcBar,
26
+ type TearsheetSeriesPoint,
27
+ type TearsheetTrade,
28
+ type TradeReturnBar,
29
+ } from "./types";
30
+
31
+ const W = 1000;
32
+ const PAD = { top: 30, right: 18, bottom: 34, left: 68 };
33
+ /** Tighter gutters for homepage preview cards — more plot area edge-to-edge. */
34
+ const PAD_COMPACT = { top: 16, right: 4, bottom: 22, left: 44 };
35
+
36
+ type ChartPad = typeof PAD;
37
+
38
+ function resolveChartPad(widthPx: number, compact: boolean): ChartPad {
39
+ const base = compact ? PAD_COMPACT : PAD;
40
+ if (widthPx >= 640) return { ...base };
41
+ const t = Math.max(0, Math.min(1, (widthPx - 280) / (640 - 280)));
42
+ return {
43
+ top: compact ? base.top : Math.round(20 + t * (base.top - 20)),
44
+ right: compact ? base.right : Math.round(6 + t * (base.right - 6)),
45
+ bottom: compact ? base.bottom : Math.round(26 + t * (base.bottom - 26)),
46
+ left: compact
47
+ ? Math.round(26 + t * (base.left - 26))
48
+ : Math.round(40 + t * (base.left - 40)),
49
+ };
50
+ }
51
+
52
+ /** Match viewBox width to container aspect so fill-mode scaling stays uniform. */
53
+ function useChartLayout(
54
+ wrapRef: RefObject<HTMLDivElement | null>,
55
+ vbH: number,
56
+ compact: boolean,
57
+ ): { vbW: number; pad: ChartPad } {
58
+ const [layout, setLayout] = useState<{ vbW: number; pad: ChartPad }>(() => ({
59
+ vbW: W,
60
+ pad: compact ? PAD_COMPACT : PAD,
61
+ }));
62
+
63
+ useLayoutEffect(() => {
64
+ const el = wrapRef.current;
65
+ if (!el) return;
66
+
67
+ const update = () => {
68
+ const { width, height } = el.getBoundingClientRect();
69
+ if (width <= 0 || height <= 0) return;
70
+ const vbW = Math.max(280, Math.round((width / height) * vbH));
71
+ setLayout({
72
+ vbW,
73
+ pad: resolveChartPad(width, compact),
74
+ });
75
+ };
76
+
77
+ update();
78
+ const ro = new ResizeObserver(update);
79
+ ro.observe(el);
80
+ return () => ro.disconnect();
81
+ }, [wrapRef, vbH, compact]);
82
+
83
+ return layout;
84
+ }
85
+
86
+ /** Pointer position in viewBox coords. */
87
+ function viewBoxPoint(
88
+ clientX: number,
89
+ clientY: number,
90
+ svg: Element,
91
+ vbW: number,
92
+ vbH: number,
93
+ ): { x: number; y: number } {
94
+ const rect = svg.getBoundingClientRect();
95
+ if (rect.width === 0 || rect.height === 0) return { x: vbW / 2, y: vbH / 2 };
96
+ return {
97
+ x: ((clientX - rect.left) / rect.width) * vbW,
98
+ y: ((clientY - rect.top) / rect.height) * vbH,
99
+ };
100
+ }
101
+
102
+ interface ChartHoverTip {
103
+ x: number;
104
+ y: number;
105
+ flipX: boolean;
106
+ flipY: boolean;
107
+ content: ReactNode;
108
+ }
109
+
110
+ function positionHoverTip(
111
+ clientX: number,
112
+ clientY: number,
113
+ wrap: HTMLElement,
114
+ estW = 200,
115
+ estH = 96,
116
+ ): Pick<ChartHoverTip, "x" | "y" | "flipX" | "flipY"> {
117
+ const rect = wrap.getBoundingClientRect();
118
+ const pad = 12;
119
+ const relX = clientX - rect.left;
120
+ const relY = clientY - rect.top;
121
+ const flipX = relX + pad + estW > rect.width;
122
+ const flipY = relY - pad - estH < 0;
123
+ return {
124
+ x: relX,
125
+ y: relY,
126
+ flipX,
127
+ flipY,
128
+ };
129
+ }
130
+
131
+ function ChartHoverShell({
132
+ hover,
133
+ children,
134
+ wrapRef,
135
+ }: {
136
+ hover: ChartHoverTip | null;
137
+ children: ReactNode;
138
+ wrapRef?: RefObject<HTMLDivElement | null>;
139
+ }) {
140
+ return (
141
+ <div className="ts-chart-wrap" ref={wrapRef}>
142
+ {children}
143
+ {hover ? (
144
+ <div
145
+ className={
146
+ "ts-chart-tip" +
147
+ (hover.flipX ? " is-flip-x" : "") +
148
+ (hover.flipY ? " is-flip-y" : "")
149
+ }
150
+ style={{ left: hover.x, top: hover.y }}
151
+ role="tooltip"
152
+ >
153
+ {hover.content}
154
+ </div>
155
+ ) : null}
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function TradeTipContent({ trade, showPnlMoney = false }: { trade: TearsheetTrade; showPnlMoney?: boolean }) {
161
+ const open = isOpenTrade(trade);
162
+ const pnlTone = toneClass(trade.pnl_pct);
163
+ return (
164
+ <div className="ts-chart-tip-body">
165
+ <div className="ts-chart-tip-title">
166
+ <span className={`ts-dir ts-dir-${trade.direction}`}>{trade.direction}</span>
167
+ {trade.entry_label ? <span className="ts-chart-tip-signal">{trade.entry_label}</span> : null}
168
+ </div>
169
+ <dl className="ts-chart-tip-dl">
170
+ <div>
171
+ <dt>Entry</dt>
172
+ <dd>
173
+ {(trade.entry_date || "").slice(0, 10)} @ {fmtNum(trade.entry_price, 2)}
174
+ </dd>
175
+ </div>
176
+ <div>
177
+ <dt>Exit</dt>
178
+ <dd>
179
+ {open
180
+ ? "open"
181
+ : `${(trade.exit_date || "").slice(0, 10)} @ ${fmtNum(trade.exit_price, 2)}`}
182
+ </dd>
183
+ </div>
184
+ <div>
185
+ <dt>P&amp;L</dt>
186
+ <dd className={pnlTone}>
187
+ {fmtPct(trade.pnl_pct)}
188
+ {showPnlMoney ? ` · ${fmtMoney(trade.pnl)}` : null}
189
+ </dd>
190
+ </div>
191
+ </dl>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ function OhlcTipContent({ bar }: { bar: TearsheetOhlcBar }) {
197
+ return (
198
+ <dl className="ts-chart-tip-dl">
199
+ <div>
200
+ <dt>Date</dt>
201
+ <dd>{(bar.t || "").slice(0, 10)}</dd>
202
+ </div>
203
+ <div>
204
+ <dt>Open</dt>
205
+ <dd>{fmtNum(bar.o, 2)}</dd>
206
+ </div>
207
+ <div>
208
+ <dt>High</dt>
209
+ <dd>{fmtNum(bar.h, 2)}</dd>
210
+ </div>
211
+ <div>
212
+ <dt>Low</dt>
213
+ <dd>{fmtNum(bar.l, 2)}</dd>
214
+ </div>
215
+ <div>
216
+ <dt>Close</dt>
217
+ <dd>{fmtNum(bar.c, 2)}</dd>
218
+ </div>
219
+ </dl>
220
+ );
221
+ }
222
+
223
+ function SeriesTipContent({ date, value }: { date: string; value: string }) {
224
+ return (
225
+ <dl className="ts-chart-tip-dl">
226
+ <div>
227
+ <dt>Date</dt>
228
+ <dd>{date.slice(0, 10)}</dd>
229
+ </div>
230
+ <div>
231
+ <dt>Value</dt>
232
+ <dd>{value}</dd>
233
+ </div>
234
+ </dl>
235
+ );
236
+ }
237
+
238
+ function ContributionReturnTipContent({
239
+ point,
240
+ colors,
241
+ benchmark,
242
+ }: {
243
+ point: ContributionReturnPoint;
244
+ /** Per-series swatch colors — replaces a header legend, which does not scale past a handful of assets. */
245
+ colors?: Record<string, string>;
246
+ benchmark?: { label: string; value: number };
247
+ }) {
248
+ const contributions = Object.entries(point.contributions)
249
+ .filter(([, value]) => value !== 0)
250
+ .sort((left, right) => Math.abs(right[1]) - Math.abs(left[1]));
251
+ return (
252
+ <div className="ts-chart-tip-body">
253
+ <dl className="ts-chart-tip-dl">
254
+ <div><dt>Date</dt><dd>{point.t.slice(0, 10)}</dd></div>
255
+ <div><dt>Portfolio</dt><dd>{fmtPct(point.returnPct)}</dd></div>
256
+ {benchmark ? <div><dt>{benchmark.label}</dt><dd>{fmtPct(benchmark.value)}</dd></div> : null}
257
+ {contributions.map(([label, value]) => (
258
+ <div key={label}>
259
+ <dt>
260
+ {colors?.[label] ? (
261
+ <span
262
+ aria-hidden="true"
263
+ style={{
264
+ display: "inline-block",
265
+ width: "0.55em",
266
+ height: "0.55em",
267
+ marginRight: "0.45em",
268
+ backgroundColor: colors[label],
269
+ }}
270
+ />
271
+ ) : null}
272
+ {label}
273
+ </dt>
274
+ <dd>{fmtPct(value)}</dd>
275
+ </div>
276
+ ))}
277
+ </dl>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ interface MarkerHit {
283
+ x: number;
284
+ y: number;
285
+ role: "entry" | "exit";
286
+ trade: TearsheetTrade;
287
+ }
288
+
289
+ /** Nearest marker triangle; on reversal bars entry/exit share a bar — prefer entry. */
290
+ function hitMarker(vbX: number, vbY: number, markers: MarkerHit[], threshold = 20): MarkerHit | null {
291
+ let best: MarkerHit | null = null;
292
+ let bestD = threshold;
293
+ for (const m of markers) {
294
+ const d = Math.hypot(vbX - m.x, vbY - m.y);
295
+ if (d > bestD) continue;
296
+ if (
297
+ d < bestD ||
298
+ !best ||
299
+ (m.role === "entry" && best.role === "exit")
300
+ ) {
301
+ bestD = d;
302
+ best = m;
303
+ }
304
+ }
305
+ return best;
306
+ }
307
+
308
+ function nearestBarIndex(vbX: number, n: number, xAt: (i: number) => number, maxDist: number): number {
309
+ if (n <= 0) return -1;
310
+ if (n === 1) return 0;
311
+ let best = 0;
312
+ let bestD = Infinity;
313
+ for (let i = 0; i < n; i++) {
314
+ const d = Math.abs(vbX - xAt(i));
315
+ if (d < bestD) {
316
+ bestD = d;
317
+ best = i;
318
+ }
319
+ }
320
+ return bestD <= maxDist ? best : -1;
321
+ }
322
+
323
+ /** Default zoom: last calendar year of the shared span (readable on long backtests). */
324
+ export function viewWindowLastYear(fullSpan: [string, string] | undefined): ViewWindow {
325
+ return viewWindowForPreset("1y", fullSpan);
326
+ }
327
+
328
+ export type LookbackPreset = "1m" | "3m" | "6m" | "ytd" | "1y" | "3y" | "all";
329
+
330
+ export const LOOKBACK_OPTIONS: { value: LookbackPreset; label: string }[] = [
331
+ { value: "1m", label: "1M" },
332
+ { value: "3m", label: "3M" },
333
+ { value: "6m", label: "6M" },
334
+ { value: "ytd", label: "YTD" },
335
+ { value: "1y", label: "1Y" },
336
+ { value: "3y", label: "3Y" },
337
+ { value: "all", label: "All" },
338
+ ];
339
+
340
+ const MS_DAY = 24 * 3600 * 1000;
341
+
342
+ function spanEndpoints(fullSpan: [string, string]): { t0: number; t1: number; spanMs: number } | null {
343
+ const t0 = new Date(fullSpan[0]).getTime();
344
+ const t1 = new Date(fullSpan[1]).getTime();
345
+ const spanMs = t1 - t0;
346
+ if (!Number.isFinite(spanMs) || spanMs <= 0) return null;
347
+ return { t0, t1, spanMs };
348
+ }
349
+
350
+ /** Map a lookback preset to a normalized [lo, hi] window over `fullSpan`. */
351
+ export function viewWindowForPreset(
352
+ preset: LookbackPreset,
353
+ fullSpan: [string, string] | undefined,
354
+ ): ViewWindow {
355
+ if (!fullSpan || preset === "all") return { lo: 0, hi: 1 };
356
+ const span = spanEndpoints(fullSpan);
357
+ if (!span) return { lo: 0, hi: 1 };
358
+
359
+ const { t0, t1, spanMs } = span;
360
+ let startMs: number;
361
+ switch (preset) {
362
+ case "1m":
363
+ startMs = t1 - 30 * MS_DAY;
364
+ break;
365
+ case "3m":
366
+ startMs = t1 - 91.25 * MS_DAY;
367
+ break;
368
+ case "6m":
369
+ startMs = t1 - 182.625 * MS_DAY;
370
+ break;
371
+ case "ytd": {
372
+ const end = new Date(fullSpan[1]);
373
+ startMs = Date.UTC(end.getUTCFullYear(), 0, 1);
374
+ break;
375
+ }
376
+ case "1y":
377
+ startMs = t1 - 365.25 * MS_DAY;
378
+ break;
379
+ case "3y":
380
+ startMs = t1 - 3 * 365.25 * MS_DAY;
381
+ break;
382
+ default: {
383
+ const _exhaustive: never = preset;
384
+ return _exhaustive;
385
+ }
386
+ }
387
+ const lo = Math.max(0, (startMs - t0) / spanMs);
388
+ return clampView(lo, 1);
389
+ }
390
+
391
+ const VIEW_EPS = 0.002;
392
+
393
+ /** True when two normalized windows are effectively the same. */
394
+ export function viewsNear(a: ViewWindow, b: ViewWindow): boolean {
395
+ return Math.abs(a.lo - b.lo) < VIEW_EPS && Math.abs(a.hi - b.hi) < VIEW_EPS;
396
+ }
397
+
398
+ /** Which preset matches this window, if any. */
399
+ export function matchLookbackPreset(
400
+ view: ViewWindow,
401
+ fullSpan: [string, string] | undefined,
402
+ ): LookbackPreset | null {
403
+ if (!fullSpan) return null;
404
+ for (const { value } of LOOKBACK_OPTIONS) {
405
+ if (viewsNear(view, viewWindowForPreset(value, fullSpan))) return value;
406
+ }
407
+ return null;
408
+ }
409
+
410
+ /** Tight y-domain in transformed scale space with optional zero anchor. */
411
+ function dataDomain(
412
+ values: number[],
413
+ scale: { f: (v: number) => number },
414
+ opts: { padRatio?: number; anchorZero?: "min" | "max" },
415
+ ): { lo: number; hi: number } {
416
+ let lo = Infinity;
417
+ let hi = -Infinity;
418
+ for (const v of values) {
419
+ const y = scale.f(v);
420
+ if (y < lo) lo = y;
421
+ if (y > hi) hi = y;
422
+ }
423
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { lo: 0, hi: 1 };
424
+ if (opts.anchorZero === "min") lo = Math.min(lo, scale.f(0));
425
+ if (opts.anchorZero === "max") hi = Math.max(hi, scale.f(0));
426
+ if (lo === hi) hi = lo + 1;
427
+ const pad = (hi - lo) * (opts.padRatio ?? 0.05);
428
+ return { lo: lo - pad, hi: hi + pad };
429
+ }
430
+
431
+ export type ChartLegendKind =
432
+ | "line"
433
+ | "line-up"
434
+ | "line-down"
435
+ | "line-mute"
436
+ | "line-dashed"
437
+ | "bar-up"
438
+ | "bar-down"
439
+ | "bar-open"
440
+ | "marker-buy"
441
+ | "marker-sell";
442
+
443
+ /** Compact HTML legend for panel headers (top-right, beside controls). */
444
+ export function ChartLegend({
445
+ items,
446
+ }: {
447
+ items: { kind: ChartLegendKind; label: string }[];
448
+ }) {
449
+ return (
450
+ // Not aria-hidden: these labels ("long"/"short"/"Realized %"/etc.) are
451
+ // the one place the chart's visual encoding is spelled out in words —
452
+ // hiding them left the charts' role="img" panes with no way for a
453
+ // screen-reader user to learn what a line/bar/marker color means.
454
+ <div className="ts-chart-legend">
455
+ {items.map((it) => (
456
+ <span className="ts-chart-legend-item" key={it.label}>
457
+ <span className={`ts-chart-legend-swatch ts-chart-legend-${it.kind}`} />
458
+ <span>{it.label}</span>
459
+ </span>
460
+ ))}
461
+ </div>
462
+ );
463
+ }
464
+
465
+ function axisLabelY(y: number, plotTop: number, plotBottom: number): number {
466
+ return Math.max(plotTop + 11, Math.min(plotBottom - 2, y + 4));
467
+ }
468
+
469
+ export type ChartScale = "linear" | "log" | "symlog";
470
+ export type ChartTone = "accent" | "up" | "down";
471
+ /** Overlay lines may also use the muted ink (rails, lump/flat benchmarks). */
472
+ export type OverlayTone = ChartTone | "mute";
473
+
474
+ /**
475
+ * Reference overlays for TimeSeries / MultiTimeSeries (Q3b slice 5, #4443).
476
+ * Published levels, conviction bands and event anchors that are data, not
477
+ * decoration — every consumer pairs them with a text caption carrying the
478
+ * same facts so the chart stays legible without pixels.
479
+ */
480
+ export type ReferenceTone = "accent" | "up" | "down" | "warn" | "mute";
481
+ export interface ReferenceLineSpec {
482
+ value: number;
483
+ tone?: ReferenceTone;
484
+ /** Dashed by default (all current consumers dash); pass false for solid. */
485
+ dashed?: boolean;
486
+ label?: string;
487
+ }
488
+ export interface ReferenceBandSpec {
489
+ from: number;
490
+ to: number;
491
+ tone?: ReferenceTone;
492
+ }
493
+ export interface ReferenceMarkerSpec {
494
+ /** ISO date — snaps to the nearest plotted date. */
495
+ t: string;
496
+ v: number;
497
+ tone?: ReferenceTone;
498
+ label?: string;
499
+ }
500
+ export interface ChartReferences {
501
+ lines?: ReferenceLineSpec[];
502
+ bands?: ReferenceBandSpec[];
503
+ markers?: ReferenceMarkerSpec[];
504
+ }
505
+
506
+ /** A normalized x-domain window (fractions 0..1 over a chart's full date span). */
507
+ export interface ViewWindow {
508
+ lo: number;
509
+ hi: number;
510
+ }
511
+ /**
512
+ * Smallest allowed window (2% of the span) — keeps zoom from collapsing into
513
+ * an empty/degenerate range that blanks the chart (legacy ComboPnl bug #1180;
514
+ * the SVG tearsheet grammar owns that surface after the digiquant-web → digiweb
515
+ * promotion).
516
+ */
517
+ export const MIN_VIEW = 0.02;
518
+
519
+ /** Clamp a zoom window into `[0,1]` and enforce {@link MIN_VIEW} width. */
520
+ export function clampView(lo: number, hi: number): ViewWindow {
521
+ let l = Math.max(0, Math.min(lo, 1));
522
+ let h = Math.max(0, Math.min(hi, 1));
523
+ if (h - l < MIN_VIEW) {
524
+ // Re-expand around the window centre, then re-clamp into [0,1].
525
+ const mid = (l + h) / 2;
526
+ l = Math.max(0, mid - MIN_VIEW / 2);
527
+ h = Math.min(1, l + MIN_VIEW);
528
+ l = Math.max(0, h - MIN_VIEW);
529
+ }
530
+ return { lo: l, hi: h };
531
+ }
532
+
533
+ /** Icon-only reset zoom — floats over the chart area (top-right). */
534
+ export function ChartResetButton({ onClick }: { onClick: () => void }) {
535
+ return (
536
+ <button
537
+ type="button"
538
+ className="ts-reset ts-reset-chart"
539
+ onClick={onClick}
540
+ aria-label="Reset zoom to selected time range"
541
+ >
542
+ <svg
543
+ viewBox="0 0 24 24"
544
+ width="15"
545
+ height="15"
546
+ fill="none"
547
+ stroke="currentColor"
548
+ strokeWidth="2"
549
+ strokeLinecap="round"
550
+ strokeLinejoin="round"
551
+ aria-hidden="true"
552
+ >
553
+ <path d="M8 3H5a2 2 0 0 0-2 2v3" />
554
+ <path d="M16 3h3a2 2 0 0 1 2 2v3" />
555
+ <path d="M8 21H5a2 2 0 0 1-2-2v-3" />
556
+ <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
557
+ </svg>
558
+ </button>
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Generic segmented-button toggle. Real <button>s with aria-pressed inside a
564
+ * labelled group — accessible, theme-aware (active = accent). Used for the equity
565
+ * scale, the cumulative-P&L scale, and the returns-matrix period selector.
566
+ */
567
+ export function SegToggle<T extends string>({
568
+ value,
569
+ options,
570
+ onChange,
571
+ label,
572
+ className,
573
+ }: {
574
+ value: T;
575
+ options: { value: T; label: string }[];
576
+ onChange: (v: T) => void;
577
+ label: string;
578
+ className?: string;
579
+ }) {
580
+ return (
581
+ <div className={"ts-seg" + (className ? ` ${className}` : "")} role="group" aria-label={label}>
582
+ {options.map((o) => (
583
+ <button
584
+ key={o.value}
585
+ type="button"
586
+ className={"ts-seg-btn" + (o.value === value ? " is-active" : "")}
587
+ aria-pressed={o.value === value}
588
+ onClick={() => onChange(o.value)}
589
+ >
590
+ {o.label}
591
+ </button>
592
+ ))}
593
+ </div>
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Translate a pointer event over the chart SVG into a fraction (0..1) across the
599
+ * plot area (inside the left/right insets).
600
+ */
601
+ function plotFraction(
602
+ clientX: number,
603
+ target: Element,
604
+ padLeft: number,
605
+ padRight: number,
606
+ vbW: number,
607
+ ): number {
608
+ const rect = target.getBoundingClientRect();
609
+ if (rect.width === 0) return 0.5;
610
+ const x = ((clientX - rect.left) / rect.width) * vbW;
611
+ const plotW = vbW - padLeft - padRight;
612
+ if (plotW <= 0) return 0.5;
613
+ return Math.max(0, Math.min(1, (x - padLeft) / plotW));
614
+ }
615
+
616
+ /** What a view-controlled chart needs to drive zoom/pan; null ⇒ static chart. */
617
+ interface ViewControl {
618
+ /** plot-area right inset (differs per chart). */
619
+ padRight: number;
620
+ /** wheel zoom, centred on cursor clientX, against the chart's own width. */
621
+ onWheel: (clientX: number, deltaY: number, target: Element) => void;
622
+ /** Drag-pan start — Pointer Events (not mouse-only), so a single-finger
623
+ * touch drag pans the same as a mouse drag. Pinch-zoom has no wheel
624
+ * equivalent and is not implemented (see the P2 note in charts.tsx's
625
+ * header comment). */
626
+ onPointerDown: (e: React.PointerEvent<SVGSVGElement>) => void;
627
+ onDoubleClick: () => void;
628
+ }
629
+
630
+ /**
631
+ * Which pointerId currently owns an active drag on a given chart <svg> —
632
+ * keyed by the DOM element itself (not module-global) so independent chart
633
+ * instances never contend with each other, and a re-render's fresh
634
+ * viewHandlers() closure still sees a drag its previous closure started.
635
+ * A single mouse can never produce two concurrent "down" states, but a
636
+ * touchscreen can (two fingers, or a fast second tap while the first is
637
+ * still held) — without this guard a second pointerdown starts a SECOND
638
+ * independent drag session with its own startX/lo/hi snapshot, and the two
639
+ * sessions' onView(...) calls overwrite each other every other frame,
640
+ * visibly thrashing the shared view.
641
+ */
642
+ const activeDragPointer = new WeakMap<Element, number>();
643
+
644
+ /**
645
+ * Build the shared wheel / drag / double-click control for a ViewWindow. Returns
646
+ * null (static chart) when not view-controlled, so the same component renders
647
+ * statically wherever `view`/`onView` are omitted.
648
+ */
649
+ function viewHandlers(
650
+ view: ViewWindow | undefined,
651
+ onView: ((v: ViewWindow) => void) | undefined,
652
+ pad: Pick<ChartPad, "left" | "right">,
653
+ vbW: number,
654
+ resetTo?: ViewWindow,
655
+ ): ViewControl | null {
656
+ if (!view || !onView) return null;
657
+ const { lo, hi } = view;
658
+ const resetView = resetTo ?? { lo: 0, hi: 1 };
659
+
660
+ const onWheel = (clientX: number, deltaY: number, target: Element) => {
661
+ const span = hi - lo;
662
+ const cursor = lo + plotFraction(clientX, target, pad.left, pad.right, vbW) * span;
663
+ // Wheel up (deltaY < 0) zooms in; down zooms out. Centred on the cursor.
664
+ const factor = Math.exp(deltaY * 0.0011);
665
+ const nlo = cursor - (cursor - lo) * factor;
666
+ const nhi = cursor + (hi - cursor) * factor;
667
+ onView(clampView(nlo, nhi));
668
+ };
669
+
670
+ // Pointer Events (not mouse-only): the same handler drives mouse drag AND
671
+ // single-finger touch drag, so drag-pan works on the mobile breakpoint
672
+ // this family's own CSS already ships for these panes. setPointerCapture
673
+ // keeps move/up routed to this element even if the finger/cursor leaves
674
+ // its bounds mid-drag (load-bearing for touch, where a real finger drifts
675
+ // off the SVG far more easily than a mouse does).
676
+ const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
677
+ const el = e.currentTarget;
678
+ // A drag is already active on this chart (a second finger, or a fast
679
+ // second tap while the first is still held) — ignore it rather than
680
+ // starting a competing session against the same shared view.
681
+ if (activeDragPointer.has(el)) return;
682
+ e.preventDefault();
683
+ activeDragPointer.set(el, e.pointerId);
684
+ el.setPointerCapture(e.pointerId);
685
+ const startX = e.clientX;
686
+ const span = hi - lo;
687
+ const rect = el.getBoundingClientRect();
688
+ const plotPxW = rect.width * ((vbW - pad.left - pad.right) / vbW);
689
+ const move = (me: PointerEvent) => {
690
+ if (me.pointerId !== e.pointerId) return;
691
+ if (plotPxW === 0) return;
692
+ // Drag right ⇒ window shifts left (content follows the cursor). Clamp the
693
+ // shift so the window TRANSLATES (keeps its width) against the [0,1] edges
694
+ // instead of narrowing — true pan, not zoom, at the boundary.
695
+ let dFrac = ((me.clientX - startX) / plotPxW) * span;
696
+ dFrac = Math.max(hi - 1, Math.min(lo, dFrac));
697
+ onView(clampView(lo - dFrac, hi - dFrac));
698
+ };
699
+ const up = (ue: PointerEvent) => {
700
+ if (ue.pointerId !== e.pointerId) return;
701
+ activeDragPointer.delete(el);
702
+ window.removeEventListener("pointermove", move);
703
+ window.removeEventListener("pointerup", up);
704
+ window.removeEventListener("pointercancel", up);
705
+ };
706
+ window.addEventListener("pointermove", move);
707
+ window.addEventListener("pointerup", up);
708
+ window.addEventListener("pointercancel", up);
709
+ };
710
+
711
+ const onDoubleClick = () => onView(resetView);
712
+
713
+ return { padRight: pad.right, onWheel, onPointerDown, onDoubleClick };
714
+ }
715
+
716
+ function makeScale(kind: ChartScale) {
717
+ if (kind === "log") {
718
+ return { f: (v: number) => Math.log10(Math.max(v, 1e-9)), inv: (y: number) => Math.pow(10, y) };
719
+ }
720
+ if (kind === "symlog") {
721
+ return {
722
+ f: (v: number) => Math.sign(v) * Math.log10(1 + Math.abs(v)),
723
+ inv: (y: number) => Math.sign(y) * (Math.pow(10, Math.abs(y)) - 1),
724
+ };
725
+ }
726
+ return { f: (v: number) => v, inv: (y: number) => y };
727
+ }
728
+
729
+ function niceLinearTicks(min: number, max: number, count: number): number[] {
730
+ if (min === max) return [min];
731
+ const span = max - min;
732
+ const step0 = Math.pow(10, Math.floor(Math.log10(span / count)));
733
+ const e = span / count / step0;
734
+ const step = e >= 7.5 ? step0 * 10 : e >= 3 ? step0 * 5 : e >= 1.5 ? step0 * 2 : step0;
735
+ const out: number[] = [];
736
+ for (let v = Math.ceil(min / step) * step; v <= max + step * 0.5; v += step) out.push(v);
737
+ return out;
738
+ }
739
+
740
+ /** Log-scale ticks with ~targetCount labels between min and max (not just decades). */
741
+ function logTicks(realLo: number, realHi: number, targetCount = 6): number[] {
742
+ const lo = Math.max(realLo, 1e-12);
743
+ const hi = Math.max(realHi, lo * 1.001);
744
+ const loLog = Math.log10(lo);
745
+ const hiLog = Math.log10(hi);
746
+ const span = hiLog - loLog;
747
+ if (!Number.isFinite(span) || span <= 0) return [lo, hi];
748
+
749
+ const roughStep = span / Math.max(2, targetCount - 1);
750
+ const niceSteps = [0.1, 0.2, 0.3, 0.5, 1.0, 2.0];
751
+ let logStep = niceSteps[niceSteps.length - 1]!;
752
+ for (const s of niceSteps) {
753
+ if (s >= roughStep - 1e-9) {
754
+ logStep = s;
755
+ break;
756
+ }
757
+ }
758
+
759
+ const ticks: number[] = [];
760
+ const start = Math.floor(loLog / logStep) * logStep;
761
+ for (let lk = start; lk <= hiLog + logStep * 0.001; lk += logStep) {
762
+ const v = Math.pow(10, lk);
763
+ if (v + 1e-9 >= lo && v - 1e-9 <= hi) ticks.push(v);
764
+ }
765
+ return ticks.length >= 2 ? ticks : [lo, hi];
766
+ }
767
+
768
+ // Decade ticks for symlog, in real (untransformed) space.
769
+ function decadeTicks(kind: ChartScale, realLo: number, realHi: number): number[] {
770
+ const ticks: number[] = [];
771
+ const maxAbs = Math.max(Math.abs(realLo), Math.abs(realHi));
772
+ if (maxAbs <= 0) return [0];
773
+ const topK = Math.ceil(Math.log10(maxAbs));
774
+ if (kind === "symlog") {
775
+ ticks.push(0);
776
+ for (let k = 1; k <= topK; k++) {
777
+ const v = Math.pow(10, k);
778
+ if (v <= realHi) ticks.push(v);
779
+ if (-v >= realLo) ticks.push(-v);
780
+ }
781
+ } else {
782
+ const botK = Math.floor(Math.log10(Math.max(realLo, 1e-9)));
783
+ for (let k = botK; k <= topK; k++) {
784
+ const v = Math.pow(10, k);
785
+ if (v >= realLo * 0.999 && v <= realHi * 1.001) ticks.push(v);
786
+ }
787
+ }
788
+ return ticks.length ? ticks : [realLo, realHi];
789
+ }
790
+
791
+ function normalizeWheelDelta(e: WheelEvent): number {
792
+ let dy = e.deltaY;
793
+ if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) dy *= 16;
794
+ else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) dy *= window.innerHeight;
795
+ return dy;
796
+ }
797
+
798
+ function Svg({
799
+ height,
800
+ vbW = W,
801
+ children,
802
+ control,
803
+ onMouseMove,
804
+ onMouseLeave,
805
+ preserveAspectRatio = "none",
806
+ ariaLabel,
807
+ }: {
808
+ height: number;
809
+ vbW?: number;
810
+ children: ReactNode;
811
+ control?: ViewControl | null;
812
+ onMouseMove?: (e: React.MouseEvent<SVGSVGElement>) => void;
813
+ onMouseLeave?: (e: React.MouseEvent<SVGSVGElement>) => void;
814
+ preserveAspectRatio?: string;
815
+ /** role="img" needs an accessible name or it announces as a bare, unnamed
816
+ * image — every chart consumer must pass one describing the series. */
817
+ ariaLabel: string;
818
+ }) {
819
+ const ref = useRef<SVGSVGElement>(null);
820
+ const controlRef = useRef(control);
821
+ const wheelAccumRef = useRef<{ clientX: number; deltaY: number } | null>(null);
822
+ const wheelRafRef = useRef<number | null>(null);
823
+
824
+ useEffect(() => {
825
+ controlRef.current = control;
826
+ }, [control]);
827
+
828
+ useEffect(() => {
829
+ const el = ref.current;
830
+ if (!el) return;
831
+
832
+ const flushWheel = () => {
833
+ wheelRafRef.current = null;
834
+ const c = controlRef.current;
835
+ const accum = wheelAccumRef.current;
836
+ wheelAccumRef.current = null;
837
+ if (!c || !accum) return;
838
+ c.onWheel(accum.clientX, accum.deltaY, el);
839
+ };
840
+
841
+ const handler = (e: WheelEvent) => {
842
+ if (!controlRef.current) return;
843
+ e.preventDefault();
844
+
845
+ const dy = normalizeWheelDelta(e);
846
+ if (Math.abs(e.deltaX) > Math.abs(dy) * 1.25) return;
847
+
848
+ if (wheelAccumRef.current) {
849
+ wheelAccumRef.current.deltaY += dy;
850
+ wheelAccumRef.current.clientX = e.clientX;
851
+ } else {
852
+ wheelAccumRef.current = { clientX: e.clientX, deltaY: dy };
853
+ }
854
+ if (wheelRafRef.current === null) {
855
+ wheelRafRef.current = requestAnimationFrame(flushWheel);
856
+ }
857
+ };
858
+
859
+ el.addEventListener("wheel", handler, { passive: false });
860
+ return () => {
861
+ el.removeEventListener("wheel", handler);
862
+ if (wheelRafRef.current !== null) cancelAnimationFrame(wheelRafRef.current);
863
+ };
864
+ }, []);
865
+
866
+ return (
867
+ <svg
868
+ ref={ref}
869
+ viewBox={`0 0 ${vbW} ${height}`}
870
+ preserveAspectRatio={preserveAspectRatio}
871
+ className={"ts-svg" + (control ? " is-interactive" : "")}
872
+ role="img"
873
+ aria-label={ariaLabel}
874
+ onPointerDown={control ? control.onPointerDown : undefined}
875
+ onDoubleClick={control ? control.onDoubleClick : undefined}
876
+ onMouseMove={onMouseMove}
877
+ onMouseLeave={onMouseLeave}
878
+ >
879
+ {children}
880
+ </svg>
881
+ );
882
+ }
883
+
884
+ function Empty({ height, msg, vbW = W }: { height: number; msg: string; vbW?: number }) {
885
+ return (
886
+ <Svg height={height} vbW={vbW} ariaLabel={msg}>
887
+ <text x={vbW / 2} y={height / 2} textAnchor="middle" className="ts-svg-empty">
888
+ {msg}
889
+ </text>
890
+ </Svg>
891
+ );
892
+ }
893
+
894
+ export interface TimeSeriesProps {
895
+ points: TearsheetSeriesPoint[];
896
+ height?: number;
897
+ scale?: ChartScale;
898
+ tone?: ChartTone;
899
+ fmt?: (v: number) => string;
900
+ zeroBaseline?: boolean;
901
+ /** Shared normalized x-window (date span fraction). Omit ⇒ full range, static. */
902
+ view?: ViewWindow;
903
+ /** Notified on wheel-zoom / drag-pan / double-click reset. */
904
+ onView?: (v: ViewWindow) => void;
905
+ /** Full date span [firstISO, lastISO] for the *whole* series; defaults to the
906
+ * series' own endpoints. Pass the shared span so every chart maps the same
907
+ * fraction to the same calendar window even if point counts differ. */
908
+ fullSpan?: [string, string];
909
+ /** Double-click / internal reset target (defaults to full range). */
910
+ resetView?: ViewWindow;
911
+ /** When false, omit hover tooltips (static print-first panes). */
912
+ interactive?: boolean;
913
+ /**
914
+ * Reference overlays: horizontal levels, shaded bands, date-anchored dots.
915
+ * Rendered beneath the series paths; out-of-domain values clamp to the plot.
916
+ */
917
+ references?: ChartReferences;
918
+ /** Explicit y-domain [lo, hi] (data units). Omit ⇒ derived from the data. */
919
+ domain?: [number, number];
920
+ /** Accessible name for the chart (role="img" has none without it) — should
921
+ * summarize the series, e.g. "Equity curve, percent return, linear scale,
922
+ * Jan 2023 to Feb 2025". */
923
+ ariaLabel: string;
924
+ }
925
+
926
+ /** Nearest plotted date index for a reference marker's ISO date. */
927
+ function nearestDateIndex(dates: string[], t: string): number {
928
+ const target = Date.parse(t);
929
+ if (!Number.isFinite(target) || dates.length === 0) return -1;
930
+ let best = 0;
931
+ let bestDist = Infinity;
932
+ for (let i = 0; i < dates.length; i++) {
933
+ const d = Date.parse(dates[i]);
934
+ if (!Number.isFinite(d)) continue;
935
+ const dist = Math.abs(d - target);
936
+ if (dist < bestDist) {
937
+ bestDist = dist;
938
+ best = i;
939
+ }
940
+ }
941
+ return bestDist === Infinity ? -1 : best;
942
+ }
943
+
944
+ /**
945
+ * Reference bands (behind), lines and date-anchored dots for the two series
946
+ * panes. Render the result before the series paths so bands sit beneath them.
947
+ */
948
+ function referenceEls(args: {
949
+ dates: string[];
950
+ xAt: (i: number) => number;
951
+ yAt: (v: number) => number;
952
+ plotLeft: number;
953
+ plotWidth: number;
954
+ plotTop: number;
955
+ plotBottom: number;
956
+ references: ChartReferences | undefined;
957
+ }): ReactNode[] {
958
+ const { references } = args;
959
+ if (!references) return [];
960
+ const els: ReactNode[] = [];
961
+ const clampY = (y: number) => Math.max(args.plotTop, Math.min(args.plotBottom, y));
962
+ (references.bands ?? []).forEach((b, i) => {
963
+ const y1 = clampY(args.yAt(b.from));
964
+ const y2 = clampY(args.yAt(b.to));
965
+ els.push(
966
+ <rect
967
+ key={`ref-band-${i}`}
968
+ x={args.plotLeft}
969
+ y={Math.min(y1, y2)}
970
+ width={args.plotWidth}
971
+ height={Math.max(1, Math.abs(y2 - y1))}
972
+ className={`ts-ref-band ts-ref-band-${b.tone ?? "accent"}`}
973
+ data-chart-layer="reference-band"
974
+ />,
975
+ );
976
+ });
977
+ (references.lines ?? []).forEach((l, i) => {
978
+ const y = clampY(args.yAt(l.value));
979
+ els.push(
980
+ <line
981
+ key={`ref-line-${i}`}
982
+ x1={args.plotLeft}
983
+ y1={y}
984
+ x2={args.plotLeft + args.plotWidth}
985
+ y2={y}
986
+ className={`ts-ref-line ts-ref-tone-${l.tone ?? "mute"}${l.dashed === false ? "" : " ts-ref-dashed"}`}
987
+ data-chart-layer="reference-line"
988
+ >
989
+ {l.label ? <title>{l.label}</title> : null}
990
+ </line>,
991
+ );
992
+ });
993
+ (references.markers ?? []).forEach((m, i) => {
994
+ const idx = nearestDateIndex(args.dates, m.t);
995
+ if (idx < 0) return;
996
+ els.push(
997
+ <circle
998
+ key={`ref-marker-${i}`}
999
+ cx={args.xAt(idx)}
1000
+ cy={clampY(args.yAt(m.v))}
1001
+ r={3.5}
1002
+ className={`ts-ref-dot ts-ref-dot-${m.tone ?? "accent"}`}
1003
+ data-chart-layer="reference-marker"
1004
+ >
1005
+ {m.label ? <title>{m.label}</title> : null}
1006
+ </circle>,
1007
+ );
1008
+ });
1009
+ return els;
1010
+ }
1011
+
1012
+ /**
1013
+ * Slice points to a normalized fraction window over a shared date span. The
1014
+ * fraction is mapped to a calendar window [t(lo), t(hi)] (linear in time across
1015
+ * the span), then points whose date falls inside are kept — so two series that
1016
+ * share a span stay locked to the same calendar window regardless of sampling.
1017
+ */
1018
+ export function sliceByView(
1019
+ points: TearsheetSeriesPoint[],
1020
+ view: ViewWindow | undefined,
1021
+ fullSpan: [string, string] | undefined,
1022
+ ): TearsheetSeriesPoint[] {
1023
+ if (!view || (view.lo <= 0 && view.hi >= 1) || points.length === 0) return points;
1024
+ const t0 = new Date((fullSpan ? fullSpan[0] : points[0].t)).getTime();
1025
+ const t1 = new Date((fullSpan ? fullSpan[1] : points[points.length - 1].t)).getTime();
1026
+ const span = t1 - t0;
1027
+ if (span <= 0) return points;
1028
+ const loT = t0 + view.lo * span;
1029
+ const hiT = t0 + view.hi * span;
1030
+ const out = points.filter((p) => {
1031
+ const t = new Date(p.t).getTime();
1032
+ return t >= loT && t <= hiT;
1033
+ });
1034
+ // Guarantee at least a couple of points so the path/area still draws.
1035
+ if (out.length >= 2) return out;
1036
+ const mid = (loT + hiT) / 2;
1037
+ let nearest = 0;
1038
+ for (let i = 1; i < points.length; i++) {
1039
+ if (Math.abs(new Date(points[i].t).getTime() - mid) < Math.abs(new Date(points[nearest].t).getTime() - mid)) nearest = i;
1040
+ }
1041
+ return points.slice(Math.max(0, nearest - 1), Math.min(points.length, nearest + 1));
1042
+ }
1043
+
1044
+ /** Slice OHLC bars to the same calendar window as ``sliceByView``. */
1045
+ function sliceBarsByView(
1046
+ bars: TearsheetOhlcBar[],
1047
+ view: ViewWindow | undefined,
1048
+ fullSpan: [string, string] | undefined,
1049
+ ): TearsheetOhlcBar[] {
1050
+ if (!view || (view.lo <= 0 && view.hi >= 1) || bars.length === 0) return bars;
1051
+ const t0 = new Date((fullSpan ? fullSpan[0] : bars[0].t)).getTime();
1052
+ const t1 = new Date((fullSpan ? fullSpan[1] : bars[bars.length - 1].t)).getTime();
1053
+ const span = t1 - t0;
1054
+ if (span <= 0) return bars;
1055
+ const loT = t0 + view.lo * span;
1056
+ const hiT = t0 + view.hi * span;
1057
+ const out = bars.filter((b) => {
1058
+ const t = new Date(b.t).getTime();
1059
+ return t >= loT && t <= hiT;
1060
+ });
1061
+ if (out.length >= 2) return out;
1062
+ const mid = (loT + hiT) / 2;
1063
+ let nearest = 0;
1064
+ for (let i = 1; i < bars.length; i++) {
1065
+ if (Math.abs(new Date(bars[i].t).getTime() - mid) < Math.abs(new Date(bars[nearest].t).getTime() - mid)) {
1066
+ nearest = i;
1067
+ }
1068
+ }
1069
+ return bars.slice(Math.max(0, nearest - 1), Math.min(bars.length, nearest + 1));
1070
+ }
1071
+
1072
+ function barIndexForDate(bars: TearsheetOhlcBar[], iso: string): number {
1073
+ if (!iso) return -1;
1074
+ const exact = bars.findIndex((b) => b.t === iso);
1075
+ if (exact >= 0) return exact;
1076
+ const target = new Date(iso).getTime();
1077
+ if (Number.isNaN(target)) return -1;
1078
+ let best = 0;
1079
+ let bestD = Infinity;
1080
+ for (let i = 0; i < bars.length; i++) {
1081
+ const d = Math.abs(new Date(bars[i].t).getTime() - target);
1082
+ if (d < bestD) {
1083
+ bestD = d;
1084
+ best = i;
1085
+ }
1086
+ }
1087
+ return best;
1088
+ }
1089
+
1090
+ export interface CandlestickChartProps {
1091
+ bars: TearsheetOhlcBar[];
1092
+ trades: TearsheetTrade[];
1093
+ height?: number;
1094
+ scale?: ChartScale;
1095
+ view?: ViewWindow;
1096
+ onView?: (v: ViewWindow) => void;
1097
+ fullSpan?: [string, string];
1098
+ resetView?: ViewWindow;
1099
+ /** When false, omit hover tooltips (e.g. homepage preview cards). */
1100
+ interactive?: boolean;
1101
+ /** Tighter plot padding for compact preview cards. */
1102
+ compact?: boolean;
1103
+ /** Accessible name for the chart (role="img" has none without it) — should
1104
+ * summarize the series, e.g. "BTC-USD candlestick price, linear scale,
1105
+ * with trade entry/exit markers, Jan 2023 to Feb 2025". */
1106
+ ariaLabel: string;
1107
+ }
1108
+
1109
+ /**
1110
+ * Candlestick price chart with TradingView-style entry/exit markers.
1111
+ * Long entry = buy arrow below; short entry = sell arrow above; exits flip.
1112
+ */
1113
+ export function CandlestickChart(props: CandlestickChartProps) {
1114
+ const { bars: allBars, height = 380 } = props;
1115
+ if (!allBars || allBars.length === 0) return <Empty height={height} msg="no price data" />;
1116
+ return <CandlestickChartBody {...props} bars={allBars} height={height} />;
1117
+ }
1118
+
1119
+ function CandlestickChartBody({
1120
+ bars: allBars,
1121
+ trades,
1122
+ height,
1123
+ scale: scaleKind = "linear",
1124
+ view,
1125
+ onView,
1126
+ fullSpan,
1127
+ resetView,
1128
+ interactive = true,
1129
+ compact = false,
1130
+ ariaLabel,
1131
+ }: CandlestickChartProps & { bars: TearsheetOhlcBar[]; height: number }) {
1132
+ const wrapRef = useRef<HTMLDivElement>(null);
1133
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
1134
+ const { vbW, pad } = useChartLayout(wrapRef, height, compact);
1135
+ const bars = sliceBarsByView(allBars, view, fullSpan);
1136
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
1137
+ const scale = makeScale(scaleKind);
1138
+ const plotW = vbW - pad.left - pad.right;
1139
+ const plotH = height - pad.top - pad.bottom;
1140
+ const plotTop = pad.top;
1141
+ const plotBottom = pad.top + plotH;
1142
+
1143
+ const t0 = new Date((fullSpan ? fullSpan[0] : bars[0].t)).getTime();
1144
+ const t1 = new Date((fullSpan ? fullSpan[1] : bars[bars.length - 1].t)).getTime();
1145
+ const winLo = view && !(view.lo <= 0 && view.hi >= 1) && t1 > t0 ? t0 + view.lo * (t1 - t0) : t0;
1146
+ const winHi = view && !(view.lo <= 0 && view.hi >= 1) && t1 > t0 ? t0 + view.hi * (t1 - t0) : t1;
1147
+
1148
+ const inWin = (iso: string) => {
1149
+ if (!iso) return false;
1150
+ const t = new Date(iso).getTime();
1151
+ return t >= winLo && t <= winHi;
1152
+ };
1153
+
1154
+ const priceVals: number[] = [];
1155
+ for (const b of bars) {
1156
+ priceVals.push(b.l, b.h);
1157
+ }
1158
+ for (const t of trades) {
1159
+ if (!inWin(t.entry_date) && !( !isOpenTrade(t) && inWin(t.exit_date))) continue;
1160
+ if (t.entry_price > 0) priceVals.push(t.entry_price);
1161
+ if (!isOpenTrade(t) && t.exit_price > 0) priceVals.push(t.exit_price);
1162
+ }
1163
+
1164
+ const { lo: loF, hi: hiF } = dataDomain(priceVals, scale, { padRatio: 0.04 });
1165
+
1166
+ const n = bars.length;
1167
+
1168
+ const slotEst = n > 1 ? plotW / (n - 1) : plotW;
1169
+ const bodyEst = Math.max(1.2, Math.min(slotEst * 0.65, 10));
1170
+ // Inset candles from the right edge so the latest bar (and markers) are not clipped.
1171
+ const rightGutter = compact
1172
+ ? Math.max(bodyEst * 0.45, plotW * 0.015)
1173
+ : Math.max(bodyEst * 0.85, plotW * 0.06);
1174
+ const leftGutter = compact ? bodyEst * 0.2 : bodyEst * 0.45;
1175
+ const xSpan = Math.max(plotW * 0.2, plotW - leftGutter - rightGutter);
1176
+ const xAt = (i: number) =>
1177
+ pad.left + leftGutter + (n === 1 ? xSpan / 2 : (i / (n - 1)) * xSpan);
1178
+ const yAt = (val: number) => plotBottom - ((scale.f(val) - loF) / (hiF - loF)) * plotH;
1179
+
1180
+ const realLo = scale.inv(loF);
1181
+ const realHi = scale.inv(hiF);
1182
+ const ticks =
1183
+ scaleKind === "log"
1184
+ ? logTicks(realLo, realHi, 6)
1185
+ : niceLinearTicks(realLo, realHi, 5);
1186
+
1187
+ const gridEls: ReactNode[] = [];
1188
+ ticks.forEach((tv, i) => {
1189
+ const y = yAt(tv);
1190
+ if (y < plotTop - 1 || y > plotBottom + 1) return;
1191
+ gridEls.push(
1192
+ <line key={`g${i}`} x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className="ts-grid" />,
1193
+ <text key={`gt${i}`} x={pad.left - 6} y={axisLabelY(y, plotTop, plotBottom)} textAnchor="end" className="ts-axis">
1194
+ {fmtCompact(tv)}
1195
+ </text>,
1196
+ );
1197
+ });
1198
+
1199
+ const slot = n > 1 ? xSpan / (n - 1) : xSpan;
1200
+ const bodyW = Math.max(1.2, Math.min(slot * 0.65, 10));
1201
+
1202
+ const candleEls: ReactNode[] = bars.map((b, i) => {
1203
+ const x = xAt(i);
1204
+ const bull = b.c >= b.o;
1205
+ const yO = yAt(b.o);
1206
+ const yC = yAt(b.c);
1207
+ const yH = yAt(b.h);
1208
+ const yL = yAt(b.l);
1209
+ const top = Math.min(yO, yC);
1210
+ const h = Math.max(1, Math.abs(yC - yO));
1211
+ const tone = bull ? "up" : "down";
1212
+ return (
1213
+ <g key={`c${i}`} className={"ts-candle ts-candle-" + tone}>
1214
+ <line x1={x} y1={yH} x2={x} y2={yL} className="ts-candle-wick" />
1215
+ <rect x={x - bodyW / 2} y={top} width={bodyW} height={h} className="ts-candle-body" rx={0.4} />
1216
+ </g>
1217
+ );
1218
+ });
1219
+
1220
+ const markerEls: ReactNode[] = [];
1221
+ const markerHits: MarkerHit[] = [];
1222
+
1223
+ const addMarker = (
1224
+ x: number,
1225
+ y: number,
1226
+ kind: "buy" | "sell",
1227
+ role: "entry" | "exit",
1228
+ key: string,
1229
+ trade: TearsheetTrade,
1230
+ ) => {
1231
+ const scaleM = Math.max(1, Math.min(2.5, slot / 5.5));
1232
+ const hw = 7.5 * scaleM;
1233
+ const th = 12 * scaleM;
1234
+ const gap = Math.max(11, 9 + th * 0.3);
1235
+ const d =
1236
+ kind === "buy"
1237
+ ? `M${x} ${y + gap} L${x - hw} ${y + gap + th} L${x + hw} ${y + gap + th} Z`
1238
+ : `M${x} ${y - gap} L${x - hw} ${y - gap - th} L${x + hw} ${y - gap - th} Z`;
1239
+ const hitY = kind === "buy" ? y + gap + th * 0.55 : y - gap - th * 0.55;
1240
+ markerHits.push({ x, y: hitY, role, trade });
1241
+ markerEls.push(
1242
+ <g key={key} className={"ts-marker-wrap ts-marker-wrap-" + kind} aria-hidden="true">
1243
+ <path d={d} className="ts-marker-halo" />
1244
+ <path d={d} className={"ts-marker ts-marker-" + kind} />
1245
+ </g>,
1246
+ );
1247
+ };
1248
+
1249
+ for (const t of trades) {
1250
+ if (inWin(t.entry_date)) {
1251
+ const i = barIndexForDate(bars, t.entry_date);
1252
+ if (i >= 0) {
1253
+ const x = xAt(i);
1254
+ const y = yAt(t.entry_price);
1255
+ addMarker(x, y, t.direction === "long" ? "buy" : "sell", "entry", `e${t.n}`, t);
1256
+ }
1257
+ }
1258
+ if (!isOpenTrade(t) && inWin(t.exit_date)) {
1259
+ const i = barIndexForDate(bars, t.exit_date);
1260
+ if (i >= 0) {
1261
+ const x = xAt(i);
1262
+ const y = yAt(t.exit_price);
1263
+ addMarker(x, y, t.direction === "long" ? "sell" : "buy", "exit", `x${t.n}`, t);
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+ const idxs = [0, Math.floor((n - 1) / 2), n - 1];
1269
+
1270
+ const onChartMouseMove = useCallback(
1271
+ (e: React.MouseEvent<SVGSVGElement>) => {
1272
+ if (e.buttons !== 0) {
1273
+ setHover(null);
1274
+ return;
1275
+ }
1276
+ const wrap = wrapRef.current;
1277
+ if (!wrap) return;
1278
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
1279
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
1280
+ setHover(null);
1281
+ return;
1282
+ }
1283
+ const marker = hitMarker(vbX, vbY, markerHits);
1284
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 220, marker ? 108 : 132);
1285
+ if (marker) {
1286
+ setHover({ ...pos, content: <TradeTipContent trade={marker.trade} /> });
1287
+ return;
1288
+ }
1289
+ const bi = nearestBarIndex(vbX, n, xAt, slot * 0.55);
1290
+ if (bi < 0) {
1291
+ setHover(null);
1292
+ return;
1293
+ }
1294
+ setHover({ ...pos, content: <OhlcTipContent bar={bars[bi]} /> });
1295
+ },
1296
+ [bars, height, markerHits, n, pad.left, pad.right, plotBottom, plotTop, slot, vbW, xAt],
1297
+ );
1298
+
1299
+ const onChartMouseLeave = useCallback(() => setHover(null), []);
1300
+
1301
+ return (
1302
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
1303
+ <Svg
1304
+ height={height}
1305
+ vbW={vbW}
1306
+ control={control}
1307
+ onMouseMove={interactive ? onChartMouseMove : undefined}
1308
+ onMouseLeave={interactive ? onChartMouseLeave : undefined}
1309
+ ariaLabel={ariaLabel}
1310
+ >
1311
+ <defs>
1312
+ <clipPath id="ts-candle-clip">
1313
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
1314
+ </clipPath>
1315
+ </defs>
1316
+ {gridEls}
1317
+ <g clipPath="url(#ts-candle-clip)">{candleEls}</g>
1318
+ <g className="ts-marker-layer">{markerEls}</g>
1319
+ {idxs.map((i, k) => {
1320
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
1321
+ const xAxisY = compact ? height - 6 : height - 10;
1322
+ return (
1323
+ <text key={`x${k}`} x={xAt(i)} y={xAxisY} textAnchor={anchor} className="ts-axis">
1324
+ {(bars[i].t || "").slice(0, 10)}
1325
+ </text>
1326
+ );
1327
+ })}
1328
+ </Svg>
1329
+ </ChartHoverShell>
1330
+ );
1331
+ }
1332
+
1333
+ /** Time-series area/line chart. */
1334
+ export function TimeSeries(props: TimeSeriesProps) {
1335
+ const { points: allPoints, height = 320 } = props;
1336
+ if (!allPoints || allPoints.length === 0) return <Empty height={height} msg="no data" />;
1337
+ return <TimeSeriesBody {...props} points={allPoints} height={height} />;
1338
+ }
1339
+
1340
+ function TimeSeriesBody({
1341
+ points: allPoints,
1342
+ height,
1343
+ scale: scaleKind = "linear",
1344
+ tone = "accent",
1345
+ fmt = fmtCompact,
1346
+ zeroBaseline = false,
1347
+ view,
1348
+ onView,
1349
+ fullSpan,
1350
+ resetView,
1351
+ interactive = true,
1352
+ references,
1353
+ domain,
1354
+ ariaLabel,
1355
+ }: TimeSeriesProps & { points: TearsheetSeriesPoint[]; height: number }) {
1356
+ const wrapRef = useRef<HTMLDivElement>(null);
1357
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
1358
+ const { vbW, pad } = useChartLayout(wrapRef, height, false);
1359
+ const points = sliceByView(allPoints, view, fullSpan);
1360
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
1361
+ const scale = makeScale(scaleKind);
1362
+ const plotW = vbW - pad.left - pad.right;
1363
+ const plotH = height - pad.top - pad.bottom;
1364
+ const plotTop = pad.top;
1365
+ const plotBottom = pad.top + plotH;
1366
+
1367
+ const values = points.map((p) => p.v);
1368
+ const auto = dataDomain(values, scale, {
1369
+ padRatio: 0.05,
1370
+ anchorZero: zeroBaseline ? "max" : undefined,
1371
+ });
1372
+ const lo = domain ? scale.f(domain[0]) : auto.lo;
1373
+ const hi = domain ? scale.f(domain[1]) : auto.hi;
1374
+
1375
+ const n = points.length;
1376
+ const xAt = (i: number) => pad.left + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
1377
+ const yAt = (val: number) => pad.top + plotH - ((scale.f(val) - lo) / (hi - lo)) * plotH;
1378
+
1379
+ const realLo = scale.inv(lo), realHi = scale.inv(hi);
1380
+ const ticks =
1381
+ scaleKind === "log"
1382
+ ? logTicks(realLo, realHi, 6)
1383
+ : scaleKind === "symlog"
1384
+ ? decadeTicks("symlog", realLo, realHi)
1385
+ : niceLinearTicks(realLo, realHi, 4);
1386
+
1387
+ const gridEls: ReactNode[] = [];
1388
+ ticks.forEach((tv, i) => {
1389
+ const y = yAt(tv);
1390
+ if (y < plotTop - 1 || y > plotBottom + 1) return;
1391
+ gridEls.push(
1392
+ <line key={`g${i}`} x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className={"ts-grid" + (tv === 0 ? " ts-grid-zero" : "")} />,
1393
+ <text key={`gt${i}`} x={pad.left - 8} y={axisLabelY(y, plotTop, plotBottom)} textAnchor="end" className="ts-axis">{fmt(tv)}</text>,
1394
+ );
1395
+ });
1396
+
1397
+ let line = "";
1398
+ for (let i = 0; i < n; i++) {
1399
+ line += (i ? "L" : "M") + xAt(i).toFixed(1) + " " + yAt(points[i].v).toFixed(1) + " ";
1400
+ }
1401
+ const baseReal = zeroBaseline ? 0 : realLo;
1402
+ const baseY = yAt(baseReal);
1403
+ const area = line + `L${xAt(n - 1).toFixed(1)} ${baseY.toFixed(1)} L${xAt(0).toFixed(1)} ${baseY.toFixed(1)} Z`;
1404
+
1405
+ const idxs = [0, Math.floor((n - 1) / 2), n - 1];
1406
+
1407
+ const onChartMouseMove = useCallback(
1408
+ (e: React.MouseEvent<SVGSVGElement>) => {
1409
+ if (e.buttons !== 0) {
1410
+ setHover(null);
1411
+ return;
1412
+ }
1413
+ const wrap = wrapRef.current;
1414
+ if (!wrap) return;
1415
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
1416
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
1417
+ setHover(null);
1418
+ return;
1419
+ }
1420
+ const frac = plotFraction(e.clientX, e.currentTarget, pad.left, pad.right, vbW);
1421
+ const idx = n === 1 ? 0 : Math.round(frac * (n - 1));
1422
+ const pt = points[idx];
1423
+ if (!pt) {
1424
+ setHover(null);
1425
+ return;
1426
+ }
1427
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 180, 72);
1428
+ setHover({
1429
+ ...pos,
1430
+ content: <SeriesTipContent date={pt.t} value={fmt(pt.v)} />,
1431
+ });
1432
+ },
1433
+ [fmt, height, n, pad.left, pad.right, plotBottom, plotTop, points, vbW],
1434
+ );
1435
+
1436
+ const onChartMouseLeave = useCallback(() => setHover(null), []);
1437
+
1438
+ return (
1439
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
1440
+ <Svg
1441
+ height={height}
1442
+ vbW={vbW}
1443
+ control={control}
1444
+ onMouseMove={interactive ? onChartMouseMove : undefined}
1445
+ onMouseLeave={interactive ? onChartMouseLeave : undefined}
1446
+ ariaLabel={ariaLabel}
1447
+ >
1448
+ <defs>
1449
+ <clipPath id="ts-series-clip">
1450
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
1451
+ </clipPath>
1452
+ </defs>
1453
+ {gridEls}
1454
+ <g clipPath="url(#ts-series-clip)">
1455
+ {referenceEls({
1456
+ dates: points.map((p) => p.t),
1457
+ xAt,
1458
+ yAt,
1459
+ plotLeft: pad.left,
1460
+ plotWidth: plotW,
1461
+ plotTop,
1462
+ plotBottom,
1463
+ references,
1464
+ })}
1465
+ <path d={area} className={"ts-area ts-tone-" + tone} />
1466
+ <path d={line} className={"ts-line ts-tone-" + tone} fill="none" />
1467
+ </g>
1468
+ {idxs.map((i, k) => {
1469
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
1470
+ return (
1471
+ <text key={`x${k}`} x={xAt(i)} y={height - 10} textAnchor={anchor} className="ts-axis">
1472
+ {(points[i].t || "").slice(0, 10)}
1473
+ </text>
1474
+ );
1475
+ })}
1476
+ </Svg>
1477
+ </ChartHoverShell>
1478
+ );
1479
+ }
1480
+
1481
+ export interface SignedBarsProps {
1482
+ values: number[];
1483
+ height?: number;
1484
+ fmt?: (v: number) => string;
1485
+ /** Accessible name for the chart (role="img" has none without it). */
1486
+ ariaLabel: string;
1487
+ /**
1488
+ * Optional per-bar x-axis labels, parallel to `values` by index. Entries
1489
+ * beyond `values` are ignored; a missing or empty entry leaves that bar
1490
+ * unlabeled. Labels render angled (-30°) in the bottom gutter, matching
1491
+ * the categorical-bar convention, and join the bar's hover title.
1492
+ */
1493
+ labels?: string[];
1494
+ }
1495
+
1496
+ /**
1497
+ * Per-item signed bar chart (gains var(--up), losses var(--down)).
1498
+ *
1499
+ * Each bar carries a native SVG `<title>` with its exact formatted value
1500
+ * (plus label when given) so the precise reading survives without a JS
1501
+ * tooltip layer — and without pixels, so it stays testable in markup.
1502
+ */
1503
+ export function SignedBars({ values, height = 220, fmt = fmtCompact, ariaLabel, labels }: SignedBarsProps) {
1504
+ if (!values || values.length === 0) return <Empty height={height} msg="no trades" />;
1505
+
1506
+ const plotW = W - PAD.left - PAD.right;
1507
+ const plotH = height - PAD.top - PAD.bottom;
1508
+ let lo = 0, hi = 0;
1509
+ for (const v of values) {
1510
+ if (v < lo) lo = v;
1511
+ if (v > hi) hi = v;
1512
+ }
1513
+ if (lo === hi) hi = lo + 1;
1514
+ const padF = (hi - lo) * 0.08;
1515
+ lo -= padF;
1516
+ hi += padF;
1517
+
1518
+ const yAt = (v: number) => PAD.top + plotH - ((v - lo) / (hi - lo)) * plotH;
1519
+ const zeroY = yAt(0);
1520
+
1521
+ const gridEls: ReactNode[] = [];
1522
+ niceLinearTicks(lo, hi, 4).forEach((tv, i) => {
1523
+ const y = yAt(tv);
1524
+ gridEls.push(
1525
+ <line key={`g${i}`} x1={PAD.left} y1={y} x2={W - PAD.right} y2={y} className={"ts-grid" + (tv === 0 ? " ts-grid-zero" : "")} />,
1526
+ <text key={`gt${i}`} x={PAD.left - 10} y={y + 4} textAnchor="end" className="ts-axis">{fmt(tv)}</text>,
1527
+ );
1528
+ });
1529
+
1530
+ const n = values.length;
1531
+ const slot = plotW / n;
1532
+ const bw = Math.max(0.6, Math.min(slot * 0.7, 16));
1533
+
1534
+ return (
1535
+ <Svg height={height} ariaLabel={ariaLabel}>
1536
+ {gridEls}
1537
+ {values.map((v, i) => {
1538
+ const x = PAD.left + i * slot + (slot - bw) / 2;
1539
+ const y = v >= 0 ? yAt(v) : zeroY;
1540
+ const h = Math.max(0.5, Math.abs(yAt(v) - zeroY));
1541
+ const label = labels?.[i]?.trim() ? labels[i].trim() : null;
1542
+ return (
1543
+ <rect key={i} x={x.toFixed(1)} y={y.toFixed(1)} width={bw.toFixed(1)} height={h.toFixed(1)} className={"ts-bar ts-tone-" + (v >= 0 ? "up" : "down")}>
1544
+ <title>{label ? `${label}: ${fmt(v)}` : fmt(v)}</title>
1545
+ </rect>
1546
+ );
1547
+ })}
1548
+ {(labels ?? []).map((raw, i) => {
1549
+ const label = raw?.trim() ? raw.trim() : null;
1550
+ if (!label || i >= n) return null;
1551
+ const cx = PAD.left + i * slot + slot / 2;
1552
+ // Gutter below the plot: a -30° label drops ~half its length under
1553
+ // its anchor, so the anchor sits 28px above the viewBox bottom —
1554
+ // anchoring at the old height-10 clipped tickers against the
1555
+ // section edge (caught by screenshot in Q3b slice 4).
1556
+ const ly = height - 28;
1557
+ return (
1558
+ <text key={`xl${i}`} x={cx.toFixed(1)} y={ly} textAnchor="end" transform={`rotate(-30 ${cx.toFixed(1)} ${ly})`} className="ts-axis">{label}</text>
1559
+ );
1560
+ })}
1561
+ </Svg>
1562
+ );
1563
+ }
1564
+
1565
+ export interface ContributionReturnPoint {
1566
+ t: string;
1567
+ /** Exact cumulative portfolio return from the NAV series, in percent. */
1568
+ returnPct: number;
1569
+ /** Cumulative per-position contribution, in percentage points. */
1570
+ contributions: Record<string, number>;
1571
+ }
1572
+
1573
+ export interface ContributionReturnChartProps {
1574
+ points: ContributionReturnPoint[];
1575
+ /** Stable series identity colors supplied by the consuming product. */
1576
+ colors: Record<string, string>;
1577
+ height?: number;
1578
+ interactive?: boolean;
1579
+ benchmark?: { label: string; values: number[] };
1580
+ /** Accessible name for the chart (role="img" has none without it) — should
1581
+ * summarize the series, e.g. "Portfolio return contribution by factor". */
1582
+ ariaLabel: string;
1583
+ }
1584
+
1585
+ /** Signed cumulative contribution stacks with the exact portfolio return overlaid. */
1586
+ export function ContributionReturnChart({
1587
+ points,
1588
+ colors,
1589
+ height = 360,
1590
+ interactive = true,
1591
+ benchmark,
1592
+ ariaLabel,
1593
+ }: ContributionReturnChartProps) {
1594
+ if (points.length < 2) return <Empty height={height} msg="not enough history" />;
1595
+ return (
1596
+ <ContributionReturnChartBody
1597
+ points={points}
1598
+ colors={colors}
1599
+ height={height}
1600
+ interactive={interactive}
1601
+ benchmark={benchmark}
1602
+ ariaLabel={ariaLabel}
1603
+ />
1604
+ );
1605
+ }
1606
+
1607
+ function ContributionReturnChartBody({
1608
+ points,
1609
+ colors,
1610
+ height,
1611
+ interactive,
1612
+ benchmark,
1613
+ ariaLabel,
1614
+ }: ContributionReturnChartProps & { height: number; interactive: boolean }) {
1615
+ const wrapRef = useRef<HTMLDivElement>(null);
1616
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
1617
+ const { vbW, pad } = useChartLayout(wrapRef, height, false);
1618
+ const plotW = vbW - pad.left - pad.right;
1619
+ const plotH = height - pad.top - pad.bottom;
1620
+ const plotBottom = pad.top + plotH;
1621
+ const keys = [...new Set(points.flatMap((point) => Object.keys(point.contributions)))];
1622
+ const extents = points.map((point) => {
1623
+ let positive = 0;
1624
+ let negative = 0;
1625
+ for (const value of Object.values(point.contributions)) {
1626
+ if (value >= 0) positive += value;
1627
+ else negative += value;
1628
+ }
1629
+ return { positive, negative };
1630
+ });
1631
+ const values = [
1632
+ ...points.map((point) => point.returnPct),
1633
+ ...(benchmark?.values ?? []),
1634
+ ...extents.flatMap((extent) => [extent.positive, extent.negative]),
1635
+ 0,
1636
+ ];
1637
+ let lo = Math.min(...values);
1638
+ let hi = Math.max(...values);
1639
+ if (lo === hi) hi = lo + 1;
1640
+ const domainPad = (hi - lo) * 0.08;
1641
+ lo -= domainPad;
1642
+ hi += domainPad;
1643
+ const yAt = (value: number) => pad.top + plotH - ((value - lo) / (hi - lo)) * plotH;
1644
+ const zeroY = yAt(0);
1645
+ const slot = plotW / points.length;
1646
+ const barWidth = Math.max(1, Math.min(slot * 0.72, 24));
1647
+ const xCenter = (index: number) => pad.left + slot * index + slot / 2;
1648
+ const line = points
1649
+ .map((point, index) => `${index ? 'L' : 'M'}${xCenter(index).toFixed(1)} ${yAt(point.returnPct).toFixed(1)}`)
1650
+ .join(' ');
1651
+ const benchmarkLine =
1652
+ benchmark && benchmark.values.length === points.length
1653
+ ? benchmark.values
1654
+ .map((value, index) => `${index ? 'L' : 'M'}${xCenter(index).toFixed(1)} ${yAt(value).toFixed(1)}`)
1655
+ .join(' ')
1656
+ : null;
1657
+
1658
+ const grid: ReactNode[] = [];
1659
+ niceLinearTicks(lo, hi, 4).forEach((tick, index) => {
1660
+ const y = yAt(tick);
1661
+ grid.push(
1662
+ <line key={`g${index}`} x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className={`ts-grid${tick === 0 ? ' ts-grid-zero' : ''}`} />,
1663
+ <text key={`gt${index}`} x={pad.left - 8} y={axisLabelY(y, pad.top, plotBottom)} textAnchor="end" className="ts-axis ts-contribution-axis">{fmtCompact(tick)}%</text>,
1664
+ );
1665
+ });
1666
+
1667
+ const onChartMouseMove = useCallback((event: React.MouseEvent<SVGSVGElement>) => {
1668
+ if (event.buttons !== 0) {
1669
+ setHover(null);
1670
+ return;
1671
+ }
1672
+ const wrap = wrapRef.current;
1673
+ if (!wrap) return;
1674
+ const { x, y } = viewBoxPoint(event.clientX, event.clientY, event.currentTarget, vbW, height);
1675
+ if (x < pad.left || x > vbW - pad.right || y < pad.top || y > plotBottom) {
1676
+ setHover(null);
1677
+ return;
1678
+ }
1679
+ const index = Math.max(0, Math.min(points.length - 1, Math.floor((x - pad.left) / slot)));
1680
+ setHover({
1681
+ ...positionHoverTip(event.clientX, event.clientY, wrap, 220, 128),
1682
+ content: (
1683
+ <ContributionReturnTipContent
1684
+ point={points[index]}
1685
+ colors={colors}
1686
+ benchmark={benchmarkLine && benchmark ? { label: benchmark.label, value: benchmark.values[index] } : undefined}
1687
+ />
1688
+ ),
1689
+ });
1690
+ }, [benchmark, benchmarkLine, colors, height, pad.left, pad.right, pad.top, plotBottom, points, slot, vbW]);
1691
+
1692
+ return (
1693
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
1694
+ <Svg
1695
+ height={height}
1696
+ vbW={vbW}
1697
+ onMouseMove={interactive ? onChartMouseMove : undefined}
1698
+ onMouseLeave={interactive ? () => setHover(null) : undefined}
1699
+ ariaLabel={ariaLabel}
1700
+ >
1701
+ <defs>
1702
+ <clipPath id="ts-contribution-return-clip">
1703
+ <rect x={pad.left} y={pad.top} width={plotW} height={plotH} />
1704
+ </clipPath>
1705
+ </defs>
1706
+ {grid}
1707
+ <g clipPath="url(#ts-contribution-return-clip)" data-chart-layer="contributions">
1708
+ {points.flatMap((point, index) => {
1709
+ let positive = 0;
1710
+ let negative = 0;
1711
+ return keys.flatMap((key) => {
1712
+ const value = point.contributions[key] ?? 0;
1713
+ if (value === 0) return [];
1714
+ const start = value >= 0 ? positive : negative;
1715
+ const end = start + value;
1716
+ if (value >= 0) positive = end;
1717
+ else negative = end;
1718
+ const top = Math.min(yAt(start), yAt(end));
1719
+ return (
1720
+ <rect
1721
+ key={`${point.t}:${key}`}
1722
+ x={(xCenter(index) - barWidth / 2).toFixed(1)}
1723
+ y={top.toFixed(1)}
1724
+ width={barWidth.toFixed(1)}
1725
+ height={Math.max(0.75, Math.abs(yAt(end) - yAt(start))).toFixed(1)}
1726
+ fill={colors[key] ?? 'var(--ink-mute)'}
1727
+ className="ts-contribution-segment"
1728
+ data-series={key}
1729
+ />
1730
+ );
1731
+ });
1732
+ })}
1733
+ <line x1={pad.left} y1={zeroY} x2={vbW - pad.right} y2={zeroY} className="ts-grid ts-grid-zero" />
1734
+ <path d={line} className="ts-line ts-tone-accent ts-portfolio-return-line" fill="none" data-chart-layer="portfolio-return" />
1735
+ {benchmarkLine && benchmark ? (
1736
+ <path
1737
+ d={benchmarkLine}
1738
+ className="ts-line ts-benchmark-return-line"
1739
+ fill="none"
1740
+ data-chart-layer="benchmark-return"
1741
+ data-series={benchmark.label}
1742
+ />
1743
+ ) : null}
1744
+ {points.map((point, index) => (
1745
+ <circle key={point.t} cx={xCenter(index)} cy={yAt(point.returnPct)} r="2.5" className="ts-portfolio-return-dot" />
1746
+ ))}
1747
+ </g>
1748
+ {[...new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])].map((index) => (
1749
+ <text
1750
+ key={`${points[index].t}:${index}`}
1751
+ x={xCenter(index)}
1752
+ y={height - 10}
1753
+ textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
1754
+ className="ts-axis ts-contribution-axis"
1755
+ >
1756
+ {points[index].t.slice(0, 10)}
1757
+ </text>
1758
+ ))}
1759
+ </Svg>
1760
+ </ChartHoverShell>
1761
+ );
1762
+ }
1763
+
1764
+ /** Per-trade realized/unrealized return % (single-axis bar chart). */
1765
+ export interface TradeReturnChartProps {
1766
+ bars: TradeReturnBar[];
1767
+ height?: number;
1768
+ view?: ViewWindow;
1769
+ onView?: (v: ViewWindow) => void;
1770
+ fullSpan?: [string, string];
1771
+ resetView?: ViewWindow;
1772
+ /** When false, omit hover tooltips (static print-first panes). */
1773
+ interactive?: boolean;
1774
+ /** Accessible name for the chart (role="img" has none without it) — should
1775
+ * summarize the series, e.g. "Per-trade profit and loss, realized and
1776
+ * open trades, Jan 2023 to Feb 2025". */
1777
+ ariaLabel: string;
1778
+ }
1779
+
1780
+ function sliceTradeBarsByView(
1781
+ bars: TradeReturnBar[],
1782
+ view: ViewWindow | undefined,
1783
+ fullSpan: [string, string] | undefined,
1784
+ ): TradeReturnBar[] {
1785
+ if (!view || (view.lo <= 0 && view.hi >= 1) || bars.length === 0) return bars;
1786
+ const t0 = new Date((fullSpan ? fullSpan[0] : bars[0].t)).getTime();
1787
+ const t1 = new Date((fullSpan ? fullSpan[1] : bars[bars.length - 1].t)).getTime();
1788
+ const span = t1 - t0;
1789
+ if (span <= 0) return bars;
1790
+ const loT = t0 + view.lo * span;
1791
+ const hiT = t0 + view.hi * span;
1792
+ const out = bars.filter((b) => {
1793
+ const t = new Date(b.t).getTime();
1794
+ return t >= loT && t <= hiT;
1795
+ });
1796
+ if (out.length > 0) return out;
1797
+ const mid = (loT + hiT) / 2;
1798
+ let nearest = 0;
1799
+ for (let i = 1; i < bars.length; i++) {
1800
+ if (Math.abs(new Date(bars[i].t).getTime() - mid) < Math.abs(new Date(bars[nearest].t).getTime() - mid)) {
1801
+ nearest = i;
1802
+ }
1803
+ }
1804
+ return bars.slice(Math.max(0, nearest - 1), Math.min(bars.length, nearest + 1));
1805
+ }
1806
+
1807
+ /**
1808
+ * Per-trade return % bars in trade order. Open leg is appended last and styled
1809
+ * as unrealized. Shares zoom/pan with the other time-series charts.
1810
+ */
1811
+ export function TradeReturnChart(props: TradeReturnChartProps) {
1812
+ const { bars: allBars, height = 300 } = props;
1813
+ if (!allBars || allBars.length === 0) return <Empty height={height} msg="no trades" />;
1814
+ const bars = sliceTradeBarsByView(allBars, props.view, props.fullSpan);
1815
+ if (bars.length === 0) return <Empty height={height} msg="no trades in window" />;
1816
+ return <TradeReturnChartBody {...props} bars={bars} height={height} />;
1817
+ }
1818
+
1819
+ function TradeReturnChartBody({
1820
+ bars,
1821
+ height,
1822
+ view,
1823
+ onView,
1824
+ resetView,
1825
+ interactive = true,
1826
+ ariaLabel,
1827
+ }: TradeReturnChartProps & { bars: TradeReturnBar[]; height: number }) {
1828
+ const wrapRef = useRef<HTMLDivElement>(null);
1829
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
1830
+ const { vbW, pad } = useChartLayout(wrapRef, height, false);
1831
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
1832
+
1833
+ const plotW = vbW - pad.left - pad.right;
1834
+ const plotH = height - pad.top - pad.bottom;
1835
+ const plotTop = pad.top;
1836
+ const plotBottom = pad.top + plotH;
1837
+ const n = bars.length;
1838
+ const slot = plotW / n;
1839
+ const bw = Math.max(0.6, Math.min(slot * 0.7, 16));
1840
+ const xCenter = (i: number) => pad.left + (i + 0.5) * slot;
1841
+
1842
+ let lo = 0;
1843
+ let hi = 0;
1844
+ for (const b of bars) {
1845
+ if (b.pct < lo) lo = b.pct;
1846
+ if (b.pct > hi) hi = b.pct;
1847
+ }
1848
+ if (lo === hi) hi = lo + (lo >= 0 ? 1 : -1);
1849
+ const padF = (hi - lo) * 0.08;
1850
+ lo -= padF;
1851
+ hi += padF;
1852
+
1853
+ const yAt = (v: number) => pad.top + plotH - ((v - lo) / (hi - lo)) * plotH;
1854
+ const zeroY = yAt(0);
1855
+ const fmtPctAxis = (v: number) => fmtCompact(v) + "%";
1856
+
1857
+ const gridEls: ReactNode[] = [];
1858
+ niceLinearTicks(lo, hi, 4).forEach((tv, i) => {
1859
+ const y = yAt(tv);
1860
+ if (y < plotTop - 1 || y > plotBottom + 1) return;
1861
+ gridEls.push(
1862
+ <line key={`g${i}`} x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className={"ts-grid" + (tv === 0 ? " ts-grid-zero" : "")} />,
1863
+ <text key={`gt${i}`} x={pad.left - 8} y={axisLabelY(y, plotTop, plotBottom)} textAnchor="end" className="ts-axis">
1864
+ {fmtPctAxis(tv)}
1865
+ </text>,
1866
+ );
1867
+ });
1868
+
1869
+ const idxs = [0, Math.floor((n - 1) / 2), n - 1];
1870
+
1871
+ const onChartMouseMove = useCallback(
1872
+ (e: React.MouseEvent<SVGSVGElement>) => {
1873
+ if (e.buttons !== 0) {
1874
+ setHover(null);
1875
+ return;
1876
+ }
1877
+ const wrap = wrapRef.current;
1878
+ if (!wrap) return;
1879
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
1880
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
1881
+ setHover(null);
1882
+ return;
1883
+ }
1884
+ const relX = vbX - pad.left;
1885
+ const idx = Math.min(n - 1, Math.max(0, Math.floor(relX / slot)));
1886
+ const bar = bars[idx];
1887
+ if (!bar?.trade) {
1888
+ setHover(null);
1889
+ return;
1890
+ }
1891
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 220, 120);
1892
+ setHover({
1893
+ ...pos,
1894
+ content: <TradeTipContent trade={bar.trade} showPnlMoney />,
1895
+ });
1896
+ },
1897
+ [bars, height, n, pad.left, pad.right, plotBottom, plotTop, slot, vbW],
1898
+ );
1899
+
1900
+ const onChartMouseLeave = useCallback(() => setHover(null), []);
1901
+
1902
+ return (
1903
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
1904
+ <Svg
1905
+ height={height}
1906
+ vbW={vbW}
1907
+ control={control}
1908
+ onMouseMove={interactive ? onChartMouseMove : undefined}
1909
+ onMouseLeave={interactive ? onChartMouseLeave : undefined}
1910
+ ariaLabel={ariaLabel}
1911
+ >
1912
+ <defs>
1913
+ <clipPath id="ts-pnl-clip">
1914
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
1915
+ </clipPath>
1916
+ </defs>
1917
+ {gridEls}
1918
+ <g clipPath="url(#ts-pnl-clip)">
1919
+ {bars.map((b, i) => {
1920
+ const x = pad.left + i * slot + (slot - bw) / 2;
1921
+ const y = b.pct >= 0 ? yAt(b.pct) : zeroY;
1922
+ const h = Math.max(0.5, Math.abs(yAt(b.pct) - zeroY));
1923
+ const tone = b.pct >= 0 ? "up" : "down";
1924
+ return (
1925
+ <rect
1926
+ key={`${b.t}-${i}`}
1927
+ x={x.toFixed(1)}
1928
+ y={y.toFixed(1)}
1929
+ width={bw.toFixed(1)}
1930
+ height={h.toFixed(1)}
1931
+ className={"ts-bar ts-tone-" + tone + (b.open ? " ts-bar-open" : "")}
1932
+ />
1933
+ );
1934
+ })}
1935
+ </g>
1936
+ {idxs.map((i, k) => {
1937
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
1938
+ const label = bars[i].open ? "open" : (bars[i].t || "").slice(0, 10);
1939
+ return (
1940
+ <text key={`x${k}`} x={xCenter(i)} y={height - 10} textAnchor={anchor} className="ts-axis">
1941
+ {label}
1942
+ </text>
1943
+ );
1944
+ })}
1945
+ </Svg>
1946
+ </ChartHoverShell>
1947
+ );
1948
+ }
1949
+
1950
+ export interface OverlaySeries {
1951
+ id: string;
1952
+ label: string;
1953
+ points: TearsheetSeriesPoint[];
1954
+ tone?: OverlayTone;
1955
+ /**
1956
+ * Explicit stroke color (CSS color string) — for categorical series whose
1957
+ * identity must stay stable across themes (e.g. per-currency hues from an
1958
+ * allowlisted palette). Wins over `tone`. No literals at call sites: pass
1959
+ * values, not hex.
1960
+ */
1961
+ color?: string;
1962
+ dashed?: boolean;
1963
+ /**
1964
+ * Round-dot stroke for the benchmark leg (the owner's landing-band grammar:
1965
+ * portfolio is the solid filled curve, the benchmark is mute dots). Wins
1966
+ * over `dashed` when both are set; call sites set exactly one.
1967
+ */
1968
+ dotted?: boolean;
1969
+ /** Area fill under the line (default false — overlays stay as strokes). */
1970
+ fill?: boolean;
1971
+ }
1972
+
1973
+ export interface MultiTimeSeriesProps {
1974
+ series: OverlaySeries[];
1975
+ height?: number;
1976
+ scale?: ChartScale;
1977
+ fmt?: (v: number) => string;
1978
+ zeroBaseline?: boolean;
1979
+ view?: ViewWindow;
1980
+ onView?: (v: ViewWindow) => void;
1981
+ fullSpan?: [string, string];
1982
+ resetView?: ViewWindow;
1983
+ interactive?: boolean;
1984
+ /**
1985
+ * Reference overlays: horizontal levels, shaded bands, date-anchored dots.
1986
+ * Rendered beneath the series paths; out-of-domain values clamp to the plot.
1987
+ */
1988
+ references?: ChartReferences;
1989
+ /** Explicit y-domain [lo, hi] (data units). Omit ⇒ derived from the data. */
1990
+ domain?: [number, number];
1991
+ ariaLabel: string;
1992
+ }
1993
+
1994
+ function unionDates(series: OverlaySeries[]): string[] {
1995
+ const set = new Set<string>();
1996
+ for (const s of series) {
1997
+ for (const p of s.points) set.add(p.t);
1998
+ }
1999
+ return [...set].sort();
2000
+ }
2001
+
2002
+ function pathForOverlay(
2003
+ dates: string[],
2004
+ values: Map<string, number>,
2005
+ xAt: (i: number) => number,
2006
+ yAt: (v: number) => number,
2007
+ ): string {
2008
+ let d = "";
2009
+ let drawing = false;
2010
+ for (let i = 0; i < dates.length; i++) {
2011
+ const v = values.get(dates[i]);
2012
+ if (v === undefined || !Number.isFinite(v)) {
2013
+ drawing = false;
2014
+ continue;
2015
+ }
2016
+ d += `${drawing ? "L" : "M"}${xAt(i).toFixed(1)} ${yAt(v).toFixed(1)} `;
2017
+ drawing = true;
2018
+ }
2019
+ return d;
2020
+ }
2021
+
2022
+ function OverlayTipContent({
2023
+ date,
2024
+ rows,
2025
+ }: {
2026
+ date: string;
2027
+ rows: { label: string; value: string }[];
2028
+ }) {
2029
+ return (
2030
+ <dl className="ts-chart-tip-dl">
2031
+ <div>
2032
+ <dt>Date</dt>
2033
+ <dd>{date.slice(0, 10)}</dd>
2034
+ </div>
2035
+ {rows.map((r) => (
2036
+ <div key={r.label}>
2037
+ <dt>{r.label}</dt>
2038
+ <dd>{r.value}</dd>
2039
+ </div>
2040
+ ))}
2041
+ </dl>
2042
+ );
2043
+ }
2044
+
2045
+ /** Overlay several time series on one SVG pane (valuation rails, three-way equity). */
2046
+ export function MultiTimeSeries(props: MultiTimeSeriesProps) {
2047
+ const { series, height = 320 } = props;
2048
+ const any = series.some((s) => s.points.length > 0);
2049
+ if (!any) return <Empty height={height} msg="no data" />;
2050
+ return <MultiTimeSeriesBody {...props} height={height} />;
2051
+ }
2052
+
2053
+ function MultiTimeSeriesBody({
2054
+ series,
2055
+ height,
2056
+ scale: scaleKind = "linear",
2057
+ fmt = fmtCompact,
2058
+ zeroBaseline = false,
2059
+ view,
2060
+ onView,
2061
+ fullSpan,
2062
+ resetView,
2063
+ interactive = true,
2064
+ references,
2065
+ domain,
2066
+ ariaLabel,
2067
+ }: MultiTimeSeriesProps & { height: number }) {
2068
+ const wrapRef = useRef<HTMLDivElement>(null);
2069
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
2070
+ const { vbW, pad } = useChartLayout(wrapRef, height, false);
2071
+ const clipId = `ts-overlay-clip-${useId().replace(/:/g, "")}`;
2072
+ const sliced = series.map((s) => ({
2073
+ ...s,
2074
+ points: sliceByView(s.points, view, fullSpan),
2075
+ }));
2076
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
2077
+ const scale = makeScale(scaleKind);
2078
+ const plotW = vbW - pad.left - pad.right;
2079
+ const plotH = height - pad.top - pad.bottom;
2080
+ const plotTop = pad.top;
2081
+ const plotBottom = pad.top + plotH;
2082
+
2083
+ const dates = unionDates(sliced);
2084
+ const maps = sliced.map((s) => new Map(s.points.map((p) => [p.t, p.v])));
2085
+ const values: number[] = [];
2086
+ for (const m of maps) {
2087
+ for (const v of m.values()) values.push(v);
2088
+ }
2089
+ const { lo, hi } = (() => {
2090
+ const auto = dataDomain(values, scale, {
2091
+ padRatio: 0.05,
2092
+ anchorZero: zeroBaseline ? "max" : undefined,
2093
+ });
2094
+ if (!domain) return auto;
2095
+ return { lo: scale.f(domain[0]), hi: scale.f(domain[1]) };
2096
+ })();
2097
+ const n = dates.length;
2098
+ const xAt = (i: number) => pad.left + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW);
2099
+ const yAt = (val: number) => pad.top + plotH - ((scale.f(val) - lo) / (hi - lo)) * plotH;
2100
+
2101
+ const realLo = scale.inv(lo);
2102
+ const realHi = scale.inv(hi);
2103
+ const ticks =
2104
+ scaleKind === "log"
2105
+ ? logTicks(realLo, realHi, 6)
2106
+ : scaleKind === "symlog"
2107
+ ? decadeTicks("symlog", realLo, realHi)
2108
+ : niceLinearTicks(realLo, realHi, 4);
2109
+
2110
+ const gridEls: ReactNode[] = [];
2111
+ ticks.forEach((tv, i) => {
2112
+ const y = yAt(tv);
2113
+ if (y < plotTop - 1 || y > plotBottom + 1) return;
2114
+ gridEls.push(
2115
+ <line key={`g${i}`} x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className={"ts-grid" + (tv === 0 ? " ts-grid-zero" : "")} />,
2116
+ <text key={`gt${i}`} x={pad.left - 8} y={axisLabelY(y, plotTop, plotBottom)} textAnchor="end" className="ts-axis">{fmt(tv)}</text>,
2117
+ );
2118
+ });
2119
+
2120
+ const lineEls: ReactNode[] = [];
2121
+ sliced.forEach((s, si) => {
2122
+ const line = pathForOverlay(dates, maps[si], xAt, yAt);
2123
+ if (!line) return;
2124
+ const tone = s.tone ?? "accent";
2125
+ const dash = s.dotted ? " ts-line-dotted" : s.dashed ? " ts-line-dashed" : "";
2126
+ if (s.fill) {
2127
+ const baseReal = zeroBaseline ? 0 : realLo;
2128
+ const baseY = yAt(baseReal);
2129
+ const first = dates.findIndex((t) => maps[si].has(t));
2130
+ const last = (() => {
2131
+ for (let i = dates.length - 1; i >= 0; i--) if (maps[si].has(dates[i])) return i;
2132
+ return first;
2133
+ })();
2134
+ if (first >= 0 && last >= 0) {
2135
+ const area = `${line}L${xAt(last).toFixed(1)} ${baseY.toFixed(1)} L${xAt(first).toFixed(1)} ${baseY.toFixed(1)} Z`;
2136
+ lineEls.push(
2137
+ <path key={`${s.id}-fill`} d={area} className={"ts-area ts-tone-" + tone} data-series={s.id} data-chart-layer="overlay-fill" />,
2138
+ );
2139
+ }
2140
+ }
2141
+ lineEls.push(
2142
+ <path
2143
+ key={s.id}
2144
+ d={line}
2145
+ fill="none"
2146
+ className={"ts-line ts-tone-" + tone + dash}
2147
+ style={s.color ? { stroke: s.color } : undefined}
2148
+ data-series={s.id}
2149
+ data-chart-layer="overlay-line"
2150
+ />,
2151
+ );
2152
+ });
2153
+
2154
+ const idxs = n === 0 ? [] : [0, Math.floor((n - 1) / 2), n - 1];
2155
+
2156
+ const onChartMouseMove = useCallback(
2157
+ (e: React.MouseEvent<SVGSVGElement>) => {
2158
+ if (e.buttons !== 0) {
2159
+ setHover(null);
2160
+ return;
2161
+ }
2162
+ const wrap = wrapRef.current;
2163
+ if (!wrap || n === 0) return;
2164
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
2165
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
2166
+ setHover(null);
2167
+ return;
2168
+ }
2169
+ const frac = plotFraction(e.clientX, e.currentTarget, pad.left, pad.right, vbW);
2170
+ const idx = n === 1 ? 0 : Math.round(frac * (n - 1));
2171
+ const date = dates[idx];
2172
+ if (!date) {
2173
+ setHover(null);
2174
+ return;
2175
+ }
2176
+ const rows = sliced.flatMap((s, si) => {
2177
+ const v = maps[si].get(date);
2178
+ if (v === undefined) return [];
2179
+ return [{ label: s.label, value: fmt(v) }];
2180
+ });
2181
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 200, 24 + rows.length * 22);
2182
+ setHover({ ...pos, content: <OverlayTipContent date={date} rows={rows} /> });
2183
+ },
2184
+ [dates, fmt, height, maps, n, pad.left, pad.right, plotBottom, plotTop, sliced, vbW],
2185
+ );
2186
+
2187
+ const onChartMouseLeave = useCallback(() => setHover(null), []);
2188
+
2189
+ return (
2190
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
2191
+ <Svg
2192
+ height={height}
2193
+ vbW={vbW}
2194
+ control={control}
2195
+ onMouseMove={interactive ? onChartMouseMove : undefined}
2196
+ onMouseLeave={interactive ? onChartMouseLeave : undefined}
2197
+ ariaLabel={ariaLabel}
2198
+ >
2199
+ <defs>
2200
+ <clipPath id={clipId}>
2201
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
2202
+ </clipPath>
2203
+ </defs>
2204
+ {gridEls}
2205
+ <g clipPath={`url(#${clipId})`}>
2206
+ {referenceEls({
2207
+ dates,
2208
+ xAt,
2209
+ yAt,
2210
+ plotLeft: pad.left,
2211
+ plotWidth: plotW,
2212
+ plotTop,
2213
+ plotBottom,
2214
+ references,
2215
+ })}
2216
+ {lineEls}
2217
+ </g>
2218
+ {idxs.map((i, k) => {
2219
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
2220
+ return (
2221
+ <text key={`x${k}`} x={xAt(i)} y={height - 10} textAnchor={anchor} className="ts-axis">
2222
+ {(dates[i] || "").slice(0, 10)}
2223
+ </text>
2224
+ );
2225
+ })}
2226
+ </Svg>
2227
+ </ChartHoverShell>
2228
+ );
2229
+ }
2230
+
2231
+ export interface RiskBandStripProps {
2232
+ points: TearsheetSeriesPoint[];
2233
+ height?: number;
2234
+ view?: ViewWindow;
2235
+ onView?: (v: ViewWindow) => void;
2236
+ fullSpan?: [string, string];
2237
+ resetView?: ViewWindow;
2238
+ interactive?: boolean;
2239
+ ariaLabel: string;
2240
+ /** Horizontal accumulate / distribute knees (published curve). */
2241
+ thresholds?: { id: string; value: number; label: string }[];
2242
+ /** Faint log-scale price drawn behind the 0–100 index (right axis). */
2243
+ priceOverlay?: TearsheetSeriesPoint[];
2244
+ }
2245
+
2246
+ /** Composite-risk 0–100 strip with labelled SDCA bands (#3172). */
2247
+ export function RiskBandStrip(props: RiskBandStripProps) {
2248
+ const { points: allPoints, height = 220 } = props;
2249
+ if (!allPoints || allPoints.length === 0) return <Empty height={height} msg="no data" />;
2250
+ return <RiskBandStripBody {...props} points={allPoints} height={height} />;
2251
+ }
2252
+
2253
+ function RiskBandStripBody({
2254
+ points: allPoints,
2255
+ height,
2256
+ view,
2257
+ onView,
2258
+ fullSpan,
2259
+ resetView,
2260
+ interactive = true,
2261
+ ariaLabel,
2262
+ thresholds = [],
2263
+ priceOverlay = [],
2264
+ }: RiskBandStripProps & { points: TearsheetSeriesPoint[]; height: number }) {
2265
+ const wrapRef = useRef<HTMLDivElement>(null);
2266
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
2267
+ const dual = priceOverlay.length > 0;
2268
+ const { vbW, pad: layoutPad } = useChartLayout(wrapRef, height, false);
2269
+ const pad = dual ? { ...layoutPad, right: Math.max(layoutPad.right, 64) } : layoutPad;
2270
+ const clipId = `ts-risk-clip-${useId().replace(/:/g, "")}`;
2271
+ const points = sliceByView(allPoints, view, fullSpan);
2272
+ const pricePts = sliceByView(priceOverlay, view, fullSpan);
2273
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
2274
+ const plotW = vbW - pad.left - pad.right;
2275
+ const plotH = height - pad.top - pad.bottom;
2276
+ const plotTop = pad.top;
2277
+ const plotBottom = pad.top + plotH;
2278
+
2279
+ const n = points.length;
2280
+ const xAt = (i: number) => pad.left + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW);
2281
+ const yAt = (val: number) => pad.top + plotH - (val / 100) * plotH;
2282
+
2283
+ const bandEls = RISK_BANDS.map((b) => {
2284
+ const yHi = yAt(b.hi);
2285
+ const yLo = yAt(b.lo);
2286
+ return (
2287
+ <rect
2288
+ key={b.id}
2289
+ x={pad.left}
2290
+ y={yHi}
2291
+ width={plotW}
2292
+ height={Math.max(0, yLo - yHi)}
2293
+ className={"ts-risk-band ts-risk-band-" + b.id}
2294
+ data-band={b.id}
2295
+ />
2296
+ );
2297
+ });
2298
+
2299
+ const ticks = [0, 10, 25, 50, 75, 95, 100];
2300
+ const gridEls: ReactNode[] = ticks.map((tv) => {
2301
+ const y = yAt(tv);
2302
+ return (
2303
+ <g key={`g${tv}`}>
2304
+ <line x1={pad.left} y1={y} x2={vbW - pad.right} y2={y} className="ts-grid" />
2305
+ <text x={pad.left - 8} y={axisLabelY(y, plotTop, plotBottom)} textAnchor="end" className="ts-axis">
2306
+ {tv}
2307
+ </text>
2308
+ </g>
2309
+ );
2310
+ });
2311
+
2312
+ let line = "";
2313
+ for (let i = 0; i < n; i++) {
2314
+ const v = Math.max(0, Math.min(100, points[i].v));
2315
+ line += `${i ? "L" : "M"}${xAt(i).toFixed(1)} ${yAt(v).toFixed(1)} `;
2316
+ }
2317
+
2318
+ const priceMap = new Map(pricePts.map((p) => [p.t, p.v]));
2319
+ const priceVals = points.map((p) => priceMap.get(p.t)).filter((v): v is number => v != null && v > 0);
2320
+ const priceScale = makeScale("log");
2321
+ const priceDom =
2322
+ priceVals.length > 0
2323
+ ? dataDomain(priceVals, priceScale, { padRatio: 0.04 })
2324
+ : { lo: 0, hi: 1 };
2325
+ const yPrice = (val: number) =>
2326
+ pad.top + plotH - ((priceScale.f(val) - priceDom.lo) / (priceDom.hi - priceDom.lo)) * plotH;
2327
+ let priceLine = "";
2328
+ let drawingPrice = false;
2329
+ for (let i = 0; i < n; i++) {
2330
+ const v = priceMap.get(points[i].t);
2331
+ if (v == null || !(v > 0)) {
2332
+ drawingPrice = false;
2333
+ continue;
2334
+ }
2335
+ priceLine += `${drawingPrice ? "L" : "M"}${xAt(i).toFixed(1)} ${yPrice(v).toFixed(1)} `;
2336
+ drawingPrice = true;
2337
+ }
2338
+ const priceTicks =
2339
+ priceVals.length > 0 ? logTicks(priceScale.inv(priceDom.lo), priceScale.inv(priceDom.hi), 4) : [];
2340
+
2341
+ const idxs = n === 0 ? [] : [0, Math.floor((n - 1) / 2), n - 1];
2342
+
2343
+ const onChartMouseMove = useCallback(
2344
+ (e: React.MouseEvent<SVGSVGElement>) => {
2345
+ if (e.buttons !== 0) {
2346
+ setHover(null);
2347
+ return;
2348
+ }
2349
+ const wrap = wrapRef.current;
2350
+ if (!wrap || n === 0) return;
2351
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
2352
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
2353
+ setHover(null);
2354
+ return;
2355
+ }
2356
+ const frac = plotFraction(e.clientX, e.currentTarget, pad.left, pad.right, vbW);
2357
+ const idx = n === 1 ? 0 : Math.round(frac * (n - 1));
2358
+ const pt = points[idx];
2359
+ if (!pt) {
2360
+ setHover(null);
2361
+ return;
2362
+ }
2363
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 180, 72);
2364
+ setHover({
2365
+ ...pos,
2366
+ content: <SeriesTipContent date={pt.t} value={pt.v.toFixed(1)} />,
2367
+ });
2368
+ },
2369
+ [height, n, pad.left, pad.right, plotBottom, plotTop, points, vbW],
2370
+ );
2371
+
2372
+ const onChartMouseLeave = useCallback(() => setHover(null), []);
2373
+
2374
+ return (
2375
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
2376
+ <Svg
2377
+ height={height}
2378
+ vbW={vbW}
2379
+ control={control}
2380
+ onMouseMove={interactive ? onChartMouseMove : undefined}
2381
+ onMouseLeave={interactive ? onChartMouseLeave : undefined}
2382
+ ariaLabel={ariaLabel}
2383
+ >
2384
+ <defs>
2385
+ <clipPath id={clipId}>
2386
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
2387
+ </clipPath>
2388
+ </defs>
2389
+ <g clipPath={`url(#${clipId})`} data-chart-layer="risk-bands">
2390
+ {bandEls}
2391
+ </g>
2392
+ {gridEls}
2393
+ {priceLine ? (
2394
+ <g clipPath={`url(#${clipId})`} data-chart-layer="price-overlay">
2395
+ <path d={priceLine} fill="none" className="ts-line ts-price-faint" />
2396
+ </g>
2397
+ ) : null}
2398
+ {thresholds.map((th) => {
2399
+ const y = yAt(th.value);
2400
+ return (
2401
+ <g key={th.id} data-chart-layer="risk-threshold" data-threshold={th.id}>
2402
+ <line
2403
+ x1={pad.left}
2404
+ y1={y}
2405
+ x2={vbW - pad.right}
2406
+ y2={y}
2407
+ className={"ts-risk-threshold ts-risk-threshold-" + th.id}
2408
+ />
2409
+ <text x={vbW - pad.right - 4} y={y - 4} textAnchor="end" className="ts-risk-threshold-label">
2410
+ {th.label}
2411
+ </text>
2412
+ </g>
2413
+ );
2414
+ })}
2415
+ <g clipPath={`url(#${clipId})`}>
2416
+ <path d={line} fill="none" className="ts-line ts-tone-accent" data-chart-layer="risk-line" />
2417
+ </g>
2418
+ {priceTicks.map((tv, i) => (
2419
+ <text
2420
+ key={`pr${i}`}
2421
+ x={vbW - pad.right + 8}
2422
+ y={axisLabelY(yPrice(tv), plotTop, plotBottom)}
2423
+ textAnchor="start"
2424
+ className="ts-axis ts-axis-mute"
2425
+ >
2426
+ {fmtCompact(tv)}
2427
+ </text>
2428
+ ))}
2429
+ {idxs.map((i, k) => {
2430
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
2431
+ return (
2432
+ <text key={`x${k}`} x={xAt(i)} y={height - 10} textAnchor={anchor} className="ts-axis">
2433
+ {(points[i].t || "").slice(0, 10)}
2434
+ </text>
2435
+ );
2436
+ })}
2437
+ </Svg>
2438
+ </ChartHoverShell>
2439
+ );
2440
+ }
2441
+
2442
+ export interface AllocationFillMarker {
2443
+ t: string;
2444
+ side: "buy" | "sell";
2445
+ book_frac: number;
2446
+ }
2447
+
2448
+ export interface AllocationStepChartProps {
2449
+ allocated: TearsheetSeriesPoint[];
2450
+ markers: AllocationFillMarker[];
2451
+ priceOverlay?: TearsheetSeriesPoint[];
2452
+ height?: number;
2453
+ view?: ViewWindow;
2454
+ onView?: (v: ViewWindow) => void;
2455
+ fullSpan?: [string, string];
2456
+ resetView?: ViewWindow;
2457
+ interactive?: boolean;
2458
+ ariaLabel: string;
2459
+ }
2460
+
2461
+ function stepPath(
2462
+ points: TearsheetSeriesPoint[],
2463
+ xAt: (i: number) => number,
2464
+ yAt: (v: number) => number,
2465
+ ): string {
2466
+ if (points.length === 0) return "";
2467
+ let d = `M${xAt(0).toFixed(1)} ${yAt(points[0].v).toFixed(1)} `;
2468
+ for (let i = 1; i < points.length; i++) {
2469
+ d += `H${xAt(i).toFixed(1)} V${yAt(points[i].v).toFixed(1)} `;
2470
+ }
2471
+ return d;
2472
+ }
2473
+
2474
+ /** Step % allocated (MTM) with sized buy/sell fill dots and faint log price. Cash is the inverse of allocated and is not drawn. */
2475
+ export function AllocationStepChart(props: AllocationStepChartProps) {
2476
+ const { allocated, height = 320 } = props;
2477
+ if (!allocated || allocated.length === 0) return <Empty height={height} msg="no data" />;
2478
+ return <AllocationStepChartBody {...props} height={height} />;
2479
+ }
2480
+
2481
+ function AllocationStepChartBody({
2482
+ allocated: allAllocated,
2483
+ markers,
2484
+ priceOverlay = [],
2485
+ height,
2486
+ view,
2487
+ onView,
2488
+ fullSpan,
2489
+ resetView,
2490
+ interactive = true,
2491
+ ariaLabel,
2492
+ }: AllocationStepChartProps & { height: number }) {
2493
+ const wrapRef = useRef<HTMLDivElement>(null);
2494
+ const [hover, setHover] = useState<ChartHoverTip | null>(null);
2495
+ const { vbW, pad: layoutPad } = useChartLayout(wrapRef, height, false);
2496
+ const pad = { ...layoutPad, right: Math.max(layoutPad.right, 64) };
2497
+ const clipId = `ts-alloc-clip-${useId().replace(/:/g, "")}`;
2498
+ const allocated = sliceByView(allAllocated, view, fullSpan);
2499
+ const pricePts = sliceByView(priceOverlay, view, fullSpan);
2500
+ const control = viewHandlers(view, onView, pad, vbW, resetView);
2501
+ const plotW = vbW - pad.left - pad.right;
2502
+ const plotH = height - pad.top - pad.bottom;
2503
+ const plotTop = pad.top;
2504
+ const plotBottom = pad.top + plotH;
2505
+ const n = allocated.length;
2506
+ const xAt = (i: number) => pad.left + (n <= 1 ? plotW / 2 : (i / (n - 1)) * plotW);
2507
+ const yAt = (val: number) => pad.top + plotH - (val / 100) * plotH;
2508
+ const allocPath = stepPath(allocated, xAt, yAt);
2509
+ const dateIx = new Map(allocated.map((p, i) => [p.t.slice(0, 10), i]));
2510
+
2511
+ const priceMap = new Map(pricePts.map((p) => [p.t, p.v]));
2512
+ const priceVals = allocated
2513
+ .map((p) => priceMap.get(p.t))
2514
+ .filter((v): v is number => v != null && v > 0);
2515
+ const priceScale = makeScale("log");
2516
+ const priceDom =
2517
+ priceVals.length > 0 ? dataDomain(priceVals, priceScale, { padRatio: 0.04 }) : { lo: 0, hi: 1 };
2518
+ const yPrice = (val: number) =>
2519
+ pad.top + plotH - ((priceScale.f(val) - priceDom.lo) / (priceDom.hi - priceDom.lo)) * plotH;
2520
+ let priceLine = "";
2521
+ let drawingPrice = false;
2522
+ for (let i = 0; i < n; i++) {
2523
+ const v = priceMap.get(allocated[i].t);
2524
+ if (v == null || !(v > 0)) {
2525
+ drawingPrice = false;
2526
+ continue;
2527
+ }
2528
+ priceLine += `${drawingPrice ? "L" : "M"}${xAt(i).toFixed(1)} ${yPrice(v).toFixed(1)} `;
2529
+ drawingPrice = true;
2530
+ }
2531
+
2532
+ const ticks = [0, 25, 50, 75, 100];
2533
+ const idxs = n === 0 ? [] : [0, Math.floor((n - 1) / 2), n - 1];
2534
+
2535
+ const onChartMouseMove = useCallback(
2536
+ (e: React.MouseEvent<SVGSVGElement>) => {
2537
+ if (e.buttons !== 0) {
2538
+ setHover(null);
2539
+ return;
2540
+ }
2541
+ const wrap = wrapRef.current;
2542
+ if (!wrap || n === 0) return;
2543
+ const { x: vbX, y: vbY } = viewBoxPoint(e.clientX, e.clientY, e.currentTarget, vbW, height);
2544
+ if (vbX < pad.left || vbX > vbW - pad.right || vbY < plotTop || vbY > plotBottom) {
2545
+ setHover(null);
2546
+ return;
2547
+ }
2548
+ const frac = plotFraction(e.clientX, e.currentTarget, pad.left, pad.right, vbW);
2549
+ const idx = n === 1 ? 0 : Math.round(frac * (n - 1));
2550
+ const pt = allocated[idx];
2551
+ if (!pt) {
2552
+ setHover(null);
2553
+ return;
2554
+ }
2555
+ const pos = positionHoverTip(e.clientX, e.clientY, wrap, 200, 80);
2556
+ setHover({
2557
+ ...pos,
2558
+ content: <SeriesTipContent date={pt.t} value={`${pt.v.toFixed(1)}% allocated`} />,
2559
+ });
2560
+ },
2561
+ [allocated, height, n, pad.left, pad.right, plotBottom, plotTop, vbW],
2562
+ );
2563
+
2564
+ return (
2565
+ <ChartHoverShell hover={interactive ? hover : null} wrapRef={wrapRef}>
2566
+ <Svg
2567
+ height={height}
2568
+ vbW={vbW}
2569
+ control={control}
2570
+ onMouseMove={interactive ? onChartMouseMove : undefined}
2571
+ onMouseLeave={interactive ? () => setHover(null) : undefined}
2572
+ ariaLabel={ariaLabel}
2573
+ >
2574
+ <defs>
2575
+ <clipPath id={clipId}>
2576
+ <rect x={pad.left} y={plotTop} width={plotW} height={plotH} />
2577
+ </clipPath>
2578
+ </defs>
2579
+ {ticks.map((tv) => (
2580
+ <g key={`g${tv}`}>
2581
+ <line x1={pad.left} y1={yAt(tv)} x2={vbW - pad.right} y2={yAt(tv)} className="ts-grid" />
2582
+ <text x={pad.left - 8} y={axisLabelY(yAt(tv), plotTop, plotBottom)} textAnchor="end" className="ts-axis">
2583
+ {tv}%
2584
+ </text>
2585
+ </g>
2586
+ ))}
2587
+ {priceLine ? (
2588
+ <g clipPath={`url(#${clipId})`} data-chart-layer="price-overlay">
2589
+ <path d={priceLine} fill="none" className="ts-line ts-price-faint" />
2590
+ </g>
2591
+ ) : null}
2592
+ <g clipPath={`url(#${clipId})`}>
2593
+ <path d={allocPath} fill="none" className="ts-line ts-tone-accent" data-chart-layer="alloc-step" />
2594
+ </g>
2595
+ <g clipPath={`url(#${clipId})`} data-chart-layer="fill-markers">
2596
+ {markers.map((m, i) => {
2597
+ const idx = dateIx.get(m.t.slice(0, 10));
2598
+ if (idx == null) return null;
2599
+ const r = 3 + 10 * Math.sqrt(Math.min(m.book_frac, 0.25));
2600
+ return (
2601
+ <circle
2602
+ key={`${m.side}-${m.t}-${i}`}
2603
+ cx={xAt(idx)}
2604
+ cy={yAt(allocated[idx].v)}
2605
+ r={r}
2606
+ className={m.side === "buy" ? "ts-fill-dot ts-fill-dot-buy" : "ts-fill-dot ts-fill-dot-sell"}
2607
+ data-side={m.side}
2608
+ />
2609
+ );
2610
+ })}
2611
+ </g>
2612
+ {idxs.map((i, k) => {
2613
+ const anchor = i === 0 ? "start" : i === n - 1 ? "end" : "middle";
2614
+ return (
2615
+ <text key={`x${k}`} x={xAt(i)} y={height - 10} textAnchor={anchor} className="ts-axis">
2616
+ {(allocated[i].t || "").slice(0, 10)}
2617
+ </text>
2618
+ );
2619
+ })}
2620
+ </Svg>
2621
+ </ChartHoverShell>
2622
+ );
2623
+ }
2624
+