@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.
- package/README.md +48 -2
- package/package.json +143 -4
- package/src/components/NavShell.tsx +776 -0
- package/src/components/SocialRow.tsx +87 -0
- package/src/components/Spinner.tsx +27 -0
- package/src/components/StackLogo.tsx +40 -0
- package/src/components/Terminal.tsx +110 -0
- package/src/components/TerminalManifest.tsx +212 -0
- package/src/components/ThemeProvider.tsx +271 -0
- package/src/components/account/AuthCard.tsx +309 -0
- package/src/components/assistant-ui/elements/mermaid-diagram.aui.tsx +63 -0
- package/src/components/assistant-ui/elements/mermaid-diagram.tsx +340 -0
- package/src/components/brand/BrandWordmark.tsx +32 -0
- package/src/components/changelog/ReleaseRail.tsx +81 -0
- package/src/components/changelog/index.ts +1 -0
- package/src/components/chat/ChatCodeBlock.tsx +67 -0
- package/src/components/chat/ChatMarkdown.tsx +66 -0
- package/src/components/chat/ChatMarkdownSource.tsx +203 -0
- package/src/components/chat/ChatMermaidBlock.tsx +202 -0
- package/src/components/chat/ChatMessage.tsx +77 -0
- package/src/components/chat/ChatPlayback.tsx +422 -0
- package/src/components/chat/ChatStreamCursor.tsx +25 -0
- package/src/components/chat/ChatThinking.tsx +113 -0
- package/src/components/chat/ChatToolCall.tsx +190 -0
- package/src/components/chat/ChatTranscript.tsx +60 -0
- package/src/components/chat/ChatWidgetFrame.tsx +107 -0
- package/src/components/chat/DigichatBootLoader.tsx +683 -0
- package/src/components/chat/DigichatLauncher.tsx +361 -0
- package/src/components/chat/DigichatThread.tsx +118 -0
- package/src/components/chat/DigichatThreadList.tsx +54 -0
- package/src/components/chat/DigichatWordmark.tsx +24 -0
- package/src/components/chat/DotMatrix.tsx +650 -0
- package/src/components/chat/boot-lab.tsx +766 -0
- package/src/components/chat/boot-signal.ts +144 -0
- package/src/components/chat/chat-surfaces.ts +52 -0
- package/src/components/chat/connection-status.ts +40 -0
- package/src/components/chat/digichat-wordmark.ts +355 -0
- package/src/components/chat/disclosure-chevron.tsx +49 -0
- package/src/components/chat/followable-href.ts +9 -0
- package/src/components/chat/gallery-thread/action-icons.tsx +25 -0
- package/src/components/chat/gallery-thread/attachment.aui.tsx +262 -0
- package/src/components/chat/gallery-thread/block-caret.tsx +439 -0
- package/src/components/chat/gallery-thread/cn.ts +1 -0
- package/src/components/chat/gallery-thread/composer-keyboard.ts +106 -0
- package/src/components/chat/gallery-thread/composer-trigger-popover.aui.tsx +235 -0
- package/src/components/chat/gallery-thread/css.d.ts +6 -0
- package/src/components/chat/gallery-thread/file.tsx +238 -0
- package/src/components/chat/gallery-thread/follow-up-suggestions.aui.tsx +92 -0
- package/src/components/chat/gallery-thread/format-json-dump.ts +112 -0
- package/src/components/chat/gallery-thread/hooks/use-attachment-src.ts +46 -0
- package/src/components/chat/gallery-thread/hooks/use-copy-to-clipboard.ts +9 -0
- package/src/components/chat/gallery-thread/image.tsx +519 -0
- package/src/components/chat/gallery-thread/markdown-text.tsx +278 -0
- package/src/components/chat/gallery-thread/message-error.aui.tsx +105 -0
- package/src/components/chat/gallery-thread/message-timing.aui.tsx +143 -0
- package/src/components/chat/gallery-thread/private-work-step.tsx +188 -0
- package/src/components/chat/gallery-thread/reasoning.tsx +332 -0
- package/src/components/chat/gallery-thread/scroll-mode.ts +174 -0
- package/src/components/chat/gallery-thread/source.tsx +76 -0
- package/src/components/chat/gallery-thread/thread.aui.tsx +1619 -0
- package/src/components/chat/gallery-thread/tooltip-icon-button.tsx +53 -0
- package/src/components/chat/gallery-thread/typed-welcome-copy.tsx +18 -0
- package/src/components/chat/gallery-thread/ui/tooltip.tsx +22 -0
- package/src/components/chat/gallery-thread/work-chain-summary.tsx +48 -0
- package/src/components/chat/gallery-thread/work-chain.tsx +110 -0
- package/src/components/chat/gallery-thread/work-status.ts +66 -0
- package/src/components/chat/overlay-scrollbar.tsx +86 -0
- package/src/components/chat/sanitize-mermaid-svg.ts +38 -0
- package/src/components/chat/skins/SOURCE.md +36 -0
- package/src/components/chat/skins/base/attachment.aui.tsx +287 -0
- package/src/components/chat/skins/base/aui-default/README.md +45 -0
- package/src/components/chat/skins/base/aui-default/collapsible-root.tsx +19 -0
- package/src/components/chat/skins/base/aui-default/collapsible.tsx +21 -0
- package/src/components/chat/skins/base/aui-default/reasoning.aui.tsx +120 -0
- package/src/components/chat/skins/base/aui-default/reasoning.tsx +331 -0
- package/src/components/chat/skins/base/aui-default/surfaces.ts +7 -0
- package/src/components/chat/skins/base/aui-default/tool-fallback.aui.tsx +772 -0
- package/src/components/chat/skins/base/aui-default/tool-group.aui.tsx +241 -0
- package/src/components/chat/skins/base/docs-model-options.ts +6 -0
- package/src/components/chat/skins/base/elements/assistant-trace.aui.tsx +169 -0
- package/src/components/chat/skins/base/elements/composer-trigger-popover.aui.tsx +271 -0
- package/src/components/chat/skins/base/elements/directive-text.aui.tsx +29 -0
- package/src/components/chat/skins/base/elements/directive-text.tsx +76 -0
- package/src/components/chat/skins/base/elements/message-timing.aui.tsx +104 -0
- package/src/components/chat/skins/base/elements/model-selector.aui.tsx +140 -0
- package/src/components/chat/skins/base/elements/model-selector.tsx +720 -0
- package/src/components/chat/skins/base/elements/quote.aui.tsx +290 -0
- package/src/components/chat/skins/base/elements/work-chain.tsx +100 -0
- package/src/components/chat/skins/base/markdown-text.tsx +294 -0
- package/src/components/chat/skins/base/model.ts +1 -0
- package/src/components/chat/skins/base/thread.tsx +1050 -0
- package/src/components/chat/skins/base/tooltip-icon-button.tsx +56 -0
- package/src/components/chat/skins/base/ui/avatar.tsx +115 -0
- package/src/components/chat/skins/base/ui/badge.tsx +51 -0
- package/src/components/chat/skins/base/ui/button.tsx +67 -0
- package/src/components/chat/skins/base/ui/command.tsx +185 -0
- package/src/components/chat/skins/base/ui/dialog.tsx +168 -0
- package/src/components/chat/skins/base/ui/dot-matrix.tsx +267 -0
- package/src/components/chat/skins/base/ui/popover.tsx +97 -0
- package/src/components/chat/skins/base/ui/skeleton.tsx +20 -0
- package/src/components/chat/skins/base/ui/textarea.tsx +24 -0
- package/src/components/chat/skins/base/ui/tooltip.tsx +73 -0
- package/src/components/chat/skins/base-assistant-ui/config-provider.tsx +90 -0
- package/src/components/chat/skins/base-assistant-ui/defaults.ts +419 -0
- package/src/components/chat/skins/base-assistant-ui/index.tsx +44 -0
- package/src/components/chat/skins/chatgpt.tsx +448 -0
- package/src/components/chat/skins/claude.tsx +380 -0
- package/src/components/chat/skins/digichat-composer-toolbar.tsx +151 -0
- package/src/components/chat/skins/digichat-context-meter-shares.ts +158 -0
- package/src/components/chat/skins/digichat-context-meter.tsx +243 -0
- package/src/components/chat/skins/digichat-meter-tone.ts +26 -0
- package/src/components/chat/skins/digichat-usage-meter.tsx +103 -0
- package/src/components/chat/skins/digichat.tsx +144 -0
- package/src/components/chat/skins/expo-react-native.tsx +29 -0
- package/src/components/chat/skins/gemini.tsx +322 -0
- package/src/components/chat/skins/grok-icon.tsx +34 -0
- package/src/components/chat/skins/grok.tsx +369 -0
- package/src/components/chat/skins/index.ts +46 -0
- package/src/components/chat/skins/perplexity.tsx +462 -0
- package/src/components/chat/skins/product-page-assistant/assistant-modal.tsx +73 -0
- package/src/components/chat/skins/product-page-assistant/index.tsx +19 -0
- package/src/components/chat/skins/product-page-assistant/product-dashboard.tsx +164 -0
- package/src/components/chat/skins/react-ink.tsx +97 -0
- package/src/components/chat/skins/thread-skin-host-contract.ts +102 -0
- package/src/components/chat/skins/thread-skin-view.tsx +156 -0
- package/src/components/chat/skins/thread-skins.ts +143 -0
- package/src/components/chat/skins/tooltip-icon-button.tsx +35 -0
- package/src/components/chat/skins/ui/dropdown-menu.tsx +272 -0
- package/src/components/chat/skins/webpage-assistant/assistant-config.ts +238 -0
- package/src/components/chat/skins/webpage-assistant/assistant-modal.tsx +78 -0
- package/src/components/chat/skins/webpage-assistant/config-provider.tsx +132 -0
- package/src/components/chat/skins/webpage-assistant/docs-app.tsx +106 -0
- package/src/components/chat/skins/webpage-assistant/docs-article.tsx +109 -0
- package/src/components/chat/skins/webpage-assistant/docs-assistant.tsx +73 -0
- package/src/components/chat/skins/webpage-assistant/docs-nav.tsx +81 -0
- package/src/components/chat/skins/webpage-assistant/host-ui.ts +79 -0
- package/src/components/chat/skins/webpage-assistant/index.tsx +12 -0
- package/src/components/chat/skins/webpage-assistant/markdown-page.tsx +11 -0
- package/src/components/chat/skins/webpage-assistant/pages.ts +9 -0
- package/src/components/chat/skins/webpage-assistant/types.ts +178 -0
- package/src/components/chat/skins/webpage-assistant/use-mobile.ts +19 -0
- package/src/components/chat/stock/SOURCE.md +90 -0
- package/src/components/chat/stock/artifacts-panel.tsx +305 -0
- package/src/components/chat/stock/attachment.aui.tsx +280 -0
- package/src/components/chat/stock/chat-notice.tsx +143 -0
- package/src/components/chat/stock/chat-private-mode.tsx +64 -0
- package/src/components/chat/stock/deploy-ui-context.tsx +89 -0
- package/src/components/chat/stock/embed-chat-prefs.tsx +471 -0
- package/src/components/chat/stock/file.tsx +265 -0
- package/src/components/chat/stock/follow-up-suggestions.aui.tsx +83 -0
- package/src/components/chat/stock/image.tsx +531 -0
- package/src/components/chat/stock/index.ts +129 -0
- package/src/components/chat/stock/json-view.tsx +140 -0
- package/src/components/chat/stock/markdown-text.tsx +319 -0
- package/src/components/chat/stock/message-error.aui.tsx +96 -0
- package/src/components/chat/stock/message-export-menu.tsx +156 -0
- package/src/components/chat/stock/palette-row-preview.tsx +26 -0
- package/src/components/chat/stock/reasoning.aui.tsx +120 -0
- package/src/components/chat/stock/reasoning.tsx +335 -0
- package/src/components/chat/stock/skin-chrome.tsx +81 -0
- package/src/components/chat/stock/skin-credit.tsx +63 -0
- package/src/components/chat/stock/skin-palette-bridge.tsx +235 -0
- package/src/components/chat/stock/skin-palette-context.ts +16 -0
- package/src/components/chat/stock/skin-runtime.tsx +52 -0
- package/src/components/chat/stock/skin-slash-palette.tsx +393 -0
- package/src/components/chat/stock/skin-types.ts +217 -0
- package/src/components/chat/stock/stock-chat-prefs-host.tsx +501 -0
- package/src/components/chat/stock/stock-send-gate.tsx +94 -0
- package/src/components/chat/stock/stock-shape.tsx +39 -0
- package/src/components/chat/stock/thread.aui.tsx +645 -0
- package/src/components/chat/stock/tool-display.ts +6 -0
- package/src/components/chat/stock/tool-fallback.aui.tsx +866 -0
- package/src/components/chat/stock/tool-group.aui.tsx +244 -0
- package/src/components/chat/stock/tool-surface.ts +120 -0
- package/src/components/chat/stock/tooltip-icon-button.tsx +49 -0
- package/src/components/chat/stock/ui/avatar.tsx +108 -0
- package/src/components/chat/stock/ui/button.tsx +81 -0
- package/src/components/chat/stock/ui/collapsible.tsx +21 -0
- package/src/components/chat/stock/ui/dialog.tsx +163 -0
- package/src/components/chat/stock/ui/skeleton.tsx +13 -0
- package/src/components/chat/stock/ui/textarea.tsx +20 -0
- package/src/components/chat/stock/ui/tooltip.tsx +68 -0
- package/src/components/chat/stock/use-attachment-src.ts +42 -0
- package/src/components/chat/stock/use-copy-to-clipboard.ts +29 -0
- package/src/components/chat/strip-model-boilerplate.ts +42 -0
- package/src/components/chat/suggestion-cycle.tsx +230 -0
- package/src/components/chat/suggestion-scroll-row.tsx +81 -0
- package/src/components/chat/trace-grouping/chain-group-by.ts +44 -0
- package/src/components/chat/trace-grouping/chain-state.ts +141 -0
- package/src/components/chat/trace-grouping/index.ts +32 -0
- package/src/components/chat/trace-grouping/tool-row.ts +74 -0
- package/src/components/chat/trace-grouping/use-deploy-work-disclosure.ts +35 -0
- package/src/components/chat/trace-grouping/use-work-chain.ts +103 -0
- package/src/components/chat/trace-i18n/core.ts +48 -0
- package/src/components/chat/trace-i18n/index.tsx +23 -0
- package/src/components/chat/trace-i18n/locales/en.ts +24 -0
- package/src/components/chat/trace-i18n/locales/fr.ts +28 -0
- package/src/components/chat/trace-i18n/types.ts +35 -0
- package/src/components/chat/trace-spacing.ts +249 -0
- package/src/components/chat/transcript/activity-view.ts +375 -0
- package/src/components/chat/transcript/chat-export.ts +133 -0
- package/src/components/chat/transcript/index.ts +10 -0
- package/src/components/chat/transcript/save-file.ts +209 -0
- package/src/components/chat/transcript/transcript-markdown.ts +428 -0
- package/src/components/chat/transcript/types.ts +81 -0
- package/src/components/chat/turn-outcome.ts +52 -0
- package/src/components/chat/use-warmup-phase.ts +18 -0
- package/src/components/chat/work-status-lead.tsx +49 -0
- package/src/components/chrome.tsx +264 -0
- package/src/components/command-palette/CommandPalette.tsx +404 -0
- package/src/components/command-palette/index.ts +6 -0
- package/src/components/contact/ContactMailto.tsx +112 -0
- package/src/components/contact/index.ts +5 -0
- package/src/components/conviction/ConvictionMeter.tsx +40 -0
- package/src/components/conviction/SignedConvictionBadge.tsx +26 -0
- package/src/components/conviction/index.ts +7 -0
- package/src/components/data-layout/BentoGrid.tsx +52 -0
- package/src/components/data-layout/CardRail.tsx +150 -0
- package/src/components/data-layout/DotMatrixStat.tsx +65 -0
- package/src/components/data-layout/FeatureCell.tsx +80 -0
- package/src/components/data-layout/MediaFrame.tsx +125 -0
- package/src/components/data-layout/ModuleGrid.tsx +735 -0
- package/src/components/data-layout/Odometer.tsx +149 -0
- package/src/components/data-layout/ProductFrame.tsx +79 -0
- package/src/components/data-layout/TestimonialWall.tsx +139 -0
- package/src/components/data-layout/index.ts +19 -0
- package/src/components/data-layout/treemap.ts +368 -0
- package/src/components/deck/DeckStack.tsx +243 -0
- package/src/components/desk/DataTable.tsx +373 -0
- package/src/components/desk/KpiGrid.tsx +80 -0
- package/src/components/desk/Sparkline.tsx +63 -0
- package/src/components/desk/StateBlock.tsx +132 -0
- package/src/components/desk/index.ts +18 -0
- package/src/components/diagrams/ArchitectureDiagram.tsx +350 -0
- package/src/components/diagrams/ArchitectureTour.tsx +912 -0
- package/src/components/diagrams/TerminalSchematic.tsx +192 -0
- package/src/components/diagrams/architecture-svg.tsx +384 -0
- package/src/components/diagrams/index.ts +30 -0
- package/src/components/diagrams/mermaid-theme.ts +115 -0
- package/src/components/diagrams/tour-camera.ts +171 -0
- package/src/components/docs/CodeTabs.tsx +90 -0
- package/src/components/docs/CopyCommand.tsx +151 -0
- package/src/components/docs/DocsLayout.tsx +197 -0
- package/src/components/docs/DocsSearch.tsx +97 -0
- package/src/components/docs/Endpoint.tsx +135 -0
- package/src/components/document/DocumentFrame.tsx +43 -0
- package/src/components/document/GlyphList.tsx +51 -0
- package/src/components/document/PageTitle.tsx +44 -0
- package/src/components/document/Prose.tsx +38 -0
- package/src/components/document/Section.tsx +55 -0
- package/src/components/document/index.ts +13 -0
- package/src/components/effects-chrome/AnnouncementBar.tsx +88 -0
- package/src/components/effects-chrome/HorizontalScrollTrack.tsx +461 -0
- package/src/components/effects-chrome/Pipeline.tsx +226 -0
- package/src/components/effects-chrome/RotatingPrompts.tsx +116 -0
- package/src/components/effects-chrome/StackingPanels.tsx +101 -0
- package/src/components/effects-chrome/TabStrip.tsx +232 -0
- package/src/components/effects-chrome/ToastStack.tsx +201 -0
- package/src/components/effects-chrome/horizontal-track-core.ts +70 -0
- package/src/components/effects-chrome/index.ts +35 -0
- package/src/components/emblems.tsx +188 -0
- package/src/components/faviconTheme.ts +23 -0
- package/src/components/figure/Figure.tsx +38 -0
- package/src/components/figure/index.ts +2 -0
- package/src/components/finance-charts/DrawdownPlot.tsx +76 -0
- package/src/components/finance-charts/EquityCurve.tsx +71 -0
- package/src/components/finance-charts/PriceChart.tsx +119 -0
- package/src/components/finance-charts/chart-host.tsx +236 -0
- package/src/components/finance-charts/chart-scale.ts +93 -0
- package/src/components/finance-charts/demo-data.ts +88 -0
- package/src/components/finance-charts/index.ts +46 -0
- package/src/components/finance-charts/lw-chart.tsx +292 -0
- package/src/components/finance-composites/MarketBar.tsx +243 -0
- package/src/components/finance-composites/OrderBook.tsx +133 -0
- package/src/components/finance-composites/PerformanceDashboard.tsx +185 -0
- package/src/components/finance-composites/SortableTable.tsx +144 -0
- package/src/components/finance-composites/StockTicker.tsx +65 -0
- package/src/components/finance-composites/SyncedTearsheet.tsx +226 -0
- package/src/components/finance-composites/index.ts +28 -0
- package/src/components/finance-tearsheet/KpiStrip.tsx +49 -0
- package/src/components/finance-tearsheet/LiveBadge.tsx +29 -0
- package/src/components/finance-tearsheet/ReturnsMatrix.tsx +321 -0
- package/src/components/finance-tearsheet/StrategyTearsheet.tsx +99 -0
- package/src/components/finance-tearsheet/TearsheetCard.tsx +54 -0
- package/src/components/finance-tearsheet/TradeLogTable.tsx +73 -0
- package/src/components/finance-tearsheet/charts.tsx +2624 -0
- package/src/components/finance-tearsheet/demo-data.ts +177 -0
- package/src/components/finance-tearsheet/format.ts +41 -0
- package/src/components/finance-tearsheet/index.ts +120 -0
- package/src/components/finance-tearsheet/print.ts +52 -0
- package/src/components/finance-tearsheet/risk-bands.ts +48 -0
- package/src/components/finance-tearsheet/stats.ts +35 -0
- package/src/components/finance-tearsheet/strategy-sheet-model.ts +238 -0
- package/src/components/finance-tearsheet/types.ts +58 -0
- package/src/components/footer/FooterCells.tsx +109 -0
- package/src/components/footer/index.ts +6 -0
- package/src/components/graph.tsx +158 -0
- package/src/components/icons.tsx +98 -0
- package/src/components/landing/PixelWordmark.tsx +36 -0
- package/src/components/logos.ts +39 -0
- package/src/components/marquee/Marquee.tsx +204 -0
- package/src/components/metrics/PerfMetrics.tsx +68 -0
- package/src/components/metrics/StatCounter.tsx +104 -0
- package/src/components/page-geometry.tsx +177 -0
- package/src/components/pricing/Pricing.tsx +174 -0
- package/src/components/pricing/PricingMatrix.tsx +219 -0
- package/src/components/prose/index.ts +2 -0
- package/src/components/prose/prose.tsx +86 -0
- package/src/components/repo-activity/RepoActivity.tsx +599 -0
- package/src/components/repo-activity/RepoHeatmap.tsx +225 -0
- package/src/components/repo-activity/demo.ts +201 -0
- package/src/components/repo-activity/fetch.ts +211 -0
- package/src/components/repo-activity/heatmap.ts +123 -0
- package/src/components/repo-activity/index.ts +28 -0
- package/src/components/repo-activity/types.ts +99 -0
- package/src/components/stages/NumberedStages.tsx +116 -0
- package/src/components/symbols/marks.tsx +116 -0
- package/src/components/symbols/terminal-lockup.tsx +136 -0
- package/src/components/symbols/terminal-marks.tsx +226 -0
- package/src/components/terminal/ChatResponseLoader.tsx +67 -0
- package/src/components/terminal/ContainerBootLoader.tsx +82 -0
- package/src/components/terminal/TerminalStepCaret.tsx +202 -0
- package/src/components/themeInit.ts +24 -0
- package/src/components/typography/MotionReveal.tsx +108 -0
- package/src/components/typography/TypewriterReveal.tsx +101 -0
- package/src/components/typography/WordReveal.tsx +143 -0
- package/src/data/modules.ts +378 -0
- package/src/data/subsystems.ts +101 -0
- package/src/index.ts +587 -0
- package/src/lib/base-path.ts +60 -0
- package/src/lib/cx.ts +25 -0
- package/src/lib/gloomberb.ts +69 -0
- package/src/lib/record.ts +19 -0
- package/src/lib/stub-payload.ts +183 -0
- package/src/lib/useBodyScrollLock.ts +44 -0
- package/src/lib/utils.ts +6 -0
- package/src/motion/primitives.tsx +105 -0
- package/src/motion/scroll-glide.ts +62 -0
- package/src/motion/scrolly-core.ts +38 -0
- package/src/motion/scrolly.tsx +95 -0
- package/src/navigation/HashScrollManager.tsx +167 -0
- package/src/navigation/hashScroll.ts +78 -0
- package/src/replay/browser.ts +280 -0
- package/src/replay/chat-export-run.ts +121 -0
- package/src/replay/export-format.ts +25 -0
- package/src/replay/export-mount.ts +32 -0
- package/src/replay/filename.ts +54 -0
- package/src/replay/index.ts +21 -0
- package/src/replay/redact-dom.ts +178 -0
- package/src/replay/redact.ts +113 -0
- package/src/replay/snapshot.ts +659 -0
- package/src/styles/account-auth.css +339 -0
- package/src/styles/brand-wordmark.css +11 -0
- package/src/styles/card-rail.css +120 -0
- package/src/styles/chat-aui.css +1143 -0
- package/src/styles/chat-core.css +469 -0
- package/src/styles/chat-digichat.css +2531 -0
- package/src/styles/chat-math.css +70 -0
- package/src/styles/chat-skin-palette.css +138 -0
- package/src/styles/chat-stock-menu.css +37 -0
- package/src/styles/chat-widgets.css +190 -0
- package/src/styles/command-palette.css +372 -0
- package/src/styles/controls-core.css +2261 -0
- package/src/styles/controls-overlay.css +603 -0
- package/src/styles/data-layout.css +288 -0
- package/src/styles/deck.css +141 -0
- package/src/styles/diagrams.css +1860 -0
- package/src/styles/digichat-app-theme.css +250 -0
- package/src/styles/digichat-boot-loader.css +487 -0
- package/src/styles/digichat-launcher.css +377 -0
- package/src/styles/docs.css +165 -0
- package/src/styles/effects-chrome.css +888 -0
- package/src/styles/finance-composites.css +214 -0
- package/src/styles/finance-tearsheet-overlay.css +90 -0
- package/src/styles/finance-tearsheet.css +945 -0
- package/src/styles/marquee.css +168 -0
- package/src/styles/metrics.css +82 -0
- package/src/styles/nav-shell.css +551 -0
- package/src/styles/pricing.css +79 -0
- package/src/styles/repo-activity.css +575 -0
- package/src/styles/stages.css +69 -0
- package/src/styles/terminal-loaders.css +122 -0
- package/src/styles/terminal-manifest.css +228 -0
- package/src/styles/web-theme.css +1007 -0
- package/src/styles/word-reveal.css +204 -0
- package/src/theme/apply-theme.ts +214 -0
- package/src/theme/index.ts +27 -0
- package/src/theme/palette-registry.ts +855 -0
- package/src/theme/tui-prefs.ts +64 -0
- package/src/ui/alert.tsx +78 -0
- package/src/ui/avatar.tsx +104 -0
- package/src/ui/badge.tsx +75 -0
- package/src/ui/breadcrumb.tsx +61 -0
- package/src/ui/button.tsx +67 -0
- package/src/ui/card.tsx +139 -0
- package/src/ui/checkbox.tsx +27 -0
- package/src/ui/collapsible.tsx +32 -0
- package/src/ui/date-pager.tsx +325 -0
- package/src/ui/dialog.tsx +157 -0
- package/src/ui/dropdown-menu.tsx +267 -0
- package/src/ui/empty-state.tsx +165 -0
- package/src/ui/field.tsx +104 -0
- package/src/ui/form.tsx +36 -0
- package/src/ui/icon-button.tsx +36 -0
- package/src/ui/index.ts +34 -0
- package/src/ui/input.tsx +33 -0
- package/src/ui/kbd.tsx +30 -0
- package/src/ui/label.tsx +32 -0
- package/src/ui/pager.tsx +119 -0
- package/src/ui/pagination.tsx +126 -0
- package/src/ui/radio-group.tsx +51 -0
- package/src/ui/search-bar.tsx +85 -0
- package/src/ui/segmented-control.tsx +76 -0
- package/src/ui/select.tsx +257 -0
- package/src/ui/separator.tsx +24 -0
- package/src/ui/sheet.tsx +141 -0
- package/src/ui/skeleton.tsx +76 -0
- package/src/ui/slider.tsx +76 -0
- package/src/ui/switch.tsx +31 -0
- package/src/ui/table.tsx +179 -0
- package/src/ui/tabs.tsx +89 -0
- package/src/ui/tags-input.tsx +168 -0
- package/src/ui/textarea.tsx +17 -0
- 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&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
|
+
|