@tellegen/svelte 0.2.0 → 0.4.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 (88) hide show
  1. package/README.md +269 -5
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +518 -202
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +40 -4
  6. package/dist/TellegenShell.svelte +24 -46
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +18 -3
  9. package/dist/api.js +8 -2
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +25 -7
  13. package/dist/colors.js +39 -13
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +44 -19
  16. package/dist/components/BindingLines.svelte +4 -7
  17. package/dist/components/BusPicker.svelte +11 -5
  18. package/dist/components/ControlPanel.svelte +106 -343
  19. package/dist/components/DemandSlider.svelte +36 -29
  20. package/dist/components/DiagramCanvas.svelte +486 -0
  21. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  22. package/dist/components/DisplayControls.svelte +8 -2
  23. package/dist/components/DropZone.svelte +1 -1
  24. package/dist/components/FormulationSelector.svelte +9 -4
  25. package/dist/components/LocalCaseDetails.svelte +61 -48
  26. package/dist/components/McResults.svelte +428 -0
  27. package/dist/components/McResults.svelte.d.ts +17 -0
  28. package/dist/components/ModelDetails.svelte +230 -0
  29. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  30. package/dist/components/MulticonductorDetails.svelte +232 -83
  31. package/dist/components/NetworkStats.svelte +20 -18
  32. package/dist/components/NotificationCenter.svelte +245 -0
  33. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  34. package/dist/components/PanelCard.svelte +384 -0
  35. package/dist/components/PanelCard.svelte.d.ts +9 -0
  36. package/dist/components/PanelFrame.svelte +46 -0
  37. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  38. package/dist/components/PanelHost.svelte +237 -0
  39. package/dist/components/PanelHost.svelte.d.ts +6 -0
  40. package/dist/components/PlacementCue.svelte +1 -1
  41. package/dist/components/PowerFlowDetails.svelte +99 -0
  42. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  43. package/dist/components/RatingSlider.svelte +19 -14
  44. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  45. package/dist/components/SavedNetworkDetails.svelte +214 -0
  46. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  47. package/dist/components/SensitivityReadout.svelte +26 -18
  48. package/dist/components/ShellEnvironment.svelte +51 -0
  49. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  50. package/dist/components/SizeLegend.svelte +2 -2
  51. package/dist/components/SolveCard.svelte +48 -51
  52. package/dist/components/TopMovers.svelte +9 -4
  53. package/dist/components/TopMovers.svelte.d.ts +2 -17
  54. package/dist/components.d.ts +5 -0
  55. package/dist/components.js +5 -0
  56. package/dist/context.svelte.d.ts +8 -1
  57. package/dist/context.svelte.js +8 -3
  58. package/dist/controller.svelte.d.ts +117 -36
  59. package/dist/controller.svelte.js +1133 -253
  60. package/dist/diagram-labels.d.ts +9 -0
  61. package/dist/diagram-labels.js +40 -0
  62. package/dist/display.d.ts +3 -7
  63. package/dist/display.js +31 -27
  64. package/dist/format.d.ts +9 -0
  65. package/dist/format.js +17 -4
  66. package/dist/index.d.ts +15 -5
  67. package/dist/index.js +9 -2
  68. package/dist/map.d.ts +4 -2
  69. package/dist/map.js +2 -1
  70. package/dist/maplibre-worker.d.ts +5 -0
  71. package/dist/maplibre-worker.dev.d.ts +11 -0
  72. package/dist/maplibre-worker.dev.js +15 -0
  73. package/dist/maplibre-worker.js +8 -0
  74. package/dist/multiconductor.d.ts +11 -2
  75. package/dist/multiconductor.js +107 -14
  76. package/dist/notices.svelte.d.ts +29 -0
  77. package/dist/notices.svelte.js +86 -0
  78. package/dist/panels.svelte.d.ts +71 -0
  79. package/dist/panels.svelte.js +245 -0
  80. package/dist/slider.d.ts +12 -0
  81. package/dist/slider.js +31 -0
  82. package/dist/state.svelte.d.ts +107 -23
  83. package/dist/state.svelte.js +106 -20
  84. package/dist/styles.css +59 -6
  85. package/dist/synthetic-layout.js +1 -0
  86. package/package.json +29 -11
  87. package/dist/study-package.d.ts +0 -8
  88. package/dist/study-package.js +0 -17
@@ -1,25 +1,41 @@
1
1
  <script lang="ts">
2
2
  import 'maplibre-gl/dist/maplibre-gl.css';
3
- import { tick } from 'svelte';
3
+ import { tick, untrack } from 'svelte';
4
4
  import type { Layer, PickingInfo } from '@deck.gl/core';
5
5
  import type { IconLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
6
- import type { MapboxOverlay } from '@deck.gl/mapbox';
6
+ import type { MapLibreOverlay } from '@deck.gl/maplibre';
7
7
  import type { LngLatBoundsLike, Map as MapLibreMap } from 'maplibre-gl';
8
8
  import type { NetworkBranch, NetworkBus } from './api.js';
9
9
  import {
10
+ MAP_PALETTE,
10
11
  branchColor,
11
12
  branchWidth,
12
13
  busNeutral,
13
14
  busRadius,
14
- lmpColor,
15
+ priceColor,
15
16
  scalarDomain,
16
17
  sensColor,
17
18
  sensFlatColor,
18
19
  sensitivityDomain,
19
20
  type SensitivityDomain
20
21
  } from './colors.js';
22
+ import {
23
+ attachBasemapFallback,
24
+ isOpenFreeMapStyle,
25
+ openFreeMapEdits,
26
+ resolveBasemap,
27
+ type BasemapFallbackReason,
28
+ type BasemapStyle,
29
+ type MapProjection,
30
+ type MapTheme
31
+ } from './basemap.js';
21
32
  import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
22
- import { CaseState, type DisplayMode, type LocalCase } from './state.svelte.js';
33
+ import {
34
+ CaseState,
35
+ type CameraSnapshot,
36
+ type DisplayMode,
37
+ type LocalCase
38
+ } from './state.svelte.js';
23
39
  import {
24
40
  attachmentColor,
25
41
  edgeColor,
@@ -29,13 +45,18 @@
29
45
  transformerMarks,
30
46
  type PlacedMultiBus,
31
47
  type PlacedMultiEdge,
32
- type TransformerMark
48
+ type TransformerMark,
49
+ multiDiagramNetwork
33
50
  } from './multiconductor.js';
34
51
  import { transformerIcon } from './transformer-icon.js';
35
52
  import { foldMapBounds } from './map-bounds.js';
36
53
  import { getAppState, getController } from './context.svelte.js';
37
54
  import { displayFmt, fmt, rgbaCss } from './format.js';
55
+ import { getPanelLayout } from './panels.svelte.js';
38
56
  import type { RGBA } from './colors.js';
57
+ import DiagramCanvas from './components/DiagramCanvas.svelte';
58
+
59
+ const DEFAULT_CAMERA: CameraSnapshot = { center: [-85, 36], zoom: 4.5, bearing: 0, pitch: 0 };
39
60
 
40
61
  let {
41
62
  onbusclick,
@@ -45,7 +66,19 @@
45
66
  onmultibusclick,
46
67
  onmultiedgeclick,
47
68
  onplacecase,
48
- onmapclick
69
+ onmapclick,
70
+ mapStyle,
71
+ fallbackMapStyle,
72
+ theme = 'light',
73
+ projection = 'mercator',
74
+ initialCamera = DEFAULT_CAMERA,
75
+ hideBasemapLabels = true,
76
+ cartoApiKey,
77
+ extraLayers = [],
78
+ attribution = 'compact',
79
+ onMapReady,
80
+ onBasemapFallback,
81
+ onBasemapError
49
82
  }: {
50
83
  onbusclick: (caseId: string, busId: number) => void;
51
84
  onlocalbusclick: (caseId: string, busId: number) => void;
@@ -55,18 +88,112 @@
55
88
  onmultiedgeclick?: (caseId: string, edgeId: string) => void;
56
89
  onplacecase: (lon: number, lat: number) => void;
57
90
  onmapclick: () => void;
91
+ /** Primary basemap: a style URL or inline style. Default: the CARTO style
92
+ * for `theme` when `cartoApiKey` is set, else the OpenFreeMap style.
93
+ * Changing it (or `fallbackMapStyle`, `theme`, `hideBasemapLabels`,
94
+ * `cartoApiKey`, `attribution`) rebuilds the map in place and keeps the
95
+ * camera. */
96
+ mapStyle?: BasemapStyle;
97
+ /** Basemap used when the primary cannot be shown; `null` disables the
98
+ * switch. Default: whichever of the CARTO and OpenFreeMap styles is not
99
+ * the default primary. */
100
+ fallbackMapStyle?: BasemapStyle | null;
101
+ /** Picks the default basemaps and the overlay palette (bus outlines,
102
+ * hover highlight, unsolved grey). */
103
+ theme?: MapTheme;
104
+ /** Re-applied after every `style.load`, so a basemap switch keeps it. */
105
+ projection?: MapProjection;
106
+ /** Camera for the first paint; read once per mount. */
107
+ initialCamera?: CameraSnapshot;
108
+ /** Hide the basemap's `symbol` layers after every `style.load`. */
109
+ hideBasemapLabels?: boolean;
110
+ /** CARTO basemap key (free for non-commercial use from
111
+ * carto.com/basemaps/apikey; restrict it to your hosts). With one, the
112
+ * CARTO style leads and OpenFreeMap is the fallback; without one, the
113
+ * order flips. Trimmed; a blank key counts as none. */
114
+ cartoApiKey?: string;
115
+ /** deck.gl layers drawn after tellegen's own, in the same overlay. */
116
+ extraLayers?: Layer[];
117
+ /** Attribution control mode. There is no off switch: tile licences
118
+ * require the credit on screen. */
119
+ attribution?: 'compact' | 'full';
120
+ /** Called once the map and overlay exist, with an optional cleanup. It
121
+ * fires again after a context-loss or basemap remount, after the previous
122
+ * cleanup ran. */
123
+ onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
124
+ /** The primary basemap failed and the fallback was requested. The
125
+ * reason's `from` and `to` name the providers (`'carto'`,
126
+ * `'openfreemap'` or `'custom'`). */
127
+ onBasemapFallback?: (reason: BasemapFallbackReason) => void;
128
+ /** No basemap can be shown; the network layers still draw. The reason's
129
+ * `from` names the provider that failed last, and `to` is `null`. */
130
+ onBasemapError?: (reason: BasemapFallbackReason) => void;
58
131
  } = $props();
59
132
 
60
133
  const app = getAppState();
61
134
  const ctrl = getController();
135
+ const panels = getPanelLayout();
136
+ const diagramCase = $derived(app.studyView ? null : (app.active ?? app.activeLocal));
137
+ const diagramMulti = $derived(
138
+ !app.studyView && app.activeMulti?.coordsKind !== 'geographic' ? app.activeMulti : null
139
+ );
140
+ const multiNetwork = $derived(
141
+ diagramMulti?.view
142
+ ? multiDiagramNetwork(diagramMulti.id, diagramMulti.label, diagramMulti.view)
143
+ : null
144
+ );
145
+ const displayedNetwork = $derived(app.studyView?.network ?? diagramCase?.network);
146
+ const diagramNetwork = $derived(
147
+ multiNetwork ?? (displayedNetwork?.coordinate_space === 'diagram' ? displayedNetwork : null)
148
+ );
149
+ const diagramCaseId = $derived(
150
+ app.studyView?.caseId ?? diagramMulti?.id ?? diagramCase?.id ?? 'diagram'
151
+ );
152
+ function selectDiagramBus(busId: number) {
153
+ if (diagramMulti?.view) {
154
+ const bus = diagramMulti.view.buses[busId];
155
+ if (bus) onmultibusclick(diagramMulti.id, bus.id);
156
+ return;
157
+ }
158
+ if (app.studyView) {
159
+ app.selectedBranch = null;
160
+ app.selectedBus = busId;
161
+ } else if (app.activeLocal) onlocalbusclick(diagramCaseId, busId);
162
+ else onbusclick(diagramCaseId, busId);
163
+ }
164
+ function selectDiagramBranch(branchId: number) {
165
+ if (diagramMulti?.view) {
166
+ const edge = diagramMulti.view.edges[branchId];
167
+ if (edge) onmultiedgeclick?.(diagramMulti.id, edge.id);
168
+ return;
169
+ }
170
+ if (app.studyView) {
171
+ app.selectedBus = null;
172
+ app.selectedBranch = branchId;
173
+ } else if (app.activeLocal) onlocalbranchclick?.(diagramCaseId, branchId);
174
+ else onbranchclick?.(diagramCaseId, branchId);
175
+ }
62
176
 
63
- const STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
177
+ const basemap = $derived(
178
+ resolveBasemap({ mapStyle, fallbackMapStyle, theme, hideBasemapLabels, cartoApiKey })
179
+ );
180
+ // Read as separate values, not one object, so a theme change that resolves
181
+ // to the same styles does not rebuild the map.
182
+ const basemapPrimary = $derived(basemap.primary);
183
+ const basemapFallback = $derived(basemap.fallback);
184
+ const basemapHideLabels = $derived(basemap.hideLabels);
185
+ const basemapPrimaryProvider = $derived(basemap.primaryProvider);
186
+ const basemapFallbackProvider = $derived(basemap.fallbackProvider);
187
+ const palette = $derived(MAP_PALETTE[theme]);
188
+ // Set once a map has existed in this component instance: a rebuild (context
189
+ // loss or basemap change) restores the camera instead of reframing.
190
+ let hadMap = false;
64
191
  const CUE_MARGIN_PX = 64;
65
192
  const BRANCH_FOCUS_MS = 850;
66
193
  const BRANCH_FOCUS_MAX_ZOOM = 9.5;
67
194
 
68
195
  let map = $state.raw<MapLibreMap | null>(null);
69
- let overlay = $state.raw<MapboxOverlay | null>(null);
196
+ let overlay = $state.raw<MapLibreOverlay | null>(null);
70
197
  // Bumped to remount the map after a WebGL context is lost and cannot be
71
198
  // repainted in place. See initMap's context-loss handling.
72
199
  let mapGen = $state(0);
@@ -85,7 +212,7 @@
85
212
  let layerCtors = $state.raw<LayerCtors | null>(null);
86
213
 
87
214
  interface CaseDisplay {
88
- lmp: Map<number, number>;
215
+ prices: Map<number, number>;
89
216
  scalar: Map<number, number>;
90
217
  scalarLo: number;
91
218
  scalarHi: number;
@@ -93,7 +220,7 @@
93
220
  scalarLabel: string;
94
221
  scalarUnit: string;
95
222
  loading: Map<number, number>;
96
- mode: 'lmp' | 'sens' | 'preview';
223
+ mode: 'price' | 'sens' | 'preview';
97
224
  sens: Map<number, number>;
98
225
  sensDomain: SensitivityDomain | null;
99
226
  preview: Map<number, number>;
@@ -106,7 +233,7 @@
106
233
  type SolvableCase = CaseState | LocalCase;
107
234
 
108
235
  // Everything the accessors need, rebuilt when any case's data moves. The
109
- // LMP scale normalizes per network: each case is an islanded market, so
236
+ // The LMP scale normalizes per network: each case is an islanded network, so
110
237
  // within network structure beats cross network color comparability (the
111
238
  // panel legend and tooltips carry the actual numbers). Preview values
112
239
  // shift individual buses without rescaling.
@@ -114,8 +241,8 @@
114
241
  const active = app.active ?? app.activeLocal;
115
242
  const selectedBus = app.selectedBus;
116
243
  const selectedBranch = app.selectedBranch;
117
- // The active case's column for the live selection: the ∂LMP/∂d column at the
118
- // selected bus, or the ∂LMP/∂rating column at the selected branch. Both are
244
+ // The active case's column for the live selection: the price-demand column at the
245
+ // selected bus, or the price-rating column at the selected branch. Both are
119
246
  // bus-keyed value maps, so the color pipeline below is target-agnostic.
120
247
  const activeSensitivity =
121
248
  active &&
@@ -125,18 +252,18 @@
125
252
  : null;
126
253
  const activeDeltas = active ? caseDeltas(active) : {};
127
254
  const activeRatings = active ? caseRatings(active) : {};
128
- // Engine first-order LMP preview (Study.preview): predicted per-bus ΔLMP for
255
+ // Engine first order preview (Study.preview): predicted per-bus LMP change for
129
256
  // the live drag, scoped to this case + selection target. Preferred over the JS
130
257
  // gradient shift when present; the gradient path stays for server and browser
131
258
  // fallbacks.
132
259
  const enginePreview =
133
260
  active &&
134
- app.previewLmp &&
135
- app.previewLmp.caseId === active.id &&
136
- ('bus' in app.previewLmp.target
137
- ? app.previewLmp.target.bus === selectedBus
138
- : app.previewLmp.target.branch === selectedBranch)
139
- ? app.previewLmp.delta
261
+ app.previewPrices &&
262
+ app.previewPrices.caseId === active.id &&
263
+ ('bus' in app.previewPrices.target
264
+ ? app.previewPrices.target.bus === selectedBus
265
+ : app.previewPrices.target.branch === selectedBranch)
266
+ ? app.previewPrices.delta
140
267
  : null;
141
268
  const previewStep =
142
269
  active && activeSensitivity
@@ -153,13 +280,13 @@
153
280
  const perCase = new Map<string, CaseDisplay>();
154
281
  const addCase = (c: CaseState | LocalCase) => {
155
282
  if (!c.network || !c.solution) return;
156
- const lmp = new Map<number, number>();
157
- for (const e of c.solution.lmp) lmp.set(e.bus, e.usd_per_mwh);
283
+ const prices = new Map<number, number>();
284
+ for (const e of c.solution.prices) prices.set(e.bus, e.value);
158
285
  // Resolve the active display mode against what this case can show, falling
159
- // back to LMP when the chosen mode doesn't apply (e.g. |V| under DC OPF).
286
+ // back to LMPs when the chosen mode doesn't apply (e.g. |V| under DC OPF).
160
287
  const meta = displayMetaFor(c);
161
288
  const activeMeta = meta.find((o) => o.mode === app.displayMode) ?? meta[0] ?? null;
162
- const scalarMode = activeMeta?.mode ?? 'lmp';
289
+ const scalarMode = activeMeta?.mode ?? 'price';
163
290
  const seriesValues = displaySeriesFor(c, scalarMode);
164
291
  const scalar = new Map<number, number>();
165
292
  for (const e of seriesValues) scalar.set(e.bus, e.value);
@@ -175,10 +302,10 @@
175
302
  let domain: SensitivityDomain | null = null;
176
303
  const isActive = c === active;
177
304
  if (isActive && enginePreview) {
178
- // Engine preview owns the LMP shift: add the predicted ΔLMP per bus.
179
- for (const [bus, dlmp] of enginePreview) {
180
- preview.set(bus, dlmp);
181
- lmp.set(bus, (lmp.get(bus) ?? 0) + dlmp);
305
+ // Engine preview owns the LMP shift.
306
+ for (const [bus, deltaPrice] of enginePreview) {
307
+ preview.set(bus, deltaPrice);
308
+ prices.set(bus, (prices.get(bus) ?? 0) + deltaPrice);
182
309
  }
183
310
  }
184
311
  if (isActive && activeSensitivity) {
@@ -190,9 +317,9 @@
190
317
  if (!enginePreview && previewStep !== 0) {
191
318
  // Fallback first order preview: shift LMPs along the gradient.
192
319
  for (const v of activeSensitivity.values) {
193
- const dlmp = v.value * previewStep;
194
- preview.set(v.bus, dlmp);
195
- lmp.set(v.bus, (lmp.get(v.bus) ?? 0) + dlmp);
320
+ const deltaPrice = v.value * previewStep;
321
+ preview.set(v.bus, deltaPrice);
322
+ prices.set(v.bus, (prices.get(v.bus) ?? 0) + deltaPrice);
196
323
  }
197
324
  }
198
325
  }
@@ -203,16 +330,15 @@
203
330
  previewDomain &&
204
331
  (app.previewActive || c.solving || enginePreview || Math.abs(previewStep) >= 0.25)
205
332
  );
206
- // The sensitivity and demand-preview overlays are price-space fields (∂LMP/∂d
207
- // and the ΔLMP preview). They only paint the map under the LMP display mode;
333
+ // The sensitivity and demand preview overlays are LMP fields. They only paint the map under the price display mode;
208
334
  // under angle or |V| the map stays on the chosen scalar and the sensitivity
209
335
  // shows in the side panel, so selecting a bus never silently overrides the
210
336
  // angle/|V| coloring with the price ramp.
211
- const sensEligible = scalarMode === 'lmp';
337
+ const sensEligible = scalarMode === 'price';
212
338
  // While a sensitivity is still loading for the selected bus, stay in sens
213
- // mode (renders neutral, not the prior LMP palette) so the node colors
339
+ // mode (renders neutral, not the prior price palette) so the node colors
214
340
  // don't flash orange->purple between the old and new selection.
215
- const mode: 'lmp' | 'sens' | 'preview' =
341
+ const mode: 'price' | 'sens' | 'preview' =
216
342
  sensEligible && showPreview
217
343
  ? 'preview'
218
344
  : sensEligible &&
@@ -221,15 +347,15 @@
221
347
  (domain || app.sensitivityLoading) &&
222
348
  !previewing
223
349
  ? 'sens'
224
- : 'lmp';
350
+ : 'price';
225
351
  perCase.set(c.id, {
226
- lmp,
352
+ prices,
227
353
  scalar,
228
354
  scalarLo,
229
355
  scalarHi,
230
356
  scalarMode,
231
357
  scalarLabel: activeMeta?.label ?? 'LMP',
232
- scalarUnit: activeMeta?.unit ?? '$/MWh',
358
+ scalarUnit: activeMeta?.unit ?? 'objective units/MW',
233
359
  loading,
234
360
  mode,
235
361
  sens,
@@ -266,7 +392,7 @@
266
392
  return sensColor((d.preview.get(bus.id) ?? 0) / d.previewDomain.scale);
267
393
  }
268
394
  const mid = (d.scalarLo + d.scalarHi) / 2;
269
- return lmpColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
395
+ return priceColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
270
396
  };
271
397
  }
272
398
 
@@ -282,34 +408,38 @@
282
408
  // click handler must not treat those clicks as "empty map" and clear the selection.
283
409
  function isSelectableLayer(layerId: string | undefined): boolean {
284
410
  return Boolean(
411
+ layerId?.startsWith('study-') ||
285
412
  layerId?.startsWith('buses-') ||
286
- layerId?.startsWith('local-buses-') ||
287
- layerId?.startsWith('branches-') ||
288
- layerId?.startsWith('local-branches-') ||
289
- layerId?.startsWith('multi-buses-') ||
290
- layerId?.startsWith('multi-edges-')
413
+ layerId?.startsWith('local-buses-') ||
414
+ layerId?.startsWith('branches-') ||
415
+ layerId?.startsWith('local-branches-') ||
416
+ layerId?.startsWith('multi-buses-') ||
417
+ layerId?.startsWith('multi-edges-')
291
418
  );
292
419
  }
293
420
 
421
+ function displayedGeometry(caseId: string) {
422
+ if (app.studyView) return app.studyView.caseId === caseId ? app.studyView.network : null;
423
+ const local = app.localCases.find((c) => c.id === caseId);
424
+ return app.byId(caseId)?.network ?? local?.view ?? local?.network ?? null;
425
+ }
294
426
  const selectedBusCue = $derived.by(() => {
295
427
  const busId = app.selectedBus;
296
- const c = app.active ?? app.activeLocal;
297
- if (!c || busId === null) return null;
298
- const buses =
299
- app.active?.network?.buses ?? app.activeLocal?.network?.buses ?? app.activeLocal?.view?.buses ?? [];
300
- const bus = buses.find((b) => b.id === busId);
301
- return bus ? { key: `${c.id}-${bus.id}`, id: bus.id, lon: bus.lon, lat: bus.lat } : null;
428
+ const caseId = app.studyView?.caseId ?? app.active?.id ?? app.activeLocal?.id;
429
+ if (!caseId || busId === null) return null;
430
+ const bus = displayedGeometry(caseId)?.buses.find((b) => b.id === busId);
431
+ return bus
432
+ ? { key: `${app.studyView?.id ?? caseId}-${bus.id}`, id: bus.id, lon: bus.lon, lat: bus.lat }
433
+ : null;
302
434
  });
303
435
  const selectedBranchCue = $derived.by(() => {
304
436
  const branchId = app.selectedBranch;
305
- const c = app.active ?? app.activeLocal;
306
- if (!c || branchId === null) return null;
307
- const branches =
308
- c instanceof CaseState ? c.network?.branches : (c.view?.branches ?? c.network?.branches);
309
- const branch = branches?.find((b) => b.id === branchId);
437
+ const caseId = app.studyView?.caseId ?? app.active?.id ?? app.activeLocal?.id;
438
+ if (!caseId || branchId === null) return null;
439
+ const branch = displayedGeometry(caseId)?.branches.find((b) => b.id === branchId);
310
440
  const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
311
441
  return branch && path && path.length >= 2
312
- ? { key: `${c.id}-${branch.id}`, caseId: c.id, branchId: branch.id, path }
442
+ ? { key: `${app.studyView?.id ?? caseId}-${branch.id}`, caseId, branchId: branch.id, path }
313
443
  : null;
314
444
  });
315
445
  let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
@@ -317,6 +447,7 @@
317
447
  let selectedBranchHaloPath = $state.raw<SVGPathElement | null>(null);
318
448
  let selectedBranchLinePath = $state.raw<SVGPathElement | null>(null);
319
449
  let handledFrameSeq = 0;
450
+ let handledCameraSeq = 0;
320
451
 
321
452
  // The selected multiconductor bus and its incident edges, whose per-conductor
322
453
  // strands and terminal stack the detail overlay draws in screen space.
@@ -460,9 +591,9 @@
460
591
  terminals.forEach((t, k) => {
461
592
  const cy = top + k * STACK_DY;
462
593
  const grounded = bus.grounded.includes(t);
463
- const fill = isPhaseTerminal(t) ? col(phaseColor(t)) : col([120, 114, 102, 255]);
594
+ const fill = isPhaseTerminal(t) ? col(phaseColor(t)) : col(palette.neutral);
464
595
  if (grounded) stack += groundGlyph(sx - 14, cy);
465
- stack += `<circle cx="${sx.toFixed(1)}" cy="${cy.toFixed(1)}" r="6" fill="${fill}" stroke="#20242b" stroke-width="1.2"/>`;
596
+ stack += `<circle class="mc-terminal" cx="${sx.toFixed(1)}" cy="${cy.toFixed(1)}" r="6" fill="${fill}"/>`;
466
597
  stack += `<text class="mc-term" x="${(sx + 11).toFixed(1)}" y="${(cy + 3.5).toFixed(1)}">${esc(t)}</text>`;
467
598
  // Attachment squares for elements landing on this terminal.
468
599
  const kinds = [...new Set((bus.terminalAttachments[t] ?? []).map((a) => a.kind))];
@@ -492,18 +623,23 @@
492
623
  });
493
624
  }
494
625
 
495
- /** Camera padding that keeps the framed subject clear of the panel chrome.
496
- * The branch variant also clears the rating readout on the panel's lower edge. */
626
+ /** Camera framing reserves the dock columns and the active compact drawer. */
497
627
  function framePadding(width: number, height: number, kind: 'case' | 'branch') {
498
- if (width <= 760) {
499
- // bottom follows the sheet; capped so a fully open sheet still leaves a band to fit into
500
- const clearance = kind === 'branch' ? 16 : 28;
501
- const bottom = Math.min(app.sheetInset + clearance, Math.round(height * 0.55));
502
- return { top: app.headerInset + 8, left: 24, right: 24, bottom };
628
+ const clearance = kind === 'branch' ? 16 : 28;
629
+ if (panels.compact) {
630
+ return {
631
+ top: panels.top + 8,
632
+ left: 24,
633
+ right: 24,
634
+ bottom: Math.min(app.sheetInset + clearance, Math.round(height * 0.6))
635
+ };
503
636
  }
504
- return kind === 'branch'
505
- ? { top: 106, left: 388, right: 76, bottom: 80 }
506
- : { top: 96, left: 380, right: 60, bottom: 64 };
637
+ return {
638
+ top: panels.top + 16,
639
+ left: Math.min(panels.dockWidth('left') + 44, width * 0.34),
640
+ right: Math.min(panels.dockWidth('right') + 44, width * 0.34),
641
+ bottom: 80
642
+ };
507
643
  }
508
644
 
509
645
  function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
@@ -512,8 +648,7 @@
512
648
 
513
649
  function prefersReducedMotion(): boolean {
514
650
  return (
515
- typeof window !== 'undefined' &&
516
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
651
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
517
652
  );
518
653
  }
519
654
 
@@ -521,25 +656,9 @@
521
656
  return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
522
657
  }
523
658
 
524
- // Compact chrome anchoring. maplibre's bottom-right controls sit over the map,
525
- // so the sheet would bury them; they ride above it instead. Under
526
- // CHROME_MIN_BAND_PX of map left they drop out rather than climb into the
527
- // header — at that point the sheet covers what they annotate.
528
- const CHROME_MIN_BAND_PX = 132;
529
-
530
- /** How far the sheet can push the chrome up before it would reach the header. */
531
- const chromeLiftCap = $derived(
532
- Math.max(0, app.viewportHeight - app.headerInset - CHROME_MIN_BAND_PX)
533
- );
534
- const chromeInset = $derived(
535
- app.compactLayout ? Math.min(app.sheetInset, chromeLiftCap) : 0
536
- );
537
- const chromeVisible = $derived(!app.compactLayout || app.sheetInset <= chromeLiftCap);
538
-
539
- /** Fly to the selected branch; true when a camera move started. */
540
- function focusSelectedBranch(): boolean {
541
- if (!map || !selectedBranchCue) return false;
542
- const bounds = branchBounds(selectedBranchCue.path);
659
+ function focusGeometry(points: [number, number][], settle: () => void): boolean {
660
+ if (!map) return false;
661
+ const bounds = branchBounds(points);
543
662
  if (!bounds) return false;
544
663
  const { clientWidth, clientHeight } = map.getContainer();
545
664
  const camera = map.cameraForBounds(bounds, {
@@ -547,6 +666,7 @@
547
666
  maxZoom: Math.min(map.getZoom() + 2, BRANCH_FOCUS_MAX_ZOOM)
548
667
  });
549
668
  if (!camera?.center) return false;
669
+ map.once('moveend', settle);
550
670
  map.easeTo({
551
671
  center: camera.center,
552
672
  zoom: camera.zoom ?? map.getZoom(),
@@ -580,6 +700,21 @@
580
700
  function tooltip(info: PickingInfo): { html: string } | null {
581
701
  const { object } = info;
582
702
  if (!object) return null;
703
+ if (app.studyView && info.layer?.id.startsWith('study-')) {
704
+ const view = app.studyView.solution;
705
+ const id = Number((object as { id: number }).id);
706
+ if (info.layer.id === 'study-branches') {
707
+ const flow = view?.flows?.find((f) => f.branch === id);
708
+ return {
709
+ html: `<b>branch ${id}</b><br>saved state: ${esc(app.studyView.label)}${flow ? `<br>P from ${flow.pf.toFixed(3)} MW, loading ${(100 * flow.loading).toFixed(1)}%` : ''}`
710
+ };
711
+ }
712
+ const p = view?.lmp?.find((v) => v.bus === id),
713
+ v = view?.vm?.find((v) => v.bus === id);
714
+ return {
715
+ html: `<b>bus ${id}</b><br>saved state: ${esc(app.studyView.label)}${p ? `<br>LMP ${p.value.toFixed(4)}` : ''}${v ? `<br>voltage ${v.value.toFixed(5)} pu` : ''}`
716
+ };
717
+ }
583
718
  if (info.layer?.id.startsWith('local-subs-')) {
584
719
  // PowerWorld .pwd substation: display data only, position inferred from the diagram.
585
720
  // The name is free text from a dropped file, so escape it; the number
@@ -587,7 +722,7 @@
587
722
  const s = object as { number: number; name: string };
588
723
  const named = s.name ? ` ${esc(s.name)}` : '';
589
724
  return {
590
- html: `<div class="tt"><b>substation ${Number(s.number)}</b>${named}<br><span style="opacity:0.6">.pwd diagram &#8901; approx. position</span></div>`
725
+ html: `<div class="tt"><b>substation ${Number(s.number)}</b>${named}<br><span style="opacity:0.6">.pwd diagram, approximate position</span></div>`
591
726
  };
592
727
  }
593
728
  const layerId = info.layer?.id;
@@ -627,7 +762,7 @@
627
762
  <span style="opacity:0.6">local file</span></div>`
628
763
  };
629
764
  }
630
- const lmp = d?.lmp.get(bus.id);
765
+ const price = d?.prices.get(bus.id);
631
766
  const scalar = d?.scalar.get(bus.id);
632
767
  const sens = d?.mode === 'sens' ? d.sens.get(bus.id) : undefined;
633
768
  const preview = d?.mode === 'preview' ? d.preview.get(bus.id) : undefined;
@@ -639,19 +774,19 @@
639
774
  const sensRow =
640
775
  sens === undefined
641
776
  ? ''
642
- : `<br>&part;LMP/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
777
+ : `<br>&part;price/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
643
778
  const previewRow =
644
779
  preview === undefined
645
780
  ? ''
646
- : `<br>&Delta;LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} $/MWh`;
781
+ : `<br>&Delta; LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
647
782
  const localRow =
648
783
  c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
649
784
  const scalarRow =
650
- !d || d.scalarMode === 'lmp'
651
- ? `LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`
785
+ !d || d.scalarMode === 'price'
786
+ ? `LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`
652
787
  : `${d.scalarLabel} ${
653
788
  scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
654
- } ${d.scalarUnit}<br>LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`;
789
+ } ${d.scalarUnit}<br>LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`;
655
790
  return {
656
791
  html: `<div class="tt"><b>bus ${bus.id}</b>
657
792
  ${scalarRow}<br>
@@ -660,14 +795,20 @@
660
795
  }
661
796
 
662
797
  async function loadMapModules() {
663
- const [maplibre, mapbox, layers] = await Promise.all([
798
+ const [maplibre, deckMap, layers, workerUrl] = await Promise.all([
664
799
  import('maplibre-gl'),
665
- import('@deck.gl/mapbox'),
666
- import('@deck.gl/layers')
800
+ import('@deck.gl/maplibre'),
801
+ import('@deck.gl/layers'),
802
+ // A package export, not a relative import: its `development`
803
+ // condition keeps the worker's `?worker&url` import out of the dev
804
+ // server's pre-bundle (see maplibre-worker.dev.ts).
805
+ import('@tellegen/svelte/maplibre-worker').then((m) => m.maplibreWorkerUrl())
667
806
  ]);
807
+ // Bundle the worker with its shared imports and configure it before creating a map.
808
+ maplibre.setWorkerUrl(workerUrl);
668
809
  return {
669
- maplibregl: maplibre.default,
670
- MapboxOverlay: mapbox.MapboxOverlay,
810
+ maplibregl: maplibre,
811
+ MapLibreOverlay: deckMap.MapLibreOverlay,
671
812
  PathLayer: layers.PathLayer,
672
813
  ScatterplotLayer: layers.ScatterplotLayer,
673
814
  IconLayer: layers.IconLayer
@@ -682,9 +823,9 @@
682
823
  // memory and tab pressure, and one context survives that far better; the
683
824
  // deck layers also inherit maplibre's 4096 canvas clamp. The flat positron
684
825
  // basemap writes no depth, so the overlay always draws on top.
685
- function buildOverlay(MapboxOverlay: MapModules['MapboxOverlay']): MapboxOverlay {
826
+ function buildOverlay(MapLibreOverlay: MapModules['MapLibreOverlay']): MapLibreOverlay {
686
827
  const touch = coarsePointer();
687
- return new MapboxOverlay({
828
+ return new MapLibreOverlay({
688
829
  interleaved: true,
689
830
  layers: [],
690
831
  parameters: {
@@ -704,21 +845,64 @@
704
845
  }
705
846
 
706
847
  function initMap(container: HTMLDivElement) {
848
+ // Attachments re-run when state they read changes, so reading the
849
+ // resolved basemap and attribution mode here is what rebuilds the map
850
+ // when those props change. The camera is read once: it is initial.
851
+ const spec = {
852
+ primary: basemapPrimary,
853
+ fallback: basemapFallback,
854
+ hideLabels: basemapHideLabels,
855
+ primaryProvider: basemapPrimaryProvider,
856
+ fallbackProvider: basemapFallbackProvider
857
+ };
858
+ const compactAttribution = attribution === 'compact';
859
+ const camera = untrack(() => initialCamera);
860
+ const restoreCamera = hadMap;
861
+ hadMap = true;
707
862
  let cleanup = () => {};
708
863
  let cancelled = false;
709
864
  void loadMapModules()
710
- .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
865
+ .then(({ maplibregl, MapLibreOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
711
866
  if (cancelled) return;
712
867
  layerCtors = { PathLayer, ScatterplotLayer, IconLayer };
713
868
  const m = new maplibregl.Map({
714
869
  container,
715
- style: STYLE,
716
- center: [-85, 36],
717
- zoom: 4.5,
870
+ style: spec.primary,
871
+ center: camera.center,
872
+ zoom: camera.zoom,
873
+ bearing: camera.bearing,
874
+ pitch: camera.pitch,
718
875
  canvasContextAttributes: { antialias: true },
719
- attributionControl: { compact: true }
876
+ attributionControl: { compact: compactAttribution }
877
+ });
878
+ // `setStyle` (the fallback path included) drops the projection and
879
+ // shows every layer again, so both are re-applied on each load.
880
+ const applyStyleExtras = () => {
881
+ m.setProjection({ type: untrack(() => projection) });
882
+ const style = m.getStyle();
883
+ if (spec.hideLabels) {
884
+ for (const layer of style.layers) {
885
+ if (layer.type === 'symbol') m.setLayoutProperty(layer.id, 'visibility', 'none');
886
+ }
887
+ }
888
+ // OpenFreeMap renders as the primary or as the fallback; either
889
+ // way it is tuned towards CARTO's quieter look.
890
+ if (!isOpenFreeMapStyle(style)) return;
891
+ const edits = openFreeMapEdits(
892
+ style,
893
+ untrack(() => theme)
894
+ );
895
+ for (const edit of edits) {
896
+ if (edit.kind === 'paint') m.setPaintProperty(edit.layer, edit.property, edit.value);
897
+ else m.setLayerZoomRange(edit.layer, edit.minzoom, edit.maxzoom);
898
+ }
899
+ };
900
+ m.on('style.load', applyStyleExtras);
901
+ const detachFallback = attachBasemapFallback(m, spec, {
902
+ onFallback: (reason) => onBasemapFallback?.(reason),
903
+ onFailure: (reason) => onBasemapError?.(reason)
720
904
  });
721
- const o = buildOverlay(MapboxOverlay);
905
+ const o = buildOverlay(MapLibreOverlay);
722
906
  m.addControl(o);
723
907
  m.on('click', (e) => {
724
908
  if (app.placingId) onplacecase(e.lngLat.lng, e.lngLat.lat);
@@ -743,6 +927,16 @@
743
927
  if (document.visibilityState === 'visible') repaint();
744
928
  };
745
929
  const syncCues = () => scheduleCueSync();
930
+ const saveCamera = () => {
931
+ const center = m.getCenter();
932
+ app.camera = {
933
+ center: [center.lng, center.lat],
934
+ zoom: m.getZoom(),
935
+ bearing: m.getBearing(),
936
+ pitch: m.getPitch()
937
+ };
938
+ };
939
+ m.on('moveend', saveCamera);
746
940
  let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
747
941
  const clearRebuild = () => {
748
942
  if (rebuildTimer !== null) {
@@ -793,11 +987,15 @@
793
987
 
794
988
  map = m;
795
989
  overlay = o;
990
+ const detachReady = onMapReady?.(m, o);
796
991
  // Frame the active grid once the map is ready. The initial frame request
797
992
  // (from the case load) can fire before the async map import finishes and
798
993
  // be lost; re-issuing it here ensures the first view is the active case,
799
- // not the default world view. A remount opens at the default view too.
800
- app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
994
+ // not the default world view. A rebuilt map opens at the default view
995
+ // too, so it restores the camera the last map saved.
996
+ if (restoreCamera && app.camera) m.jumpTo(app.camera);
997
+ else if (app.cameraSeq === handledCameraSeq)
998
+ app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
801
999
  if (mapGen > 0) {
802
1000
  // This is a rebuilt map. Once it has held a live context without
803
1001
  // another loss, zero the budget so the cap only ever catches a tight
@@ -817,6 +1015,9 @@
817
1015
  m.off('load', syncCues);
818
1016
  m.off('move', syncCues);
819
1017
  m.off('resize', syncCues);
1018
+ m.off('style.load', applyStyleExtras);
1019
+ detachFallback();
1020
+ detachReady?.();
820
1021
  app.settleFrame();
821
1022
  m.remove();
822
1023
  map = null;
@@ -832,12 +1033,95 @@
832
1033
  };
833
1034
  }
834
1035
 
1036
+ // A projection change applies at once when the style is ready. While a
1037
+ // style is still loading (the first paint, or just after a basemap switch)
1038
+ // MapLibre throws, and the `style.load` handler above applies it instead.
1039
+ $effect(() => {
1040
+ const type = projection;
1041
+ const m = map;
1042
+ if (!m) return;
1043
+ try {
1044
+ m.setProjection({ type });
1045
+ } catch {
1046
+ // style not loaded yet; applyStyleExtras runs on style.load
1047
+ }
1048
+ });
1049
+
835
1050
  // Sync deck.gl layers with app state. New layer instances diff cheaply;
836
- // updateTriggers tell deck.gl when accessor outputs changed.
1051
+ // updateTriggers tell deck.gl when accessor outputs changed. Host layers
1052
+ // (`extraLayers`) draw after tellegen's own at both exits.
837
1053
  $effect(() => {
838
1054
  if (!overlay || !layerCtors) return;
839
1055
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
840
1056
  const layers: Layer[] = [];
1057
+ const study = app.studyView;
1058
+ if (study) {
1059
+ const s = study.solution;
1060
+ const mode = app.displayMode;
1061
+ const series =
1062
+ mode === 'angle'
1063
+ ? (s?.va ?? [])
1064
+ : mode === 'voltage'
1065
+ ? (s?.vm ??
1066
+ (s?.w ?? []).map((v) => ({
1067
+ ...v,
1068
+ value: Math.sqrt(Math.max(0, v.value))
1069
+ })))
1070
+ : (s?.lmp ?? []);
1071
+ const values = new Map(series.map((v) => [v.bus, v.value]));
1072
+ const domain = scalarDomain(
1073
+ mode,
1074
+ series.map((v) => v.value)
1075
+ );
1076
+ const flows = new Map((s?.flows ?? []).map((f) => [f.branch, f.loading]));
1077
+ layers.push(
1078
+ new PathLayer<NetworkBranch>({
1079
+ id: 'study-branches',
1080
+ data: study.network.branches,
1081
+ getPath: (b) => b.path,
1082
+ getColor: (b) => branchColor(flows.get(b.id) ?? 0, b.status === 1),
1083
+ getWidth: (b) => branchWidth(flows.get(b.id) ?? 0),
1084
+ widthUnits: 'pixels',
1085
+ widthMinPixels: 1.5,
1086
+ pickable: true,
1087
+ onClick: (info: PickingInfo) => {
1088
+ const branch = info.object as NetworkBranch | undefined;
1089
+ if (!branch) return false;
1090
+ app.selectedBus = null;
1091
+ app.selectedBranch = branch.id;
1092
+ return true;
1093
+ },
1094
+ updateTriggers: { getColor: [study], getWidth: [study] }
1095
+ }),
1096
+ new ScatterplotLayer<NetworkBus>({
1097
+ id: 'study-buses',
1098
+ data: study.network.buses,
1099
+ getPosition: (b) => [b.lon, b.lat],
1100
+ getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
1101
+ radiusUnits: 'pixels',
1102
+ getFillColor: (b) => {
1103
+ const value = values.get(b.id);
1104
+ return value === undefined
1105
+ ? palette.grey
1106
+ : priceColor((value - domain.lo) / Math.max(domain.hi - domain.lo, 1e-12));
1107
+ },
1108
+ pickable: true,
1109
+ stroked: true,
1110
+ getLineColor: palette.outline,
1111
+ lineWidthUnits: 'pixels',
1112
+ onClick: (info: PickingInfo) => {
1113
+ const bus = info.object as NetworkBus | undefined;
1114
+ if (!bus) return false;
1115
+ app.selectedBranch = null;
1116
+ app.selectedBus = bus.id;
1117
+ return true;
1118
+ },
1119
+ updateTriggers: { getFillColor: [study, mode, theme] }
1120
+ })
1121
+ );
1122
+ overlay.setProps({ layers: [...layers, ...extraLayers] });
1123
+ return;
1124
+ }
841
1125
  for (const c of app.cases) {
842
1126
  if (!c.network) continue;
843
1127
  const d = display.get(c.id);
@@ -847,7 +1131,7 @@
847
1131
  data: c.network.branches,
848
1132
  getPath: (b) => b.path,
849
1133
  // The selected branch draws in the selection blue of the bus halo cue,
850
- // wider, so the ∂LMP/∂rating source line reads at a glance.
1134
+ // wider, so the price-rating source line reads at a glance.
851
1135
  getColor: (b) =>
852
1136
  c.id === app.activeCaseId && b.id === app.selectedBranch
853
1137
  ? [47, 111, 187, 255]
@@ -863,7 +1147,7 @@
863
1147
  miterLimit: 2,
864
1148
  pickable: true,
865
1149
  autoHighlight: true,
866
- highlightColor: [32, 36, 43, 90],
1150
+ highlightColor: palette.highlight,
867
1151
  onClick: (info: PickingInfo) => {
868
1152
  const branch = info.object as NetworkBranch | undefined;
869
1153
  if (!branch || !onbranchclick) return false;
@@ -886,13 +1170,13 @@
886
1170
  billboard: true,
887
1171
  getLineColor: (b) =>
888
1172
  c.id === app.activeCaseId && b.id === app.selectedBus
889
- ? [32, 36, 43, 255]
890
- : [46, 42, 34, 110],
1173
+ ? palette.selected
1174
+ : palette.outline,
891
1175
  getLineWidth: (b) => (c.id === app.activeCaseId && b.id === app.selectedBus ? 2.5 : 1),
892
1176
  lineWidthUnits: 'pixels',
893
1177
  pickable: true,
894
1178
  autoHighlight: true,
895
- highlightColor: [32, 36, 43, 70],
1179
+ highlightColor: palette.highlightSoft,
896
1180
  onClick: (info: PickingInfo) => {
897
1181
  const bus = info.object as NetworkBus | undefined;
898
1182
  if (!bus) return false;
@@ -901,7 +1185,7 @@
901
1185
  },
902
1186
  updateTriggers: {
903
1187
  getFillColor: [display],
904
- getLineColor: [app.selectedBus, app.activeCaseId],
1188
+ getLineColor: [app.selectedBus, app.activeCaseId, theme],
905
1189
  getLineWidth: [app.selectedBus, app.activeCaseId]
906
1190
  }
907
1191
  })
@@ -923,7 +1207,7 @@
923
1207
  ? [47, 111, 187, 255]
924
1208
  : d
925
1209
  ? branchColor(d.loading.get(b.id) ?? 0, b.status === 1)
926
- : [138, 131, 117, 150],
1210
+ : palette.greyLine,
927
1211
  getWidth: (b) =>
928
1212
  c.id === app.activeLocalId && b.id === app.selectedBranch
929
1213
  ? Math.max((d ? branchWidth(d.loading.get(b.id) ?? 0) : 1.5) + 2, 4.5)
@@ -937,7 +1221,7 @@
937
1221
  miterLimit: 2,
938
1222
  pickable: true,
939
1223
  autoHighlight: true,
940
- highlightColor: [32, 36, 43, 90],
1224
+ highlightColor: palette.highlight,
941
1225
  onClick: (info: PickingInfo) => {
942
1226
  const branch = info.object as NetworkBranch | undefined;
943
1227
  if (!branch || !onlocalbranchclick) return false;
@@ -945,7 +1229,7 @@
945
1229
  return true;
946
1230
  },
947
1231
  updateTriggers: {
948
- getColor: [display, app.selectedBranch, app.activeLocalId],
1232
+ getColor: [display, app.selectedBranch, app.activeLocalId, theme],
949
1233
  getWidth: [display, app.selectedBranch, app.activeLocalId]
950
1234
  }
951
1235
  }),
@@ -955,18 +1239,18 @@
955
1239
  getPosition: (b) => [b.lon, b.lat],
956
1240
  getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
957
1241
  radiusUnits: 'pixels',
958
- getFillColor: d ? busFill(c.id) : [110, 115, 120, 200],
1242
+ getFillColor: d ? busFill(c.id) : palette.grey,
959
1243
  stroked: true,
960
1244
  billboard: true,
961
1245
  getLineColor: (b) =>
962
1246
  c.id === app.activeLocalId && b.id === app.selectedBus
963
- ? [32, 36, 43, 255]
964
- : [46, 42, 34, 110],
1247
+ ? palette.selected
1248
+ : palette.outline,
965
1249
  getLineWidth: (b) => (c.id === app.activeLocalId && b.id === app.selectedBus ? 2.5 : 1),
966
1250
  lineWidthUnits: 'pixels',
967
1251
  pickable: true,
968
1252
  autoHighlight: true,
969
- highlightColor: [32, 36, 43, 70],
1253
+ highlightColor: palette.highlightSoft,
970
1254
  onClick: (info: PickingInfo) => {
971
1255
  const bus = info.object as NetworkBus | undefined;
972
1256
  if (!bus) return false;
@@ -975,7 +1259,7 @@
975
1259
  },
976
1260
  updateTriggers: {
977
1261
  getFillColor: [display],
978
- getLineColor: [app.selectedBus, app.activeLocalId],
1262
+ getLineColor: [app.selectedBus, app.activeLocalId, theme],
979
1263
  getLineWidth: [app.selectedBus, app.activeLocalId]
980
1264
  }
981
1265
  })
@@ -983,22 +1267,27 @@
983
1267
  }
984
1268
  if (c.substations) {
985
1269
  layers.push(
986
- new ScatterplotLayer<{ number: number; name: string; lon: number; lat: number }>({
1270
+ new ScatterplotLayer<{
1271
+ number: number;
1272
+ name: string;
1273
+ lon: number;
1274
+ lat: number;
1275
+ }>({
987
1276
  id: `local-subs-${c.id}`,
988
1277
  data: c.substations.points,
989
1278
  getPosition: (s) => [s.lon, s.lat],
990
1279
  getRadius: 5,
991
1280
  radiusUnits: 'pixels',
992
1281
  radiusMinPixels: 3,
993
- getFillColor: [70, 92, 124, 190],
1282
+ getFillColor: palette.substation,
994
1283
  stroked: true,
995
1284
  billboard: true,
996
- getLineColor: [38, 52, 78, 220],
1285
+ getLineColor: palette.substationLine,
997
1286
  getLineWidth: 1,
998
1287
  lineWidthUnits: 'pixels',
999
1288
  pickable: true,
1000
1289
  autoHighlight: true,
1001
- highlightColor: [32, 36, 43, 70]
1290
+ highlightColor: palette.highlightSoft
1002
1291
  })
1003
1292
  );
1004
1293
  }
@@ -1007,7 +1296,7 @@
1007
1296
  // kind and buses badged by attachment role. Selecting a bus fans its
1008
1297
  // incident conductors and expands its terminal stack in the SVG overlay.
1009
1298
  for (const c of app.multiCases) {
1010
- if (!c.view) continue;
1299
+ if (c.coordsKind !== 'geographic' || !c.view) continue;
1011
1300
  const selectedId = c.id === app.activeMultiId ? c.selectedBusId : null;
1012
1301
  const selectedEdgeId = c.id === app.activeMultiId ? c.selectedEdgeId : null;
1013
1302
  layers.push(
@@ -1027,7 +1316,7 @@
1027
1316
  miterLimit: 2,
1028
1317
  pickable: true,
1029
1318
  autoHighlight: true,
1030
- highlightColor: [32, 36, 43, 90],
1319
+ highlightColor: palette.highlight,
1031
1320
  onClick: (info: PickingInfo) => {
1032
1321
  const e = info.object as PlacedMultiEdge | undefined;
1033
1322
  if (!e || !onmultiedgeclick) return false;
@@ -1070,15 +1359,15 @@
1070
1359
  billboard: true,
1071
1360
  getLineColor: (b) =>
1072
1361
  selectedId === b.id
1073
- ? [32, 36, 43, 255]
1362
+ ? palette.selected
1074
1363
  : b.has_source
1075
1364
  ? [63, 111, 187, 220]
1076
- : [46, 42, 34, 120],
1365
+ : palette.outlineStrong,
1077
1366
  getLineWidth: (b) => (selectedId === b.id ? 2.5 : b.has_source ? 2 : 1),
1078
1367
  lineWidthUnits: 'pixels',
1079
1368
  pickable: true,
1080
1369
  autoHighlight: true,
1081
- highlightColor: [32, 36, 43, 70],
1370
+ highlightColor: palette.highlightSoft,
1082
1371
  onClick: (info: PickingInfo) => {
1083
1372
  const bus = info.object as PlacedMultiBus | undefined;
1084
1373
  if (!bus) return false;
@@ -1086,13 +1375,13 @@
1086
1375
  return true;
1087
1376
  },
1088
1377
  updateTriggers: {
1089
- getLineColor: [selectedId],
1378
+ getLineColor: [selectedId, theme],
1090
1379
  getLineWidth: [selectedId]
1091
1380
  }
1092
1381
  })
1093
1382
  );
1094
1383
  }
1095
- overlay.setProps({ layers });
1384
+ overlay.setProps({ layers: [...layers, ...extraLayers] });
1096
1385
  });
1097
1386
 
1098
1387
  $effect(() => {
@@ -1114,7 +1403,7 @@
1114
1403
  // does not re-run (and re-pan) on every frame of a sheet drag.
1115
1404
  $effect(() => {
1116
1405
  const cue = selectedBusCue;
1117
- if (!map || !cue || !app.compactLayout) return;
1406
+ if (!map || !cue || !app.compactLayout || app.studyView) return;
1118
1407
  void cue.key;
1119
1408
  const m = map;
1120
1409
  const timer = setTimeout(() => {
@@ -1129,6 +1418,7 @@
1129
1418
  });
1130
1419
 
1131
1420
  function boundsFor(target: string): LngLatBoundsLike | null {
1421
+ if (app.studyView) return foldMapBounds(app.studyView.network.buses.map((b) => [b.lon, b.lat]));
1132
1422
  const points: [number, number][] = [];
1133
1423
  const fold = (pts: { lon: number; lat: number }[]) => {
1134
1424
  for (const b of pts) points.push([b.lon, b.lat]);
@@ -1143,28 +1433,41 @@
1143
1433
  if (c.substations) fold(c.substations.points);
1144
1434
  }
1145
1435
  for (const c of app.multiCases) {
1436
+ if (c.coordsKind !== 'geographic') continue;
1146
1437
  if (target !== 'all' && c.id !== target) continue;
1147
1438
  if (c.view) fold(c.view.buses);
1148
1439
  }
1149
1440
  return foldMapBounds(points);
1150
1441
  }
1151
1442
 
1443
+ $effect(() => {
1444
+ const seq = app.cameraSeq;
1445
+ const snapshot = app.cameraRequest;
1446
+ if (diagramNetwork || !map || !snapshot || seq === handledCameraSeq) return;
1447
+ handledCameraSeq = seq;
1448
+ handledFrameSeq = app.frameSeq;
1449
+ map.jumpTo(snapshot);
1450
+ });
1451
+
1152
1452
  // Fly to whatever the header, the initial load, or a branch selection asked
1153
1453
  // for, and settle the request's promise once the camera lands so callers can
1154
1454
  // defer heavy work past the animation. A branch request waits (unhandled)
1155
1455
  // until its cue exists; anything unframeable settles immediately.
1156
1456
  $effect(() => {
1157
1457
  const seq = app.frameSeq;
1158
- if (!map || seq === handledFrameSeq) return;
1458
+ if (diagramNetwork || !map || seq === handledFrameSeq) return;
1159
1459
  const m = map;
1160
1460
  const target = app.frameTarget;
1161
1461
  const settle = () => app.settleFrame();
1162
1462
  if (typeof target === 'object') {
1163
- const cue = selectedBranchCue;
1164
- if (!cue || cue.caseId !== target.caseId || cue.branchId !== target.branchId) return;
1463
+ const geometry = displayedGeometry(target.caseId);
1464
+ const bus =
1465
+ 'busId' in target ? geometry?.buses.find((b) => b.id === target.busId) : undefined;
1466
+ const branch =
1467
+ 'branchId' in target ? geometry?.branches.find((b) => b.id === target.branchId) : undefined;
1165
1468
  handledFrameSeq = seq;
1166
- if (focusSelectedBranch()) m.once('moveend', settle);
1167
- else settle();
1469
+ const points: [number, number][] = bus ? [[bus.lon, bus.lat]] : (branch?.path ?? []);
1470
+ if (!focusGeometry(points, settle)) settle();
1168
1471
  return;
1169
1472
  }
1170
1473
  handledFrameSeq = seq;
@@ -1174,47 +1477,53 @@
1174
1477
  return;
1175
1478
  }
1176
1479
  const { clientWidth: w, clientHeight: h } = m.getContainer();
1480
+ m.once('moveend', settle);
1177
1481
  m.fitBounds(bounds, {
1178
1482
  padding: framePadding(w, h, 'case'),
1179
1483
  duration: prefersReducedMotion() ? 0 : 1400
1180
1484
  });
1181
- m.once('moveend', settle);
1182
1485
  });
1183
1486
  </script>
1184
1487
 
1185
- {#key mapGen}
1186
- <div
1187
- class="map-stage"
1188
- class:chrome-clear={!chromeVisible}
1189
- style="--chrome-inset: {chromeInset}px"
1190
- >
1191
- <div class="map" {@attach initMap}></div>
1192
- {#if selectedBusCue}
1193
- {#key selectedBusCue.key}
1194
- <div class="selected-bus-cue" bind:this={selectedCueEl} aria-hidden="true"></div>
1195
- {/key}
1196
- {/if}
1197
- {#if selectedBranchCue}
1198
- {#key selectedBranchCue.key}
1199
- <svg class="selected-branch-cue" bind:this={selectedBranchCueEl} aria-hidden="true">
1200
- <path class="selected-branch-halo" bind:this={selectedBranchHaloPath}></path>
1201
- <path class="selected-branch-line" bind:this={selectedBranchLinePath}></path>
1202
- </svg>
1203
- {/key}
1204
- {/if}
1205
- {#if selectedMultiDetail}
1206
- <!-- Content is built imperatively in syncMultiDetail (screen-space
1488
+ {#if diagramNetwork}
1489
+ <DiagramCanvas
1490
+ network={diagramNetwork}
1491
+ multiconductor={diagramMulti}
1492
+ caseId={diagramCaseId}
1493
+ onbusclick={selectDiagramBus}
1494
+ onbranchclick={selectDiagramBranch}
1495
+ onclear={onmapclick}
1496
+ />
1497
+ {:else}
1498
+ {#key mapGen}
1499
+ <div class="map-stage">
1500
+ <div class="map" {@attach initMap}></div>
1501
+ {#if selectedBusCue}
1502
+ {#key selectedBusCue.key}
1503
+ <div class="selected-bus-cue" bind:this={selectedCueEl} aria-hidden="true"></div>
1504
+ {/key}
1505
+ {/if}
1506
+ {#if selectedBranchCue}
1507
+ {#key selectedBranchCue.key}
1508
+ <svg class="selected-branch-cue" bind:this={selectedBranchCueEl} aria-hidden="true">
1509
+ <path class="selected-branch-halo" bind:this={selectedBranchHaloPath}></path>
1510
+ <path class="selected-branch-line" bind:this={selectedBranchLinePath}></path>
1511
+ </svg>
1512
+ {/key}
1513
+ {/if}
1514
+ {#if selectedMultiDetail}
1515
+ <!-- Content is built imperatively in syncMultiDetail (screen-space
1207
1516
  projection); file-derived text is escaped there. -->
1208
- <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1209
- {/if}
1210
- </div>
1211
- {/key}
1517
+ <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1518
+ {/if}
1519
+ </div>
1520
+ {/key}
1521
+ {/if}
1212
1522
 
1213
1523
  {#if app.placingId && map}
1214
1524
  <button
1215
1525
  type="button"
1216
1526
  class="place-at-center mono"
1217
- style="--chrome-inset: {chromeInset}px"
1218
1527
  onclick={() => {
1219
1528
  if (!map) return;
1220
1529
  const center = map.getCenter();
@@ -1278,16 +1587,21 @@
1278
1587
  }
1279
1588
 
1280
1589
  .multi-detail :global(.mc-leader) {
1281
- stroke: rgba(32, 36, 43, 0.35);
1590
+ stroke: rgb(var(--ink-rgb) / 0.35);
1282
1591
  stroke-width: 1;
1283
1592
  stroke-dasharray: 2 2;
1284
1593
  }
1285
1594
 
1286
1595
  .multi-detail :global(.mc-ground line) {
1287
- stroke: #3a3a3a;
1596
+ stroke: var(--mc-ground-stroke);
1288
1597
  stroke-width: 1.3;
1289
1598
  }
1290
1599
 
1600
+ .multi-detail :global(.mc-terminal) {
1601
+ stroke: var(--ink);
1602
+ stroke-width: 1.2;
1603
+ }
1604
+
1291
1605
  .multi-detail :global(.mc-term) {
1292
1606
  fill: var(--ink, #20242b);
1293
1607
  font-family: var(--font-mono);
@@ -1398,7 +1712,7 @@
1398
1712
  .place-at-center {
1399
1713
  position: absolute;
1400
1714
  left: 50%;
1401
- bottom: calc(92px + var(--chrome-inset, 0px));
1715
+ bottom: 92px;
1402
1716
  z-index: 15;
1403
1717
  transform: translateX(-50%);
1404
1718
  padding: 7px 14px;
@@ -1408,48 +1722,46 @@
1408
1722
  color: var(--text-accent);
1409
1723
  font-size: 11px;
1410
1724
  cursor: pointer;
1411
- box-shadow: 0 4px 18px rgba(32, 36, 43, 0.1);
1725
+ box-shadow: var(--elev-cue);
1412
1726
  }
1413
1727
 
1414
1728
  @media (max-width: 420px) {
1415
1729
  .place-at-center {
1416
- bottom: calc(78px + var(--chrome-inset, 0px));
1730
+ bottom: 78px;
1417
1731
  }
1418
1732
  }
1419
1733
 
1420
1734
  .map :global(.maplibregl-ctrl-bottom-right) {
1421
1735
  right: 20px;
1422
- /* rides above the bottom sheet so attribution stays legible */
1423
- bottom: calc(18px + var(--chrome-inset, 0px));
1736
+ bottom: 12px;
1424
1737
  display: flex;
1425
- flex-direction: column;
1738
+ flex-direction: row;
1426
1739
  align-items: flex-end;
1427
- gap: 6px;
1740
+ gap: 16px;
1428
1741
  transition:
1429
1742
  bottom var(--dur-med) var(--ease-out),
1430
1743
  opacity var(--dur-fast) var(--ease-out);
1431
1744
  }
1432
1745
 
1433
- /* The sheet has taken the map; nothing left down there to attribute. */
1434
- .map-stage.chrome-clear :global(.maplibregl-ctrl-bottom-right) {
1435
- opacity: 0;
1436
- pointer-events: none;
1437
- }
1438
-
1439
1746
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl) {
1440
1747
  float: none;
1441
1748
  margin: 0;
1442
1749
  }
1443
1750
 
1751
+ .map :global(.maplibregl-ctrl-group) {
1752
+ order: 2;
1753
+ }
1754
+
1444
1755
  .map :global(.maplibregl-ctrl-attrib) {
1445
- order: -1;
1756
+ order: 1;
1446
1757
  position: relative;
1447
- right: 2px;
1758
+ bottom: 0;
1759
+ right: 0;
1448
1760
  width: auto;
1449
1761
  min-width: 0;
1450
1762
  max-width: calc(100vw - 96px);
1451
1763
  box-sizing: border-box;
1452
- background: rgba(252, 251, 247, 0.75);
1764
+ background: rgb(var(--paper-rgb) / 0.75);
1453
1765
  font-family: var(--font-mono);
1454
1766
  font-size: 10px;
1455
1767
  white-space: nowrap;
@@ -1490,35 +1802,39 @@
1490
1802
  line-height: 1.5;
1491
1803
  padding: 8px 10px !important;
1492
1804
  border-radius: 2px;
1493
- box-shadow: 0 2px 10px rgba(32, 36, 43, 0.12);
1805
+ box-shadow: var(--elev-tooltip);
1494
1806
  }
1495
1807
 
1496
1808
  @media (max-width: 760px) {
1809
+ .map :global(.maplibregl-ctrl-bottom-right) {
1810
+ bottom: 12px;
1811
+ right: 12px;
1812
+ gap: 12px;
1813
+ }
1497
1814
  .map :global(.maplibregl-ctrl-attrib) {
1498
- /* last in the column so it rests on the sheet's edge instead of
1499
- floating over the network a zoom control's height above it */
1500
- order: 1;
1815
+ position: relative;
1816
+ bottom: auto;
1501
1817
  width: auto;
1502
1818
  min-width: 0;
1503
1819
  right: 0;
1504
1820
  white-space: normal;
1505
1821
  }
1506
1822
 
1507
- /* The band of map a phone has left between the header and the sheet is
1508
- about the height of the solve card, which covers the buttons wherever
1509
- they land. Pinch and double tap zoom; the attribution stays. */
1510
1823
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group) {
1511
- display: none;
1824
+ display: flex;
1825
+ }
1826
+ .map :global(.maplibregl-ctrl-group button + button) {
1827
+ border-top: 0;
1828
+ border-left: 1px solid var(--line);
1512
1829
  }
1513
1830
  }
1514
1831
 
1515
- /* One line on a small phone: wrapped attribution turns into a wide block
1516
- sitting over the network. */
1832
+ /* Attribution shares the bottom row with touch-sized zoom buttons. */
1517
1833
  @media (max-width: 460px) {
1518
1834
  .map :global(.maplibregl-ctrl-attrib) {
1519
- max-width: calc(100vw - 20px);
1835
+ max-width: calc(100vw - 140px);
1520
1836
  font-size: 9px;
1521
- white-space: nowrap;
1837
+ white-space: normal;
1522
1838
  }
1523
1839
  }
1524
1840