gloomberb 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (489) hide show
  1. package/README.md +6 -4
  2. package/README.zh-CN.md +4 -2
  3. package/package.json +28 -1
  4. package/src/api-client/auth.ts +10 -0
  5. package/src/api-client/data.ts +103 -1
  6. package/src/api-client/index.test.ts +127 -0
  7. package/src/api-client/index.ts +129 -1
  8. package/src/api-client/normalizers.ts +39 -0
  9. package/src/api-client/paths.ts +85 -1
  10. package/src/api-client/types.ts +196 -0
  11. package/src/app/pane-runtime/index.tsx +15 -12
  12. package/src/app/pane-runtime/layout-placement.test.ts +27 -0
  13. package/src/app/pane-runtime/layout-placement.ts +13 -0
  14. package/src/app/pane-runtime/plugin-bindings.ts +62 -0
  15. package/src/app.tsx +60 -18
  16. package/src/architecture/browser-entry-graph.test.ts +96 -0
  17. package/src/brokers/profile-form.test.ts +25 -27
  18. package/src/brokers/test-broker.ts +68 -0
  19. package/src/cli/commands/plugins.ts +39 -11
  20. package/src/cli/desktop-pane-shot.ts +285 -16
  21. package/src/cli/index.ts +1 -1
  22. package/src/cli/pane-functions/capabilities.ts +58 -28
  23. package/src/cli/pane-functions/catalog.ts +16 -4
  24. package/src/cli/pane-functions/discovery.ts +1 -0
  25. package/src/cli/pane-functions/headless.test.ts +247 -0
  26. package/src/cli/pane-functions/headless.ts +423 -0
  27. package/src/cli/pane-functions/index.test.ts +9 -0
  28. package/src/cli/pane-functions/index.ts +6 -2
  29. package/src/cli/pane-functions/options.ts +27 -2
  30. package/src/cli/pane-functions/report.ts +4 -0
  31. package/src/cli/pane-functions/resolver.ts +26 -8
  32. package/src/cli/pane-functions/screenshot.test.ts +76 -0
  33. package/src/cli/pane-functions/screenshot.ts +236 -20
  34. package/src/cli/restore-plugins.ts +34 -0
  35. package/src/components/chart/{static/chart → composite}/axis-overlays.test.tsx +3 -3
  36. package/src/components/chart/{static/chart → composite}/axis-overlays.tsx +6 -11
  37. package/src/components/chart/{static/chart → composite}/bitmap.ts +17 -3
  38. package/src/components/chart/composite/composite-chart.test.tsx +68 -20
  39. package/src/components/chart/composite/composite-chart.tsx +301 -204
  40. package/src/components/chart/composite/format.ts +8 -2
  41. package/src/components/chart/composite/interactions.test.ts +68 -102
  42. package/src/components/chart/composite/interactions.ts +214 -264
  43. package/src/components/chart/composite/price-axis-labels.test.ts +21 -0
  44. package/src/components/chart/{price-axis-labels.tsx → composite/price-axis-labels.tsx} +9 -4
  45. package/src/components/chart/composite/scene.ts +57 -3
  46. package/src/components/chart/composite/text-renderer.ts +3 -2
  47. package/src/components/chart/composite/time-scale.ts +138 -57
  48. package/src/components/chart/composite/types.ts +25 -1
  49. package/src/components/chart/core/data.ts +9 -13
  50. package/src/components/chart/core/date-window.ts +7 -1
  51. package/src/components/chart/core/pointer.ts +3 -0
  52. package/src/components/chart/core/types.ts +0 -45
  53. package/src/components/chart/native/chart-rasterizer.ts +0 -1
  54. package/src/components/chart/native/kitty/index.test.ts +0 -188
  55. package/src/components/chart/native/raster/crosshair.ts +6 -5
  56. package/src/components/chart/native/raster/primitives.ts +0 -23
  57. package/src/components/chart/native/renderer-selection.ts +81 -31
  58. package/src/components/chart/static/{chart/surface.test.tsx → chart-surface.test.tsx} +28 -80
  59. package/src/components/chart/static/chart-surface.tsx +150 -0
  60. package/src/components/chart/static/index.ts +8 -0
  61. package/src/components/chart/static/multi-line-chart-surface.tsx +51 -166
  62. package/src/components/chart/static/scatter-chart-surface.test.ts +30 -0
  63. package/src/components/chart/static/scatter-chart-surface.tsx +135 -34
  64. package/src/components/chart/static/series.ts +49 -0
  65. package/src/components/command-bar/action-runtime.ts +1 -3
  66. package/src/components/command-bar/assist/model.test.ts +17 -2
  67. package/src/components/command-bar/assist/model.ts +40 -9
  68. package/src/components/command-bar/commands/direct/index.ts +1 -1
  69. package/src/components/command-bar/commands/direct/runtime.ts +1 -1
  70. package/src/components/command-bar/commands/registry.ts +0 -11
  71. package/src/components/command-bar/helpers.test.ts +2 -2
  72. package/src/components/command-bar/helpers.ts +2 -6
  73. package/src/components/command-bar/keyboard-shortcuts.ts +0 -11
  74. package/src/components/command-bar/list/badge.test.ts +54 -0
  75. package/src/components/command-bar/list/badge.ts +62 -0
  76. package/src/components/command-bar/list/model.test.ts +103 -0
  77. package/src/components/command-bar/list/model.ts +61 -4
  78. package/src/components/command-bar/list/view.test.tsx +113 -0
  79. package/src/components/command-bar/list/view.tsx +147 -131
  80. package/src/components/command-bar/pane-templates/workflow.ts +1 -1
  81. package/src/components/command-bar/panel/body.tsx +51 -65
  82. package/src/components/command-bar/panel/index.tsx +48 -13
  83. package/src/components/command-bar/panel/layout.test.ts +139 -0
  84. package/src/components/command-bar/panel/layout.ts +65 -28
  85. package/src/components/command-bar/panel/native-surface.ts +24 -0
  86. package/src/components/command-bar/panel/prompt-binding.ts +54 -0
  87. package/src/components/command-bar/panel/runtime.ts +6 -3
  88. package/src/components/command-bar/panel/state.ts +41 -7
  89. package/src/components/command-bar/panel/types.ts +7 -1
  90. package/src/components/command-bar/routes/root/provider-search.ts +34 -15
  91. package/src/components/command-bar/routes/root/results.test.ts +161 -0
  92. package/src/components/command-bar/routes/root/results.ts +30 -28
  93. package/src/components/command-bar/routes/root/runtime.ts +37 -20
  94. package/src/components/command-bar/routes/root/search-providers.test.tsx +224 -0
  95. package/src/components/command-bar/routes/root/search-providers.ts +205 -0
  96. package/src/components/command-bar/routes/root/selection.ts +1 -12
  97. package/src/components/command-bar/routes/root/shortcuts.test.ts +8 -8
  98. package/src/components/command-bar/routes/ticker-search/actions.ts +10 -9
  99. package/src/components/command-bar/routes/ticker-search/results.test.ts +54 -1
  100. package/src/components/command-bar/routes/ticker-search/results.ts +60 -19
  101. package/src/components/command-bar/routes/ticker-search/route.ts +1 -0
  102. package/src/components/command-bar/routing/actions.ts +1 -81
  103. package/src/components/command-bar/routing/launch-request.ts +1 -1
  104. package/src/components/command-bar/routing/list-state.ts +6 -21
  105. package/src/components/command-bar/selection-runtime.ts +1 -1
  106. package/src/components/command-bar/surface/assist.test.tsx +14 -10
  107. package/src/components/command-bar/surface/environment.ts +8 -1
  108. package/src/components/command-bar/surface/index.test.tsx +84 -68
  109. package/src/components/command-bar/surface/index.tsx +33 -36
  110. package/src/components/command-bar/surface/test-harness.tsx +15 -3
  111. package/src/components/command-bar/theme-picker.tsx +43 -15
  112. package/src/components/command-bar/view-model.test.ts +53 -8
  113. package/src/components/command-bar/view-model.ts +43 -14
  114. package/src/components/command-bar/workflow/tickers.ts +1 -0
  115. package/src/components/command-bar/workflow/types.ts +1 -1
  116. package/src/components/data-table/view.test.tsx +45 -6
  117. package/src/components/data-table/view.tsx +55 -9
  118. package/src/components/index.ts +10 -2
  119. package/src/components/input-search-bar.test.tsx +36 -2
  120. package/src/components/input-search-bar.tsx +18 -1
  121. package/src/components/layout/header.test.tsx +57 -10
  122. package/src/components/layout/header.tsx +292 -149
  123. package/src/components/layout/market-summary.test.ts +81 -0
  124. package/src/components/layout/market-summary.ts +109 -0
  125. package/src/components/layout/shell/chrome.ts +96 -1
  126. package/src/components/layout/shell/index.tsx +11 -3
  127. package/src/components/layout/shell/menu.ts +9 -0
  128. package/src/components/layout/shell/pane/actions.ts +29 -0
  129. package/src/components/layout/shell/pane/management-shortcuts.ts +5 -0
  130. package/src/components/layout/shell/shortcuts.ts +3 -0
  131. package/src/components/layout/status-bar.test.tsx +9 -8
  132. package/src/components/layout/status-bar.tsx +85 -54
  133. package/src/components/onboarding/onboarding-frame.tsx +2 -26
  134. package/src/components/price-sparkline/index.ts +61 -34
  135. package/src/components/price-sparkline/view.tsx +1 -1
  136. package/src/components/sign-in-gate.test.tsx +128 -0
  137. package/src/components/sign-in-gate.tsx +149 -0
  138. package/src/components/speedometer-gauge/desktop.tsx +2 -12
  139. package/src/components/ticker/badge/list.tsx +17 -3
  140. package/src/components/ticker/list-table-view.tsx +6 -3
  141. package/src/components/ui/data-table/index.tsx +3 -2
  142. package/src/components/ui/data-table/opentui/index.tsx +165 -120
  143. package/src/components/ui/frame.tsx +28 -0
  144. package/src/components/ui/index.ts +3 -0
  145. package/src/components/ui/native-select.tsx +20 -10
  146. package/src/components/ui/select-button.tsx +138 -0
  147. package/src/components/ui/status.tsx +2 -2
  148. package/src/components/ui/table-layout.ts +1 -1
  149. package/src/components/use-double-click-activation.ts +7 -3
  150. package/src/core/app-services.ts +3 -3
  151. package/src/data/config/layout.ts +12 -6
  152. package/src/data/config/store/index.test.ts +97 -0
  153. package/src/data/config/store/migrations.ts +41 -0
  154. package/src/data/config/store/normalize.ts +5 -3
  155. package/src/i18n/es.ts +6 -2
  156. package/src/i18n/ja.ts +6 -2
  157. package/src/i18n/ko.ts +6 -2
  158. package/src/i18n/zh-cn.ts +6 -2
  159. package/src/i18n/zh-tw.ts +6 -2
  160. package/src/layout-marketplace/pane-imagery.ts +1 -0
  161. package/src/layout-marketplace/payload.test.ts +17 -0
  162. package/src/layout-marketplace/payload.ts +40 -5
  163. package/src/market-data/coordinator/index.ts +3 -2
  164. package/src/market-data/coordinator/quotes.ts +173 -66
  165. package/src/market-data/coordinator/subscriptions.test.ts +215 -37
  166. package/src/pane-settings.ts +1 -1
  167. package/src/plugins/builtin/analytics/pane-model.ts +1 -1
  168. package/src/plugins/builtin/analytics/view.tsx +1 -1
  169. package/src/plugins/builtin/broker-manager/index.test.tsx +3 -2
  170. package/src/plugins/builtin/broker-manager/model.test.ts +2 -2
  171. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +16 -2
  172. package/src/plugins/builtin/chart-composer/pane.tsx +22 -8
  173. package/src/plugins/builtin/chat/content/composer.tsx +8 -4
  174. package/src/plugins/builtin/cloud/auth-dialog.tsx +14 -228
  175. package/src/plugins/builtin/cloud/auth-form.tsx +277 -0
  176. package/src/plugins/builtin/cloud/plugin.tsx +1 -0
  177. package/src/plugins/builtin/composite-plugins.ts +14 -1
  178. package/src/plugins/builtin/correlation/relationship/model.ts +1 -1
  179. package/src/plugins/builtin/correlation/relationship/pane.tsx +2 -6
  180. package/src/plugins/builtin/correlation/relationship/view-model.ts +2 -2
  181. package/src/plugins/builtin/credit-conditions/index.test.tsx +12 -6
  182. package/src/plugins/builtin/credit-conditions/index.tsx +2 -21
  183. package/src/plugins/builtin/dividend-yield/index.tsx +1 -0
  184. package/src/plugins/builtin/dividend-yield/pane.tsx +1 -1
  185. package/src/plugins/builtin/earnings-calls/data.ts +223 -0
  186. package/src/plugins/builtin/earnings-calls/format.ts +49 -0
  187. package/src/plugins/builtin/earnings-calls/index.tsx +69 -0
  188. package/src/plugins/builtin/earnings-calls/pane.tsx +756 -0
  189. package/src/plugins/builtin/earnings-calls/prose.test.ts +36 -0
  190. package/src/plugins/builtin/earnings-calls/prose.ts +59 -0
  191. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +334 -0
  192. package/src/plugins/builtin/econ/detail-view.tsx +1 -2
  193. package/src/plugins/builtin/econ/fred-series-map.ts +16 -1
  194. package/src/plugins/builtin/econ/index.tsx +2 -1
  195. package/src/plugins/builtin/econ-statistics/cache.ts +4 -0
  196. package/src/plugins/builtin/econ-statistics/client.ts +92 -0
  197. package/src/plugins/builtin/econ-statistics/defs.ts +66 -0
  198. package/src/plugins/builtin/econ-statistics/detail.tsx +154 -0
  199. package/src/plugins/builtin/econ-statistics/headless.test.ts +54 -0
  200. package/src/plugins/builtin/econ-statistics/headless.ts +171 -0
  201. package/src/plugins/builtin/econ-statistics/index.tsx +54 -0
  202. package/src/plugins/builtin/econ-statistics/pane.tsx +356 -0
  203. package/src/plugins/builtin/econ-statistics/stats.ts +522 -0
  204. package/src/plugins/builtin/econ-statistics/transform.test.ts +86 -0
  205. package/src/plugins/builtin/econ-statistics/transform.ts +85 -0
  206. package/src/plugins/builtin/econ-statistics/trend.ts +57 -0
  207. package/src/plugins/builtin/econ-statistics/view.ts +90 -0
  208. package/src/plugins/builtin/fear-greed/charts.tsx +7 -18
  209. package/src/plugins/builtin/fear-greed/data.ts +6 -1
  210. package/src/plugins/builtin/help/index.tsx +4 -0
  211. package/src/plugins/builtin/holders/index.tsx +1 -0
  212. package/src/plugins/builtin/insider/index.tsx +1 -0
  213. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  214. package/src/plugins/builtin/kelly-sizer/sections.tsx +2 -3
  215. package/src/plugins/builtin/market-valuation/align.ts +122 -0
  216. package/src/plugins/builtin/market-valuation/cache.ts +32 -0
  217. package/src/plugins/builtin/market-valuation/chart-projection.ts +217 -0
  218. package/src/plugins/builtin/market-valuation/chart-series.ts +89 -0
  219. package/src/plugins/builtin/market-valuation/client.test.ts +141 -0
  220. package/src/plugins/builtin/market-valuation/client.ts +116 -0
  221. package/src/plugins/builtin/market-valuation/defs.ts +251 -0
  222. package/src/plugins/builtin/market-valuation/detail.tsx +159 -0
  223. package/src/plugins/builtin/market-valuation/headless.test.ts +58 -0
  224. package/src/plugins/builtin/market-valuation/headless.ts +170 -0
  225. package/src/plugins/builtin/market-valuation/index.tsx +64 -0
  226. package/src/plugins/builtin/market-valuation/indicators.ts +422 -0
  227. package/src/plugins/builtin/market-valuation/model.test.ts +350 -0
  228. package/src/plugins/builtin/market-valuation/model.ts +8 -0
  229. package/src/plugins/builtin/market-valuation/pane.test.tsx +247 -0
  230. package/src/plugins/builtin/market-valuation/pane.tsx +344 -0
  231. package/src/plugins/builtin/market-valuation/series.ts +12 -0
  232. package/src/plugins/builtin/market-valuation/settings.ts +38 -0
  233. package/src/plugins/builtin/market-valuation/sources.ts +130 -0
  234. package/src/plugins/builtin/market-valuation/trend.ts +89 -0
  235. package/src/plugins/builtin/market-valuation/view.ts +137 -0
  236. package/src/plugins/builtin/market-valuation/zone-scale/desktop.tsx +123 -0
  237. package/src/plugins/builtin/market-valuation/zone-scale/index.tsx +12 -0
  238. package/src/plugins/builtin/market-valuation/zone-scale/model.ts +60 -0
  239. package/src/plugins/builtin/market-valuation/zone-scale/terminal.tsx +94 -0
  240. package/src/plugins/builtin/news/wire/breaking/filters.test.ts +99 -0
  241. package/src/plugins/builtin/news/wire/breaking/filters.ts +110 -0
  242. package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +157 -53
  243. package/src/plugins/builtin/news/wire/breaking/notifications.ts +91 -25
  244. package/src/plugins/builtin/news/wire/index.tsx +31 -44
  245. package/src/plugins/builtin/options/index.tsx +1 -0
  246. package/src/plugins/builtin/options/view.test.tsx +29 -1
  247. package/src/plugins/builtin/options/view.tsx +1 -22
  248. package/src/plugins/builtin/plugin-marketplace/feed.ts +80 -0
  249. package/src/plugins/builtin/plugin-marketplace/index.tsx +31 -0
  250. package/src/plugins/builtin/plugin-marketplace/model.test.ts +200 -0
  251. package/src/plugins/builtin/plugin-marketplace/model.ts +204 -0
  252. package/src/plugins/builtin/plugin-marketplace/pane.tsx +385 -0
  253. package/src/plugins/builtin/plugin-marketplace/store.ts +46 -0
  254. package/src/plugins/builtin/plugin-module.ts +9 -1
  255. package/src/plugins/builtin/research/index.tsx +4 -0
  256. package/src/plugins/builtin/research-search/command-bar-search.test.ts +81 -0
  257. package/src/plugins/builtin/research-search/command-bar-search.ts +175 -0
  258. package/src/plugins/builtin/research-search/data.ts +109 -0
  259. package/src/plugins/builtin/research-search/document-view.test.tsx +75 -0
  260. package/src/plugins/builtin/research-search/document-view.tsx +254 -0
  261. package/src/plugins/builtin/research-search/filter-bar.tsx +62 -0
  262. package/src/plugins/builtin/research-search/focus-handoff.ts +47 -0
  263. package/src/plugins/builtin/research-search/index.tsx +85 -0
  264. package/src/plugins/builtin/research-search/model.test.ts +40 -0
  265. package/src/plugins/builtin/research-search/model.ts +291 -0
  266. package/src/plugins/builtin/research-search/pane.test.tsx +241 -0
  267. package/src/plugins/builtin/research-search/pane.tsx +678 -0
  268. package/src/plugins/builtin/research-search/saved-view.tsx +171 -0
  269. package/src/plugins/builtin/research-search/snippet-text.tsx +35 -0
  270. package/src/plugins/builtin/research-search/snippet.test.ts +78 -0
  271. package/src/plugins/builtin/research-search/snippet.ts +134 -0
  272. package/src/plugins/builtin/scanner/flow-model.test.ts +11 -1
  273. package/src/plugins/builtin/scanner/flow-model.ts +29 -1
  274. package/src/plugins/builtin/scanner/flow-pane.tsx +78 -22
  275. package/src/plugins/builtin/sec/index.tsx +1 -0
  276. package/src/plugins/builtin/shared/series-cache.ts +93 -0
  277. package/src/plugins/builtin/short-interest/index.tsx +1 -0
  278. package/src/plugins/builtin/short-interest/pane.tsx +1 -1
  279. package/src/plugins/builtin/thirteenf/index.tsx +1 -0
  280. package/src/plugins/builtin/ticker-detail/index.tsx +0 -14
  281. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +1 -1
  282. package/src/plugins/builtin/volatility/index.tsx +1 -1
  283. package/src/plugins/builtin/world-venue-map/map.desktop.test.tsx +106 -0
  284. package/src/plugins/builtin/world-venue-map/map.tsx +213 -69
  285. package/src/plugins/builtin/world-venue-map/model.test.ts +32 -0
  286. package/src/plugins/builtin/world-venue-map/model.ts +134 -13
  287. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  288. package/src/plugins/bundle.test.ts +109 -0
  289. package/src/plugins/bundle.ts +139 -0
  290. package/src/plugins/catalog-browser.test.ts +1 -0
  291. package/src/plugins/catalog-browser.ts +10 -0
  292. package/src/plugins/catalog-manifest.test.ts +43 -0
  293. package/src/plugins/catalog-manifest.ts +92 -0
  294. package/src/plugins/catalog-ui.ts +20 -4
  295. package/src/plugins/catalog.ts +9 -2
  296. package/src/plugins/current-target.ts +28 -0
  297. package/src/plugins/host-contract.ts +37 -0
  298. package/src/plugins/host-link.ts +158 -0
  299. package/src/plugins/host-modules.ts +61 -0
  300. package/src/plugins/loader.ts +41 -22
  301. package/src/plugins/ownership.ts +1 -0
  302. package/src/plugins/prediction-markets/index.tsx +1 -0
  303. package/src/plugins/registry/context.ts +3 -0
  304. package/src/plugins/registry/contributions.ts +27 -0
  305. package/src/plugins/registry/index.test.ts +25 -0
  306. package/src/plugins/registry/index.ts +8 -0
  307. package/src/plugins/registry/pane-settings.ts +6 -13
  308. package/src/plugins/seed.test.ts +83 -0
  309. package/src/plugins/seed.ts +80 -0
  310. package/src/public/broker.ts +31 -0
  311. package/src/public/market-data.ts +9 -0
  312. package/src/public/pane-hooks.ts +57 -0
  313. package/src/public/public-api.test.ts +285 -0
  314. package/src/public/react.ts +62 -0
  315. package/src/public/test-support.ts +23 -0
  316. package/src/public/time-series.ts +8 -0
  317. package/src/public/utils.ts +77 -0
  318. package/src/remote/layout-helpers.ts +6 -8
  319. package/src/remote/resources.ts +2 -0
  320. package/src/remote/schema.ts +1 -0
  321. package/src/remote/semantic-tree.test.tsx +105 -0
  322. package/src/remote/semantic-tree.tsx +53 -35
  323. package/src/renderers/browser/main.tsx +6 -0
  324. package/src/renderers/cloudflare/worker.test.ts +19 -0
  325. package/src/renderers/cloudflare/worker.ts +1 -1
  326. package/src/renderers/electrobun/bun/external-plugins.ts +140 -0
  327. package/src/renderers/electrobun/bun/index.ts +5 -2
  328. package/src/renderers/electrobun/shared/protocol.ts +21 -0
  329. package/src/renderers/electrobun/view/build-assets.ts +0 -1
  330. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +51 -1
  331. package/src/renderers/electrobun/view/external-plugins.test.ts +45 -0
  332. package/src/renderers/electrobun/view/external-plugins.ts +63 -0
  333. package/src/renderers/electrobun/view/host/ascii-text.test.ts +48 -0
  334. package/src/renderers/electrobun/view/host/ascii-text.ts +11 -1
  335. package/src/renderers/electrobun/view/host/chart-surface.tsx +38 -32
  336. package/src/renderers/electrobun/view/host/mouse.ts +16 -2
  337. package/src/renderers/electrobun/view/main.tsx +27 -2
  338. package/src/renderers/electrobun/view/styles.css +5 -0
  339. package/src/renderers/electrobun/view/toast-host.tsx +18 -0
  340. package/src/renderers/opentui/chart-surface.tsx +5 -1
  341. package/src/renderers/opentui/host.tsx +16 -2
  342. package/src/renderers/opentui/interaction-performance.test.ts +79 -0
  343. package/src/renderers/opentui/interaction-performance.ts +188 -0
  344. package/src/renderers/opentui/start.tsx +26 -1
  345. package/src/renderers/opentui/test-utils.tsx +13 -0
  346. package/src/renderers/opentui/toast-host.tsx +26 -2
  347. package/src/renderers/opentui/ui-host.tsx +1 -0
  348. package/src/sources/provider-router/search.test.ts +191 -0
  349. package/src/sources/provider-router/search.ts +92 -39
  350. package/src/state/app/context/index.tsx +2 -0
  351. package/src/state/app/context/selector.test.tsx +33 -1
  352. package/src/state/hooks/quote-streaming.test.tsx +93 -0
  353. package/src/state/hooks/quote-streaming.ts +106 -30
  354. package/src/state/pane-table-export-registry.ts +19 -0
  355. package/src/theme/color-utils.ts +1 -1
  356. package/src/theme/colors.ts +50 -0
  357. package/src/theme/themes.test.ts +15 -2
  358. package/src/theme/themes.ts +19 -1
  359. package/src/tickers/search/index.test.ts +26 -0
  360. package/src/tickers/search/index.ts +42 -17
  361. package/src/tickers/search/ranking.ts +14 -0
  362. package/src/time-series/chart-data.test.ts +216 -0
  363. package/src/time-series/resolution.test.ts +25 -26
  364. package/src/time-series/resolution.ts +41 -10
  365. package/src/time-series/resolve.ts +7 -0
  366. package/src/time-series/types.ts +3 -1
  367. package/src/time-series/use-chart-resolution.ts +5 -0
  368. package/src/types/config.ts +123 -3
  369. package/src/types/data-provider.ts +11 -0
  370. package/src/types/headless.ts +161 -0
  371. package/src/types/plugin.ts +102 -0
  372. package/src/ui/host.tsx +12 -1
  373. package/src/ui/index.tsx +1 -0
  374. package/src/ui/toast.tsx +8 -4
  375. package/src/utils/format.ts +35 -0
  376. package/src/utils/fuzzy-search.test.ts +94 -0
  377. package/src/utils/fuzzy-search.ts +81 -22
  378. package/src/utils/shortcut-labels.ts +14 -0
  379. package/src/components/chart/core/axis-format.ts +0 -157
  380. package/src/components/chart/core/index.test.ts +0 -1075
  381. package/src/components/chart/core/projection.ts +0 -357
  382. package/src/components/chart/core/render-utils.ts +0 -4
  383. package/src/components/chart/core/renderer.ts +0 -244
  384. package/src/components/chart/core/scene.ts +0 -180
  385. package/src/components/chart/core/time-axis.ts +0 -330
  386. package/src/components/chart/cursor-motion.ts +0 -47
  387. package/src/components/chart/indicators/types.ts +0 -26
  388. package/src/components/chart/market-session.ts +0 -176
  389. package/src/components/chart/multi-line/renderer.test.ts +0 -83
  390. package/src/components/chart/multi-line/renderer.ts +0 -161
  391. package/src/components/chart/multi-line/scene.ts +0 -188
  392. package/src/components/chart/native/raster/layout.ts +0 -129
  393. package/src/components/chart/native/raster/price-chart.ts +0 -244
  394. package/src/components/chart/rendering/chart-draw.ts +0 -270
  395. package/src/components/chart/rendering/geometry.ts +0 -95
  396. package/src/components/chart/rendering/pixel-buffer.ts +0 -267
  397. package/src/components/chart/scatter-chart-renderer.test.ts +0 -31
  398. package/src/components/chart/scatter-chart-renderer.ts +0 -223
  399. package/src/components/chart/static/chart/surface.tsx +0 -358
  400. package/src/components/command-bar/routes/root/article-results.test.ts +0 -83
  401. package/src/components/command-bar/routes/root/article-results.ts +0 -55
  402. package/src/plugins/builtin/news/wire/article-search.test.ts +0 -72
  403. package/src/plugins/builtin/news/wire/article-search.ts +0 -140
  404. package/src/plugins/builtin/substack/api/auth.ts +0 -185
  405. package/src/plugins/builtin/substack/api/cache.ts +0 -82
  406. package/src/plugins/builtin/substack/api/loaders.ts +0 -170
  407. package/src/plugins/builtin/substack/api/store.ts +0 -186
  408. package/src/plugins/builtin/substack/api/types.ts +0 -42
  409. package/src/plugins/builtin/substack/api.test.ts +0 -298
  410. package/src/plugins/builtin/substack/article-detail.tsx +0 -397
  411. package/src/plugins/builtin/substack/article-stack.tsx +0 -133
  412. package/src/plugins/builtin/substack/content.ts +0 -478
  413. package/src/plugins/builtin/substack/feed-tabs.tsx +0 -44
  414. package/src/plugins/builtin/substack/index.tsx +0 -49
  415. package/src/plugins/builtin/substack/login-view.tsx +0 -126
  416. package/src/plugins/builtin/substack/model.test.ts +0 -155
  417. package/src/plugins/builtin/substack/normalize.ts +0 -343
  418. package/src/plugins/builtin/substack/pane-footer.ts +0 -52
  419. package/src/plugins/builtin/substack/pane-state.ts +0 -170
  420. package/src/plugins/builtin/substack/pane.tsx +0 -547
  421. package/src/plugins/builtin/substack/read-state.ts +0 -30
  422. package/src/plugins/builtin/substack/table.ts +0 -111
  423. package/src/plugins/builtin/substack/types.ts +0 -71
  424. package/src/plugins/builtin/substack/utils.ts +0 -131
  425. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +0 -229
  426. package/src/plugins/ibkr/account-cache.test.ts +0 -102
  427. package/src/plugins/ibkr/account-cache.ts +0 -24
  428. package/src/plugins/ibkr/broker-adapter.ts +0 -286
  429. package/src/plugins/ibkr/config.test.ts +0 -79
  430. package/src/plugins/ibkr/config.ts +0 -250
  431. package/src/plugins/ibkr/dialogs.tsx +0 -40
  432. package/src/plugins/ibkr/flex/index.test.ts +0 -264
  433. package/src/plugins/ibkr/flex/index.ts +0 -367
  434. package/src/plugins/ibkr/flex/statement-client.ts +0 -235
  435. package/src/plugins/ibkr/fundamental-parser.ts +0 -149
  436. package/src/plugins/ibkr/gateway/account-loaders.ts +0 -266
  437. package/src/plugins/ibkr/gateway/account-summary.ts +0 -164
  438. package/src/plugins/ibkr/gateway/client-lock.ts +0 -154
  439. package/src/plugins/ibkr/gateway/connection-events.ts +0 -114
  440. package/src/plugins/ibkr/gateway/connection.ts +0 -152
  441. package/src/plugins/ibkr/gateway/contracts.ts +0 -132
  442. package/src/plugins/ibkr/gateway/fundamentals.ts +0 -83
  443. package/src/plugins/ibkr/gateway/helpers.ts +0 -33
  444. package/src/plugins/ibkr/gateway/history-requests.ts +0 -158
  445. package/src/plugins/ibkr/gateway/history.ts +0 -120
  446. package/src/plugins/ibkr/gateway/instrument-lookup.ts +0 -117
  447. package/src/plugins/ibkr/gateway/market-data.ts +0 -345
  448. package/src/plugins/ibkr/gateway/order-execution.ts +0 -218
  449. package/src/plugins/ibkr/gateway/orders.ts +0 -63
  450. package/src/plugins/ibkr/gateway/price-normalization.test.ts +0 -44
  451. package/src/plugins/ibkr/gateway/price-normalization.ts +0 -50
  452. package/src/plugins/ibkr/gateway/quote-stream-controller.ts +0 -203
  453. package/src/plugins/ibkr/gateway/quote-stream.ts +0 -171
  454. package/src/plugins/ibkr/gateway/selection.ts +0 -51
  455. package/src/plugins/ibkr/gateway/service/index.test.ts +0 -675
  456. package/src/plugins/ibkr/gateway/service/index.ts +0 -429
  457. package/src/plugins/ibkr/gateway/service/lifecycle.ts +0 -191
  458. package/src/plugins/ibkr/gateway/service/manager.ts +0 -52
  459. package/src/plugins/ibkr/gateway/service/market-data.ts +0 -48
  460. package/src/plugins/ibkr/gateway/service/native-loader.ts +0 -12
  461. package/src/plugins/ibkr/gateway/service/native.ts +0 -543
  462. package/src/plugins/ibkr/gateway/timeouts.ts +0 -13
  463. package/src/plugins/ibkr/gateway/types.ts +0 -27
  464. package/src/plugins/ibkr/index.test.tsx +0 -162
  465. package/src/plugins/ibkr/index.tsx +0 -159
  466. package/src/plugins/ibkr/instance-selection.test.ts +0 -120
  467. package/src/plugins/ibkr/instance-selection.ts +0 -36
  468. package/src/plugins/ibkr/portfolio-performance.test.ts +0 -32
  469. package/src/plugins/ibkr/portfolio-performance.ts +0 -129
  470. package/src/plugins/ibkr/trade/badge.tsx +0 -28
  471. package/src/plugins/ibkr/trade/dialogs.tsx +0 -44
  472. package/src/plugins/ibkr/trade/preview-panel.tsx +0 -130
  473. package/src/plugins/ibkr/trade/tab/actions.tsx +0 -443
  474. package/src/plugins/ibkr/trade/tab/field-editors.tsx +0 -79
  475. package/src/plugins/ibkr/trade/tab/footer.ts +0 -53
  476. package/src/plugins/ibkr/trade/tab/header.tsx +0 -167
  477. package/src/plugins/ibkr/trade/tab/index.test.tsx +0 -178
  478. package/src/plugins/ibkr/trade/tab/index.tsx +0 -298
  479. package/src/plugins/ibkr/trade/tab/model.ts +0 -159
  480. package/src/plugins/ibkr/trade/tab/order-request.ts +0 -64
  481. package/src/plugins/ibkr/trade/tab/shortcuts.ts +0 -89
  482. package/src/plugins/ibkr/trade/tab/state-sync.ts +0 -86
  483. package/src/plugins/ibkr/trade/ticket-panel.tsx +0 -301
  484. package/src/plugins/ibkr/trade/utils.test.ts +0 -175
  485. package/src/plugins/ibkr/trade/utils.ts +0 -144
  486. package/src/plugins/ibkr/trading/pane-view.tsx +0 -237
  487. package/src/plugins/ibkr/trading/pane.tsx +0 -332
  488. package/src/plugins/ibkr/trading/state.test.ts +0 -109
  489. package/src/plugins/ibkr/trading/state.ts +0 -335
@@ -0,0 +1,93 @@
1
+ import type { PluginPersistence } from "../../../types/plugin";
2
+
3
+ export interface DatedObservation {
4
+ date: string;
5
+ value: number | null;
6
+ }
7
+
8
+ export interface SeriesCacheEntry {
9
+ observations: DatedObservation[];
10
+ fetchedAt: number;
11
+ stale: boolean;
12
+ }
13
+
14
+ export interface SeriesCache {
15
+ attach(persistence: PluginPersistence): void;
16
+ reset(): void;
17
+ /** Preloads server-fetched legs for renderers that cannot reach the cloud API. */
18
+ hydrate(entries: readonly (readonly [string, DatedObservation[]])[]): void;
19
+ get(key: string, options?: { allowExpired?: boolean }): SeriesCacheEntry | null;
20
+ load(key: string, loader: () => Promise<DatedObservation[]>): Promise<DatedObservation[]>;
21
+ }
22
+
23
+ const CACHE_SOURCE = "gloomberb-cloud";
24
+ const CACHE_SCHEMA_VERSION = 1;
25
+
26
+ /**
27
+ * Disk-backed cache for dated series, one instance per pane so their keys cannot
28
+ * collide. Serves a fresh entry without a request, refreshes a stale one, and falls
29
+ * back to expired data when the network fails, so an outage degrades to old numbers
30
+ * rather than an empty pane.
31
+ */
32
+ export function createSeriesCache(kind: string, staleMs: number): SeriesCache {
33
+ const policy = { staleMs, expireMs: 30 * 24 * 60 * 60 * 1000 } as const;
34
+ let persistence: PluginPersistence | null = null;
35
+ const inflight = new Map<string, Promise<DatedObservation[]>>();
36
+ const hydrated = new Map<string, DatedObservation[]>();
37
+
38
+ const get: SeriesCache["get"] = (key, options) => {
39
+ const record = persistence?.getResource<DatedObservation[]>(kind, key, {
40
+ sourceKey: CACHE_SOURCE,
41
+ schemaVersion: CACHE_SCHEMA_VERSION,
42
+ allowExpired: options?.allowExpired,
43
+ });
44
+ if (!record) return null;
45
+ return { observations: record.value, fetchedAt: record.fetchedAt, stale: !!record.stale };
46
+ };
47
+
48
+ return {
49
+ attach(next) {
50
+ persistence = next;
51
+ },
52
+ reset() {
53
+ persistence = null;
54
+ inflight.clear();
55
+ hydrated.clear();
56
+ },
57
+ hydrate(entries) {
58
+ hydrated.clear();
59
+ for (const [key, observations] of entries) hydrated.set(key, observations);
60
+ },
61
+ get,
62
+ load(key, loader) {
63
+ const preloaded = hydrated.get(key);
64
+ if (preloaded) return Promise.resolve(preloaded);
65
+
66
+ const cached = get(key);
67
+ if (cached && !cached.stale) return Promise.resolve(cached.observations);
68
+
69
+ const active = inflight.get(key);
70
+ if (active) return active;
71
+
72
+ const request = (async () => {
73
+ try {
74
+ const observations = await loader();
75
+ persistence?.setResource(kind, key, observations, {
76
+ sourceKey: CACHE_SOURCE,
77
+ schemaVersion: CACHE_SCHEMA_VERSION,
78
+ cachePolicy: policy,
79
+ });
80
+ return observations;
81
+ } catch (error) {
82
+ const stale = cached ?? get(key, { allowExpired: true });
83
+ if (stale) return stale.observations;
84
+ throw error;
85
+ } finally {
86
+ inflight.delete(key);
87
+ }
88
+ })();
89
+ inflight.set(key, request);
90
+ return request;
91
+ },
92
+ };
93
+ }
@@ -45,6 +45,7 @@ export const shortInterestModule: PluginModule = {
45
45
  defaultPosition: "right",
46
46
  defaultMode: "floating",
47
47
  defaultFloatingSize: { width: 90, height: 25 },
48
+ tableExport: true,
48
49
  },
49
50
  ],
50
51
 
@@ -11,7 +11,7 @@ import {
11
11
  type DataTableCell,
12
12
  type DataTableKeyEvent,
13
13
  } from "../../../components";
14
- import { resolveChartPalette } from "../../../components/chart/core/renderer";
14
+ import { resolveChartPalette } from "../../../components/chart/core/palette";
15
15
  import type { ProjectedChartPoint } from "../../../components/chart/core/data";
16
16
  import { colors, blendHex } from "../../../theme/colors";
17
17
  import { useShortcut } from "../../../react/input";
@@ -38,6 +38,7 @@ export const thirteenFModule: PluginModule = {
38
38
  defaultPosition: "right",
39
39
  defaultMode: "floating",
40
40
  defaultFloatingSize: { width: 112, height: 36 },
41
+ tableExport: true,
41
42
  },
42
43
  ],
43
44
 
@@ -4,10 +4,6 @@ import { normalizeTickerInput } from "../../../tickers/search";
4
4
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
5
5
  import { FinancialAnalysisPane } from "./financials/pane";
6
6
  import { HistoricalPricesPane } from "./data-panes/historical-prices";
7
- import {
8
- createProviderSearchPaneTemplate,
9
- ProviderSearchPane,
10
- } from "./data-panes/provider-search";
11
7
  import { TickerResearchPane } from "./pane";
12
8
  import { TICKER_RESEARCH_BUILTIN_TABS } from "./research-tabs";
13
9
  import { QuoteMonitorPane } from "./quote-monitor";
@@ -75,15 +71,6 @@ export const tickerDetailModule: PluginModule = {
75
71
  defaultFloatingSize: { width: 92, height: 26 },
76
72
  tableExport: true,
77
73
  },
78
- {
79
- id: "provider-search-results",
80
- name: "Provider Search",
81
- icon: "S",
82
- component: ProviderSearchPane,
83
- defaultPosition: "right",
84
- defaultMode: "floating",
85
- defaultFloatingSize: { width: 86, height: 24 },
86
- },
87
74
  ],
88
75
  paneTemplates: [
89
76
  {
@@ -159,7 +146,6 @@ export const tickerDetailModule: PluginModule = {
159
146
  shortcut: "HP",
160
147
  publicShare: true,
161
148
  }),
162
- createProviderSearchPaneTemplate(),
163
149
  createTickerSurfacePaneTemplate({
164
150
  id: "financial-analysis-pane",
165
151
  paneId: "financial-analysis",
@@ -20,7 +20,7 @@ import {
20
20
  pricePointsToResolvedSeries,
21
21
  } from "../../../components/chart/composite";
22
22
  import { resolveExchangeTimeZone } from "../../../utils/exchanges";
23
- import { appendLiveQuotePoint } from "../../../components/chart/core/data";
23
+ import { appendLiveQuotePoint } from "../../../time-series/chart-data";
24
24
  import { PriceReturnStrip } from "../../../components/price-performance";
25
25
  import {
26
26
  appendQuoteToPriceReturnHistory,
@@ -12,7 +12,7 @@ import {
12
12
  import { useAutoRefresh } from "../shared/auto-refresh";
13
13
  import { ListView } from "../../../components/ui/list-view";
14
14
  import type { ProjectedChartPoint } from "../../../components/chart/core/data";
15
- import { resolveChartPalette } from "../../../components/chart/core/renderer";
15
+ import { resolveChartPalette } from "../../../components/chart/core/palette";
16
16
  import type { PaneProps } from "../../../types/plugin";
17
17
  import { colors } from "../../../theme/colors";
18
18
  import type { PluginModule } from "../plugin-module";
@@ -0,0 +1,106 @@
1
+ /** @jsxImportSource react */
2
+ import { Window } from "happy-dom";
3
+
4
+ const testWindow = new Window({ url: "http://localhost" });
5
+ const domGlobals = {
6
+ IS_REACT_ACT_ENVIRONMENT: true,
7
+ window: testWindow,
8
+ document: testWindow.document,
9
+ navigator: testWindow.navigator,
10
+ KeyboardEvent: testWindow.KeyboardEvent,
11
+ MouseEvent: testWindow.MouseEvent,
12
+ WheelEvent: testWindow.WheelEvent,
13
+ HTMLElement: testWindow.HTMLElement,
14
+ Node: testWindow.Node,
15
+ };
16
+ const priorGlobals = Object.fromEntries(
17
+ Object.keys(domGlobals).map((key) => [key, (globalThis as Record<string, unknown>)[key]]),
18
+ );
19
+ Object.assign(globalThis, domGlobals);
20
+
21
+ import { afterAll, afterEach, expect, test } from "bun:test";
22
+ import { act } from "react";
23
+ import { createRoot } from "react-dom/client";
24
+ import { UiHostProvider, type RendererHost, type UiHost } from "../../../ui";
25
+ import { WebBox } from "../../../renderers/electrobun/view/host/box";
26
+ import { WebText } from "../../../renderers/electrobun/view/host/text";
27
+ import { WorldVenueMap } from "./map";
28
+ import type { CloudWorldVenuePayload } from "../../../api-client";
29
+
30
+ const renderer: RendererHost = {
31
+ requestExit() {},
32
+ async openExternal() {},
33
+ async copyText() {},
34
+ async readText() { return ""; },
35
+ notify() {},
36
+ };
37
+
38
+ const ui = {
39
+ kind: "desktop-web",
40
+ capabilities: { cellWidthPx: 8, cellHeightPx: 18, fractionalViewport: true },
41
+ Box: WebBox,
42
+ Text: WebText,
43
+ } as unknown as UiHost;
44
+
45
+ afterAll(() => {
46
+ for (const [key, value] of Object.entries(priorGlobals)) {
47
+ if (value === undefined) delete (globalThis as Record<string, unknown>)[key];
48
+ else (globalThis as Record<string, unknown>)[key] = value;
49
+ }
50
+ });
51
+
52
+ const venue: CloudWorldVenuePayload = {
53
+ mic: "XNYS",
54
+ name: "NYSE",
55
+ title: "New York Stock Exchange",
56
+ country: "United States",
57
+ countryCode: "US",
58
+ city: "New York",
59
+ timezone: "America/New_York",
60
+ latitude: 40.7127,
61
+ longitude: -74.006,
62
+ isOpen: true,
63
+ };
64
+
65
+ let root: ReturnType<typeof createRoot> | undefined;
66
+ let container: HTMLElement | undefined;
67
+
68
+ afterEach(async () => {
69
+ if (root) {
70
+ await act(async () => root!.unmount());
71
+ root = undefined;
72
+ }
73
+ container?.remove();
74
+ container = undefined;
75
+ });
76
+
77
+ test("zooms the desktop map toward the pointer on wheel", async () => {
78
+ container = testWindow.document.createElement("div") as unknown as HTMLElement;
79
+ testWindow.document.body.appendChild(container as unknown as Node);
80
+ root = createRoot(container);
81
+ await act(async () => {
82
+ root!.render(
83
+ <UiHostProvider ui={ui} renderer={renderer}>
84
+ <WorldVenueMap
85
+ venues={[venue]}
86
+ selectedMic="XNYS"
87
+ width={80}
88
+ height={24}
89
+ onSelect={() => {}}
90
+ />
91
+ </UiHostProvider>,
92
+ );
93
+ });
94
+
95
+ const surface = container.querySelector('[data-gloom-role="world-venue-map"]') as HTMLElement;
96
+ expect(surface).toBeTruthy();
97
+ expect(surface.getAttribute("data-zoomed")).toBe("false");
98
+
99
+ const wheel = new testWindow.WheelEvent("wheel", { deltaY: -180, bubbles: true, cancelable: true });
100
+ await act(async () => {
101
+ surface.dispatchEvent(wheel as unknown as Event);
102
+ });
103
+
104
+ expect(wheel.defaultPrevented).toBe(true);
105
+ expect(surface.getAttribute("data-zoomed")).toBe("true");
106
+ });
@@ -1,6 +1,6 @@
1
- import { createElement, useMemo, useRef } from "react";
1
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
2
  import type { CloudWorldVenuePayload } from "../../../api-client";
3
- import { ChartSurface, Text, useNativeRenderer, useUiHost } from "../../../ui";
3
+ import { Box, ChartSurface, Text, useNativeRenderer, useUiHost } from "../../../ui";
4
4
  import { useThemeColors } from "../../../theme/theme-context";
5
5
  import { resolveNativeBitmapSize, shouldRenderNativeBitmap } from "../../../components/chart/native/bitmap-support";
6
6
  import { drawCircle, drawLine, fillOpaque, parseHex } from "../../../components/chart/native/raster/primitives";
@@ -9,7 +9,12 @@ import { getLocalPlotPointer, type ChartMouseEvent } from "../../../components/c
9
9
  import {
10
10
  closestWorldVenueCluster,
11
11
  clusterWorldVenues,
12
+ DEFAULT_WORLD_MAP_VIEWPORT,
13
+ panWorldMapViewport,
12
14
  projectWorldPoint,
15
+ zoomWorldMapViewport,
16
+ type WorldMapPoint,
17
+ type WorldMapViewport,
13
18
  type WorldVenueCluster,
14
19
  } from "./model";
15
20
  import { WORLD_OUTLINES } from "./world-outlines";
@@ -236,17 +241,67 @@ function TerminalWorldVenueMap(props: WorldVenueMapProps) {
236
241
  );
237
242
  }
238
243
 
244
+ const DESKTOP_MAP_ASPECT = 2.12;
245
+ const MAP_PAN_THRESHOLD_PX = 4;
246
+ const MAP_CLICK_HIT_PX = 14;
247
+ const MAP_DOUBLE_CLICK_ZOOM = 1.8;
248
+
249
+ function wheelZoomFactor(event: WheelEvent): number {
250
+ const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
251
+ return Math.exp(-delta * 0.002);
252
+ }
253
+
254
+ function clientToMapPoint(
255
+ event: { clientX: number; clientY: number },
256
+ element: HTMLElement,
257
+ width: number,
258
+ height: number,
259
+ ): WorldMapPoint {
260
+ const rect = element.getBoundingClientRect();
261
+ return {
262
+ x: rect.width <= 0 ? 0 : ((event.clientX - rect.left) / rect.width) * width,
263
+ y: rect.height <= 0 ? 0 : ((event.clientY - rect.top) / rect.height) * height,
264
+ };
265
+ }
266
+
267
+ function clientDeltaToMapDelta(
268
+ deltaX: number,
269
+ deltaY: number,
270
+ element: HTMLElement,
271
+ width: number,
272
+ height: number,
273
+ ): WorldMapPoint {
274
+ const rect = element.getBoundingClientRect();
275
+ return {
276
+ x: rect.width <= 0 ? 0 : (deltaX / rect.width) * width,
277
+ y: rect.height <= 0 ? 0 : (deltaY / rect.height) * height,
278
+ };
279
+ }
280
+
239
281
  function DesktopWorldVenueMap(props: WorldVenueMapProps) {
240
282
  const colors = useThemeColors();
283
+ const surfaceRef = useRef<HTMLDivElement | null>(null);
284
+ const [viewport, setViewport] = useState<WorldMapViewport>(DEFAULT_WORLD_MAP_VIEWPORT);
285
+ const viewportRef = useRef(viewport);
286
+ viewportRef.current = viewport;
287
+ const [dragging, setDragging] = useState(false);
288
+ const dragRef = useRef<{
289
+ pointerId: number;
290
+ lastX: number;
291
+ lastY: number;
292
+ originX: number;
293
+ originY: number;
294
+ moved: boolean;
295
+ } | null>(null);
241
296
  // Desktop rows are roughly twice as tall as they are wide. Matching the SVG
242
297
  // view box to those pixels lets the contained world projection keep its shape.
243
- const plotHeight = props.height * 2.12;
298
+ const plotHeight = props.height * DESKTOP_MAP_ASPECT;
299
+ const zoomed = viewport.zoom > 1;
244
300
  const clusters = useMemo(
245
- () => clusterWorldVenues(props.venues, props.width, plotHeight),
246
- [plotHeight, props.venues, props.width],
301
+ () => clusterWorldVenues(props.venues, props.width, plotHeight, 1, viewport),
302
+ [plotHeight, props.venues, props.width, viewport],
247
303
  );
248
-
249
- const outlinePath = (outline: (typeof WORLD_OUTLINES)[number]) => {
304
+ const outlinePaths = useMemo(() => WORLD_OUTLINES.map((outline) => {
250
305
  let drawing = false;
251
306
  let previousLongitude: number | null = null;
252
307
  return outline.map(([longitude, latitude]) => {
@@ -255,78 +310,167 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
255
310
  previousLongitude = longitude;
256
311
  return "";
257
312
  }
258
- const point = projectWorldPoint(longitude, latitude, props.width, plotHeight);
313
+ const point = projectWorldPoint(longitude, latitude, props.width, plotHeight, 1, viewport);
259
314
  previousLongitude = longitude;
260
315
  const command = drawing ? "L" : "M";
261
316
  drawing = true;
262
317
  return `${command}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
263
318
  }).join(" ");
319
+ }), [plotHeight, props.width, viewport]);
320
+
321
+ const selectAt = useCallback((point: WorldMapPoint, element: HTMLElement) => {
322
+ const rect = element.getBoundingClientRect();
323
+ const hit = rect.height <= 0 ? 2.4 : (MAP_CLICK_HIT_PX / rect.height) * plotHeight;
324
+ const cluster = closestWorldVenueCluster(clusters, point.x, point.y, Math.max(1.6, hit));
325
+ if (cluster) props.onSelect(clusterVenue(cluster, props.selectedMic));
326
+ }, [clusters, plotHeight, props]);
327
+
328
+ useEffect(() => {
329
+ const element = surfaceRef.current;
330
+ if (!element) return;
331
+ const onWheel = (event: WheelEvent) => {
332
+ event.preventDefault();
333
+ event.stopPropagation();
334
+ const point = clientToMapPoint(event, element, props.width, plotHeight);
335
+ const factor = wheelZoomFactor(event);
336
+ setViewport((current) => zoomWorldMapViewport(current, props.width, plotHeight, point, factor));
337
+ };
338
+ element.addEventListener("wheel", onWheel, { passive: false });
339
+ return () => element.removeEventListener("wheel", onWheel);
340
+ }, [plotHeight, props.width]);
341
+
342
+ const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
343
+ const drag = dragRef.current;
344
+ if (!drag || drag.pointerId !== event.pointerId) return;
345
+ dragRef.current = null;
346
+ setDragging(false);
347
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
348
+ event.currentTarget.releasePointerCapture(event.pointerId);
349
+ }
350
+ if (!drag.moved) selectAt(clientToMapPoint(event, event.currentTarget, props.width, plotHeight), event.currentTarget);
264
351
  };
265
352
 
266
353
  return (
267
- <svg
268
- viewBox={`0 0 ${props.width} ${plotHeight}`}
269
- width="100%"
270
- height="100%"
271
- role="img"
272
- aria-label="World venue map"
273
- style={{ display: "block", background: colors.bg }}
274
- >
275
- {WORLD_OUTLINES.map((outline, index) => (
276
- <path
277
- key={index}
278
- d={outlinePath(outline)}
279
- fill="none"
280
- stroke={colors.textDim}
281
- strokeOpacity="0.64"
282
- strokeWidth="0.55"
283
- vectorEffect="non-scaling-stroke"
284
- />
285
- ))}
286
- {clusters.map((cluster) => {
287
- const selected = isSelectedCluster(cluster, props.selectedMic);
288
- const venue = clusterVenue(cluster, props.selectedMic);
289
- const radius = Math.max(0.55, Math.min(1.5, 0.45 + Math.sqrt(cluster.venues.length) * 0.22));
290
- return (
291
- <g
292
- key={cluster.id}
293
- role="button"
294
- tabIndex={0}
295
- aria-label={`${cluster.venues.length} venues near ${venue.city}`}
296
- style={{ cursor: "pointer" }}
297
- onMouseDown={(event) => {
298
- event.stopPropagation();
299
- props.onSelect(venue);
300
- }}
301
- onKeyDown={(event) => {
302
- if (event.key === "Enter" || event.key === " ") props.onSelect(venue);
303
- }}
304
- >
305
- <title>{`${cluster.venues.length} venue${cluster.venues.length === 1 ? "" : "s"} near ${venue.city}: ${cluster.venues.map((item) => `${item.mic} ${item.name}`).join(", ")}`}</title>
306
- <circle
307
- cx={cluster.x}
308
- cy={cluster.y}
309
- r={radius + (selected ? 0.24 : 0)}
310
- fill={cluster.isOpen ? colors.positive : colors.textMuted}
311
- stroke={selected ? colors.selectedText : colors.bg}
312
- strokeWidth={selected ? 0.35 : 0.16}
354
+ <Box width={props.width} height={props.height} overflow="hidden">
355
+ <div
356
+ ref={surfaceRef}
357
+ data-gloom-role="world-venue-map"
358
+ data-zoomed={zoomed ? "true" : "false"}
359
+ role="application"
360
+ aria-label="World venue map"
361
+ onPointerDown={(event) => {
362
+ if (event.button !== 0) return;
363
+ event.stopPropagation();
364
+ dragRef.current = {
365
+ pointerId: event.pointerId,
366
+ lastX: event.clientX,
367
+ lastY: event.clientY,
368
+ originX: event.clientX,
369
+ originY: event.clientY,
370
+ moved: false,
371
+ };
372
+ event.currentTarget.setPointerCapture(event.pointerId);
373
+ }}
374
+ onPointerMove={(event) => {
375
+ const drag = dragRef.current;
376
+ if (!drag || drag.pointerId !== event.pointerId) return;
377
+ const travel = Math.hypot(event.clientX - drag.originX, event.clientY - drag.originY);
378
+ if (!drag.moved && travel < MAP_PAN_THRESHOLD_PX) return;
379
+ if (viewportRef.current.zoom <= 1) return;
380
+ drag.moved = true;
381
+ const delta = clientDeltaToMapDelta(
382
+ event.clientX - drag.lastX,
383
+ event.clientY - drag.lastY,
384
+ event.currentTarget,
385
+ props.width,
386
+ plotHeight,
387
+ );
388
+ drag.lastX = event.clientX;
389
+ drag.lastY = event.clientY;
390
+ setDragging(true);
391
+ setViewport((current) => panWorldMapViewport(current, props.width, plotHeight, delta.x, delta.y));
392
+ }}
393
+ onPointerUp={endDrag}
394
+ onPointerCancel={endDrag}
395
+ onDoubleClick={(event) => {
396
+ const point = clientToMapPoint(event, event.currentTarget, props.width, plotHeight);
397
+ setViewport((current) => zoomWorldMapViewport(current, props.width, plotHeight, point, MAP_DOUBLE_CLICK_ZOOM));
398
+ }}
399
+ style={{
400
+ position: "relative",
401
+ width: "100%",
402
+ height: "100%",
403
+ flex: 1,
404
+ minHeight: 0,
405
+ overflow: "hidden",
406
+ touchAction: "none",
407
+ userSelect: "none",
408
+ cursor: dragging ? "grabbing" : zoomed ? "grab" : "pointer",
409
+ background: colors.bg,
410
+ }}
411
+ >
412
+ <svg
413
+ viewBox={`0 0 ${props.width} ${plotHeight}`}
414
+ width="100%"
415
+ height="100%"
416
+ aria-hidden="true"
417
+ style={{ display: "block", background: colors.bg, pointerEvents: "none" }}
418
+ >
419
+ {outlinePaths.map((path, index) => (
420
+ <path
421
+ key={index}
422
+ d={path}
423
+ fill="none"
424
+ stroke={colors.textDim}
425
+ strokeOpacity="0.64"
426
+ strokeWidth="0.55"
313
427
  vectorEffect="non-scaling-stroke"
314
428
  />
315
- {cluster.venues.length > 1 ? createElement("text", {
316
- x: cluster.x,
317
- y: cluster.y,
318
- fill: colors.bg,
319
- dy: "0.34em",
320
- fontSize: Math.max(0.62, Math.min(0.95, radius * 0.78)),
321
- fontWeight: "700",
322
- fontFamily: "inherit",
323
- textAnchor: "middle",
324
- pointerEvents: "none",
325
- }, cluster.venues.length) : null}
326
- </g>
327
- );
328
- })}
329
- </svg>
429
+ ))}
430
+ {clusters.map((cluster) => {
431
+ const selected = isSelectedCluster(cluster, props.selectedMic);
432
+ const venue = clusterVenue(cluster, props.selectedMic);
433
+ const radius = Math.max(0.55, Math.min(1.5, 0.45 + Math.sqrt(cluster.venues.length) * 0.22));
434
+ return (
435
+ <g
436
+ key={cluster.id}
437
+ role="button"
438
+ tabIndex={0}
439
+ aria-label={`${cluster.venues.length} venues near ${venue.city}`}
440
+ onKeyDown={(event) => {
441
+ if (event.key === "Enter" || event.key === " ") {
442
+ event.preventDefault();
443
+ props.onSelect(venue);
444
+ }
445
+ }}
446
+ >
447
+ <title>{`${cluster.venues.length} venue${cluster.venues.length === 1 ? "" : "s"} near ${venue.city}: ${cluster.venues.map((item) => `${item.mic} ${item.name}`).join(", ")}`}</title>
448
+ <circle
449
+ cx={cluster.x}
450
+ cy={cluster.y}
451
+ r={radius + (selected ? 0.24 : 0)}
452
+ fill={cluster.isOpen ? colors.positive : colors.textMuted}
453
+ stroke={selected ? colors.selectedText : colors.bg}
454
+ strokeWidth={selected ? 0.35 : 0.16}
455
+ vectorEffect="non-scaling-stroke"
456
+ />
457
+ {cluster.venues.length > 1 ? createElement("text", {
458
+ x: cluster.x,
459
+ y: cluster.y,
460
+ fill: colors.bg,
461
+ dy: "0.34em",
462
+ fontSize: Math.max(0.62, Math.min(0.95, radius * 0.78)),
463
+ fontWeight: "700",
464
+ fontFamily: "inherit",
465
+ textAnchor: "middle",
466
+ pointerEvents: "none",
467
+ }, cluster.venues.length) : null}
468
+ </g>
469
+ );
470
+ })}
471
+ </svg>
472
+ </div>
473
+ </Box>
330
474
  );
331
475
  }
332
476
 
@@ -2,9 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { CloudWorldVenuePayload } from "../../../api-client";
3
3
  import {
4
4
  clusterWorldVenues,
5
+ DEFAULT_WORLD_MAP_VIEWPORT,
5
6
  filterWorldVenues,
7
+ panWorldMapViewport,
6
8
  projectWorldPoint,
9
+ unprojectWorldPoint,
7
10
  venueRemainingSeconds,
11
+ zoomWorldMapViewport,
8
12
  } from "./model";
9
13
 
10
14
  function venue(overrides: Partial<CloudWorldVenuePayload>): CloudWorldVenuePayload {
@@ -50,4 +54,32 @@ describe("world venue map model", () => {
50
54
  expect(filterWorldVenues(rows, "")[0]?.mic).toBe("XASE");
51
55
  expect(venueRemainingSeconds(rows[0]!, 1_000, 61_000)).toBe(3_540);
52
56
  });
57
+
58
+ test("keeps a cursor point anchored while zooming and splits nearby clusters", () => {
59
+ const width = 361;
60
+ const height = 146;
61
+ const cursor = { x: 90, y: 40 };
62
+ const geographic = unprojectWorldPoint(cursor.x, cursor.y, width, height);
63
+ const zoomed = zoomWorldMapViewport(DEFAULT_WORLD_MAP_VIEWPORT, width, height, cursor, 4);
64
+ expect(zoomed.zoom).toBe(4);
65
+ const stillUnderCursor = projectWorldPoint(geographic.longitude, geographic.latitude, width, height, 1, zoomed);
66
+ expect(stillUnderCursor.x).toBeCloseTo(cursor.x, 6);
67
+ expect(stillUnderCursor.y).toBeCloseTo(cursor.y, 6);
68
+
69
+ const nearby = [
70
+ venue({ mic: "WEST", longitude: -20, latitude: 20 }),
71
+ venue({ mic: "EAST", longitude: -18, latitude: 20 }),
72
+ ];
73
+ expect(clusterWorldVenues(nearby, 80, 24)).toHaveLength(1);
74
+ expect(clusterWorldVenues(nearby, 80, 24, 1, {
75
+ zoom: 10,
76
+ centerLongitude: 0,
77
+ centerLatitude: DEFAULT_WORLD_MAP_VIEWPORT.centerLatitude,
78
+ })).toHaveLength(2);
79
+
80
+ const panned = panWorldMapViewport(zoomed, width, height, 40, 0);
81
+ expect(panned.centerLongitude).toBeLessThan(zoomed.centerLongitude);
82
+ expect(panWorldMapViewport(DEFAULT_WORLD_MAP_VIEWPORT, width, height, 40, 0)).toEqual(DEFAULT_WORLD_MAP_VIEWPORT);
83
+ expect(zoomWorldMapViewport(zoomed, width, height, cursor, 0.01)).toEqual(DEFAULT_WORLD_MAP_VIEWPORT);
84
+ });
53
85
  });