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