gloomberb 0.12.0 → 0.13.2

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 (219) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -2
  3. package/src/api-client/auth.ts +2 -1
  4. package/src/api-client/index.test.ts +34 -0
  5. package/src/api-client/index.ts +10 -2
  6. package/src/api-client/request.ts +4 -1
  7. package/src/api-client/research-activity.ts +61 -0
  8. package/src/api-client/types.ts +2 -1
  9. package/src/cli/commands/plugins.ts +4 -3
  10. package/src/cli/desktop-pane-shot.ts +79 -10
  11. package/src/cli/pane-functions/capabilities.ts +76 -23
  12. package/src/cli/pane-functions/cloud-session.test.ts +54 -0
  13. package/src/cli/pane-functions/cloud-session.ts +38 -0
  14. package/src/cli/pane-functions/dom.test.ts +41 -0
  15. package/src/cli/pane-functions/dom.ts +199 -0
  16. package/src/cli/pane-functions/index.test.ts +13 -2
  17. package/src/cli/pane-functions/index.ts +9 -5
  18. package/src/cli/pane-functions/intraday-shot.test.ts +307 -0
  19. package/src/cli/pane-functions/intraday-shot.ts +334 -0
  20. package/src/cli/pane-functions/report.test.ts +29 -1
  21. package/src/cli/pane-functions/report.ts +68 -7
  22. package/src/cli/pane-functions/screenshot.ts +195 -42
  23. package/src/cli/restore-plugins.ts +10 -3
  24. package/src/components/chart/composite/composite-chart.test.tsx +68 -1
  25. package/src/components/chart/composite/composite-chart.tsx +87 -21
  26. package/src/components/chart/composite/rasterizer.ts +33 -0
  27. package/src/components/chart/composite/renderers.test.ts +51 -4
  28. package/src/components/chart/composite/scene.test.ts +128 -2
  29. package/src/components/chart/composite/scene.ts +92 -16
  30. package/src/components/chart/composite/text-renderer.ts +13 -0
  31. package/src/components/chart/composite/time-scale.ts +30 -10
  32. package/src/components/chart/composite/types.ts +26 -0
  33. package/src/components/command-bar/assist/runtime.ts +38 -30
  34. package/src/components/command-bar/surface/assist.test.tsx +63 -9
  35. package/src/components/command-bar/surface/test-harness.tsx +23 -4
  36. package/src/components/onboarding/account-step/chooser-panel.tsx +2 -2
  37. package/src/components/onboarding/account-step/qr-panel.tsx +2 -10
  38. package/src/components/onboarding/onboarding-wizard.test.tsx +38 -12
  39. package/src/components/onboarding/onboarding-wizard.tsx +49 -14
  40. package/src/data/config/store/index.test.ts +23 -0
  41. package/src/data/config/store/node.ts +7 -1
  42. package/src/data/config/store/normalize.ts +4 -0
  43. package/src/news/types.ts +1 -0
  44. package/src/plugins/builtin/cds/client.test.ts +20 -0
  45. package/src/plugins/builtin/cds/client.ts +13 -3
  46. package/src/plugins/builtin/cds/headless.test.ts +30 -0
  47. package/src/plugins/builtin/cds/headless.ts +113 -0
  48. package/src/plugins/builtin/cds/index.tsx +4 -0
  49. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -1
  50. package/src/plugins/builtin/chart-composer/index.tsx +1 -1
  51. package/src/plugins/builtin/chat/controller/channels.ts +2 -1
  52. package/src/plugins/builtin/chat/controller/index.ts +13 -5
  53. package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
  54. package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
  55. package/src/plugins/builtin/chat/controller/state.ts +1 -0
  56. package/src/plugins/builtin/chat/controller.test.ts +260 -4
  57. package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
  58. package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
  59. package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
  60. package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
  61. package/src/plugins/builtin/cloud/askg/protocol.test.ts +190 -0
  62. package/src/plugins/builtin/cloud/askg/protocol.ts +286 -0
  63. package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
  64. package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
  65. package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
  66. package/src/plugins/builtin/cloud/auth-dialog.tsx +7 -1
  67. package/src/plugins/builtin/cloud/device-signin-dialog.tsx +21 -8
  68. package/src/plugins/builtin/cloud/plugin.tsx +2 -0
  69. package/src/plugins/builtin/cloud/verification-panel.tsx +56 -0
  70. package/src/plugins/builtin/congress-trades/client.ts +11 -0
  71. package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
  72. package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
  73. package/src/plugins/builtin/congress-trades/headless.ts +145 -0
  74. package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
  75. package/src/plugins/builtin/credit-conditions/client.ts +1 -1
  76. package/src/plugins/builtin/credit-conditions/headless.test.ts +31 -0
  77. package/src/plugins/builtin/credit-conditions/headless.ts +55 -0
  78. package/src/plugins/builtin/credit-conditions/index.tsx +4 -0
  79. package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
  80. package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
  81. package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
  82. package/src/plugins/builtin/dividend-yield/model.ts +4 -22
  83. package/src/plugins/builtin/dividend-yield/view.ts +17 -0
  84. package/src/plugins/builtin/earnings/headless.test.ts +82 -0
  85. package/src/plugins/builtin/earnings/headless.ts +162 -0
  86. package/src/plugins/builtin/earnings/index.tsx +2 -0
  87. package/src/plugins/builtin/earnings-calls/data.ts +29 -8
  88. package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
  89. package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
  90. package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
  91. package/src/plugins/builtin/earnings-calls/model.ts +238 -0
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +4 -0
  93. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +13 -11
  94. package/src/plugins/builtin/fear-greed/headless.test.ts +53 -0
  95. package/src/plugins/builtin/fear-greed/headless.ts +84 -0
  96. package/src/plugins/builtin/fear-greed/index.tsx +4 -0
  97. package/src/plugins/builtin/holders/client.ts +29 -0
  98. package/src/plugins/builtin/holders/headless.test.ts +58 -0
  99. package/src/plugins/builtin/holders/headless.ts +162 -0
  100. package/src/plugins/builtin/holders/index.tsx +14 -8
  101. package/src/plugins/builtin/holders/pane.tsx +7 -1
  102. package/src/plugins/builtin/insider/client.ts +50 -0
  103. package/src/plugins/builtin/insider/headless.test.ts +122 -0
  104. package/src/plugins/builtin/insider/headless.ts +117 -0
  105. package/src/plugins/builtin/insider/index.tsx +21 -50
  106. package/src/plugins/builtin/insider/insider-data.ts +1 -1
  107. package/src/plugins/builtin/insider/model.ts +72 -0
  108. package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
  109. package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
  110. package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
  111. package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
  112. package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
  113. package/src/plugins/builtin/market-movers/client.ts +76 -0
  114. package/src/plugins/builtin/market-movers/headless.test.ts +34 -0
  115. package/src/plugins/builtin/market-movers/headless.ts +73 -0
  116. package/src/plugins/builtin/market-movers/index.tsx +11 -51
  117. package/src/plugins/builtin/news/headless.test.ts +138 -0
  118. package/src/plugins/builtin/news/headless.ts +190 -0
  119. package/src/plugins/builtin/news/index.tsx +13 -9
  120. package/src/plugins/builtin/news/wire/index.tsx +2 -1
  121. package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
  122. package/src/plugins/builtin/research/analyst-headless.ts +172 -0
  123. package/src/plugins/builtin/research/analyst-model.ts +219 -0
  124. package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
  125. package/src/plugins/builtin/research/client.ts +12 -0
  126. package/src/plugins/builtin/research/corporate-actions-pane.tsx +3 -237
  127. package/src/plugins/builtin/research/event-model.ts +245 -0
  128. package/src/plugins/builtin/research/events-headless.test.ts +32 -0
  129. package/src/plugins/builtin/research/events-headless.ts +87 -0
  130. package/src/plugins/builtin/research/headless.test.ts +93 -0
  131. package/src/plugins/builtin/research/headless.ts +161 -0
  132. package/src/plugins/builtin/research/index.tsx +32 -18
  133. package/src/plugins/builtin/research-search/pane.tsx +4 -0
  134. package/src/plugins/builtin/scanner/headless.test.ts +59 -0
  135. package/src/plugins/builtin/scanner/headless.ts +125 -0
  136. package/src/plugins/builtin/scanner/index.ts +4 -0
  137. package/src/plugins/builtin/sec/client.ts +16 -0
  138. package/src/plugins/builtin/sec/headless.test.ts +72 -0
  139. package/src/plugins/builtin/sec/headless.ts +69 -0
  140. package/src/plugins/builtin/sec/index.tsx +20 -75
  141. package/src/plugins/builtin/sec/model.ts +89 -0
  142. package/src/plugins/builtin/sectors/client.ts +54 -0
  143. package/src/plugins/builtin/sectors/headless.test.ts +28 -0
  144. package/src/plugins/builtin/sectors/headless.ts +80 -0
  145. package/src/plugins/builtin/sectors/index.tsx +6 -51
  146. package/src/plugins/builtin/shared/cloud-upgrade.ts +8 -4
  147. package/src/plugins/builtin/short-interest/client.ts +5 -2
  148. package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
  149. package/src/plugins/builtin/short-interest/headless.ts +111 -0
  150. package/src/plugins/builtin/short-interest/index.tsx +15 -9
  151. package/src/plugins/builtin/thirteenf/data.ts +13 -11
  152. package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
  153. package/src/plugins/builtin/thirteenf/headless.ts +286 -0
  154. package/src/plugins/builtin/thirteenf/index.tsx +4 -0
  155. package/src/plugins/builtin/thirteenf/model.ts +13 -0
  156. package/src/plugins/builtin/thirteenf/table.ts +3 -15
  157. package/src/plugins/builtin/ticker-detail/pane.tsx +14 -0
  158. package/src/plugins/builtin/treasury-auctions/headless.test.ts +34 -0
  159. package/src/plugins/builtin/treasury-auctions/headless.ts +95 -0
  160. package/src/plugins/builtin/treasury-auctions/index.tsx +4 -0
  161. package/src/plugins/builtin/volatility/client.ts +16 -3
  162. package/src/plugins/builtin/volatility/headless.test.ts +35 -0
  163. package/src/plugins/builtin/volatility/headless.ts +75 -0
  164. package/src/plugins/builtin/volatility/index.tsx +4 -0
  165. package/src/plugins/builtin/world-indices/client.ts +43 -0
  166. package/src/plugins/builtin/world-indices/headless.test.ts +32 -0
  167. package/src/plugins/builtin/world-indices/headless.ts +109 -0
  168. package/src/plugins/builtin/world-indices/index.tsx +4 -0
  169. package/src/plugins/builtin/yield-curve/headless.test.ts +24 -0
  170. package/src/plugins/builtin/yield-curve/headless.ts +67 -0
  171. package/src/plugins/builtin/yield-curve/index.tsx +6 -8
  172. package/src/plugins/builtin/yield-curve/treasury-data.ts +16 -6
  173. package/src/plugins/bundle.test.ts +58 -1
  174. package/src/plugins/bundle.ts +4 -2
  175. package/src/plugins/catalog-backend.test.ts +26 -0
  176. package/src/plugins/catalog-backend.ts +9 -2
  177. package/src/plugins/catalog-browser.test.ts +0 -13
  178. package/src/plugins/catalog-browser.ts +4 -0
  179. package/src/plugins/catalog-manifest.test.ts +15 -0
  180. package/src/plugins/catalog-manifest.ts +12 -5
  181. package/src/plugins/catalog-ui.ts +0 -6
  182. package/src/plugins/host-contract.ts +9 -0
  183. package/src/plugins/host-modules.ts +12 -0
  184. package/src/plugins/loader.ts +63 -15
  185. package/src/plugins/prediction-markets/headless.test.ts +119 -0
  186. package/src/plugins/prediction-markets/headless.ts +255 -0
  187. package/src/plugins/prediction-markets/index.tsx +2 -0
  188. package/src/plugins/seed.ts +3 -0
  189. package/src/public/broker.ts +6 -0
  190. package/src/remote/schema.test.ts +65 -0
  191. package/src/remote/schema.ts +322 -39
  192. package/src/remote/types.ts +49 -2
  193. package/src/renderers/browser/app-services.ts +3 -0
  194. package/src/renderers/browser/config-host.ts +24 -8
  195. package/src/renderers/browser/deeplink-bridge.test.ts +11 -0
  196. package/src/renderers/browser/deeplink-bridge.ts +15 -3
  197. package/src/renderers/browser/main.tsx +3 -1
  198. package/src/renderers/browser/research-entry.test.ts +8 -0
  199. package/src/renderers/browser/research-entry.ts +7 -0
  200. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +2 -2
  201. package/src/renderers/electrobun/bun/desktop/initialization.ts +10 -3
  202. package/src/renderers/electrobun/bun/external-plugins.ts +20 -4
  203. package/src/renderers/electrobun/view/build-assets.ts +0 -2
  204. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +63 -10
  205. package/src/sources/gloomberb-cloud/news.ts +1 -0
  206. package/src/sync/core-contributors.test.ts +111 -0
  207. package/src/sync/core-contributors.ts +155 -1
  208. package/src/time-series/resolve.test.ts +118 -1
  209. package/src/time-series/resolve.ts +14 -16
  210. package/src/types/config.ts +2 -0
  211. package/src/plugins/broker-sync/broker-sync.test.ts +0 -188
  212. package/src/plugins/broker-sync/native-loader.ts +0 -10
  213. package/src/plugins/broker-sync/normalize.ts +0 -308
  214. package/src/plugins/broker-sync/public.ts +0 -122
  215. package/src/plugins/broker-sync/robinhood-native.ts +0 -367
  216. package/src/plugins/broker-sync/robinhood.ts +0 -114
  217. package/src/plugins/broker-sync/simplefin-native.ts +0 -201
  218. package/src/plugins/broker-sync/simplefin.ts +0 -70
  219. package/src/renderers/electrobun/view/native-stubs/broker-sync-native-loader.ts +0 -7
@@ -92,7 +92,11 @@ import {
92
92
  type ChartToolDrag,
93
93
  type ChartToolKind,
94
94
  } from "./tools";
95
- import { unprojectCompositeTimestamp } from "./time-scale";
95
+ import {
96
+ COMPOSITE_RIGHT_OFFSET_RATIO,
97
+ compositeRightOffsetRatio,
98
+ unprojectCompositeTimestamp,
99
+ } from "./time-scale";
96
100
  import {
97
101
  allocateCompositePanelHeights,
98
102
  applyCompositeChartCursor,
@@ -139,12 +143,17 @@ interface PanGesture {
139
143
  positionsPerCell: number;
140
144
  }
141
145
 
142
- /** A drag pans in the frame it started in; see `CompositeNavigationFrame`. */
146
+ /**
147
+ * A drag pans in the frame it started in; see `CompositeNavigationFrame`. The
148
+ * plot covers the viewport plus its reserved right offset, so a cell is worth
149
+ * that much time and the bars keep pace with the pointer.
150
+ */
143
151
  function startPanGesture(
144
152
  frame: CompositeNavigationFrame,
145
153
  viewport: CompositeViewportRange,
146
154
  plotWidth: number,
147
155
  globalX: number,
156
+ plotSpanFactor: number,
148
157
  ): PanGesture {
149
158
  const positions = compositeViewportPositions(frame, viewport);
150
159
  const span = positions ? Math.max(positions.end - positions.start, Number.EPSILON) : 1;
@@ -153,7 +162,7 @@ function startPanGesture(
153
162
  startGlobalX: globalX,
154
163
  frame,
155
164
  startViewport: viewport,
156
- positionsPerCell: span / Math.max(plotWidth, 1),
165
+ positionsPerCell: span * plotSpanFactor / Math.max(plotWidth, 1),
157
166
  };
158
167
  }
159
168
 
@@ -740,6 +749,9 @@ function CompositePanelSurface({
740
749
  >(null);
741
750
  const [toolDrag, setToolDrag] = useState<ChartToolDrag | null>(null);
742
751
  const plotAspect = (plotWidth * cellWidthPx) / Math.max(panel.height * cellHeightPx, 1);
752
+ // The plot is wider than the viewport by the reserved right offset. Gestures
753
+ // read the pointer in plot space, so they convert through this.
754
+ const plotSpanFactor = 1 + compositeRightOffsetRatio(scene.timeScale);
743
755
  const bitmapSize = useStaticChartBitmapSize(plotWidth, panel.height);
744
756
  const bitmap = useCompositePanelBitmap({ panel, bitmapSize, colors, isDesktopWeb });
745
757
  const columnLayout = useMemo(() => buildCompositeColumnLayout(panel), [panel]);
@@ -924,20 +936,37 @@ function CompositePanelSurface({
924
936
  // anchor the drag started from.
925
937
  if (!toolDrag || toolDrag.kind === "zoom" || !toolReadout?.startValueLabel) return null;
926
938
  return {
939
+ side: null,
927
940
  yRatio: toolDrag.startYRatio,
928
941
  label: toolReadout.startValueLabel,
929
942
  color: toolReadout.direction === "down" ? colors.negative : themeColors.positive,
930
943
  };
931
944
  }, [colors.negative, toolDrag, toolReadout]);
945
+ const lastPriceMarker = useMemo(() => {
946
+ const marker = panel.lastPrice;
947
+ const domain = marker ? panel.axes[marker.axis] : undefined;
948
+ if (!marker || !domain) return null;
949
+ return {
950
+ side: marker.axis,
951
+ yRatio: marker.yRatio,
952
+ label: formatAxisValue
953
+ ? formatAxisValue(marker.value, domain)
954
+ : formatCompositeCursorValue(marker.value, domain),
955
+ color: marker.color,
956
+ };
957
+ }, [formatAxisValue, panel]);
932
958
  const buildAxisMarkers = (side: "left" | "right") => {
933
- if (!axisMarkers || !panel.axes[side]) return undefined;
934
- const row = Math.round(axisMarkers.yRatio * Math.max(panel.height - 1, 0));
935
- return [{
936
- row,
937
- pixelY: axisMarkers.yRatio * Math.max(panel.height * cellHeightPx - 1, 0),
938
- label: axisMarkers.label,
939
- color: axisMarkers.color,
940
- }];
959
+ if (!panel.axes[side]) return undefined;
960
+ const markers = [lastPriceMarker, axisMarkers].flatMap((marker) => (
961
+ marker && (marker.side === null || marker.side === side) ? [marker] : []
962
+ ));
963
+ if (markers.length === 0) return undefined;
964
+ return markers.map((marker) => ({
965
+ row: Math.round(marker.yRatio * Math.max(panel.height - 1, 0)),
966
+ pixelY: marker.yRatio * Math.max(panel.height * cellHeightPx - 1, 0),
967
+ label: marker.label,
968
+ color: marker.color,
969
+ }));
941
970
  };
942
971
  const leftAxisMarkers = buildAxisMarkers("left");
943
972
  const rightAxisMarkers = buildAxisMarkers("right");
@@ -1013,7 +1042,13 @@ function CompositePanelSurface({
1013
1042
  return;
1014
1043
  }
1015
1044
  if (!updateCursor(event)) return;
1016
- dragRef.current = startPanGesture(frame, viewport, plotWidth, getGlobalMouseX(event, renderer));
1045
+ dragRef.current = startPanGesture(
1046
+ frame,
1047
+ viewport,
1048
+ plotWidth,
1049
+ getGlobalMouseX(event, renderer),
1050
+ plotSpanFactor,
1051
+ );
1017
1052
  }, [
1018
1053
  armedTool,
1019
1054
  drawings,
@@ -1022,6 +1057,7 @@ function CompositePanelSurface({
1022
1057
  onSelectDrawing,
1023
1058
  panel,
1024
1059
  plotAspect,
1060
+ plotSpanFactor,
1025
1061
  plotWidth,
1026
1062
  pointerRatios,
1027
1063
  renderer,
@@ -1067,7 +1103,7 @@ function CompositePanelSurface({
1067
1103
  if (drag.frame !== frame) {
1068
1104
  // Data refreshed mid-drag. The new frame maps positions differently, so
1069
1105
  // continue from where the plot is now instead of replaying the drag.
1070
- Object.assign(drag, startPanGesture(frame, viewport, plotWidth, globalX));
1106
+ Object.assign(drag, startPanGesture(frame, viewport, plotWidth, globalX, plotSpanFactor));
1071
1107
  }
1072
1108
  onPanViewport(
1073
1109
  (globalX - drag.startGlobalX) * drag.positionsPerCell,
@@ -1078,6 +1114,7 @@ function CompositePanelSurface({
1078
1114
  onEditDrawing,
1079
1115
  onPanViewport,
1080
1116
  panel,
1117
+ plotSpanFactor,
1081
1118
  plotWidth,
1082
1119
  pointerRatios,
1083
1120
  renderer,
@@ -1117,8 +1154,8 @@ function CompositePanelSurface({
1117
1154
  // absorbs every event that arrived since the last one.
1118
1155
  const pendingWheelRef = useRef<PendingWheel | null>(null);
1119
1156
  const wheelFrameRef = useRef<number | null>(null);
1120
- const latestWheelStateRef = useRef({ frame, viewport, onPanViewport, onZoomViewport });
1121
- latestWheelStateRef.current = { frame, viewport, onPanViewport, onZoomViewport };
1157
+ const latestWheelStateRef = useRef({ frame, viewport, onPanViewport, onZoomViewport, plotSpanFactor });
1158
+ latestWheelStateRef.current = { frame, viewport, onPanViewport, onZoomViewport, plotSpanFactor };
1122
1159
  const flushWheel = useCallback(() => {
1123
1160
  wheelFrameRef.current = null;
1124
1161
  const pending = pendingWheelRef.current;
@@ -1128,7 +1165,7 @@ function CompositePanelSurface({
1128
1165
  if (pending.panRatio !== 0) {
1129
1166
  const positions = compositeViewportPositions(latest.frame, latest.viewport);
1130
1167
  const span = positions ? Math.max(positions.end - positions.start, Number.EPSILON) : 0;
1131
- latest.onPanViewport(pending.panRatio * span);
1168
+ latest.onPanViewport(pending.panRatio * span * latest.plotSpanFactor);
1132
1169
  }
1133
1170
  if (pending.zoomLog !== 0) {
1134
1171
  latest.onZoomViewport(Math.exp(pending.zoomLog), pending.anchorRatio);
@@ -1152,7 +1189,12 @@ function CompositePanelSurface({
1152
1189
  const pending = pendingWheelRef.current ?? { panRatio: 0, zoomLog: 0, anchorRatio: 0.5 };
1153
1190
  if (event.modifiers.ctrl && pointer && isVerticalWheelDirection(scroll.direction)) {
1154
1191
  pending.zoomLog += Math.log(resolveCompositeWheelZoom(scroll));
1155
- pending.anchorRatio = pointer.cellX / Math.max(plotWidth - 1, 1);
1192
+ // The anchor is a fraction of the viewport, so a pointer over the empty
1193
+ // right offset anchors on the newest observation instead of past it.
1194
+ pending.anchorRatio = Math.min(
1195
+ 1,
1196
+ pointer.cellX / Math.max(plotWidth - 1, 1) * plotSpanFactor,
1197
+ );
1156
1198
  } else {
1157
1199
  pending.panRatio += resolveCompositeWheelPan(scroll, plotWidth * cellWidthPx);
1158
1200
  }
@@ -1164,7 +1206,16 @@ function CompositePanelSurface({
1164
1206
  if (wheelFrameRef.current === null) {
1165
1207
  wheelFrameRef.current = webFrame.requestAnimationFrame(flushWheel);
1166
1208
  }
1167
- }, [cellWidthPx, flushWheel, isDesktopWeb, onActivate, plotWidth, renderer, updateCursor]);
1209
+ }, [
1210
+ cellWidthPx,
1211
+ flushWheel,
1212
+ isDesktopWeb,
1213
+ onActivate,
1214
+ plotSpanFactor,
1215
+ plotWidth,
1216
+ renderer,
1217
+ updateCursor,
1218
+ ]);
1168
1219
 
1169
1220
  return (
1170
1221
  <Box
@@ -1611,6 +1662,9 @@ export function CompositeChart({
1611
1662
  const stableSeries = reuseResolvedSeriesList(seriesIdentityRef.current, series);
1612
1663
  seriesIdentityRef.current = stableSeries;
1613
1664
  const visibleSeries = useMemo(() => stableSeries.filter((entry) => entry.points.length > 0), [stableSeries]);
1665
+ // Panel layout follows the authored series so a panel keeps its height while
1666
+ // its data loads; only the marks inside it wait for observations.
1667
+ const panelSeries = useMemo(() => stableSeries.filter((entry) => !entry.hidden), [stableSeries]);
1614
1668
  const marketTimelineSeries = useMemo(() => {
1615
1669
  const supplied = timelineSeries?.filter((entry) => entry.points.length > 0) ?? [];
1616
1670
  return supplied.some((entry) => entry.timeBasis?.kind === "market")
@@ -1749,7 +1803,7 @@ export function CompositeChart({
1749
1803
  const timeAxisRows = showTimeAxis ? 1 : 0;
1750
1804
  const xMarkers = xAxis?.markers ?? NO_X_MARKERS;
1751
1805
  const xMarkerRows = xMarkers.some((marker) => marker.label) ? 1 : 0;
1752
- const panelCount = new Set(visibleSeries.map((entry) => entry.panelId)).size;
1806
+ const panelCount = new Set(panelSeries.map((entry) => entry.panelId)).size;
1753
1807
  const lastTickKey = visibleSeries.map((entry) => {
1754
1808
  const last = entry.points.at(-1);
1755
1809
  if (!last) return entry.id;
@@ -1764,16 +1818,28 @@ export function CompositeChart({
1764
1818
  textDim: colors?.textDim ?? activeThemeColors.textDim,
1765
1819
  negative: colors?.negative ?? activeThemeColors.negative,
1766
1820
  }), [activeThemeColors, colors]);
1821
+ // A custom x axis means x is not time: a tenor, a fraction, a return. Its
1822
+ // last observation belongs at the right edge, under its own label.
1823
+ const rightOffsetRatio = xAxis ? 0 : COMPOSITE_RIGHT_OFFSET_RATIO;
1767
1824
  const projectedScene = useMemo(() => {
1768
1825
  // lastTickKey busts this memo when a live tick mutates series identity in place.
1769
1826
  void lastTickKey;
1770
- return buildCompositeChartScene(visibleSeries, panels, {
1827
+ return buildCompositeChartScene(panelSeries, panels, {
1771
1828
  width: 1,
1772
1829
  height: Math.max(panelCount, 1),
1773
1830
  viewport: effectiveViewport ?? undefined,
1774
1831
  timelineSeries: marketTimelineSeries,
1832
+ rightOffsetRatio,
1775
1833
  });
1776
- }, [effectiveViewport, lastTickKey, marketTimelineSeries, panelCount, panels, visibleSeries]);
1834
+ }, [
1835
+ effectiveViewport,
1836
+ lastTickKey,
1837
+ marketTimelineSeries,
1838
+ panelCount,
1839
+ panelSeries,
1840
+ panels,
1841
+ rightOffsetRatio,
1842
+ ]);
1777
1843
  // Gutters follow the axes the scene actually built. Reading the series list
1778
1844
  // instead drops a gutter the moment its series has no observation in view,
1779
1845
  // which is exactly what a zoom does, taking the axis labels with it.
@@ -29,6 +29,11 @@ interface RenderCompositePanelBitmapOptions {
29
29
 
30
30
  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
31
31
 
32
+ /** Dash geometry of the last price level, in bitmap pixels. */
33
+ const LAST_PRICE_DASH_PIXELS = 6;
34
+ const LAST_PRICE_GAP_PIXELS = 5;
35
+ const LAST_PRICE_OPACITY = 0.9;
36
+
32
37
  function pixelPoint(point: CompositeProjectedPoint, width: number, height: number): { x: number; y: number } {
33
38
  return {
34
39
  x: clamp(point.xRatio * Math.max(width - 1, 0), 0, Math.max(width - 1, 0)),
@@ -288,6 +293,30 @@ function drawOhlc(
288
293
  }
289
294
  }
290
295
 
296
+ function drawLastPriceLevel(
297
+ data: Uint8Array,
298
+ width: number,
299
+ height: number,
300
+ yRatio: number,
301
+ color: RgbaColor,
302
+ ): void {
303
+ const y = Math.round(clamp(yRatio * Math.max(height - 1, 0), 0, Math.max(height - 1, 0)));
304
+ const period = LAST_PRICE_DASH_PIXELS + LAST_PRICE_GAP_PIXELS;
305
+ for (let x = 0; x < width; x += period) {
306
+ fillRect(
307
+ data,
308
+ width,
309
+ height,
310
+ x,
311
+ y,
312
+ Math.min(x + LAST_PRICE_DASH_PIXELS - 1, width - 1),
313
+ y,
314
+ color,
315
+ LAST_PRICE_OPACITY,
316
+ );
317
+ }
318
+ }
319
+
291
320
  export function renderCompositePanelBitmap(
292
321
  panel: CompositePanelScene,
293
322
  options: RenderCompositePanelBitmapOptions,
@@ -362,5 +391,9 @@ export function renderCompositePanelBitmap(
362
391
  }
363
392
  }
364
393
 
394
+ if (panel.lastPrice) {
395
+ drawLastPriceLevel(data, width, height, panel.lastPrice.yRatio, parseHex(panel.lastPrice.color));
396
+ }
397
+
365
398
  return { width, height, pixels: data };
366
399
  }
@@ -71,8 +71,8 @@ describe("composite chart renderers", () => {
71
71
 
72
72
  const rows = renderCompositePanelText(scene.panels[0]!, scene.width, null, null);
73
73
  expect(rows[7]!.slice(0, 2)).toBe("██");
74
- expect(rows[7]!.slice(15, 17)).toBe("██");
75
- expect(rows[7]!.slice(-2)).toBe("██");
74
+ expect(rows[7]!.slice(14, 16)).toBe("██");
75
+ expect(rows[7]!.slice(28, 30)).toBe("██");
76
76
  });
77
77
 
78
78
  test("rasterizes same-date columns as separate colored bars", () => {
@@ -401,7 +401,7 @@ describe("composite chart renderers", () => {
401
401
  const rows = renderCompositePanelText(scene.panels[0]!, scene.width, null, null);
402
402
  const occupied = [...rows[7]!]
403
403
  .flatMap((cell, index) => cell === "█" ? [index] : []);
404
- expect(occupied).toEqual([0, 15, 30]);
404
+ expect(occupied).toEqual([0, 14, 28]);
405
405
  });
406
406
 
407
407
  test("does not group columns across independent axes or panels", () => {
@@ -732,12 +732,59 @@ describe("composite chart renderers", () => {
732
732
  else runs.push([x]);
733
733
  }
734
734
 
735
+ const widths = runs.map((run) => run.length);
735
736
  expect(runs).toHaveLength(3);
736
- expect(runs.map((run) => run.length)).toEqual([13, 13, 13]);
737
+ expect(Math.min(...widths)).toBeGreaterThanOrEqual(13);
738
+ // Fractional centers cost a body at most one pixel of rounding.
739
+ expect(Math.max(...widths) - Math.min(...widths)).toBeLessThanOrEqual(1);
737
740
  expect(runs[0]?.[0]).toBe(0);
741
+ // Three bars leave less reserved space than half a body, so the clamp is
742
+ // still what keeps the newest one whole.
738
743
  expect(runs.at(-1)?.at(-1)).toBe(bitmap.width - 1);
739
744
  });
740
745
 
746
+ test("leaves the reserved right offset empty after the newest candle", () => {
747
+ const candles = series("price", "candles", [], "left", "#00ff66");
748
+ candles.points = Array.from({ length: 40 }, (_, index) => ({
749
+ ...point(`2025-02-${String(index + 1).padStart(2, "0")}`, 10),
750
+ open: 8,
751
+ high: 13,
752
+ low: 7,
753
+ close: 10,
754
+ }));
755
+ const scene = buildCompositeChartScene(
756
+ [candles],
757
+ [{ id: "main" }],
758
+ { width: 81, height: 9 },
759
+ )!;
760
+ const panel = scene.panels[0]!;
761
+ const bitmap = renderCompositePanelBitmap(panel, {
762
+ pixelWidth: 400,
763
+ pixelHeight: 51,
764
+ colors: {
765
+ background: "#000000",
766
+ grid: "#202020",
767
+ crosshair: "#ffffff",
768
+ text: "#eeeeee",
769
+ textDim: "#999999",
770
+ negative: "#ff0000",
771
+ },
772
+ });
773
+ const domain = panel.axes.left!;
774
+ const bodyY = Math.round(
775
+ (projectCompositeValue(8, domain)! + projectCompositeValue(10, domain)!) / 2
776
+ * (bitmap.height - 1),
777
+ );
778
+ const painted = Array.from({ length: bitmap.width }, (_, x) => x).filter((x) => {
779
+ const offset = (bodyY * bitmap.width + x) * 4;
780
+ return bitmap.pixels[offset + 1]! > 220;
781
+ });
782
+ const slotWidth = (bitmap.width - 1) / 40;
783
+
784
+ // The newest body ends short of the axis, with whole bar slots of air.
785
+ expect(bitmap.width - 1 - painted.at(-1)!).toBeGreaterThan(slotWidth);
786
+ });
787
+
741
788
  test("marks a standalone line observation without extending it through time", () => {
742
789
  const scene = buildCompositeChartScene(
743
790
  [series("estimate", "line", [42], "left")],
@@ -9,6 +9,10 @@ import {
9
9
  unprojectCompositeValue,
10
10
  } from "./scene";
11
11
  import { buildCompositeColumnLayout } from "./column-layout";
12
+ import { COMPOSITE_RIGHT_OFFSET_RATIO } from "./time-scale";
13
+
14
+ /** Ratio the newest observation lands on once the right offset is reserved. */
15
+ const NEWEST_X_RATIO = 1 / (1 + COMPOSITE_RIGHT_OFFSET_RATIO);
12
16
 
13
17
  function point(date: string, value: number): TimeSeriesPoint {
14
18
  const observedAt = new Date(`${date}T00:00:00.000Z`);
@@ -37,6 +41,49 @@ function series(overrides: Partial<ResolvedSeries> & Pick<ResolvedSeries, "id" |
37
41
  }
38
42
 
39
43
  describe("composite chart scene", () => {
44
+ test("keeps a panel and its height while its series has no observations in view", () => {
45
+ const price = series({
46
+ id: "price",
47
+ points: [point("2025-01-01", 90), point("2025-01-02", 95), point("2025-01-03", 110)],
48
+ });
49
+ const volume = series({
50
+ id: "volume",
51
+ panelId: "volume",
52
+ style: "columns",
53
+ unit: "shares",
54
+ unitGroup: "volume",
55
+ points: [point("2025-01-01", 1_000), point("2025-01-02", 1_200), point("2025-01-03", 1_400)],
56
+ });
57
+ const panels = [{ id: "main" }, { id: "volume", height: 0.25 }];
58
+ const options = { width: 10, height: 8 };
59
+
60
+ const loaded = buildCompositeChartScene([price, volume], panels, {
61
+ ...options,
62
+ viewport: { start: new Date("2025-01-01T00:00:00.000Z"), end: new Date("2025-01-03T00:00:00.000Z") },
63
+ });
64
+ // The same window, with the lower panel's data not yet loaded.
65
+ const loading = buildCompositeChartScene([price, { ...volume, points: [] }], panels, {
66
+ ...options,
67
+ viewport: { start: new Date("2025-01-01T00:00:00.000Z"), end: new Date("2025-01-03T00:00:00.000Z") },
68
+ });
69
+ // A window the upper panel covers and the lower panel has no bars in.
70
+ const partial = buildCompositeChartScene([price, {
71
+ ...volume,
72
+ points: [point("2025-01-01", 1_000)],
73
+ }], panels, {
74
+ ...options,
75
+ viewport: { start: new Date("2025-01-02T00:00:00.000Z"), end: new Date("2025-01-03T00:00:00.000Z") },
76
+ });
77
+
78
+ const layout = (scene: typeof loaded) => scene?.panels.map((panel) => [panel.id, panel.height]);
79
+ expect(layout(loaded)).toEqual([["main", 6], ["volume", 2]]);
80
+ expect(layout(loading)).toEqual(layout(loaded));
81
+ expect(layout(partial)).toEqual(layout(loaded));
82
+ expect(loading?.panels[1]?.series).toEqual([]);
83
+ // The axis still reads the loaded history, so the gutter does not jump either.
84
+ expect(partial?.panels[1]?.axes.left?.max).toBeGreaterThan(0);
85
+ });
86
+
40
87
  test("keeps panels synchronized while preserving independent dual-axis domains", () => {
41
88
  const price = series({
42
89
  id: "price",
@@ -235,10 +282,85 @@ describe("composite chart scene", () => {
235
282
  );
236
283
 
237
284
  expect(scene).not.toBeNull();
238
- expect(scene?.panels[0]?.series[0]?.points.map(({ xRatio }) => xRatio)).toEqual([0, 1]);
285
+ const stepPoints = scene?.panels[0]?.series[0]?.points ?? [];
286
+ expect(stepPoints).toHaveLength(2);
287
+ expect(stepPoints[0]?.xRatio).toBe(0);
288
+ // The level runs to the viewport end and stops: the reserved right offset
289
+ // never carries a projected value.
290
+ expect(stepPoints[1]?.xRatio).toBeCloseTo(NEWEST_X_RATIO, 10);
239
291
  expect(scene?.cursorValues[0]?.value).toBe(90);
240
292
  });
241
293
 
294
+ test("reserves empty space after the newest observation on both time scales", () => {
295
+ const calendar = series({
296
+ id: "calendar",
297
+ points: [point("2025-01-01", 10), point("2025-01-11", 20), point("2025-01-21", 30)],
298
+ });
299
+ const market = series({
300
+ ...calendar,
301
+ id: "market",
302
+ timeBasis: { kind: "market", timeZone: "America/New_York" },
303
+ });
304
+
305
+ for (const entry of [calendar, market]) {
306
+ const scene = buildCompositeChartScene([entry], [{ id: "main" }], { width: 101, height: 10 })!;
307
+ const points = scene.panels[0]!.series[0]!.points;
308
+
309
+ expect(points.at(-1)?.xRatio).toBeCloseTo(NEWEST_X_RATIO, 10);
310
+ // The empty tail is the plot's, not the viewport's: navigation still ends
311
+ // on the newest observation.
312
+ expect(scene.endTime).toBe(new Date("2025-01-21T00:00:00.000Z").getTime());
313
+ // A pointer over the reserved space still reads the newest bar.
314
+ expect(resolveCompositeCursorDate(scene, 100)?.toISOString())
315
+ .toBe("2025-01-21T00:00:00.000Z");
316
+ }
317
+ });
318
+
319
+ test("marks the newest close of the primary price series, skipping studies and volume", () => {
320
+ const timeBasis = { kind: "market", timeZone: "America/New_York" } as const;
321
+ const price = series({
322
+ id: "price",
323
+ unitGroup: "price:USD",
324
+ dataShape: "ohlcv",
325
+ style: "candles",
326
+ timeBasis,
327
+ points: ["2025-01-02", "2025-01-03"].map((date, index) => ({
328
+ ...point(date, 100 + index),
329
+ open: 90 + index,
330
+ high: 110 + index,
331
+ low: 80 + index,
332
+ close: 101 + index,
333
+ })),
334
+ });
335
+ const average = series({
336
+ id: "sma",
337
+ unitGroup: "price:USD",
338
+ timeBasis,
339
+ points: [point("2025-01-02", 40), point("2025-01-03", 41)],
340
+ });
341
+ const volume = series({
342
+ id: "volume",
343
+ unitGroup: "volume",
344
+ style: "columns",
345
+ panelId: "volume",
346
+ timeBasis,
347
+ points: [point("2025-01-02", 5_000), point("2025-01-03", 6_000)],
348
+ });
349
+ const scene = buildCompositeChartScene(
350
+ [price, average, volume],
351
+ [{ id: "main" }, { id: "volume" }],
352
+ { width: 101, height: 12 },
353
+ )!;
354
+ const [main, volumePanel] = scene.panels;
355
+
356
+ expect(main?.lastPrice?.seriesId).toBe("price");
357
+ expect(main?.lastPrice?.value).toBe(102);
358
+ expect(main?.lastPrice?.color).toBe(price.color);
359
+ expect(main?.lastPrice?.yRatio)
360
+ .toBe(projectCompositeValue(102, main!.axes.left!));
361
+ expect(volumePanel?.lastPrice).toBeUndefined();
362
+ });
363
+
242
364
  test("uses as-of values across mixed-frequency dates without looking ahead", () => {
243
365
  const daily = series({
244
366
  id: "daily",
@@ -430,7 +552,11 @@ describe("composite chart scene", () => {
430
552
  );
431
553
 
432
554
  expect(scene?.timeScale).toMatchObject({ kind: "market", anchorSeriesId: "price" });
433
- expect(scene?.panels[0]?.series[0]?.points.map(({ xRatio }) => xRatio)).toEqual([0, 0.5, 1]);
555
+ const ratios = scene?.panels[0]?.series[0]?.points.map(({ xRatio }) => xRatio) ?? [];
556
+ expect(ratios).toHaveLength(3);
557
+ expect(ratios[0]).toBe(0);
558
+ expect(ratios[1]).toBeCloseTo(0.5 * NEWEST_X_RATIO, 10);
559
+ expect(ratios[2]).toBeCloseTo(NEWEST_X_RATIO, 10);
434
560
  });
435
561
 
436
562
  test("retains the authored primary market scale when its plotted series is hidden", () => {