@tellegen/svelte 0.1.0

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 (121) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +70 -0
  3. package/dist/Sparkline.svelte +100 -0
  4. package/dist/Sparkline.svelte.d.ts +8 -0
  5. package/dist/Sparkline.svelte.d.ts.map +1 -0
  6. package/dist/TellegenMap.svelte +1430 -0
  7. package/dist/TellegenMap.svelte.d.ts +14 -0
  8. package/dist/TellegenMap.svelte.d.ts.map +1 -0
  9. package/dist/TellegenProvider.svelte +38 -0
  10. package/dist/TellegenProvider.svelte.d.ts +9 -0
  11. package/dist/TellegenProvider.svelte.d.ts.map +1 -0
  12. package/dist/TellegenShell.svelte +126 -0
  13. package/dist/TellegenShell.svelte.d.ts +4 -0
  14. package/dist/TellegenShell.svelte.d.ts.map +1 -0
  15. package/dist/TellegenViewer.svelte +19 -0
  16. package/dist/TellegenViewer.svelte.d.ts +4 -0
  17. package/dist/TellegenViewer.svelte.d.ts.map +1 -0
  18. package/dist/api.d.ts +44 -0
  19. package/dist/api.d.ts.map +1 -0
  20. package/dist/api.js +137 -0
  21. package/dist/colors.d.ts +51 -0
  22. package/dist/colors.d.ts.map +1 -0
  23. package/dist/colors.js +140 -0
  24. package/dist/components/AppFooter.svelte +73 -0
  25. package/dist/components/AppFooter.svelte.d.ts +19 -0
  26. package/dist/components/AppFooter.svelte.d.ts.map +1 -0
  27. package/dist/components/AppHeader.svelte +405 -0
  28. package/dist/components/AppHeader.svelte.d.ts +4 -0
  29. package/dist/components/AppHeader.svelte.d.ts.map +1 -0
  30. package/dist/components/BindingLines.svelte +101 -0
  31. package/dist/components/BindingLines.svelte.d.ts +4 -0
  32. package/dist/components/BindingLines.svelte.d.ts.map +1 -0
  33. package/dist/components/BusPicker.svelte +227 -0
  34. package/dist/components/BusPicker.svelte.d.ts +4 -0
  35. package/dist/components/BusPicker.svelte.d.ts.map +1 -0
  36. package/dist/components/ControlPanel.svelte +154 -0
  37. package/dist/components/ControlPanel.svelte.d.ts +19 -0
  38. package/dist/components/ControlPanel.svelte.d.ts.map +1 -0
  39. package/dist/components/DemandSlider.svelte +271 -0
  40. package/dist/components/DemandSlider.svelte.d.ts +4 -0
  41. package/dist/components/DemandSlider.svelte.d.ts.map +1 -0
  42. package/dist/components/DisplayControls.svelte +81 -0
  43. package/dist/components/DisplayControls.svelte.d.ts +19 -0
  44. package/dist/components/DisplayControls.svelte.d.ts.map +1 -0
  45. package/dist/components/DropZone.svelte +46 -0
  46. package/dist/components/DropZone.svelte.d.ts +19 -0
  47. package/dist/components/DropZone.svelte.d.ts.map +1 -0
  48. package/dist/components/FormulationSelector.svelte +82 -0
  49. package/dist/components/FormulationSelector.svelte.d.ts +19 -0
  50. package/dist/components/FormulationSelector.svelte.d.ts.map +1 -0
  51. package/dist/components/LocalCaseDetails.svelte +253 -0
  52. package/dist/components/LocalCaseDetails.svelte.d.ts +4 -0
  53. package/dist/components/LocalCaseDetails.svelte.d.ts.map +1 -0
  54. package/dist/components/MulticonductorDetails.svelte +280 -0
  55. package/dist/components/MulticonductorDetails.svelte.d.ts +19 -0
  56. package/dist/components/MulticonductorDetails.svelte.d.ts.map +1 -0
  57. package/dist/components/NetworkStats.svelte +75 -0
  58. package/dist/components/NetworkStats.svelte.d.ts +19 -0
  59. package/dist/components/NetworkStats.svelte.d.ts.map +1 -0
  60. package/dist/components/PlacementCue.svelte +35 -0
  61. package/dist/components/PlacementCue.svelte.d.ts +19 -0
  62. package/dist/components/PlacementCue.svelte.d.ts.map +1 -0
  63. package/dist/components/RatingSlider.svelte +273 -0
  64. package/dist/components/RatingSlider.svelte.d.ts +4 -0
  65. package/dist/components/RatingSlider.svelte.d.ts.map +1 -0
  66. package/dist/components/RestoreDefaultsButton.svelte +34 -0
  67. package/dist/components/RestoreDefaultsButton.svelte.d.ts +19 -0
  68. package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +1 -0
  69. package/dist/components/SensitivityReadout.svelte +126 -0
  70. package/dist/components/SensitivityReadout.svelte.d.ts +4 -0
  71. package/dist/components/SensitivityReadout.svelte.d.ts.map +1 -0
  72. package/dist/components/SizeLegend.svelte +55 -0
  73. package/dist/components/SizeLegend.svelte.d.ts +19 -0
  74. package/dist/components/SizeLegend.svelte.d.ts.map +1 -0
  75. package/dist/components/SolveCard.svelte +84 -0
  76. package/dist/components/SolveCard.svelte.d.ts +4 -0
  77. package/dist/components/SolveCard.svelte.d.ts.map +1 -0
  78. package/dist/components/TopMovers.svelte +68 -0
  79. package/dist/components/TopMovers.svelte.d.ts +19 -0
  80. package/dist/components/TopMovers.svelte.d.ts.map +1 -0
  81. package/dist/components.d.ts +19 -0
  82. package/dist/components.d.ts.map +1 -0
  83. package/dist/components.js +18 -0
  84. package/dist/context.svelte.d.ts +20 -0
  85. package/dist/context.svelte.d.ts.map +1 -0
  86. package/dist/context.svelte.js +43 -0
  87. package/dist/controller.svelte.d.ts +334 -0
  88. package/dist/controller.svelte.d.ts.map +1 -0
  89. package/dist/controller.svelte.js +2026 -0
  90. package/dist/display.d.ts +31 -0
  91. package/dist/display.d.ts.map +1 -0
  92. package/dist/display.js +63 -0
  93. package/dist/drop-classify.d.ts +48 -0
  94. package/dist/drop-classify.d.ts.map +1 -0
  95. package/dist/drop-classify.js +92 -0
  96. package/dist/format.d.ts +30 -0
  97. package/dist/format.d.ts.map +1 -0
  98. package/dist/format.js +49 -0
  99. package/dist/index.d.ts +21 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +12 -0
  102. package/dist/map.d.ts +9 -0
  103. package/dist/map.d.ts.map +1 -0
  104. package/dist/map.js +4 -0
  105. package/dist/multiconductor.d.ts +63 -0
  106. package/dist/multiconductor.d.ts.map +1 -0
  107. package/dist/multiconductor.js +255 -0
  108. package/dist/state.svelte.d.ts +258 -0
  109. package/dist/state.svelte.d.ts.map +1 -0
  110. package/dist/state.svelte.js +323 -0
  111. package/dist/study-package.d.ts +9 -0
  112. package/dist/study-package.d.ts.map +1 -0
  113. package/dist/study-package.js +17 -0
  114. package/dist/styles.css +393 -0
  115. package/dist/synthetic-layout.d.ts +12 -0
  116. package/dist/synthetic-layout.d.ts.map +1 -0
  117. package/dist/synthetic-layout.js +271 -0
  118. package/dist/types.d.ts +6 -0
  119. package/dist/types.d.ts.map +1 -0
  120. package/dist/types.js +1 -0
  121. package/package.json +67 -0
@@ -0,0 +1,1430 @@
1
+ <script lang="ts">
2
+ import 'maplibre-gl/dist/maplibre-gl.css';
3
+ import { tick } from 'svelte';
4
+ import type { Layer, PickingInfo } from '@deck.gl/core';
5
+ import type { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
6
+ import type { MapboxOverlay } from '@deck.gl/mapbox';
7
+ import type { LngLatBoundsLike, Map as MapLibreMap } from 'maplibre-gl';
8
+ import type { NetworkBranch, NetworkBus } from './api.js';
9
+ import {
10
+ branchColor,
11
+ branchWidth,
12
+ busNeutral,
13
+ busRadius,
14
+ lmpColor,
15
+ scalarDomain,
16
+ sensColor,
17
+ sensFlatColor,
18
+ sensitivityDomain,
19
+ type SensitivityDomain
20
+ } from './colors.js';
21
+ import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
22
+ import { CaseState, type DisplayMode, type LocalCase } from './state.svelte.js';
23
+ import {
24
+ attachmentColor,
25
+ edgeColor,
26
+ edgeWidth,
27
+ isPhaseTerminal,
28
+ phaseColor,
29
+ type PlacedMultiBus,
30
+ type PlacedMultiEdge
31
+ } from './multiconductor.js';
32
+ import { getAppState, getController } from './context.svelte.js';
33
+ import { displayFmt, fmt, rgbaCss } from './format.js';
34
+ import type { RGBA } from './colors.js';
35
+
36
+ let {
37
+ onbusclick,
38
+ onlocalbusclick,
39
+ onbranchclick,
40
+ onlocalbranchclick,
41
+ onmultibusclick,
42
+ onplacecase,
43
+ onmapclick
44
+ }: {
45
+ onbusclick: (caseId: string, busId: number) => void;
46
+ onlocalbusclick: (caseId: string, busId: number) => void;
47
+ onbranchclick?: (caseId: string, branchId: number) => void;
48
+ onlocalbranchclick?: (caseId: string, branchId: number) => void;
49
+ onmultibusclick: (caseId: string, busId: string) => void;
50
+ onplacecase: (lon: number, lat: number) => void;
51
+ onmapclick: () => void;
52
+ } = $props();
53
+
54
+ const app = getAppState();
55
+ const ctrl = getController();
56
+
57
+ const STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
58
+ const CUE_MARGIN_PX = 64;
59
+ const BRANCH_FOCUS_MS = 850;
60
+ const BRANCH_FOCUS_MAX_ZOOM = 9.5;
61
+
62
+ let map = $state.raw<MapLibreMap | null>(null);
63
+ let overlay = $state.raw<MapboxOverlay | null>(null);
64
+ // Bumped to remount the map after a WebGL context is lost and cannot be
65
+ // repainted in place. See initMap's context-loss handling.
66
+ let mapGen = $state(0);
67
+ // Cap automatic remounts so sustained GPU pressure cannot loop forever.
68
+ let glRebuilds = 0;
69
+
70
+ type LayerCtors = {
71
+ PathLayer: typeof PathLayer;
72
+ ScatterplotLayer: typeof ScatterplotLayer;
73
+ };
74
+ type CursorState = {
75
+ isDragging: boolean;
76
+ isHovering: boolean;
77
+ };
78
+ let layerCtors = $state.raw<LayerCtors | null>(null);
79
+
80
+ interface CaseDisplay {
81
+ lmp: Map<number, number>;
82
+ scalar: Map<number, number>;
83
+ scalarLo: number;
84
+ scalarHi: number;
85
+ scalarMode: DisplayMode;
86
+ scalarLabel: string;
87
+ scalarUnit: string;
88
+ loading: Map<number, number>;
89
+ mode: 'lmp' | 'sens' | 'preview';
90
+ sens: Map<number, number>;
91
+ sensDomain: SensitivityDomain | null;
92
+ preview: Map<number, number>;
93
+ previewDomain: SensitivityDomain | null;
94
+ /** Fixed drag normalization (column scale × full slider deflection); the
95
+ * per-frame previewDomain is degree-1 homogeneous in the step, which would
96
+ * cancel the step out of the color and freeze the intensity. */
97
+ previewScale: number | null;
98
+ }
99
+ type SolvableCase = CaseState | LocalCase;
100
+
101
+ // Everything the accessors need, rebuilt when any case's data moves. The
102
+ // LMP scale normalizes per network: each case is an islanded market, so
103
+ // within network structure beats cross network color comparability (the
104
+ // panel legend and tooltips carry the actual numbers). Preview values
105
+ // shift individual buses without rescaling.
106
+ const display = $derived.by(() => {
107
+ const active = app.active ?? app.activeLocal;
108
+ const selectedBus = app.selectedBus;
109
+ const selectedBranch = app.selectedBranch;
110
+ // The active case's column for the live selection: the ∂LMP/∂d column at the
111
+ // selected bus, or the ∂LMP/∂rating column at the selected branch. Both are
112
+ // bus-keyed value maps, so the color pipeline below is target-agnostic.
113
+ const activeSensitivity =
114
+ active &&
115
+ ((selectedBus !== null && active.sensitivity?.bus === selectedBus) ||
116
+ (selectedBranch !== null && active.sensitivity?.branch === selectedBranch))
117
+ ? active.sensitivity
118
+ : null;
119
+ const activeDeltas = active ? caseDeltas(active) : {};
120
+ const activeRatings = active ? caseRatings(active) : {};
121
+ // Engine first-order LMP preview (Study.preview): predicted per-bus ΔLMP for
122
+ // the live drag, scoped to this case + selection target. Preferred over the JS
123
+ // gradient shift when present; the gradient path stays for server and browser
124
+ // fallbacks.
125
+ const enginePreview =
126
+ active &&
127
+ app.previewLmp &&
128
+ app.previewLmp.caseId === active.id &&
129
+ ('bus' in app.previewLmp.target
130
+ ? app.previewLmp.target.bus === selectedBus
131
+ : app.previewLmp.target.branch === selectedBranch)
132
+ ? app.previewLmp.delta
133
+ : null;
134
+ const previewStep =
135
+ active && activeSensitivity
136
+ ? selectedBus !== null && app.previewDeltaMw !== null
137
+ ? app.previewDeltaMw - (activeDeltas[selectedBus] ?? 0)
138
+ : selectedBranch !== null && app.previewRatingMw !== null
139
+ ? app.previewRatingMw - (activeRatings[selectedBranch] ?? 0)
140
+ : 0
141
+ : 0;
142
+ const previewing = Boolean(
143
+ active?.solving || app.previewActive || enginePreview || Math.abs(previewStep) >= 0.25
144
+ );
145
+
146
+ const perCase = new Map<string, CaseDisplay>();
147
+ const addCase = (c: CaseState | LocalCase) => {
148
+ if (!c.network || !c.solution) return;
149
+ const lmp = new Map<number, number>();
150
+ for (const e of c.solution.lmp) lmp.set(e.bus, e.usd_per_mwh);
151
+ // Resolve the active display mode against what this case can show, falling
152
+ // back to LMP when the chosen mode doesn't apply (e.g. |V| under DC OPF).
153
+ const meta = displayMetaFor(c);
154
+ const activeMeta = meta.find((o) => o.mode === app.displayMode) ?? meta[0] ?? null;
155
+ const scalarMode = activeMeta?.mode ?? 'lmp';
156
+ const seriesValues = displaySeriesFor(c, scalarMode);
157
+ const scalar = new Map<number, number>();
158
+ for (const e of seriesValues) scalar.set(e.bus, e.value);
159
+ const { lo: scalarLo, hi: scalarHi } = scalarDomain(
160
+ scalarMode,
161
+ seriesValues.map((e) => e.value)
162
+ );
163
+ const loading = new Map<number, number>();
164
+ for (const f of c.solution.flows) loading.set(f.branch, f.loading);
165
+
166
+ const sens = new Map<number, number>();
167
+ const preview = new Map<number, number>();
168
+ let domain: SensitivityDomain | null = null;
169
+ const isActive = c === active;
170
+ if (isActive && enginePreview) {
171
+ // Engine preview owns the LMP shift: add the predicted ΔLMP per bus.
172
+ for (const [bus, dlmp] of enginePreview) {
173
+ preview.set(bus, dlmp);
174
+ lmp.set(bus, (lmp.get(bus) ?? 0) + dlmp);
175
+ }
176
+ }
177
+ if (isActive && activeSensitivity) {
178
+ const values = activeSensitivity.values.map((v) => v.value);
179
+ domain = sensitivityDomain(values);
180
+ for (const v of activeSensitivity.values) {
181
+ sens.set(v.bus, v.value);
182
+ }
183
+ if (!enginePreview && previewStep !== 0) {
184
+ // Fallback first order preview: shift LMPs along the gradient.
185
+ for (const v of activeSensitivity.values) {
186
+ const dlmp = v.value * previewStep;
187
+ preview.set(v.bus, dlmp);
188
+ lmp.set(v.bus, (lmp.get(v.bus) ?? 0) + dlmp);
189
+ }
190
+ }
191
+ }
192
+ const previewValues = [...preview.values()];
193
+ const previewDomain = previewValues.length > 0 ? sensitivityDomain(previewValues) : null;
194
+ const showPreview = Boolean(
195
+ isActive &&
196
+ previewDomain &&
197
+ (app.previewActive || c.solving || enginePreview || Math.abs(previewStep) >= 0.25)
198
+ );
199
+ // The sensitivity and demand-preview overlays are price-space fields (∂LMP/∂d
200
+ // and the ΔLMP preview). They only paint the map under the LMP display mode;
201
+ // under angle or |V| the map stays on the chosen scalar and the sensitivity
202
+ // shows in the side panel, so selecting a bus never silently overrides the
203
+ // angle/|V| coloring with the price ramp.
204
+ const sensEligible = scalarMode === 'lmp';
205
+ // While a sensitivity is still loading for the selected bus, stay in sens
206
+ // mode (renders neutral, not the prior LMP palette) so the node colors
207
+ // don't flash orange->purple between the old and new selection.
208
+ const mode: 'lmp' | 'sens' | 'preview' =
209
+ sensEligible && showPreview
210
+ ? 'preview'
211
+ : sensEligible &&
212
+ isActive &&
213
+ (selectedBus !== null || selectedBranch !== null) &&
214
+ (domain || app.sensitivityLoading) &&
215
+ !previewing
216
+ ? 'sens'
217
+ : 'lmp';
218
+ perCase.set(c.id, {
219
+ lmp,
220
+ scalar,
221
+ scalarLo,
222
+ scalarHi,
223
+ scalarMode,
224
+ scalarLabel: activeMeta?.label ?? 'LMP',
225
+ scalarUnit: activeMeta?.unit ?? '$/MWh',
226
+ loading,
227
+ mode,
228
+ sens,
229
+ sensDomain: domain,
230
+ preview,
231
+ previewDomain,
232
+ previewScale: isActive ? ctrl.previewScale : null
233
+ });
234
+ };
235
+ for (const c of app.cases) addCase(c);
236
+ for (const c of app.localCases) addCase(c);
237
+ return perCase;
238
+ });
239
+
240
+ function busFill(caseId: string) {
241
+ const d = display.get(caseId);
242
+ return (bus: NetworkBus): [number, number, number, number] => {
243
+ if (!d) return busNeutral;
244
+ if (d.mode === 'sens') {
245
+ if (!d.sensDomain) return busNeutral;
246
+ if (d.sensDomain.flat) return sensFlatColor(d.sensDomain);
247
+ return sensColor((d.sens.get(bus.id) ?? 0) / d.sensDomain.scale);
248
+ }
249
+ if (d.mode === 'preview') {
250
+ // Fixed drag scale first, so intensity tracks the step. It also bypasses
251
+ // the per-frame domain's flat guard, whose absolute epsilon misreads a
252
+ // structured column at a tiny step as flat. previewScale is null when the
253
+ // committed column itself is flat, which falls through to the flat tint.
254
+ if (d.previewScale) {
255
+ return sensColor((d.preview.get(bus.id) ?? 0) / d.previewScale);
256
+ }
257
+ if (!d.previewDomain) return busNeutral;
258
+ if (d.previewDomain.flat) return sensFlatColor(d.previewDomain);
259
+ return sensColor((d.preview.get(bus.id) ?? 0) / d.previewDomain.scale);
260
+ }
261
+ const mid = (d.scalarLo + d.scalarHi) / 2;
262
+ return lmpColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
263
+ };
264
+ }
265
+
266
+ // A multiconductor bus is badged by its dominant attachment role (source,
267
+ // then generator, IBR, load, shunt); a bus with none stays neutral. The full
268
+ // per-terminal attachment list expands in the selection overlay.
269
+ function multiBusFill(b: PlacedMultiBus): [number, number, number, number] {
270
+ const primary = b.attachmentKinds[0];
271
+ return primary ? attachmentColor(primary) : busNeutral;
272
+ }
273
+
274
+ // Layers whose click selects something (a bus or a branch); the overlay-level
275
+ // click handler must not treat those clicks as "empty map" and clear the selection.
276
+ function isSelectableLayer(layerId: string | undefined): boolean {
277
+ return Boolean(
278
+ layerId?.startsWith('buses-') ||
279
+ layerId?.startsWith('local-buses-') ||
280
+ layerId?.startsWith('branches-') ||
281
+ layerId?.startsWith('local-branches-') ||
282
+ layerId?.startsWith('multi-buses-') ||
283
+ layerId?.startsWith('multi-edges-')
284
+ );
285
+ }
286
+
287
+ const selectedBusCue = $derived.by(() => {
288
+ const busId = app.selectedBus;
289
+ const c = app.active ?? app.activeLocal;
290
+ if (!c || busId === null) return null;
291
+ const buses =
292
+ app.active?.network?.buses ?? app.activeLocal?.network?.buses ?? app.activeLocal?.view?.buses ?? [];
293
+ const bus = buses.find((b) => b.id === busId);
294
+ return bus ? { key: `${c.id}-${bus.id}`, id: bus.id, lon: bus.lon, lat: bus.lat } : null;
295
+ });
296
+ const selectedBranchCue = $derived.by(() => {
297
+ const branchId = app.selectedBranch;
298
+ const c = app.active ?? app.activeLocal;
299
+ if (!c || branchId === null) return null;
300
+ const branches =
301
+ c instanceof CaseState ? c.network?.branches : (c.view?.branches ?? c.network?.branches);
302
+ const branch = branches?.find((b) => b.id === branchId);
303
+ const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
304
+ return branch && path && path.length >= 2
305
+ ? { key: `${c.id}-${branch.id}`, caseId: c.id, branchId: branch.id, path }
306
+ : null;
307
+ });
308
+ let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
309
+ let selectedBranchCueEl = $state.raw<SVGSVGElement | null>(null);
310
+ let selectedBranchHaloPath = $state.raw<SVGPathElement | null>(null);
311
+ let selectedBranchLinePath = $state.raw<SVGPathElement | null>(null);
312
+ let handledFrameSeq = 0;
313
+
314
+ // The selected multiconductor bus and its incident edges, whose per-conductor
315
+ // strands and terminal stack the detail overlay draws in screen space.
316
+ const selectedMultiDetail = $derived.by(() => {
317
+ const c = app.activeMulti;
318
+ const busId = c?.selectedBusId ?? null;
319
+ if (!c?.view || busId === null) return null;
320
+ const bus = c.view.buses.find((b) => b.id === busId);
321
+ if (!bus) return null;
322
+ const edges = c.view.edges.filter((e) => e.from === busId || e.to === busId);
323
+ return { key: `${c.id}-${busId}`, bus, edges };
324
+ });
325
+ let multiDetailEl = $state.raw<SVGSVGElement | null>(null);
326
+
327
+ function syncSelectedCue() {
328
+ if (!map || !selectedCueEl || !selectedBusCue) return;
329
+ const point = map.project([selectedBusCue.lon, selectedBusCue.lat]);
330
+ const { clientWidth, clientHeight } = map.getContainer();
331
+ const inRange =
332
+ point.x >= -40 &&
333
+ point.y >= -40 &&
334
+ point.x <= clientWidth + 40 &&
335
+ point.y <= clientHeight + 40;
336
+ selectedCueEl.style.left = `${point.x}px`;
337
+ selectedCueEl.style.top = `${point.y}px`;
338
+ selectedCueEl.style.display = inRange ? 'block' : 'none';
339
+ }
340
+
341
+ function syncSelectedBranchCue() {
342
+ if (
343
+ !map ||
344
+ !selectedBranchCueEl ||
345
+ !selectedBranchHaloPath ||
346
+ !selectedBranchLinePath ||
347
+ !selectedBranchCue
348
+ ) {
349
+ return;
350
+ }
351
+ const activeMap = map;
352
+ const { clientWidth, clientHeight } = activeMap.getContainer();
353
+ const points = selectedBranchCue.path
354
+ .map(([lon, lat]) => activeMap.project([lon, lat]))
355
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
356
+ if (clientWidth === 0 || clientHeight === 0 || points.length < 2) {
357
+ selectedBranchCueEl.style.display = 'none';
358
+ return;
359
+ }
360
+
361
+ let minX = Infinity;
362
+ let minY = Infinity;
363
+ let maxX = -Infinity;
364
+ let maxY = -Infinity;
365
+ const d = points
366
+ .map((p, i) => {
367
+ minX = Math.min(minX, p.x);
368
+ minY = Math.min(minY, p.y);
369
+ maxX = Math.max(maxX, p.x);
370
+ maxY = Math.max(maxY, p.y);
371
+ return `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
372
+ })
373
+ .join(' ');
374
+ const inRange =
375
+ maxX >= -CUE_MARGIN_PX &&
376
+ maxY >= -CUE_MARGIN_PX &&
377
+ minX <= clientWidth + CUE_MARGIN_PX &&
378
+ minY <= clientHeight + CUE_MARGIN_PX;
379
+
380
+ selectedBranchCueEl.setAttribute('viewBox', `0 0 ${clientWidth} ${clientHeight}`);
381
+ selectedBranchCueEl.style.display = inRange ? 'block' : 'none';
382
+ selectedBranchHaloPath.setAttribute('d', d);
383
+ selectedBranchLinePath.setAttribute('d', d);
384
+ }
385
+
386
+ const col = (c: RGBA) => rgbaCss(c);
387
+
388
+ /** A small ground symbol centered at (x, y): a short stem into three
389
+ * shrinking bars, the standard earth glyph. */
390
+ function groundGlyph(x: number, y: number): string {
391
+ return (
392
+ `<g class="mc-ground">` +
393
+ `<line x1="${x}" y1="${y - 4}" x2="${x}" y2="${y}"/>` +
394
+ `<line x1="${x - 5}" y1="${y}" x2="${x + 5}" y2="${y}"/>` +
395
+ `<line x1="${x - 3}" y1="${y + 2.5}" x2="${x + 3}" y2="${y + 2.5}"/>` +
396
+ `<line x1="${x - 1.5}" y1="${y + 5}" x2="${x + 1.5}" y2="${y + 5}"/></g>`
397
+ );
398
+ }
399
+
400
+ // Draw the selected bus's terminal-level detail in screen space: each incident
401
+ // edge fans into one strand per conductor (offset perpendicular, colored by
402
+ // phase), and the bus expands a stack of terminal badges with ground markers
403
+ // and attachment squares. Rebuilt per frame from map.project, like the branch
404
+ // cue. Bus ids and terminal names come from the file, so they are escaped.
405
+ function syncMultiDetail() {
406
+ if (!map || !multiDetailEl) return;
407
+ const detail = selectedMultiDetail;
408
+ if (!detail) {
409
+ multiDetailEl.style.display = 'none';
410
+ multiDetailEl.innerHTML = '';
411
+ return;
412
+ }
413
+ const m = map;
414
+ const { clientWidth, clientHeight } = m.getContainer();
415
+ if (clientWidth === 0 || clientHeight === 0) {
416
+ multiDetailEl.style.display = 'none';
417
+ return;
418
+ }
419
+ const { bus, edges } = detail;
420
+ const busPt = m.project([bus.lon, bus.lat]);
421
+
422
+ const STRAND_SPACING = 5;
423
+ let strands = '';
424
+ for (const e of edges) {
425
+ const selectedIsFrom = e.from === bus.id;
426
+ const other = selectedIsFrom ? e.path[e.path.length - 1] : e.path[0];
427
+ const b = m.project(other);
428
+ const dx = b.x - busPt.x;
429
+ const dy = b.y - busPt.y;
430
+ const len = Math.hypot(dx, dy) || 1;
431
+ const px = -dy / len;
432
+ const py = dx / len;
433
+ const n = e.conductors.length || Math.max(e.n_phases, 1);
434
+ for (let i = 0; i < n; i++) {
435
+ const pair = e.conductors[i];
436
+ const term = (selectedIsFrom ? pair?.[0] : pair?.[1]) ?? '';
437
+ const off = (i - (n - 1) / 2) * STRAND_SPACING;
438
+ const ax = (busPt.x + px * off).toFixed(1);
439
+ const ay = (busPt.y + py * off).toFixed(1);
440
+ const bx = (b.x + px * off).toFixed(1);
441
+ const by = (b.y + py * off).toFixed(1);
442
+ strands += `<line x1="${ax}" y1="${ay}" x2="${bx}" y2="${by}" stroke="${col(phaseColor(term))}" stroke-width="2.4" stroke-linecap="round" opacity="0.9"/>`;
443
+ }
444
+ }
445
+
446
+ // The terminal stack sits to the right of the bus; a leader connects them.
447
+ const terminals = bus.terminals.slice(0, 12);
448
+ const STACK_DX = 26;
449
+ const STACK_DY = 18;
450
+ const sx = busPt.x + STACK_DX;
451
+ const top = busPt.y - ((terminals.length - 1) / 2) * STACK_DY;
452
+ let stack = `<line class="mc-leader" x1="${busPt.x.toFixed(1)}" y1="${busPt.y.toFixed(1)}" x2="${sx.toFixed(1)}" y2="${busPt.y.toFixed(1)}"/>`;
453
+ terminals.forEach((t, k) => {
454
+ const cy = top + k * STACK_DY;
455
+ const grounded = bus.grounded.includes(t);
456
+ const fill = isPhaseTerminal(t) ? col(phaseColor(t)) : col([120, 114, 102, 255]);
457
+ if (grounded) stack += groundGlyph(sx - 14, cy);
458
+ stack += `<circle cx="${sx.toFixed(1)}" cy="${cy.toFixed(1)}" r="6" fill="${fill}" stroke="#20242b" stroke-width="1.2"/>`;
459
+ stack += `<text class="mc-term" x="${(sx + 11).toFixed(1)}" y="${(cy + 3.5).toFixed(1)}">${esc(t)}</text>`;
460
+ // Attachment squares for elements landing on this terminal.
461
+ const kinds = [...new Set((bus.terminalAttachments[t] ?? []).map((a) => a.kind))];
462
+ kinds.slice(0, 5).forEach((kind, j) => {
463
+ const bxp = sx + 26 + j * 9;
464
+ stack += `<rect x="${bxp.toFixed(1)}" y="${(cy - 4).toFixed(1)}" width="7" height="7" rx="1.5" fill="${col(attachmentColor(kind))}"><title>${esc(kind)}</title></rect>`;
465
+ });
466
+ });
467
+
468
+ multiDetailEl.setAttribute('viewBox', `0 0 ${clientWidth} ${clientHeight}`);
469
+ multiDetailEl.style.display = 'block';
470
+ multiDetailEl.innerHTML = `<g class="mc-strands">${strands}</g><g class="mc-stack">${stack}</g>`;
471
+ }
472
+
473
+ // Coalesce cue syncs to one per animation frame: 'move' fires for every
474
+ // camera change (including zooms), and each sync reprojects the whole
475
+ // selected branch path.
476
+ let cueSyncScheduled = false;
477
+ function scheduleCueSync() {
478
+ if (cueSyncScheduled) return;
479
+ cueSyncScheduled = true;
480
+ requestAnimationFrame(() => {
481
+ cueSyncScheduled = false;
482
+ syncSelectedCue();
483
+ syncSelectedBranchCue();
484
+ syncMultiDetail();
485
+ });
486
+ }
487
+
488
+ /** Camera padding that keeps the framed subject clear of the panel chrome.
489
+ * The branch variant also clears the rating readout on the panel's lower edge. */
490
+ function framePadding(width: number, height: number, kind: 'case' | 'branch') {
491
+ if (width <= 760) {
492
+ const bottom =
493
+ kind === 'branch'
494
+ ? Math.min(Math.round(height * 0.42), 300)
495
+ : Math.min(Math.round(height * 0.5), 330);
496
+ return { top: 130, left: 24, right: 24, bottom };
497
+ }
498
+ return kind === 'branch'
499
+ ? { top: 106, left: 388, right: 76, bottom: 80 }
500
+ : { top: 96, left: 380, right: 60, bottom: 64 };
501
+ }
502
+
503
+ /** Fold lon/lat pairs into LngLat bounds; null when no point is finite. */
504
+ function foldBounds(
505
+ points: Iterable<[number, number]>
506
+ ): [[number, number], [number, number]] | null {
507
+ let minLon = Infinity;
508
+ let minLat = Infinity;
509
+ let maxLon = -Infinity;
510
+ let maxLat = -Infinity;
511
+ for (const [lon, lat] of points) {
512
+ minLon = Math.min(minLon, lon);
513
+ minLat = Math.min(minLat, lat);
514
+ maxLon = Math.max(maxLon, lon);
515
+ maxLat = Math.max(maxLat, lat);
516
+ }
517
+ if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null;
518
+ return [
519
+ [minLon, minLat],
520
+ [maxLon, maxLat]
521
+ ];
522
+ }
523
+
524
+ function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
525
+ const bounds = foldBounds(path);
526
+ if (!bounds) return null;
527
+ let [[minLon, minLat], [maxLon, maxLat]] = bounds;
528
+ if (minLon === maxLon) {
529
+ minLon -= 0.005;
530
+ maxLon += 0.005;
531
+ }
532
+ if (minLat === maxLat) {
533
+ minLat -= 0.005;
534
+ maxLat += 0.005;
535
+ }
536
+ return [
537
+ [minLon, minLat],
538
+ [maxLon, maxLat]
539
+ ];
540
+ }
541
+
542
+ function prefersReducedMotion(): boolean {
543
+ return (
544
+ typeof window !== 'undefined' &&
545
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
546
+ );
547
+ }
548
+
549
+ /** Fly to the selected branch; true when a camera move started. */
550
+ function focusSelectedBranch(): boolean {
551
+ if (!map || !selectedBranchCue) return false;
552
+ const bounds = branchBounds(selectedBranchCue.path);
553
+ if (!bounds) return false;
554
+ const { clientWidth, clientHeight } = map.getContainer();
555
+ const camera = map.cameraForBounds(bounds, {
556
+ padding: framePadding(clientWidth, clientHeight, 'branch'),
557
+ maxZoom: Math.min(map.getZoom() + 2, BRANCH_FOCUS_MAX_ZOOM)
558
+ });
559
+ if (!camera?.center) return false;
560
+ map.easeTo({
561
+ center: camera.center,
562
+ zoom: camera.zoom ?? map.getZoom(),
563
+ duration: prefersReducedMotion() ? 0 : BRANCH_FOCUS_MS,
564
+ easing: (t) => 1 - Math.pow(1 - t, 3)
565
+ });
566
+ return true;
567
+ }
568
+
569
+ /** Case owning a picked network layer; null for display-only layers. */
570
+ function caseOf(info: PickingInfo): SolvableCase | null {
571
+ const layerId = info.layer?.id;
572
+ if (!layerId) return null;
573
+ const bundled = layerId.match(/^(?:buses|branches)-(.+)$/);
574
+ if (bundled) return app.byId(bundled[1]);
575
+ const local = layerId.match(/^local-(?:buses|branches)-(.+)$/);
576
+ if (local) return app.localCases.find((c) => c.id === local[1]) ?? null;
577
+ return null;
578
+ }
579
+
580
+ /** Escape free text from a dropped file before it goes into tooltip html
581
+ * (deck.gl renders the html string as innerHTML). */
582
+ const esc = (s: string) =>
583
+ s
584
+ .replace(/&/g, '&amp;')
585
+ .replace(/</g, '&lt;')
586
+ .replace(/>/g, '&gt;')
587
+ .replace(/"/g, '&quot;')
588
+ .replace(/'/g, '&#39;');
589
+
590
+ function tooltip(info: PickingInfo): { html: string } | null {
591
+ const { object } = info;
592
+ if (!object) return null;
593
+ if (info.layer?.id.startsWith('local-subs-')) {
594
+ // PowerWorld .pwd substation: display data only, position inferred from the diagram.
595
+ // The name is free text from a dropped file, so escape it; the number
596
+ // is coerced before interpolation.
597
+ const s = object as { number: number; name: string };
598
+ const named = s.name ? ` ${esc(s.name)}` : '';
599
+ return {
600
+ html: `<div class="tt"><b>substation ${Number(s.number)}</b>${named}<br><span style="opacity:0.6">.pwd diagram &#8901; approx. position</span></div>`
601
+ };
602
+ }
603
+ const layerId = info.layer?.id;
604
+ if (layerId?.startsWith('multi-edges-')) {
605
+ const e = object as PlacedMultiEdge;
606
+ const state = e.kind === 'switch' ? (e.closed ? ' &#8901; closed' : ' &#8901; open') : '';
607
+ return {
608
+ html: `<div class="tt"><b>${esc(e.kind)} ${esc(e.from)}&#8201;&ndash;&#8201;${esc(e.to)}</b> ${e.n_phases}&#8202;&phi;${state}</div>`
609
+ };
610
+ }
611
+ if (layerId?.startsWith('multi-buses-')) {
612
+ const b = object as PlacedMultiBus;
613
+ const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'none';
614
+ return {
615
+ html: `<div class="tt"><b>bus ${esc(b.id)}</b>
616
+ ${b.terminals.length} terminals${b.grounded.length ? ` &#8901; ${b.grounded.length} grounded` : ''}<br>
617
+ load ${b.load_kw.toFixed(0)} kW &#8901; gen ${b.gen_kw.toFixed(0)} kW<br>
618
+ <span style="opacity:0.6">${esc(roles)}</span></div>`
619
+ };
620
+ }
621
+ const c = caseOf(info);
622
+ const d = c ? display.get(c.id) : undefined;
623
+ if ('path' in object) {
624
+ const b = object as NetworkBranch;
625
+ const flow = d
626
+ ? `${((d.loading.get(b.id) ?? 0) * 100).toFixed(0)}% of ${b.rate_mw.toFixed(0)} MW`
627
+ : `rate ${b.rate_mw.toFixed(0)} MW`;
628
+ return {
629
+ html: `<div class="tt"><b>line ${b.from}&#8201;&ndash;&#8201;${b.to}</b> ${flow}</div>`
630
+ };
631
+ }
632
+ const bus = object as NetworkBus;
633
+ if (!c) {
634
+ return {
635
+ html: `<div class="tt"><b>bus ${bus.id}</b>
636
+ load ${bus.demand_mw.toFixed(0)} MW &#8901; gen ${bus.gen_mw.toFixed(0)} MW<br>
637
+ <span style="opacity:0.6">local file</span></div>`
638
+ };
639
+ }
640
+ const lmp = d?.lmp.get(bus.id);
641
+ const scalar = d?.scalar.get(bus.id);
642
+ const sens = d?.mode === 'sens' ? d.sens.get(bus.id) : undefined;
643
+ const preview = d?.mode === 'preview' ? d.preview.get(bus.id) : undefined;
644
+ const delta = caseDeltas(c)[bus.id] ?? 0;
645
+ const loadRow =
646
+ delta === 0
647
+ ? `load ${bus.demand_mw.toFixed(0)} MW`
648
+ : `load ${(bus.demand_mw + delta).toFixed(0)} MW (${delta > 0 ? '+' : ''}${delta.toFixed(0)})`;
649
+ const sensRow =
650
+ sens === undefined
651
+ ? ''
652
+ : `<br>&part;LMP/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
653
+ const previewRow =
654
+ preview === undefined
655
+ ? ''
656
+ : `<br>&Delta;LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} $/MWh`;
657
+ const localRow =
658
+ c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
659
+ const scalarRow =
660
+ !d || d.scalarMode === 'lmp'
661
+ ? `LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`
662
+ : `${d.scalarLabel} ${
663
+ scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
664
+ } ${d.scalarUnit}<br>LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`;
665
+ return {
666
+ html: `<div class="tt"><b>bus ${bus.id}</b>
667
+ ${scalarRow}<br>
668
+ ${loadRow} &#8901; gen ${bus.gen_mw.toFixed(0)} MW${sensRow}${previewRow}${localRow}</div>`
669
+ };
670
+ }
671
+
672
+ async function loadMapModules() {
673
+ const [maplibre, mapbox, layers] = await Promise.all([
674
+ import('maplibre-gl'),
675
+ import('@deck.gl/mapbox'),
676
+ import('@deck.gl/layers')
677
+ ]);
678
+ return {
679
+ maplibregl: maplibre.default,
680
+ MapboxOverlay: mapbox.MapboxOverlay,
681
+ PathLayer: layers.PathLayer,
682
+ ScatterplotLayer: layers.ScatterplotLayer
683
+ };
684
+ }
685
+
686
+ type MapModules = Awaited<ReturnType<typeof loadMapModules>>;
687
+
688
+ // The deck overlay options. interleaved:true renders the network into
689
+ // maplibre's own WebGL2 context instead of a second canvas, so the page
690
+ // holds one GL context rather than two. Safari evicts WebGL contexts under
691
+ // memory and tab pressure, and one context survives that far better; the
692
+ // deck layers also inherit maplibre's 4096 canvas clamp. The flat positron
693
+ // basemap writes no depth, so the overlay always draws on top.
694
+ function buildOverlay(MapboxOverlay: MapModules['MapboxOverlay']): MapboxOverlay {
695
+ return new MapboxOverlay({
696
+ interleaved: true,
697
+ layers: [],
698
+ parameters: {
699
+ depthWriteEnabled: false,
700
+ depthCompare: 'always'
701
+ },
702
+ onClick: (info: PickingInfo) => {
703
+ if (!app.placingId && !isSelectableLayer(info.layer?.id)) onmapclick();
704
+ },
705
+ getTooltip: tooltip,
706
+ getCursor: ({ isHovering, isDragging }: CursorState) =>
707
+ app.placingId ? 'crosshair' : isDragging ? 'grabbing' : isHovering ? 'pointer' : 'grab'
708
+ });
709
+ }
710
+
711
+ function initMap(container: HTMLDivElement) {
712
+ let cleanup = () => {};
713
+ let cancelled = false;
714
+ void loadMapModules()
715
+ .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer }) => {
716
+ if (cancelled) return;
717
+ layerCtors = { PathLayer, ScatterplotLayer };
718
+ const m = new maplibregl.Map({
719
+ container,
720
+ style: STYLE,
721
+ center: [-85, 36],
722
+ zoom: 4.5,
723
+ canvasContextAttributes: { antialias: true },
724
+ attributionControl: { compact: true }
725
+ });
726
+ const o = buildOverlay(MapboxOverlay);
727
+ m.addControl(o);
728
+ m.on('click', (e) => {
729
+ if (app.placingId) onplacecase(e.lngLat.lng, e.lngLat.lat);
730
+ });
731
+ m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right');
732
+
733
+ // WebGL resilience on Safari. Two failure modes seen in the wild:
734
+ // 1. The canvas backing store is discarded while the tab is idle or
735
+ // backgrounded, so the map shows blank until something forces a
736
+ // redraw. A manual window resize fixes it; do that automatically
737
+ // when the tab returns to the foreground.
738
+ // 2. The whole GL context is lost under pressure. maplibre rebuilds
739
+ // its painter on restore but not the deck custom layer (its Deck
740
+ // still holds the dead context), so a clean remount is simplest:
741
+ // bump mapGen to tear the map down and build it again.
742
+ const repaint = () => {
743
+ if (cancelled) return;
744
+ m.resize();
745
+ m.triggerRepaint();
746
+ };
747
+ const onVisible = () => {
748
+ if (document.visibilityState === 'visible') repaint();
749
+ };
750
+ const syncCues = () => scheduleCueSync();
751
+ let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
752
+ const clearRebuild = () => {
753
+ if (rebuildTimer !== null) {
754
+ clearTimeout(rebuildTimer);
755
+ rebuildTimer = null;
756
+ }
757
+ };
758
+ // A rebuilt map that holds a live context for a while proves the GPU
759
+ // recovered, so the rebuild budget is forgiven (see below). Clear that
760
+ // pending forgiveness the moment another loss arrives.
761
+ let stabilizeTimer: ReturnType<typeof setTimeout> | null = null;
762
+ const clearStabilize = () => {
763
+ if (stabilizeTimer !== null) {
764
+ clearTimeout(stabilizeTimer);
765
+ stabilizeTimer = null;
766
+ }
767
+ };
768
+ // At most one rebuild per map instance: the 1500ms timer and
769
+ // webglcontextrestored both call rebuild for a single loss, so guard
770
+ // against counting that loss twice against the budget.
771
+ let rebuilt = false;
772
+ const rebuild = () => {
773
+ clearRebuild();
774
+ if (cancelled || rebuilt) return;
775
+ if (glRebuilds >= 6) {
776
+ app.error =
777
+ 'the map lost its graphics context repeatedly; reload the page to restore it';
778
+ return;
779
+ }
780
+ rebuilt = true;
781
+ glRebuilds++;
782
+ mapGen++;
783
+ };
784
+ const onContextLost = () => {
785
+ overlay = null; // stop the layer effect touching a dead overlay
786
+ clearStabilize(); // a fresh loss is not a stable recovery
787
+ rebuildTimer ??= setTimeout(rebuild, 1500);
788
+ };
789
+ document.addEventListener('visibilitychange', onVisible);
790
+ window.addEventListener('focus', repaint);
791
+ window.addEventListener('pageshow', repaint);
792
+ m.on('webglcontextlost', onContextLost);
793
+ m.on('webglcontextrestored', rebuild);
794
+ // 'move' fires for every camera change, including zooms.
795
+ m.on('load', syncCues);
796
+ m.on('move', syncCues);
797
+ m.on('resize', syncCues);
798
+
799
+ map = m;
800
+ overlay = o;
801
+ // Frame the active grid once the map is ready. The initial frame request
802
+ // (from the case load) can fire before the async map import finishes and
803
+ // be lost; re-issuing it here ensures the first view is the active case,
804
+ // not the default world view. A remount opens at the default view too.
805
+ app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
806
+ if (mapGen > 0) {
807
+ // This is a rebuilt map. Once it has held a live context without
808
+ // another loss, zero the budget so the cap only ever catches a tight
809
+ // loss loop, not recoveries spread across a long session.
810
+ stabilizeTimer = setTimeout(() => {
811
+ glRebuilds = 0;
812
+ stabilizeTimer = null;
813
+ }, 10000);
814
+ }
815
+
816
+ cleanup = () => {
817
+ clearRebuild();
818
+ clearStabilize();
819
+ document.removeEventListener('visibilitychange', onVisible);
820
+ window.removeEventListener('focus', repaint);
821
+ window.removeEventListener('pageshow', repaint);
822
+ m.off('load', syncCues);
823
+ m.off('move', syncCues);
824
+ m.off('resize', syncCues);
825
+ app.settleFrame();
826
+ m.remove();
827
+ map = null;
828
+ overlay = null;
829
+ };
830
+ })
831
+ .catch((e) => {
832
+ if (!cancelled) app.error = `map failed to load: ${e instanceof Error ? e.message : e}`;
833
+ });
834
+ return () => {
835
+ cancelled = true;
836
+ cleanup();
837
+ };
838
+ }
839
+
840
+ // Sync deck.gl layers with app state. New layer instances diff cheaply;
841
+ // updateTriggers tell deck.gl when accessor outputs changed.
842
+ $effect(() => {
843
+ if (!overlay || !layerCtors) return;
844
+ const { PathLayer, ScatterplotLayer } = layerCtors;
845
+ const layers: Layer[] = [];
846
+ for (const c of app.cases) {
847
+ if (!c.network) continue;
848
+ const d = display.get(c.id);
849
+ layers.push(
850
+ new PathLayer<NetworkBranch>({
851
+ id: `branches-${c.id}`,
852
+ data: c.network.branches,
853
+ getPath: (b) => b.path,
854
+ // The selected branch draws in the selection blue of the bus halo cue,
855
+ // wider, so the ∂LMP/∂rating source line reads at a glance.
856
+ getColor: (b) =>
857
+ c.id === app.activeCaseId && b.id === app.selectedBranch
858
+ ? [47, 111, 187, 255]
859
+ : branchColor(d?.loading.get(b.id) ?? 0, b.status === 1),
860
+ getWidth: (b) =>
861
+ c.id === app.activeCaseId && b.id === app.selectedBranch
862
+ ? Math.max(branchWidth(d?.loading.get(b.id) ?? 0) + 2, 4.5)
863
+ : branchWidth(d?.loading.get(b.id) ?? 0),
864
+ widthUnits: 'pixels',
865
+ widthMinPixels: 1.5,
866
+ capRounded: true,
867
+ jointRounded: true,
868
+ miterLimit: 2,
869
+ pickable: true,
870
+ autoHighlight: true,
871
+ highlightColor: [32, 36, 43, 90],
872
+ onClick: (info: PickingInfo) => {
873
+ const branch = info.object as NetworkBranch | undefined;
874
+ if (!branch || !onbranchclick) return false;
875
+ onbranchclick(c.id, branch.id);
876
+ return true;
877
+ },
878
+ updateTriggers: {
879
+ getColor: [display, app.selectedBranch, app.activeCaseId],
880
+ getWidth: [display, app.selectedBranch, app.activeCaseId]
881
+ }
882
+ }),
883
+ new ScatterplotLayer<NetworkBus>({
884
+ id: `buses-${c.id}`,
885
+ data: c.network.buses,
886
+ getPosition: (b) => [b.lon, b.lat],
887
+ getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
888
+ radiusUnits: 'pixels',
889
+ getFillColor: busFill(c.id),
890
+ stroked: true,
891
+ billboard: true,
892
+ getLineColor: (b) =>
893
+ c.id === app.activeCaseId && b.id === app.selectedBus
894
+ ? [32, 36, 43, 255]
895
+ : [46, 42, 34, 110],
896
+ getLineWidth: (b) => (c.id === app.activeCaseId && b.id === app.selectedBus ? 2.5 : 1),
897
+ lineWidthUnits: 'pixels',
898
+ pickable: true,
899
+ autoHighlight: true,
900
+ highlightColor: [32, 36, 43, 70],
901
+ onClick: (info: PickingInfo) => {
902
+ const bus = info.object as NetworkBus | undefined;
903
+ if (!bus) return false;
904
+ onbusclick(c.id, bus.id);
905
+ return true;
906
+ },
907
+ updateTriggers: {
908
+ getFillColor: [display],
909
+ getLineColor: [app.selectedBus, app.activeCaseId],
910
+ getLineWidth: [app.selectedBus, app.activeCaseId]
911
+ }
912
+ })
913
+ );
914
+ }
915
+ // Local cases are grey until the browser solve returns. A .pwd display
916
+ // file contributes substation points only, in a cooler slate so they
917
+ // read as diagram derived positions.
918
+ for (const c of app.localCases) {
919
+ if (c.view) {
920
+ const d = display.get(c.id);
921
+ layers.push(
922
+ new PathLayer<NetworkBranch>({
923
+ id: `local-branches-${c.id}`,
924
+ data: c.view.branches,
925
+ getPath: (b) => b.path,
926
+ getColor: (b) =>
927
+ c.id === app.activeLocalId && b.id === app.selectedBranch
928
+ ? [47, 111, 187, 255]
929
+ : d
930
+ ? branchColor(d.loading.get(b.id) ?? 0, b.status === 1)
931
+ : [138, 131, 117, 150],
932
+ getWidth: (b) =>
933
+ c.id === app.activeLocalId && b.id === app.selectedBranch
934
+ ? Math.max((d ? branchWidth(d.loading.get(b.id) ?? 0) : 1.5) + 2, 4.5)
935
+ : d
936
+ ? branchWidth(d.loading.get(b.id) ?? 0)
937
+ : 1.5,
938
+ widthUnits: 'pixels',
939
+ widthMinPixels: 1.2,
940
+ capRounded: true,
941
+ jointRounded: true,
942
+ miterLimit: 2,
943
+ pickable: true,
944
+ autoHighlight: true,
945
+ highlightColor: [32, 36, 43, 90],
946
+ onClick: (info: PickingInfo) => {
947
+ const branch = info.object as NetworkBranch | undefined;
948
+ if (!branch || !onlocalbranchclick) return false;
949
+ onlocalbranchclick(c.id, branch.id);
950
+ return true;
951
+ },
952
+ updateTriggers: {
953
+ getColor: [display, app.selectedBranch, app.activeLocalId],
954
+ getWidth: [display, app.selectedBranch, app.activeLocalId]
955
+ }
956
+ }),
957
+ new ScatterplotLayer<NetworkBus>({
958
+ id: `local-buses-${c.id}`,
959
+ data: c.view.buses,
960
+ getPosition: (b) => [b.lon, b.lat],
961
+ getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
962
+ radiusUnits: 'pixels',
963
+ getFillColor: d ? busFill(c.id) : [110, 115, 120, 200],
964
+ stroked: true,
965
+ billboard: true,
966
+ getLineColor: (b) =>
967
+ c.id === app.activeLocalId && b.id === app.selectedBus
968
+ ? [32, 36, 43, 255]
969
+ : [46, 42, 34, 110],
970
+ getLineWidth: (b) => (c.id === app.activeLocalId && b.id === app.selectedBus ? 2.5 : 1),
971
+ lineWidthUnits: 'pixels',
972
+ pickable: true,
973
+ autoHighlight: true,
974
+ highlightColor: [32, 36, 43, 70],
975
+ onClick: (info: PickingInfo) => {
976
+ const bus = info.object as NetworkBus | undefined;
977
+ if (!bus) return false;
978
+ onlocalbusclick(c.id, bus.id);
979
+ return true;
980
+ },
981
+ updateTriggers: {
982
+ getFillColor: [display],
983
+ getLineColor: [app.selectedBus, app.activeLocalId],
984
+ getLineWidth: [app.selectedBus, app.activeLocalId]
985
+ }
986
+ })
987
+ );
988
+ }
989
+ if (c.substations) {
990
+ layers.push(
991
+ new ScatterplotLayer<{ number: number; name: string; lon: number; lat: number }>({
992
+ id: `local-subs-${c.id}`,
993
+ data: c.substations.points,
994
+ getPosition: (s) => [s.lon, s.lat],
995
+ getRadius: 5,
996
+ radiusUnits: 'pixels',
997
+ radiusMinPixels: 3,
998
+ getFillColor: [70, 92, 124, 190],
999
+ stroked: true,
1000
+ billboard: true,
1001
+ getLineColor: [38, 52, 78, 220],
1002
+ getLineWidth: 1,
1003
+ lineWidthUnits: 'pixels',
1004
+ pickable: true,
1005
+ autoHighlight: true,
1006
+ highlightColor: [32, 36, 43, 70]
1007
+ })
1008
+ );
1009
+ }
1010
+ }
1011
+ // Multiconductor cases (viewing only): the bus graph with edges styled by
1012
+ // kind and buses badged by attachment role. Selecting a bus fans its
1013
+ // incident conductors and expands its terminal stack in the SVG overlay.
1014
+ for (const c of app.multiCases) {
1015
+ if (!c.view) continue;
1016
+ const selectedId = c.id === app.activeMultiId ? c.selectedBusId : null;
1017
+ layers.push(
1018
+ new PathLayer<PlacedMultiEdge>({
1019
+ id: `multi-edges-${c.id}`,
1020
+ data: c.view.edges,
1021
+ getPath: (e) => e.path,
1022
+ getColor: (e) => edgeColor(e.kind, e.closed),
1023
+ getWidth: (e) => edgeWidth(e.kind, e.n_phases),
1024
+ widthUnits: 'pixels',
1025
+ widthMinPixels: 1.2,
1026
+ capRounded: true,
1027
+ jointRounded: true,
1028
+ miterLimit: 2,
1029
+ pickable: true,
1030
+ autoHighlight: true,
1031
+ highlightColor: [32, 36, 43, 90]
1032
+ }),
1033
+ new ScatterplotLayer<PlacedMultiBus>({
1034
+ id: `multi-buses-${c.id}`,
1035
+ data: c.view.buses,
1036
+ getPosition: (b) => [b.lon, b.lat],
1037
+ getRadius: (b) => busRadius(Math.max(b.load_kw, b.gen_kw)),
1038
+ radiusUnits: 'pixels',
1039
+ radiusMinPixels: 3,
1040
+ getFillColor: (b) => multiBusFill(b),
1041
+ stroked: true,
1042
+ billboard: true,
1043
+ getLineColor: (b) =>
1044
+ selectedId === b.id
1045
+ ? [32, 36, 43, 255]
1046
+ : b.has_source
1047
+ ? [63, 111, 187, 220]
1048
+ : [46, 42, 34, 120],
1049
+ getLineWidth: (b) => (selectedId === b.id ? 2.5 : b.has_source ? 2 : 1),
1050
+ lineWidthUnits: 'pixels',
1051
+ pickable: true,
1052
+ autoHighlight: true,
1053
+ highlightColor: [32, 36, 43, 70],
1054
+ onClick: (info: PickingInfo) => {
1055
+ const bus = info.object as PlacedMultiBus | undefined;
1056
+ if (!bus) return false;
1057
+ onmultibusclick(c.id, bus.id);
1058
+ return true;
1059
+ },
1060
+ updateTriggers: {
1061
+ getLineColor: [selectedId],
1062
+ getLineWidth: [selectedId]
1063
+ }
1064
+ })
1065
+ );
1066
+ }
1067
+ overlay.setProps({ layers });
1068
+ });
1069
+
1070
+ $effect(() => {
1071
+ if (!map) return;
1072
+ map.getCanvas().style.cursor = app.placingId ? 'crosshair' : '';
1073
+ });
1074
+
1075
+ $effect(() => {
1076
+ if (!map) return;
1077
+ void mapGen;
1078
+ void selectedBusCue?.key;
1079
+ void selectedBranchCue?.key;
1080
+ void selectedMultiDetail?.key;
1081
+ void tick().then(scheduleCueSync);
1082
+ });
1083
+
1084
+ function boundsFor(target: string): LngLatBoundsLike | null {
1085
+ const points: [number, number][] = [];
1086
+ const fold = (pts: { lon: number; lat: number }[]) => {
1087
+ for (const b of pts) points.push([b.lon, b.lat]);
1088
+ };
1089
+ for (const c of app.cases) {
1090
+ if (!c.network || (target !== 'all' && c.id !== target)) continue;
1091
+ fold(c.network.buses);
1092
+ }
1093
+ for (const c of app.localCases) {
1094
+ if (target !== 'all' && c.id !== target) continue;
1095
+ if (c.view) fold(c.view.buses);
1096
+ if (c.substations) fold(c.substations.points);
1097
+ }
1098
+ for (const c of app.multiCases) {
1099
+ if (target !== 'all' && c.id !== target) continue;
1100
+ if (c.view) fold(c.view.buses);
1101
+ }
1102
+ return foldBounds(points);
1103
+ }
1104
+
1105
+ // Fly to whatever the header, the initial load, or a branch selection asked
1106
+ // for, and settle the request's promise once the camera lands so callers can
1107
+ // defer heavy work past the animation. A branch request waits (unhandled)
1108
+ // until its cue exists; anything unframeable settles immediately.
1109
+ $effect(() => {
1110
+ const seq = app.frameSeq;
1111
+ if (!map || seq === handledFrameSeq) return;
1112
+ const m = map;
1113
+ const target = app.frameTarget;
1114
+ const settle = () => app.settleFrame();
1115
+ if (typeof target === 'object') {
1116
+ const cue = selectedBranchCue;
1117
+ if (!cue || cue.caseId !== target.caseId || cue.branchId !== target.branchId) return;
1118
+ handledFrameSeq = seq;
1119
+ if (focusSelectedBranch()) m.once('moveend', settle);
1120
+ else settle();
1121
+ return;
1122
+ }
1123
+ handledFrameSeq = seq;
1124
+ const bounds = boundsFor(target);
1125
+ if (!bounds) {
1126
+ settle();
1127
+ return;
1128
+ }
1129
+ const { clientWidth: w, clientHeight: h } = m.getContainer();
1130
+ m.fitBounds(bounds, {
1131
+ padding: framePadding(w, h, 'case'),
1132
+ duration: prefersReducedMotion() ? 0 : 1400
1133
+ });
1134
+ m.once('moveend', settle);
1135
+ });
1136
+ </script>
1137
+
1138
+ {#key mapGen}
1139
+ <div class="map-stage">
1140
+ <div class="map" {@attach initMap}></div>
1141
+ {#if selectedBusCue}
1142
+ {#key selectedBusCue.key}
1143
+ <div class="selected-bus-cue" bind:this={selectedCueEl} aria-hidden="true"></div>
1144
+ {/key}
1145
+ {/if}
1146
+ {#if selectedBranchCue}
1147
+ {#key selectedBranchCue.key}
1148
+ <svg class="selected-branch-cue" bind:this={selectedBranchCueEl} aria-hidden="true">
1149
+ <path class="selected-branch-halo" bind:this={selectedBranchHaloPath}></path>
1150
+ <path class="selected-branch-line" bind:this={selectedBranchLinePath}></path>
1151
+ </svg>
1152
+ {/key}
1153
+ {/if}
1154
+ {#if selectedMultiDetail}
1155
+ <!-- Content is built imperatively in syncMultiDetail (screen-space
1156
+ projection); file-derived text is escaped there. -->
1157
+ <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1158
+ {/if}
1159
+ </div>
1160
+ {/key}
1161
+
1162
+ {#if app.placingId && map}
1163
+ <button
1164
+ type="button"
1165
+ class="place-at-center mono"
1166
+ onclick={() => {
1167
+ if (!map) return;
1168
+ const center = map.getCenter();
1169
+ onplacecase(center.lng, center.lat);
1170
+ }}
1171
+ >
1172
+ place at map center
1173
+ </button>
1174
+ {/if}
1175
+
1176
+ <style>
1177
+ .map-stage,
1178
+ .map {
1179
+ position: absolute;
1180
+ inset: 0;
1181
+ }
1182
+
1183
+ .map {
1184
+ background: var(--bg);
1185
+ }
1186
+
1187
+ .selected-bus-cue {
1188
+ position: absolute;
1189
+ z-index: calc(var(--z-chrome) - 1);
1190
+ display: none;
1191
+ width: 28px;
1192
+ height: 28px;
1193
+ margin: -14px 0 0 -14px;
1194
+ border: 2px solid #2f6fbb;
1195
+ border-radius: 999px;
1196
+ background: rgba(47, 111, 187, 0.1);
1197
+ box-shadow:
1198
+ 0 0 0 4px rgba(47, 111, 187, 0.12),
1199
+ 0 0 18px rgba(47, 111, 187, 0.24);
1200
+ pointer-events: none;
1201
+ animation:
1202
+ selected-bus-pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1),
1203
+ selected-bus-pulse 1.8s ease-in-out 420ms infinite;
1204
+ }
1205
+
1206
+ .selected-branch-cue {
1207
+ position: absolute;
1208
+ inset: 0;
1209
+ z-index: calc(var(--z-chrome) - 1);
1210
+ display: none;
1211
+ width: 100%;
1212
+ height: 100%;
1213
+ overflow: visible;
1214
+ pointer-events: none;
1215
+ }
1216
+
1217
+ .multi-detail {
1218
+ position: absolute;
1219
+ inset: 0;
1220
+ z-index: calc(var(--z-chrome) - 1);
1221
+ display: none;
1222
+ width: 100%;
1223
+ height: 100%;
1224
+ overflow: visible;
1225
+ pointer-events: none;
1226
+ }
1227
+
1228
+ .multi-detail :global(.mc-leader) {
1229
+ stroke: rgba(32, 36, 43, 0.35);
1230
+ stroke-width: 1;
1231
+ stroke-dasharray: 2 2;
1232
+ }
1233
+
1234
+ .multi-detail :global(.mc-ground line) {
1235
+ stroke: #3a3a3a;
1236
+ stroke-width: 1.3;
1237
+ }
1238
+
1239
+ .multi-detail :global(.mc-term) {
1240
+ fill: var(--ink, #20242b);
1241
+ font-family: var(--font-mono);
1242
+ font-size: 10px;
1243
+ }
1244
+
1245
+ .selected-branch-cue path {
1246
+ fill: none;
1247
+ stroke-linecap: round;
1248
+ stroke-linejoin: round;
1249
+ vector-effect: non-scaling-stroke;
1250
+ }
1251
+
1252
+ .selected-branch-halo {
1253
+ stroke: rgba(47, 111, 187, 0.2);
1254
+ stroke-width: 14;
1255
+ filter: drop-shadow(0 0 14px rgba(47, 111, 187, 0.2));
1256
+ animation:
1257
+ selected-branch-pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1),
1258
+ selected-branch-pulse 1.8s ease-in-out 420ms infinite;
1259
+ }
1260
+
1261
+ .selected-branch-line {
1262
+ stroke: rgba(47, 111, 187, 0.92);
1263
+ stroke-width: 3;
1264
+ animation: selected-branch-line-pop 420ms cubic-bezier(0.34, 1.56, 0.64, 1);
1265
+ }
1266
+
1267
+ @keyframes selected-bus-pop {
1268
+ 0% {
1269
+ opacity: 0;
1270
+ transform: scale(0.35);
1271
+ }
1272
+ 65% {
1273
+ opacity: 0.95;
1274
+ transform: scale(1.35);
1275
+ }
1276
+ 100% {
1277
+ opacity: 0.9;
1278
+ transform: scale(1);
1279
+ }
1280
+ }
1281
+
1282
+ @keyframes selected-bus-pulse {
1283
+ 0%,
1284
+ 100% {
1285
+ opacity: 0.9;
1286
+ transform: scale(1);
1287
+ box-shadow:
1288
+ 0 0 0 4px rgba(47, 111, 187, 0.12),
1289
+ 0 0 18px rgba(47, 111, 187, 0.24);
1290
+ }
1291
+ 50% {
1292
+ opacity: 0.48;
1293
+ transform: scale(1.22);
1294
+ box-shadow:
1295
+ 0 0 0 8px rgba(47, 111, 187, 0.08),
1296
+ 0 0 28px rgba(47, 111, 187, 0.18);
1297
+ }
1298
+ }
1299
+
1300
+ @keyframes selected-branch-pop {
1301
+ 0% {
1302
+ opacity: 0;
1303
+ stroke-width: 4;
1304
+ }
1305
+ 65% {
1306
+ opacity: 0.95;
1307
+ stroke-width: 18;
1308
+ }
1309
+ 100% {
1310
+ opacity: 0.82;
1311
+ stroke-width: 14;
1312
+ }
1313
+ }
1314
+
1315
+ @keyframes selected-branch-line-pop {
1316
+ 0% {
1317
+ opacity: 0;
1318
+ stroke-width: 1;
1319
+ }
1320
+ 65% {
1321
+ opacity: 1;
1322
+ stroke-width: 4;
1323
+ }
1324
+ 100% {
1325
+ opacity: 0.95;
1326
+ stroke-width: 3;
1327
+ }
1328
+ }
1329
+
1330
+ @keyframes selected-branch-pulse {
1331
+ 0%,
1332
+ 100% {
1333
+ opacity: 0.82;
1334
+ stroke-width: 14;
1335
+ filter: drop-shadow(0 0 14px rgba(47, 111, 187, 0.2));
1336
+ }
1337
+ 50% {
1338
+ opacity: 0.42;
1339
+ stroke-width: 22;
1340
+ filter: drop-shadow(0 0 24px rgba(47, 111, 187, 0.16));
1341
+ }
1342
+ }
1343
+
1344
+ /* Keyboard/touch equivalent to clicking the map while placing a local case;
1345
+ sits just above PlacementCue's instruction text so the two don't overlap. */
1346
+ .place-at-center {
1347
+ position: absolute;
1348
+ left: 50%;
1349
+ bottom: 92px;
1350
+ z-index: 15;
1351
+ transform: translateX(-50%);
1352
+ padding: 7px 14px;
1353
+ background: var(--panel);
1354
+ border: 1px solid var(--accent);
1355
+ border-radius: 3px;
1356
+ color: var(--text-accent);
1357
+ font-size: 11px;
1358
+ cursor: pointer;
1359
+ box-shadow: 0 4px 18px rgba(32, 36, 43, 0.1);
1360
+ }
1361
+
1362
+ @media (max-width: 420px) {
1363
+ .place-at-center {
1364
+ bottom: 78px;
1365
+ }
1366
+ }
1367
+
1368
+ .map :global(.maplibregl-ctrl-bottom-right) {
1369
+ right: 20px;
1370
+ bottom: 18px;
1371
+ display: flex;
1372
+ flex-direction: column;
1373
+ align-items: flex-end;
1374
+ gap: 6px;
1375
+ }
1376
+
1377
+ .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl) {
1378
+ float: none;
1379
+ margin: 0;
1380
+ }
1381
+
1382
+ .map :global(.maplibregl-ctrl-attrib) {
1383
+ order: -1;
1384
+ position: relative;
1385
+ right: 2px;
1386
+ width: auto;
1387
+ min-width: 0;
1388
+ max-width: calc(100vw - 96px);
1389
+ box-sizing: border-box;
1390
+ background: rgba(252, 251, 247, 0.75);
1391
+ font-family: var(--font-mono);
1392
+ font-size: 10px;
1393
+ white-space: nowrap;
1394
+ text-align: right;
1395
+ box-shadow: var(--elev-1);
1396
+ }
1397
+
1398
+ .map :global(.maplibregl-ctrl-attrib a) {
1399
+ color: var(--text-secondary);
1400
+ }
1401
+
1402
+ .map :global(.deck-tooltip) {
1403
+ background: var(--panel) !important;
1404
+ border: 1px solid var(--line);
1405
+ color: var(--ink) !important;
1406
+ font-family: var(--font-mono);
1407
+ font-size: 11px;
1408
+ line-height: 1.5;
1409
+ padding: 8px 10px !important;
1410
+ border-radius: 2px;
1411
+ box-shadow: 0 2px 10px rgba(32, 36, 43, 0.12);
1412
+ }
1413
+
1414
+ @media (max-width: 760px) {
1415
+ .map :global(.maplibregl-ctrl-attrib) {
1416
+ width: auto;
1417
+ min-width: 0;
1418
+ right: 0;
1419
+ white-space: normal;
1420
+ }
1421
+ }
1422
+
1423
+ @media (prefers-reduced-motion: reduce) {
1424
+ .selected-bus-cue,
1425
+ .selected-branch-halo,
1426
+ .selected-branch-line {
1427
+ animation: none;
1428
+ }
1429
+ }
1430
+ </style>