gloomberb 0.9.5 → 0.9.7

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 (99) hide show
  1. package/README.md +49 -8
  2. package/package.json +2 -2
  3. package/src/app/global-shortcuts.test.tsx +20 -1
  4. package/src/app/global-shortcuts.ts +2 -0
  5. package/src/app/pane-runtime/index.tsx +11 -4
  6. package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
  7. package/src/app.tsx +41 -17
  8. package/src/cli/commands/remote.ts +206 -0
  9. package/src/cli/index.ts +2 -0
  10. package/src/components/chart/core/keyboard.ts +10 -1
  11. package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
  12. package/src/components/chart/static/chart/surface.test.tsx +59 -4
  13. package/src/components/chart/static/chart/surface.tsx +4 -0
  14. package/src/components/chart/stock-chart/index.test.ts +7 -0
  15. package/src/components/command-bar/list/view.tsx +47 -0
  16. package/src/components/command-bar/multi-select-picker.tsx +7 -0
  17. package/src/components/command-bar/routing/navigation-state.ts +10 -1
  18. package/src/components/command-bar/surface/index.test.tsx +14 -0
  19. package/src/components/command-bar/theme-picker.tsx +91 -79
  20. package/src/components/command-bar/workflow/field-row.tsx +30 -5
  21. package/src/components/index.ts +7 -0
  22. package/src/components/layout/pane/footer/index.tsx +23 -0
  23. package/src/components/layout/shell/index.test.tsx +2 -0
  24. package/src/components/layout/shell/index.tsx +8 -3
  25. package/src/components/layout/shell/pane/actions.ts +21 -7
  26. package/src/components/metric-treemap/index.tsx +317 -0
  27. package/src/components/metric-treemap/layout.test.ts +77 -0
  28. package/src/components/metric-treemap/layout.ts +476 -0
  29. package/src/components/toggle-list.tsx +10 -0
  30. package/src/components/ui/button.tsx +12 -0
  31. package/src/components/ui/data-table/index.tsx +51 -0
  32. package/src/components/ui/fields.tsx +18 -0
  33. package/src/components/ui/list-view.tsx +83 -3
  34. package/src/components/ui/message-composer.tsx +22 -0
  35. package/src/components/ui/tabs.tsx +45 -0
  36. package/src/core/state/app/layout-reducer.ts +7 -1
  37. package/src/core/state/app/layout.ts +6 -0
  38. package/src/core/state/app/state.ts +23 -1
  39. package/src/core/state/app/types.ts +4 -2
  40. package/src/pane-settings.test.ts +1 -0
  41. package/src/plugins/builtin/chat/channels.ts +5 -18
  42. package/src/plugins/builtin/chat/chat.test.tsx +1 -7
  43. package/src/plugins/builtin/chat/status-widget.tsx +1 -7
  44. package/src/plugins/builtin/cloud/plugin.tsx +4 -13
  45. package/src/plugins/builtin/holders/treemap.tsx +28 -297
  46. package/src/plugins/builtin/holders/types.ts +0 -34
  47. package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
  48. package/src/plugins/builtin/market-heatmap/data.ts +296 -0
  49. package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
  50. package/src/plugins/builtin/plugin-groups.ts +2 -0
  51. package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
  52. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -1
  53. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  54. package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
  55. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
  57. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
  58. package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
  59. package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
  60. package/src/plugins/ibkr/gateway/service/index.ts +6 -9
  61. package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
  62. package/src/remote/app-host.tsx +77 -0
  63. package/src/remote/client.ts +34 -0
  64. package/src/remote/command-bar.ts +98 -0
  65. package/src/remote/controller-utils.ts +111 -0
  66. package/src/remote/controller.test.ts +365 -0
  67. package/src/remote/controller.ts +434 -0
  68. package/src/remote/endpoint.ts +41 -0
  69. package/src/remote/json-patch.test.ts +31 -0
  70. package/src/remote/json-patch.ts +89 -0
  71. package/src/remote/layout-helpers.ts +62 -0
  72. package/src/remote/resources.ts +186 -0
  73. package/src/remote/revision.ts +18 -0
  74. package/src/remote/schema.ts +143 -0
  75. package/src/remote/semantic-helpers.ts +161 -0
  76. package/src/remote/semantic-tree.tsx +112 -0
  77. package/src/remote/server.test.ts +50 -0
  78. package/src/remote/server.ts +121 -0
  79. package/src/remote/types.ts +119 -0
  80. package/src/renderers/electrobun/bun/index.ts +55 -1
  81. package/src/renderers/electrobun/shared/protocol.ts +11 -1
  82. package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
  83. package/src/renderers/electrobun/view/build-assets.ts +1 -0
  84. package/src/renderers/electrobun/view/host/box.tsx +5 -0
  85. package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
  86. package/src/renderers/electrobun/view/host/mouse.ts +2 -0
  87. package/src/renderers/electrobun/view/main.tsx +5 -1
  88. package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
  89. package/src/renderers/opentui/chart-surface.test.tsx +101 -0
  90. package/src/renderers/opentui/chart-surface.tsx +15 -20
  91. package/src/renderers/opentui/start.tsx +32 -0
  92. package/src/sources/yahoo-finance/http.ts +31 -4
  93. package/src/state/app/context/index.test.tsx +47 -0
  94. package/src/state/app/context/index.tsx +48 -18
  95. package/src/types/config.ts +1 -0
  96. package/src/ui/host.tsx +1 -0
  97. package/src/ui/index.tsx +183 -7
  98. package/src/version.ts +1 -1
  99. package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
@@ -0,0 +1,296 @@
1
+ import { YahooHttpClient } from "../../../sources/yahoo-finance/http";
2
+ import { httpFetch, type HttpFetchTransport } from "../../../utils/http-transport";
3
+
4
+ export const MARKET_HEATMAP_UNIVERSES = [
5
+ { id: "us-equity", label: "US Stocks", yahooQuoteType: "EQUITY", sortField: "intradaymarketcap", sizeField: "intradaymarketcap", minSize: 1_000_000_000 },
6
+ { id: "us-etf", label: "US ETFs", yahooQuoteType: "ETF", sortField: "fundnetassets", sizeField: "fundnetassets", minSize: 1_000_000_000 },
7
+ ] as const;
8
+
9
+ export type MarketHeatmapUniverseId = typeof MARKET_HEATMAP_UNIVERSES[number]["id"];
10
+ export type MarketHeatmapSource = "yahoo" | "nasdaq";
11
+ export type MarketHeatmapSizeKind = "market-cap" | "net-assets";
12
+
13
+ export interface MarketHeatmapAsset {
14
+ symbol: string;
15
+ name: string;
16
+ price: number;
17
+ change: number;
18
+ changePercent: number;
19
+ size: number | null;
20
+ sizeKind: MarketHeatmapSizeKind;
21
+ volume: number | null;
22
+ currency: string;
23
+ exchange: string;
24
+ sector: string | null;
25
+ industry: string | null;
26
+ marketState: string | null;
27
+ source: MarketHeatmapSource;
28
+ }
29
+
30
+ export interface MarketHeatmapResult {
31
+ universe: MarketHeatmapUniverseId;
32
+ source: MarketHeatmapSource;
33
+ fetchedAt: number;
34
+ assets: MarketHeatmapAsset[];
35
+ }
36
+
37
+ export interface MarketHeatmapFetchOptions {
38
+ count?: number;
39
+ forceRefresh?: boolean;
40
+ cache?: boolean;
41
+ }
42
+
43
+ export interface YahooMarketHeatmapClient {
44
+ postJsonWithCrumb<T>(url: string, body: unknown): Promise<T>;
45
+ }
46
+
47
+ export type MarketHeatmapFetchTransport = HttpFetchTransport;
48
+
49
+ export interface MarketHeatmapSources {
50
+ yahooClient?: YahooMarketHeatmapClient;
51
+ nasdaqFetch?: MarketHeatmapFetchTransport;
52
+ }
53
+
54
+ const YAHOO_SCREENER_URL = "https://query1.finance.yahoo.com/v1/finance/screener?lang=en-US&region=US&formatted=false&corsDomain=finance.yahoo.com";
55
+ const NASDAQ_SCREENER_URL = "https://api.nasdaq.com/api/screener/stocks";
56
+ const DEFAULT_COUNT = 80;
57
+ const CACHE_TTL_MS = 60_000;
58
+ const defaultYahooClient = new YahooHttpClient();
59
+ const activeFetches = new Map<string, Promise<MarketHeatmapResult>>();
60
+ const memoryCache = new Map<string, { expiresAt: number; result: MarketHeatmapResult }>();
61
+
62
+ const NASDAQ_HEADERS = {
63
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
64
+ Accept: "application/json,text/plain,*/*",
65
+ "Accept-Language": "en-US,en;q=0.9",
66
+ Origin: "https://www.nasdaq.com",
67
+ Referer: "https://www.nasdaq.com/",
68
+ };
69
+
70
+ function universeSpec(universe: MarketHeatmapUniverseId) {
71
+ return MARKET_HEATMAP_UNIVERSES.find((entry) => entry.id === universe) ?? MARKET_HEATMAP_UNIVERSES[0];
72
+ }
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === "object" && value !== null;
76
+ }
77
+
78
+ function getString(record: Record<string, unknown>, key: string): string | null {
79
+ const value = record[key];
80
+ return typeof value === "string" && value.trim() ? value.trim() : null;
81
+ }
82
+
83
+ function getNumber(record: Record<string, unknown>, key: string): number | null {
84
+ const value = record[key];
85
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
86
+ }
87
+
88
+ function parseLooseNumber(value: unknown): number | null {
89
+ if (typeof value === "number" && Number.isFinite(value)) return value;
90
+ if (typeof value !== "string") return null;
91
+ const trimmed = value.trim();
92
+ if (!trimmed || trimmed === "N/A" || trimmed === "--") return null;
93
+ const negative = trimmed.startsWith("(") && trimmed.endsWith(")");
94
+ const parsed = Number(trimmed.replace(/[$,%(),]/g, ""));
95
+ if (!Number.isFinite(parsed)) return null;
96
+ return negative ? -parsed : parsed;
97
+ }
98
+
99
+ function sizeKindForUniverse(universe: MarketHeatmapUniverseId): MarketHeatmapSizeKind {
100
+ return universe === "us-etf" ? "net-assets" : "market-cap";
101
+ }
102
+
103
+ function buildYahooScreenerBody(universe: MarketHeatmapUniverseId, count: number): unknown {
104
+ const spec = universeSpec(universe);
105
+ return {
106
+ size: count,
107
+ offset: 0,
108
+ sortField: spec.sortField,
109
+ sortType: "DESC",
110
+ quoteType: spec.yahooQuoteType,
111
+ topOperator: "AND",
112
+ query: {
113
+ operator: "AND",
114
+ operands: [
115
+ { operator: "EQ", operands: ["region", "us"] },
116
+ { operator: "EQ", operands: ["quoteType", spec.yahooQuoteType] },
117
+ { operator: "GT", operands: [spec.sizeField, spec.minSize] },
118
+ ],
119
+ },
120
+ userId: "",
121
+ userIdType: "guid",
122
+ };
123
+ }
124
+
125
+ export function parseYahooMarketHeatmapResponse(data: unknown, universe: MarketHeatmapUniverseId): MarketHeatmapAsset[] {
126
+ if (!isRecord(data)) return [];
127
+ const finance = isRecord(data.finance) ? data.finance : null;
128
+ const results = Array.isArray(finance?.result) ? finance.result : [];
129
+ const first = isRecord(results[0]) ? results[0] : null;
130
+ const quotes = Array.isArray(first?.quotes) ? first.quotes : [];
131
+ const sizeKind = sizeKindForUniverse(universe);
132
+ const assets: MarketHeatmapAsset[] = [];
133
+
134
+ for (const quote of quotes) {
135
+ if (!isRecord(quote)) continue;
136
+ const symbol = getString(quote, "symbol");
137
+ if (!symbol) continue;
138
+ const size = sizeKind === "net-assets"
139
+ ? getNumber(quote, "netAssets") ?? getNumber(quote, "fundNetAssets")
140
+ : getNumber(quote, "marketCap") ?? getNumber(quote, "intradayMarketCap");
141
+ const price = getNumber(quote, "regularMarketPrice") ?? 0;
142
+ const change = getNumber(quote, "regularMarketChange") ?? 0;
143
+ const changePercent = getNumber(quote, "regularMarketChangePercent") ?? 0;
144
+
145
+ assets.push({
146
+ symbol,
147
+ name: getString(quote, "shortName") ?? getString(quote, "longName") ?? getString(quote, "displayName") ?? symbol,
148
+ price,
149
+ change,
150
+ changePercent,
151
+ size,
152
+ sizeKind,
153
+ volume: getNumber(quote, "regularMarketVolume"),
154
+ currency: getString(quote, "currency") ?? "USD",
155
+ exchange: getString(quote, "fullExchangeName") ?? getString(quote, "exchange") ?? "",
156
+ sector: getString(quote, "sector"),
157
+ industry: getString(quote, "industry"),
158
+ marketState: getString(quote, "marketState"),
159
+ source: "yahoo",
160
+ });
161
+ }
162
+
163
+ return assets;
164
+ }
165
+
166
+ export function parseNasdaqMarketHeatmapResponse(data: unknown): MarketHeatmapAsset[] {
167
+ if (!isRecord(data)) return [];
168
+ const dataNode = isRecord(data.data) ? data.data : null;
169
+ const rows = Array.isArray(dataNode?.rows) ? dataNode.rows : [];
170
+ const assets: MarketHeatmapAsset[] = [];
171
+
172
+ for (const row of rows) {
173
+ if (!isRecord(row)) continue;
174
+ const symbol = getString(row, "symbol");
175
+ if (!symbol) continue;
176
+ const size = parseLooseNumber(row.marketCap);
177
+ if (size == null || size <= 0) continue;
178
+ const price = parseLooseNumber(row.lastsale) ?? 0;
179
+
180
+ assets.push({
181
+ symbol,
182
+ name: getString(row, "name") ?? symbol,
183
+ price,
184
+ change: parseLooseNumber(row.netchange) ?? 0,
185
+ changePercent: parseLooseNumber(row.pctchange) ?? 0,
186
+ size,
187
+ sizeKind: "market-cap",
188
+ volume: parseLooseNumber(row.volume),
189
+ currency: "USD",
190
+ exchange: getString(row, "exchange") ?? "",
191
+ sector: getString(row, "sector"),
192
+ industry: getString(row, "industry"),
193
+ marketState: null,
194
+ source: "nasdaq",
195
+ });
196
+ }
197
+
198
+ return assets.sort((left, right) => (right.size ?? 0) - (left.size ?? 0));
199
+ }
200
+
201
+ async function fetchYahooMarketHeatmap(
202
+ universe: MarketHeatmapUniverseId,
203
+ count: number,
204
+ client: YahooMarketHeatmapClient,
205
+ ): Promise<MarketHeatmapAsset[]> {
206
+ const data = await client.postJsonWithCrumb<unknown>(YAHOO_SCREENER_URL, buildYahooScreenerBody(universe, count));
207
+ return parseYahooMarketHeatmapResponse(data, universe);
208
+ }
209
+
210
+ async function fetchNasdaqMarketHeatmap(count: number, transport: MarketHeatmapFetchTransport): Promise<MarketHeatmapAsset[]> {
211
+ const url = new URL(NASDAQ_SCREENER_URL);
212
+ url.searchParams.set("tableonly", "true");
213
+ url.searchParams.set("limit", "10000");
214
+ url.searchParams.set("offset", "0");
215
+ url.searchParams.set("download", "true");
216
+
217
+ const response = await transport(url.toString(), {
218
+ headers: NASDAQ_HEADERS,
219
+ signal: AbortSignal.timeout(20_000),
220
+ });
221
+ if (!response.ok) {
222
+ throw new Error(`[${response.status}] Nasdaq screener unavailable`);
223
+ }
224
+
225
+ const data = await response.json();
226
+ return parseNasdaqMarketHeatmapResponse(data).slice(0, count);
227
+ }
228
+
229
+ async function loadMarketHeatmap(
230
+ universe: MarketHeatmapUniverseId,
231
+ count: number,
232
+ sources?: MarketHeatmapSources,
233
+ ): Promise<MarketHeatmapResult> {
234
+ const yahooClient = sources?.yahooClient ?? defaultYahooClient;
235
+ const nasdaqFetch = sources?.nasdaqFetch ?? httpFetch;
236
+ let yahooError: unknown;
237
+
238
+ try {
239
+ const assets = await fetchYahooMarketHeatmap(universe, count, yahooClient);
240
+ if (assets.length > 0) {
241
+ return { universe, source: "yahoo", fetchedAt: Date.now(), assets: assets.slice(0, count) };
242
+ }
243
+ } catch (error) {
244
+ yahooError = error;
245
+ }
246
+
247
+ if (universe === "us-equity") {
248
+ const assets = await fetchNasdaqMarketHeatmap(count, nasdaqFetch);
249
+ return { universe, source: "nasdaq", fetchedAt: Date.now(), assets };
250
+ }
251
+
252
+ throw yahooError instanceof Error
253
+ ? yahooError
254
+ : new Error("Market heatmap unavailable");
255
+ }
256
+
257
+ export async function fetchMarketHeatmap(
258
+ universe: MarketHeatmapUniverseId,
259
+ options?: MarketHeatmapFetchOptions,
260
+ sources?: MarketHeatmapSources,
261
+ ): Promise<MarketHeatmapResult> {
262
+ const count = Math.max(1, Math.min(160, Math.round(options?.count ?? DEFAULT_COUNT)));
263
+ const cacheKey = `${universe}:${count}`;
264
+ const useCache = options?.cache !== false && !sources?.yahooClient && !sources?.nasdaqFetch;
265
+ const now = Date.now();
266
+ const cached = memoryCache.get(cacheKey);
267
+ if (useCache && !options?.forceRefresh && cached && cached.expiresAt > now) {
268
+ return cached.result;
269
+ }
270
+
271
+ if (useCache) {
272
+ const active = activeFetches.get(cacheKey);
273
+ if (active) return active;
274
+ }
275
+
276
+ const fetchPromise = loadMarketHeatmap(universe, count, sources)
277
+ .then((result) => {
278
+ if (useCache) {
279
+ memoryCache.set(cacheKey, { result, expiresAt: Date.now() + CACHE_TTL_MS });
280
+ }
281
+ return result;
282
+ })
283
+ .finally(() => {
284
+ if (activeFetches.get(cacheKey) === fetchPromise) {
285
+ activeFetches.delete(cacheKey);
286
+ }
287
+ });
288
+
289
+ if (useCache) activeFetches.set(cacheKey, fetchPromise);
290
+ return fetchPromise;
291
+ }
292
+
293
+ export function resetMarketHeatmapCache(): void {
294
+ activeFetches.clear();
295
+ memoryCache.clear();
296
+ }
@@ -0,0 +1,320 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, useUiCapabilities } from "../../../ui";
3
+ import {
4
+ buildMetricTreemapNavigationTiles,
5
+ findMetricTreemapNeighbor,
6
+ MetricTreemapSurface,
7
+ Tabs,
8
+ usePaneFooter,
9
+ type MetricTreemapDirection,
10
+ type MetricTreemapItem,
11
+ } from "../../../components";
12
+ import { useShortcut } from "../../../react/input";
13
+ import { TICKER_RESEARCH_PANE_ID } from "../../../types/config";
14
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
15
+ import { priceColor } from "../../../theme/colors";
16
+ import { formatCompact, formatCurrency, formatPercentRaw } from "../../../utils/format";
17
+ import { isPlainKey } from "../../../utils/keyboard";
18
+ import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
19
+ import {
20
+ MARKET_HEATMAP_UNIVERSES,
21
+ fetchMarketHeatmap,
22
+ resetMarketHeatmapCache,
23
+ type MarketHeatmapAsset,
24
+ type MarketHeatmapUniverseId,
25
+ } from "./data";
26
+
27
+ function formatMoneyCompact(value: number | null | undefined, currency: string): string {
28
+ if (value == null) return "—";
29
+ if (currency.toUpperCase() === "USD") return `$${formatCompact(value)}`;
30
+ return `${formatCompact(value)} ${currency}`;
31
+ }
32
+
33
+ function sizeLabel(asset: MarketHeatmapAsset): string {
34
+ const label = asset.sizeKind === "net-assets" ? "Assets" : "Mkt";
35
+ return `${label} ${formatMoneyCompact(asset.size, asset.currency)}`;
36
+ }
37
+
38
+ function updatedLabel(timestamp: number | null): string | null {
39
+ if (!timestamp) return null;
40
+ return new Date(timestamp).toLocaleTimeString("en-US", {
41
+ hour: "2-digit",
42
+ minute: "2-digit",
43
+ });
44
+ }
45
+
46
+ function buildItems(assets: MarketHeatmapAsset[]): Array<MetricTreemapItem<MarketHeatmapAsset>> {
47
+ return assets.map((asset) => ({
48
+ id: asset.symbol,
49
+ label: asset.symbol,
50
+ weight: asset.size ?? 0,
51
+ colorValue: asset.changePercent,
52
+ primaryText: formatPercentRaw(asset.changePercent),
53
+ secondaryText: sizeLabel(asset),
54
+ tertiaryText: asset.volume != null ? `Vol ${formatCompact(asset.volume)}` : asset.exchange || null,
55
+ data: asset,
56
+ }));
57
+ }
58
+
59
+ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
60
+ const { pinTicker } = usePluginTickerActions();
61
+ const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
62
+ const [activeUniverse, setActiveUniverse] = usePluginPaneState<MarketHeatmapUniverseId>("universe", "us-equity");
63
+ const [assets, setAssets] = useState<MarketHeatmapAsset[]>([]);
64
+ const [lastUpdated, setLastUpdated] = useState<number | null>(null);
65
+ const [loading, setLoading] = useState(false);
66
+ const [loadError, setLoadError] = useState<string | null>(null);
67
+ const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
68
+ const fetchGenRef = useRef(0);
69
+
70
+ const chartHeight = Math.max(1, height - 1);
71
+ const chartWidth = Math.max(1, width - 2);
72
+ const cellAspect = Math.max(0.5, Math.min(4, cellHeightPx / Math.max(1, cellWidthPx)));
73
+ const items = useMemo(() => buildItems(assets), [assets]);
74
+ const navigationTiles = useMemo(
75
+ () => buildMetricTreemapNavigationTiles(items, chartWidth, chartHeight, cellAspect, nativePaneChrome ? "float" : "integer"),
76
+ [cellAspect, chartHeight, chartWidth, items, nativePaneChrome],
77
+ );
78
+ const selectedIdx = selectedSymbol
79
+ ? assets.findIndex((asset) => asset.symbol === selectedSymbol)
80
+ : -1;
81
+ const activeIdx = selectedIdx >= 0 ? selectedIdx : (assets.length > 0 ? 0 : -1);
82
+ const selectedAsset = activeIdx >= 0 ? assets[activeIdx] ?? null : null;
83
+
84
+ const loadUniverse = useCallback(async (universe: MarketHeatmapUniverseId, options?: { forceRefresh?: boolean }) => {
85
+ fetchGenRef.current += 1;
86
+ const gen = fetchGenRef.current;
87
+ setLoading(true);
88
+ setLoadError(null);
89
+
90
+ try {
91
+ const result = await fetchMarketHeatmap(universe, {
92
+ count: 96,
93
+ forceRefresh: options?.forceRefresh,
94
+ });
95
+ if (fetchGenRef.current !== gen) return;
96
+ setAssets(result.assets);
97
+ setLastUpdated(result.fetchedAt);
98
+ setSelectedSymbol(result.assets[0]?.symbol ?? null);
99
+ } catch (error) {
100
+ if (fetchGenRef.current !== gen) return;
101
+ setAssets([]);
102
+ setLastUpdated(null);
103
+ setSelectedSymbol(null);
104
+ setLoadError(error instanceof Error ? error.message : "Market heatmap unavailable");
105
+ } finally {
106
+ if (fetchGenRef.current === gen) setLoading(false);
107
+ }
108
+ }, []);
109
+
110
+ useEffect(() => {
111
+ void loadUniverse(activeUniverse);
112
+ }, [activeUniverse, loadUniverse]);
113
+
114
+ useEffect(() => {
115
+ if (selectedSymbol && assets.some((asset) => asset.symbol === selectedSymbol)) return;
116
+ setSelectedSymbol(assets[0]?.symbol ?? null);
117
+ }, [assets, selectedSymbol]);
118
+
119
+ const refresh = useCallback(() => {
120
+ void loadUniverse(activeUniverse, { forceRefresh: true });
121
+ }, [activeUniverse, loadUniverse]);
122
+
123
+ const selectAdjacentUniverse = useCallback((direction: -1 | 1) => {
124
+ const index = MARKET_HEATMAP_UNIVERSES.findIndex((universe) => universe.id === activeUniverse);
125
+ const nextUniverse = MARKET_HEATMAP_UNIVERSES[Math.max(0, Math.min(MARKET_HEATMAP_UNIVERSES.length - 1, index + direction))];
126
+ if (nextUniverse && nextUniverse.id !== activeUniverse) {
127
+ setActiveUniverse(nextUniverse.id);
128
+ setSelectedSymbol(null);
129
+ }
130
+ }, [activeUniverse, setActiveUniverse]);
131
+
132
+ const selectUniverseAt = useCallback((index: number) => {
133
+ const universe = MARKET_HEATMAP_UNIVERSES[index];
134
+ if (!universe || universe.id === activeUniverse) return;
135
+ setActiveUniverse(universe.id);
136
+ setSelectedSymbol(null);
137
+ }, [activeUniverse, setActiveUniverse]);
138
+
139
+ const openSymbol = useCallback((symbol: string) => {
140
+ pinTicker(symbol, { floating: true, paneType: TICKER_RESEARCH_PANE_ID });
141
+ }, [pinTicker]);
142
+
143
+ const selectIndex = useCallback((index: number) => {
144
+ const asset = assets[index];
145
+ if (asset) setSelectedSymbol(asset.symbol);
146
+ }, [assets]);
147
+
148
+ const selectNeighbor = useCallback((direction: MetricTreemapDirection) => {
149
+ const target = findMetricTreemapNeighbor(navigationTiles, selectedSymbol, direction);
150
+ if (target) setSelectedSymbol(target.item.data.symbol);
151
+ }, [navigationTiles, selectedSymbol]);
152
+
153
+ useShortcut((event) => {
154
+ if (!focused) return;
155
+ if (isPlainKey(event, "r")) {
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ refresh();
159
+ return;
160
+ }
161
+ if (isPlainKey(event, "1")) {
162
+ event.preventDefault();
163
+ event.stopPropagation();
164
+ selectUniverseAt(0);
165
+ return;
166
+ }
167
+ if (isPlainKey(event, "2")) {
168
+ event.preventDefault();
169
+ event.stopPropagation();
170
+ selectUniverseAt(1);
171
+ return;
172
+ }
173
+ if (isPlainKey(event, "[")) {
174
+ event.preventDefault();
175
+ event.stopPropagation();
176
+ selectAdjacentUniverse(-1);
177
+ return;
178
+ }
179
+ if (isPlainKey(event, "]")) {
180
+ event.preventDefault();
181
+ event.stopPropagation();
182
+ selectAdjacentUniverse(1);
183
+ return;
184
+ }
185
+ if (isPlainKey(event, "j")) {
186
+ event.preventDefault();
187
+ event.stopPropagation();
188
+ selectIndex(Math.min((activeIdx >= 0 ? activeIdx : 0) + 1, assets.length - 1));
189
+ return;
190
+ }
191
+ if (isPlainKey(event, "k")) {
192
+ event.preventDefault();
193
+ event.stopPropagation();
194
+ selectIndex(Math.max((activeIdx >= 0 ? activeIdx : 0) - 1, 0));
195
+ return;
196
+ }
197
+ if (isPlainKey(event, "left", "h")) {
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ selectNeighbor("left");
201
+ return;
202
+ }
203
+ if (isPlainKey(event, "right", "l")) {
204
+ event.preventDefault();
205
+ event.stopPropagation();
206
+ selectNeighbor("right");
207
+ return;
208
+ }
209
+ if (isPlainKey(event, "up")) {
210
+ event.preventDefault();
211
+ event.stopPropagation();
212
+ selectNeighbor("up");
213
+ return;
214
+ }
215
+ if (isPlainKey(event, "down")) {
216
+ event.preventDefault();
217
+ event.stopPropagation();
218
+ selectNeighbor("down");
219
+ return;
220
+ }
221
+ if (isPlainKey(event, "enter", "return") && selectedAsset) {
222
+ event.preventDefault();
223
+ event.stopPropagation();
224
+ openSymbol(selectedAsset.symbol);
225
+ }
226
+ });
227
+
228
+ usePaneFooter("market-heatmap", () => {
229
+ const updated = updatedLabel(lastUpdated);
230
+ return {
231
+ info: [
232
+ ...(selectedAsset ? [{
233
+ id: "selected",
234
+ parts: [
235
+ { text: selectedAsset.symbol, tone: "label" as const },
236
+ { text: formatCurrency(selectedAsset.price, selectedAsset.currency), tone: "value" as const },
237
+ { text: formatPercentRaw(selectedAsset.changePercent), tone: "value" as const, color: priceColor(selectedAsset.changePercent), bold: true },
238
+ ],
239
+ }] : []),
240
+ ...(updated ? [{
241
+ id: "updated",
242
+ parts: [
243
+ { text: "updated", tone: "label" as const },
244
+ { text: updated, tone: "value" as const },
245
+ ],
246
+ }] : []),
247
+ ...(loading ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }] : []),
248
+ ...(loadError ? [{ id: "error", parts: [{ text: "error", tone: "muted" as const }] }] : []),
249
+ ],
250
+ };
251
+ }, [lastUpdated, loadError, loading, selectedAsset]);
252
+
253
+ const emptyStateTitle = loading
254
+ ? "Loading market heatmap..."
255
+ : loadError ?? "No market heatmap data";
256
+
257
+ return (
258
+ <Box flexDirection="column" width={width} height={height}>
259
+ <Box height={1} paddingX={1}>
260
+ <Tabs
261
+ tabs={MARKET_HEATMAP_UNIVERSES.map((universe) => ({ label: universe.label, value: universe.id }))}
262
+ activeValue={activeUniverse}
263
+ onSelect={(value) => {
264
+ setActiveUniverse(value as MarketHeatmapUniverseId);
265
+ setSelectedSymbol(null);
266
+ }}
267
+ compact
268
+ variant="bare"
269
+ focused={focused}
270
+ keyboardNavigation={false}
271
+ />
272
+ </Box>
273
+
274
+ <MetricTreemapSurface
275
+ items={items}
276
+ width={width}
277
+ height={chartHeight}
278
+ selectedId={selectedSymbol}
279
+ onSelect={(item) => setSelectedSymbol(item.data.symbol)}
280
+ onActivate={(item) => openSymbol(item.data.symbol)}
281
+ emptyStateTitle={emptyStateTitle}
282
+ />
283
+ </Box>
284
+ );
285
+ }
286
+
287
+ export const marketHeatmapPlugin: GloomPlugin = {
288
+ id: "market-heatmap",
289
+ name: "Market Heatmap",
290
+ version: "1.0.0",
291
+ description: "Public market heatmap for the largest US stocks and ETFs.",
292
+ toggleable: true,
293
+
294
+ dispose() {
295
+ resetMarketHeatmapCache();
296
+ },
297
+
298
+ panes: [
299
+ {
300
+ id: "market-heatmap",
301
+ name: "Market Heatmap",
302
+ icon: "H",
303
+ component: MarketHeatmapPane,
304
+ defaultPosition: "right",
305
+ defaultMode: "floating",
306
+ defaultFloatingSize: { width: 110, height: 36 },
307
+ },
308
+ ],
309
+
310
+ paneTemplates: [
311
+ {
312
+ id: "market-heatmap-pane",
313
+ paneId: "market-heatmap",
314
+ label: "Market Heatmap",
315
+ description: "Largest US stocks and ETFs, sized by market cap or assets and colored by daily move.",
316
+ keywords: ["heatmap", "market", "largest", "top", "stocks", "etf", "screener"],
317
+ shortcut: { prefix: "HM" },
318
+ },
319
+ ],
320
+ };
@@ -7,6 +7,7 @@ import { fxMatrixPlugin } from "./fx-matrix";
7
7
  import { fearGreedPlugin } from "./fear-greed";
8
8
  import { holdersPlugin } from "./holders";
9
9
  import { insiderPlugin } from "./insider";
10
+ import { marketHeatmapPlugin } from "./market-heatmap";
10
11
  import { marketMoversPlugin } from "./market-movers";
11
12
  import { optionsPlugin } from "./options";
12
13
  import { researchPlugin } from "./research";
@@ -91,6 +92,7 @@ export const marketOverviewPlugin = createPluginGroup({
91
92
  comparisonChartPlugin,
92
93
  correlationPlugin,
93
94
  worldIndicesPlugin,
95
+ marketHeatmapPlugin,
94
96
  marketMoversPlugin,
95
97
  fearGreedPlugin,
96
98
  sectorsPlugin,