gloomberb 0.16.2 → 0.16.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.
Files changed (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -49,7 +49,7 @@ export const worldVenueMapModule: PluginModule = {
49
49
  panes: [
50
50
  {
51
51
  id: WORLD_VENUE_MAP_PANE_ID,
52
- name: "World Venue Map",
52
+ name: "World Map",
53
53
  icon: "M",
54
54
  component: WorldVenueMapPane,
55
55
  defaultPosition: "right",
@@ -63,9 +63,9 @@ export const worldVenueMapModule: PluginModule = {
63
63
  {
64
64
  id: "world-venue-map-pane",
65
65
  paneId: WORLD_VENUE_MAP_PANE_ID,
66
- label: "World Venue Map",
67
- description: "Trading venues with live status and local time; ships, ports, energy and airports as layers on the same map.",
68
- keywords: ["world", "map", "venue", "venues", "exchange", "mic", "market hours", "open markets", "ships", "vessels", "ports", "chokepoints", "pipelines", "airports", "energy", "layers"],
66
+ label: "World Map",
67
+ description: "A world map with trading venues and their hours; ships, ports, energy and airports as layers.",
68
+ keywords: ["world", "map", "globe", "countries", "venue", "venues", "exchange", "mic", "market hours", "open markets", "ships", "vessels", "ports", "chokepoints", "pipelines", "airports", "energy", "layers"],
69
69
  shortcut: {
70
70
  prefix: "MAP",
71
71
  argPlaceholder: "layers",
@@ -78,7 +78,7 @@ export const worldVenueMapModule: PluginModule = {
78
78
  const preset = parseMapPreset(options?.arg ?? options?.values?.layers);
79
79
  if (!preset) return { placement: "floating" };
80
80
  return {
81
- title: `Map · ${preset.layers.map(groupTitle).join(", ")}`,
81
+ title: preset.layers.length ? `Map · ${preset.layers.map(groupTitle).join(", ")}` : "Map · Venues",
82
82
  placement: "floating",
83
83
  settings: { layers: preset.layers, venues: preset.venues },
84
84
  };
@@ -49,10 +49,13 @@ import type { GeoLayerState } from "./feed";
49
49
  import { geoLayerColor, type GeoMapLayer, type GeoMapOverlay, type GeoMapSelection } from "./geo-draw";
50
50
  import { WORLD_VENUE_MAP_PANE_ID } from "./ids";
51
51
  import {
52
+ entityOpenZoom,
53
+ entityTableBbox,
52
54
  featureAnchor,
53
55
  formatGeoValue,
54
56
  formatTickerLinks,
55
57
  isChangeColumn,
58
+ LAYERS_SETTING_KEY,
56
59
  readVenuesSetting,
57
60
  sameGeoView,
58
61
  tickerLinkKey,
@@ -71,6 +74,8 @@ interface LayeredMapViewProps extends PaneProps {
71
74
  layers: GeoLayerInfo[];
72
75
  /** The layer ids or groups the pane was asked for. */
73
76
  tokens: readonly string[];
77
+ /** A plain MAP: venues show unless the pane turned them off. */
78
+ plain?: boolean;
74
79
  }
75
80
 
76
81
  interface OpenEntity {
@@ -80,15 +85,9 @@ interface OpenEntity {
80
85
 
81
86
  type SortState = { key: string | null; dir: "asc" | "desc" };
82
87
 
83
- /**
84
- * Zoom an opened entity is shown at: 16 times the world (the server's zoom 4,
85
- * where dense point layers stop clustering) for a ship or a port, wider for a
86
- * pipeline or a field.
87
- */
88
- function openZoom(layer: GeoLayerInfo): number {
89
- return layer.geometry === "point" ? 16 : 4;
90
- }
91
88
  const NO_SORT: SortState = { key: null, dir: "asc" };
89
+ /** How long the map rests before a table that follows it asks again. */
90
+ const TABLE_VIEW_SETTLE_MS = 600;
92
91
  /** Layers go closer than venues: 64 times the world is the server's zoom 6, where flights and single ports show. */
93
92
  const LAYERED_MAX_ZOOM = 64;
94
93
 
@@ -165,7 +164,7 @@ const SelectedEntityHeader = memo(function SelectedEntityHeader({
165
164
  );
166
165
  });
167
166
 
168
- export function LayeredMapView({ focused, width, height, layers, tokens }: LayeredMapViewProps) {
167
+ export function LayeredMapView({ focused, width, height, layers, tokens, plain = false }: LayeredMapViewProps) {
169
168
  const colors = useThemeColors();
170
169
  const dark = relativeLuminance(colors.text) > relativeLuminance(colors.bg);
171
170
  const { nativePaneChrome } = useUiCapabilities();
@@ -174,7 +173,7 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
174
173
  const { hasProAccess } = usePlanAccess();
175
174
  const upgrade = useCloudUpgradeAction("map-layers");
176
175
  const [venuesSetting] = usePaneSettingValue<boolean | undefined>(VENUES_SETTING_KEY, undefined);
177
- const venuesOn = readVenuesSetting({ [VENUES_SETTING_KEY]: venuesSetting }, layers.length);
176
+ const venuesOn = readVenuesSetting(plain ? { [VENUES_SETTING_KEY]: venuesSetting } : { [VENUES_SETTING_KEY]: venuesSetting, [LAYERS_SETTING_KEY]: tokens }, layers.length);
178
177
  const { data: venueData } = useAsyncResource(venuesOn ? loadWorldVenues : null);
179
178
  const [now, setNow] = useState(Date.now());
180
179
  useEffect(() => {
@@ -214,16 +213,28 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
214
213
  const [selectedMic, setSelectedMic] = useState<string | null>(null);
215
214
  const [headerSubject, setHeaderSubject] = useState<"entity" | "venue">("entity");
216
215
  const [focus, setFocus] = useState<WorldMapFocus | null>(null);
216
+ // Kept per pane, so docking, undocking, popping out or reloading reopens the map where it was.
217
+ const [savedViewport, setSavedViewport] = usePluginPaneState<unknown>("map:viewport", null);
217
218
  const { active: searchFocused, focus: focusSearch, searchProps } = useQueryBarSearch();
218
219
  const scrollRef = useRef<ScrollBoxRenderable>(null);
219
220
  const detailScrollRef = useRef<ScrollBoxRenderable>(null);
220
221
 
221
222
  const tableLocked = !canAccess(tableLayer) || tableState?.phase === "locked";
222
223
  const tableReadable = tableLayer.status !== "unavailable" && tableState?.phase !== "unavailable" && !tableLocked;
224
+ // The table follows the map once the view rests, not every frame of a pan.
225
+ const [tableView, setTableView] = useState<GeoView>(view);
226
+ useEffect(() => {
227
+ if (sameGeoView(tableView, view)) return;
228
+ const timer = setTimeout(() => setTableView(view), TABLE_VIEW_SETTLE_MS);
229
+ return () => clearTimeout(timer);
230
+ }, [tableView, view]);
231
+ const tableBbox = entityTableBbox(tableLayer, tableView);
232
+ const tableBboxKey = tableBbox?.join(",") ?? "";
223
233
  const loadPage = useCallback(async ({ offset, signal }: PageRequest) => {
224
234
  const page = await loadEntityPage(tableLayer.id, {
225
235
  q: query,
226
236
  ...(sort.key ? { sort: sort.key, dir: sort.dir } : {}),
237
+ ...(tableBbox ? { bbox: tableBbox } : {}),
227
238
  offset,
228
239
  }, signal);
229
240
  const rows = page.rows ?? [];
@@ -234,7 +245,7 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
234
245
  hasMore: rows.length >= ENTITY_PAGE_SIZE && (total === null || offset + rows.length < total),
235
246
  nextOffset: offset + rows.length,
236
247
  };
237
- }, [query, sort.dir, sort.key, tableLayer.id]);
248
+ }, [query, sort.dir, sort.key, tableBboxKey, tableLayer.id]);
238
249
  const pages = usePagedRows(tableReadable ? loadPage : null, { getId: (row) => row.id });
239
250
  const rows = pages.rows;
240
251
  useWorldMapEvidence(tokens, states, rows.length, pages.loading);
@@ -301,7 +312,7 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
301
312
  const lats = trail.map(([, lat]) => lat);
302
313
  const span = Math.max(Math.max(...lons) - Math.min(...lons), (Math.max(...lats) - Math.min(...lats)) * 2, 0.05);
303
314
  const last = trail.at(-1)!;
304
- setFocus({ key: trailKey, longitude: last[0], latitude: last[1], zoom: Math.min(LAYERED_MAX_ZOOM, Math.max(openZoom(tableLayer), 360 / (span * 3))) });
315
+ setFocus({ key: trailKey, longitude: last[0], latitude: last[1], zoom: Math.min(LAYERED_MAX_ZOOM, Math.max(entityOpenZoom(tableLayer), 360 / (span * 3))) });
305
316
  }, [trailKey]);
306
317
  const overlay = useMemo<GeoMapOverlay>(() => ({ layers: mapLayers, selected: selection, trail }), [mapLayers, selection, trail]);
307
318
 
@@ -472,7 +483,7 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
472
483
  setHeaderSubject("entity");
473
484
  selectRow(row.id);
474
485
  setOpen({ layer: tableLayer.id, id: row.id });
475
- setFocus({ key: `open:${tableLayer.id}:${row.id}`, longitude: row.lon, latitude: row.lat, zoom: openZoom(tableLayer) });
486
+ setFocus({ key: `open:${tableLayer.id}:${row.id}`, longitude: row.lon, latitude: row.lat, zoom: entityOpenZoom(tableLayer) });
476
487
  }}
477
488
  detailOpen={detailOpen}
478
489
  onBack={() => setOpen(null)}
@@ -497,7 +508,7 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
497
508
  );
498
509
 
499
510
  const map = (
500
- <Box flexDirection="column" width={mapWidth} height={mapSectionHeight} overflow="hidden">
511
+ <Box key="map" flexDirection="column" width={mapWidth} height={mapSectionHeight} overflow="hidden">
501
512
  {headerSubject === "venue" && selectedVenue && venueData ? (
502
513
  <SelectedVenueHeader venue={selectedVenue} checkedAt={venueData.checkedAt} now={now} width={mapWidth} />
503
514
  ) : (
@@ -517,6 +528,8 @@ export function LayeredMapView({ focused, width, height, layers, tokens }: Layer
517
528
  onSelectGeo={onSelectGeo}
518
529
  onViewChange={onViewChange}
519
530
  focus={focus}
531
+ savedViewport={savedViewport}
532
+ onViewportSettled={setSavedViewport}
520
533
  />
521
534
  </Box>
522
535
  );
@@ -75,6 +75,33 @@ function capLayerSelection(ordered: readonly GeoLayerInfo[]): GeoLayerInfo[] {
75
75
  return kept;
76
76
  }
77
77
 
78
+ /** Layers a plain map opens with: the server's default layers that can serve, two at most. */
79
+ const MAX_DEFAULT_LAYERS = 2;
80
+
81
+ function defaultLayerIds(layers: readonly GeoLayerInfo[] | null | undefined): string[] {
82
+ if (!layers?.length) return [];
83
+ const defaults = layers.filter((layer) => layer.defaultVisible && layer.status !== "unavailable");
84
+ return capLayerSelection(defaults.slice(0, MAX_DEFAULT_LAYERS)).map((layer) => layer.id);
85
+ }
86
+
87
+ /**
88
+ * A pane that never chose its layers or venues: a plain `MAP`, or a map
89
+ * saved before layers existed. It shows venues and the server's default
90
+ * layers. A preset or an edit in the layer picker writes both settings, and
91
+ * from then on the pane keeps exactly what was chosen.
92
+ */
93
+ export function isPlainMap(settings: Record<string, unknown> | undefined): boolean {
94
+ return settings?.[LAYERS_SETTING_KEY] == null && typeof settings?.[VENUES_SETTING_KEY] !== "boolean";
95
+ }
96
+
97
+ /** The layer ids or groups a pane shows: what it chose, or the defaults on a plain map. */
98
+ export function activeLayerTokens(
99
+ settings: Record<string, unknown> | undefined,
100
+ layers: readonly GeoLayerInfo[] | null | undefined,
101
+ ): string[] {
102
+ return isPlainMap(settings) ? defaultLayerIds(layers) : parseLayerTokens(settings?.[LAYERS_SETTING_KEY]);
103
+ }
104
+
78
105
  /** Expands groups to their layers, drops what the server does not list, and applies the caps. */
79
106
  export function resolveActiveLayers(tokens: readonly string[], layers: readonly GeoLayerInfo[] | null | undefined): GeoLayerInfo[] {
80
107
  if (!layers?.length || !tokens.length) return [];
@@ -98,18 +125,23 @@ export interface MapPreset {
98
125
  venues: boolean;
99
126
  }
100
127
 
101
- /** `MAP ships` and friends: the typed words become layer tokens, resolved once the catalog is known. */
128
+ /**
129
+ * `MAP ships` and friends: the typed words become layer tokens, resolved once
130
+ * the catalog is known. `MAP venues` alone is the venue map without layers.
131
+ */
102
132
  export function parseMapPreset(arg: string | null | undefined): MapPreset | null {
103
133
  const tokens = parseLayerTokens(arg ?? "");
104
- if (!tokens.length) return null;
105
- return { layers: tokens, venues: /\bvenues\b/i.test(arg ?? "") };
134
+ const venues = /\bvenues\b/i.test(arg ?? "");
135
+ if (!tokens.length) return venues ? { layers: [], venues: true } : null;
136
+ return { layers: tokens, venues };
106
137
  }
107
138
 
108
139
  export const MAP_PRESET_OPTIONS = [
140
+ { value: "venues", label: "Trading venues only" },
109
141
  { value: "ships", label: "Ships and chokepoints" },
110
142
  { value: "ports", label: "Ports" },
111
143
  { value: "energy", label: "Pipelines, fields and terminals" },
112
- { value: "air", label: "Airports" },
144
+ { value: "air", label: "Airports and flights" },
113
145
  ] as const;
114
146
 
115
147
  function shortDate(iso: string): string | null {
@@ -152,8 +184,8 @@ function groupsOf(layers: readonly GeoLayerInfo[]): string[] {
152
184
  export function readVenuesSetting(settings: Record<string, unknown> | undefined, activeLayers: number): boolean {
153
185
  const value = settings?.[VENUES_SETTING_KEY];
154
186
  if (typeof value === "boolean") return value;
155
- // A preset opens on its layers; a plain MAP stays the venue map.
156
- return activeLayers === 0;
187
+ // A plain MAP shows venues under its default layers; a preset opens on its layers alone.
188
+ return settings?.[LAYERS_SETTING_KEY] == null || activeLayers === 0;
157
189
  }
158
190
 
159
191
  /**
@@ -166,7 +198,7 @@ export function buildMapSettingsDef(
166
198
  layers: readonly GeoLayerInfo[] | null | undefined,
167
199
  ): PaneSettingsDef | undefined {
168
200
  if (!layers?.length) return undefined;
169
- const active = resolveActiveLayers(parseLayerTokens(settings[LAYERS_SETTING_KEY]), layers);
201
+ const active = resolveActiveLayers(activeLayerTokens(settings, layers), layers);
170
202
  const values: Record<string, unknown> = { [VENUES_SETTING_KEY]: readVenuesSetting(settings, active.length) };
171
203
  const fields: PaneSettingField[] = [{
172
204
  key: VENUES_SETTING_KEY,
@@ -199,12 +231,16 @@ export function applyMapSetting(
199
231
  value: unknown,
200
232
  layers: readonly GeoLayerInfo[],
201
233
  ): Record<string, unknown> {
202
- if (key === VENUES_SETTING_KEY) return { ...settings, [VENUES_SETTING_KEY]: value === true };
203
- if (!key.startsWith(GROUP_PREFIX)) return { ...settings, [key]: value };
234
+ const before = resolveActiveLayers(activeLayerTokens(settings, layers), layers);
235
+ // The first edit of a plain map writes down what it showed, so only the edited part changes.
236
+ const chosen = isPlainMap(settings)
237
+ ? { ...settings, [LAYERS_SETTING_KEY]: before.map((layer) => layer.id), [VENUES_SETTING_KEY]: readVenuesSetting(settings, before.length) }
238
+ : settings;
239
+ if (key === VENUES_SETTING_KEY) return { ...chosen, [VENUES_SETTING_KEY]: value === true };
240
+ if (!key.startsWith(GROUP_PREFIX)) return { ...chosen, [key]: value };
204
241
  const group = key.slice(GROUP_PREFIX.length);
205
242
  const members = new Set(layers.filter((layer) => String(layer.group || "other") === group).map((layer) => layer.id));
206
243
  const picked = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && members.has(entry)) : [];
207
- const before = resolveActiveLayers(parseLayerTokens(settings[LAYERS_SETTING_KEY]), layers);
208
244
  // Other groups keep their order; this group's picks follow, newest last, so the caps drop the oldest.
209
245
  const kept = before.filter((layer) => !members.has(layer.id) || picked.includes(layer.id));
210
246
  const added = picked.filter((id) => !kept.some((layer) => layer.id === id))
@@ -253,6 +289,28 @@ export function geoViewForViewport(
253
289
  };
254
290
  }
255
291
 
292
+ /**
293
+ * Zoom an opened entity is shown at: 16 times the world (the server's zoom 4,
294
+ * where dense point layers stop clustering) for a ship or a port, wider for a
295
+ * pipeline or a field, and never short of the zoom its layer draws from
296
+ * (flights from the server's zoom 5, 32 times the world).
297
+ */
298
+ export function entityOpenZoom(layer: Pick<GeoLayerInfo, "geometry" | "minZoom">): number {
299
+ const base = layer.geometry === "point" ? 16 : 4;
300
+ return layer.minZoom === undefined ? base : Math.max(base, 2 ** layer.minZoom);
301
+ }
302
+
303
+ /**
304
+ * The area a layer's table reads. A layer drawn only from a zoom (live
305
+ * flights) lists what the map shows once it is that close; further out, and
306
+ * for every other layer, the table asks without one and the server answers
307
+ * for the whole layer or, for flights, one busy area.
308
+ */
309
+ export function entityTableBbox(layer: Pick<GeoLayerInfo, "minZoom">, view: GeoView): GeoBbox | undefined {
310
+ if (layer.minZoom === undefined || view.zoom < layer.minZoom) return undefined;
311
+ return view.bbox;
312
+ }
313
+
256
314
  export function geoViewKey(view: GeoView): string {
257
315
  return `${view.bbox.join(",")}@${Math.floor(view.zoom)}`;
258
316
  }
@@ -16,6 +16,7 @@ import {
16
16
  MAX_WORLD_MAP_ZOOM,
17
17
  panWorldMapViewport,
18
18
  projectWorldPoint,
19
+ savedWorldMapViewport,
19
20
  unprojectWorldPoint,
20
21
  zoomWorldMapViewport,
21
22
  type WorldMapPoint,
@@ -26,7 +27,8 @@ import { WORLD_OUTLINES } from "./world-outlines";
26
27
  import { drawGeoBitmap, hitTestGeo, rasterGeoCells, type GeoHit, type GeoMapOverlay } from "./geo-draw";
27
28
  import { GeoSvgLayers, MapIconSwatch, toneColor, type MapMatrix, type SymbolTones } from "./geo-svg";
28
29
  import { BasemapLayer, type BasemapPalette } from "./basemap-layer";
29
- import type { GeoBox } from "./basemap";
30
+ import { loadCountryLabels, type CountryLabel, type GeoBox } from "./basemap";
31
+ import { COUNTRY_LABEL_FONT_PX, layoutCountryLabels, type LabelObstacle, type PlacedCountryLabel } from "./country-labels";
30
32
  import { layerSymbol, shipClass, usesClassTones, type MapSymbolId, type SymbolTone } from "./map-symbols";
31
33
  import { geoViewForViewport, sameGeoView, WORLD_GEO_VIEW, type GeoView } from "./layers";
32
34
 
@@ -52,6 +54,13 @@ interface WorldVenueMapProps {
52
54
  focus?: WorldMapFocus | null;
53
55
  /** How far the map may zoom; geo layers go closer than venues. */
54
56
  maxZoom?: number;
57
+ /**
58
+ * Where the map was when this pane last drew it (saved pane state). A pane
59
+ * docked, undocked, popped out or reloaded mounts a new map; it opens here.
60
+ */
61
+ savedViewport?: unknown;
62
+ /** The view once it has rested for a moment, for the pane to keep. */
63
+ onViewportSettled?: (viewport: WorldMapViewport) => void;
55
64
  }
56
65
 
57
66
  function clusterVenue(cluster: WorldVenueCluster, selectedMic: string | null): CloudWorldVenuePayload {
@@ -306,6 +315,75 @@ const MAP_CLICK_HIT_PX = 14;
306
315
  const MAP_DOUBLE_CLICK_ZOOM = 1.8;
307
316
  /** Share of the plot a selected venue may sit inside before the map pans to it. */
308
317
  const MAP_FOLLOW_MARGIN = 0.06;
318
+ /** How long the view rests before the pane keeps it. */
319
+ const VIEWPORT_SETTLE_MS = 400;
320
+
321
+ /**
322
+ * Whether a map of this many cells can be drawn. A pane mid-layout (a new
323
+ * window, a dock move) can hand the map a width of one cell for a frame; a
324
+ * projection from that has no scale, and a focus applied to it loses its
325
+ * centre.
326
+ */
327
+ function mapHasArea(width: number, height: number): boolean {
328
+ return width > 2 && height > 1;
329
+ }
330
+
331
+ /** A venue dot's radius in view units, larger for a cluster of venues. */
332
+ function venueDotRadius(count: number): number {
333
+ return Math.max(0.55, Math.min(1.5, 0.45 + Math.sqrt(count) * 0.22));
334
+ }
335
+
336
+ /** Country names are laid out again once the view has rested this long, not on every drag frame. */
337
+ const LABEL_SETTLE_MS = 150;
338
+ /** Room a selected or hovered marker keeps clear of country names, in pixels. */
339
+ const LABEL_MARKER_CLEARANCE_PX = 14;
340
+
341
+ /** The country names, from their lazy chunk; none until it arrives, none in a build without map data. */
342
+ function useCountryLabels(): readonly CountryLabel[] | null {
343
+ const [labels, setLabels] = useState<readonly CountryLabel[] | null>(null);
344
+ useEffect(() => {
345
+ let current = true;
346
+ loadCountryLabels().then((loaded) => {
347
+ if (current) setLabels(loaded);
348
+ }, () => {});
349
+ return () => {
350
+ current = false;
351
+ };
352
+ }, []);
353
+ return labels;
354
+ }
355
+
356
+ /** Country names under the markers: small, quiet, never in the way of a click. */
357
+ function CountryNames({ labels, project, unitPx, color, halo }: {
358
+ labels: readonly PlacedCountryLabel[];
359
+ project: (longitude: number, latitude: number) => WorldMapPoint;
360
+ unitPx: number;
361
+ color: string;
362
+ halo: string;
363
+ }) {
364
+ if (!labels.length) return null;
365
+ const fontSize = COUNTRY_LABEL_FONT_PX / unitPx;
366
+ return createElement(
367
+ "g",
368
+ {
369
+ "data-gloom-role": "country-labels",
370
+ fill: color,
371
+ stroke: halo,
372
+ strokeWidth: 3 / unitPx,
373
+ strokeOpacity: 0.8,
374
+ strokeLinejoin: "round",
375
+ paintOrder: "stroke",
376
+ fontSize,
377
+ fontFamily: "inherit",
378
+ textAnchor: "middle",
379
+ pointerEvents: "none",
380
+ },
381
+ ...labels.map((label) => {
382
+ const at = project(label.longitude, label.latitude);
383
+ return createElement("text", { key: label.name, x: at.x, y: at.y, dy: "0.35em" }, label.name);
384
+ }),
385
+ );
386
+ }
309
387
 
310
388
  function wheelZoomFactor(event: WheelEvent): number {
311
389
  const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
@@ -386,7 +464,10 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
386
464
  const maxZoom = props.maxZoom ?? MAX_WORLD_MAP_ZOOM;
387
465
  const surfaceRef = useRef<HTMLDivElement | null>(null);
388
466
  const surfaceSize = useElementSize(surfaceRef);
389
- const [viewport, setViewport] = useState<WorldMapViewport>(DEFAULT_WORLD_MAP_VIEWPORT);
467
+ const [viewport, setViewport] = useState<WorldMapViewport>(
468
+ () => savedWorldMapViewport(props.savedViewport, maxZoom) ?? DEFAULT_WORLD_MAP_VIEWPORT,
469
+ );
470
+ const sized = mapHasArea(props.width, props.height);
390
471
  const viewportRef = useRef(viewport);
391
472
  viewportRef.current = viewport;
392
473
  const [dragging, setDragging] = useState(false);
@@ -431,6 +512,7 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
431
512
  land: blendHex(colors.bg, colors.text, dark ? 0.1 : 0.07),
432
513
  coast: blendHex(colors.bg, colors.textDim, dark ? 0.85 : 0.75),
433
514
  border: blendHex(colors.bg, colors.textDim, dark ? 0.42 : 0.35),
515
+ label: blendHex(colors.bg, colors.textDim, dark ? 0.7 : 0.8),
434
516
  }), [colors.bg, colors.text, colors.textDim, dark]);
435
517
  // Five colours in all: the layer's own for cargo, amber tankers, bright
436
518
  // passenger ships, green fishing boats, and one quiet grey for the rest.
@@ -439,6 +521,42 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
439
521
  return { tanker: colors.warning, passenger: colors.textBright, fishing: colors.positive, service: quiet, other: quiet };
440
522
  }, [colors.positive, colors.text, colors.textBright, colors.textDim, colors.warning]);
441
523
 
524
+ // Names are placed for the view once it rests; a drag moves the ones already placed.
525
+ const countryLabels = useCountryLabels();
526
+ const [labelViewport, setLabelViewport] = useState(viewport);
527
+ useEffect(() => {
528
+ if (labelViewport === viewport) return;
529
+ const timer = setTimeout(() => setLabelViewport(viewport), LABEL_SETTLE_MS);
530
+ return () => clearTimeout(timer);
531
+ }, [labelViewport, viewport]);
532
+ const selectedPoint = props.overlay?.selected ?? null;
533
+ const selectedVenuePoint = props.venues.find((venue) => venue.mic === props.selectedMic) ?? null;
534
+ const hoverAnchor = hover?.hit.kind === "feature" && hover.hit.feature.geometry.type === "Point" ? hover.hit.feature.geometry.coordinates : null;
535
+ const placedLabels = useMemo(() => {
536
+ if (!countryLabels?.length) return [];
537
+ const at = (longitude: number, latitude: number) => {
538
+ const point = projectWorldPoint(longitude, latitude, props.width, plotHeight, 1, labelViewport);
539
+ return { x: point.x * unitPx, y: point.y * unitPx };
540
+ };
541
+ const markers = [
542
+ selectedPoint ? [selectedPoint.longitude, selectedPoint.latitude] : null,
543
+ selectedVenuePoint ? [selectedVenuePoint.longitude, selectedVenuePoint.latitude] : null,
544
+ hoverAnchor,
545
+ ].filter((point): point is [number, number] => point !== null);
546
+ const obstacles: LabelObstacle[] = markers.map(([longitude, latitude]) => ({ ...at(longitude, latitude), radius: LABEL_MARKER_CLEARANCE_PX }));
547
+ // Venue dots are few and sit on land, where the names are: a name never runs under one.
548
+ for (const cluster of clusterWorldVenues(props.venues, props.width, plotHeight, 1, labelViewport)) {
549
+ obstacles.push({ x: cluster.x * unitPx, y: cluster.y * unitPx, radius: (venueDotRadius(cluster.venues.length) + 0.2) * unitPx });
550
+ }
551
+ return layoutCountryLabels(countryLabels, {
552
+ pxPerDegree: (at(1, 0).x - at(0, 0).x),
553
+ toPixels: at,
554
+ widthPx: props.width * unitPx,
555
+ heightPx: plotHeight * unitPx,
556
+ obstacles,
557
+ });
558
+ }, [countryLabels, hoverAnchor?.[0], hoverAnchor?.[1], labelViewport, plotHeight, props.venues, props.width, selectedPoint?.latitude, selectedPoint?.longitude, selectedVenuePoint, unitPx]);
559
+
442
560
  const hitRadius = useCallback((element: HTMLElement) => {
443
561
  const rect = element.getBoundingClientRect();
444
562
  return rect.height <= 0 ? 2.4 : (MAP_CLICK_HIT_PX / rect.height) * plotHeight;
@@ -464,24 +582,39 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
464
582
  const onViewChange = props.onViewChange;
465
583
  const reportedViewRef = useRef<GeoView | null>(null);
466
584
  useEffect(() => {
467
- if (!onViewChange) return;
585
+ if (!onViewChange || !sized) return;
468
586
  const next = geoViewForViewport(viewport, props.width, plotHeight);
469
587
  if (reportedViewRef.current && sameGeoView(reportedViewRef.current, next)) return;
470
588
  reportedViewRef.current = next;
471
589
  onViewChange(next);
472
- }, [onViewChange, plotHeight, props.width, viewport]);
590
+ }, [onViewChange, plotHeight, props.width, sized, viewport]);
473
591
 
474
- // A new focus (an entity picked in the table) centres the map on it.
592
+ // The pane keeps the view once it rests, so a remount opens where this left off.
593
+ const onViewportSettled = props.onViewportSettled;
594
+ const settledViewportRef = useRef(viewport);
595
+ useEffect(() => {
596
+ if (!onViewportSettled || settledViewportRef.current === viewport) return;
597
+ const timer = setTimeout(() => {
598
+ settledViewportRef.current = viewport;
599
+ onViewportSettled(viewport);
600
+ }, VIEWPORT_SETTLE_MS);
601
+ return () => clearTimeout(timer);
602
+ }, [onViewportSettled, viewport]);
603
+
604
+ // A new focus (an entity picked in the table) centres the map on it, once
605
+ // the map has an area to centre it in.
475
606
  const focusKey = props.focus?.key ?? null;
607
+ const appliedFocusRef = useRef<string | null>(null);
476
608
  useEffect(() => {
477
609
  const focus = props.focus;
478
- if (!focus) return;
610
+ if (!focus || !sized || appliedFocusRef.current === focus.key) return;
611
+ appliedFocusRef.current = focus.key;
479
612
  setViewport((current) => {
480
613
  const zoom = Math.max(current.zoom, focus.zoom ?? 1);
481
614
  if (zoom <= 1) return current;
482
615
  return clampWorldMapViewport({ zoom, centerLongitude: focus.longitude, centerLatitude: focus.latitude }, props.width, plotHeight, 1, maxZoom);
483
616
  });
484
- }, [focusKey]);
617
+ }, [focusKey, sized]);
485
618
 
486
619
  const selectedVenue = props.venues.find((venue) => venue.mic === props.selectedMic) ?? null;
487
620
  const selectedVenueRef = useRef(selectedVenue);
@@ -637,60 +770,63 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
637
770
  background: colors.bg,
638
771
  }}
639
772
  >
640
- <svg
641
- viewBox={`0 0 ${props.width} ${plotHeight}`}
642
- width="100%"
643
- height="100%"
644
- aria-hidden="true"
645
- style={{ display: "block", background: colors.bg, pointerEvents: "none" }}
646
- >
647
- <BasemapLayer matrix={matrix} view={view} pxPerDegree={pxPerDegree} palette={palette} />
648
- {props.overlay ? (
649
- <GeoSvgLayers
650
- overlay={props.overlay}
651
- matrix={matrix}
652
- project={project}
653
- view={view}
654
- unitPx={unitPx}
655
- mapWidthPx={surfaceSize?.width ?? props.width * 8}
656
- background={colors.bg}
657
- selectedColor={colors.selectedText}
658
- textColor={colors.textBright}
659
- tones={tones}
660
- hovered={hovered?.hit ?? null}
661
- />
662
- ) : null}
663
- {clusters.map((cluster) => {
664
- const selected = isSelectedCluster(cluster, props.selectedMic);
665
- const venue = clusterVenue(cluster, props.selectedMic);
666
- const radius = Math.max(0.55, Math.min(1.5, 0.45 + Math.sqrt(cluster.venues.length) * 0.22));
667
- return (
668
- <g key={cluster.id}>
669
- <title>{`${cluster.venues.length} venue${cluster.venues.length === 1 ? "" : "s"} near ${venue.city}: ${cluster.venues.map((item) => `${item.mic} ${item.name}`).join(", ")}`}</title>
670
- <circle
671
- cx={cluster.x}
672
- cy={cluster.y}
673
- r={radius + (selected ? 0.24 : 0)}
674
- fill={cluster.isOpen ? colors.positive : colors.textMuted}
675
- stroke={selected ? colors.selectedText : colors.bg}
676
- strokeWidth={selected ? 0.35 : 0.16}
677
- vectorEffect="non-scaling-stroke"
678
- />
679
- {cluster.venues.length > 1 ? createElement("text", {
680
- x: cluster.x,
681
- y: cluster.y,
682
- fill: colors.bg,
683
- dy: "0.34em",
684
- fontSize: Math.max(0.62, Math.min(0.95, radius * 0.78)),
685
- fontWeight: "700",
686
- fontFamily: "inherit",
687
- textAnchor: "middle",
688
- pointerEvents: "none",
689
- }, cluster.venues.length) : null}
690
- </g>
691
- );
692
- })}
693
- </svg>
773
+ {sized ? (
774
+ <svg
775
+ viewBox={`0 0 ${props.width} ${plotHeight}`}
776
+ width="100%"
777
+ height="100%"
778
+ aria-hidden="true"
779
+ style={{ display: "block", background: colors.bg, pointerEvents: "none" }}
780
+ >
781
+ <BasemapLayer matrix={matrix} view={view} pxPerDegree={pxPerDegree} palette={palette} />
782
+ <CountryNames labels={placedLabels} project={project} unitPx={unitPx} color={palette.label} halo={colors.bg} />
783
+ {props.overlay ? (
784
+ <GeoSvgLayers
785
+ overlay={props.overlay}
786
+ matrix={matrix}
787
+ project={project}
788
+ view={view}
789
+ unitPx={unitPx}
790
+ mapWidthPx={surfaceSize?.width ?? props.width * 8}
791
+ background={colors.bg}
792
+ selectedColor={colors.selectedText}
793
+ textColor={colors.textBright}
794
+ tones={tones}
795
+ hovered={hovered?.hit ?? null}
796
+ />
797
+ ) : null}
798
+ {clusters.map((cluster) => {
799
+ const selected = isSelectedCluster(cluster, props.selectedMic);
800
+ const venue = clusterVenue(cluster, props.selectedMic);
801
+ const radius = venueDotRadius(cluster.venues.length);
802
+ return (
803
+ <g key={cluster.id}>
804
+ <title>{`${cluster.venues.length} venue${cluster.venues.length === 1 ? "" : "s"} near ${venue.city}: ${cluster.venues.map((item) => `${item.mic} ${item.name}`).join(", ")}`}</title>
805
+ <circle
806
+ cx={cluster.x}
807
+ cy={cluster.y}
808
+ r={radius + (selected ? 0.24 : 0)}
809
+ fill={cluster.isOpen ? colors.positive : colors.textMuted}
810
+ stroke={selected ? colors.selectedText : colors.bg}
811
+ strokeWidth={selected ? 0.35 : 0.16}
812
+ vectorEffect="non-scaling-stroke"
813
+ />
814
+ {cluster.venues.length > 1 ? createElement("text", {
815
+ x: cluster.x,
816
+ y: cluster.y,
817
+ fill: colors.bg,
818
+ dy: "0.34em",
819
+ fontSize: Math.max(0.62, Math.min(0.95, radius * 0.78)),
820
+ fontWeight: "700",
821
+ fontFamily: "inherit",
822
+ textAnchor: "middle",
823
+ pointerEvents: "none",
824
+ }, cluster.venues.length) : null}
825
+ </g>
826
+ );
827
+ })}
828
+ </svg>
829
+ ) : null}
694
830
  {tooltip && hovered ? (
695
831
  <MapTooltip
696
832
  title={tooltip.title}
@@ -700,7 +836,7 @@ function DesktopWorldVenueMap(props: WorldVenueMapProps) {
700
836
  width={surfaceSize?.width ?? 0}
701
837
  />
702
838
  ) : null}
703
- {props.overlay ? <GeoLegend overlay={props.overlay} venues={props.venues.length > 0} tones={tones} /> : null}
839
+ {props.overlay && sized ? <GeoLegend overlay={props.overlay} venues={props.venues.length > 0} tones={tones} /> : null}
704
840
  </div>
705
841
  </Box>
706
842
  );