gloomberb 0.9.3 → 0.9.4

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 (137) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/app/runtime/desktop-menu.ts +5 -1
  4. package/src/app/runtime/ticker-refresh.ts +4 -1
  5. package/src/app.tsx +10 -1
  6. package/src/brokers/account-cache.ts +1 -1
  7. package/src/brokers/sync-broker-instance.test.ts +107 -0
  8. package/src/brokers/sync-broker-instance.ts +22 -10
  9. package/src/cli/desktop-pane-shot.ts +2 -1
  10. package/src/cli/pane-functions/screenshot.ts +13 -1
  11. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  12. package/src/components/chart/bar-chart-renderer.ts +226 -27
  13. package/src/components/chart/core/pointer.ts +32 -6
  14. package/src/components/chart/price-axis-labels.tsx +3 -1
  15. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  16. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  17. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  18. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  19. package/src/components/chart/static/chart/surface.tsx +167 -24
  20. package/src/components/chart/stock-chart/index.test.ts +25 -0
  21. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  22. package/src/components/layout/detached-pane-shell.tsx +6 -3
  23. package/src/components/layout/floating-pane.tsx +3 -2
  24. package/src/components/layout/pane/content.tsx +20 -8
  25. package/src/components/layout/pane/footer/registration.tsx +6 -2
  26. package/src/components/layout/pane/frame.tsx +4 -4
  27. package/src/components/layout/pane/header.tsx +40 -4
  28. package/src/components/layout/pane/index.tsx +3 -1
  29. package/src/components/layout/pane/sizing.test.ts +43 -0
  30. package/src/components/layout/pane/sizing.ts +13 -4
  31. package/src/components/layout/shell/cursor-occlusion.ts +196 -0
  32. package/src/components/layout/shell/drag/runtime.ts +9 -2
  33. package/src/components/layout/shell/index.test.tsx +92 -2
  34. package/src/components/layout/shell/index.tsx +37 -2
  35. package/src/components/layout/shell/layout-state.test.ts +33 -0
  36. package/src/components/layout/shell/layout-state.ts +4 -9
  37. package/src/components/layout/shell/pane/layers.tsx +8 -2
  38. package/src/components/layout/shell/visible-layout.ts +17 -0
  39. package/src/components/layout/status-bar.test.tsx +6 -1
  40. package/src/components/layout/status-bar.tsx +5 -1
  41. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  42. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  43. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  44. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  45. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  46. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  47. package/src/components/table-view-shared.tsx +12 -4
  48. package/src/components/ui/data-table/opentui/index.tsx +25 -12
  49. package/src/components/ui/data-table/types.ts +4 -0
  50. package/src/components/ui/fields.tsx +10 -3
  51. package/src/components/ui/page-stack-view.tsx +6 -2
  52. package/src/components/ui/table-layout.test.ts +25 -0
  53. package/src/components/ui/table-layout.ts +22 -6
  54. package/src/components/ui/tabs.tsx +26 -10
  55. package/src/components/ui/ui.test.tsx +87 -0
  56. package/src/core/state/app/layout-reducer.ts +2 -2
  57. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  58. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  59. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  60. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  61. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  62. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  63. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  64. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  65. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  66. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  67. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  68. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  69. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  70. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  71. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  72. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  73. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  74. package/src/plugins/builtin/options/view.test.tsx +141 -4
  75. package/src/plugins/builtin/options/view.tsx +76 -15
  76. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  77. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  78. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  79. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  80. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  81. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  82. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  83. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  84. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  85. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  86. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  87. package/src/plugins/catalog-ui.ts +2 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  89. package/src/plugins/ibkr/config.test.ts +31 -2
  90. package/src/plugins/ibkr/config.ts +9 -2
  91. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  92. package/src/plugins/ibkr/flex/index.ts +30 -0
  93. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  94. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  95. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  96. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  97. package/src/plugins/pane-manager/gridlock.ts +13 -5
  98. package/src/plugins/pane-manager/layout-state.ts +25 -0
  99. package/src/plugins/pane-manager.test.ts +33 -0
  100. package/src/plugins/pane-manager.ts +6 -1
  101. package/src/plugins/prediction-markets/chart.tsx +10 -4
  102. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  103. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  104. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  105. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  106. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  107. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  108. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  109. package/src/plugins/prediction-markets/pane.tsx +50 -22
  110. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  111. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  112. package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
  113. package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
  114. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  115. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  116. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  117. package/src/renderers/electrobun/view/styles.css +11 -0
  118. package/src/renderers/opentui/host.tsx +17 -1
  119. package/src/renderers/opentui/ui-host.tsx +57 -25
  120. package/src/sources/provider-router/batches.ts +16 -2
  121. package/src/sources/provider-router/financial-routes.ts +5 -0
  122. package/src/sources/provider-router/financials.ts +11 -23
  123. package/src/sources/provider-router/index.test.ts +189 -0
  124. package/src/sources/provider-router/primary.ts +3 -2
  125. package/src/sources/sec-edgar.test.ts +83 -0
  126. package/src/sources/sec-edgar.ts +231 -0
  127. package/src/sources/yahoo-finance.test.ts +51 -0
  128. package/src/sources/yahoo-finance.ts +49 -1
  129. package/src/state/app/context/index.test.tsx +5 -8
  130. package/src/types/broker.ts +1 -0
  131. package/src/types/config.ts +10 -0
  132. package/src/types/trading.ts +2 -0
  133. package/src/ui/host.tsx +16 -0
  134. package/src/ui/index.tsx +2 -0
  135. package/src/utils/financial-statements.ts +23 -0
  136. package/src/version.ts +1 -1
  137. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,14 +1,19 @@
1
- import { useMemo } from "react";
2
- import { Box, ChartSurface, Span, Text } from "../../../ui";
1
+ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { Box, ChartSurface, Span, Text, useNativeRenderer, type BoxRenderable } from "../../../ui";
3
3
  import { colors } from "../../../theme/colors";
4
+ import { consumeChartMouseEvent, getLocalPlotPointer, type ChartMouseEvent } from "../core/pointer";
4
5
  import type { NativeChartBitmap } from "../native/chart-rasterizer";
6
+ import { truncateWithEllipsis } from "../../../utils/text-wrap";
5
7
  import { useStaticChartBitmapSize } from "./chart/bitmap";
6
8
  import {
7
9
  buildBarChartScene,
8
10
  renderBarChart,
9
11
  renderBarChartAxis,
12
+ renderBarChartYAxis,
10
13
  renderNativeBarChart,
14
+ resolveBarChartHover,
11
15
  type BarChartColors,
16
+ type BarChartHover,
12
17
  type BarChartSeries,
13
18
  } from "../bar-chart-renderer";
14
19
 
@@ -17,42 +22,116 @@ export interface StaticBarChartSurfaceProps {
17
22
  width: number;
18
23
  height: number;
19
24
  colors?: BarChartColors;
25
+ title?: string;
26
+ header?: ReactNode;
27
+ formatValue?: (value: number) => string;
28
+ onHoverChange?: (hover: BarChartHover | null) => void;
29
+ onMouseDown?: (event: ChartMouseEvent) => void;
20
30
  }
21
31
 
22
32
  export function StaticBarChartSurface({
23
33
  series,
24
34
  width,
25
35
  height,
26
- colors: chartColors = {
27
- bgColor: colors.bg,
28
- gridColor: colors.border,
29
- axisColor: colors.textDim,
30
- },
36
+ title,
37
+ header,
38
+ formatValue,
39
+ onHoverChange,
40
+ onMouseDown,
41
+ colors: chartColors,
31
42
  }: StaticBarChartSurfaceProps) {
43
+ const renderer = useNativeRenderer();
44
+ const plotRef = useRef<BoxRenderable | null>(null);
45
+ const [hover, setHover] = useState<BarChartHover | null>(null);
32
46
  const totalWidth = Math.max(1, Math.floor(width));
33
47
  const totalHeight = Math.max(4, Math.floor(height));
48
+ const headerRows = title || header ? 1 : 0;
34
49
  const legendRows = series.length > 1 ? 1 : 0;
35
50
  const axisRows = 1;
36
- const plotHeight = Math.max(2, totalHeight - legendRows - axisRows);
51
+ const yAxisWidth = totalWidth >= 24 ? 8 : 0;
52
+ const plotRightPadding = totalWidth >= 32 ? 1 : 0;
53
+ const plotWidth = Math.max(1, totalWidth - yAxisWidth - plotRightPadding);
54
+ const plotHeight = Math.max(2, totalHeight - headerRows - legendRows - axisRows);
55
+ const resolvedChartColors = useMemo<BarChartColors>(() => ({
56
+ bgColor: chartColors?.bgColor ?? colors.bg,
57
+ gridColor: chartColors?.gridColor ?? colors.border,
58
+ axisColor: chartColors?.axisColor ?? colors.textDim,
59
+ negativeColor: chartColors?.negativeColor ?? colors.negative,
60
+ hoverColor: chartColors?.hoverColor ?? colors.textBright,
61
+ }), [chartColors]);
37
62
  const scene = useMemo(() => buildBarChartScene(series, {
38
- width: totalWidth,
63
+ width: plotWidth,
39
64
  height: plotHeight,
40
- colors: chartColors,
41
- }), [chartColors, plotHeight, series, totalWidth]);
65
+ colors: resolvedChartColors,
66
+ }), [plotHeight, plotWidth, resolvedChartColors, series]);
42
67
 
43
- const bitmapSize = useStaticChartBitmapSize(totalWidth, plotHeight);
68
+ const bitmapSize = useStaticChartBitmapSize(plotWidth, plotHeight);
44
69
 
45
70
  const bitmap = useMemo<NativeChartBitmap | null>(() => {
46
71
  if (!scene || !bitmapSize) return null;
47
- return renderNativeBarChart(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight);
48
- }, [bitmapSize, scene]);
72
+ return renderNativeBarChart(scene, bitmapSize.pixelWidth, bitmapSize.pixelHeight, hover);
73
+ }, [bitmapSize, hover, scene]);
49
74
 
50
- const textLines = useMemo(() => scene ? renderBarChart(scene) : [], [scene]);
51
- const axis = useMemo(() => scene ? renderBarChartAxis(scene) : "", [scene]);
75
+ const textLines = useMemo(() => scene ? renderBarChart(scene, hover) : [], [hover, scene]);
76
+ const axis = useMemo(() => scene ? renderBarChartAxis(scene, axisRows) : [], [axisRows, scene]);
77
+ const yAxis = useMemo(() => scene ? renderBarChartYAxis(scene, yAxisWidth, formatValue) : [], [formatValue, scene, yAxisWidth]);
78
+ const hoverLabel = useMemo(() => {
79
+ if (!hover) return null;
80
+ const value = formatValue ? formatValue(hover.value) : String(hover.value);
81
+ return series.length > 1 ? `${hover.category} ${hover.seriesLabel}: ${value}` : `${hover.category}: ${value}`;
82
+ }, [formatValue, hover, series.length]);
83
+ const readout = useMemo(() => {
84
+ if (!hoverLabel) return title ?? "";
85
+ return title ? `${title} ${hoverLabel}` : hoverLabel;
86
+ }, [hoverLabel, title]);
87
+ const hoverLabelLayout = useMemo(() => {
88
+ if (!hover || !hoverLabel) return null;
89
+ const text = truncateWithEllipsis(hoverLabel, plotWidth);
90
+ const labelWidth = Math.min(plotWidth, Math.max(1, text.length));
91
+ const labelLeft = Math.max(0, Math.min(
92
+ plotWidth - labelWidth,
93
+ hover.x + Math.floor(hover.width / 2) - Math.floor(labelWidth / 2),
94
+ ));
95
+ const labelTop = hover.value >= 0
96
+ ? Math.max(0, hover.row - 1)
97
+ : Math.min(plotHeight - 1, hover.row + 1);
98
+ return { labelLeft, labelTop, labelWidth, text };
99
+ }, [hover, hoverLabel, plotHeight, plotWidth]);
100
+
101
+ const handleMouseMove = useCallback((event: ChartMouseEvent) => {
102
+ if (!scene) return;
103
+ const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
104
+ if (!pointer) {
105
+ setHover(null);
106
+ onHoverChange?.(null);
107
+ return;
108
+ }
109
+ const nextHover = resolveBarChartHover(scene, pointer.cellX);
110
+ setHover(nextHover);
111
+ onHoverChange?.(nextHover);
112
+ consumeChartMouseEvent(event);
113
+ }, [onHoverChange, renderer, scene]);
114
+
115
+ const handleMouseOut = useCallback(() => {
116
+ setHover(null);
117
+ onHoverChange?.(null);
118
+ }, [onHoverChange]);
119
+
120
+ const handleMouseDown = useCallback((event: ChartMouseEvent) => {
121
+ onMouseDown?.(event);
122
+ if (!scene) return;
123
+ const pointer = getLocalPlotPointer(event, plotRef.current, renderer);
124
+ if (!pointer) return;
125
+ const nextHover = resolveBarChartHover(scene, pointer.cellX);
126
+ setHover(nextHover);
127
+ onHoverChange?.(nextHover);
128
+ consumeChartMouseEvent(event);
129
+ }, [onHoverChange, onMouseDown, renderer, scene]);
52
130
 
53
131
  if (!scene) {
54
132
  return (
55
- <Box width={totalWidth} height={totalHeight}>
133
+ <Box flexDirection="column" width={totalWidth} height={totalHeight} onMouseDown={onMouseDown}>
134
+ {header ?? (title ? <Text fg={colors.textBright} bold>{truncateWithEllipsis(title, totalWidth)}</Text> : null)}
56
135
  <Text fg={colors.textDim}>No chart data</Text>
57
136
  </Box>
58
137
  );
@@ -60,6 +139,13 @@ export function StaticBarChartSurface({
60
139
 
61
140
  return (
62
141
  <Box flexDirection="column" width={totalWidth} height={totalHeight}>
142
+ {headerRows ? (
143
+ header ?? (
144
+ <Text fg={hover ? hover.value < 0 ? colors.negative : colors.textBright : colors.textDim} bold={!!title}>
145
+ {truncateWithEllipsis(readout, totalWidth)}
146
+ </Text>
147
+ )
148
+ ) : null}
63
149
  {legendRows ? (
64
150
  <Text>
65
151
  {series.map((item, index) => (
@@ -69,17 +155,54 @@ export function StaticBarChartSurface({
69
155
  ))}
70
156
  </Text>
71
157
  ) : null}
72
- <ChartSurface
73
- width={totalWidth}
74
- height={plotHeight}
75
- flexDirection="column"
76
- bitmaps={bitmap ? [bitmap] : null}
77
- >
78
- {textLines.map((line, index) => (
79
- <Text key={index} fg={colors.text}>{line}</Text>
80
- ))}
81
- </ChartSurface>
82
- <Text fg={colors.textDim}>{axis}</Text>
158
+ <Box flexDirection="row" width={totalWidth} height={plotHeight}>
159
+ {yAxisWidth ? (
160
+ <Box flexDirection="column" width={yAxisWidth} height={plotHeight}>
161
+ {yAxis.map((line, index) => (
162
+ <Text key={index} fg={colors.textDim}>{line}</Text>
163
+ ))}
164
+ </Box>
165
+ ) : null}
166
+ <Box position="relative" width={plotWidth} height={plotHeight}>
167
+ <ChartSurface
168
+ ref={plotRef}
169
+ width={plotWidth}
170
+ height={plotHeight}
171
+ flexDirection="column"
172
+ bitmaps={bitmap ? [bitmap] : null}
173
+ onMouseMove={handleMouseMove}
174
+ onMouseDown={handleMouseDown}
175
+ onMouseOut={handleMouseOut}
176
+ cursor="pointer"
177
+ data-gloom-interactive="true"
178
+ data-gloom-role="bar-chart-surface"
179
+ >
180
+ {textLines.map((line, index) => (
181
+ <Text key={index} fg={colors.text}>{line}</Text>
182
+ ))}
183
+ </ChartSurface>
184
+ {hoverLabelLayout ? (
185
+ <Box
186
+ position="absolute"
187
+ left={hoverLabelLayout.labelLeft}
188
+ top={hoverLabelLayout.labelTop}
189
+ width={hoverLabelLayout.labelWidth}
190
+ height={1}
191
+ zIndex={1}
192
+ backgroundColor={colors.panel}
193
+ >
194
+ <Text fg={hover && hover.value < 0 ? colors.negative : colors.textBright} bold>
195
+ {hoverLabelLayout.text}
196
+ </Text>
197
+ </Box>
198
+ ) : null}
199
+ </Box>
200
+ </Box>
201
+ {axis.map((line, index) => (
202
+ <Text key={index} fg={colors.textDim}>
203
+ {yAxisWidth ? `${" ".repeat(yAxisWidth)}${line}` : line}
204
+ </Text>
205
+ ))}
83
206
  </Box>
84
207
  );
85
208
  }
@@ -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
+ }
@@ -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
  });