gloomberb 0.6.1 → 0.7.1

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 (208) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +158 -57
  4. package/src/capabilities/factories.ts +3 -0
  5. package/src/cli/registry.test.ts +0 -1
  6. package/src/components/chart/chart-controller.ts +0 -1
  7. package/src/components/chart/chart-data.ts +1 -31
  8. package/src/components/chart/chart-renderer.ts +94 -12
  9. package/src/components/chart/chart-resolution.test.ts +0 -36
  10. package/src/components/chart/chart-resolution.ts +0 -27
  11. package/src/components/chart/chart.test.ts +60 -45
  12. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  13. package/src/components/chart/comparison-chart-data.ts +1 -4
  14. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  15. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  16. package/src/components/chart/comparison-stock-chart.tsx +63 -39
  17. package/src/components/chart/price-axis-labels.tsx +114 -0
  18. package/src/components/chart/static-chart-surface.tsx +30 -4
  19. package/src/components/chart/stock-chart.test.ts +0 -16
  20. package/src/components/chart/stock-chart.tsx +69 -41
  21. package/src/components/chart/time-axis-label.tsx +120 -0
  22. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  23. package/src/components/command-bar/command-bar.test.tsx +182 -36
  24. package/src/components/command-bar/command-bar.tsx +943 -1016
  25. package/src/components/command-bar/command-registry.ts +0 -10
  26. package/src/components/command-bar/confirm-body.tsx +51 -0
  27. package/src/components/command-bar/helpers.ts +2 -5
  28. package/src/components/command-bar/list-model.ts +76 -0
  29. package/src/components/command-bar/theme-picker.tsx +254 -0
  30. package/src/components/command-bar/view-model.test.ts +4 -4
  31. package/src/components/command-bar/view-model.ts +1 -20
  32. package/src/components/command-bar/workflow-ops.ts +4 -0
  33. package/src/components/command-bar/workflow-types.ts +1 -2
  34. package/src/components/data-table-stack-view.test.tsx +39 -4
  35. package/src/components/data-table-stack-view.tsx +11 -104
  36. package/src/components/data-table-view.tsx +43 -79
  37. package/src/components/feed-data-table-stack-view.tsx +10 -17
  38. package/src/components/feed-stack-controller.ts +39 -0
  39. package/src/components/index.ts +5 -1
  40. package/src/components/layout/detached-pane-shell.tsx +29 -10
  41. package/src/components/layout/floating-pane.tsx +35 -4
  42. package/src/components/layout/header.tsx +12 -7
  43. package/src/components/layout/pane-content.test.tsx +64 -0
  44. package/src/components/layout/pane-content.tsx +2 -0
  45. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  46. package/src/components/layout/pane-cycle-order.ts +32 -0
  47. package/src/components/layout/pane-footer.tsx +11 -4
  48. package/src/components/layout/pane-header.tsx +5 -5
  49. package/src/components/layout/pane-sizing.ts +9 -2
  50. package/src/components/layout/pane-title.ts +4 -1
  51. package/src/components/layout/pane.tsx +11 -5
  52. package/src/components/layout/shell.test.tsx +264 -22
  53. package/src/components/layout/shell.tsx +46 -8
  54. package/src/components/layout/status-bar.tsx +2 -0
  55. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  56. package/src/components/pane-settings-dialog.tsx +12 -4
  57. package/src/components/pane-template-wizard.test.tsx +3 -4
  58. package/src/components/speedometer-gauge.test.tsx +45 -0
  59. package/src/components/speedometer-gauge.tsx +562 -0
  60. package/src/components/table-view-shared.tsx +218 -0
  61. package/src/components/ticker-list-table-view.tsx +57 -73
  62. package/src/components/ticker-list-table.test.tsx +82 -1
  63. package/src/components/ticker-list-table.tsx +22 -9
  64. package/src/components/ui/confirm-dialog.tsx +66 -0
  65. package/src/components/ui/data-table.tsx +30 -19
  66. package/src/components/ui/frame.tsx +2 -0
  67. package/src/components/ui/index.ts +0 -1
  68. package/src/components/ui/page-stack-view.tsx +39 -10
  69. package/src/components/ui/table-layout.test.ts +42 -0
  70. package/src/components/ui/table-layout.ts +49 -4
  71. package/src/components/ui/tabs.tsx +34 -1
  72. package/src/components/ui/ui.test.tsx +168 -21
  73. package/src/core/state/app-state.ts +10 -17
  74. package/src/market-data/coordinator.test.ts +76 -1
  75. package/src/market-data/coordinator.ts +218 -49
  76. package/src/news/aggregator.test.ts +49 -1
  77. package/src/news/aggregator.ts +48 -3
  78. package/src/news/hooks.ts +6 -2
  79. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  80. package/src/plugins/builtin/ai/providers.ts +0 -17
  81. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  82. package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
  83. package/src/plugins/builtin/analytics/index.tsx +1 -18
  84. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  85. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  86. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  87. package/src/plugins/builtin/chat-controller.test.ts +188 -1
  88. package/src/plugins/builtin/chat-controller.ts +537 -220
  89. package/src/plugins/builtin/chat.test.tsx +431 -13
  90. package/src/plugins/builtin/chat.tsx +1134 -227
  91. package/src/plugins/builtin/cloud-tweets.tsx +1 -1
  92. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  93. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  94. package/src/plugins/builtin/correlation/index.tsx +1 -6
  95. package/src/plugins/builtin/debug.tsx +1 -3
  96. package/src/plugins/builtin/econ/index.tsx +1 -2
  97. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  98. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  99. package/src/plugins/builtin/fear-greed/index.tsx +587 -0
  100. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  101. package/src/plugins/builtin/help.test.tsx +2 -2
  102. package/src/plugins/builtin/help.tsx +2 -2
  103. package/src/plugins/builtin/holders/index.test.ts +13 -1
  104. package/src/plugins/builtin/holders/index.tsx +64 -29
  105. package/src/plugins/builtin/insider/index.tsx +22 -89
  106. package/src/plugins/builtin/market-movers/index.tsx +13 -19
  107. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  108. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  109. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  110. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  111. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  112. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  113. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  114. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  115. package/src/plugins/builtin/news.tsx +4 -16
  116. package/src/plugins/builtin/notes.tsx +19 -55
  117. package/src/plugins/builtin/options.test.tsx +58 -2
  118. package/src/plugins/builtin/options.tsx +110 -65
  119. package/src/plugins/builtin/plugin-groups.ts +2 -0
  120. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  121. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  122. package/src/plugins/builtin/research/index.test.ts +94 -0
  123. package/src/plugins/builtin/research/index.tsx +164 -33
  124. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  125. package/src/plugins/builtin/sec.tsx +13 -66
  126. package/src/plugins/builtin/sectors/index.tsx +1 -1
  127. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  128. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  129. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  130. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  131. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  132. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  133. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  134. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  135. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  136. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  137. package/src/plugins/ibkr/gateway-service.ts +1 -1
  138. package/src/plugins/ibkr/index.test.tsx +0 -2
  139. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  140. package/src/plugins/pane-manager.ts +0 -13
  141. package/src/plugins/plugin-runtime.test.tsx +40 -6
  142. package/src/plugins/plugin-runtime.tsx +20 -10
  143. package/src/plugins/prediction-markets/cli.ts +0 -1
  144. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  145. package/src/plugins/prediction-markets/controller.ts +0 -15
  146. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  147. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  148. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  149. package/src/plugins/registry.ts +13 -42
  150. package/src/plugins/ticker-navigation.test.ts +66 -0
  151. package/src/plugins/ticker-navigation.ts +44 -0
  152. package/src/react/input.ts +2 -1
  153. package/src/renderers/electrobun/bun/index.ts +32 -11
  154. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  155. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  156. package/src/renderers/electrobun/view/app-services.ts +88 -31
  157. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  158. package/src/renderers/electrobun/view/data-table.tsx +138 -101
  159. package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
  160. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  161. package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
  162. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  163. package/src/renderers/electrobun/view/key-event.ts +1 -1
  164. package/src/renderers/electrobun/view/main.tsx +18 -1
  165. package/src/renderers/electrobun/view/styles.css +329 -0
  166. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  167. package/src/renderers/electrobun/view/ui-host.tsx +42 -0
  168. package/src/renderers/opentui/chart-surface.tsx +179 -0
  169. package/src/renderers/opentui/input-host.tsx +1 -1
  170. package/src/renderers/opentui/start.tsx +48 -30
  171. package/src/renderers/opentui/test-utils.tsx +17 -0
  172. package/src/renderers/opentui/ui-host.tsx +5 -2
  173. package/src/sources/gloomberb-cloud.test.ts +86 -1
  174. package/src/sources/gloomberb-cloud.ts +117 -21
  175. package/src/sources/provider-router.test.ts +122 -0
  176. package/src/sources/provider-router.ts +198 -15
  177. package/src/sources/yahoo-finance.test.ts +44 -0
  178. package/src/sources/yahoo-finance.ts +6 -0
  179. package/src/state/app-bootstrap.ts +20 -7
  180. package/src/state/app-context-selector.test.tsx +189 -4
  181. package/src/state/app-context.test.ts +65 -0
  182. package/src/state/app-context.tsx +48 -16
  183. package/src/state/use-inline-tickers.test.tsx +73 -0
  184. package/src/state/use-inline-tickers.ts +19 -10
  185. package/src/test-support/plugin-runtime.ts +3 -2
  186. package/src/theme/colors.test.ts +75 -0
  187. package/src/theme/colors.ts +76 -6
  188. package/src/theme/theme-context.tsx +26 -0
  189. package/src/theme/themes.test.ts +23 -1
  190. package/src/theme/themes.ts +115 -0
  191. package/src/types/broker.ts +1 -1
  192. package/src/types/capability-route-source.ts +1 -0
  193. package/src/types/data-provider.ts +15 -2
  194. package/src/types/desktop-window.ts +6 -0
  195. package/src/types/financials.ts +2 -0
  196. package/src/types/plugin.ts +1 -3
  197. package/src/ui/host.tsx +7 -0
  198. package/src/utils/api-client.test.ts +32 -0
  199. package/src/utils/api-client.ts +119 -18
  200. package/src/utils/back-navigation.ts +24 -1
  201. package/src/utils/double-escape-close.test.ts +33 -0
  202. package/src/utils/double-escape-close.ts +40 -0
  203. package/src/utils/throttled-fetch.test.ts +22 -0
  204. package/src/utils/throttled-fetch.ts +3 -1
  205. package/src/utils/ticker-search.ts +53 -9
  206. package/src/version.ts +1 -1
  207. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  208. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { act } from "react";
3
+ import { testRender } from "../renderers/opentui/test-utils";
4
+ import { colors } from "../theme/colors";
5
+ import { SpeedometerGauge, type SpeedometerSegment } from "./speedometer-gauge";
6
+
7
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
8
+
9
+ afterEach(async () => {
10
+ if (!testSetup) return;
11
+ await act(async () => {
12
+ testSetup!.renderer.destroy();
13
+ });
14
+ testSetup = undefined;
15
+ });
16
+
17
+ const segments: SpeedometerSegment[] = [
18
+ { from: 0, to: 24.999, label: "EXTREME FEAR", color: colors.negative },
19
+ { from: 25, to: 44.999, label: "FEAR", color: colors.warning },
20
+ { from: 45, to: 55, label: "NEUTRAL", color: colors.neutral },
21
+ { from: 55.001, to: 75, label: "GREED", color: colors.positive },
22
+ { from: 75.001, to: 100, label: "EXTREME GREED", color: colors.positive },
23
+ ];
24
+
25
+ describe("SpeedometerGauge", () => {
26
+ test("keeps the terminal fallback compact in wide panes", async () => {
27
+ testSetup = await testRender(
28
+ <SpeedometerGauge value={67} valueLabel="GREED" width={120} segments={segments} />,
29
+ { width: 124, height: 14 },
30
+ );
31
+
32
+ await act(async () => {
33
+ await testSetup!.renderOnce();
34
+ await testSetup!.renderOnce();
35
+ });
36
+
37
+ const frame = testSetup.captureCharFrame();
38
+ expect(frame).toContain("Current reading GREED");
39
+ expect(frame).toContain("67");
40
+
41
+ const labelLine = frame.split("\n").find((line) => line.includes("EXT FEAR") && line.includes("EXT GREED"));
42
+ expect(labelLine).toBeTruthy();
43
+ expect(labelLine!.indexOf("EXT GREED") - labelLine!.indexOf("EXT FEAR")).toBeLessThanOrEqual(58);
44
+ });
45
+ });
@@ -0,0 +1,562 @@
1
+ import { createElement, useMemo, type SVGProps } from "react";
2
+ import { Box, ChartSurface, Span, Text, TextAttributes, useNativeRenderer, useUiCapabilities, useUiHost } from "../ui";
3
+ import { computeBitmapSize, type NativeChartBitmap } from "./chart/native/chart-rasterizer";
4
+ import { colors } from "../theme/colors";
5
+ import { useThemeColors } from "../theme/theme-context";
6
+
7
+ export interface SpeedometerSegment {
8
+ from: number;
9
+ to: number;
10
+ label: string;
11
+ color: string;
12
+ }
13
+
14
+ export interface SpeedometerGaugeProps {
15
+ value: number;
16
+ valueLabel: string;
17
+ width: number;
18
+ segments: SpeedometerSegment[];
19
+ min?: number;
20
+ max?: number;
21
+ currentLabel?: string;
22
+ minWidth?: number;
23
+ maxWidth?: number;
24
+ compact?: boolean;
25
+ }
26
+
27
+ interface GaugeCell {
28
+ char: string;
29
+ color?: string;
30
+ }
31
+
32
+ interface GaugeChunk {
33
+ text: string;
34
+ color?: string;
35
+ }
36
+
37
+ interface RgbaColor {
38
+ r: number;
39
+ g: number;
40
+ b: number;
41
+ a: number;
42
+ }
43
+
44
+ const DEFAULT_MIN_WIDTH = 34;
45
+ const DEFAULT_MAX_WIDTH = 50;
46
+ const DESKTOP_VIEWBOX_WIDTH = 520;
47
+ const DESKTOP_VIEWBOX_HEIGHT = 232;
48
+ const DESKTOP_COMPACT_VIEWBOX_HEIGHT = 214;
49
+ const DESKTOP_CENTER_X = 260;
50
+ const DESKTOP_CENTER_Y = 182;
51
+ const DESKTOP_ARC_RADIUS = 138;
52
+ const DESKTOP_NEEDLE_RADIUS = 104;
53
+ const DESKTOP_LABEL_POSITIONS = [
54
+ { x: 76, y: 84 },
55
+ { x: 154, y: 44 },
56
+ { x: 260, y: 18 },
57
+ { x: 366, y: 44 },
58
+ { x: 444, y: 84 },
59
+ ];
60
+
61
+ function SvgText(props: SVGProps<SVGTextElement>) {
62
+ return createElement("text", props);
63
+ }
64
+
65
+ function clamp(value: number, min: number, max: number): number {
66
+ return Math.max(min, Math.min(max, value));
67
+ }
68
+
69
+ function normalizeValue(value: number, min: number, max: number): number {
70
+ if (max <= min) return 0;
71
+ return clamp((value - min) / (max - min), 0, 1);
72
+ }
73
+
74
+ function valueToAngle(value: number, min: number, max: number): number {
75
+ return Math.PI - normalizeValue(value, min, max) * Math.PI;
76
+ }
77
+
78
+ function valueToDegrees(value: number, min: number, max: number): number {
79
+ return -90 + normalizeValue(value, min, max) * 180;
80
+ }
81
+
82
+ function formatGaugeValue(value: number): string {
83
+ if (!Number.isFinite(value)) return "--";
84
+ return String(Math.round(value));
85
+ }
86
+
87
+ function segmentForValue(value: number, segments: SpeedometerSegment[]): SpeedometerSegment | null {
88
+ return segments.find((segment) => value >= segment.from && value <= segment.to)
89
+ ?? segments[segments.length - 1]
90
+ ?? null;
91
+ }
92
+
93
+ function blankGaugeLine(width: number): GaugeCell[] {
94
+ return Array.from({ length: width }, () => ({ char: " " }));
95
+ }
96
+
97
+ function placeText(cells: GaugeCell[], label: string, center: number, color?: string): void {
98
+ const width = cells.length;
99
+ if (width <= 0) return;
100
+ const text = label.length > width ? label.slice(0, width) : label;
101
+ const start = Math.max(0, Math.min(width - text.length, Math.round(center - text.length / 2)));
102
+ for (let index = 0; index < text.length; index += 1) {
103
+ cells[start + index] = { char: text[index]!, color };
104
+ }
105
+ }
106
+
107
+ function cellsToChunks(cells: GaugeCell[]): GaugeChunk[] {
108
+ const chunks: GaugeChunk[] = [];
109
+ for (const cell of cells) {
110
+ const last = chunks[chunks.length - 1];
111
+ if (last && last.color === cell.color) {
112
+ last.text += cell.char;
113
+ } else {
114
+ chunks.push({ text: cell.char, color: cell.color });
115
+ }
116
+ }
117
+ return chunks;
118
+ }
119
+
120
+ function segmentColorForScore(score: number, segments: SpeedometerSegment[]): string {
121
+ return segmentForValue(score, segments)?.color ?? colors.textDim;
122
+ }
123
+
124
+ function labelForSegment(segment: SpeedometerSegment, gaugeWidth: number): string {
125
+ if (gaugeWidth >= 64) return segment.label;
126
+ const compact = compactSegmentLabel(segment);
127
+ if (gaugeWidth >= 44) {
128
+ return compact === "NEUTRAL" ? "NEUT" : compact;
129
+ }
130
+ if (compact === "EXT FEAR") return "XF";
131
+ if (compact === "EXT GREED") return "XG";
132
+ if (compact === "NEUTRAL") return "NEUT";
133
+ return compact;
134
+ }
135
+
136
+ function compactSegmentLabel(segment: SpeedometerSegment): string {
137
+ return segment.label.replace(/^EXTREME /, "EXT ");
138
+ }
139
+
140
+ function renderLabelRow(width: number, min: number, max: number, segments: SpeedometerSegment[]): GaugeChunk[] {
141
+ const cells = blankGaugeLine(width);
142
+ for (const segment of segments) {
143
+ const centerValue = (segment.from + segment.to) / 2;
144
+ const center = normalizeValue(centerValue, min, max) * (width - 1);
145
+ placeText(cells, labelForSegment(segment, width), center, segment.color);
146
+ }
147
+ return cellsToChunks(cells);
148
+ }
149
+
150
+ function renderTickRow(width: number, min: number, max: number): GaugeChunk[] {
151
+ const cells = blankGaugeLine(width);
152
+ for (const tick of [min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max]) {
153
+ placeText(cells, formatGaugeValue(tick), normalizeValue(tick, min, max) * (width - 1), colors.textDim);
154
+ }
155
+ return cellsToChunks(cells);
156
+ }
157
+
158
+ function renderArcRows(value: number, min: number, max: number, width: number, segments: SpeedometerSegment[]): GaugeChunk[][] {
159
+ const dialHeight = 7;
160
+ const dial = Array.from({ length: dialHeight }, () => blankGaugeLine(width));
161
+ const centerX = Math.floor((width - 1) / 2);
162
+ const centerY = dialHeight - 1;
163
+ const radiusX = Math.max(8, (width - 4) / 2);
164
+ const radiusY = dialHeight - 1;
165
+
166
+ for (let step = 0; step <= 220; step += 1) {
167
+ const normalized = step / 220;
168
+ const score = min + normalized * (max - min);
169
+ const angle = Math.PI - normalized * Math.PI;
170
+ const x = Math.round(centerX + Math.cos(angle) * radiusX);
171
+ const y = Math.round(centerY - Math.sin(angle) * radiusY);
172
+ if (dial[y]?.[x]) {
173
+ dial[y]![x] = { char: "●", color: segmentColorForScore(score, segments) };
174
+ }
175
+ }
176
+
177
+ for (const tick of [min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max]) {
178
+ const angle = valueToAngle(tick, min, max);
179
+ const x = Math.round(centerX + Math.cos(angle) * radiusX);
180
+ const y = Math.round(centerY - Math.sin(angle) * radiusY);
181
+ if (dial[y]?.[x]) {
182
+ dial[y]![x] = { char: "│", color: colors.textDim };
183
+ }
184
+ }
185
+
186
+ const pointerAngle = valueToAngle(value, min, max);
187
+ const pointerChar = Math.abs(Math.cos(pointerAngle)) < 0.22
188
+ ? "│"
189
+ : Math.cos(pointerAngle) > 0
190
+ ? "/"
191
+ : "\\";
192
+ for (let t = 0.14; t <= 0.75; t += 0.06) {
193
+ const x = Math.round(centerX + Math.cos(pointerAngle) * radiusX * t);
194
+ const y = Math.round(centerY - Math.sin(pointerAngle) * radiusY * t);
195
+ if (dial[y]?.[x]) {
196
+ dial[y]![x] = { char: pointerChar, color: colors.textBright };
197
+ }
198
+ }
199
+
200
+ placeText(dial[centerY]!, formatGaugeValue(value), centerX, colors.textBright);
201
+ return dial.map(cellsToChunks);
202
+ }
203
+
204
+ function GaugeLine({ chunks }: { chunks: GaugeChunk[] }) {
205
+ return (
206
+ <Text>
207
+ {chunks.map((chunk, index) => (
208
+ <Span key={`${index}-${chunk.text}`} fg={chunk.color}>{chunk.text}</Span>
209
+ ))}
210
+ </Text>
211
+ );
212
+ }
213
+
214
+ function parseHex(hex: string, alpha = 1): RgbaColor {
215
+ const normalized = hex.replace("#", "");
216
+ return {
217
+ r: parseInt(normalized.slice(0, 2), 16),
218
+ g: parseInt(normalized.slice(2, 4), 16),
219
+ b: parseInt(normalized.slice(4, 6), 16),
220
+ a: Math.round(clamp(alpha, 0, 1) * 255),
221
+ };
222
+ }
223
+
224
+ function blendPixel(
225
+ data: Uint8Array,
226
+ width: number,
227
+ height: number,
228
+ x: number,
229
+ y: number,
230
+ color: RgbaColor,
231
+ opacity = 1,
232
+ ) {
233
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
234
+
235
+ const alpha = clamp((color.a / 255) * opacity, 0, 1);
236
+ if (alpha <= 0) return;
237
+
238
+ const index = (y * width + x) * 4;
239
+ const dstAlpha = data[index + 3]! / 255;
240
+ const outAlpha = alpha + dstAlpha * (1 - alpha);
241
+ if (outAlpha <= 0) return;
242
+
243
+ const dstFactor = dstAlpha * (1 - alpha);
244
+ data[index] = Math.round((color.r * alpha + data[index]! * dstFactor) / outAlpha);
245
+ data[index + 1] = Math.round((color.g * alpha + data[index + 1]! * dstFactor) / outAlpha);
246
+ data[index + 2] = Math.round((color.b * alpha + data[index + 2]! * dstFactor) / outAlpha);
247
+ data[index + 3] = Math.round(outAlpha * 255);
248
+ }
249
+
250
+ function drawCircle(data: Uint8Array, width: number, height: number, centerX: number, centerY: number, radius: number, color: RgbaColor) {
251
+ const minX = Math.floor(centerX - radius - 1);
252
+ const maxX = Math.ceil(centerX + radius + 1);
253
+ const minY = Math.floor(centerY - radius - 1);
254
+ const maxY = Math.ceil(centerY + radius + 1);
255
+
256
+ for (let y = minY; y <= maxY; y += 1) {
257
+ for (let x = minX; x <= maxX; x += 1) {
258
+ const distance = Math.hypot(x + 0.5 - centerX, y + 0.5 - centerY);
259
+ const coverage = 1 - clamp((distance - (radius - 0.75)) / 1.5, 0, 1);
260
+ if (coverage > 0) blendPixel(data, width, height, x, y, color, coverage);
261
+ }
262
+ }
263
+ }
264
+
265
+ function drawLine(data: Uint8Array, width: number, height: number, x0: number, y0: number, x1: number, y1: number, color: RgbaColor, thickness: number) {
266
+ const half = thickness / 2;
267
+ const minX = Math.floor(Math.min(x0, x1) - half - 1);
268
+ const maxX = Math.ceil(Math.max(x0, x1) + half + 1);
269
+ const minY = Math.floor(Math.min(y0, y1) - half - 1);
270
+ const maxY = Math.ceil(Math.max(y0, y1) + half + 1);
271
+ const dx = x1 - x0;
272
+ const dy = y1 - y0;
273
+ const segmentLengthSq = dx * dx + dy * dy || 1;
274
+
275
+ for (let y = minY; y <= maxY; y += 1) {
276
+ for (let x = minX; x <= maxX; x += 1) {
277
+ const projection = clamp((((x + 0.5) - x0) * dx + ((y + 0.5) - y0) * dy) / segmentLengthSq, 0, 1);
278
+ const nearestX = x0 + dx * projection;
279
+ const nearestY = y0 + dy * projection;
280
+ const distance = Math.hypot(x + 0.5 - nearestX, y + 0.5 - nearestY);
281
+ const coverage = 1 - clamp((distance - half) / 1.2, 0, 1);
282
+ if (coverage > 0) blendPixel(data, width, height, x, y, color, coverage);
283
+ }
284
+ }
285
+ }
286
+
287
+ function renderGaugeBitmap(
288
+ value: number,
289
+ min: number,
290
+ max: number,
291
+ segments: SpeedometerSegment[],
292
+ pixelWidth: number,
293
+ pixelHeight: number,
294
+ ): NativeChartBitmap {
295
+ const pixels = new Uint8Array(pixelWidth * pixelHeight * 4);
296
+ const centerX = pixelWidth / 2;
297
+ const centerY = pixelHeight * 0.84;
298
+ const radius = Math.min(pixelWidth * 0.42, pixelHeight * 0.72);
299
+ const arcThickness = Math.max(4, Math.min(pixelWidth, pixelHeight) * 0.038);
300
+
301
+ for (const segment of segments) {
302
+ const start = valueToAngle(segment.from, min, max);
303
+ const end = valueToAngle(segment.to, min, max);
304
+ const steps = Math.max(8, Math.ceil(Math.abs(end - start) * radius * 0.36));
305
+ const color = parseHex(segment.color, 0.9);
306
+ for (let index = 0; index <= steps; index += 1) {
307
+ const t = index / steps;
308
+ const angle = start + (end - start) * t;
309
+ const x = centerX + Math.cos(angle) * radius;
310
+ const y = centerY - Math.sin(angle) * radius;
311
+ drawCircle(pixels, pixelWidth, pixelHeight, x, y, arcThickness, color);
312
+ }
313
+ }
314
+
315
+ for (const tick of [min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max]) {
316
+ const angle = valueToAngle(tick, min, max);
317
+ const innerX = centerX + Math.cos(angle) * (radius - arcThickness * 1.45);
318
+ const innerY = centerY - Math.sin(angle) * (radius - arcThickness * 1.45);
319
+ const outerX = centerX + Math.cos(angle) * (radius + arcThickness * 1.25);
320
+ const outerY = centerY - Math.sin(angle) * (radius + arcThickness * 1.25);
321
+ drawLine(pixels, pixelWidth, pixelHeight, innerX, innerY, outerX, outerY, parseHex(colors.textDim, 0.8), Math.max(1, arcThickness * 0.22));
322
+ }
323
+
324
+ const angle = valueToAngle(value, min, max);
325
+ const needleEndX = centerX + Math.cos(angle) * (radius * 0.76);
326
+ const needleEndY = centerY - Math.sin(angle) * (radius * 0.76);
327
+ drawLine(pixels, pixelWidth, pixelHeight, centerX, centerY, needleEndX, needleEndY, parseHex(colors.textBright, 0.98), Math.max(3, arcThickness * 0.48));
328
+
329
+ return { width: pixelWidth, height: pixelHeight, pixels };
330
+ }
331
+
332
+ function polarToCartesian(centerX: number, centerY: number, radius: number, angleDegrees: number) {
333
+ const angleRadians = (angleDegrees - 90) * Math.PI / 180;
334
+ return {
335
+ x: centerX + radius * Math.cos(angleRadians),
336
+ y: centerY + radius * Math.sin(angleRadians),
337
+ };
338
+ }
339
+
340
+ function describeArc(centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number): string {
341
+ const start = polarToCartesian(centerX, centerY, radius, endAngle);
342
+ const end = polarToCartesian(centerX, centerY, radius, startAngle);
343
+ const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
344
+ return `M ${start.x.toFixed(2)} ${start.y.toFixed(2)} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x.toFixed(2)} ${end.y.toFixed(2)}`;
345
+ }
346
+
347
+ function DesktopGauge({
348
+ value,
349
+ valueLabel,
350
+ width,
351
+ segments,
352
+ min,
353
+ max,
354
+ currentLabel,
355
+ minWidth,
356
+ maxWidth,
357
+ compact,
358
+ }: Required<SpeedometerGaugeProps>) {
359
+ const gaugeWidth = Math.min(Math.max(width - 2, minWidth), maxWidth);
360
+ const gaugeHeight = compact ? 9 : 12;
361
+ const viewBoxHeight = compact ? DESKTOP_COMPACT_VIEWBOX_HEIGHT : DESKTOP_VIEWBOX_HEIGHT;
362
+ const needleAngle = valueToDegrees(value, min, max);
363
+ const needleEnd = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_NEEDLE_RADIUS, needleAngle);
364
+
365
+ return (
366
+ <Box
367
+ width={gaugeWidth}
368
+ height={gaugeHeight}
369
+ marginTop={compact ? 0 : 1}
370
+ overflow="hidden"
371
+ style={{ alignSelf: "center", maxWidth: 420 }}
372
+ >
373
+ <svg
374
+ viewBox={`0 0 ${DESKTOP_VIEWBOX_WIDTH} ${viewBoxHeight}`}
375
+ width="100%"
376
+ height="100%"
377
+ role="img"
378
+ aria-label={`${currentLabel} ${formatGaugeValue(value)} ${valueLabel}`}
379
+ style={{ display: "block" }}
380
+ >
381
+ {segments.map((segment) => (
382
+ <path
383
+ key={segment.label}
384
+ d={describeArc(
385
+ DESKTOP_CENTER_X,
386
+ DESKTOP_CENTER_Y,
387
+ DESKTOP_ARC_RADIUS,
388
+ valueToDegrees(segment.from, min, max),
389
+ valueToDegrees(segment.to, min, max),
390
+ )}
391
+ fill="none"
392
+ stroke={segment.color}
393
+ strokeWidth="22"
394
+ strokeLinecap="butt"
395
+ opacity={value >= segment.from && value <= segment.to ? 0.96 : 0.42}
396
+ />
397
+ ))}
398
+ {segments.map((segment, index) => {
399
+ const fixedPoint = segments.length === DESKTOP_LABEL_POSITIONS.length
400
+ ? DESKTOP_LABEL_POSITIONS[index]
401
+ : null;
402
+ const midpoint = (valueToDegrees(segment.from, min, max) + valueToDegrees(segment.to, min, max)) / 2;
403
+ const point = fixedPoint ?? polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS + 36, midpoint);
404
+ return (
405
+ <SvgText
406
+ key={`label:${segment.label}`}
407
+ x={point.x}
408
+ y={point.y}
409
+ fill={segment.color}
410
+ textAnchor="middle"
411
+ dominantBaseline="middle"
412
+ fontFamily="inherit"
413
+ fontSize="14"
414
+ fontWeight="700"
415
+ >
416
+ {compactSegmentLabel(segment)}
417
+ </SvgText>
418
+ );
419
+ })}
420
+ {[min, min + (max - min) * 0.25, min + (max - min) * 0.5, min + (max - min) * 0.75, max].map((tick) => {
421
+ const angle = valueToDegrees(tick, min, max);
422
+ const outer = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS + 16, angle);
423
+ const inner = polarToCartesian(DESKTOP_CENTER_X, DESKTOP_CENTER_Y, DESKTOP_ARC_RADIUS - 10, angle);
424
+ return (
425
+ <line key={tick} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} stroke={colors.textDim} strokeWidth="2" />
426
+ );
427
+ })}
428
+ <line
429
+ x1={DESKTOP_CENTER_X}
430
+ y1={DESKTOP_CENTER_Y}
431
+ x2={needleEnd.x}
432
+ y2={needleEnd.y}
433
+ stroke={colors.textBright}
434
+ strokeWidth="7"
435
+ strokeLinecap="round"
436
+ />
437
+ <circle cx={DESKTOP_CENTER_X} cy={DESKTOP_CENTER_Y} r="27" fill={colors.bg} />
438
+ <SvgText
439
+ x={DESKTOP_CENTER_X}
440
+ y={DESKTOP_CENTER_Y + 2}
441
+ fill={colors.textBright}
442
+ textAnchor="middle"
443
+ dominantBaseline="central"
444
+ fontFamily="inherit"
445
+ fontSize="31"
446
+ fontWeight="800"
447
+ >
448
+ {formatGaugeValue(value)}
449
+ </SvgText>
450
+ </svg>
451
+ </Box>
452
+ );
453
+ }
454
+
455
+ function TerminalGauge({
456
+ value,
457
+ valueLabel,
458
+ width,
459
+ segments,
460
+ min,
461
+ max,
462
+ currentLabel,
463
+ minWidth,
464
+ maxWidth,
465
+ }: Required<SpeedometerGaugeProps>) {
466
+ const {
467
+ nativeCharts,
468
+ cellWidthPx = 8,
469
+ cellHeightPx = 18,
470
+ pixelRatio = 1,
471
+ } = useUiCapabilities();
472
+ const nativeRenderer = useNativeRenderer();
473
+ const gaugeWidth = Math.max(minWidth, Math.min(maxWidth, Math.floor(width - 4)));
474
+ const labelRow = useMemo(() => renderLabelRow(gaugeWidth, min, max, segments), [gaugeWidth, max, min, segments]);
475
+ const tickRow = useMemo(() => renderTickRow(gaugeWidth, min, max), [gaugeWidth, max, min]);
476
+ const arcRows = useMemo(() => renderArcRows(value, min, max, gaugeWidth, segments), [gaugeWidth, max, min, segments, value]);
477
+ const rendererResolution = nativeRenderer.resolution;
478
+ const rendererTerminalWidth = nativeRenderer.terminalWidth;
479
+ const rendererTerminalHeight = nativeRenderer.terminalHeight;
480
+ const bitmap = useMemo<NativeChartBitmap | null>(() => {
481
+ if (!nativeCharts) return null;
482
+ const bitmapSize = rendererResolution && rendererTerminalWidth > 0 && rendererTerminalHeight > 0
483
+ ? computeBitmapSize(
484
+ { x: 0, y: 0, width: gaugeWidth, height: arcRows.length },
485
+ rendererResolution,
486
+ rendererTerminalWidth,
487
+ rendererTerminalHeight,
488
+ )
489
+ : null;
490
+ const scale = Math.max(1, pixelRatio);
491
+ const pixelWidth = bitmapSize?.pixelWidth ?? Math.max(1, Math.round(gaugeWidth * cellWidthPx * scale));
492
+ const pixelHeight = bitmapSize?.pixelHeight ?? Math.max(1, Math.round(arcRows.length * cellHeightPx * scale));
493
+ return renderGaugeBitmap(
494
+ value,
495
+ min,
496
+ max,
497
+ segments,
498
+ pixelWidth,
499
+ pixelHeight,
500
+ );
501
+ }, [
502
+ arcRows.length,
503
+ cellHeightPx,
504
+ cellWidthPx,
505
+ gaugeWidth,
506
+ max,
507
+ min,
508
+ nativeCharts,
509
+ pixelRatio,
510
+ rendererResolution,
511
+ rendererTerminalHeight,
512
+ rendererTerminalWidth,
513
+ segments,
514
+ value,
515
+ ]);
516
+
517
+ return (
518
+ <Box flexDirection="column" alignItems="center" marginTop={1}>
519
+ <GaugeLine chunks={labelRow} />
520
+ <ChartSurface width={gaugeWidth} height={arcRows.length} flexDirection="column" bitmaps={bitmap ? [bitmap] : null}>
521
+ {arcRows.map((chunks, index) => (
522
+ <GaugeLine key={index} chunks={chunks} />
523
+ ))}
524
+ </ChartSurface>
525
+ <GaugeLine chunks={tickRow} />
526
+ <Box flexDirection="row" marginTop={1}>
527
+ <Text fg={colors.textDim}>{currentLabel} </Text>
528
+ <Text fg={segmentColorForScore(value, segments)} attributes={TextAttributes.BOLD}>{valueLabel}</Text>
529
+ </Box>
530
+ </Box>
531
+ );
532
+ }
533
+
534
+ export function SpeedometerGauge({
535
+ value,
536
+ valueLabel,
537
+ width,
538
+ segments,
539
+ min = 0,
540
+ max = 100,
541
+ currentLabel = "Current reading",
542
+ minWidth = DEFAULT_MIN_WIDTH,
543
+ maxWidth = DEFAULT_MAX_WIDTH,
544
+ compact = false,
545
+ }: SpeedometerGaugeProps) {
546
+ useThemeColors();
547
+ const props = {
548
+ value,
549
+ valueLabel,
550
+ width,
551
+ segments,
552
+ min,
553
+ max,
554
+ currentLabel,
555
+ minWidth,
556
+ maxWidth,
557
+ compact,
558
+ };
559
+ return useUiHost().kind === "desktop-web"
560
+ ? <DesktopGauge {...props} />
561
+ : <TerminalGauge {...props} />;
562
+ }