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.
- package/package.json +1 -1
- package/src/brokers/account-cache.ts +45 -0
- package/src/cli/command-options.ts +74 -0
- package/src/cli/commands/command-utils.ts +29 -0
- package/src/cli/commands/market.ts +170 -54
- package/src/cli/commands/overview.ts +62 -19
- package/src/cli/commands/ticker.ts +138 -43
- package/src/cli/desktop-pane-shot.ts +3 -0
- package/src/cli/help.ts +26 -6
- package/src/cli/history-rows.ts +187 -15
- package/src/cli/index.ts +12 -7
- package/src/cli/options.ts +5 -1
- package/src/cli/pane-functions/capabilities.ts +5 -0
- package/src/cli/pane-functions/dom.ts +69 -16
- package/src/cli/pane-functions/function-help.ts +4 -2
- package/src/cli/pane-functions/headless.ts +163 -31
- package/src/cli/pane-functions/index.ts +9 -2
- package/src/cli/pane-functions/report.ts +8 -1
- package/src/cli/pane-functions/resolver.ts +28 -2
- package/src/cli/pane-functions/screenshot.ts +4 -0
- package/src/cli/registry.ts +6 -3
- package/src/cli/report-tables.ts +460 -0
- package/src/cli/result.ts +13 -3
- package/src/components/data-table/export.ts +6 -51
- package/src/components/data-table/view.tsx +3 -1
- package/src/components/layout/shell/active-drag.ts +67 -35
- package/src/components/layout/shell/cursor-occlusion.ts +6 -5
- package/src/components/layout/shell/drag/index.ts +12 -7
- package/src/components/layout/shell/drag/live.ts +122 -0
- package/src/components/layout/shell/drag/overlays.tsx +179 -35
- package/src/components/layout/shell/drag/runtime.ts +62 -50
- package/src/components/layout/shell/drag/slide.ts +10 -0
- package/src/components/layout/shell/index.tsx +37 -51
- package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
- package/src/components/layout/shell/native/surfaces.ts +30 -14
- package/src/components/layout/shell/pane/layers.tsx +419 -179
- package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
- package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
- package/src/data/json-persistence.ts +17 -3
- package/src/data/json-storage.ts +2 -0
- package/src/data/memory-resource-store.ts +46 -0
- package/src/market-data/market/freshness.ts +45 -0
- package/src/market-data/quotes/freshness.ts +5 -0
- package/src/plugins/builtin/analytics/pane-model.ts +10 -6
- package/src/plugins/builtin/attention/pane.tsx +1 -1
- package/src/plugins/builtin/catalysts/detail.tsx +1 -1
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/holders/client.ts +3 -6
- package/src/plugins/builtin/holders/format.ts +31 -1
- package/src/plugins/builtin/holders/headless.ts +26 -5
- package/src/plugins/builtin/holders/pane.tsx +22 -13
- package/src/plugins/builtin/holders/report-header.ts +46 -5
- package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
- package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
- package/src/plugins/builtin/members/pane.tsx +10 -0
- package/src/plugins/builtin/perps/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
- package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
- package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
- package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
- package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
- package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
- package/src/plugins/builtin/shared/members-menu.tsx +260 -0
- package/src/plugins/builtin/short-volume/pane.tsx +1 -1
- package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
- package/src/plugins/builtin/themes/pane.tsx +3 -0
- package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
- package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
- package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
- package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
- package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
- package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
- package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
- package/src/plugins/builtin/world-venue-map/index.ts +5 -5
- package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
- package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
- package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
- package/src/plugins/builtin/world-venue-map/model.ts +16 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
- package/src/renderers/browser/app-services.ts +3 -1
- package/src/renderers/dom/data-table/dom.ts +31 -0
- package/src/renderers/dom/data-table/index.tsx +5 -0
- package/src/renderers/dom/host/box.tsx +113 -17
- package/src/renderers/dom/host/drag-shield.ts +24 -0
- package/src/renderers/dom/host/live-frame.ts +54 -0
- package/src/renderers/dom/host/mouse.ts +8 -1
- package/src/renderers/dom/host/scroll-box.tsx +6 -5
- package/src/renderers/dom/host/tabs.tsx +3 -2
- package/src/renderers/dom/styles.css +13 -5
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
- package/src/renderers/electrobun/shared/protocol.ts +10 -1
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
- package/src/renderers/electrobun/view/host/native.ts +0 -10
- package/src/renderers/electrobun/view/host/window-move.ts +28 -0
- package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
- package/src/renderers/electrobun/view/ui-host.tsx +2 -5
- package/src/renderers/opentui/ui-host.tsx +7 -1
- package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
- package/src/sources/provider-router/batches.ts +3 -3
- package/src/sources/provider-router/financial-routes.ts +2 -2
- package/src/sources/provider-router/financials.ts +70 -7
- package/src/sources/provider-router/primary.ts +2 -2
- package/src/state/pane-scroll-registry.tsx +67 -35
- package/src/test-support/margin-account.ts +108 -0
- package/src/time-series/resolution.ts +15 -0
- package/src/types/financials.ts +32 -3
- package/src/ui/host.tsx +12 -0
- package/src/ui/index.tsx +8 -3
- package/src/ui/scroll-box-axis.ts +26 -0
- package/src/utils/currency-units.ts +8 -3
- package/src/utils/depositary-receipt.ts +54 -10
- package/src/utils/display-number.ts +63 -0
- package/src/utils/fundamentals.ts +17 -1
- 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
|
|
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
|
|
67
|
-
description: "
|
|
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(
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
105
|
-
return { layers:
|
|
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
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
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>(
|
|
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
|
-
//
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
<
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
<
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
);
|