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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { ChoiceDialog, TextPromptDialog, usePaneMenuItems } from "../../../components";
|
|
2
|
+
import { createFormCollectionActions } from "../../../components/form-modal/deps";
|
|
3
|
+
import { t, tf } from "../../../i18n";
|
|
4
|
+
import { usePluginAppActions } from "../../../public/react";
|
|
5
|
+
import { useAppDispatch, useAppGetState, useAppSelector } from "../../../state/app/context";
|
|
6
|
+
import { formatTickerListInput, MAX_TICKER_LIST_SIZE } from "../../../tickers/list";
|
|
7
|
+
import type { TickerMetadata, TickerRecord } from "../../../types/ticker";
|
|
8
|
+
import { useOptionalDialog, type PromptContext } from "../../../ui/dialog";
|
|
9
|
+
import { canonicalExchange, publicTickerKey } from "../../../utils/exchanges";
|
|
10
|
+
import { slugifyName } from "../../../utils/slugify";
|
|
11
|
+
import { getSharedRegistry } from "../../registry";
|
|
12
|
+
import { MAX_CORRELATION_TICKERS } from "../correlation/settings";
|
|
13
|
+
import { ROTATION_LIMIT } from "../relative-rotation/model";
|
|
14
|
+
import { SHORT_WATCH_LIMIT } from "../short-interest/watch-model";
|
|
15
|
+
|
|
16
|
+
/** One US-listed member of a theme or fund, in the order its list shows it. */
|
|
17
|
+
export interface MemberListing {
|
|
18
|
+
symbol: string;
|
|
19
|
+
name?: string | null;
|
|
20
|
+
exchange?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The members a pane shows, and what to call them. */
|
|
24
|
+
export interface MemberList {
|
|
25
|
+
/** Names the members in dialog titles: a theme's name or a fund's ticker. */
|
|
26
|
+
title: string;
|
|
27
|
+
/** The name the save dialog suggests. */
|
|
28
|
+
watchlistName: string;
|
|
29
|
+
members: readonly MemberListing[];
|
|
30
|
+
/** A search narrowing the list: what was typed and how many members the whole list has. */
|
|
31
|
+
search?: { query: string; total: number } | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface MemberDestination {
|
|
35
|
+
templateId: string;
|
|
36
|
+
label: string;
|
|
37
|
+
/** The most symbols the destination takes; more would be refused or cut off there. */
|
|
38
|
+
limit: number;
|
|
39
|
+
minimum: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Functions that take a list of tickers through `options.symbols`. CORR and
|
|
44
|
+
* RIPL resolve the list the way the command bar does, which takes ten
|
|
45
|
+
* tickers; RIPL's own setting takes more, but not through a new pane.
|
|
46
|
+
*/
|
|
47
|
+
export const MEMBER_DESTINATIONS: readonly MemberDestination[] = [
|
|
48
|
+
{ templateId: "relative-rotation-rrg", label: "Relative Rotation (RRG)", limit: ROTATION_LIMIT, minimum: 1 },
|
|
49
|
+
{ templateId: "correlation-pane", label: "Correlation Matrix (CORR)", limit: MAX_CORRELATION_TICKERS, minimum: 2 },
|
|
50
|
+
{ templateId: "short-watch-pane", label: "Short Squeeze Watch (SIW)", limit: SHORT_WATCH_LIMIT, minimum: 1 },
|
|
51
|
+
{ templateId: "earnings-ripple-pane", label: "Earnings Ripple (RIPL)", limit: MAX_TICKER_LIST_SIZE, minimum: 1 },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A watchlist is a list to read and follow; a whole Russell 2000 stays in
|
|
56
|
+
* MEMB. Larger lists save their first 100 as listed.
|
|
57
|
+
*/
|
|
58
|
+
export const MEMBER_WATCHLIST_LIMIT = 100;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Distinct members in list order, as `SYMBOL:EXCHANGE` when the list names the
|
|
62
|
+
* listing: CORR and RIPL resolve each one, and a bare CCJ also matches Xetra.
|
|
63
|
+
*/
|
|
64
|
+
export function memberSymbols(members: readonly MemberListing[]): string[] {
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
return members.flatMap((member) => {
|
|
67
|
+
const symbol = member.symbol.trim().toUpperCase();
|
|
68
|
+
if (!symbol || seen.has(symbol)) return [];
|
|
69
|
+
seen.add(symbol);
|
|
70
|
+
const exchange = member.exchange?.trim().toUpperCase();
|
|
71
|
+
return [exchange ? `${symbol}:${exchange}` : symbol];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What each destination would open: the first `limit` symbols as listed, or
|
|
77
|
+
* disabled below its minimum. A disabled row cannot be selected, so its reason
|
|
78
|
+
* sits beside the label rather than in the description under the list.
|
|
79
|
+
*/
|
|
80
|
+
export function memberDestinationChoices(symbols: readonly string[], destinations: readonly MemberDestination[]) {
|
|
81
|
+
return destinations.map((destination) => {
|
|
82
|
+
const taken = symbols.slice(0, destination.limit);
|
|
83
|
+
const tooFew = taken.length < destination.minimum;
|
|
84
|
+
return {
|
|
85
|
+
id: destination.templateId,
|
|
86
|
+
label: destination.label,
|
|
87
|
+
symbols: taken,
|
|
88
|
+
disabled: tooFew,
|
|
89
|
+
detail: tooFew ? tf("needs {count}", { count: destination.minimum }) : undefined,
|
|
90
|
+
description: tooFew ? undefined
|
|
91
|
+
: taken.length < symbols.length ? tf("First {count} of {total} as listed", { count: taken.length, total: symbols.length })
|
|
92
|
+
: tf("All {count}", { count: taken.length }),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The dialog text for `count` distinct members. A search says what it matched
|
|
99
|
+
* and how many members the whole list has, so a saved or opened subset never
|
|
100
|
+
* reads as the whole fund.
|
|
101
|
+
*/
|
|
102
|
+
function memberListText(list: Pick<MemberList, "title" | "search">, count: number) {
|
|
103
|
+
const query = list.search?.query.trim();
|
|
104
|
+
const search = query ? { query, total: list.search!.total } : null;
|
|
105
|
+
const saved = Math.min(count, MEMBER_WATCHLIST_LIMIT);
|
|
106
|
+
return {
|
|
107
|
+
openTitle: search ? tf("Open {title} members matching \"{query}\" in", { title: list.title, query: search.query })
|
|
108
|
+
: tf("Open {title} members in", { title: list.title }),
|
|
109
|
+
saveBody: count > MEMBER_WATCHLIST_LIMIT
|
|
110
|
+
? search ? tf("The first {count} of the {matching} members matching \"{query}\", as listed.", { count: saved, matching: count, query: search.query })
|
|
111
|
+
: tf("The first {count} of {total} members, as listed.", { count: saved, total: count })
|
|
112
|
+
: search ? tf("{count} of {total} members match \"{query}\".", { count, total: search.total, query: search.query })
|
|
113
|
+
: tf("All {count} members.", { count }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The requested name, numbered when a watchlist already has that name or id. */
|
|
118
|
+
export function uniqueWatchlistName(requested: string, watchlists: readonly { id: string; name: string }[]): string {
|
|
119
|
+
const base = requested.trim();
|
|
120
|
+
const taken = (name: string) => watchlists.some((list) =>
|
|
121
|
+
list.name.toLowerCase() === name.toLowerCase() || list.id === slugifyName(name, "watchlist"));
|
|
122
|
+
let name = base;
|
|
123
|
+
for (let suffix = 2; taken(name); suffix += 1) name = `${base} ${suffix}`;
|
|
124
|
+
return name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Tickers to create on the watchlist and existing ones to add to it, first
|
|
129
|
+
* `MEMBER_WATCHLIST_LIMIT` members as listed. A saved record of the same
|
|
130
|
+
* symbol on another venue (CCJ on Xetra) stays as it is: the member's listing
|
|
131
|
+
* gets its own `SYMBOL:EXCHANGE` record, the way search opens a second listing.
|
|
132
|
+
*/
|
|
133
|
+
export function planMemberWatchlist(
|
|
134
|
+
tickers: ReadonlyMap<string, TickerRecord>,
|
|
135
|
+
watchlistId: string,
|
|
136
|
+
members: readonly MemberListing[],
|
|
137
|
+
): { create: TickerMetadata[]; update: TickerRecord[]; count: number } {
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
const create: TickerMetadata[] = [];
|
|
140
|
+
const update: TickerRecord[] = [];
|
|
141
|
+
for (const member of members) {
|
|
142
|
+
const symbol = member.symbol.trim().toUpperCase();
|
|
143
|
+
if (!symbol || seen.has(symbol)) continue;
|
|
144
|
+
if (seen.size >= MEMBER_WATCHLIST_LIMIT) break;
|
|
145
|
+
seen.add(symbol);
|
|
146
|
+
const exchange = member.exchange?.trim() ?? "";
|
|
147
|
+
let key = symbol;
|
|
148
|
+
let existing = tickers.get(symbol);
|
|
149
|
+
if (existing && exchange && existing.metadata.exchange
|
|
150
|
+
&& canonicalExchange(existing.metadata.exchange) !== canonicalExchange(exchange)) {
|
|
151
|
+
key = publicTickerKey(symbol, exchange);
|
|
152
|
+
existing = tickers.get(key);
|
|
153
|
+
}
|
|
154
|
+
if (existing) {
|
|
155
|
+
if (!existing.metadata.watchlists.includes(watchlistId)) {
|
|
156
|
+
update.push({ ...existing, metadata: { ...existing.metadata, watchlists: [...existing.metadata.watchlists, watchlistId] } });
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Theme and fund members are US listings quoted in dollars. The sector is
|
|
161
|
+
// left to the ticker's profile, so a list never mixes two sector schemes.
|
|
162
|
+
create.push({ ticker: key, name: member.name || symbol, exchange, currency: "USD",
|
|
163
|
+
portfolios: [], watchlists: [watchlistId], positions: [], custom: {}, tags: [] });
|
|
164
|
+
}
|
|
165
|
+
return { create, update, count: seen.size };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pane menu entries for a list of members (a theme's, a fund's): open them in
|
|
170
|
+
* RRG, CORR, SIW or RIPL, or save them as a watchlist. Functions of disabled
|
|
171
|
+
* plugins are left out; each destination gets as many as it takes.
|
|
172
|
+
*/
|
|
173
|
+
export function useMembersMenu(registrationId: string, list: MemberList | null) {
|
|
174
|
+
const dialog = useOptionalDialog();
|
|
175
|
+
const { createPaneFromTemplate, notify } = usePluginAppActions();
|
|
176
|
+
const dispatch = useAppDispatch();
|
|
177
|
+
const getState = useAppGetState();
|
|
178
|
+
const disabledPlugins = useAppSelector((state) => state.config.disabledPlugins);
|
|
179
|
+
usePaneMenuItems(registrationId, () => {
|
|
180
|
+
const registry = getSharedRegistry();
|
|
181
|
+
const symbols = list ? memberSymbols(list.members) : [];
|
|
182
|
+
if (!list || !symbols.length || !dialog || !registry) return null;
|
|
183
|
+
const destinations = MEMBER_DESTINATIONS.filter((destination) => {
|
|
184
|
+
const owner = registry.getPaneTemplatePluginId?.(destination.templateId);
|
|
185
|
+
return registry.paneTemplates.has(destination.templateId) && (!owner || !disabledPlugins.includes(owner));
|
|
186
|
+
});
|
|
187
|
+
const choices = memberDestinationChoices(symbols, destinations);
|
|
188
|
+
const text = memberListText(list, symbols.length);
|
|
189
|
+
const openIn = () => {
|
|
190
|
+
void dialog.prompt<string>({
|
|
191
|
+
closeOnClickOutside: true,
|
|
192
|
+
content: (context: PromptContext<string>) => <ChoiceDialog {...context} title={text.openTitle}
|
|
193
|
+
choices={choices.map(({ id, label, description, detail, disabled }) => ({ id, label: t(label), description, detail, disabled }))} />,
|
|
194
|
+
}).then((templateId) => {
|
|
195
|
+
const choice = choices.find((item) => item.id === templateId && !item.disabled);
|
|
196
|
+
// CORR and RIPL resolve `arg` like the command bar; RRG and SIW read `symbols`.
|
|
197
|
+
if (choice) createPaneFromTemplate(choice.id, { symbols: choice.symbols, arg: formatTickerListInput(choice.symbols) });
|
|
198
|
+
}).catch(() => {});
|
|
199
|
+
};
|
|
200
|
+
const saveAsWatchlist = async (requested: string) => {
|
|
201
|
+
// The writes hold the app for about a second with a hundred names; let the dialog close on screen first.
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
203
|
+
const name = uniqueWatchlistName(requested, getState().config.watchlists);
|
|
204
|
+
await createFormCollectionActions({ dataProvider: registry.marketData, dispatch, getState, pluginRegistry: registry,
|
|
205
|
+
tickerRepository: registry.tickerRepository }, (body, options) => {
|
|
206
|
+
if (options?.type === "error") notify({ body, type: "error" });
|
|
207
|
+
}).createWatchlist(name);
|
|
208
|
+
const watchlist = getState().config.watchlists.find((entry) => entry.name === name);
|
|
209
|
+
if (!watchlist) throw new Error(t("The watchlist was not created."));
|
|
210
|
+
const plan = planMemberWatchlist(getState().tickers, watchlist.id, list.members);
|
|
211
|
+
// One ticker that fails to save leaves the others on the list; the toast says how many made it.
|
|
212
|
+
let failed = 0;
|
|
213
|
+
const created: TickerRecord[] = [];
|
|
214
|
+
const saved: TickerRecord[] = [];
|
|
215
|
+
for (const metadata of plan.create) {
|
|
216
|
+
try {
|
|
217
|
+
const ticker = await registry.tickerRepository.createTicker(metadata);
|
|
218
|
+
created.push(ticker);
|
|
219
|
+
saved.push(ticker);
|
|
220
|
+
} catch {
|
|
221
|
+
failed += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const ticker of plan.update) {
|
|
225
|
+
try {
|
|
226
|
+
await registry.tickerRepository.saveTicker(ticker);
|
|
227
|
+
saved.push(ticker);
|
|
228
|
+
} catch {
|
|
229
|
+
failed += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// One state update for the whole list: an update per ticker redraws every pane a hundred times.
|
|
233
|
+
if (saved.length) {
|
|
234
|
+
const tickers = new Map(getState().tickers);
|
|
235
|
+
for (const ticker of saved) tickers.set(ticker.metadata.ticker, ticker);
|
|
236
|
+
dispatch({ type: "SET_TICKERS", tickers });
|
|
237
|
+
}
|
|
238
|
+
for (const ticker of created) registry.events.emit("ticker:added", { symbol: ticker.metadata.ticker, ticker });
|
|
239
|
+
notify(failed
|
|
240
|
+
? { type: "error", body: tf("Saved {saved} of {count} members to {name}. {failed} could not be added.", { saved: plan.count - failed, count: plan.count, name, failed }) }
|
|
241
|
+
: { type: "success", body: tf("Saved {count} members to {name}.", { count: plan.count, name }) });
|
|
242
|
+
};
|
|
243
|
+
return [
|
|
244
|
+
...(choices.length ? [{ id: "members-open-in", label: t("Open Members In…"), onSelect: openIn }] : []),
|
|
245
|
+
{ id: "members-save-watchlist", label: t("Save as Watchlist…"), onSelect: () => {
|
|
246
|
+
// 54 columns fill the dialog; the prompt's default field is wider and runs past its frame.
|
|
247
|
+
void dialog.prompt<string>({
|
|
248
|
+
closeOnClickOutside: true,
|
|
249
|
+
content: (context: PromptContext<string>) => <TextPromptDialog {...context} title={t("Save as Watchlist")}
|
|
250
|
+
body={[text.saveBody]} label={t("Name")} initialValue={list.watchlistName} confirmLabel={t("Save")} width={54} />,
|
|
251
|
+
}).then((name) => {
|
|
252
|
+
if (!name?.trim()) return;
|
|
253
|
+
saveAsWatchlist(name).catch((error: unknown) => {
|
|
254
|
+
notify({ type: "error", body: error instanceof Error ? error.message : t("The watchlist was not saved.") });
|
|
255
|
+
});
|
|
256
|
+
}).catch(() => {});
|
|
257
|
+
} },
|
|
258
|
+
];
|
|
259
|
+
}, [createPaneFromTemplate, dialog, disabledPlugins, dispatch, getState, list, notify]);
|
|
260
|
+
}
|
|
@@ -27,7 +27,7 @@ function renderCell(row: ShortVolumeObservation, column: VolumeColumn, _index: n
|
|
|
27
27
|
function VolumeDetail({ row, width, height }: { row: ShortVolumeObservation; width: number; height: number }) {
|
|
28
28
|
const { nativePaneChrome } = useUiCapabilities();
|
|
29
29
|
// The stack title is the date, so the rows start with the figures.
|
|
30
|
-
return <ScrollBox width={width} height={nativePaneChrome ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
|
|
30
|
+
return <ScrollBox scrollY width={width} height={nativePaneChrome ? undefined : height} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
|
|
31
31
|
<KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Off-exchange short ratio" value={volumePercent(row.ratioPercent)} />
|
|
32
32
|
<KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Short shares" value={exactQuantity(row.shortVolume)} />
|
|
33
33
|
<KeyValueRow labelWidth={DETAIL_LABEL_WIDTH} label="Exempt shares" value={exactQuantity(row.shortExemptVolume)} detail="included in short shares" />
|
|
@@ -59,7 +59,7 @@ function DayDetail({ symbol, row, recent, width, height }: {
|
|
|
59
59
|
{ id: "ratio", label: "Vs median", value: socialRatio(row.ratio) },
|
|
60
60
|
{ id: "stance", label: "Stance", value: socialStance(row.stance), detail: stanceWord(row.stance) },
|
|
61
61
|
]} />
|
|
62
|
-
<ScrollBox width={width} height={nativePaneChrome ? undefined : Math.max(1, height - 3)} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
|
|
62
|
+
<ScrollBox scrollY width={width} height={nativePaneChrome ? undefined : Math.max(1, height - 3)} flexGrow={1} flexBasis={0} minHeight={0} contentOptions={{ paddingX: 1 }}>
|
|
63
63
|
<PaneStatusBody loading={posts.loading && !list.length} error={!list.length ? posts.error : null} subject="top posts"
|
|
64
64
|
empty={!posts.loading && !list.length} emptyTitle="No top posts for this day.">
|
|
65
65
|
<Box flexDirection="column" gap={1} width={lineWidth}>
|
|
@@ -8,6 +8,7 @@ import { priceColor } from "../../../theme/colors";
|
|
|
8
8
|
import { useThemeColors } from "../../../theme/theme-context";
|
|
9
9
|
import { Box, Text } from "../../../ui";
|
|
10
10
|
import { nextHeaderSort } from "../../../utils/sort-values";
|
|
11
|
+
import { useMembersMenu } from "../shared/members-menu";
|
|
11
12
|
import { cachedMembers, cachedThemes, loadMembers, loadThemes } from "./client";
|
|
12
13
|
import { aggregateText, coverageWidth, DEFAULT_SORT, matchTheme, memberColumns, memberPrice, percent, sortMembers, sortThemes, themeColumns, type ThemeSort } from "./model";
|
|
13
14
|
|
|
@@ -37,6 +38,8 @@ function Members({ id, width, height, focused }: { id: string; width: number; he
|
|
|
37
38
|
useAutoRefresh(resource.updatedAt, resource.load);
|
|
38
39
|
usePaneRefreshKey(() => void resource.reload(), { focused });
|
|
39
40
|
useSnapshotFooter("themes:members", resource, focused);
|
|
41
|
+
const memberList = useMemo(() => data ? { title: data.theme.name, watchlistName: data.theme.name, members: rows } : null, [data, rows]);
|
|
42
|
+
useMembersMenu("themes:members-menu", memberList);
|
|
40
43
|
const renderCell = useCallback((row: ThemeMember, column: DataTableColumn): DataTableCell => {
|
|
41
44
|
if (column.id === "symbol") return { text: row.symbol, color: colors.textBright };
|
|
42
45
|
if (column.id === "name") return { text: row.name ?? "--", color: colors.text };
|
|
@@ -166,10 +166,14 @@ function aggregateQuarterlyStatements(
|
|
|
166
166
|
}
|
|
167
167
|
const currencies = new Set(statements.map((statement) => statement.currency));
|
|
168
168
|
if (currencies.size > 1) return null;
|
|
169
|
+
// Its counts and EPS are on the quarters' share basis when all four state the same one.
|
|
170
|
+
const shareBases = new Set(statements.map((statement) => statement.shareBasis));
|
|
171
|
+
const shareBasis = shareBases.size === 1 ? [...shareBases][0] : undefined;
|
|
169
172
|
|
|
170
173
|
const aggregate: FinancialTableStatement = {
|
|
171
174
|
date,
|
|
172
175
|
currency: [...currencies][0],
|
|
176
|
+
...(shareBasis ? { shareBasis } : {}),
|
|
173
177
|
aggregation: {
|
|
174
178
|
kind: "trailing-four-quarters",
|
|
175
179
|
periodEnd: statements.at(-1)!.date,
|
|
@@ -107,7 +107,13 @@ const SHARE_COUNT_FIELDS = new Set<keyof FinancialStatement>([
|
|
|
107
107
|
"basicShares", "dilutedShares", "shareIssued", "ordinarySharesNumber", "treasurySharesNumber",
|
|
108
108
|
]);
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
const PER_SHARE_EARNINGS_FIELDS = new Set<keyof FinancialStatement>(["eps", "basicEps"]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Currency changes affect monetary comparisons, not counts of reported
|
|
114
|
+
* shares. Counts and EPS compare only on one share basis: receipts against
|
|
115
|
+
* ordinary shares would read as a split.
|
|
116
|
+
*/
|
|
111
117
|
export function canCompareFinancialRow(
|
|
112
118
|
row: FinancialTableRow,
|
|
113
119
|
current: FinancialStatement,
|
|
@@ -116,7 +122,9 @@ export function canCompareFinancialRow(
|
|
|
116
122
|
): boolean {
|
|
117
123
|
if (!previous) return false;
|
|
118
124
|
const key = row.kind === "group" ? row.summaryKey : row.key;
|
|
119
|
-
|
|
125
|
+
const sameShareBasis = current.shareBasis === previous.shareBasis;
|
|
126
|
+
if (key && SHARE_COUNT_FIELDS.has(key)) return sameShareBasis;
|
|
127
|
+
if (key && PER_SHARE_EARNINGS_FIELDS.has(key) && !sameShareBasis) return false;
|
|
120
128
|
const currentCurrency = (current.currency ?? financialCurrency)?.trim();
|
|
121
129
|
const previousCurrency = (previous.currency ?? financialCurrency)?.trim();
|
|
122
130
|
return !!currentCurrency && currentCurrency === previousCurrency;
|
|
@@ -269,6 +277,19 @@ export function financialStatementLimitations(financials: TickerFinancials | nul
|
|
|
269
277
|
return limitations;
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
/** "Ordinary Shares (bn ADRs)": a share-count line whose counts are depositary receipts. */
|
|
281
|
+
export function receiptShareCountLabel(row: Pick<FinancialTableModelRow, "key" | "label" | "unitLabel">): string {
|
|
282
|
+
if (!row.key || !SHARE_COUNT_FIELDS.has(row.key)) return row.unitLabel;
|
|
283
|
+
const unit = row.unitLabel.startsWith(`${row.label} (`) ? row.unitLabel.slice(row.label.length + 2, -1) : "";
|
|
284
|
+
return `${row.label} (${unit ? `${unit} ` : ""}ADRs)`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Whether a table row shows share counts or per-share earnings. */
|
|
288
|
+
export function isPerShareFinancialRow(row: Pick<FinancialTableModelRow, "key" | "summaryKey">): boolean {
|
|
289
|
+
const key = row.key ?? row.summaryKey;
|
|
290
|
+
return !!key && (SHARE_COUNT_FIELDS.has(key) || PER_SHARE_EARNINGS_FIELDS.has(key));
|
|
291
|
+
}
|
|
292
|
+
|
|
272
293
|
export function resolveFinancialPeriod(
|
|
273
294
|
requestedPeriod: FinancialPeriod,
|
|
274
295
|
hasAnnualStatements: boolean,
|
|
@@ -495,6 +516,7 @@ interface FinancialTableModelRow {
|
|
|
495
516
|
id: string;
|
|
496
517
|
key?: keyof FinancialStatement;
|
|
497
518
|
summaryKey?: keyof FinancialStatement;
|
|
519
|
+
label: string;
|
|
498
520
|
unitLabel: string;
|
|
499
521
|
depth: number;
|
|
500
522
|
growthDirection: FinancialGrowthDirection;
|
|
@@ -614,6 +636,7 @@ export function buildFinancialTableModel(
|
|
|
614
636
|
id: row.id,
|
|
615
637
|
key: row.kind === "metric" ? row.key : undefined,
|
|
616
638
|
summaryKey: row.kind === "group" ? row.summaryKey : undefined,
|
|
639
|
+
label: row.label,
|
|
617
640
|
unitLabel: row.unitLabel,
|
|
618
641
|
depth: row.depth,
|
|
619
642
|
growthDirection: row.growthDirection,
|
|
@@ -6,7 +6,11 @@ import type { TimeRange } from "../../../time-series/range";
|
|
|
6
6
|
import { formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
7
7
|
import { formatMarketPrice, formatMarketPriceWithCurrency, formatPriceObservation, withCurrencyMinorDigits, type MarketFormatOptions } from "../../../market-data/market/format";
|
|
8
8
|
import { pricePointValues, priceHistoryIntegrityNotice } from "../../../utils/price-history-integrity";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildFinancialTableModel, financialStatementCurrency, financialStatementDateNotice, financialStatementLimitations,
|
|
11
|
+
formatFinancialHeader, isPerShareFinancialRow, receiptShareCountLabel,
|
|
12
|
+
} from "./financials/model";
|
|
13
|
+
import { adrRatioText } from "../../../utils/depositary-receipt";
|
|
10
14
|
import { paneSchemas } from "./headless-schema";
|
|
11
15
|
import { loadPeriodEndHistory } from "./financials/period-end-history";
|
|
12
16
|
import {
|
|
@@ -52,8 +56,18 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
52
56
|
const statementCurrency = financialStatementCurrency(financials, [
|
|
53
57
|
...financials.annualStatements, ...financials.quarterlyStatements,
|
|
54
58
|
]);
|
|
55
|
-
|
|
59
|
+
// Gloom Cloud states when a period's share counts and EPS are on the receipts.
|
|
60
|
+
const receiptPeriods = table?.statements.filter(({ shareBasis }) => shareBasis === "depositary_receipt").map(({ date }) => date) ?? [];
|
|
61
|
+
const allReceipts = receiptPeriods.length > 0 && receiptPeriods.length === table?.statements.length;
|
|
62
|
+
const shareBases = new Set(table?.statements.map(({ shareBasis }) => shareBasis ?? null));
|
|
63
|
+
const ratio = adrRatioText(financials.fundamentals?.adrRatio ?? financials.quote?.adrRatio);
|
|
64
|
+
const shareBasisNotice = receiptPeriods.length === 0 || !table?.rows.some(isPerShareFinancialRow) ? null
|
|
65
|
+
: allReceipts
|
|
66
|
+
? `Share counts are in ADRs and EPS is per ADR${ratio ? `; ${ratio}` : ""}.`
|
|
67
|
+
: `Share counts are in ADRs and EPS is per ADR for ${receiptPeriods.join(", ")}${ratio ? ` (${ratio})` : ""}; other periods as reported.`;
|
|
68
|
+
const dates = table?.statements.map(({ date, currency, dateSource, providerDate, dateEvidence, availableAt, fieldAvailability, fieldSources, unavailableFields, epsBasis, unavailableEarnings, aggregation, shareBasis }) => ({
|
|
56
69
|
date, currency: currency ?? statementCurrency ?? null,
|
|
70
|
+
shareBasis: shareBasis ?? null,
|
|
57
71
|
availableAt: availableAt ?? null,
|
|
58
72
|
fieldAvailability: fieldAvailability ? { ...fieldAvailability } : null,
|
|
59
73
|
...(fieldSources ? { fieldSources } : {}),
|
|
@@ -68,7 +82,7 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
68
82
|
label: formatFinancialHeader(date, [currency ?? statementCurrency, table.unit].filter(Boolean).join(" ") || undefined, dateSource, false, aggregation?.periodEnd).trim(),
|
|
69
83
|
})) ?? [];
|
|
70
84
|
const rows = table?.rows.map((row) => ({
|
|
71
|
-
id: row.id, kind: row.kind, metric: row.unitLabel,
|
|
85
|
+
id: row.id, kind: row.kind, metric: allReceipts ? receiptShareCountLabel(row) : row.unitLabel,
|
|
72
86
|
cells: row.cells.map((cell, index) => ({
|
|
73
87
|
date: dates[index]?.date ?? "", value: cell.value ?? null,
|
|
74
88
|
growth: cell.growth ?? null, formatted: cell.valueText.trim(), growthFormatted: cell.growthText.trim(),
|
|
@@ -92,7 +106,9 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
|
|
|
92
106
|
symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null, unit: table?.unit ?? null, quoteCurrency: financials.quote?.currency ?? null,
|
|
93
107
|
statement: table?.subTab.key ?? options.statement, statementLabel: table?.subTab.name ?? null,
|
|
94
108
|
period: requestedPeriod, growthBasis: requestedPeriod === "quarterly" ? "QoQ" : "YoY", columns: dates,
|
|
95
|
-
|
|
109
|
+
shareBasis: shareBases.size === 1 ? [...shareBases][0] ?? null : null,
|
|
110
|
+
...(receiptPeriods.length > 0 ? { adrRatio: financials.fundamentals?.adrRatio ?? financials.quote?.adrRatio ?? null } : {}),
|
|
111
|
+
notices: rows.length ? [FINANCIAL_VINTAGE_NOTICE, ...(shareBasisNotice ? [shareBasisNotice] : [])] : [],
|
|
96
112
|
limitations: financialStatementLimitations(financials),
|
|
97
113
|
dateProvenance: financialStatementDateNotice(table?.statements ?? []),
|
|
98
114
|
},
|
|
@@ -22,6 +22,7 @@ import type { OverviewFunctionLink, PositionTableRow, StatField } from "./types"
|
|
|
22
22
|
import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, resolvePortfolioMarketValue, resolvePortfolioPositionPnl, portfolioPnlPercent, signedPositionDirection } from "../../portfolio-list/position-metrics";
|
|
23
23
|
import { liveDividendYield, liveForwardPE, liveMarketCapitalization, liveTrailingPE } from "../../portfolio-list/live-valuation";
|
|
24
24
|
import { formatReportedMoney } from "../../../../utils/reported-money";
|
|
25
|
+
import { fundamentalsCurrency, reportedEnterpriseValue } from "../../../../utils/fundamentals";
|
|
25
26
|
import { formatShortDate } from "../../../../utils/datetime-format";
|
|
26
27
|
import { safeExternalUrl } from "../../../../utils/external-url";
|
|
27
28
|
|
|
@@ -68,6 +69,7 @@ function compactPositionAccount(position: TickerPosition): string {
|
|
|
68
69
|
export function buildOverviewStats({
|
|
69
70
|
quote,
|
|
70
71
|
fundamentals,
|
|
72
|
+
financialCurrency,
|
|
71
73
|
quoteCurrency,
|
|
72
74
|
baseCurrency,
|
|
73
75
|
marketCapExchangeRates = new Map(),
|
|
@@ -77,18 +79,20 @@ export function buildOverviewStats({
|
|
|
77
79
|
}: {
|
|
78
80
|
quote: Quote | undefined;
|
|
79
81
|
fundamentals: TickerFinancials["fundamentals"] | undefined;
|
|
82
|
+
/** The currency the statements report in, for fundamentals that declare none. */
|
|
83
|
+
financialCurrency?: TickerFinancials["financialCurrency"];
|
|
80
84
|
quoteCurrency: string;
|
|
81
85
|
baseCurrency: string;
|
|
82
86
|
toBase: CurrencyConverter;
|
|
83
87
|
marketCapExchangeRates?: ReadonlyMap<string, number>;
|
|
84
88
|
nextEarnings?: NextEarnings;
|
|
85
|
-
/** The
|
|
89
|
+
/** The share count is in depositary receipts, not ordinary shares. */
|
|
86
90
|
depositaryReceipt?: boolean;
|
|
87
91
|
/** The listing's calendar day, YYYY-MM-DD; dates before it are left out. */
|
|
88
92
|
today?: string;
|
|
89
93
|
}): StatField[] {
|
|
90
94
|
const stats: StatField[] = [];
|
|
91
|
-
const money = (value: number, perShare = false) => formatReportedMoney(value, fundamentals
|
|
95
|
+
const money = (value: number, perShare = false) => formatReportedMoney(value, fundamentalsCurrency({ fundamentals, financialCurrency }), perShare);
|
|
92
96
|
|
|
93
97
|
if (quote?.volume != null) {
|
|
94
98
|
// Live figures keep their decimals (12.30M, not 12.3M) so the digits hold still.
|
|
@@ -178,10 +182,11 @@ export function buildOverviewStats({
|
|
|
178
182
|
valueColor: priceColor(fundamentals.revenueGrowth),
|
|
179
183
|
});
|
|
180
184
|
}
|
|
185
|
+
const enterpriseValue = reportedEnterpriseValue(fundamentals);
|
|
181
186
|
if (fundamentals?.unavailableFields?.includes("enterpriseValue")) {
|
|
182
187
|
stats.push({ label: "EV", value: "—" });
|
|
183
|
-
} else if (
|
|
184
|
-
stats.push({ label: "EV", value: formatCompactCurrency(
|
|
188
|
+
} else if (enterpriseValue != null) {
|
|
189
|
+
stats.push({ label: "EV", value: formatCompactCurrency(enterpriseValue, quoteCurrency) });
|
|
185
190
|
}
|
|
186
191
|
if (finite(fundamentals?.beta)) {
|
|
187
192
|
stats.push({ label: "Beta", value: formatNumber(fundamentals.beta, 2), link: RELATIONSHIP_GRAPH });
|
|
@@ -26,7 +26,7 @@ import { Box, ScrollBox, Text, useUiCapabilities } from "../../../ui";
|
|
|
26
26
|
import { publicTickerKey, resolveExchangeTimeZone } from "../../../utils/exchanges";
|
|
27
27
|
import { convertCurrency, displayWidth, formatPercentRaw, truncateToDisplayWidth } from "../../../utils/format";
|
|
28
28
|
import { zonedDateKey } from "../../../utils/zoned-date-time";
|
|
29
|
-
import {
|
|
29
|
+
import { sharesOutstandingInReceipts } from "../../../utils/depositary-receipt";
|
|
30
30
|
import {
|
|
31
31
|
CompactRangeBar,
|
|
32
32
|
FundamentalsGrid,
|
|
@@ -189,12 +189,13 @@ function ResolvedOverviewTab({ width, focused = false, ticker, financials, onOpe
|
|
|
189
189
|
const stats = buildOverviewStats({
|
|
190
190
|
quote,
|
|
191
191
|
fundamentals,
|
|
192
|
+
financialCurrency: financials?.financialCurrency,
|
|
192
193
|
quoteCurrency,
|
|
193
194
|
baseCurrency,
|
|
194
195
|
toBase,
|
|
195
196
|
marketCapExchangeRates: exchangeRates,
|
|
196
197
|
nextEarnings: financials?.nextEarnings,
|
|
197
|
-
depositaryReceipt:
|
|
198
|
+
depositaryReceipt: sharesOutstandingInReceipts(quote, fundamentals, description),
|
|
198
199
|
// Report and ex-dividend dates are the listing's calendar days.
|
|
199
200
|
today: zonedDateKey(Date.now(), chartTimeZone ?? "America/New_York"),
|
|
200
201
|
});
|
|
@@ -138,9 +138,13 @@ export function tilesInView(tiles: readonly DetailTile[], view: GeoBox, margin =
|
|
|
138
138
|
return tiles.filter(({ box }) => box[0] < east && box[2] > west && box[1] < north && box[3] > south);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/** A country name at its label point, with the web-map zoom it first shows at and its rank. */
|
|
142
|
+
export type CountryLabel = readonly [name: string, longitude: number, latitude: number, minZoom: number, rank: number];
|
|
143
|
+
|
|
141
144
|
/** What `natural-earth/index.chunk.ts` exports. */
|
|
142
145
|
interface BasemapIndex {
|
|
143
146
|
loadWorld: () => Promise<BasemapChunk>;
|
|
147
|
+
loadLabels: () => Promise<{ COUNTRY_LABELS: readonly CountryLabel[] }>;
|
|
144
148
|
DETAIL_TILES: readonly DetailTile[];
|
|
145
149
|
}
|
|
146
150
|
|
|
@@ -175,6 +179,20 @@ export function loadWorldBasemap(): Promise<BasemapPaths> {
|
|
|
175
179
|
return worldPaths;
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
let countryLabels: Promise<readonly CountryLabel[]> | null = null;
|
|
183
|
+
|
|
184
|
+
/** Country names, most prominent first; a lazy chunk like the land. */
|
|
185
|
+
export function loadCountryLabels(): Promise<readonly CountryLabel[]> {
|
|
186
|
+
countryLabels ??= loadIndex()
|
|
187
|
+
.then((index) => index.loadLabels())
|
|
188
|
+
.then((chunk) => chunk.COUNTRY_LABELS)
|
|
189
|
+
.catch((error: unknown) => {
|
|
190
|
+
countryLabels = null;
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
return countryLabels;
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
export function loadDetailIndex(): Promise<readonly DetailTile[]> {
|
|
179
197
|
return loadIndex().then((index) => index.DETAIL_TILES);
|
|
180
198
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which country names the desktop and web map write, and where. Names come
|
|
3
|
+
* from Natural Earth's label points, most prominent first, each with the
|
|
4
|
+
* web-map zoom it first shows at. A name shows once the map is that close (a
|
|
5
|
+
* little before), sits wholly inside the map, and keeps clear of the names
|
|
6
|
+
* already placed and of the selected or hovered marker. Same view, same names.
|
|
7
|
+
*/
|
|
8
|
+
import type { CountryLabel } from "./basemap";
|
|
9
|
+
|
|
10
|
+
export interface PlacedCountryLabel {
|
|
11
|
+
name: string;
|
|
12
|
+
longitude: number;
|
|
13
|
+
latitude: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A marker the names keep clear of, in pixels from the map's top left. */
|
|
17
|
+
export interface LabelObstacle {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
radius: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const COUNTRY_LABEL_FONT_PX = 10;
|
|
24
|
+
/** The map's font is monospaced: every character advances this much of the font size. */
|
|
25
|
+
const CHARACTER_EM = 0.6;
|
|
26
|
+
/** Room kept around a name, so two never touch. */
|
|
27
|
+
const GAP_PX = 6;
|
|
28
|
+
/** Names show at Natural Earth's own zoom; this moves the threshold earlier (positive) or later. */
|
|
29
|
+
const ZOOM_LEAD = 0;
|
|
30
|
+
/** The largest countries are named on any world view, room permitting. */
|
|
31
|
+
const WORLD_VIEW_MIN_ZOOM = 1.7;
|
|
32
|
+
/** Never more names than this on one view. */
|
|
33
|
+
const MAX_LABELS = 60;
|
|
34
|
+
|
|
35
|
+
/** The web-map zoom (256 px tiles for 360 degrees) of a map drawn at this many pixels per degree. */
|
|
36
|
+
export function webMapZoom(pxPerDegree: number): number {
|
|
37
|
+
return Math.log2((Math.max(pxPerDegree, 1e-9) * 360) / 256);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function layoutCountryLabels(
|
|
41
|
+
labels: readonly CountryLabel[],
|
|
42
|
+
options: {
|
|
43
|
+
pxPerDegree: number;
|
|
44
|
+
/** Where a longitude and latitude fall, in pixels from the map's top left. */
|
|
45
|
+
toPixels: (longitude: number, latitude: number) => { x: number; y: number };
|
|
46
|
+
widthPx: number;
|
|
47
|
+
heightPx: number;
|
|
48
|
+
obstacles?: readonly LabelObstacle[];
|
|
49
|
+
fontPx?: number;
|
|
50
|
+
},
|
|
51
|
+
): PlacedCountryLabel[] {
|
|
52
|
+
const { toPixels, widthPx, heightPx, obstacles = [] } = options;
|
|
53
|
+
const fontPx = options.fontPx ?? COUNTRY_LABEL_FONT_PX;
|
|
54
|
+
if (!(widthPx > 0 && heightPx > 0 && options.pxPerDegree > 0)) return [];
|
|
55
|
+
const threshold = Math.max(webMapZoom(options.pxPerDegree) + ZOOM_LEAD, WORLD_VIEW_MIN_ZOOM);
|
|
56
|
+
const taken: [number, number, number, number][] = [];
|
|
57
|
+
const placed: PlacedCountryLabel[] = [];
|
|
58
|
+
for (const [name, longitude, latitude, minZoom] of labels) {
|
|
59
|
+
if (placed.length >= MAX_LABELS) break;
|
|
60
|
+
if (minZoom > threshold) continue;
|
|
61
|
+
const { x, y } = toPixels(longitude, latitude);
|
|
62
|
+
const halfWidth = (name.length * fontPx * CHARACTER_EM) / 2 + GAP_PX / 2;
|
|
63
|
+
const halfHeight = fontPx / 2 + GAP_PX / 4;
|
|
64
|
+
const box: [number, number, number, number] = [x - halfWidth, y - halfHeight, x + halfWidth, y + halfHeight];
|
|
65
|
+
if (box[0] < 0 || box[1] < 0 || box[2] > widthPx || box[3] > heightPx) continue;
|
|
66
|
+
if (taken.some((other) => box[0] < other[2] && box[2] > other[0] && box[1] < other[3] && box[3] > other[1])) continue;
|
|
67
|
+
if (obstacles.some((marker) => circleMeetsBox(marker, box))) continue;
|
|
68
|
+
taken.push(box);
|
|
69
|
+
placed.push({ name, longitude, latitude });
|
|
70
|
+
}
|
|
71
|
+
return placed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function circleMeetsBox(circle: LabelObstacle, box: readonly [number, number, number, number]): boolean {
|
|
75
|
+
const nearestX = Math.max(box[0], Math.min(circle.x, box[2]));
|
|
76
|
+
const nearestY = Math.max(box[1], Math.min(circle.y, box[3]));
|
|
77
|
+
return Math.hypot(circle.x - nearestX, circle.y - nearestY) < circle.radius;
|
|
78
|
+
}
|
|
@@ -357,7 +357,8 @@ export function GeoSvgLayers({ overlay, matrix, project, view, unitPx, mapWidthP
|
|
|
357
357
|
const selectedLayer = overlay.selected ? overlay.layers.find((layer) => layer.id === overlay.selected!.layerId) ?? null : null;
|
|
358
358
|
const selectedFeature = selectedLayer?.features.find((feature) => feature.id === overlay.selected!.id) ?? null;
|
|
359
359
|
const pxPerDegree = matrix.a * unitPx;
|
|
360
|
-
|
|
360
|
+
// A map with no scale draws nothing; never hand the icons a counter-scale of 1/epsilon.
|
|
361
|
+
const iconScale = { [ICON_SCALE_VAR]: pxPerDegree > 0 && Number.isFinite(pxPerDegree) ? 1 / pxPerDegree : 0 } as CSSProperties;
|
|
361
362
|
const trailPath = overlay.trail && overlay.trail.length > 1 ? degreePath([overlay.trail], false) : null;
|
|
362
363
|
const points = new Map(overlay.layers.filter((layer) => layer.geometry === "point").map((layer) => {
|
|
363
364
|
const landmark = layer.features.length > 0 && MAP_SYMBOLS[featureSymbol(layer.id, layer.features[0]!).id].landmark === true;
|