gloomberb 0.16.0 → 0.16.1

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 (167) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/data.ts +18 -3
  3. package/src/api-client/index.ts +3 -0
  4. package/src/api-client/members.ts +26 -0
  5. package/src/api-client/perps.ts +4 -0
  6. package/src/api-client/types.ts +7 -1
  7. package/src/app/global-shortcuts.ts +14 -6
  8. package/src/app/keybindings/actions.ts +1 -1
  9. package/src/app/runtime/update.ts +38 -2
  10. package/src/app.tsx +10 -6
  11. package/src/cli/pane-functions/function-help.ts +9 -2
  12. package/src/components/command-bar/commands/collection.ts +5 -1
  13. package/src/components/command-bar/routes/root/command-items.ts +9 -2
  14. package/src/components/command-bar/routes/root/plugin-install.ts +2 -2
  15. package/src/components/command-bar/routes/root/provider-search.ts +19 -5
  16. package/src/components/command-bar/routes/root/runtime.ts +14 -5
  17. package/src/components/command-bar/routes/ticker-search/results.ts +31 -5
  18. package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
  19. package/src/components/command-bar/routing/actions.ts +2 -0
  20. package/src/components/command-bar/selection-runtime.ts +38 -0
  21. package/src/components/command-bar/surface/index.tsx +7 -2
  22. package/src/components/index.ts +2 -2
  23. package/src/components/layout/floating-pane.tsx +6 -0
  24. package/src/components/layout/header.tsx +24 -7
  25. package/src/components/layout/missing-pane.tsx +2 -2
  26. package/src/components/layout/pane/header.tsx +63 -30
  27. package/src/components/layout/pane/index.tsx +6 -0
  28. package/src/components/layout/pane/title.ts +31 -7
  29. package/src/components/layout/shell/drag/index.ts +6 -5
  30. package/src/components/layout/shell/drag/runtime.ts +3 -0
  31. package/src/components/layout/shell/index.tsx +6 -0
  32. package/src/components/layout/shell/pane/layers.tsx +15 -7
  33. package/src/components/layout/shell/terminal-pointer-runtime.ts +24 -3
  34. package/src/components/layout/shell/ticker-link-menu.ts +23 -9
  35. package/src/components/metric-treemap/heat-surface.tsx +718 -0
  36. package/src/components/metric-treemap/heat-tiles.ts +210 -0
  37. package/src/components/metric-treemap/index.tsx +2 -0
  38. package/src/components/metric-treemap/layout.ts +6 -0
  39. package/src/components/metric-treemap/scene.ts +432 -0
  40. package/src/components/pane-settings-dialog/field-dialogs.tsx +1 -0
  41. package/src/components/toggle-list.tsx +38 -12
  42. package/src/components/ui/country-flag-art.tsx +449 -0
  43. package/src/components/ui/country-flag.tsx +41 -0
  44. package/src/components/ui/data-table/types.ts +13 -0
  45. package/src/components/ui/icon.tsx +2 -0
  46. package/src/components/ui/index.ts +4 -0
  47. package/src/components/ui/multi-select/dialog.tsx +108 -22
  48. package/src/components/ui/range-track.tsx +46 -0
  49. package/src/components/ui/tabs.tsx +4 -0
  50. package/src/core/state/app/types.ts +10 -1
  51. package/src/i18n/es.ts +5 -3
  52. package/src/i18n/ja.ts +5 -3
  53. package/src/i18n/ko.ts +5 -3
  54. package/src/i18n/zh-cn.ts +5 -3
  55. package/src/i18n/zh-tw.ts +5 -3
  56. package/src/layout/ticker-navigation.ts +28 -2
  57. package/src/market-data/quotes/contributions.ts +6 -0
  58. package/src/market-data/quotes/freshness.ts +36 -0
  59. package/src/market-data/quotes/resolution.ts +22 -8
  60. package/src/market-data/quotes/time.ts +16 -0
  61. package/src/plugins/builtin/analytics/risk-client.ts +185 -59
  62. package/src/plugins/builtin/analytics/risk-headless.ts +55 -49
  63. package/src/plugins/builtin/analytics/risk-math.ts +33 -13
  64. package/src/plugins/builtin/analytics/risk-model.ts +250 -60
  65. package/src/plugins/builtin/analytics/risk-pane.tsx +61 -30
  66. package/src/plugins/builtin/analytics/risk-test-data.ts +182 -0
  67. package/src/plugins/builtin/buildout/cells.tsx +15 -12
  68. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  69. package/src/plugins/builtin/buildout/detail/index.tsx +0 -1
  70. package/src/plugins/builtin/buildout/detail/intel.tsx +6 -6
  71. package/src/plugins/builtin/buildout/detail/site.tsx +4 -7
  72. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -19
  73. package/src/plugins/builtin/catalysts/index.tsx +2 -2
  74. package/src/plugins/builtin/catalysts/pane.tsx +3 -1
  75. package/src/plugins/builtin/cds/index.tsx +1 -0
  76. package/src/plugins/builtin/debt-maturities/wall-bar.tsx +14 -41
  77. package/src/plugins/builtin/dividend-yield/client.ts +4 -2
  78. package/src/plugins/builtin/earnings/client.ts +2 -2
  79. package/src/plugins/builtin/earnings-calls/index.tsx +1 -0
  80. package/src/plugins/builtin/fx-matrix/direction.ts +81 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +71 -33
  82. package/src/plugins/builtin/fx-matrix/live-legs.ts +17 -0
  83. package/src/plugins/builtin/fx-matrix/pairs.ts +68 -14
  84. package/src/plugins/builtin/fx-matrix/settings.ts +62 -0
  85. package/src/plugins/builtin/jobs/index.tsx +1 -0
  86. package/src/plugins/builtin/jobs/share-bars.tsx +5 -28
  87. package/src/plugins/builtin/market-heatmap/data.ts +28 -2
  88. package/src/plugins/builtin/market-heatmap/index.tsx +250 -79
  89. package/src/plugins/builtin/market-heatmap/live.ts +88 -0
  90. package/src/plugins/builtin/market-heatmap/portfolio.ts +27 -7
  91. package/src/plugins/builtin/market-movers/client.ts +3 -2
  92. package/src/plugins/builtin/market-movers/headless.ts +8 -1
  93. package/src/plugins/builtin/market-movers/screener.ts +18 -5
  94. package/src/plugins/builtin/members/client.ts +36 -0
  95. package/src/plugins/builtin/members/headless.ts +33 -0
  96. package/src/plugins/builtin/members/index.tsx +20 -0
  97. package/src/plugins/builtin/members/model.ts +60 -0
  98. package/src/plugins/builtin/members/pane.tsx +168 -0
  99. package/src/plugins/builtin/mna/index.tsx +2 -1
  100. package/src/plugins/builtin/mna/pane.tsx +5 -2
  101. package/src/plugins/builtin/options/index.tsx +2 -1
  102. package/src/plugins/builtin/pe-band/client.ts +29 -2
  103. package/src/plugins/builtin/pe-band/headless.ts +2 -2
  104. package/src/plugins/builtin/pe-band/model.ts +11 -7
  105. package/src/plugins/builtin/pe-band/pane.tsx +2 -1
  106. package/src/plugins/builtin/pe-band/report-dates.ts +78 -0
  107. package/src/plugins/builtin/perps/client.ts +17 -3
  108. package/src/plugins/builtin/perps/equity-identity.ts +16 -0
  109. package/src/plugins/builtin/perps/equity-row.tsx +6 -4
  110. package/src/plugins/builtin/plugin-marketplace/activation.ts +19 -0
  111. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -2
  112. package/src/plugins/builtin/plugin-marketplace/store.ts +2 -0
  113. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +43 -2
  114. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +48 -5
  115. package/src/plugins/builtin/scanner/hilo-bars.tsx +12 -70
  116. package/src/plugins/builtin/scanner/hilo-model.ts +0 -18
  117. package/src/plugins/builtin/sectors/move-bar.tsx +10 -32
  118. package/src/plugins/builtin/social-mentions/index.tsx +1 -1
  119. package/src/plugins/builtin/thirteenf/index.tsx +2 -1
  120. package/src/plugins/builtin/ticker-detail/overview/components.tsx +5 -60
  121. package/src/plugins/builtin/ticker-detail/overview/pane.tsx +1 -1
  122. package/src/plugins/builtin/ticker-detail/pane.tsx +1 -1
  123. package/src/plugins/builtin/ticker-detail/quote-footer.ts +7 -1
  124. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +20 -4
  125. package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +2 -2
  126. package/src/plugins/builtin/vol-surface/index.tsx +1 -1
  127. package/src/plugins/catalog-browser.ts +2 -0
  128. package/src/plugins/catalog-manifest.ts +1 -0
  129. package/src/plugins/catalog-ui.ts +3 -1
  130. package/src/plugins/catalog.ts +1 -1
  131. package/src/plugins/host-link.ts +33 -1
  132. package/src/plugins/installer.ts +48 -12
  133. package/src/plugins/loader.ts +18 -3
  134. package/src/plugins/manager-node.ts +15 -5
  135. package/src/plugins/registry/index.ts +1 -1
  136. package/src/plugins/registry/pane-settings.ts +25 -7
  137. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  138. package/src/renderers/dom/data-table/row.tsx +50 -16
  139. package/src/renderers/dom/desktop/icons.tsx +1 -0
  140. package/src/renderers/dom/host/box.tsx +69 -26
  141. package/src/renderers/dom/host/tabs.tsx +37 -1
  142. package/src/renderers/dom/styles.css +40 -5
  143. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +11 -6
  144. package/src/renderers/electrobun/bun/desktop/backend.ts +1 -0
  145. package/src/renderers/electrobun/bun/desktop/update.ts +181 -87
  146. package/src/renderers/electrobun/bun/index.ts +73 -6
  147. package/src/renderers/electrobun/bun/window/remembered-frame.ts +122 -0
  148. package/src/renderers/electrobun/shared/protocol.ts +3 -0
  149. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +2 -1
  150. package/src/renderers/electrobun/view/main.tsx +7 -1
  151. package/src/renderers/electrobun/view/renderer-window-error.ts +18 -0
  152. package/src/renderers/electrobun/view/update-host.ts +45 -21
  153. package/src/sources/gloomberb-cloud/normalizers.ts +9 -2
  154. package/src/sources/provider-router/financials.ts +3 -1
  155. package/src/telemetry/benign-window-error.ts +7 -0
  156. package/src/telemetry/crash-reports-dom.ts +2 -0
  157. package/src/test-support/daily-nav.ts +25 -0
  158. package/src/theme/heat-colors.ts +174 -1
  159. package/src/tickers/instrument-kind.ts +34 -7
  160. package/src/tickers/search/asset-classes.ts +24 -9
  161. package/src/tickers/search/index.ts +44 -11
  162. package/src/tickers/search/ranking.ts +23 -3
  163. package/src/types/financials.ts +2 -0
  164. package/src/types/plugin.ts +18 -5
  165. package/src/ui/context-menu.tsx +1 -0
  166. package/src/ui/host.tsx +2 -0
  167. package/src/updater/index.ts +29 -2
@@ -0,0 +1,210 @@
1
+ import type { MetricTreemapItem } from "./layout";
2
+
3
+ /**
4
+ * A heat map tile says as much as its size allows and no more: a big tile its
5
+ * ticker over the move, a small one the ticker, a tiny one only its colour.
6
+ */
7
+ export type HeatTileLabelTier = "full" | "ticker" | "none";
8
+
9
+ export interface HeatTileLabel {
10
+ tier: HeatTileLabelTier;
11
+ /** Font sizes in px, for the desktop and the web. */
12
+ tickerPx: number;
13
+ valuePx: number;
14
+ }
15
+
16
+ /** Advance of one monospace glyph, in em. */
17
+ const MONO_CHAR_EM = 0.62;
18
+ const TILE_PAD_X_PX = 3;
19
+ const TILE_PAD_Y_PX = 2;
20
+ const MAX_TICKER_PX = 26;
21
+ const MIN_TICKER_PX = 9;
22
+ /** Below this the move under the ticker is too small to read at a glance. */
23
+ const MIN_FULL_TICKER_PX = 11;
24
+ const MIN_VALUE_PX = 9;
25
+ const VALUE_SCALE = 0.7;
26
+ const LINE_HEIGHT = 1.12;
27
+ /** Text grows with the tile's area, not just its width, so a long thin tile stays calm. */
28
+ const AREA_FONT_DIVISOR = 4.4;
29
+
30
+ const NO_LABEL: HeatTileLabel = { tier: "none", tickerPx: 0, valuePx: 0 };
31
+
32
+ export function heatTileLabelPx(width: number, height: number, ticker: string, value: string | null): HeatTileLabel {
33
+ const innerWidth = width - TILE_PAD_X_PX * 2;
34
+ const innerHeight = height - TILE_PAD_Y_PX * 2;
35
+ if (innerWidth <= 0 || innerHeight <= 0 || ticker.length === 0) return NO_LABEL;
36
+ const byWidth = innerWidth / (ticker.length * MONO_CHAR_EM);
37
+ // Area caps big tiles only; a small one may still use the smallest readable size.
38
+ const byArea = Math.max(MIN_FULL_TICKER_PX, Math.sqrt(width * height) / AREA_FONT_DIVISOR);
39
+ const cap = Math.min(MAX_TICKER_PX, byWidth, byArea);
40
+
41
+ if (value) {
42
+ const valueByWidth = innerWidth / (value.length * MONO_CHAR_EM * VALUE_SCALE);
43
+ const byHeight = innerHeight / (LINE_HEIGHT * (1 + VALUE_SCALE));
44
+ const tickerPx = Math.min(cap, valueByWidth, byHeight);
45
+ const valuePx = Math.max(MIN_VALUE_PX, tickerPx * VALUE_SCALE);
46
+ if (
47
+ tickerPx >= MIN_FULL_TICKER_PX
48
+ && value.length * MONO_CHAR_EM * valuePx <= innerWidth
49
+ && (tickerPx + valuePx) * LINE_HEIGHT <= innerHeight
50
+ ) {
51
+ return { tier: "full", tickerPx, valuePx };
52
+ }
53
+ }
54
+
55
+ const tickerPx = Math.min(cap, innerHeight / LINE_HEIGHT);
56
+ if (tickerPx >= MIN_TICKER_PX) return { tier: "ticker", tickerPx, valuePx: 0 };
57
+ return NO_LABEL;
58
+ }
59
+
60
+ /** The same tiers on a cell grid: whole words or nothing, never a clipped ticker. */
61
+ export function heatTileLabelCells(width: number, height: number, ticker: string, value: string | null): HeatTileLabelTier {
62
+ if (width < ticker.length || height < 1 || ticker.length === 0) return "none";
63
+ if (value && height >= 2 && width >= value.length) return "full";
64
+ return "ticker";
65
+ }
66
+
67
+ /** "+2.7%": one decimal on the tile; the footer and the tooltip carry two. */
68
+ export function formatHeatTileMove(changePercent: number | null | undefined): string | null {
69
+ if (changePercent == null || !Number.isFinite(changePercent)) return null;
70
+ const fixed = Math.abs(changePercent).toFixed(1);
71
+ if (!/[1-9]/.test(fixed)) return `${fixed}%`;
72
+ return `${changePercent > 0 ? "+" : "-"}${fixed}%`;
73
+ }
74
+
75
+ /**
76
+ * Keeps the previous layout input while no weight moved by more than
77
+ * `tolerance` (relative) and the names and groups are the same, so a stream
78
+ * of ticks does not relayout the board. A real change returns `next`.
79
+ */
80
+ export function settleTreemapLayoutItems<T>(
81
+ previous: readonly MetricTreemapItem<T>[] | null,
82
+ next: readonly MetricTreemapItem<T>[],
83
+ tolerance: number,
84
+ ): readonly MetricTreemapItem<T>[] {
85
+ if (!previous || previous.length !== next.length) return next;
86
+ for (let index = 0; index < next.length; index += 1) {
87
+ const before = previous[index]!;
88
+ const after = next[index]!;
89
+ if (before.id !== after.id || before.group !== after.group || before.subgroup !== after.subgroup) return next;
90
+ const left = before.weight ?? 0;
91
+ const right = after.weight ?? 0;
92
+ if (left === right) continue;
93
+ if (!(left > 0) || Math.abs(right - left) / left > tolerance) return next;
94
+ }
95
+ return previous;
96
+ }
97
+
98
+ export interface HeatPulseOptions {
99
+ /** A tile pulses when its move has travelled this many percentage points since its last pulse. */
100
+ threshold: number;
101
+ /** And not again before this long. */
102
+ minIntervalMs: number;
103
+ /** Across the whole board, the largest changes first, so a busy tape or a snapshot refresh never flashes. */
104
+ maxPerSecond: number;
105
+ }
106
+
107
+ const HEAT_PULSE_OPTIONS: HeatPulseOptions = {
108
+ threshold: 0.2,
109
+ minIntervalMs: 4_000,
110
+ maxPerSecond: 6,
111
+ };
112
+
113
+ interface PulseState {
114
+ base: number;
115
+ at: number;
116
+ count: number;
117
+ }
118
+
119
+ /**
120
+ * Decides which tiles pulse as their moves change. `update` returns each
121
+ * tile's pulse count: a tile whose count went up pulses once (the desktop
122
+ * alternates two keyframe names on parity, which restarts the animation). The
123
+ * first value a tile shows is its baseline and never pulses.
124
+ */
125
+ export class HeatPulseTracker {
126
+ private readonly states = new Map<string, PulseState>();
127
+ private readonly counts = new Map<string, number>();
128
+ private budget: number;
129
+ private budgetAt: number | null = null;
130
+
131
+ constructor(private readonly options: HeatPulseOptions = HEAT_PULSE_OPTIONS) {
132
+ this.budget = options.maxPerSecond;
133
+ }
134
+
135
+ update(values: Iterable<readonly [string, number | null]>, now: number): ReadonlyMap<string, number> {
136
+ if (this.budgetAt != null) {
137
+ this.budget = Math.min(this.options.maxPerSecond, this.budget + (now - this.budgetAt) / 1000 * this.options.maxPerSecond);
138
+ }
139
+ this.budgetAt = now;
140
+ const candidates: Array<{ id: string; delta: number; value: number }> = [];
141
+ const seen = new Set<string>();
142
+ for (const [id, value] of values) {
143
+ seen.add(id);
144
+ if (value == null || !Number.isFinite(value)) continue;
145
+ const state = this.states.get(id);
146
+ if (!state) {
147
+ this.states.set(id, { base: value, at: now - this.options.minIntervalMs, count: 0 });
148
+ continue;
149
+ }
150
+ const delta = Math.abs(value - state.base);
151
+ if (delta >= this.options.threshold && now - state.at >= this.options.minIntervalMs) {
152
+ candidates.push({ id, delta, value });
153
+ }
154
+ }
155
+ for (const id of this.states.keys()) {
156
+ if (!seen.has(id)) {
157
+ this.states.delete(id);
158
+ this.counts.delete(id);
159
+ }
160
+ }
161
+ candidates.sort((left, right) => right.delta - left.delta);
162
+ const allowed = Math.floor(this.budget);
163
+ for (const candidate of candidates.slice(0, allowed)) {
164
+ const state = this.states.get(candidate.id)!;
165
+ state.base = candidate.value;
166
+ state.at = now;
167
+ state.count += 1;
168
+ this.counts.set(candidate.id, state.count);
169
+ }
170
+ this.budget -= Math.min(allowed, candidates.length);
171
+ return this.counts;
172
+ }
173
+ }
174
+
175
+ const GLIDE_MS = 500;
176
+ const FADE_MS = 450;
177
+ const PULSE_MS = 700;
178
+
179
+ export interface HeatTileMotion {
180
+ /** On the tile, after a relayout: slide from where it was (set `--heat-glide-x/y`). */
181
+ glideAnimation?: string;
182
+ /** On an overlay in the previous colour, fading out over the new one. */
183
+ fadeAnimation?: string;
184
+ /** On a light overlay that brightens the tile once. */
185
+ pulseAnimation?: string;
186
+ }
187
+
188
+ /**
189
+ * How a desktop tile moves. Every animation is a transform or an opacity, so
190
+ * the compositor runs it without a main-thread frame: a relayout slides each
191
+ * tile from its old place (its new size applies at once), a colour change
192
+ * fades the old colour out over the new one, and a jump in the move flashes
193
+ * once. `glideCount` alternates two keyframe names, which restarts the slide.
194
+ * Reduced motion keeps every change instant.
195
+ */
196
+ export function heatTileMotion({ reducedMotion, glide, glideCount, pulseCount }: {
197
+ reducedMotion: boolean;
198
+ glide: boolean;
199
+ glideCount: number;
200
+ pulseCount: number;
201
+ }): HeatTileMotion {
202
+ if (reducedMotion) return {};
203
+ return {
204
+ ...(glide && glideCount > 0
205
+ ? { glideAnimation: `gloom-heat-glide-${glideCount % 2 === 0 ? "b" : "a"} ${GLIDE_MS}ms ease` }
206
+ : {}),
207
+ fadeAnimation: `gloom-heat-fade ${FADE_MS}ms ease forwards`,
208
+ ...(pulseCount > 0 ? { pulseAnimation: `gloom-heat-pulse ${PULSE_MS}ms ease-out forwards` } : {}),
209
+ };
210
+ }
@@ -18,6 +18,8 @@ export {
18
18
  type MetricTreemapDirection,
19
19
  type MetricTreemapItem,
20
20
  } from "./layout";
21
+ export { buildHeatTreemapScene, heatTreemapCanvas, HeatTreemapSurface } from "./heat-surface";
22
+ export { formatHeatTileMove, settleTreemapLayoutItems } from "./heat-tiles";
21
23
 
22
24
  type PreventableMouseEvent = { preventDefault(): void };
23
25
  type PointerMoveEvent = { x?: number; y?: number; pixelX?: number; pixelY?: number };
@@ -6,6 +6,12 @@ export interface MetricTreemapItem<T = unknown> {
6
6
  primaryText?: string | null;
7
7
  secondaryText?: string | null;
8
8
  tertiaryText?: string | null;
9
+ /** Optional hierarchy for the heat map layout (a sector); the flat layouts ignore it. */
10
+ group?: string | null;
11
+ /** Second level inside `group` (an industry). */
12
+ subgroup?: string | null;
13
+ /** Hover lines on the desktop heat map, the first one emphasised. */
14
+ tooltip?: readonly string[];
9
15
  data: T;
10
16
  }
11
17
 
@@ -0,0 +1,432 @@
1
+ import type { FloatMetricTreemapTile, MetricTreemapItem } from "./layout";
2
+
3
+ /**
4
+ * A treemap with an optional two-level hierarchy (sector, then industry) and
5
+ * an "Other" block for names too small to draw. Items without a `group` lay
6
+ * out flat. Every block is squarified, so the canvas is always filled; a
7
+ * header band is reserved only where its block has room for it.
8
+ */
9
+
10
+ export interface TreemapRect {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ export interface MetricTreemapHeader {
18
+ id: string;
19
+ level: 0 | 1;
20
+ /** The group or subgroup key; empty for the Other block, which the surface names. */
21
+ label: string;
22
+ /** The Other block's header: names without a group and the folded tail. */
23
+ other?: boolean;
24
+ rect: TreemapRect;
25
+ }
26
+
27
+ interface MetricTreemapOtherBlock<T> {
28
+ /** The area of the folded names: the whole Other block, or its last part when it also draws tiles. */
29
+ rect: TreemapRect;
30
+ /** Names folded in, largest first. */
31
+ items: MetricTreemapItem<T>[];
32
+ /** Whether this rect names itself; false when the Other header above it already does. */
33
+ showLabel: boolean;
34
+ }
35
+
36
+ export interface MetricTreemapScene<T> {
37
+ width: number;
38
+ height: number;
39
+ tiles: FloatMetricTreemapTile<T>[];
40
+ headers: MetricTreemapHeader[];
41
+ other: MetricTreemapOtherBlock<T> | null;
42
+ }
43
+
44
+ interface BoxSize {
45
+ width: number;
46
+ height: number;
47
+ }
48
+
49
+ export interface MetricTreemapSceneOptions {
50
+ /** Snap every edge to whole units, for a cell grid. */
51
+ snap: boolean;
52
+ /** Height of one y unit in x units, so a square tile looks square. */
53
+ aspect: number;
54
+ /** Space kept around each group and subgroup, in layout units (the gutter between blocks is twice this). */
55
+ groupInset: number;
56
+ subgroupInset: number;
57
+ /** Header band height for groups and subgroups. */
58
+ headerHeight: readonly [number, number];
59
+ /**
60
+ * The smallest block that carries its header. A subgroup header also needs
61
+ * company: a one-name industry never gets one, its tile already says it all.
62
+ */
63
+ headerMin: readonly [BoxSize, BoxSize];
64
+ /** Smaller tiles fold into Other. */
65
+ minTile: BoxSize;
66
+ }
67
+
68
+ interface SubgroupNode<T> {
69
+ key: string | null;
70
+ weight: number;
71
+ items: Array<{ item: MetricTreemapItem<T>; weight: number }>;
72
+ }
73
+
74
+ interface GroupNode<T> {
75
+ key: string | null;
76
+ weight: number;
77
+ subgroups: SubgroupNode<T>[];
78
+ }
79
+
80
+ const MAX_LAYOUT_PASSES = 6;
81
+ /** Headers, gutters and squarify slack take part of the canvas; the first guess at what fits allows for it. */
82
+ const EXPECTED_AREA_EFFICIENCY = 0.72;
83
+ const EMPTY_RECT: TreemapRect = { x: 0, y: 0, width: 0, height: 0 };
84
+
85
+ function itemWeight(item: MetricTreemapItem): number {
86
+ const weight = item.weight ?? 0;
87
+ return Number.isFinite(weight) && weight > 0 ? weight : 0;
88
+ }
89
+
90
+ function worstRatio(sum: number, min: number, max: number, side: number): number {
91
+ const sideSquared = side * side;
92
+ const sumSquared = sum * sum;
93
+ return Math.max((sideSquared * max) / sumSquared, sumSquared / (sideSquared * min));
94
+ }
95
+
96
+ /**
97
+ * Squarified treemap of `weights`, in order, filling `rect`. Strips share
98
+ * edges computed from one running sum, so snapping a shared edge rounds it
99
+ * the same way for both neighbours and blocks never overlap or gap.
100
+ */
101
+ function squarifyRects(
102
+ weights: readonly number[],
103
+ rect: TreemapRect,
104
+ aspect: number,
105
+ snap: boolean,
106
+ ): TreemapRect[] {
107
+ const out: TreemapRect[] = [];
108
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
109
+ if (weights.length === 0) return out;
110
+ if (!(total > 0) || rect.width <= 0 || rect.height <= 0) {
111
+ return weights.map(() => ({ x: rect.x, y: rect.y, width: 0, height: 0 }));
112
+ }
113
+ const fullWidth = rect.width;
114
+ const fullHeight = rect.height * aspect;
115
+ const scale = (fullWidth * fullHeight) / total;
116
+ const local: TreemapRect[] = new Array(weights.length);
117
+ let x = 0;
118
+ let y = 0;
119
+ let index = 0;
120
+
121
+ while (index < weights.length) {
122
+ const width = fullWidth - x;
123
+ const height = fullHeight - y;
124
+ const side = Math.min(width, height);
125
+ let end = index + 1;
126
+ let sum = weights[index]! * scale;
127
+ let min = sum;
128
+ let max = sum;
129
+ let worst = worstRatio(sum, min, max, side);
130
+ while (end < weights.length) {
131
+ const area = weights[end]! * scale;
132
+ const nextSum = sum + area;
133
+ const nextMin = Math.min(min, area);
134
+ const nextMax = Math.max(max, area);
135
+ const nextWorst = worstRatio(nextSum, nextMin, nextMax, side);
136
+ if (nextWorst > worst) break;
137
+ sum = nextSum;
138
+ min = nextMin;
139
+ max = nextMax;
140
+ worst = nextWorst;
141
+ end += 1;
142
+ }
143
+ const last = end === weights.length;
144
+ if (width >= height) {
145
+ const stripWidth = last ? width : Math.min(width, sum / height);
146
+ let cursor = y;
147
+ for (let k = index; k < end; k += 1) {
148
+ const itemHeight = k === end - 1 ? fullHeight - cursor : (weights[k]! * scale) / stripWidth;
149
+ local[k] = { x, y: cursor, width: stripWidth, height: itemHeight };
150
+ cursor += itemHeight;
151
+ }
152
+ x += stripWidth;
153
+ } else {
154
+ const stripHeight = last ? height : Math.min(height, sum / width);
155
+ let cursor = x;
156
+ for (let k = index; k < end; k += 1) {
157
+ const itemWidth = k === end - 1 ? fullWidth - cursor : (weights[k]! * scale) / stripHeight;
158
+ local[k] = { x: cursor, y, width: itemWidth, height: stripHeight };
159
+ cursor += itemWidth;
160
+ }
161
+ y += stripHeight;
162
+ }
163
+ index = end;
164
+ }
165
+
166
+ for (const box of local) {
167
+ if (snap) {
168
+ const left = Math.round(box.x);
169
+ const right = Math.round(box.x + box.width);
170
+ const top = Math.round(box.y / aspect);
171
+ const bottom = Math.round((box.y + box.height) / aspect);
172
+ out.push({ x: rect.x + left, y: rect.y + top, width: right - left, height: bottom - top });
173
+ } else {
174
+ out.push({ x: rect.x + box.x, y: rect.y + box.y / aspect, width: box.width, height: box.height / aspect });
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function inset(rect: TreemapRect, amount: number): TreemapRect {
181
+ if (amount <= 0) return rect;
182
+ const dx = Math.min(amount, rect.width / 2);
183
+ const dy = Math.min(amount, rect.height / 2);
184
+ return { x: rect.x + dx, y: rect.y + dy, width: rect.width - dx * 2, height: rect.height - dy * 2 };
185
+ }
186
+
187
+ function fits(rect: TreemapRect, size: BoxSize): boolean {
188
+ return rect.width >= size.width && rect.height >= size.height;
189
+ }
190
+
191
+ function takeHeader(rect: TreemapRect, height: number): { band: TreemapRect; rest: TreemapRect } {
192
+ return {
193
+ band: { x: rect.x, y: rect.y, width: rect.width, height },
194
+ rest: { x: rect.x, y: rect.y + height, width: rect.width, height: Math.max(0, rect.height - height) },
195
+ };
196
+ }
197
+
198
+ function byWeight<T extends { weight: number }>(left: T, right: T): number {
199
+ return right.weight - left.weight;
200
+ }
201
+
202
+ /** Where each block and tile sat in the last layout, so a refresh keeps the board in place. */
203
+ type SceneRanks = ReadonlyMap<string, number>;
204
+
205
+ /**
206
+ * A block or tile overtakes the one before it only when it is this much
207
+ * larger. Without it two sectors of nearly equal size swap places on a
208
+ * refresh and every tile flies across the board.
209
+ */
210
+ const ORDER_HYSTERESIS = 1.06;
211
+
212
+ function groupRankKey(group: string | null): string {
213
+ return `g:${group ?? ""}`;
214
+ }
215
+
216
+ function subgroupRankKey(group: string | null, subgroup: string | null): string {
217
+ return `s:${group ?? ""}\u0000${subgroup ?? ""}`;
218
+ }
219
+
220
+ function sceneRanks(scene: MetricTreemapScene<unknown> | null | undefined): SceneRanks | null {
221
+ if (!scene || scene.tiles.length === 0) return null;
222
+ const ranks = new Map<string, number>();
223
+ scene.tiles.forEach((tile, index) => {
224
+ const group = tile.item.group ?? null;
225
+ const subgroup = tile.item.subgroup ?? null;
226
+ if (!ranks.has(groupRankKey(group))) ranks.set(groupRankKey(group), index);
227
+ if (!ranks.has(subgroupRankKey(group, subgroup))) ranks.set(subgroupRankKey(group, subgroup), index);
228
+ ranks.set(`i:${tile.item.id}`, index);
229
+ });
230
+ return ranks;
231
+ }
232
+
233
+ /**
234
+ * Largest first; with a previous layout, its order is kept unless a later
235
+ * entry outweighs an earlier one by the hysteresis. New entries find their
236
+ * place by weight.
237
+ */
238
+ function stickySort<N extends { weight: number }>(nodes: N[], keyOf: (node: N) => string, ranks: SceneRanks | null): N[] {
239
+ nodes.sort(byWeight);
240
+ if (!ranks) return nodes;
241
+ const rankOf = (node: N) => ranks.get(keyOf(node)) ?? Number.POSITIVE_INFINITY;
242
+ nodes.sort((left, right) => rankOf(left) - rankOf(right) || right.weight - left.weight);
243
+ for (let index = 1; index < nodes.length; index += 1) {
244
+ let cursor = index;
245
+ while (cursor > 0 && nodes[cursor]!.weight > nodes[cursor - 1]!.weight * ORDER_HYSTERESIS) {
246
+ [nodes[cursor - 1], nodes[cursor]] = [nodes[cursor]!, nodes[cursor - 1]!];
247
+ cursor -= 1;
248
+ }
249
+ }
250
+ return nodes;
251
+ }
252
+
253
+ function buildTree<T>(items: readonly MetricTreemapItem<T>[], ranks: SceneRanks | null): GroupNode<T>[] {
254
+ const groups = new Map<string | null, Map<string | null, SubgroupNode<T>>>();
255
+ for (const item of items) {
256
+ const weight = itemWeight(item);
257
+ const groupKey = item.group ?? null;
258
+ const subgroupKey = item.subgroup ?? null;
259
+ let subgroups = groups.get(groupKey);
260
+ if (!subgroups) {
261
+ subgroups = new Map();
262
+ groups.set(groupKey, subgroups);
263
+ }
264
+ let subgroup = subgroups.get(subgroupKey);
265
+ if (!subgroup) {
266
+ subgroup = { key: subgroupKey, weight: 0, items: [] };
267
+ subgroups.set(subgroupKey, subgroup);
268
+ }
269
+ subgroup.items.push({ item, weight });
270
+ subgroup.weight += weight;
271
+ }
272
+ const tree: GroupNode<T>[] = [];
273
+ for (const [key, subgroupMap] of groups) {
274
+ const subgroups = [...subgroupMap.values()];
275
+ for (const subgroup of subgroups) stickySort(subgroup.items, (entry) => `i:${entry.item.id}`, ranks);
276
+ stickySort(subgroups, (subgroup) => subgroupRankKey(key, subgroup.key), ranks);
277
+ tree.push({ key, weight: subgroups.reduce((sum, subgroup) => sum + subgroup.weight, 0), subgroups });
278
+ }
279
+ return stickySort(tree, (group) => groupRankKey(group.key), ranks);
280
+ }
281
+
282
+ function layoutPass<T>(
283
+ visible: readonly MetricTreemapItem<T>[],
284
+ folded: readonly MetricTreemapItem<T>[],
285
+ width: number,
286
+ height: number,
287
+ options: MetricTreemapSceneOptions,
288
+ ranks: SceneRanks | null,
289
+ ): MetricTreemapScene<T> {
290
+ const fullTree = buildTree(visible, ranks);
291
+ // Names without a group join the folded tail in one quiet Other block, laid
292
+ // out last so it lands bottom right. A board where nothing has a group is
293
+ // flat, not one big Other.
294
+ const grouped = fullTree.some((group) => group.key != null);
295
+ const unclassified = grouped ? fullTree.find((group) => group.key == null) ?? null : null;
296
+ const tree = unclassified ? fullTree.filter((group) => group !== unclassified) : fullTree;
297
+ const unclassifiedItems = unclassified
298
+ ? stickySort(unclassified.subgroups.flatMap((subgroup) => subgroup.items), (entry) => `i:${entry.item.id}`, ranks)
299
+ : [];
300
+ const foldedWeight = folded.reduce((sum, item) => sum + itemWeight(item), 0);
301
+ const otherWeight = foldedWeight + (unclassified?.weight ?? 0);
302
+ const blockWeights = [...tree.map((group) => group.weight), ...(otherWeight > 0 ? [otherWeight] : [])];
303
+ const blockRects = squarifyRects(blockWeights, { x: 0, y: 0, width, height }, options.aspect, options.snap);
304
+ const tiles: FloatMetricTreemapTile<T>[] = [];
305
+ const headers: MetricTreemapHeader[] = [];
306
+ const placeItems = (entries: Array<{ item: MetricTreemapItem<T>; weight: number }>, rect: TreemapRect) => {
307
+ const rects = squarifyRects(entries.map((entry) => entry.weight), rect, options.aspect, options.snap);
308
+ entries.forEach((entry, index) => {
309
+ const box = rects[index] ?? EMPTY_RECT;
310
+ tiles.push({ item: entry.item, x: box.x, y: box.y, width: box.width, height: box.height });
311
+ });
312
+ };
313
+
314
+ tree.forEach((group, groupIndex) => {
315
+ let content = inset(blockRects[groupIndex] ?? EMPTY_RECT, options.groupInset);
316
+ if (group.key != null && fits(content, options.headerMin[0])) {
317
+ const { band, rest } = takeHeader(content, options.headerHeight[0]);
318
+ headers.push({ id: `group:${group.key}`, level: 0, label: group.key, rect: band });
319
+ content = rest;
320
+ }
321
+ if (group.subgroups.length === 1) {
322
+ placeItems(group.subgroups[0]!.items, content);
323
+ return;
324
+ }
325
+ const subgroupRects = squarifyRects(group.subgroups.map((subgroup) => subgroup.weight), content, options.aspect, options.snap);
326
+ group.subgroups.forEach((subgroup, subgroupIndex) => {
327
+ let subContent = inset(subgroupRects[subgroupIndex] ?? EMPTY_RECT, options.subgroupInset);
328
+ // One industry in a sector says nothing its sector header does not, and
329
+ // a one-name industry says nothing its tile does not.
330
+ if (
331
+ subgroup.key != null
332
+ && subgroup.key !== group.key
333
+ && subgroup.items.length > 1
334
+ && fits(subContent, options.headerMin[1])
335
+ ) {
336
+ const { band, rest } = takeHeader(subContent, options.headerHeight[1]);
337
+ headers.push({ id: `subgroup:${group.key ?? ""}:${subgroup.key}`, level: 1, label: subgroup.key, rect: band });
338
+ subContent = rest;
339
+ }
340
+ placeItems(subgroup.items, subContent);
341
+ });
342
+ });
343
+
344
+ let other: MetricTreemapOtherBlock<T> | null = null;
345
+ if (otherWeight > 0) {
346
+ let rect = inset(blockRects[blockRects.length - 1] ?? EMPTY_RECT, options.groupInset);
347
+ const foldedItems = [...folded].sort((left, right) => itemWeight(right) - itemWeight(left));
348
+ if (unclassifiedItems.length > 0) {
349
+ const labelled = fits(rect, options.headerMin[0]);
350
+ if (labelled) {
351
+ const { band, rest } = takeHeader(rect, options.headerHeight[0]);
352
+ headers.push({ id: "other", level: 0, label: "", other: true, rect: band });
353
+ rect = rest;
354
+ }
355
+ const parts = squarifyRects(
356
+ [...unclassifiedItems.map((entry) => entry.weight), ...(foldedWeight > 0 ? [foldedWeight] : [])],
357
+ rect,
358
+ options.aspect,
359
+ options.snap,
360
+ );
361
+ unclassifiedItems.forEach((entry, index) => {
362
+ const box = parts[index] ?? EMPTY_RECT;
363
+ tiles.push({ item: entry.item, x: box.x, y: box.y, width: box.width, height: box.height });
364
+ });
365
+ if (foldedWeight > 0) {
366
+ other = { rect: parts[parts.length - 1] ?? EMPTY_RECT, items: foldedItems, showLabel: !labelled && fits(rect, options.headerMin[0]) };
367
+ }
368
+ } else {
369
+ other = { rect, items: foldedItems, showLabel: fits(rect, options.headerMin[0]) };
370
+ }
371
+ }
372
+ return { width, height, tiles, headers, other };
373
+ }
374
+
375
+ function tooSmall(tile: { width: number; height: number }, minTile: BoxSize): boolean {
376
+ return tile.width < minTile.width || tile.height < minTile.height;
377
+ }
378
+
379
+ /**
380
+ * Lays out `items` with their optional `group` / `subgroup` keys. A name whose
381
+ * tile would be smaller than `minTile` folds into Other, which is laid out
382
+ * last so it lands in the bottom-right corner; the layout reruns until every
383
+ * drawn tile fits. Given the scene on screen, blocks and tiles keep their
384
+ * order through small size changes, so a relayout moves them a little
385
+ * instead of reshuffling the board.
386
+ */
387
+ export function buildMetricTreemapScene<T>(
388
+ items: readonly MetricTreemapItem<T>[],
389
+ width: number,
390
+ height: number,
391
+ options: MetricTreemapSceneOptions,
392
+ previous?: MetricTreemapScene<T> | null,
393
+ ): MetricTreemapScene<T> {
394
+ const ranks = sceneRanks(previous);
395
+ const weighted = items.filter((item) => itemWeight(item) > 0);
396
+ if (width <= 0 || height <= 0 || weighted.length === 0) {
397
+ return { width, height, tiles: [], headers: [], other: null };
398
+ }
399
+ const total = weighted.reduce((sum, item) => sum + itemWeight(item), 0);
400
+ const canvasArea = width * height * EXPECTED_AREA_EFFICIENCY;
401
+ const minArea = options.minTile.width * options.minTile.height;
402
+ const folded = new Set<string>();
403
+ for (const item of weighted) {
404
+ if (itemWeight(item) / total * canvasArea < minArea) folded.add(item.id);
405
+ }
406
+ // Never fold everything: the largest name always gets a tile.
407
+ if (folded.size === weighted.length) {
408
+ const largest = weighted.reduce((best, item) => (itemWeight(item) > itemWeight(best) ? item : best));
409
+ folded.delete(largest.id);
410
+ }
411
+
412
+ let scene: MetricTreemapScene<T> | null = null;
413
+ for (let pass = 0; pass < MAX_LAYOUT_PASSES; pass += 1) {
414
+ const visible = weighted.filter((item) => !folded.has(item.id));
415
+ const hidden = weighted.filter((item) => folded.has(item.id));
416
+ scene = layoutPass(visible, hidden, width, height, options, ranks);
417
+ const small = scene.tiles.filter((tile) => tooSmall(tile, options.minTile));
418
+ if (small.length === 0 || small.length === scene.tiles.length) return scene;
419
+ for (const tile of small) folded.add(tile.item.id);
420
+ }
421
+ // Still crowded after the passes: drop what does not fit rather than draw slivers.
422
+ const finalScene = scene!;
423
+ const small = finalScene.tiles.filter((tile) => tooSmall(tile, options.minTile));
424
+ if (small.length === 0 || small.length === finalScene.tiles.length) return finalScene;
425
+ return {
426
+ ...finalScene,
427
+ tiles: finalScene.tiles.filter((tile) => !tooSmall(tile, options.minTile)),
428
+ other: finalScene.other
429
+ ? { ...finalScene.other, items: [...finalScene.other.items, ...small.map((tile) => tile.item)] }
430
+ : null,
431
+ };
432
+ }
@@ -208,6 +208,7 @@ export function MultiSelectFieldDialog({
208
208
  selectedValues={coerceSelectedPaneSettingValues(currentValue)}
209
209
  onChange={onApply}
210
210
  ordered={field.type === "ordered-multi-select"}
211
+ searchable={field.searchable}
211
212
  />
212
213
  );
213
214
  }