@tellegen/svelte 0.2.0 → 0.3.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 (66) hide show
  1. package/README.md +36 -5
  2. package/dist/TellegenMap.svelte +325 -163
  3. package/dist/TellegenProvider.svelte +4 -0
  4. package/dist/TellegenShell.svelte +19 -13
  5. package/dist/api.d.ts +8 -1
  6. package/dist/api.js +6 -2
  7. package/dist/colors.d.ts +7 -7
  8. package/dist/colors.js +13 -13
  9. package/dist/components/AppHeader.svelte +23 -11
  10. package/dist/components/BindingLines.svelte +3 -6
  11. package/dist/components/BusPicker.svelte +8 -2
  12. package/dist/components/ControlPanel.svelte +106 -343
  13. package/dist/components/DemandSlider.svelte +21 -23
  14. package/dist/components/DiagramCanvas.svelte +486 -0
  15. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  16. package/dist/components/DisplayControls.svelte +4 -2
  17. package/dist/components/FormulationSelector.svelte +8 -3
  18. package/dist/components/LocalCaseDetails.svelte +55 -45
  19. package/dist/components/McResults.svelte +365 -0
  20. package/dist/components/McResults.svelte.d.ts +11 -0
  21. package/dist/components/ModelDetails.svelte +230 -0
  22. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  23. package/dist/components/MulticonductorDetails.svelte +227 -78
  24. package/dist/components/NetworkStats.svelte +20 -18
  25. package/dist/components/NotificationCenter.svelte +243 -0
  26. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  27. package/dist/components/PanelCard.svelte +319 -0
  28. package/dist/components/PanelCard.svelte.d.ts +9 -0
  29. package/dist/components/PanelFrame.svelte +46 -0
  30. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  31. package/dist/components/PanelHost.svelte +212 -0
  32. package/dist/components/PanelHost.svelte.d.ts +3 -0
  33. package/dist/components/PowerFlowDetails.svelte +99 -0
  34. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  35. package/dist/components/RatingSlider.svelte +5 -9
  36. package/dist/components/SavedNetworkDetails.svelte +214 -0
  37. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  38. package/dist/components/SensitivityReadout.svelte +24 -16
  39. package/dist/components/SolveCard.svelte +46 -49
  40. package/dist/components/TopMovers.svelte +8 -3
  41. package/dist/components/TopMovers.svelte.d.ts +2 -17
  42. package/dist/components.d.ts +2 -0
  43. package/dist/components.js +2 -0
  44. package/dist/controller.svelte.d.ts +54 -33
  45. package/dist/controller.svelte.js +927 -233
  46. package/dist/diagram-labels.d.ts +9 -0
  47. package/dist/diagram-labels.js +40 -0
  48. package/dist/display.d.ts +3 -7
  49. package/dist/display.js +31 -27
  50. package/dist/format.js +5 -4
  51. package/dist/index.d.ts +10 -4
  52. package/dist/index.js +7 -2
  53. package/dist/map.d.ts +1 -1
  54. package/dist/map.js +1 -1
  55. package/dist/multiconductor.d.ts +11 -2
  56. package/dist/multiconductor.js +107 -14
  57. package/dist/notices.svelte.d.ts +29 -0
  58. package/dist/notices.svelte.js +82 -0
  59. package/dist/panels.svelte.d.ts +65 -0
  60. package/dist/panels.svelte.js +215 -0
  61. package/dist/state.svelte.d.ts +90 -23
  62. package/dist/state.svelte.js +94 -20
  63. package/dist/synthetic-layout.js +1 -0
  64. package/package.json +22 -9
  65. package/dist/study-package.d.ts +0 -8
  66. package/dist/study-package.js +0 -17
@@ -3,7 +3,7 @@
3
3
  import { tick } 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 {
@@ -11,7 +11,7 @@
11
11
  branchWidth,
12
12
  busNeutral,
13
13
  busRadius,
14
- lmpColor,
14
+ priceColor,
15
15
  scalarDomain,
16
16
  sensColor,
17
17
  sensFlatColor,
@@ -29,13 +29,16 @@
29
29
  transformerMarks,
30
30
  type PlacedMultiBus,
31
31
  type PlacedMultiEdge,
32
- type TransformerMark
32
+ type TransformerMark,
33
+ multiDiagramNetwork
33
34
  } from './multiconductor.js';
34
35
  import { transformerIcon } from './transformer-icon.js';
35
36
  import { foldMapBounds } from './map-bounds.js';
36
37
  import { getAppState, getController } from './context.svelte.js';
37
38
  import { displayFmt, fmt, rgbaCss } from './format.js';
39
+ import { getPanelLayout } from './panels.svelte.js';
38
40
  import type { RGBA } from './colors.js';
41
+ import DiagramCanvas from './components/DiagramCanvas.svelte';
39
42
 
40
43
  let {
41
44
  onbusclick,
@@ -59,6 +62,47 @@
59
62
 
60
63
  const app = getAppState();
61
64
  const ctrl = getController();
65
+ const panels = getPanelLayout();
66
+ const diagramCase = $derived(app.studyView ? null : (app.active ?? app.activeLocal));
67
+ const diagramMulti = $derived(
68
+ !app.studyView && app.activeMulti?.coordsKind !== 'geographic' ? app.activeMulti : null
69
+ );
70
+ const multiNetwork = $derived(
71
+ diagramMulti?.view
72
+ ? multiDiagramNetwork(diagramMulti.id, diagramMulti.label, diagramMulti.view)
73
+ : null
74
+ );
75
+ const displayedNetwork = $derived(app.studyView?.network ?? diagramCase?.network);
76
+ const diagramNetwork = $derived(
77
+ multiNetwork ?? (displayedNetwork?.coordinate_space === 'diagram' ? displayedNetwork : null)
78
+ );
79
+ const diagramCaseId = $derived(
80
+ app.studyView?.caseId ?? diagramMulti?.id ?? diagramCase?.id ?? 'diagram'
81
+ );
82
+ function selectDiagramBus(busId: number) {
83
+ if (diagramMulti?.view) {
84
+ const bus = diagramMulti.view.buses[busId];
85
+ if (bus) onmultibusclick(diagramMulti.id, bus.id);
86
+ return;
87
+ }
88
+ if (app.studyView) {
89
+ app.selectedBranch = null;
90
+ app.selectedBus = busId;
91
+ } else if (app.activeLocal) onlocalbusclick(diagramCaseId, busId);
92
+ else onbusclick(diagramCaseId, busId);
93
+ }
94
+ function selectDiagramBranch(branchId: number) {
95
+ if (diagramMulti?.view) {
96
+ const edge = diagramMulti.view.edges[branchId];
97
+ if (edge) onmultiedgeclick?.(diagramMulti.id, edge.id);
98
+ return;
99
+ }
100
+ if (app.studyView) {
101
+ app.selectedBus = null;
102
+ app.selectedBranch = branchId;
103
+ } else if (app.activeLocal) onlocalbranchclick?.(diagramCaseId, branchId);
104
+ else onbranchclick?.(diagramCaseId, branchId);
105
+ }
62
106
 
63
107
  const STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
64
108
  const CUE_MARGIN_PX = 64;
@@ -66,7 +110,7 @@
66
110
  const BRANCH_FOCUS_MAX_ZOOM = 9.5;
67
111
 
68
112
  let map = $state.raw<MapLibreMap | null>(null);
69
- let overlay = $state.raw<MapboxOverlay | null>(null);
113
+ let overlay = $state.raw<MapLibreOverlay | null>(null);
70
114
  // Bumped to remount the map after a WebGL context is lost and cannot be
71
115
  // repainted in place. See initMap's context-loss handling.
72
116
  let mapGen = $state(0);
@@ -85,7 +129,7 @@
85
129
  let layerCtors = $state.raw<LayerCtors | null>(null);
86
130
 
87
131
  interface CaseDisplay {
88
- lmp: Map<number, number>;
132
+ prices: Map<number, number>;
89
133
  scalar: Map<number, number>;
90
134
  scalarLo: number;
91
135
  scalarHi: number;
@@ -93,7 +137,7 @@
93
137
  scalarLabel: string;
94
138
  scalarUnit: string;
95
139
  loading: Map<number, number>;
96
- mode: 'lmp' | 'sens' | 'preview';
140
+ mode: 'price' | 'sens' | 'preview';
97
141
  sens: Map<number, number>;
98
142
  sensDomain: SensitivityDomain | null;
99
143
  preview: Map<number, number>;
@@ -106,7 +150,7 @@
106
150
  type SolvableCase = CaseState | LocalCase;
107
151
 
108
152
  // 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
153
+ // The LMP scale normalizes per network: each case is an islanded network, so
110
154
  // within network structure beats cross network color comparability (the
111
155
  // panel legend and tooltips carry the actual numbers). Preview values
112
156
  // shift individual buses without rescaling.
@@ -114,8 +158,8 @@
114
158
  const active = app.active ?? app.activeLocal;
115
159
  const selectedBus = app.selectedBus;
116
160
  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
161
+ // The active case's column for the live selection: the price-demand column at the
162
+ // selected bus, or the price-rating column at the selected branch. Both are
119
163
  // bus-keyed value maps, so the color pipeline below is target-agnostic.
120
164
  const activeSensitivity =
121
165
  active &&
@@ -125,18 +169,18 @@
125
169
  : null;
126
170
  const activeDeltas = active ? caseDeltas(active) : {};
127
171
  const activeRatings = active ? caseRatings(active) : {};
128
- // Engine first-order LMP preview (Study.preview): predicted per-bus ΔLMP for
172
+ // Engine first order preview (Study.preview): predicted per-bus LMP change for
129
173
  // the live drag, scoped to this case + selection target. Preferred over the JS
130
174
  // gradient shift when present; the gradient path stays for server and browser
131
175
  // fallbacks.
132
176
  const enginePreview =
133
177
  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
178
+ app.previewPrices &&
179
+ app.previewPrices.caseId === active.id &&
180
+ ('bus' in app.previewPrices.target
181
+ ? app.previewPrices.target.bus === selectedBus
182
+ : app.previewPrices.target.branch === selectedBranch)
183
+ ? app.previewPrices.delta
140
184
  : null;
141
185
  const previewStep =
142
186
  active && activeSensitivity
@@ -153,13 +197,13 @@
153
197
  const perCase = new Map<string, CaseDisplay>();
154
198
  const addCase = (c: CaseState | LocalCase) => {
155
199
  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);
200
+ const prices = new Map<number, number>();
201
+ for (const e of c.solution.prices) prices.set(e.bus, e.value);
158
202
  // 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).
203
+ // back to LMPs when the chosen mode doesn't apply (e.g. |V| under DC OPF).
160
204
  const meta = displayMetaFor(c);
161
205
  const activeMeta = meta.find((o) => o.mode === app.displayMode) ?? meta[0] ?? null;
162
- const scalarMode = activeMeta?.mode ?? 'lmp';
206
+ const scalarMode = activeMeta?.mode ?? 'price';
163
207
  const seriesValues = displaySeriesFor(c, scalarMode);
164
208
  const scalar = new Map<number, number>();
165
209
  for (const e of seriesValues) scalar.set(e.bus, e.value);
@@ -175,10 +219,10 @@
175
219
  let domain: SensitivityDomain | null = null;
176
220
  const isActive = c === active;
177
221
  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);
222
+ // Engine preview owns the LMP shift.
223
+ for (const [bus, deltaPrice] of enginePreview) {
224
+ preview.set(bus, deltaPrice);
225
+ prices.set(bus, (prices.get(bus) ?? 0) + deltaPrice);
182
226
  }
183
227
  }
184
228
  if (isActive && activeSensitivity) {
@@ -190,9 +234,9 @@
190
234
  if (!enginePreview && previewStep !== 0) {
191
235
  // Fallback first order preview: shift LMPs along the gradient.
192
236
  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);
237
+ const deltaPrice = v.value * previewStep;
238
+ preview.set(v.bus, deltaPrice);
239
+ prices.set(v.bus, (prices.get(v.bus) ?? 0) + deltaPrice);
196
240
  }
197
241
  }
198
242
  }
@@ -203,16 +247,15 @@
203
247
  previewDomain &&
204
248
  (app.previewActive || c.solving || enginePreview || Math.abs(previewStep) >= 0.25)
205
249
  );
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;
250
+ // The sensitivity and demand preview overlays are LMP fields. They only paint the map under the price display mode;
208
251
  // under angle or |V| the map stays on the chosen scalar and the sensitivity
209
252
  // shows in the side panel, so selecting a bus never silently overrides the
210
253
  // angle/|V| coloring with the price ramp.
211
- const sensEligible = scalarMode === 'lmp';
254
+ const sensEligible = scalarMode === 'price';
212
255
  // 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
256
+ // mode (renders neutral, not the prior price palette) so the node colors
214
257
  // don't flash orange->purple between the old and new selection.
215
- const mode: 'lmp' | 'sens' | 'preview' =
258
+ const mode: 'price' | 'sens' | 'preview' =
216
259
  sensEligible && showPreview
217
260
  ? 'preview'
218
261
  : sensEligible &&
@@ -221,15 +264,15 @@
221
264
  (domain || app.sensitivityLoading) &&
222
265
  !previewing
223
266
  ? 'sens'
224
- : 'lmp';
267
+ : 'price';
225
268
  perCase.set(c.id, {
226
- lmp,
269
+ prices,
227
270
  scalar,
228
271
  scalarLo,
229
272
  scalarHi,
230
273
  scalarMode,
231
274
  scalarLabel: activeMeta?.label ?? 'LMP',
232
- scalarUnit: activeMeta?.unit ?? '$/MWh',
275
+ scalarUnit: activeMeta?.unit ?? 'objective units/MW',
233
276
  loading,
234
277
  mode,
235
278
  sens,
@@ -266,7 +309,7 @@
266
309
  return sensColor((d.preview.get(bus.id) ?? 0) / d.previewDomain.scale);
267
310
  }
268
311
  const mid = (d.scalarLo + d.scalarHi) / 2;
269
- return lmpColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
312
+ return priceColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
270
313
  };
271
314
  }
272
315
 
@@ -282,34 +325,38 @@
282
325
  // click handler must not treat those clicks as "empty map" and clear the selection.
283
326
  function isSelectableLayer(layerId: string | undefined): boolean {
284
327
  return Boolean(
328
+ layerId?.startsWith('study-') ||
285
329
  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-')
330
+ layerId?.startsWith('local-buses-') ||
331
+ layerId?.startsWith('branches-') ||
332
+ layerId?.startsWith('local-branches-') ||
333
+ layerId?.startsWith('multi-buses-') ||
334
+ layerId?.startsWith('multi-edges-')
291
335
  );
292
336
  }
293
337
 
338
+ function displayedGeometry(caseId: string) {
339
+ if (app.studyView) return app.studyView.caseId === caseId ? app.studyView.network : null;
340
+ const local = app.localCases.find((c) => c.id === caseId);
341
+ return app.byId(caseId)?.network ?? local?.view ?? local?.network ?? null;
342
+ }
294
343
  const selectedBusCue = $derived.by(() => {
295
344
  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;
345
+ const caseId = app.studyView?.caseId ?? app.active?.id ?? app.activeLocal?.id;
346
+ if (!caseId || busId === null) return null;
347
+ const bus = displayedGeometry(caseId)?.buses.find((b) => b.id === busId);
348
+ return bus
349
+ ? { key: `${app.studyView?.id ?? caseId}-${bus.id}`, id: bus.id, lon: bus.lon, lat: bus.lat }
350
+ : null;
302
351
  });
303
352
  const selectedBranchCue = $derived.by(() => {
304
353
  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);
354
+ const caseId = app.studyView?.caseId ?? app.active?.id ?? app.activeLocal?.id;
355
+ if (!caseId || branchId === null) return null;
356
+ const branch = displayedGeometry(caseId)?.branches.find((b) => b.id === branchId);
310
357
  const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
311
358
  return branch && path && path.length >= 2
312
- ? { key: `${c.id}-${branch.id}`, caseId: c.id, branchId: branch.id, path }
359
+ ? { key: `${app.studyView?.id ?? caseId}-${branch.id}`, caseId, branchId: branch.id, path }
313
360
  : null;
314
361
  });
315
362
  let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
@@ -317,6 +364,7 @@
317
364
  let selectedBranchHaloPath = $state.raw<SVGPathElement | null>(null);
318
365
  let selectedBranchLinePath = $state.raw<SVGPathElement | null>(null);
319
366
  let handledFrameSeq = 0;
367
+ let handledCameraSeq = 0;
320
368
 
321
369
  // The selected multiconductor bus and its incident edges, whose per-conductor
322
370
  // strands and terminal stack the detail overlay draws in screen space.
@@ -492,18 +540,23 @@
492
540
  });
493
541
  }
494
542
 
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. */
543
+ /** Camera framing reserves the dock columns and the active compact drawer. */
497
544
  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 };
545
+ const clearance = kind === 'branch' ? 16 : 28;
546
+ if (panels.compact) {
547
+ return {
548
+ top: panels.top + 8,
549
+ left: 24,
550
+ right: 24,
551
+ bottom: Math.min(app.sheetInset + clearance, Math.round(height * 0.6))
552
+ };
503
553
  }
504
- return kind === 'branch'
505
- ? { top: 106, left: 388, right: 76, bottom: 80 }
506
- : { top: 96, left: 380, right: 60, bottom: 64 };
554
+ return {
555
+ top: panels.top + 16,
556
+ left: Math.min(panels.dockWidth('left') + 44, width * 0.34),
557
+ right: Math.min(panels.dockWidth('right') + 44, width * 0.34),
558
+ bottom: 80
559
+ };
507
560
  }
508
561
 
509
562
  function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
@@ -512,8 +565,7 @@
512
565
 
513
566
  function prefersReducedMotion(): boolean {
514
567
  return (
515
- typeof window !== 'undefined' &&
516
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
568
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
517
569
  );
518
570
  }
519
571
 
@@ -521,25 +573,9 @@
521
573
  return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
522
574
  }
523
575
 
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);
576
+ function focusGeometry(points: [number, number][], settle: () => void): boolean {
577
+ if (!map) return false;
578
+ const bounds = branchBounds(points);
543
579
  if (!bounds) return false;
544
580
  const { clientWidth, clientHeight } = map.getContainer();
545
581
  const camera = map.cameraForBounds(bounds, {
@@ -547,6 +583,7 @@
547
583
  maxZoom: Math.min(map.getZoom() + 2, BRANCH_FOCUS_MAX_ZOOM)
548
584
  });
549
585
  if (!camera?.center) return false;
586
+ map.once('moveend', settle);
550
587
  map.easeTo({
551
588
  center: camera.center,
552
589
  zoom: camera.zoom ?? map.getZoom(),
@@ -580,6 +617,21 @@
580
617
  function tooltip(info: PickingInfo): { html: string } | null {
581
618
  const { object } = info;
582
619
  if (!object) return null;
620
+ if (app.studyView && info.layer?.id.startsWith('study-')) {
621
+ const view = app.studyView.solution;
622
+ const id = Number((object as { id: number }).id);
623
+ if (info.layer.id === 'study-branches') {
624
+ const flow = view?.flows?.find((f) => f.branch === id);
625
+ return {
626
+ 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)}%` : ''}`
627
+ };
628
+ }
629
+ const p = view?.lmp?.find((v) => v.bus === id),
630
+ v = view?.vm?.find((v) => v.bus === id);
631
+ return {
632
+ 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` : ''}`
633
+ };
634
+ }
583
635
  if (info.layer?.id.startsWith('local-subs-')) {
584
636
  // PowerWorld .pwd substation: display data only, position inferred from the diagram.
585
637
  // The name is free text from a dropped file, so escape it; the number
@@ -587,7 +639,7 @@
587
639
  const s = object as { number: number; name: string };
588
640
  const named = s.name ? ` ${esc(s.name)}` : '';
589
641
  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>`
642
+ html: `<div class="tt"><b>substation ${Number(s.number)}</b>${named}<br><span style="opacity:0.6">.pwd diagram, approximate position</span></div>`
591
643
  };
592
644
  }
593
645
  const layerId = info.layer?.id;
@@ -627,7 +679,7 @@
627
679
  <span style="opacity:0.6">local file</span></div>`
628
680
  };
629
681
  }
630
- const lmp = d?.lmp.get(bus.id);
682
+ const price = d?.prices.get(bus.id);
631
683
  const scalar = d?.scalar.get(bus.id);
632
684
  const sens = d?.mode === 'sens' ? d.sens.get(bus.id) : undefined;
633
685
  const preview = d?.mode === 'preview' ? d.preview.get(bus.id) : undefined;
@@ -639,19 +691,19 @@
639
691
  const sensRow =
640
692
  sens === undefined
641
693
  ? ''
642
- : `<br>&part;LMP/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
694
+ : `<br>&part;price/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
643
695
  const previewRow =
644
696
  preview === undefined
645
697
  ? ''
646
- : `<br>&Delta;LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} $/MWh`;
698
+ : `<br>&Delta; LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
647
699
  const localRow =
648
700
  c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
649
701
  const scalarRow =
650
- !d || d.scalarMode === 'lmp'
651
- ? `LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`
702
+ !d || d.scalarMode === 'price'
703
+ ? `LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`
652
704
  : `${d.scalarLabel} ${
653
705
  scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
654
- } ${d.scalarUnit}<br>LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`;
706
+ } ${d.scalarUnit}<br>LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`;
655
707
  return {
656
708
  html: `<div class="tt"><b>bus ${bus.id}</b>
657
709
  ${scalarRow}<br>
@@ -660,14 +712,17 @@
660
712
  }
661
713
 
662
714
  async function loadMapModules() {
663
- const [maplibre, mapbox, layers] = await Promise.all([
715
+ const [maplibre, deckMap, layers, worker] = await Promise.all([
664
716
  import('maplibre-gl'),
665
- import('@deck.gl/mapbox'),
666
- import('@deck.gl/layers')
717
+ import('@deck.gl/maplibre'),
718
+ import('@deck.gl/layers'),
719
+ import('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url')
667
720
  ]);
721
+ // Bundle the worker with its shared imports and configure it before creating a map.
722
+ maplibre.setWorkerUrl(worker.default);
668
723
  return {
669
- maplibregl: maplibre.default,
670
- MapboxOverlay: mapbox.MapboxOverlay,
724
+ maplibregl: maplibre,
725
+ MapLibreOverlay: deckMap.MapLibreOverlay,
671
726
  PathLayer: layers.PathLayer,
672
727
  ScatterplotLayer: layers.ScatterplotLayer,
673
728
  IconLayer: layers.IconLayer
@@ -682,9 +737,9 @@
682
737
  // memory and tab pressure, and one context survives that far better; the
683
738
  // deck layers also inherit maplibre's 4096 canvas clamp. The flat positron
684
739
  // basemap writes no depth, so the overlay always draws on top.
685
- function buildOverlay(MapboxOverlay: MapModules['MapboxOverlay']): MapboxOverlay {
740
+ function buildOverlay(MapLibreOverlay: MapModules['MapLibreOverlay']): MapLibreOverlay {
686
741
  const touch = coarsePointer();
687
- return new MapboxOverlay({
742
+ return new MapLibreOverlay({
688
743
  interleaved: true,
689
744
  layers: [],
690
745
  parameters: {
@@ -707,7 +762,7 @@
707
762
  let cleanup = () => {};
708
763
  let cancelled = false;
709
764
  void loadMapModules()
710
- .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
765
+ .then(({ maplibregl, MapLibreOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
711
766
  if (cancelled) return;
712
767
  layerCtors = { PathLayer, ScatterplotLayer, IconLayer };
713
768
  const m = new maplibregl.Map({
@@ -718,7 +773,7 @@
718
773
  canvasContextAttributes: { antialias: true },
719
774
  attributionControl: { compact: true }
720
775
  });
721
- const o = buildOverlay(MapboxOverlay);
776
+ const o = buildOverlay(MapLibreOverlay);
722
777
  m.addControl(o);
723
778
  m.on('click', (e) => {
724
779
  if (app.placingId) onplacecase(e.lngLat.lng, e.lngLat.lat);
@@ -743,6 +798,16 @@
743
798
  if (document.visibilityState === 'visible') repaint();
744
799
  };
745
800
  const syncCues = () => scheduleCueSync();
801
+ const saveCamera = () => {
802
+ const center = m.getCenter();
803
+ app.camera = {
804
+ center: [center.lng, center.lat],
805
+ zoom: m.getZoom(),
806
+ bearing: m.getBearing(),
807
+ pitch: m.getPitch()
808
+ };
809
+ };
810
+ m.on('moveend', saveCamera);
746
811
  let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
747
812
  const clearRebuild = () => {
748
813
  if (rebuildTimer !== null) {
@@ -797,7 +862,9 @@
797
862
  // (from the case load) can fire before the async map import finishes and
798
863
  // be lost; re-issuing it here ensures the first view is the active case,
799
864
  // not the default world view. A remount opens at the default view too.
800
- app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
865
+ if (mapGen > 0 && app.camera) m.jumpTo(app.camera);
866
+ else if (app.cameraSeq === handledCameraSeq)
867
+ app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
801
868
  if (mapGen > 0) {
802
869
  // This is a rebuilt map. Once it has held a live context without
803
870
  // another loss, zero the budget so the cap only ever catches a tight
@@ -838,6 +905,74 @@
838
905
  if (!overlay || !layerCtors) return;
839
906
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
840
907
  const layers: Layer[] = [];
908
+ const study = app.studyView;
909
+ if (study) {
910
+ const s = study.solution;
911
+ const mode = app.displayMode;
912
+ const series =
913
+ mode === 'angle'
914
+ ? (s?.va ?? [])
915
+ : mode === 'voltage'
916
+ ? (s?.vm ??
917
+ (s?.w ?? []).map((v) => ({
918
+ ...v,
919
+ value: Math.sqrt(Math.max(0, v.value))
920
+ })))
921
+ : (s?.lmp ?? []);
922
+ const values = new Map(series.map((v) => [v.bus, v.value]));
923
+ const domain = scalarDomain(
924
+ mode,
925
+ series.map((v) => v.value)
926
+ );
927
+ const flows = new Map((s?.flows ?? []).map((f) => [f.branch, f.loading]));
928
+ layers.push(
929
+ new PathLayer<NetworkBranch>({
930
+ id: 'study-branches',
931
+ data: study.network.branches,
932
+ getPath: (b) => b.path,
933
+ getColor: (b) => branchColor(flows.get(b.id) ?? 0, b.status === 1),
934
+ getWidth: (b) => branchWidth(flows.get(b.id) ?? 0),
935
+ widthUnits: 'pixels',
936
+ widthMinPixels: 1.5,
937
+ pickable: true,
938
+ onClick: (info: PickingInfo) => {
939
+ const branch = info.object as NetworkBranch | undefined;
940
+ if (!branch) return false;
941
+ app.selectedBus = null;
942
+ app.selectedBranch = branch.id;
943
+ return true;
944
+ },
945
+ updateTriggers: { getColor: [study], getWidth: [study] }
946
+ }),
947
+ new ScatterplotLayer<NetworkBus>({
948
+ id: 'study-buses',
949
+ data: study.network.buses,
950
+ getPosition: (b) => [b.lon, b.lat],
951
+ getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
952
+ radiusUnits: 'pixels',
953
+ getFillColor: (b) => {
954
+ const value = values.get(b.id);
955
+ return value === undefined
956
+ ? [110, 115, 120, 200]
957
+ : priceColor((value - domain.lo) / Math.max(domain.hi - domain.lo, 1e-12));
958
+ },
959
+ pickable: true,
960
+ stroked: true,
961
+ getLineColor: [46, 42, 34, 110],
962
+ lineWidthUnits: 'pixels',
963
+ onClick: (info: PickingInfo) => {
964
+ const bus = info.object as NetworkBus | undefined;
965
+ if (!bus) return false;
966
+ app.selectedBranch = null;
967
+ app.selectedBus = bus.id;
968
+ return true;
969
+ },
970
+ updateTriggers: { getFillColor: [study, mode] }
971
+ })
972
+ );
973
+ overlay.setProps({ layers });
974
+ return;
975
+ }
841
976
  for (const c of app.cases) {
842
977
  if (!c.network) continue;
843
978
  const d = display.get(c.id);
@@ -847,7 +982,7 @@
847
982
  data: c.network.branches,
848
983
  getPath: (b) => b.path,
849
984
  // 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.
985
+ // wider, so the price-rating source line reads at a glance.
851
986
  getColor: (b) =>
852
987
  c.id === app.activeCaseId && b.id === app.selectedBranch
853
988
  ? [47, 111, 187, 255]
@@ -983,7 +1118,12 @@
983
1118
  }
984
1119
  if (c.substations) {
985
1120
  layers.push(
986
- new ScatterplotLayer<{ number: number; name: string; lon: number; lat: number }>({
1121
+ new ScatterplotLayer<{
1122
+ number: number;
1123
+ name: string;
1124
+ lon: number;
1125
+ lat: number;
1126
+ }>({
987
1127
  id: `local-subs-${c.id}`,
988
1128
  data: c.substations.points,
989
1129
  getPosition: (s) => [s.lon, s.lat],
@@ -1007,7 +1147,7 @@
1007
1147
  // kind and buses badged by attachment role. Selecting a bus fans its
1008
1148
  // incident conductors and expands its terminal stack in the SVG overlay.
1009
1149
  for (const c of app.multiCases) {
1010
- if (!c.view) continue;
1150
+ if (c.coordsKind !== 'geographic' || !c.view) continue;
1011
1151
  const selectedId = c.id === app.activeMultiId ? c.selectedBusId : null;
1012
1152
  const selectedEdgeId = c.id === app.activeMultiId ? c.selectedEdgeId : null;
1013
1153
  layers.push(
@@ -1114,7 +1254,7 @@
1114
1254
  // does not re-run (and re-pan) on every frame of a sheet drag.
1115
1255
  $effect(() => {
1116
1256
  const cue = selectedBusCue;
1117
- if (!map || !cue || !app.compactLayout) return;
1257
+ if (!map || !cue || !app.compactLayout || app.studyView) return;
1118
1258
  void cue.key;
1119
1259
  const m = map;
1120
1260
  const timer = setTimeout(() => {
@@ -1129,6 +1269,7 @@
1129
1269
  });
1130
1270
 
1131
1271
  function boundsFor(target: string): LngLatBoundsLike | null {
1272
+ if (app.studyView) return foldMapBounds(app.studyView.network.buses.map((b) => [b.lon, b.lat]));
1132
1273
  const points: [number, number][] = [];
1133
1274
  const fold = (pts: { lon: number; lat: number }[]) => {
1134
1275
  for (const b of pts) points.push([b.lon, b.lat]);
@@ -1143,28 +1284,41 @@
1143
1284
  if (c.substations) fold(c.substations.points);
1144
1285
  }
1145
1286
  for (const c of app.multiCases) {
1287
+ if (c.coordsKind !== 'geographic') continue;
1146
1288
  if (target !== 'all' && c.id !== target) continue;
1147
1289
  if (c.view) fold(c.view.buses);
1148
1290
  }
1149
1291
  return foldMapBounds(points);
1150
1292
  }
1151
1293
 
1294
+ $effect(() => {
1295
+ const seq = app.cameraSeq;
1296
+ const snapshot = app.cameraRequest;
1297
+ if (diagramNetwork || !map || !snapshot || seq === handledCameraSeq) return;
1298
+ handledCameraSeq = seq;
1299
+ handledFrameSeq = app.frameSeq;
1300
+ map.jumpTo(snapshot);
1301
+ });
1302
+
1152
1303
  // Fly to whatever the header, the initial load, or a branch selection asked
1153
1304
  // for, and settle the request's promise once the camera lands so callers can
1154
1305
  // defer heavy work past the animation. A branch request waits (unhandled)
1155
1306
  // until its cue exists; anything unframeable settles immediately.
1156
1307
  $effect(() => {
1157
1308
  const seq = app.frameSeq;
1158
- if (!map || seq === handledFrameSeq) return;
1309
+ if (diagramNetwork || !map || seq === handledFrameSeq) return;
1159
1310
  const m = map;
1160
1311
  const target = app.frameTarget;
1161
1312
  const settle = () => app.settleFrame();
1162
1313
  if (typeof target === 'object') {
1163
- const cue = selectedBranchCue;
1164
- if (!cue || cue.caseId !== target.caseId || cue.branchId !== target.branchId) return;
1314
+ const geometry = displayedGeometry(target.caseId);
1315
+ const bus =
1316
+ 'busId' in target ? geometry?.buses.find((b) => b.id === target.busId) : undefined;
1317
+ const branch =
1318
+ 'branchId' in target ? geometry?.branches.find((b) => b.id === target.branchId) : undefined;
1165
1319
  handledFrameSeq = seq;
1166
- if (focusSelectedBranch()) m.once('moveend', settle);
1167
- else settle();
1320
+ const points: [number, number][] = bus ? [[bus.lon, bus.lat]] : (branch?.path ?? []);
1321
+ if (!focusGeometry(points, settle)) settle();
1168
1322
  return;
1169
1323
  }
1170
1324
  handledFrameSeq = seq;
@@ -1174,47 +1328,53 @@
1174
1328
  return;
1175
1329
  }
1176
1330
  const { clientWidth: w, clientHeight: h } = m.getContainer();
1331
+ m.once('moveend', settle);
1177
1332
  m.fitBounds(bounds, {
1178
1333
  padding: framePadding(w, h, 'case'),
1179
1334
  duration: prefersReducedMotion() ? 0 : 1400
1180
1335
  });
1181
- m.once('moveend', settle);
1182
1336
  });
1183
1337
  </script>
1184
1338
 
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
1339
+ {#if diagramNetwork}
1340
+ <DiagramCanvas
1341
+ network={diagramNetwork}
1342
+ multiconductor={diagramMulti}
1343
+ caseId={diagramCaseId}
1344
+ onbusclick={selectDiagramBus}
1345
+ onbranchclick={selectDiagramBranch}
1346
+ onclear={onmapclick}
1347
+ />
1348
+ {:else}
1349
+ {#key mapGen}
1350
+ <div class="map-stage">
1351
+ <div class="map" {@attach initMap}></div>
1352
+ {#if selectedBusCue}
1353
+ {#key selectedBusCue.key}
1354
+ <div class="selected-bus-cue" bind:this={selectedCueEl} aria-hidden="true"></div>
1355
+ {/key}
1356
+ {/if}
1357
+ {#if selectedBranchCue}
1358
+ {#key selectedBranchCue.key}
1359
+ <svg class="selected-branch-cue" bind:this={selectedBranchCueEl} aria-hidden="true">
1360
+ <path class="selected-branch-halo" bind:this={selectedBranchHaloPath}></path>
1361
+ <path class="selected-branch-line" bind:this={selectedBranchLinePath}></path>
1362
+ </svg>
1363
+ {/key}
1364
+ {/if}
1365
+ {#if selectedMultiDetail}
1366
+ <!-- Content is built imperatively in syncMultiDetail (screen-space
1207
1367
  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}
1368
+ <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1369
+ {/if}
1370
+ </div>
1371
+ {/key}
1372
+ {/if}
1212
1373
 
1213
1374
  {#if app.placingId && map}
1214
1375
  <button
1215
1376
  type="button"
1216
1377
  class="place-at-center mono"
1217
- style="--chrome-inset: {chromeInset}px"
1218
1378
  onclick={() => {
1219
1379
  if (!map) return;
1220
1380
  const center = map.getCenter();
@@ -1398,7 +1558,7 @@
1398
1558
  .place-at-center {
1399
1559
  position: absolute;
1400
1560
  left: 50%;
1401
- bottom: calc(92px + var(--chrome-inset, 0px));
1561
+ bottom: 92px;
1402
1562
  z-index: 15;
1403
1563
  transform: translateX(-50%);
1404
1564
  padding: 7px 14px;
@@ -1413,38 +1573,36 @@
1413
1573
 
1414
1574
  @media (max-width: 420px) {
1415
1575
  .place-at-center {
1416
- bottom: calc(78px + var(--chrome-inset, 0px));
1576
+ bottom: 78px;
1417
1577
  }
1418
1578
  }
1419
1579
 
1420
1580
  .map :global(.maplibregl-ctrl-bottom-right) {
1421
1581
  right: 20px;
1422
- /* rides above the bottom sheet so attribution stays legible */
1423
- bottom: calc(18px + var(--chrome-inset, 0px));
1582
+ bottom: 12px;
1424
1583
  display: flex;
1425
- flex-direction: column;
1584
+ flex-direction: row;
1426
1585
  align-items: flex-end;
1427
- gap: 6px;
1586
+ gap: 16px;
1428
1587
  transition:
1429
1588
  bottom var(--dur-med) var(--ease-out),
1430
1589
  opacity var(--dur-fast) var(--ease-out);
1431
1590
  }
1432
1591
 
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
1592
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl) {
1440
1593
  float: none;
1441
1594
  margin: 0;
1442
1595
  }
1443
1596
 
1597
+ .map :global(.maplibregl-ctrl-group) {
1598
+ order: 2;
1599
+ }
1600
+
1444
1601
  .map :global(.maplibregl-ctrl-attrib) {
1445
- order: -1;
1602
+ order: 1;
1446
1603
  position: relative;
1447
- right: 2px;
1604
+ bottom: 0;
1605
+ right: 0;
1448
1606
  width: auto;
1449
1607
  min-width: 0;
1450
1608
  max-width: calc(100vw - 96px);
@@ -1494,31 +1652,35 @@
1494
1652
  }
1495
1653
 
1496
1654
  @media (max-width: 760px) {
1655
+ .map :global(.maplibregl-ctrl-bottom-right) {
1656
+ bottom: 12px;
1657
+ right: 12px;
1658
+ gap: 12px;
1659
+ }
1497
1660
  .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;
1661
+ position: relative;
1662
+ bottom: auto;
1501
1663
  width: auto;
1502
1664
  min-width: 0;
1503
1665
  right: 0;
1504
1666
  white-space: normal;
1505
1667
  }
1506
1668
 
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
1669
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group) {
1511
- display: none;
1670
+ display: flex;
1671
+ }
1672
+ .map :global(.maplibregl-ctrl-group button + button) {
1673
+ border-top: 0;
1674
+ border-left: 1px solid var(--line);
1512
1675
  }
1513
1676
  }
1514
1677
 
1515
- /* One line on a small phone: wrapped attribution turns into a wide block
1516
- sitting over the network. */
1678
+ /* Attribution shares the bottom row with touch-sized zoom buttons. */
1517
1679
  @media (max-width: 460px) {
1518
1680
  .map :global(.maplibregl-ctrl-attrib) {
1519
- max-width: calc(100vw - 20px);
1681
+ max-width: calc(100vw - 140px);
1520
1682
  font-size: 9px;
1521
- white-space: nowrap;
1683
+ white-space: normal;
1522
1684
  }
1523
1685
  }
1524
1686