@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.
- package/README.md +46 -0
- package/dist/Sparkline.svelte.d.ts +0 -1
- package/dist/TellegenMap.svelte +328 -200
- package/dist/TellegenMap.svelte.d.ts +0 -1
- package/dist/TellegenProvider.svelte +4 -0
- package/dist/TellegenProvider.svelte.d.ts +0 -1
- package/dist/TellegenShell.svelte +19 -13
- package/dist/TellegenShell.svelte.d.ts +0 -1
- package/dist/TellegenViewer.svelte.d.ts +0 -1
- package/dist/api.d.ts +8 -2
- package/dist/api.js +6 -2
- package/dist/colors.d.ts +7 -8
- package/dist/colors.js +13 -13
- package/dist/components/AppFooter.svelte.d.ts +0 -1
- package/dist/components/AppHeader.svelte +27 -13
- package/dist/components/AppHeader.svelte.d.ts +0 -1
- package/dist/components/BindingLines.svelte +3 -6
- package/dist/components/BindingLines.svelte.d.ts +0 -1
- package/dist/components/BusPicker.svelte +8 -2
- package/dist/components/BusPicker.svelte.d.ts +0 -1
- package/dist/components/ControlPanel.svelte +106 -343
- package/dist/components/ControlPanel.svelte.d.ts +0 -1
- package/dist/components/DemandSlider.svelte +26 -8
- package/dist/components/DemandSlider.svelte.d.ts +0 -1
- package/dist/components/DiagramCanvas.svelte +486 -0
- package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
- package/dist/components/DisplayControls.svelte +4 -2
- package/dist/components/DisplayControls.svelte.d.ts +0 -1
- package/dist/components/DropZone.svelte.d.ts +0 -1
- package/dist/components/FormulationSelector.svelte +8 -3
- package/dist/components/FormulationSelector.svelte.d.ts +0 -1
- package/dist/components/LocalCaseDetails.svelte +57 -44
- package/dist/components/LocalCaseDetails.svelte.d.ts +0 -1
- package/dist/components/McResults.svelte +365 -0
- package/dist/components/McResults.svelte.d.ts +11 -0
- package/dist/components/ModelDetails.svelte +230 -0
- package/dist/components/ModelDetails.svelte.d.ts +7 -0
- package/dist/components/MulticonductorDetails.svelte +227 -78
- package/dist/components/MulticonductorDetails.svelte.d.ts +0 -1
- package/dist/components/NetworkStats.svelte +20 -18
- package/dist/components/NetworkStats.svelte.d.ts +0 -1
- package/dist/components/NotificationCenter.svelte +243 -0
- package/dist/components/NotificationCenter.svelte.d.ts +3 -0
- package/dist/components/PanelCard.svelte +319 -0
- package/dist/components/PanelCard.svelte.d.ts +9 -0
- package/dist/components/PanelFrame.svelte +46 -0
- package/dist/components/PanelFrame.svelte.d.ts +16 -0
- package/dist/components/PanelHost.svelte +212 -0
- package/dist/components/PanelHost.svelte.d.ts +3 -0
- package/dist/components/PlacementCue.svelte.d.ts +0 -1
- package/dist/components/PowerFlowDetails.svelte +99 -0
- package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
- package/dist/components/RatingSlider.svelte +5 -9
- package/dist/components/RatingSlider.svelte.d.ts +0 -1
- package/dist/components/RestoreDefaultsButton.svelte.d.ts +0 -1
- package/dist/components/SavedNetworkDetails.svelte +214 -0
- package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
- package/dist/components/SensitivityReadout.svelte +24 -16
- package/dist/components/SensitivityReadout.svelte.d.ts +0 -1
- package/dist/components/SizeLegend.svelte.d.ts +0 -1
- package/dist/components/SolveCard.svelte +46 -49
- package/dist/components/SolveCard.svelte.d.ts +0 -1
- package/dist/components/TopMovers.svelte +8 -3
- package/dist/components/TopMovers.svelte.d.ts +2 -18
- package/dist/components.d.ts +2 -1
- package/dist/components.js +2 -0
- package/dist/context.svelte.d.ts +0 -1
- package/dist/controller.svelte.d.ts +74 -41
- package/dist/controller.svelte.js +1095 -284
- package/dist/diagram-labels.d.ts +9 -0
- package/dist/diagram-labels.js +40 -0
- package/dist/display.d.ts +3 -8
- package/dist/display.js +31 -27
- package/dist/drop-classify.d.ts +5 -42
- package/dist/drop-classify.js +5 -78
- package/dist/drop-limits.d.ts +27 -0
- package/dist/drop-limits.js +83 -0
- package/dist/format.d.ts +0 -1
- package/dist/format.js +5 -4
- package/dist/index.d.ts +11 -7
- package/dist/index.js +8 -3
- package/dist/map-bounds.d.ts +5 -0
- package/dist/map-bounds.js +41 -0
- package/dist/map.d.ts +1 -2
- package/dist/map.js +1 -1
- package/dist/multiconductor.d.ts +11 -3
- package/dist/multiconductor.js +107 -14
- package/dist/notices.svelte.d.ts +29 -0
- package/dist/notices.svelte.js +82 -0
- package/dist/panels.svelte.d.ts +65 -0
- package/dist/panels.svelte.js +215 -0
- package/dist/state.svelte.d.ts +90 -24
- package/dist/state.svelte.js +94 -20
- package/dist/synthetic-layout.d.ts +0 -1
- package/dist/synthetic-layout.js +17 -2
- package/dist/transformer-icon.d.ts +0 -1
- package/dist/types.d.ts +0 -1
- package/package.json +28 -12
- package/dist/Sparkline.svelte.d.ts.map +0 -1
- package/dist/TellegenMap.svelte.d.ts.map +0 -1
- package/dist/TellegenProvider.svelte.d.ts.map +0 -1
- package/dist/TellegenShell.svelte.d.ts.map +0 -1
- package/dist/TellegenViewer.svelte.d.ts.map +0 -1
- package/dist/api.d.ts.map +0 -1
- package/dist/colors.d.ts.map +0 -1
- package/dist/components/AppFooter.svelte.d.ts.map +0 -1
- package/dist/components/AppHeader.svelte.d.ts.map +0 -1
- package/dist/components/BindingLines.svelte.d.ts.map +0 -1
- package/dist/components/BusPicker.svelte.d.ts.map +0 -1
- package/dist/components/ControlPanel.svelte.d.ts.map +0 -1
- package/dist/components/DemandSlider.svelte.d.ts.map +0 -1
- package/dist/components/DisplayControls.svelte.d.ts.map +0 -1
- package/dist/components/DropZone.svelte.d.ts.map +0 -1
- package/dist/components/FormulationSelector.svelte.d.ts.map +0 -1
- package/dist/components/LocalCaseDetails.svelte.d.ts.map +0 -1
- package/dist/components/MulticonductorDetails.svelte.d.ts.map +0 -1
- package/dist/components/NetworkStats.svelte.d.ts.map +0 -1
- package/dist/components/PlacementCue.svelte.d.ts.map +0 -1
- package/dist/components/RatingSlider.svelte.d.ts.map +0 -1
- package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +0 -1
- package/dist/components/SensitivityReadout.svelte.d.ts.map +0 -1
- package/dist/components/SizeLegend.svelte.d.ts.map +0 -1
- package/dist/components/SolveCard.svelte.d.ts.map +0 -1
- package/dist/components/TopMovers.svelte.d.ts.map +0 -1
- package/dist/components.d.ts.map +0 -1
- package/dist/context.svelte.d.ts.map +0 -1
- package/dist/controller.svelte.d.ts.map +0 -1
- package/dist/display.d.ts.map +0 -1
- package/dist/drop-classify.d.ts.map +0 -1
- package/dist/format.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/map.d.ts.map +0 -1
- package/dist/multiconductor.d.ts.map +0 -1
- package/dist/state.svelte.d.ts.map +0 -1
- package/dist/study-package.d.ts +0 -9
- package/dist/study-package.d.ts.map +0 -1
- package/dist/study-package.js +0 -17
- package/dist/synthetic-layout.d.ts.map +0 -1
- package/dist/transformer-icon.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/TellegenMap.svelte
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
117
|
-
// selected bus, or the
|
|
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
|
|
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.
|
|
134
|
-
app.
|
|
135
|
-
('bus' in app.
|
|
136
|
-
? app.
|
|
137
|
-
: app.
|
|
138
|
-
? app.
|
|
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
|
|
156
|
-
for (const e of c.solution.
|
|
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
|
|
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 ?? '
|
|
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
|
|
178
|
-
for (const [bus,
|
|
179
|
-
preview.set(bus,
|
|
180
|
-
|
|
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
|
|
193
|
-
preview.set(v.bus,
|
|
194
|
-
|
|
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
|
|
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 === '
|
|
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
|
|
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: '
|
|
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
|
-
: '
|
|
267
|
+
: 'price';
|
|
224
268
|
perCase.set(c.id, {
|
|
225
|
-
|
|
269
|
+
prices,
|
|
226
270
|
scalar,
|
|
227
271
|
scalarLo,
|
|
228
272
|
scalarHi,
|
|
229
273
|
scalarMode,
|
|
230
274
|
scalarLabel: activeMeta?.label ?? 'LMP',
|
|
231
|
-
scalarUnit: activeMeta?.unit ?? '
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
296
|
-
if (!
|
|
297
|
-
const buses
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
305
|
-
if (!
|
|
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: `${
|
|
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
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
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
|
|
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>∂
|
|
694
|
+
: `<br>∂price/∂d ${sens >= 0 ? '+' : ''}${sens.toExponential(2)}`;
|
|
677
695
|
const previewRow =
|
|
678
696
|
preview === undefined
|
|
679
697
|
? ''
|
|
680
|
-
: `<br>ΔLMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)}
|
|
698
|
+
: `<br>Δ 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 === '
|
|
685
|
-
? `LMP ${
|
|
702
|
+
!d || d.scalarMode === 'price'
|
|
703
|
+
? `LMP ${price === undefined ? '—' : fmt.format(price)} objective units/MW`
|
|
686
704
|
: `${d.scalarLabel} ${
|
|
687
705
|
scalar === undefined ? '—' : displayFmt(d.scalarMode, scalar)
|
|
688
|
-
} ${d.scalarUnit}<br>LMP ${
|
|
706
|
+
} ${d.scalarUnit}<br>LMP ${price === undefined ? '—' : 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,
|
|
715
|
+
const [maplibre, deckMap, layers, worker] = await Promise.all([
|
|
698
716
|
import('maplibre-gl'),
|
|
699
|
-
import('@deck.gl/
|
|
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
|
|
704
|
-
|
|
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(
|
|
740
|
+
function buildOverlay(MapLibreOverlay: MapModules['MapLibreOverlay']): MapLibreOverlay {
|
|
720
741
|
const touch = coarsePointer();
|
|
721
|
-
return new
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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<{
|
|
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
|
|
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
|
|
1198
|
-
|
|
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
|
-
|
|
1201
|
-
|
|
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
|
-
{#
|
|
1220
|
-
<
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
{
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
{#
|
|
1233
|
-
|
|
1234
|
-
<
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
{
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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:
|
|
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:
|
|
1576
|
+
bottom: 78px;
|
|
1451
1577
|
}
|
|
1452
1578
|
}
|
|
1453
1579
|
|
|
1454
1580
|
.map :global(.maplibregl-ctrl-bottom-right) {
|
|
1455
1581
|
right: 20px;
|
|
1456
|
-
|
|
1457
|
-
bottom: calc(18px + var(--chrome-inset, 0px));
|
|
1582
|
+
bottom: 12px;
|
|
1458
1583
|
display: flex;
|
|
1459
|
-
flex-direction:
|
|
1584
|
+
flex-direction: row;
|
|
1460
1585
|
align-items: flex-end;
|
|
1461
|
-
gap:
|
|
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:
|
|
1602
|
+
order: 1;
|
|
1480
1603
|
position: relative;
|
|
1481
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
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:
|
|
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
|
-
/*
|
|
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 -
|
|
1681
|
+
max-width: calc(100vw - 140px);
|
|
1554
1682
|
font-size: 9px;
|
|
1555
|
-
white-space:
|
|
1683
|
+
white-space: normal;
|
|
1556
1684
|
}
|
|
1557
1685
|
}
|
|
1558
1686
|
|