@tellegen/svelte 0.1.2 → 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 (140) hide show
  1. package/README.md +46 -0
  2. package/dist/Sparkline.svelte.d.ts +0 -1
  3. package/dist/TellegenMap.svelte +328 -200
  4. package/dist/TellegenMap.svelte.d.ts +0 -1
  5. package/dist/TellegenProvider.svelte +4 -0
  6. package/dist/TellegenProvider.svelte.d.ts +0 -1
  7. package/dist/TellegenShell.svelte +19 -13
  8. package/dist/TellegenShell.svelte.d.ts +0 -1
  9. package/dist/TellegenViewer.svelte.d.ts +0 -1
  10. package/dist/api.d.ts +8 -2
  11. package/dist/api.js +6 -2
  12. package/dist/colors.d.ts +7 -8
  13. package/dist/colors.js +13 -13
  14. package/dist/components/AppFooter.svelte.d.ts +0 -1
  15. package/dist/components/AppHeader.svelte +27 -13
  16. package/dist/components/AppHeader.svelte.d.ts +0 -1
  17. package/dist/components/BindingLines.svelte +3 -6
  18. package/dist/components/BindingLines.svelte.d.ts +0 -1
  19. package/dist/components/BusPicker.svelte +8 -2
  20. package/dist/components/BusPicker.svelte.d.ts +0 -1
  21. package/dist/components/ControlPanel.svelte +106 -343
  22. package/dist/components/ControlPanel.svelte.d.ts +0 -1
  23. package/dist/components/DemandSlider.svelte +26 -8
  24. package/dist/components/DemandSlider.svelte.d.ts +0 -1
  25. package/dist/components/DiagramCanvas.svelte +486 -0
  26. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  27. package/dist/components/DisplayControls.svelte +4 -2
  28. package/dist/components/DisplayControls.svelte.d.ts +0 -1
  29. package/dist/components/DropZone.svelte.d.ts +0 -1
  30. package/dist/components/FormulationSelector.svelte +8 -3
  31. package/dist/components/FormulationSelector.svelte.d.ts +0 -1
  32. package/dist/components/LocalCaseDetails.svelte +57 -44
  33. package/dist/components/LocalCaseDetails.svelte.d.ts +0 -1
  34. package/dist/components/McResults.svelte +365 -0
  35. package/dist/components/McResults.svelte.d.ts +11 -0
  36. package/dist/components/ModelDetails.svelte +230 -0
  37. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  38. package/dist/components/MulticonductorDetails.svelte +227 -78
  39. package/dist/components/MulticonductorDetails.svelte.d.ts +0 -1
  40. package/dist/components/NetworkStats.svelte +20 -18
  41. package/dist/components/NetworkStats.svelte.d.ts +0 -1
  42. package/dist/components/NotificationCenter.svelte +243 -0
  43. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  44. package/dist/components/PanelCard.svelte +319 -0
  45. package/dist/components/PanelCard.svelte.d.ts +9 -0
  46. package/dist/components/PanelFrame.svelte +46 -0
  47. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  48. package/dist/components/PanelHost.svelte +212 -0
  49. package/dist/components/PanelHost.svelte.d.ts +3 -0
  50. package/dist/components/PlacementCue.svelte.d.ts +0 -1
  51. package/dist/components/PowerFlowDetails.svelte +99 -0
  52. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  53. package/dist/components/RatingSlider.svelte +5 -9
  54. package/dist/components/RatingSlider.svelte.d.ts +0 -1
  55. package/dist/components/RestoreDefaultsButton.svelte.d.ts +0 -1
  56. package/dist/components/SavedNetworkDetails.svelte +214 -0
  57. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  58. package/dist/components/SensitivityReadout.svelte +24 -16
  59. package/dist/components/SensitivityReadout.svelte.d.ts +0 -1
  60. package/dist/components/SizeLegend.svelte.d.ts +0 -1
  61. package/dist/components/SolveCard.svelte +46 -49
  62. package/dist/components/SolveCard.svelte.d.ts +0 -1
  63. package/dist/components/TopMovers.svelte +8 -3
  64. package/dist/components/TopMovers.svelte.d.ts +2 -18
  65. package/dist/components.d.ts +2 -1
  66. package/dist/components.js +2 -0
  67. package/dist/context.svelte.d.ts +0 -1
  68. package/dist/controller.svelte.d.ts +74 -41
  69. package/dist/controller.svelte.js +1095 -284
  70. package/dist/diagram-labels.d.ts +9 -0
  71. package/dist/diagram-labels.js +40 -0
  72. package/dist/display.d.ts +3 -8
  73. package/dist/display.js +31 -27
  74. package/dist/drop-classify.d.ts +5 -42
  75. package/dist/drop-classify.js +5 -78
  76. package/dist/drop-limits.d.ts +27 -0
  77. package/dist/drop-limits.js +83 -0
  78. package/dist/format.d.ts +0 -1
  79. package/dist/format.js +5 -4
  80. package/dist/index.d.ts +11 -7
  81. package/dist/index.js +8 -3
  82. package/dist/map-bounds.d.ts +5 -0
  83. package/dist/map-bounds.js +41 -0
  84. package/dist/map.d.ts +1 -2
  85. package/dist/map.js +1 -1
  86. package/dist/multiconductor.d.ts +11 -3
  87. package/dist/multiconductor.js +107 -14
  88. package/dist/notices.svelte.d.ts +29 -0
  89. package/dist/notices.svelte.js +82 -0
  90. package/dist/panels.svelte.d.ts +65 -0
  91. package/dist/panels.svelte.js +215 -0
  92. package/dist/state.svelte.d.ts +90 -24
  93. package/dist/state.svelte.js +94 -20
  94. package/dist/synthetic-layout.d.ts +0 -1
  95. package/dist/synthetic-layout.js +17 -2
  96. package/dist/transformer-icon.d.ts +0 -1
  97. package/dist/types.d.ts +0 -1
  98. package/package.json +28 -12
  99. package/dist/Sparkline.svelte.d.ts.map +0 -1
  100. package/dist/TellegenMap.svelte.d.ts.map +0 -1
  101. package/dist/TellegenProvider.svelte.d.ts.map +0 -1
  102. package/dist/TellegenShell.svelte.d.ts.map +0 -1
  103. package/dist/TellegenViewer.svelte.d.ts.map +0 -1
  104. package/dist/api.d.ts.map +0 -1
  105. package/dist/colors.d.ts.map +0 -1
  106. package/dist/components/AppFooter.svelte.d.ts.map +0 -1
  107. package/dist/components/AppHeader.svelte.d.ts.map +0 -1
  108. package/dist/components/BindingLines.svelte.d.ts.map +0 -1
  109. package/dist/components/BusPicker.svelte.d.ts.map +0 -1
  110. package/dist/components/ControlPanel.svelte.d.ts.map +0 -1
  111. package/dist/components/DemandSlider.svelte.d.ts.map +0 -1
  112. package/dist/components/DisplayControls.svelte.d.ts.map +0 -1
  113. package/dist/components/DropZone.svelte.d.ts.map +0 -1
  114. package/dist/components/FormulationSelector.svelte.d.ts.map +0 -1
  115. package/dist/components/LocalCaseDetails.svelte.d.ts.map +0 -1
  116. package/dist/components/MulticonductorDetails.svelte.d.ts.map +0 -1
  117. package/dist/components/NetworkStats.svelte.d.ts.map +0 -1
  118. package/dist/components/PlacementCue.svelte.d.ts.map +0 -1
  119. package/dist/components/RatingSlider.svelte.d.ts.map +0 -1
  120. package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +0 -1
  121. package/dist/components/SensitivityReadout.svelte.d.ts.map +0 -1
  122. package/dist/components/SizeLegend.svelte.d.ts.map +0 -1
  123. package/dist/components/SolveCard.svelte.d.ts.map +0 -1
  124. package/dist/components/TopMovers.svelte.d.ts.map +0 -1
  125. package/dist/components.d.ts.map +0 -1
  126. package/dist/context.svelte.d.ts.map +0 -1
  127. package/dist/controller.svelte.d.ts.map +0 -1
  128. package/dist/display.d.ts.map +0 -1
  129. package/dist/drop-classify.d.ts.map +0 -1
  130. package/dist/format.d.ts.map +0 -1
  131. package/dist/index.d.ts.map +0 -1
  132. package/dist/map.d.ts.map +0 -1
  133. package/dist/multiconductor.d.ts.map +0 -1
  134. package/dist/state.svelte.d.ts.map +0 -1
  135. package/dist/study-package.d.ts +0 -9
  136. package/dist/study-package.d.ts.map +0 -1
  137. package/dist/study-package.js +0 -17
  138. package/dist/synthetic-layout.d.ts.map +0 -1
  139. package/dist/transformer-icon.d.ts.map +0 -1
  140. package/dist/types.d.ts.map +0 -1
@@ -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,12 +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';
36
+ import { foldMapBounds } from './map-bounds.js';
35
37
  import { getAppState, getController } from './context.svelte.js';
36
38
  import { displayFmt, fmt, rgbaCss } from './format.js';
39
+ import { getPanelLayout } from './panels.svelte.js';
37
40
  import type { RGBA } from './colors.js';
41
+ import DiagramCanvas from './components/DiagramCanvas.svelte';
38
42
 
39
43
  let {
40
44
  onbusclick,
@@ -58,6 +62,47 @@
58
62
 
59
63
  const app = getAppState();
60
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
+ }
61
106
 
62
107
  const STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
63
108
  const CUE_MARGIN_PX = 64;
@@ -65,7 +110,7 @@
65
110
  const BRANCH_FOCUS_MAX_ZOOM = 9.5;
66
111
 
67
112
  let map = $state.raw<MapLibreMap | null>(null);
68
- let overlay = $state.raw<MapboxOverlay | null>(null);
113
+ let overlay = $state.raw<MapLibreOverlay | null>(null);
69
114
  // Bumped to remount the map after a WebGL context is lost and cannot be
70
115
  // repainted in place. See initMap's context-loss handling.
71
116
  let mapGen = $state(0);
@@ -84,7 +129,7 @@
84
129
  let layerCtors = $state.raw<LayerCtors | null>(null);
85
130
 
86
131
  interface CaseDisplay {
87
- lmp: Map<number, number>;
132
+ prices: Map<number, number>;
88
133
  scalar: Map<number, number>;
89
134
  scalarLo: number;
90
135
  scalarHi: number;
@@ -92,7 +137,7 @@
92
137
  scalarLabel: string;
93
138
  scalarUnit: string;
94
139
  loading: Map<number, number>;
95
- mode: 'lmp' | 'sens' | 'preview';
140
+ mode: 'price' | 'sens' | 'preview';
96
141
  sens: Map<number, number>;
97
142
  sensDomain: SensitivityDomain | null;
98
143
  preview: Map<number, number>;
@@ -105,7 +150,7 @@
105
150
  type SolvableCase = CaseState | LocalCase;
106
151
 
107
152
  // Everything the accessors need, rebuilt when any case's data moves. The
108
- // 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
109
154
  // within network structure beats cross network color comparability (the
110
155
  // panel legend and tooltips carry the actual numbers). Preview values
111
156
  // shift individual buses without rescaling.
@@ -113,8 +158,8 @@
113
158
  const active = app.active ?? app.activeLocal;
114
159
  const selectedBus = app.selectedBus;
115
160
  const selectedBranch = app.selectedBranch;
116
- // The active case's column for the live selection: the ∂LMP/∂d column at the
117
- // 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
118
163
  // bus-keyed value maps, so the color pipeline below is target-agnostic.
119
164
  const activeSensitivity =
120
165
  active &&
@@ -124,18 +169,18 @@
124
169
  : null;
125
170
  const activeDeltas = active ? caseDeltas(active) : {};
126
171
  const activeRatings = active ? caseRatings(active) : {};
127
- // 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
128
173
  // the live drag, scoped to this case + selection target. Preferred over the JS
129
174
  // gradient shift when present; the gradient path stays for server and browser
130
175
  // fallbacks.
131
176
  const enginePreview =
132
177
  active &&
133
- app.previewLmp &&
134
- app.previewLmp.caseId === active.id &&
135
- ('bus' in app.previewLmp.target
136
- ? app.previewLmp.target.bus === selectedBus
137
- : app.previewLmp.target.branch === selectedBranch)
138
- ? 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
139
184
  : null;
140
185
  const previewStep =
141
186
  active && activeSensitivity
@@ -152,13 +197,13 @@
152
197
  const perCase = new Map<string, CaseDisplay>();
153
198
  const addCase = (c: CaseState | LocalCase) => {
154
199
  if (!c.network || !c.solution) return;
155
- const lmp = new Map<number, number>();
156
- 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);
157
202
  // Resolve the active display mode against what this case can show, falling
158
- // 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).
159
204
  const meta = displayMetaFor(c);
160
205
  const activeMeta = meta.find((o) => o.mode === app.displayMode) ?? meta[0] ?? null;
161
- const scalarMode = activeMeta?.mode ?? 'lmp';
206
+ const scalarMode = activeMeta?.mode ?? 'price';
162
207
  const seriesValues = displaySeriesFor(c, scalarMode);
163
208
  const scalar = new Map<number, number>();
164
209
  for (const e of seriesValues) scalar.set(e.bus, e.value);
@@ -174,10 +219,10 @@
174
219
  let domain: SensitivityDomain | null = null;
175
220
  const isActive = c === active;
176
221
  if (isActive && enginePreview) {
177
- // Engine preview owns the LMP shift: add the predicted ΔLMP per bus.
178
- for (const [bus, dlmp] of enginePreview) {
179
- preview.set(bus, dlmp);
180
- 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);
181
226
  }
182
227
  }
183
228
  if (isActive && activeSensitivity) {
@@ -189,9 +234,9 @@
189
234
  if (!enginePreview && previewStep !== 0) {
190
235
  // Fallback first order preview: shift LMPs along the gradient.
191
236
  for (const v of activeSensitivity.values) {
192
- const dlmp = v.value * previewStep;
193
- preview.set(v.bus, dlmp);
194
- 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);
195
240
  }
196
241
  }
197
242
  }
@@ -202,16 +247,15 @@
202
247
  previewDomain &&
203
248
  (app.previewActive || c.solving || enginePreview || Math.abs(previewStep) >= 0.25)
204
249
  );
205
- // The sensitivity and demand-preview overlays are price-space fields (∂LMP/∂d
206
- // 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;
207
251
  // under angle or |V| the map stays on the chosen scalar and the sensitivity
208
252
  // shows in the side panel, so selecting a bus never silently overrides the
209
253
  // angle/|V| coloring with the price ramp.
210
- const sensEligible = scalarMode === 'lmp';
254
+ const sensEligible = scalarMode === 'price';
211
255
  // While a sensitivity is still loading for the selected bus, stay in sens
212
- // 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
213
257
  // don't flash orange->purple between the old and new selection.
214
- const mode: 'lmp' | 'sens' | 'preview' =
258
+ const mode: 'price' | 'sens' | 'preview' =
215
259
  sensEligible && showPreview
216
260
  ? 'preview'
217
261
  : sensEligible &&
@@ -220,15 +264,15 @@
220
264
  (domain || app.sensitivityLoading) &&
221
265
  !previewing
222
266
  ? 'sens'
223
- : 'lmp';
267
+ : 'price';
224
268
  perCase.set(c.id, {
225
- lmp,
269
+ prices,
226
270
  scalar,
227
271
  scalarLo,
228
272
  scalarHi,
229
273
  scalarMode,
230
274
  scalarLabel: activeMeta?.label ?? 'LMP',
231
- scalarUnit: activeMeta?.unit ?? '$/MWh',
275
+ scalarUnit: activeMeta?.unit ?? 'objective units/MW',
232
276
  loading,
233
277
  mode,
234
278
  sens,
@@ -265,7 +309,7 @@
265
309
  return sensColor((d.preview.get(bus.id) ?? 0) / d.previewDomain.scale);
266
310
  }
267
311
  const mid = (d.scalarLo + d.scalarHi) / 2;
268
- 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));
269
313
  };
270
314
  }
271
315
 
@@ -281,34 +325,38 @@
281
325
  // click handler must not treat those clicks as "empty map" and clear the selection.
282
326
  function isSelectableLayer(layerId: string | undefined): boolean {
283
327
  return Boolean(
328
+ layerId?.startsWith('study-') ||
284
329
  layerId?.startsWith('buses-') ||
285
- layerId?.startsWith('local-buses-') ||
286
- layerId?.startsWith('branches-') ||
287
- layerId?.startsWith('local-branches-') ||
288
- layerId?.startsWith('multi-buses-') ||
289
- 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-')
290
335
  );
291
336
  }
292
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
+ }
293
343
  const selectedBusCue = $derived.by(() => {
294
344
  const busId = app.selectedBus;
295
- const c = app.active ?? app.activeLocal;
296
- if (!c || busId === null) return null;
297
- const buses =
298
- app.active?.network?.buses ?? app.activeLocal?.network?.buses ?? app.activeLocal?.view?.buses ?? [];
299
- const bus = buses.find((b) => b.id === busId);
300
- 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;
301
351
  });
302
352
  const selectedBranchCue = $derived.by(() => {
303
353
  const branchId = app.selectedBranch;
304
- const c = app.active ?? app.activeLocal;
305
- if (!c || branchId === null) return null;
306
- const branches =
307
- c instanceof CaseState ? c.network?.branches : (c.view?.branches ?? c.network?.branches);
308
- 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);
309
357
  const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
310
358
  return branch && path && path.length >= 2
311
- ? { 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 }
312
360
  : null;
313
361
  });
314
362
  let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
@@ -316,6 +364,7 @@
316
364
  let selectedBranchHaloPath = $state.raw<SVGPathElement | null>(null);
317
365
  let selectedBranchLinePath = $state.raw<SVGPathElement | null>(null);
318
366
  let handledFrameSeq = 0;
367
+ let handledCameraSeq = 0;
319
368
 
320
369
  // The selected multiconductor bus and its incident edges, whose per-conductor
321
370
  // strands and terminal stack the detail overlay draws in screen space.
@@ -491,63 +540,32 @@
491
540
  });
492
541
  }
493
542
 
494
- /** Camera padding that keeps the framed subject clear of the panel chrome.
495
- * 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. */
496
544
  function framePadding(width: number, height: number, kind: 'case' | 'branch') {
497
- if (width <= 760) {
498
- // bottom follows the sheet; capped so a fully open sheet still leaves a band to fit into
499
- const clearance = kind === 'branch' ? 16 : 28;
500
- const bottom = Math.min(app.sheetInset + clearance, Math.round(height * 0.55));
501
- return { top: app.headerInset + 8, left: 24, right: 24, bottom };
502
- }
503
- return kind === 'branch'
504
- ? { top: 106, left: 388, right: 76, bottom: 80 }
505
- : { top: 96, left: 380, right: 60, bottom: 64 };
506
- }
507
-
508
- /** Fold lon/lat pairs into LngLat bounds; null when no point is finite. */
509
- function foldBounds(
510
- points: Iterable<[number, number]>
511
- ): [[number, number], [number, number]] | null {
512
- let minLon = Infinity;
513
- let minLat = Infinity;
514
- let maxLon = -Infinity;
515
- let maxLat = -Infinity;
516
- for (const [lon, lat] of points) {
517
- minLon = Math.min(minLon, lon);
518
- minLat = Math.min(minLat, lat);
519
- maxLon = Math.max(maxLon, lon);
520
- maxLat = Math.max(maxLat, lat);
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
+ };
521
553
  }
522
- if (!Number.isFinite(minLon) || !Number.isFinite(minLat)) return null;
523
- return [
524
- [minLon, minLat],
525
- [maxLon, maxLat]
526
- ];
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
+ };
527
560
  }
528
561
 
529
562
  function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
530
- const bounds = foldBounds(path);
531
- if (!bounds) return null;
532
- let [[minLon, minLat], [maxLon, maxLat]] = bounds;
533
- if (minLon === maxLon) {
534
- minLon -= 0.005;
535
- maxLon += 0.005;
536
- }
537
- if (minLat === maxLat) {
538
- minLat -= 0.005;
539
- maxLat += 0.005;
540
- }
541
- return [
542
- [minLon, minLat],
543
- [maxLon, maxLat]
544
- ];
563
+ return foldMapBounds(path);
545
564
  }
546
565
 
547
566
  function prefersReducedMotion(): boolean {
548
567
  return (
549
- typeof window !== 'undefined' &&
550
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
568
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
551
569
  );
552
570
  }
553
571
 
@@ -555,25 +573,9 @@
555
573
  return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
556
574
  }
557
575
 
558
- // Compact chrome anchoring. maplibre's bottom-right controls sit over the map,
559
- // so the sheet would bury them; they ride above it instead. Under
560
- // CHROME_MIN_BAND_PX of map left they drop out rather than climb into the
561
- // header — at that point the sheet covers what they annotate.
562
- const CHROME_MIN_BAND_PX = 132;
563
-
564
- /** How far the sheet can push the chrome up before it would reach the header. */
565
- const chromeLiftCap = $derived(
566
- Math.max(0, app.viewportHeight - app.headerInset - CHROME_MIN_BAND_PX)
567
- );
568
- const chromeInset = $derived(
569
- app.compactLayout ? Math.min(app.sheetInset, chromeLiftCap) : 0
570
- );
571
- const chromeVisible = $derived(!app.compactLayout || app.sheetInset <= chromeLiftCap);
572
-
573
- /** Fly to the selected branch; true when a camera move started. */
574
- function focusSelectedBranch(): boolean {
575
- if (!map || !selectedBranchCue) return false;
576
- const bounds = branchBounds(selectedBranchCue.path);
576
+ function focusGeometry(points: [number, number][], settle: () => void): boolean {
577
+ if (!map) return false;
578
+ const bounds = branchBounds(points);
577
579
  if (!bounds) return false;
578
580
  const { clientWidth, clientHeight } = map.getContainer();
579
581
  const camera = map.cameraForBounds(bounds, {
@@ -581,6 +583,7 @@
581
583
  maxZoom: Math.min(map.getZoom() + 2, BRANCH_FOCUS_MAX_ZOOM)
582
584
  });
583
585
  if (!camera?.center) return false;
586
+ map.once('moveend', settle);
584
587
  map.easeTo({
585
588
  center: camera.center,
586
589
  zoom: camera.zoom ?? map.getZoom(),
@@ -614,6 +617,21 @@
614
617
  function tooltip(info: PickingInfo): { html: string } | null {
615
618
  const { object } = info;
616
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
+ }
617
635
  if (info.layer?.id.startsWith('local-subs-')) {
618
636
  // PowerWorld .pwd substation: display data only, position inferred from the diagram.
619
637
  // The name is free text from a dropped file, so escape it; the number
@@ -621,7 +639,7 @@
621
639
  const s = object as { number: number; name: string };
622
640
  const named = s.name ? ` ${esc(s.name)}` : '';
623
641
  return {
624
- 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>`
625
643
  };
626
644
  }
627
645
  const layerId = info.layer?.id;
@@ -661,7 +679,7 @@
661
679
  <span style="opacity:0.6">local file</span></div>`
662
680
  };
663
681
  }
664
- const lmp = d?.lmp.get(bus.id);
682
+ const price = d?.prices.get(bus.id);
665
683
  const scalar = d?.scalar.get(bus.id);
666
684
  const sens = d?.mode === 'sens' ? d.sens.get(bus.id) : undefined;
667
685
  const preview = d?.mode === 'preview' ? d.preview.get(bus.id) : undefined;
@@ -673,19 +691,19 @@
673
691
  const sensRow =
674
692
  sens === undefined
675
693
  ? ''
676
- : `<br>&part;LMP/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
694
+ : `<br>&part;price/&part;d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
677
695
  const previewRow =
678
696
  preview === undefined
679
697
  ? ''
680
- : `<br>&Delta;LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} $/MWh`;
698
+ : `<br>&Delta; LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
681
699
  const localRow =
682
700
  c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
683
701
  const scalarRow =
684
- !d || d.scalarMode === 'lmp'
685
- ? `LMP ${lmp === undefined ? '&mdash;' : fmt.format(lmp)} $/MWh`
702
+ !d || d.scalarMode === 'price'
703
+ ? `LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`
686
704
  : `${d.scalarLabel} ${
687
705
  scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
688
- } ${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`;
689
707
  return {
690
708
  html: `<div class="tt"><b>bus ${bus.id}</b>
691
709
  ${scalarRow}<br>
@@ -694,14 +712,17 @@
694
712
  }
695
713
 
696
714
  async function loadMapModules() {
697
- const [maplibre, mapbox, layers] = await Promise.all([
715
+ const [maplibre, deckMap, layers, worker] = await Promise.all([
698
716
  import('maplibre-gl'),
699
- import('@deck.gl/mapbox'),
700
- 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')
701
720
  ]);
721
+ // Bundle the worker with its shared imports and configure it before creating a map.
722
+ maplibre.setWorkerUrl(worker.default);
702
723
  return {
703
- maplibregl: maplibre.default,
704
- MapboxOverlay: mapbox.MapboxOverlay,
724
+ maplibregl: maplibre,
725
+ MapLibreOverlay: deckMap.MapLibreOverlay,
705
726
  PathLayer: layers.PathLayer,
706
727
  ScatterplotLayer: layers.ScatterplotLayer,
707
728
  IconLayer: layers.IconLayer
@@ -716,9 +737,9 @@
716
737
  // memory and tab pressure, and one context survives that far better; the
717
738
  // deck layers also inherit maplibre's 4096 canvas clamp. The flat positron
718
739
  // basemap writes no depth, so the overlay always draws on top.
719
- function buildOverlay(MapboxOverlay: MapModules['MapboxOverlay']): MapboxOverlay {
740
+ function buildOverlay(MapLibreOverlay: MapModules['MapLibreOverlay']): MapLibreOverlay {
720
741
  const touch = coarsePointer();
721
- return new MapboxOverlay({
742
+ return new MapLibreOverlay({
722
743
  interleaved: true,
723
744
  layers: [],
724
745
  parameters: {
@@ -741,7 +762,7 @@
741
762
  let cleanup = () => {};
742
763
  let cancelled = false;
743
764
  void loadMapModules()
744
- .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
765
+ .then(({ maplibregl, MapLibreOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
745
766
  if (cancelled) return;
746
767
  layerCtors = { PathLayer, ScatterplotLayer, IconLayer };
747
768
  const m = new maplibregl.Map({
@@ -752,7 +773,7 @@
752
773
  canvasContextAttributes: { antialias: true },
753
774
  attributionControl: { compact: true }
754
775
  });
755
- const o = buildOverlay(MapboxOverlay);
776
+ const o = buildOverlay(MapLibreOverlay);
756
777
  m.addControl(o);
757
778
  m.on('click', (e) => {
758
779
  if (app.placingId) onplacecase(e.lngLat.lng, e.lngLat.lat);
@@ -777,6 +798,16 @@
777
798
  if (document.visibilityState === 'visible') repaint();
778
799
  };
779
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);
780
811
  let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
781
812
  const clearRebuild = () => {
782
813
  if (rebuildTimer !== null) {
@@ -831,7 +862,9 @@
831
862
  // (from the case load) can fire before the async map import finishes and
832
863
  // be lost; re-issuing it here ensures the first view is the active case,
833
864
  // not the default world view. A remount opens at the default view too.
834
- 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');
835
868
  if (mapGen > 0) {
836
869
  // This is a rebuilt map. Once it has held a live context without
837
870
  // another loss, zero the budget so the cap only ever catches a tight
@@ -872,6 +905,74 @@
872
905
  if (!overlay || !layerCtors) return;
873
906
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
874
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
+ }
875
976
  for (const c of app.cases) {
876
977
  if (!c.network) continue;
877
978
  const d = display.get(c.id);
@@ -881,7 +982,7 @@
881
982
  data: c.network.branches,
882
983
  getPath: (b) => b.path,
883
984
  // The selected branch draws in the selection blue of the bus halo cue,
884
- // wider, so the ∂LMP/∂rating source line reads at a glance.
985
+ // wider, so the price-rating source line reads at a glance.
885
986
  getColor: (b) =>
886
987
  c.id === app.activeCaseId && b.id === app.selectedBranch
887
988
  ? [47, 111, 187, 255]
@@ -1017,7 +1118,12 @@
1017
1118
  }
1018
1119
  if (c.substations) {
1019
1120
  layers.push(
1020
- 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
+ }>({
1021
1127
  id: `local-subs-${c.id}`,
1022
1128
  data: c.substations.points,
1023
1129
  getPosition: (s) => [s.lon, s.lat],
@@ -1041,7 +1147,7 @@
1041
1147
  // kind and buses badged by attachment role. Selecting a bus fans its
1042
1148
  // incident conductors and expands its terminal stack in the SVG overlay.
1043
1149
  for (const c of app.multiCases) {
1044
- if (!c.view) continue;
1150
+ if (c.coordsKind !== 'geographic' || !c.view) continue;
1045
1151
  const selectedId = c.id === app.activeMultiId ? c.selectedBusId : null;
1046
1152
  const selectedEdgeId = c.id === app.activeMultiId ? c.selectedEdgeId : null;
1047
1153
  layers.push(
@@ -1148,7 +1254,7 @@
1148
1254
  // does not re-run (and re-pan) on every frame of a sheet drag.
1149
1255
  $effect(() => {
1150
1256
  const cue = selectedBusCue;
1151
- if (!map || !cue || !app.compactLayout) return;
1257
+ if (!map || !cue || !app.compactLayout || app.studyView) return;
1152
1258
  void cue.key;
1153
1259
  const m = map;
1154
1260
  const timer = setTimeout(() => {
@@ -1163,6 +1269,7 @@
1163
1269
  });
1164
1270
 
1165
1271
  function boundsFor(target: string): LngLatBoundsLike | null {
1272
+ if (app.studyView) return foldMapBounds(app.studyView.network.buses.map((b) => [b.lon, b.lat]));
1166
1273
  const points: [number, number][] = [];
1167
1274
  const fold = (pts: { lon: number; lat: number }[]) => {
1168
1275
  for (const b of pts) points.push([b.lon, b.lat]);
@@ -1177,28 +1284,41 @@
1177
1284
  if (c.substations) fold(c.substations.points);
1178
1285
  }
1179
1286
  for (const c of app.multiCases) {
1287
+ if (c.coordsKind !== 'geographic') continue;
1180
1288
  if (target !== 'all' && c.id !== target) continue;
1181
1289
  if (c.view) fold(c.view.buses);
1182
1290
  }
1183
- return foldBounds(points);
1291
+ return foldMapBounds(points);
1184
1292
  }
1185
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
+
1186
1303
  // Fly to whatever the header, the initial load, or a branch selection asked
1187
1304
  // for, and settle the request's promise once the camera lands so callers can
1188
1305
  // defer heavy work past the animation. A branch request waits (unhandled)
1189
1306
  // until its cue exists; anything unframeable settles immediately.
1190
1307
  $effect(() => {
1191
1308
  const seq = app.frameSeq;
1192
- if (!map || seq === handledFrameSeq) return;
1309
+ if (diagramNetwork || !map || seq === handledFrameSeq) return;
1193
1310
  const m = map;
1194
1311
  const target = app.frameTarget;
1195
1312
  const settle = () => app.settleFrame();
1196
1313
  if (typeof target === 'object') {
1197
- const cue = selectedBranchCue;
1198
- 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;
1199
1319
  handledFrameSeq = seq;
1200
- if (focusSelectedBranch()) m.once('moveend', settle);
1201
- else settle();
1320
+ const points: [number, number][] = bus ? [[bus.lon, bus.lat]] : (branch?.path ?? []);
1321
+ if (!focusGeometry(points, settle)) settle();
1202
1322
  return;
1203
1323
  }
1204
1324
  handledFrameSeq = seq;
@@ -1208,47 +1328,53 @@
1208
1328
  return;
1209
1329
  }
1210
1330
  const { clientWidth: w, clientHeight: h } = m.getContainer();
1331
+ m.once('moveend', settle);
1211
1332
  m.fitBounds(bounds, {
1212
1333
  padding: framePadding(w, h, 'case'),
1213
1334
  duration: prefersReducedMotion() ? 0 : 1400
1214
1335
  });
1215
- m.once('moveend', settle);
1216
1336
  });
1217
1337
  </script>
1218
1338
 
1219
- {#key mapGen}
1220
- <div
1221
- class="map-stage"
1222
- class:chrome-clear={!chromeVisible}
1223
- style="--chrome-inset: {chromeInset}px"
1224
- >
1225
- <div class="map" {@attach initMap}></div>
1226
- {#if selectedBusCue}
1227
- {#key selectedBusCue.key}
1228
- <div class="selected-bus-cue" bind:this={selectedCueEl} aria-hidden="true"></div>
1229
- {/key}
1230
- {/if}
1231
- {#if selectedBranchCue}
1232
- {#key selectedBranchCue.key}
1233
- <svg class="selected-branch-cue" bind:this={selectedBranchCueEl} aria-hidden="true">
1234
- <path class="selected-branch-halo" bind:this={selectedBranchHaloPath}></path>
1235
- <path class="selected-branch-line" bind:this={selectedBranchLinePath}></path>
1236
- </svg>
1237
- {/key}
1238
- {/if}
1239
- {#if selectedMultiDetail}
1240
- <!-- 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
1241
1367
  projection); file-derived text is escaped there. -->
1242
- <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1243
- {/if}
1244
- </div>
1245
- {/key}
1368
+ <svg class="multi-detail" bind:this={multiDetailEl} aria-hidden="true"></svg>
1369
+ {/if}
1370
+ </div>
1371
+ {/key}
1372
+ {/if}
1246
1373
 
1247
1374
  {#if app.placingId && map}
1248
1375
  <button
1249
1376
  type="button"
1250
1377
  class="place-at-center mono"
1251
- style="--chrome-inset: {chromeInset}px"
1252
1378
  onclick={() => {
1253
1379
  if (!map) return;
1254
1380
  const center = map.getCenter();
@@ -1432,7 +1558,7 @@
1432
1558
  .place-at-center {
1433
1559
  position: absolute;
1434
1560
  left: 50%;
1435
- bottom: calc(92px + var(--chrome-inset, 0px));
1561
+ bottom: 92px;
1436
1562
  z-index: 15;
1437
1563
  transform: translateX(-50%);
1438
1564
  padding: 7px 14px;
@@ -1447,38 +1573,36 @@
1447
1573
 
1448
1574
  @media (max-width: 420px) {
1449
1575
  .place-at-center {
1450
- bottom: calc(78px + var(--chrome-inset, 0px));
1576
+ bottom: 78px;
1451
1577
  }
1452
1578
  }
1453
1579
 
1454
1580
  .map :global(.maplibregl-ctrl-bottom-right) {
1455
1581
  right: 20px;
1456
- /* rides above the bottom sheet so attribution stays legible */
1457
- bottom: calc(18px + var(--chrome-inset, 0px));
1582
+ bottom: 12px;
1458
1583
  display: flex;
1459
- flex-direction: column;
1584
+ flex-direction: row;
1460
1585
  align-items: flex-end;
1461
- gap: 6px;
1586
+ gap: 16px;
1462
1587
  transition:
1463
1588
  bottom var(--dur-med) var(--ease-out),
1464
1589
  opacity var(--dur-fast) var(--ease-out);
1465
1590
  }
1466
1591
 
1467
- /* The sheet has taken the map; nothing left down there to attribute. */
1468
- .map-stage.chrome-clear :global(.maplibregl-ctrl-bottom-right) {
1469
- opacity: 0;
1470
- pointer-events: none;
1471
- }
1472
-
1473
1592
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl) {
1474
1593
  float: none;
1475
1594
  margin: 0;
1476
1595
  }
1477
1596
 
1597
+ .map :global(.maplibregl-ctrl-group) {
1598
+ order: 2;
1599
+ }
1600
+
1478
1601
  .map :global(.maplibregl-ctrl-attrib) {
1479
- order: -1;
1602
+ order: 1;
1480
1603
  position: relative;
1481
- right: 2px;
1604
+ bottom: 0;
1605
+ right: 0;
1482
1606
  width: auto;
1483
1607
  min-width: 0;
1484
1608
  max-width: calc(100vw - 96px);
@@ -1528,31 +1652,35 @@
1528
1652
  }
1529
1653
 
1530
1654
  @media (max-width: 760px) {
1655
+ .map :global(.maplibregl-ctrl-bottom-right) {
1656
+ bottom: 12px;
1657
+ right: 12px;
1658
+ gap: 12px;
1659
+ }
1531
1660
  .map :global(.maplibregl-ctrl-attrib) {
1532
- /* last in the column so it rests on the sheet's edge instead of
1533
- floating over the network a zoom control's height above it */
1534
- order: 1;
1661
+ position: relative;
1662
+ bottom: auto;
1535
1663
  width: auto;
1536
1664
  min-width: 0;
1537
1665
  right: 0;
1538
1666
  white-space: normal;
1539
1667
  }
1540
1668
 
1541
- /* The band of map a phone has left between the header and the sheet is
1542
- about the height of the solve card, which covers the buttons wherever
1543
- they land. Pinch and double tap zoom; the attribution stays. */
1544
1669
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group) {
1545
- 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);
1546
1675
  }
1547
1676
  }
1548
1677
 
1549
- /* One line on a small phone: wrapped attribution turns into a wide block
1550
- sitting over the network. */
1678
+ /* Attribution shares the bottom row with touch-sized zoom buttons. */
1551
1679
  @media (max-width: 460px) {
1552
1680
  .map :global(.maplibregl-ctrl-attrib) {
1553
- max-width: calc(100vw - 20px);
1681
+ max-width: calc(100vw - 140px);
1554
1682
  font-size: 9px;
1555
- white-space: nowrap;
1683
+ white-space: normal;
1556
1684
  }
1557
1685
  }
1558
1686