gloomberb 0.11.2 → 0.11.3
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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/api-client/data.ts +5 -0
- package/src/api-client/index.ts +5 -0
- package/src/api-client/types.ts +24 -0
- package/src/capabilities/chart-series.ts +3 -2
- package/src/cli/commands/market.ts +2 -2
- package/src/cli/pane-functions/capabilities.ts +3 -3
- package/src/cli/registry.ts +0 -22
- package/src/cli/result.ts +7 -10
- package/src/components/data-table/export.test.ts +21 -0
- package/src/components/data-table/export.ts +21 -0
- package/src/components/ui/data-table/index.tsx +19 -2
- package/src/data/config/chart-settings.ts +4 -4
- package/src/layout-marketplace/gallery-terminal.tsx +126 -96
- package/src/layout-marketplace/gallery.test.tsx +65 -1
- package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
- package/src/plugins/builtin/chart-composer/settings.ts +2 -13
- package/src/plugins/builtin/composite-plugins.ts +2 -0
- package/src/plugins/builtin/earnings/index.tsx +1 -0
- package/src/plugins/builtin/econ/index.tsx +1 -0
- package/src/plugins/builtin/futures/index.tsx +1 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
- package/src/plugins/builtin/market-halts/index.tsx +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +1 -0
- package/src/plugins/builtin/options/analytics.test.ts +83 -0
- package/src/plugins/builtin/options/analytics.ts +110 -0
- package/src/plugins/builtin/options/index.tsx +17 -2
- package/src/plugins/builtin/options/table.ts +121 -51
- package/src/plugins/builtin/options/types.ts +22 -15
- package/src/plugins/builtin/options/view.test.tsx +24 -0
- package/src/plugins/builtin/options/view.tsx +91 -19
- package/src/plugins/builtin/plugin-module.test.ts +2 -4
- package/src/plugins/builtin/plugin-module.ts +0 -7
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/scanner/index.ts +2 -0
- package/src/plugins/builtin/sectors/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -0
- package/src/plugins/builtin/world-venue-map/index.ts +27 -0
- package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
- package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
- package/src/plugins/builtin/world-venue-map/model.ts +147 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
- package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
- package/src/plugins/catalog-backend.ts +6 -44
- package/src/plugins/catalog-browser.ts +2 -0
- package/src/plugins/registry/pane-settings.test.ts +53 -0
- package/src/plugins/registry/pane-settings.ts +36 -3
- package/src/renderers/browser/ui-host.tsx +11 -0
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
- package/src/renderers/electrobun/bun/index.ts +1 -0
- package/src/renderers/electrobun/shared/protocol.ts +4 -0
- package/src/renderers/electrobun/view/ui-host.tsx +3 -0
- package/src/renderers/opentui/host.tsx +4 -0
- package/src/state/pane-table-export-registry.ts +31 -0
- package/src/time-series/range.ts +12 -0
- package/src/time-series/resolution.ts +3 -15
- package/src/time-series/spec.ts +5 -5
- package/src/types/plugin.ts +2 -20
- package/src/ui/host.tsx +7 -0
- package/src/utils/csv.test.ts +13 -0
- package/src/utils/csv.ts +37 -0
- package/src/utils/save-text-file.test.ts +24 -0
- package/src/utils/save-text-file.ts +36 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type DataTableColumn,
|
|
10
10
|
type DataTableKeyEvent,
|
|
11
11
|
} from "../../../../components";
|
|
12
|
-
import type
|
|
12
|
+
import { TIME_RANGES, type TimeRange } from "../../../../time-series/range";
|
|
13
13
|
import type { PaneProps } from "../../../../types/plugin";
|
|
14
14
|
import type { PricePoint } from "../../../../types/financials";
|
|
15
15
|
import { colors, priceColor } from "../../../../theme/colors";
|
|
@@ -33,8 +33,6 @@ export type HistoricalPriceRow = {
|
|
|
33
33
|
changePercent: number | null;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
const HISTORY_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
37
|
-
|
|
38
36
|
function pricePointDate(point: PricePoint): Date | null {
|
|
39
37
|
const value = point.date as Date | string | number;
|
|
40
38
|
const date = value instanceof Date ? value : new Date(value);
|
|
@@ -93,8 +91,8 @@ function buildHistoryColumns(width: number): HistoryColumn[] {
|
|
|
93
91
|
}
|
|
94
92
|
|
|
95
93
|
function nextHistoryRange(current: TimeRange): TimeRange {
|
|
96
|
-
const index =
|
|
97
|
-
return
|
|
94
|
+
const index = TIME_RANGES.indexOf(current);
|
|
95
|
+
return TIME_RANGES[(index + 1) % TIME_RANGES.length] ?? "1Y";
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
|
|
@@ -52,6 +52,7 @@ export const tickerDetailModule: PluginModule = {
|
|
|
52
52
|
defaultPosition: "right",
|
|
53
53
|
defaultMode: "floating",
|
|
54
54
|
defaultFloatingSize: { width: 98, height: 30 },
|
|
55
|
+
tableExport: true,
|
|
55
56
|
},
|
|
56
57
|
{
|
|
57
58
|
id: "quote-monitor",
|
|
@@ -72,6 +73,7 @@ export const tickerDetailModule: PluginModule = {
|
|
|
72
73
|
defaultPosition: "right",
|
|
73
74
|
defaultMode: "floating",
|
|
74
75
|
defaultFloatingSize: { width: 92, height: 26 },
|
|
76
|
+
tableExport: true,
|
|
75
77
|
},
|
|
76
78
|
{
|
|
77
79
|
id: "provider-search-results",
|
|
@@ -129,6 +129,7 @@ export const worldIndicesModule: PluginModule = {
|
|
|
129
129
|
defaultPosition: "right",
|
|
130
130
|
defaultMode: "floating",
|
|
131
131
|
defaultFloatingSize: { width: 96, height: 32 },
|
|
132
|
+
tableExport: true,
|
|
132
133
|
// Resolved per open so the dialog shows the full board until the user
|
|
133
134
|
// saves a narrower selection.
|
|
134
135
|
settings: (context) => ({
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PluginModule } from "../plugin-module";
|
|
2
|
+
import { WORLD_VENUE_MAP_PANE_ID, WorldVenueMapPane } from "./pane";
|
|
3
|
+
|
|
4
|
+
export const worldVenueMapModule: PluginModule = {
|
|
5
|
+
panes: [
|
|
6
|
+
{
|
|
7
|
+
id: WORLD_VENUE_MAP_PANE_ID,
|
|
8
|
+
name: "World Venue Map",
|
|
9
|
+
icon: "M",
|
|
10
|
+
component: WorldVenueMapPane,
|
|
11
|
+
defaultPosition: "right",
|
|
12
|
+
defaultMode: "floating",
|
|
13
|
+
defaultFloatingSize: { width: 124, height: 36 },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
paneTemplates: [
|
|
17
|
+
{
|
|
18
|
+
id: "world-venue-map-pane",
|
|
19
|
+
paneId: WORLD_VENUE_MAP_PANE_ID,
|
|
20
|
+
label: "World Venue Map",
|
|
21
|
+
description: "Live trading venue status, local time, and exchange locations around the world.",
|
|
22
|
+
keywords: ["world", "map", "venue", "venues", "exchange", "mic", "market hours", "open markets"],
|
|
23
|
+
shortcut: { prefix: "MAP" },
|
|
24
|
+
createInstance: () => ({ placement: "floating" }),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createElement, useMemo, useRef } from "react";
|
|
2
|
+
import type { CloudWorldVenuePayload } from "../../../api-client";
|
|
3
|
+
import { ChartSurface, Text, useNativeRenderer, useUiHost } from "../../../ui";
|
|
4
|
+
import { useThemeColors } from "../../../theme/theme-context";
|
|
5
|
+
import { resolveNativeBitmapSize, shouldRenderNativeBitmap } from "../../../components/chart/native/bitmap-support";
|
|
6
|
+
import { drawCircle, drawLine, fillOpaque, parseHex } from "../../../components/chart/native/raster/primitives";
|
|
7
|
+
import type { NativeChartBitmap } from "../../../components/chart/native/chart-rasterizer";
|
|
8
|
+
import { getLocalPlotPointer, type ChartMouseEvent } from "../../../components/chart/core/pointer";
|
|
9
|
+
import {
|
|
10
|
+
closestWorldVenueCluster,
|
|
11
|
+
clusterWorldVenues,
|
|
12
|
+
projectWorldPoint,
|
|
13
|
+
type WorldVenueCluster,
|
|
14
|
+
} from "./model";
|
|
15
|
+
import { WORLD_OUTLINES } from "./world-outlines";
|
|
16
|
+
|
|
17
|
+
interface WorldVenueMapProps {
|
|
18
|
+
venues: readonly CloudWorldVenuePayload[];
|
|
19
|
+
selectedMic: string | null;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
onSelect: (venue: CloudWorldVenuePayload) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clusterVenue(cluster: WorldVenueCluster, selectedMic: string | null): CloudWorldVenuePayload {
|
|
26
|
+
return cluster.venues.find((venue) => venue.mic === selectedMic)
|
|
27
|
+
?? cluster.venues.find((venue) => venue.isOpen)
|
|
28
|
+
?? cluster.venues[0]!;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isSelectedCluster(cluster: WorldVenueCluster, selectedMic: string | null): boolean {
|
|
32
|
+
return cluster.venues.some((venue) => venue.mic === selectedMic);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function drawWorldOutlines(
|
|
36
|
+
pixels: Uint8Array,
|
|
37
|
+
width: number,
|
|
38
|
+
height: number,
|
|
39
|
+
color: ReturnType<typeof parseHex>,
|
|
40
|
+
thickness: number,
|
|
41
|
+
) {
|
|
42
|
+
for (const outline of WORLD_OUTLINES) {
|
|
43
|
+
for (let index = 1; index < outline.length; index += 1) {
|
|
44
|
+
const previous = outline[index - 1]!;
|
|
45
|
+
const current = outline[index]!;
|
|
46
|
+
if (previous[1] < -60 || current[1] < -60) continue;
|
|
47
|
+
const from = projectWorldPoint(previous[0], previous[1], width, height);
|
|
48
|
+
const to = projectWorldPoint(current[0], current[1], width, height);
|
|
49
|
+
if (Math.abs(to.x - from.x) > width / 2) continue;
|
|
50
|
+
drawLine(pixels, width, height, from.x, from.y, to.x, to.y, color, thickness);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DIGITS: Record<string, readonly string[]> = {
|
|
56
|
+
"0": ["111", "101", "101", "101", "111"],
|
|
57
|
+
"1": ["010", "110", "010", "010", "111"],
|
|
58
|
+
"2": ["111", "001", "111", "100", "111"],
|
|
59
|
+
"3": ["111", "001", "111", "001", "111"],
|
|
60
|
+
"4": ["101", "101", "111", "001", "001"],
|
|
61
|
+
"5": ["111", "100", "111", "001", "111"],
|
|
62
|
+
"6": ["111", "100", "111", "101", "111"],
|
|
63
|
+
"7": ["111", "001", "010", "010", "010"],
|
|
64
|
+
"8": ["111", "101", "111", "101", "111"],
|
|
65
|
+
"9": ["111", "101", "111", "001", "111"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function drawClusterCount(
|
|
69
|
+
bitmap: NativeChartBitmap,
|
|
70
|
+
x: number,
|
|
71
|
+
y: number,
|
|
72
|
+
count: number,
|
|
73
|
+
color: ReturnType<typeof parseHex>,
|
|
74
|
+
scale: number,
|
|
75
|
+
) {
|
|
76
|
+
const value = String(count);
|
|
77
|
+
const glyphWidth = 3 * scale;
|
|
78
|
+
const gap = scale;
|
|
79
|
+
const totalWidth = value.length * glyphWidth + (value.length - 1) * gap;
|
|
80
|
+
const left = Math.round(x - (totalWidth - 1) / 2);
|
|
81
|
+
const top = Math.round(y - (5 * scale - 1) / 2);
|
|
82
|
+
for (let digitIndex = 0; digitIndex < value.length; digitIndex += 1) {
|
|
83
|
+
const glyph = DIGITS[value[digitIndex]!]!;
|
|
84
|
+
for (let row = 0; row < glyph.length; row += 1) {
|
|
85
|
+
for (let column = 0; column < 3; column += 1) {
|
|
86
|
+
if (glyph[row]![column] !== "1") continue;
|
|
87
|
+
const x0 = left + digitIndex * (glyphWidth + gap) + column * scale;
|
|
88
|
+
const y0 = top + row * scale;
|
|
89
|
+
for (let dy = 0; dy < scale; dy += 1) {
|
|
90
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
91
|
+
const pixelX = x0 + dx;
|
|
92
|
+
const pixelY = y0 + dy;
|
|
93
|
+
if (pixelX < 0 || pixelY < 0 || pixelX >= bitmap.width || pixelY >= bitmap.height) continue;
|
|
94
|
+
const offset = (pixelY * bitmap.width + pixelX) * 4;
|
|
95
|
+
bitmap.pixels[offset] = color.r;
|
|
96
|
+
bitmap.pixels[offset + 1] = color.g;
|
|
97
|
+
bitmap.pixels[offset + 2] = color.b;
|
|
98
|
+
bitmap.pixels[offset + 3] = color.a;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderWorldBitmap(
|
|
107
|
+
venues: readonly CloudWorldVenuePayload[],
|
|
108
|
+
selectedMic: string | null,
|
|
109
|
+
width: number,
|
|
110
|
+
height: number,
|
|
111
|
+
colors: ReturnType<typeof useThemeColors>,
|
|
112
|
+
): NativeChartBitmap {
|
|
113
|
+
const bitmap = { width, height, pixels: new Uint8Array(width * height * 4) };
|
|
114
|
+
fillOpaque(bitmap.pixels, parseHex(colors.bg));
|
|
115
|
+
drawWorldOutlines(bitmap.pixels, width, height, parseHex(colors.textDim, 0.62), Math.max(1, width / 900));
|
|
116
|
+
|
|
117
|
+
const clusters = clusterWorldVenues(venues, width, height);
|
|
118
|
+
for (const cluster of clusters) {
|
|
119
|
+
const selected = isSelectedCluster(cluster, selectedMic);
|
|
120
|
+
const radius = Math.max(3, Math.min(14, 3 + Math.sqrt(cluster.venues.length) * 1.7));
|
|
121
|
+
if (selected) {
|
|
122
|
+
drawCircle(bitmap.pixels, width, height, cluster.x, cluster.y, radius + 2.5, parseHex(colors.selectedText));
|
|
123
|
+
}
|
|
124
|
+
drawCircle(
|
|
125
|
+
bitmap.pixels,
|
|
126
|
+
width,
|
|
127
|
+
height,
|
|
128
|
+
cluster.x,
|
|
129
|
+
cluster.y,
|
|
130
|
+
radius,
|
|
131
|
+
parseHex(cluster.isOpen ? colors.positive : colors.textMuted),
|
|
132
|
+
);
|
|
133
|
+
if (cluster.venues.length > 1) {
|
|
134
|
+
drawClusterCount(bitmap, cluster.x, cluster.y, cluster.venues.length, parseHex(colors.bg), width >= 700 ? 2 : 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return bitmap;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderAsciiMap(
|
|
141
|
+
venues: readonly CloudWorldVenuePayload[],
|
|
142
|
+
selectedMic: string | null,
|
|
143
|
+
width: number,
|
|
144
|
+
height: number,
|
|
145
|
+
cellAspect: number,
|
|
146
|
+
): string[] {
|
|
147
|
+
const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => " "));
|
|
148
|
+
for (const outline of WORLD_OUTLINES) {
|
|
149
|
+
for (const coordinate of outline) {
|
|
150
|
+
const point = projectWorldPoint(coordinate[0], coordinate[1], width, height, cellAspect);
|
|
151
|
+
const x = Math.round(point.x);
|
|
152
|
+
const y = Math.round(point.y);
|
|
153
|
+
if (grid[y]?.[x] === " ") grid[y]![x] = ".";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const cluster of clusterWorldVenues(venues, width, height, cellAspect)) {
|
|
157
|
+
const x = Math.round(cluster.x);
|
|
158
|
+
const y = Math.round(cluster.y);
|
|
159
|
+
grid[y]![x] = isSelectedCluster(cluster, selectedMic)
|
|
160
|
+
? "@"
|
|
161
|
+
: cluster.venues.length > 1
|
|
162
|
+
? String(Math.min(cluster.venues.length, 9))
|
|
163
|
+
: cluster.isOpen ? "O" : "o";
|
|
164
|
+
}
|
|
165
|
+
return grid.map((row) => row.join(""));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function TerminalWorldVenueMap(props: WorldVenueMapProps) {
|
|
169
|
+
const colors = useThemeColors();
|
|
170
|
+
const renderer = useNativeRenderer();
|
|
171
|
+
const surfaceRef = useRef<any>(null);
|
|
172
|
+
const { nativeCharts, cellWidthPx = 8, cellHeightPx = 18, pixelRatio = 1 } = useUiHost().capabilities ?? {};
|
|
173
|
+
const rendererCapabilities = renderer.capabilities;
|
|
174
|
+
const rendererResolution = renderer.resolution;
|
|
175
|
+
const rendererTerminalWidth = renderer.terminalWidth;
|
|
176
|
+
const rendererTerminalHeight = renderer.terminalHeight;
|
|
177
|
+
const bitmap = useMemo(() => {
|
|
178
|
+
if (!shouldRenderNativeBitmap(renderer, nativeCharts === true)) return null;
|
|
179
|
+
const size = resolveNativeBitmapSize({
|
|
180
|
+
width: props.width,
|
|
181
|
+
height: props.height,
|
|
182
|
+
resolution: rendererResolution,
|
|
183
|
+
terminalWidth: rendererTerminalWidth,
|
|
184
|
+
terminalHeight: rendererTerminalHeight,
|
|
185
|
+
cellWidthPx,
|
|
186
|
+
cellHeightPx,
|
|
187
|
+
pixelRatio,
|
|
188
|
+
});
|
|
189
|
+
return renderWorldBitmap(props.venues, props.selectedMic, size.pixelWidth, size.pixelHeight, colors);
|
|
190
|
+
}, [
|
|
191
|
+
cellHeightPx,
|
|
192
|
+
cellWidthPx,
|
|
193
|
+
colors,
|
|
194
|
+
nativeCharts,
|
|
195
|
+
pixelRatio,
|
|
196
|
+
props.height,
|
|
197
|
+
props.selectedMic,
|
|
198
|
+
props.venues,
|
|
199
|
+
props.width,
|
|
200
|
+
renderer,
|
|
201
|
+
rendererCapabilities,
|
|
202
|
+
rendererResolution,
|
|
203
|
+
rendererTerminalHeight,
|
|
204
|
+
rendererTerminalWidth,
|
|
205
|
+
]);
|
|
206
|
+
const cellAspect = cellHeightPx / Math.max(cellWidthPx, 1);
|
|
207
|
+
const clusters = useMemo(
|
|
208
|
+
() => clusterWorldVenues(props.venues, props.width, props.height, cellAspect),
|
|
209
|
+
[cellAspect, props.height, props.venues, props.width],
|
|
210
|
+
);
|
|
211
|
+
const ascii = useMemo(
|
|
212
|
+
() => renderAsciiMap(props.venues, props.selectedMic, props.width, props.height, cellAspect),
|
|
213
|
+
[cellAspect, props.height, props.selectedMic, props.venues, props.width],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const selectAt = (event: ChartMouseEvent) => {
|
|
217
|
+
const pointer = getLocalPlotPointer(event, surfaceRef.current, renderer);
|
|
218
|
+
if (!pointer) return;
|
|
219
|
+
const cluster = closestWorldVenueCluster(clusters, pointer.cellX, pointer.cellY, 4);
|
|
220
|
+
if (cluster) props.onSelect(clusterVenue(cluster, props.selectedMic));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<ChartSurface
|
|
225
|
+
ref={surfaceRef}
|
|
226
|
+
width={props.width}
|
|
227
|
+
height={props.height}
|
|
228
|
+
flexDirection="column"
|
|
229
|
+
bitmaps={bitmap ? [bitmap] : null}
|
|
230
|
+
onMouseDown={selectAt}
|
|
231
|
+
data-gloom-role="world-venue-map"
|
|
232
|
+
aria-label="World venue map"
|
|
233
|
+
>
|
|
234
|
+
{ascii.map((line, index) => <Text key={index} fg={colors.textDim}>{line}</Text>)}
|
|
235
|
+
</ChartSurface>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function DesktopWorldVenueMap(props: WorldVenueMapProps) {
|
|
240
|
+
const colors = useThemeColors();
|
|
241
|
+
// Desktop rows are roughly twice as tall as they are wide. Matching the SVG
|
|
242
|
+
// view box to those pixels lets the contained world projection keep its shape.
|
|
243
|
+
const plotHeight = props.height * 2.12;
|
|
244
|
+
const clusters = useMemo(
|
|
245
|
+
() => clusterWorldVenues(props.venues, props.width, plotHeight),
|
|
246
|
+
[plotHeight, props.venues, props.width],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const outlinePath = (outline: (typeof WORLD_OUTLINES)[number]) => {
|
|
250
|
+
let drawing = false;
|
|
251
|
+
let previousLongitude: number | null = null;
|
|
252
|
+
return outline.map(([longitude, latitude]) => {
|
|
253
|
+
if (latitude < -60 || (previousLongitude != null && Math.abs(longitude - previousLongitude) > 180)) {
|
|
254
|
+
drawing = false;
|
|
255
|
+
previousLongitude = longitude;
|
|
256
|
+
return "";
|
|
257
|
+
}
|
|
258
|
+
const point = projectWorldPoint(longitude, latitude, props.width, plotHeight);
|
|
259
|
+
previousLongitude = longitude;
|
|
260
|
+
const command = drawing ? "L" : "M";
|
|
261
|
+
drawing = true;
|
|
262
|
+
return `${command}${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
|
|
263
|
+
}).join(" ");
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<svg
|
|
268
|
+
viewBox={`0 0 ${props.width} ${plotHeight}`}
|
|
269
|
+
width="100%"
|
|
270
|
+
height="100%"
|
|
271
|
+
role="img"
|
|
272
|
+
aria-label="World venue map"
|
|
273
|
+
style={{ display: "block", background: colors.bg }}
|
|
274
|
+
>
|
|
275
|
+
{WORLD_OUTLINES.map((outline, index) => (
|
|
276
|
+
<path
|
|
277
|
+
key={index}
|
|
278
|
+
d={outlinePath(outline)}
|
|
279
|
+
fill="none"
|
|
280
|
+
stroke={colors.textDim}
|
|
281
|
+
strokeOpacity="0.64"
|
|
282
|
+
strokeWidth="0.55"
|
|
283
|
+
vectorEffect="non-scaling-stroke"
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
{clusters.map((cluster) => {
|
|
287
|
+
const selected = isSelectedCluster(cluster, props.selectedMic);
|
|
288
|
+
const venue = clusterVenue(cluster, props.selectedMic);
|
|
289
|
+
const radius = Math.max(0.55, Math.min(1.5, 0.45 + Math.sqrt(cluster.venues.length) * 0.22));
|
|
290
|
+
return (
|
|
291
|
+
<g
|
|
292
|
+
key={cluster.id}
|
|
293
|
+
role="button"
|
|
294
|
+
tabIndex={0}
|
|
295
|
+
aria-label={`${cluster.venues.length} venues near ${venue.city}`}
|
|
296
|
+
style={{ cursor: "pointer" }}
|
|
297
|
+
onMouseDown={(event) => {
|
|
298
|
+
event.stopPropagation();
|
|
299
|
+
props.onSelect(venue);
|
|
300
|
+
}}
|
|
301
|
+
onKeyDown={(event) => {
|
|
302
|
+
if (event.key === "Enter" || event.key === " ") props.onSelect(venue);
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<title>{`${cluster.venues.length} venue${cluster.venues.length === 1 ? "" : "s"} near ${venue.city}: ${cluster.venues.map((item) => `${item.mic} ${item.name}`).join(", ")}`}</title>
|
|
306
|
+
<circle
|
|
307
|
+
cx={cluster.x}
|
|
308
|
+
cy={cluster.y}
|
|
309
|
+
r={radius + (selected ? 0.24 : 0)}
|
|
310
|
+
fill={cluster.isOpen ? colors.positive : colors.textMuted}
|
|
311
|
+
stroke={selected ? colors.selectedText : colors.bg}
|
|
312
|
+
strokeWidth={selected ? 0.35 : 0.16}
|
|
313
|
+
vectorEffect="non-scaling-stroke"
|
|
314
|
+
/>
|
|
315
|
+
{cluster.venues.length > 1 ? createElement("text", {
|
|
316
|
+
x: cluster.x,
|
|
317
|
+
y: cluster.y,
|
|
318
|
+
fill: colors.bg,
|
|
319
|
+
dy: "0.34em",
|
|
320
|
+
fontSize: Math.max(0.62, Math.min(0.95, radius * 0.78)),
|
|
321
|
+
fontWeight: "700",
|
|
322
|
+
fontFamily: "inherit",
|
|
323
|
+
textAnchor: "middle",
|
|
324
|
+
pointerEvents: "none",
|
|
325
|
+
}, cluster.venues.length) : null}
|
|
326
|
+
</g>
|
|
327
|
+
);
|
|
328
|
+
})}
|
|
329
|
+
</svg>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function WorldVenueMap(props: WorldVenueMapProps) {
|
|
334
|
+
return useUiHost().kind === "desktop-web"
|
|
335
|
+
? <DesktopWorldVenueMap {...props} />
|
|
336
|
+
: <TerminalWorldVenueMap {...props} />;
|
|
337
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { CloudWorldVenuePayload } from "../../../api-client";
|
|
3
|
+
import {
|
|
4
|
+
clusterWorldVenues,
|
|
5
|
+
filterWorldVenues,
|
|
6
|
+
projectWorldPoint,
|
|
7
|
+
venueRemainingSeconds,
|
|
8
|
+
} from "./model";
|
|
9
|
+
|
|
10
|
+
function venue(overrides: Partial<CloudWorldVenuePayload>): CloudWorldVenuePayload {
|
|
11
|
+
return {
|
|
12
|
+
mic: "XNYS",
|
|
13
|
+
name: "NYSE",
|
|
14
|
+
title: "New York Stock Exchange",
|
|
15
|
+
country: "United States",
|
|
16
|
+
countryCode: "US",
|
|
17
|
+
city: "New York",
|
|
18
|
+
timezone: "America/New_York",
|
|
19
|
+
latitude: 40.7127,
|
|
20
|
+
longitude: -74.006,
|
|
21
|
+
isOpen: false,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("world venue map model", () => {
|
|
27
|
+
test("projects, clusters, filters, and advances server countdowns", () => {
|
|
28
|
+
expect(projectWorldPoint(-180, 85, 361, 146)).toEqual({ x: 0, y: 0 });
|
|
29
|
+
expect(projectWorldPoint(180, -60, 361, 146)).toEqual({ x: 360, y: 145 });
|
|
30
|
+
|
|
31
|
+
const tallTopLeft = projectWorldPoint(-180, 85, 101, 101);
|
|
32
|
+
const tallBottomRight = projectWorldPoint(180, -60, 101, 101);
|
|
33
|
+
expect(tallTopLeft.x).toBe(0);
|
|
34
|
+
expect(tallBottomRight.x).toBe(100);
|
|
35
|
+
expect(100 / (tallBottomRight.y - tallTopLeft.y)).toBeCloseTo(360 / 145);
|
|
36
|
+
|
|
37
|
+
const terminalTopLeft = projectWorldPoint(-180, 85, 101, 51, 2);
|
|
38
|
+
const terminalBottomRight = projectWorldPoint(180, -60, 101, 51, 2);
|
|
39
|
+
expect(100 / ((terminalBottomRight.y - terminalTopLeft.y) * 2)).toBeCloseTo(360 / 145);
|
|
40
|
+
|
|
41
|
+
const rows = [
|
|
42
|
+
venue({ mic: "XNYS", timeToOpenSeconds: 3_600 }),
|
|
43
|
+
venue({ mic: "XASE", name: "NYSE American", isOpen: true, timeToCloseSeconds: 7_200 }),
|
|
44
|
+
venue({ mic: "XLON", name: "London Stock Exchange", title: "London Stock Exchange", city: "London", country: "United Kingdom", countryCode: "GB", longitude: -0.1278, latitude: 51.5074 }),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const clusters = clusterWorldVenues(rows, 80, 24);
|
|
48
|
+
expect(clusters.find((cluster) => cluster.venues.some((item) => item.mic === "XNYS"))?.venues).toHaveLength(2);
|
|
49
|
+
expect(filterWorldVenues(rows, "london").map((item) => item.mic)).toEqual(["XLON"]);
|
|
50
|
+
expect(filterWorldVenues(rows, "")[0]?.mic).toBe("XASE");
|
|
51
|
+
expect(venueRemainingSeconds(rows[0]!, 1_000, 61_000)).toBe(3_540);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { CloudWorldVenuePayload } from "../../../api-client";
|
|
2
|
+
|
|
3
|
+
export interface WorldMapPoint {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorldVenueCluster extends WorldMapPoint {
|
|
9
|
+
id: string;
|
|
10
|
+
venues: CloudWorldVenuePayload[];
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_LATITUDE = 85;
|
|
15
|
+
const MIN_LATITUDE = -60;
|
|
16
|
+
|
|
17
|
+
export function projectWorldPoint(
|
|
18
|
+
longitude: number,
|
|
19
|
+
latitude: number,
|
|
20
|
+
width: number,
|
|
21
|
+
height: number,
|
|
22
|
+
yUnitAspect = 1,
|
|
23
|
+
): WorldMapPoint {
|
|
24
|
+
const availableWidth = Math.max(width - 1, 0);
|
|
25
|
+
const availableHeight = Math.max(height - 1, 0);
|
|
26
|
+
const unitAspect = Math.max(yUnitAspect, Number.EPSILON);
|
|
27
|
+
const effectiveHeight = availableHeight * unitAspect;
|
|
28
|
+
const latitudeSpan = MAX_LATITUDE - MIN_LATITUDE;
|
|
29
|
+
const scale = Math.min(availableWidth / 360, effectiveHeight / latitudeSpan);
|
|
30
|
+
const mapWidth = 360 * scale;
|
|
31
|
+
const mapHeight = latitudeSpan * scale;
|
|
32
|
+
const clampedLatitude = Math.max(MIN_LATITUDE, Math.min(MAX_LATITUDE, latitude));
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
x: (availableWidth - mapWidth) / 2 + (longitude + 180) * scale,
|
|
36
|
+
y: ((effectiveHeight - mapHeight) / 2 + (MAX_LATITUDE - clampedLatitude) * scale) / unitAspect,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clusterWorldVenues(
|
|
41
|
+
venues: readonly CloudWorldVenuePayload[],
|
|
42
|
+
width: number,
|
|
43
|
+
height: number,
|
|
44
|
+
yUnitAspect = 1,
|
|
45
|
+
): WorldVenueCluster[] {
|
|
46
|
+
const buckets = new Map<string, CloudWorldVenuePayload[]>();
|
|
47
|
+
const cellWidth = Math.max(4, Math.round(width / 18));
|
|
48
|
+
const cellHeight = Math.max(2, Math.round(height / 12));
|
|
49
|
+
|
|
50
|
+
for (const venue of venues) {
|
|
51
|
+
const point = projectWorldPoint(venue.longitude, venue.latitude, width, height, yUnitAspect);
|
|
52
|
+
const key = `${Math.floor(point.x / cellWidth)}:${Math.floor(point.y / cellHeight)}`;
|
|
53
|
+
const bucket = buckets.get(key) ?? [];
|
|
54
|
+
bucket.push(venue);
|
|
55
|
+
buckets.set(key, bucket);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [...buckets.entries()].map(([id, grouped]) => {
|
|
59
|
+
const point = grouped.reduce(
|
|
60
|
+
(sum, venue) => {
|
|
61
|
+
const projected = projectWorldPoint(venue.longitude, venue.latitude, width, height, yUnitAspect);
|
|
62
|
+
return { x: sum.x + projected.x, y: sum.y + projected.y };
|
|
63
|
+
},
|
|
64
|
+
{ x: 0, y: 0 },
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
x: point.x / grouped.length,
|
|
69
|
+
y: point.y / grouped.length,
|
|
70
|
+
venues: grouped,
|
|
71
|
+
isOpen: grouped.some((venue) => venue.isOpen),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function filterWorldVenues(
|
|
77
|
+
venues: readonly CloudWorldVenuePayload[],
|
|
78
|
+
query: string,
|
|
79
|
+
): CloudWorldVenuePayload[] {
|
|
80
|
+
const normalized = query.trim().toLocaleLowerCase();
|
|
81
|
+
return venues
|
|
82
|
+
.filter((venue) => !normalized || [
|
|
83
|
+
venue.mic,
|
|
84
|
+
venue.name,
|
|
85
|
+
venue.title,
|
|
86
|
+
venue.city,
|
|
87
|
+
venue.country,
|
|
88
|
+
venue.countryCode,
|
|
89
|
+
].some((value) => value.toLocaleLowerCase().includes(normalized)))
|
|
90
|
+
.sort((left, right) => Number(right.isOpen) - Number(left.isOpen)
|
|
91
|
+
|| left.name.localeCompare(right.name)
|
|
92
|
+
|| left.mic.localeCompare(right.mic));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function closestWorldVenueCluster(
|
|
96
|
+
clusters: readonly WorldVenueCluster[],
|
|
97
|
+
x: number,
|
|
98
|
+
y: number,
|
|
99
|
+
maxDistance: number,
|
|
100
|
+
): WorldVenueCluster | null {
|
|
101
|
+
let closest: WorldVenueCluster | null = null;
|
|
102
|
+
let distance = maxDistance;
|
|
103
|
+
for (const cluster of clusters) {
|
|
104
|
+
const next = Math.hypot(cluster.x - x, cluster.y - y);
|
|
105
|
+
if (next <= distance) {
|
|
106
|
+
closest = cluster;
|
|
107
|
+
distance = next;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return closest;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const localTimeFormatters = new Map<string, Intl.DateTimeFormat>();
|
|
114
|
+
|
|
115
|
+
export function formatVenueLocalTime(timezone: string, now: number): string {
|
|
116
|
+
let formatter = localTimeFormatters.get(timezone);
|
|
117
|
+
if (!formatter) {
|
|
118
|
+
formatter = new Intl.DateTimeFormat("en-GB", {
|
|
119
|
+
timeZone: timezone,
|
|
120
|
+
hour: "2-digit",
|
|
121
|
+
minute: "2-digit",
|
|
122
|
+
hourCycle: "h23",
|
|
123
|
+
});
|
|
124
|
+
localTimeFormatters.set(timezone, formatter);
|
|
125
|
+
}
|
|
126
|
+
return formatter.format(now);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function venueRemainingSeconds(
|
|
130
|
+
venue: CloudWorldVenuePayload,
|
|
131
|
+
checkedAt: number,
|
|
132
|
+
now: number,
|
|
133
|
+
): number | null {
|
|
134
|
+
const source = venue.isOpen ? venue.timeToCloseSeconds : venue.timeToOpenSeconds;
|
|
135
|
+
if (source == null) return null;
|
|
136
|
+
return Math.max(0, source - Math.floor((now - checkedAt) / 1_000));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatVenueCountdown(seconds: number | null): string {
|
|
140
|
+
if (seconds == null) return "";
|
|
141
|
+
if (seconds < 60) return `${seconds}s`;
|
|
142
|
+
const minutes = Math.ceil(seconds / 60);
|
|
143
|
+
if (minutes < 60) return `${minutes}m`;
|
|
144
|
+
const hours = Math.floor(minutes / 60);
|
|
145
|
+
const remainder = minutes % 60;
|
|
146
|
+
return remainder ? `${hours}h ${remainder}m` : `${hours}h`;
|
|
147
|
+
}
|