gloomberb 0.9.3 → 0.9.5

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 (176) hide show
  1. package/README.md +1 -0
  2. package/package.json +3 -2
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/runtime/desktop-menu.ts +5 -1
  6. package/src/app/runtime/ticker-refresh.ts +4 -1
  7. package/src/app.tsx +10 -1
  8. package/src/brokers/account-cache.ts +1 -1
  9. package/src/brokers/sync-broker-instance.test.ts +107 -0
  10. package/src/brokers/sync-broker-instance.ts +22 -10
  11. package/src/cli/desktop-pane-shot.ts +2 -1
  12. package/src/cli/entry.ts +8 -1
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/cli/pane-functions/screenshot.ts +13 -1
  15. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  16. package/src/components/chart/bar-chart-renderer.ts +226 -27
  17. package/src/components/chart/core/pointer.ts +32 -6
  18. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  19. package/src/components/chart/native/bitmap-support.ts +55 -0
  20. package/src/components/chart/native/kitty/index.test.ts +47 -0
  21. package/src/components/chart/native/surface/visibility.ts +8 -1
  22. package/src/components/chart/price-axis-labels.tsx +3 -1
  23. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  24. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  25. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  26. package/src/components/chart/static/chart/bitmap.ts +16 -9
  27. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  28. package/src/components/chart/static/chart/surface.tsx +167 -24
  29. package/src/components/chart/stock-chart/index.test.ts +25 -0
  30. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  31. package/src/components/command-bar/list/view.tsx +1 -1
  32. package/src/components/command-bar/theme-picker.tsx +1 -1
  33. package/src/components/data-table/view.tsx +0 -12
  34. package/src/components/layout/detached-pane-shell.tsx +6 -3
  35. package/src/components/layout/floating-pane.tsx +6 -5
  36. package/src/components/layout/pane/content.tsx +20 -8
  37. package/src/components/layout/pane/footer/registration.tsx +6 -2
  38. package/src/components/layout/pane/frame.tsx +4 -4
  39. package/src/components/layout/pane/header.tsx +45 -4
  40. package/src/components/layout/pane/index.tsx +6 -4
  41. package/src/components/layout/pane/sizing.test.ts +43 -0
  42. package/src/components/layout/pane/sizing.ts +13 -4
  43. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  44. package/src/components/layout/shell/cursor-occlusion.ts +204 -0
  45. package/src/components/layout/shell/drag/runtime.ts +9 -2
  46. package/src/components/layout/shell/index.test.tsx +92 -2
  47. package/src/components/layout/shell/index.tsx +45 -3
  48. package/src/components/layout/shell/layout-state.test.ts +33 -0
  49. package/src/components/layout/shell/layout-state.ts +4 -9
  50. package/src/components/layout/shell/pane/layers.tsx +10 -4
  51. package/src/components/layout/shell/visible-layout.ts +17 -0
  52. package/src/components/layout/status-bar.test.tsx +6 -1
  53. package/src/components/layout/status-bar.tsx +10 -6
  54. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  55. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  56. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  57. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  58. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  59. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  60. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  61. package/src/components/price-performance.tsx +1 -1
  62. package/src/components/price-sparkline/view.tsx +15 -14
  63. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  64. package/src/components/table-view-shared.tsx +12 -14
  65. package/src/components/ticker/list-table-view.tsx +1 -7
  66. package/src/components/ticker/list-table.test.tsx +0 -4
  67. package/src/components/ui/data-table/opentui/index.tsx +29 -22
  68. package/src/components/ui/data-table/types.ts +10 -4
  69. package/src/components/ui/fields.tsx +10 -3
  70. package/src/components/ui/list-view.tsx +1 -1
  71. package/src/components/ui/page-stack-view.tsx +6 -2
  72. package/src/components/ui/table-layout.test.ts +25 -0
  73. package/src/components/ui/table-layout.ts +22 -6
  74. package/src/components/ui/tabs.tsx +27 -12
  75. package/src/components/ui/ui.test.tsx +87 -9
  76. package/src/core/state/app/layout-reducer.ts +2 -2
  77. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  78. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  79. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  80. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  81. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  82. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  83. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  84. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  85. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  86. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  87. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  88. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  89. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  90. package/src/plugins/builtin/debug/index.tsx +3 -4
  91. package/src/plugins/builtin/help/components.tsx +1 -1
  92. package/src/plugins/builtin/holders/treemap.tsx +1 -1
  93. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  94. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  95. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  96. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  97. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  98. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  99. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  100. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  101. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  102. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  103. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  104. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  105. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  106. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  107. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  108. package/src/plugins/builtin/options/table.ts +5 -5
  109. package/src/plugins/builtin/options/view.test.tsx +141 -4
  110. package/src/plugins/builtin/options/view.tsx +76 -15
  111. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  112. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  113. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  114. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  115. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  116. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  117. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  118. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  119. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  120. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  121. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  122. package/src/plugins/catalog-ui.ts +2 -0
  123. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  124. package/src/plugins/ibkr/config.test.ts +31 -2
  125. package/src/plugins/ibkr/config.ts +9 -2
  126. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  127. package/src/plugins/ibkr/flex/index.ts +30 -0
  128. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  129. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  130. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  131. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  132. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  133. package/src/plugins/pane-manager/gridlock.ts +13 -5
  134. package/src/plugins/pane-manager/layout-state.ts +25 -0
  135. package/src/plugins/pane-manager.test.ts +33 -0
  136. package/src/plugins/pane-manager.ts +6 -1
  137. package/src/plugins/prediction-markets/chart.tsx +10 -4
  138. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  139. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  140. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  141. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  142. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  143. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  144. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  145. package/src/plugins/prediction-markets/pane.tsx +50 -22
  146. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  147. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  148. package/src/renderers/electrobun/view/data-table/index.tsx +19 -12
  149. package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
  150. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  151. package/src/renderers/electrobun/view/host/box.tsx +6 -1
  152. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  153. package/src/renderers/electrobun/view/host/style.ts +1 -0
  154. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  155. package/src/renderers/electrobun/view/styles.css +18 -0
  156. package/src/renderers/opentui/host.tsx +17 -1
  157. package/src/renderers/opentui/native-smoke.ts +1 -0
  158. package/src/renderers/opentui/ui-host.tsx +57 -25
  159. package/src/sources/provider-router/batches.ts +16 -2
  160. package/src/sources/provider-router/financial-routes.ts +5 -0
  161. package/src/sources/provider-router/financials.ts +11 -23
  162. package/src/sources/provider-router/index.test.ts +189 -0
  163. package/src/sources/provider-router/primary.ts +3 -2
  164. package/src/sources/sec-edgar.test.ts +83 -0
  165. package/src/sources/sec-edgar.ts +231 -0
  166. package/src/sources/yahoo-finance.test.ts +51 -0
  167. package/src/sources/yahoo-finance.ts +49 -1
  168. package/src/state/app/context/index.test.tsx +5 -8
  169. package/src/types/broker.ts +1 -0
  170. package/src/types/config.ts +10 -0
  171. package/src/types/trading.ts +2 -0
  172. package/src/ui/host.tsx +16 -0
  173. package/src/ui/index.tsx +2 -0
  174. package/src/utils/financial-statements.ts +23 -0
  175. package/src/version.ts +1 -1
  176. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -0,0 +1,243 @@
1
+ import { Box, Text, useUiCapabilities, useUiHost } from "../../../../ui";
2
+
3
+ export interface StaticChartXMarker {
4
+ id: string;
5
+ xRatio: number;
6
+ label?: string;
7
+ color?: string;
8
+ lineChar?: string;
9
+ }
10
+
11
+ export function clampRatio(value: number): number {
12
+ if (!Number.isFinite(value)) return 0;
13
+ return Math.max(0, Math.min(1, value));
14
+ }
15
+
16
+ function markerColumn(xRatio: number, width: number): number {
17
+ return Math.round(clampRatio(xRatio) * Math.max(0, width - 1));
18
+ }
19
+
20
+ function alignAxisLabel(label: string, width: number, index: number, count: number): string {
21
+ const clipped = label.length > width ? label.slice(0, width) : label;
22
+ const padding = Math.max(0, width - clipped.length);
23
+ if (index === 0) return clipped.padEnd(width);
24
+ if (index === count - 1) return clipped.padStart(width);
25
+ const left = Math.floor(padding / 2);
26
+ return `${" ".repeat(left)}${clipped}${" ".repeat(padding - left)}`;
27
+ }
28
+
29
+ export function StaticXAxisLabels({
30
+ labels,
31
+ width,
32
+ color,
33
+ cursorColumn,
34
+ cursorPixelX,
35
+ cursorLabel,
36
+ cursorColor,
37
+ cursorBackgroundColor,
38
+ }: {
39
+ labels: string[];
40
+ width: number;
41
+ color?: string;
42
+ cursorColumn?: number | null;
43
+ cursorPixelX?: number | null;
44
+ cursorLabel?: string | null;
45
+ cursorColor?: string;
46
+ cursorBackgroundColor?: string;
47
+ }) {
48
+ const { cellWidthPx = 8, fractionalViewport = false } = useUiCapabilities();
49
+ const visibleLabels = labels.filter(Boolean);
50
+ if (visibleLabels.length === 0 || width <= 0) return null;
51
+ const baseWidth = Math.floor(width / visibleLabels.length);
52
+ let remainder = width - baseWidth * visibleLabels.length;
53
+ const clippedCursorLabel = cursorLabel ? cursorLabel.slice(0, width) : null;
54
+ const cursorLabelWidth = clippedCursorLabel?.length ?? 0;
55
+ const usePixelOverlay = fractionalViewport
56
+ && clippedCursorLabel !== null
57
+ && cursorPixelX !== null
58
+ && cursorPixelX !== undefined
59
+ && Number.isFinite(cursorPixelX)
60
+ && cursorLabelWidth > 0;
61
+ const pixelWidth = Math.max(width * cellWidthPx, 1);
62
+ const halfCursorLabelWidthPx = Math.min((cursorLabelWidth * cellWidthPx) / 2, (pixelWidth - 1) / 2);
63
+ const cursorLeftPercent = usePixelOverlay
64
+ ? (
65
+ Math.max(
66
+ halfCursorLabelWidthPx,
67
+ Math.min(cursorPixelX!, Math.max(pixelWidth - halfCursorLabelWidthPx, halfCursorLabelWidthPx)),
68
+ ) / Math.max(pixelWidth - 1, 1)
69
+ ) * 100
70
+ : null;
71
+ const cursorLeft = cursorColumn !== null
72
+ && cursorColumn !== undefined
73
+ && Number.isFinite(cursorColumn)
74
+ && cursorLabelWidth > 0
75
+ ? Math.max(0, Math.min(Math.max(0, width - cursorLabelWidth), Math.round(cursorColumn) - Math.floor(cursorLabelWidth / 2)))
76
+ : null;
77
+
78
+ return (
79
+ <Box
80
+ width={width}
81
+ height={1}
82
+ flexDirection="row"
83
+ position="relative"
84
+ overflow="hidden"
85
+ >
86
+ <Box width={width} height={1} flexDirection="row">
87
+ {visibleLabels.map((label, index) => {
88
+ const cellWidth = baseWidth + (remainder > 0 ? 1 : 0);
89
+ remainder -= remainder > 0 ? 1 : 0;
90
+ return (
91
+ <Box key={`${label}:${index}`} width={cellWidth} overflow="hidden">
92
+ <Text fg={color}>{alignAxisLabel(label, cellWidth, index, visibleLabels.length)}</Text>
93
+ </Box>
94
+ );
95
+ })}
96
+ </Box>
97
+ {clippedCursorLabel && (cursorLeftPercent !== null || cursorLeft !== null) ? (
98
+ <Text
99
+ fg={cursorColor}
100
+ bg={cursorBackgroundColor}
101
+ selectable={false}
102
+ style={cursorLeftPercent !== null
103
+ ? {
104
+ position: "absolute",
105
+ left: `${cursorLeftPercent}%`,
106
+ top: 0,
107
+ transform: "translateX(-50%)",
108
+ whiteSpace: "pre",
109
+ pointerEvents: "none",
110
+ zIndex: 3,
111
+ }
112
+ : {
113
+ position: "absolute",
114
+ left: cursorLeft ?? 0,
115
+ top: 0,
116
+ whiteSpace: "pre",
117
+ pointerEvents: "none",
118
+ zIndex: 3,
119
+ }}
120
+ >
121
+ {clippedCursorLabel}
122
+ </Text>
123
+ ) : null}
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ export function StaticXMarkerOverlay({
129
+ markers,
130
+ width,
131
+ height,
132
+ fallbackColor,
133
+ }: {
134
+ markers: StaticChartXMarker[];
135
+ width: number;
136
+ height: number;
137
+ fallbackColor?: string;
138
+ }) {
139
+ const uiHost = useUiHost();
140
+ const visibleMarkers = markers.filter((marker) => Number.isFinite(marker.xRatio));
141
+ if (visibleMarkers.length === 0 || width <= 0 || height <= 0) return null;
142
+
143
+ if (uiHost.kind === "desktop-web") {
144
+ return (
145
+ <Box position="absolute" left={0} top={0} width={width} height={height}>
146
+ {visibleMarkers.map((marker) => (
147
+ <Box
148
+ key={marker.id}
149
+ position="absolute"
150
+ left={`${clampRatio(marker.xRatio) * 100}%`}
151
+ top={0}
152
+ bottom={0}
153
+ width={0}
154
+ zIndex={2}
155
+ style={{
156
+ width: 1,
157
+ transform: "translateX(-0.5px)",
158
+ backgroundColor: marker.color ?? fallbackColor ?? "currentColor",
159
+ opacity: 0.85,
160
+ pointerEvents: "none",
161
+ }}
162
+ />
163
+ ))}
164
+ </Box>
165
+ );
166
+ }
167
+
168
+ return (
169
+ <Box position="absolute" left={0} top={0} width={width} height={height}>
170
+ {visibleMarkers.map((marker) => {
171
+ const left = markerColumn(marker.xRatio, width);
172
+ return (
173
+ <Box
174
+ key={marker.id}
175
+ position="absolute"
176
+ left={left}
177
+ top={0}
178
+ width={1}
179
+ height={height}
180
+ flexDirection="column"
181
+ zIndex={2}
182
+ >
183
+ {Array.from({ length: height }, (_, row) => (
184
+ <Text key={row} fg={marker.color}>
185
+ {marker.lineChar ?? "│"}
186
+ </Text>
187
+ ))}
188
+ </Box>
189
+ );
190
+ })}
191
+ </Box>
192
+ );
193
+ }
194
+
195
+ export function StaticXMarkerLabels({
196
+ markers,
197
+ width,
198
+ fallbackColor,
199
+ }: {
200
+ markers: StaticChartXMarker[];
201
+ width: number;
202
+ fallbackColor?: string;
203
+ }) {
204
+ const visibleMarkers = markers
205
+ .filter((marker) => marker.label && Number.isFinite(marker.xRatio))
206
+ .sort((left, right) => markerColumn(left.xRatio, width) - markerColumn(right.xRatio, width));
207
+ if (visibleMarkers.length === 0 || width <= 0) return null;
208
+
209
+ let nextAvailableLeft = 0;
210
+ const placements = visibleMarkers.map((marker) => {
211
+ const label = marker.label ?? "";
212
+ const labelWidth = Math.min(label.length, width);
213
+ const centeredLeft = markerColumn(marker.xRatio, width) - Math.floor(labelWidth / 2);
214
+ let left = Math.max(0, Math.min(Math.max(0, width - labelWidth), centeredLeft));
215
+ left = Math.max(left, nextAvailableLeft);
216
+ if (left + labelWidth > width) {
217
+ left = Math.max(nextAvailableLeft, width - labelWidth);
218
+ }
219
+ nextAvailableLeft = Math.min(width, left + labelWidth + 1);
220
+ return { marker, label, labelWidth, left };
221
+ }).filter((placement) => placement.labelWidth > 0 && placement.left < width);
222
+
223
+ return (
224
+ <Box position="relative" width={width} height={1}>
225
+ {placements.map(({ marker, label, labelWidth, left }) => {
226
+ return (
227
+ <Box
228
+ key={marker.id}
229
+ position="absolute"
230
+ left={left}
231
+ top={0}
232
+ width={labelWidth}
233
+ height={1}
234
+ overflow="hidden"
235
+ zIndex={2}
236
+ >
237
+ <Text fg={marker.color ?? fallbackColor}>{label.slice(0, labelWidth)}</Text>
238
+ </Box>
239
+ );
240
+ })}
241
+ </Box>
242
+ );
243
+ }
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { useNativeRenderer, useUiCapabilities } from "../../../../ui";
3
- import { computeBitmapSize } from "../../native/chart-rasterizer";
3
+ import { resolveNativeBitmapSize, shouldRenderNativeBitmap } from "../../native/bitmap-support";
4
4
 
5
5
  export interface StaticChartBitmapSize {
6
6
  pixelWidth: number;
@@ -16,20 +16,25 @@ export function useStaticChartBitmapSize(width: number, height: number): StaticC
16
16
  pixelRatio = 1,
17
17
  } = useUiCapabilities();
18
18
  const nativeRenderer = useNativeRenderer();
19
+ const rendererCapabilities = nativeRenderer.capabilities;
19
20
  const rendererResolution = nativeRenderer.resolution;
20
21
  const rendererTerminalWidth = nativeRenderer.terminalWidth;
21
22
  const rendererTerminalHeight = nativeRenderer.terminalHeight;
22
23
 
23
24
  return useMemo(() => {
24
- if (nativeCharts && rendererResolution && rendererTerminalWidth > 0 && rendererTerminalHeight > 0) {
25
- return computeBitmapSize(
26
- { x: 0, y: 0, width, height },
27
- rendererResolution,
28
- rendererTerminalWidth,
29
- rendererTerminalHeight,
30
- );
25
+ if (shouldRenderNativeBitmap(nativeRenderer, nativeCharts === true)) {
26
+ return resolveNativeBitmapSize({
27
+ width,
28
+ height,
29
+ resolution: rendererResolution,
30
+ terminalWidth: rendererTerminalWidth,
31
+ terminalHeight: rendererTerminalHeight,
32
+ cellWidthPx,
33
+ cellHeightPx,
34
+ pixelRatio,
35
+ });
31
36
  }
32
- if (!canvasCharts && !nativeCharts) return null;
37
+ if (!canvasCharts) return null;
33
38
  const scale = Math.max(1, pixelRatio);
34
39
  return {
35
40
  pixelWidth: Math.max(1, Math.round(width * cellWidthPx * scale)),
@@ -41,7 +46,9 @@ export function useStaticChartBitmapSize(width: number, height: number): StaticC
41
46
  cellWidthPx,
42
47
  height,
43
48
  nativeCharts,
49
+ nativeRenderer,
44
50
  pixelRatio,
51
+ rendererCapabilities,
45
52
  rendererResolution,
46
53
  rendererTerminalHeight,
47
54
  rendererTerminalWidth,
@@ -48,4 +48,96 @@ describe("StaticChartSurface", () => {
48
48
  expect(frame).toContain("4.90%");
49
49
  expect(frame).toContain("3.50%");
50
50
  });
51
+
52
+ test("renders custom x-axis scale labels", async () => {
53
+ testSetup = await testRender(
54
+ <StaticChartSurface
55
+ points={points}
56
+ width={48}
57
+ height={10}
58
+ mode="line"
59
+ colors={resolveChartPalette(colors, "positive")}
60
+ xAxisLabels={["0%", "50%", "100%"]}
61
+ xAxisColor={colors.textDim}
62
+ />,
63
+ { width: 50, height: 12 },
64
+ );
65
+
66
+ await act(async () => {
67
+ await testSetup!.renderOnce();
68
+ await testSetup!.renderOnce();
69
+ });
70
+
71
+ const frame = testSetup.captureCharFrame();
72
+ expect(frame).toContain("0%");
73
+ expect(frame).toContain("50%");
74
+ expect(frame).toContain("100%");
75
+ });
76
+
77
+ test("renders x-axis decision markers", async () => {
78
+ testSetup = await testRender(
79
+ <StaticChartSurface
80
+ points={points}
81
+ width={56}
82
+ height={10}
83
+ mode="line"
84
+ colors={resolveChartPalette(colors, "positive")}
85
+ xAxisLabels={["0%", "50%", "100%"]}
86
+ xMarkers={[
87
+ { id: "current", xRatio: 0.1, label: "current", lineChar: "┊" },
88
+ { id: "target", xRatio: 0.5, label: "target", lineChar: "┃" },
89
+ { id: "full", xRatio: 0.8, label: "full", lineChar: "│" },
90
+ ]}
91
+ xAxisColor={colors.textDim}
92
+ />,
93
+ { width: 58, height: 12 },
94
+ );
95
+
96
+ await act(async () => {
97
+ await testSetup!.renderOnce();
98
+ await testSetup!.renderOnce();
99
+ });
100
+
101
+ const frame = testSetup.captureCharFrame();
102
+ expect(frame).toContain("current");
103
+ expect(frame).toContain("target");
104
+ expect(frame).toContain("full");
105
+ expect(frame).toContain("┃");
106
+ });
107
+
108
+ test("renders a cursor crosshair after mouse movement", async () => {
109
+ testSetup = await testRender(
110
+ <StaticChartSurface
111
+ points={points}
112
+ width={48}
113
+ height={10}
114
+ mode="line"
115
+ colors={resolveChartPalette(colors, "positive")}
116
+ xAxisLabels={["0%", "50%", "100%"]}
117
+ formatXAxisCursorValue={(ratio) => `X${Math.round(ratio * 100)}`}
118
+ formatYAxisValue={(value) => `Y${value.toFixed(2)}`}
119
+ xMarkers={[
120
+ { id: "current", xRatio: 0.1, label: "current", lineChar: "┊" },
121
+ { id: "target", xRatio: 0.5, label: "target", lineChar: "┃" },
122
+ ]}
123
+ />,
124
+ { width: 50, height: 12 },
125
+ );
126
+
127
+ await act(async () => {
128
+ await testSetup!.renderOnce();
129
+ await testSetup!.renderOnce();
130
+ });
131
+ const initialFrame = testSetup.captureCharFrame();
132
+
133
+ await act(async () => {
134
+ await testSetup!.mockMouse.moveTo(20, 4);
135
+ await testSetup!.renderOnce();
136
+ await testSetup!.renderOnce();
137
+ });
138
+
139
+ expect(testSetup.captureCharFrame()).not.toBe(initialFrame);
140
+ expect(testSetup.captureCharFrame()).toContain("X49");
141
+ expect(testSetup.captureCharFrame()).toContain("Y3.97");
142
+ });
51
143
  });
@@ -1,16 +1,26 @@
1
- import { useMemo } from "react";
2
- import { Box, ChartSurface, Text } from "../../../../ui";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { Box, ChartSurface, Text, type BoxRenderable, type ChartSurfaceProps, useUiCapabilities } from "../../../../ui";
3
+ import { colors as themeColors } from "../../../../theme/colors";
3
4
  import { useThemeColors } from "../../../../theme/theme-context";
5
+ import { PriceAxisLabels } from "../../price-axis-labels";
4
6
  import type { ProjectedChartPoint } from "../../core/data";
5
7
  import {
6
8
  buildChartScene,
7
9
  computeGridLines,
8
- formatAxisCell,
9
10
  renderChart,
10
11
  type RenderChartOptions,
11
12
  } from "../../core/renderer";
12
13
  import { renderNativeChartBase, type NativeChartBitmap } from "../../native/chart-rasterizer";
13
14
  import { useStaticChartBitmapSize } from "./bitmap";
15
+ import {
16
+ StaticXAxisLabels,
17
+ StaticXMarkerLabels,
18
+ StaticXMarkerOverlay,
19
+ clampRatio,
20
+ type StaticChartXMarker,
21
+ } from "./axis-overlays";
22
+
23
+ export type { StaticChartXMarker } from "./axis-overlays";
14
24
 
15
25
  export interface StaticChartSurfaceProps extends Omit<
16
26
  RenderChartOptions,
@@ -23,11 +33,52 @@ export interface StaticChartSurfaceProps extends Omit<
23
33
  volumeHeight?: number;
24
34
  showTimeAxis?: boolean;
25
35
  timeAxisColor?: string;
36
+ xAxisLabels?: string[];
37
+ xAxisColor?: string;
38
+ formatXAxisCursorValue?: (xRatio: number) => string;
39
+ xMarkers?: StaticChartXMarker[];
26
40
  yAxisLabel?: string;
27
41
  yAxisColor?: string;
28
42
  formatYAxisValue?: (value: number) => string;
29
43
  }
30
44
 
45
+ interface ChartMouseEventLike {
46
+ x: number;
47
+ y: number;
48
+ preciseX?: number;
49
+ preciseY?: number;
50
+ preventDefault?: () => void;
51
+ }
52
+
53
+ function localPlotPointer(
54
+ event: ChartMouseEventLike,
55
+ renderable: BoxRenderable | null,
56
+ ): { x: number; y: number } | null {
57
+ if (!renderable) return null;
58
+ const originX = typeof renderable.absoluteX === "number"
59
+ ? renderable.absoluteX
60
+ : typeof renderable.x === "number"
61
+ ? renderable.x
62
+ : 0;
63
+ const originY = typeof renderable.absoluteY === "number"
64
+ ? renderable.absoluteY
65
+ : typeof renderable.y === "number"
66
+ ? renderable.y
67
+ : 0;
68
+ const rawX = typeof event.preciseX === "number" ? event.preciseX : event.x;
69
+ const rawY = typeof event.preciseY === "number" ? event.preciseY : event.y;
70
+ const x = rawX - originX;
71
+ const y = rawY - originY;
72
+ const width = typeof renderable.width === "number" ? renderable.width : 0;
73
+ const height = typeof renderable.height === "number" ? renderable.height : 0;
74
+ if (!Number.isFinite(x) || !Number.isFinite(y) || width <= 0 || height <= 0) return null;
75
+ if (x < 0 || y < 0 || x >= width || y >= height) return null;
76
+ return {
77
+ x: Math.max(0, Math.min(x, Math.max(width - 1, 0))),
78
+ y: Math.max(0, Math.min(y, Math.max(height - 1, 0))),
79
+ };
80
+ }
81
+
31
82
  export function StaticChartSurface({
32
83
  points,
33
84
  width,
@@ -43,16 +94,24 @@ export function StaticChartSurface({
43
94
  volumeHeight = 0,
44
95
  showTimeAxis = false,
45
96
  timeAxisColor,
97
+ xAxisLabels,
98
+ xAxisColor,
99
+ formatXAxisCursorValue,
100
+ xMarkers,
46
101
  yAxisLabel,
47
102
  yAxisColor,
48
103
  formatYAxisValue,
49
104
  }: StaticChartSurfaceProps) {
50
105
  useThemeColors();
51
- const timeAxisRows = showTimeAxis ? 1 : 0;
106
+ const { cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
107
+ const plotRef = useRef<BoxRenderable | null>(null);
108
+ const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
109
+ const xAxisRows = showTimeAxis || (xAxisLabels?.length ?? 0) > 0 ? 1 : 0;
110
+ const xMarkerRows = xMarkers?.some((marker) => marker.label) ? 1 : 0;
52
111
  const labelRows = yAxisLabel ? 1 : 0;
53
112
  const totalWidth = Math.max(1, Math.floor(width));
54
113
  const totalHeight = Math.max(1, Math.floor(height));
55
- const plotHeight = Math.max(1, totalHeight - timeAxisRows - labelRows);
114
+ const plotHeight = Math.max(1, totalHeight - xAxisRows - xMarkerRows - labelRows);
56
115
  const axisSourceOptions = useMemo<RenderChartOptions>(() => ({
57
116
  width: totalWidth,
58
117
  height: plotHeight,
@@ -104,8 +163,8 @@ export function StaticChartSurface({
104
163
  height: plotHeight,
105
164
  showVolume,
106
165
  volumeHeight,
107
- cursorX: null,
108
- cursorY: null,
166
+ cursorX: cursor?.x ?? null,
167
+ cursorY: cursor?.y ?? null,
109
168
  mode,
110
169
  axisMode,
111
170
  currency,
@@ -122,6 +181,7 @@ export function StaticChartSurface({
122
181
  mode,
123
182
  plotHeight,
124
183
  plotWidth,
184
+ cursor,
125
185
  showVolume,
126
186
  timeAxisDates,
127
187
  volumeHeight,
@@ -133,6 +193,14 @@ export function StaticChartSurface({
133
193
  const effectiveAxisLabelsByRow = useMemo(() => {
134
194
  return new Map(axisLabels.map((entry) => [entry.row, entry.label] as const));
135
195
  }, [axisLabels]);
196
+ const cursorAxisLabel = useMemo(() => {
197
+ if (!formatYAxisValue || textResult.crosshairPrice === null) return null;
198
+ return formatYAxisValue(textResult.crosshairPrice);
199
+ }, [formatYAxisValue, textResult.crosshairPrice]);
200
+ const cursorXAxisLabel = useMemo(() => {
201
+ if (!formatXAxisCursorValue || !cursor) return null;
202
+ return formatXAxisCursorValue(clampRatio(cursor.x / Math.max(plotWidth - 1, 1)));
203
+ }, [cursor, formatXAxisCursorValue, plotWidth]);
136
204
  const effectiveAxisWidth = axisWidth;
137
205
  const effectiveAxisGap = effectiveAxisWidth > 0 ? 1 : 0;
138
206
  const bitmap = useMemo<NativeChartBitmap | null>(() => {
@@ -141,40 +209,115 @@ export function StaticChartSurface({
141
209
  if (!scene) return null;
142
210
  return renderNativeChartBase(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
143
211
  }, [bitmapSize, points, renderOptions]);
212
+ const canvasCrosshair = useMemo<ChartSurfaceProps["crosshair"]>(() => {
213
+ if (!bitmap || !cursor) return null;
214
+ return {
215
+ pixelX: Math.round(clampRatio(cursor.x / Math.max(plotWidth - 1, 1)) * Math.max(bitmap.width - 1, 0)),
216
+ pixelY: Math.round(clampRatio(cursor.y / Math.max(plotHeight - 1, 1)) * Math.max(bitmap.height - 1, 0)),
217
+ color: colors.crosshairColor,
218
+ };
219
+ }, [bitmap, colors.crosshairColor, cursor, plotHeight, plotWidth]);
220
+ const handleCursorEvent = useCallback((event: ChartMouseEventLike) => {
221
+ const pointer = localPlotPointer(event, plotRef.current);
222
+ if (!pointer) return;
223
+ event.preventDefault?.();
224
+ setCursor((current) => (
225
+ current && current.x === pointer.x && current.y === pointer.y ? current : pointer
226
+ ));
227
+ }, []);
228
+ const clearCursor = useCallback(() => {
229
+ setCursor(null);
230
+ }, []);
144
231
 
145
232
  return (
146
- <Box flexDirection="column" width={totalWidth} height={plotHeight + timeAxisRows + labelRows}>
233
+ <Box flexDirection="column" width={totalWidth} height={plotHeight + xAxisRows + xMarkerRows + labelRows}>
147
234
  {yAxisLabel ? (
148
235
  <Box height={1}>
149
236
  <Text fg={yAxisColor}>{yAxisLabel}</Text>
150
237
  </Box>
151
238
  ) : null}
152
239
  <Box flexDirection="row" height={plotHeight}>
153
- <ChartSurface
240
+ <Box
241
+ ref={plotRef}
242
+ position="relative"
154
243
  width={plotWidth}
155
244
  height={plotHeight}
156
- flexDirection="column"
157
- bitmaps={bitmap ? [bitmap] : null}
245
+ onMouseMove={handleCursorEvent}
246
+ onMouseDown={handleCursorEvent}
247
+ onMouseOut={clearCursor}
248
+ data-gloom-role="static-chart-plot"
158
249
  >
159
- {textResult.lines.map((line, index) => (
160
- <Text key={index} content={line} />
161
- ))}
162
- </ChartSurface>
250
+ <ChartSurface
251
+ width={plotWidth}
252
+ height={plotHeight}
253
+ flexDirection="column"
254
+ bitmaps={bitmap ? [bitmap] : null}
255
+ crosshair={canvasCrosshair}
256
+ >
257
+ {textResult.lines.map((line, index) => (
258
+ <Text key={index} content={line} />
259
+ ))}
260
+ </ChartSurface>
261
+ {xMarkers ? (
262
+ <StaticXMarkerOverlay
263
+ markers={xMarkers}
264
+ width={plotWidth}
265
+ height={plotHeight}
266
+ fallbackColor={xAxisColor ?? timeAxisColor}
267
+ />
268
+ ) : null}
269
+ </Box>
163
270
  {effectiveAxisWidth > 0 ? (
164
271
  <>
165
272
  <Box width={effectiveAxisGap} />
166
- <Box width={effectiveAxisWidth} height={plotHeight} flexDirection="column">
167
- {Array.from({ length: plotHeight }, (_, row) => (
168
- <Text key={row} fg={yAxisColor}>
169
- {formatAxisCell(effectiveAxisLabelsByRow.get(row) ?? null, effectiveAxisWidth)}
170
- </Text>
171
- ))}
172
- </Box>
273
+ <PriceAxisLabels
274
+ axisLabels={effectiveAxisLabelsByRow}
275
+ axisWidth={effectiveAxisWidth}
276
+ axisSectionWidth={effectiveAxisWidth}
277
+ height={plotHeight}
278
+ cursorRow={textResult.cursorRow}
279
+ cursorPixelY={cursor ? cursor.y * cellHeightPx : null}
280
+ cursorLabel={cursorAxisLabel}
281
+ cursorColor={colors.crosshairColor}
282
+ axisColor={yAxisColor}
283
+ />
173
284
  </>
174
285
  ) : null}
175
286
  </Box>
176
- {showTimeAxis ? (
177
- <Text fg={timeAxisColor}>{textResult.timeLabels}</Text>
287
+ {showTimeAxis || (xAxisLabels?.length ?? 0) > 0 ? (
288
+ <Box height={1} flexDirection="row">
289
+ {xAxisLabels ? (
290
+ <StaticXAxisLabels
291
+ labels={xAxisLabels}
292
+ width={plotWidth}
293
+ color={xAxisColor ?? timeAxisColor}
294
+ cursorColumn={textResult.cursorColumn}
295
+ cursorPixelX={cursor ? cursor.x * cellWidthPx : null}
296
+ cursorLabel={cursorXAxisLabel}
297
+ cursorColor={colors.crosshairColor}
298
+ cursorBackgroundColor={colors.bgColor ?? themeColors.bg}
299
+ />
300
+ ) : (
301
+ <Text fg={timeAxisColor}>{textResult.timeLabels}</Text>
302
+ )}
303
+ {effectiveAxisWidth > 0 ? (
304
+ <>
305
+ <Box width={effectiveAxisGap} />
306
+ <Box width={effectiveAxisWidth} />
307
+ </>
308
+ ) : null}
309
+ </Box>
310
+ ) : null}
311
+ {xMarkers && xMarkerRows > 0 ? (
312
+ <Box height={1} flexDirection="row">
313
+ <StaticXMarkerLabels markers={xMarkers} width={plotWidth} fallbackColor={xAxisColor ?? timeAxisColor} />
314
+ {effectiveAxisWidth > 0 ? (
315
+ <>
316
+ <Box width={effectiveAxisGap} />
317
+ <Box width={effectiveAxisWidth} />
318
+ </>
319
+ ) : null}
320
+ </Box>
178
321
  ) : null}
179
322
  </Box>
180
323
  );
@@ -76,6 +76,31 @@ describe("stock chart pointer helpers", () => {
76
76
  });
77
77
  });
78
78
 
79
+ test("uses absolute renderable coordinates for desktop mouse hit testing", () => {
80
+ const pointer = getLocalPlotPointer({
81
+ x: 49,
82
+ y: 10,
83
+ preciseX: 49.5,
84
+ preciseY: 10.25,
85
+ modifiers: { shift: false, alt: false, ctrl: false },
86
+ }, {
87
+ x: 0,
88
+ y: 0,
89
+ absoluteX: 10,
90
+ absoluteY: 5,
91
+ width: 40,
92
+ height: 12,
93
+ }, renderer);
94
+
95
+ expect(pointer).toMatchObject({
96
+ hasPixelPrecision: false,
97
+ pixelX: null,
98
+ pixelY: null,
99
+ });
100
+ expect(pointer!.cellX).toBeCloseTo(39.5, 2);
101
+ expect(pointer!.cellY).toBeCloseTo(5.25, 2);
102
+ });
103
+
79
104
  test("can derive local pixels from cell coordinates for non-pixel fallbacks", () => {
80
105
  const pixels = projectCellCursorToLocalPixels(14, 5, renderable, renderer);
81
106