@tellegen/svelte 0.4.3 → 0.4.5
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 +45 -0
- package/dist/TellegenMap.svelte +63 -22
- package/dist/TellegenProvider.svelte +7 -4
- package/dist/TellegenShell.svelte +12 -1
- package/dist/TellegenViewer.svelte +2 -0
- package/dist/basemap.d.ts +14 -0
- package/dist/basemap.js +27 -0
- package/dist/colors.d.ts +6 -1
- package/dist/colors.js +11 -4
- package/dist/components/DiagnosticLogView.svelte +176 -0
- package/dist/components/DiagnosticLogView.svelte.d.ts +11 -0
- package/dist/components/FormulationSelector.svelte +1 -1
- package/dist/components/LocalCaseDetails.svelte +18 -3
- package/dist/components/LogEntryView.svelte +188 -0
- package/dist/components/LogEntryView.svelte.d.ts +11 -0
- package/dist/components/LogPanel.svelte +25 -0
- package/dist/components/LogPanel.svelte.d.ts +3 -0
- package/dist/components/McResults.svelte +18 -3
- package/dist/components/McWorkbench.svelte +688 -0
- package/dist/components/McWorkbench.svelte.d.ts +38 -0
- package/dist/components/MultiWire.svelte +136 -0
- package/dist/components/MultiWire.svelte.d.ts +7 -0
- package/dist/components/MulticonductorDetails.svelte +7 -0
- package/dist/components/NotificationCenter.svelte +37 -6
- package/dist/components/OmniSearch.svelte +163 -0
- package/dist/components/OmniSearch.svelte.d.ts +14 -0
- package/dist/components/ResultsTable.svelte +259 -0
- package/dist/components/ResultsTable.svelte.d.ts +13 -0
- package/dist/components/SchematicCanvas.svelte +1896 -0
- package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
- package/dist/components/SolveCard.svelte +6 -0
- package/dist/components/schematic/PickCard.svelte +126 -0
- package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
- package/dist/components/schematic/export.d.ts +21 -0
- package/dist/components/schematic/export.js +110 -0
- package/dist/components/schematic/layers.d.ts +161 -0
- package/dist/components/schematic/layers.js +756 -0
- package/dist/components/schematic/paint.d.ts +28 -0
- package/dist/components/schematic/paint.js +69 -0
- package/dist/components/schematic/palette.d.ts +34 -0
- package/dist/components/schematic/palette.js +54 -0
- package/dist/components/schematic/text.d.ts +83 -0
- package/dist/components/schematic/text.js +190 -0
- package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
- package/dist/components/schematic/view-prefs.svelte.js +77 -0
- package/dist/components.d.ts +4 -0
- package/dist/components.js +4 -0
- package/dist/context.svelte.d.ts +2 -0
- package/dist/context.svelte.js +2 -0
- package/dist/controller.svelte.d.ts +40 -5
- package/dist/controller.svelte.js +230 -25
- package/dist/diagram/basemap.d.ts +28 -0
- package/dist/diagram/basemap.js +39 -0
- package/dist/diagram/bus-stacks.d.ts +19 -0
- package/dist/diagram/bus-stacks.js +99 -0
- package/dist/diagram/colour-modes.d.ts +18 -0
- package/dist/diagram/colour-modes.js +33 -0
- package/dist/diagram/connections.d.ts +30 -0
- package/dist/diagram/connections.js +129 -0
- package/dist/diagram/display-tree.d.ts +21 -0
- package/dist/diagram/display-tree.js +65 -0
- package/dist/diagram/energisation.d.ts +10 -0
- package/dist/diagram/energisation.js +31 -0
- package/dist/diagram/feeders.d.ts +30 -0
- package/dist/diagram/feeders.js +57 -0
- package/dist/diagram/galvanic-zones.d.ts +60 -0
- package/dist/diagram/galvanic-zones.js +210 -0
- package/dist/diagram/graph-inputs.d.ts +18 -0
- package/dist/diagram/graph-inputs.js +103 -0
- package/dist/diagram/index.d.ts +28 -0
- package/dist/diagram/index.js +28 -0
- package/dist/diagram/landmarks.d.ts +50 -0
- package/dist/diagram/landmarks.js +153 -0
- package/dist/diagram/layout-morph.d.ts +15 -0
- package/dist/diagram/layout-morph.js +32 -0
- package/dist/diagram/multi-wire.d.ts +44 -0
- package/dist/diagram/multi-wire.js +65 -0
- package/dist/diagram/phase-bars.d.ts +35 -0
- package/dist/diagram/phase-bars.js +44 -0
- package/dist/diagram/results-table.d.ts +47 -0
- package/dist/diagram/results-table.js +173 -0
- package/dist/diagram/schematic-equipment.d.ts +133 -0
- package/dist/diagram/schematic-equipment.js +645 -0
- package/dist/diagram/schematic-layout.d.ts +51 -0
- package/dist/diagram/schematic-layout.js +240 -0
- package/dist/diagram/search.d.ts +36 -0
- package/dist/diagram/search.js +48 -0
- package/dist/diagram/shared-routes.d.ts +21 -0
- package/dist/diagram/shared-routes.js +40 -0
- package/dist/diagram/short-names.d.ts +11 -0
- package/dist/diagram/short-names.js +54 -0
- package/dist/diagram/snapshot.d.ts +115 -0
- package/dist/diagram/snapshot.js +242 -0
- package/dist/diagram/summary.d.ts +65 -0
- package/dist/diagram/summary.js +144 -0
- package/dist/diagram/svg-export.d.ts +80 -0
- package/dist/diagram/svg-export.js +108 -0
- package/dist/diagram/tree-walk.d.ts +13 -0
- package/dist/diagram/tree-walk.js +83 -0
- package/dist/diagram/value-labels.d.ts +24 -0
- package/dist/diagram/value-labels.js +69 -0
- package/dist/diagram/view-state.d.ts +12 -0
- package/dist/diagram/view-state.js +22 -0
- package/dist/diagram/voltage-levels.d.ts +14 -0
- package/dist/diagram/voltage-levels.js +20 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/log.svelte.d.ts +77 -0
- package/dist/log.svelte.js +149 -0
- package/dist/notices.svelte.d.ts +3 -0
- package/dist/state.svelte.d.ts +42 -5
- package/dist/state.svelte.js +56 -4
- package/dist/transformer-icon.d.ts +22 -13
- package/dist/transformer-icon.js +23 -13
- package/package.json +2 -2
|
@@ -0,0 +1,1896 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {
|
|
3
|
+
Deck,
|
|
4
|
+
Layer,
|
|
5
|
+
OrthographicView,
|
|
6
|
+
OrthographicViewState,
|
|
7
|
+
PickingInfo
|
|
8
|
+
} from '@deck.gl/core';
|
|
9
|
+
import { untrack } from 'svelte';
|
|
10
|
+
import { mcSchematicLayout, type SchematicStrategy } from '@tellegen/engine';
|
|
11
|
+
import type { MulticonductorCase } from '../state.svelte.js';
|
|
12
|
+
import type { RGBA } from '../colors.js';
|
|
13
|
+
import { priceGradient, sensGradient } from '../colors.js';
|
|
14
|
+
import { getPanelLayout } from '../panels.svelte.js';
|
|
15
|
+
import { getAppState } from '../context.svelte.js';
|
|
16
|
+
import {
|
|
17
|
+
bandColour,
|
|
18
|
+
branchLabel,
|
|
19
|
+
busLabel,
|
|
20
|
+
busStacks,
|
|
21
|
+
displaceBuses,
|
|
22
|
+
spreadOffsets,
|
|
23
|
+
chordsInto,
|
|
24
|
+
displayTree,
|
|
25
|
+
ease,
|
|
26
|
+
diagramLayer,
|
|
27
|
+
EMPTY_GEOMETRY,
|
|
28
|
+
fromEngineLayout,
|
|
29
|
+
lerpInto,
|
|
30
|
+
loadingColour,
|
|
31
|
+
MISSING_COLOUR,
|
|
32
|
+
MORPH_MAX_BUSES,
|
|
33
|
+
MORPH_MS,
|
|
34
|
+
placeLabels,
|
|
35
|
+
screenAnchored,
|
|
36
|
+
sourceLayout,
|
|
37
|
+
titleBlock,
|
|
38
|
+
worstBand,
|
|
39
|
+
attachmentGlyph,
|
|
40
|
+
ATTACHMENT_NAMES,
|
|
41
|
+
describeBus,
|
|
42
|
+
treeWalk,
|
|
43
|
+
type WalkKey,
|
|
44
|
+
BASEMAP_STYLE,
|
|
45
|
+
basemapCamera,
|
|
46
|
+
cameraFromMap,
|
|
47
|
+
fanRoute,
|
|
48
|
+
routeMiddle,
|
|
49
|
+
type FannedBranch,
|
|
50
|
+
galvanicZones,
|
|
51
|
+
landmarkCandidates,
|
|
52
|
+
landmarkGlyph,
|
|
53
|
+
landmarkStrokes,
|
|
54
|
+
placeLandmarks,
|
|
55
|
+
type Landmark,
|
|
56
|
+
voltageClass,
|
|
57
|
+
levelLabel,
|
|
58
|
+
VOLTAGE_CLASSES,
|
|
59
|
+
feeders,
|
|
60
|
+
pathToSource,
|
|
61
|
+
type VoltageClass,
|
|
62
|
+
zoneSummary,
|
|
63
|
+
zoneVoltage,
|
|
64
|
+
zoneCorridors,
|
|
65
|
+
zoneOutline,
|
|
66
|
+
zoneState,
|
|
67
|
+
type GalvanicZone,
|
|
68
|
+
conductorTicks,
|
|
69
|
+
elementPowers,
|
|
70
|
+
flowArrows,
|
|
71
|
+
energisation,
|
|
72
|
+
shortNames,
|
|
73
|
+
branchGlyph,
|
|
74
|
+
transformerGlyph,
|
|
75
|
+
transformerInfo,
|
|
76
|
+
glyphPath,
|
|
77
|
+
schematicEquipment,
|
|
78
|
+
type EquipmentItem,
|
|
79
|
+
type Glyph,
|
|
80
|
+
type LabelCandidate,
|
|
81
|
+
type SchematicGeometry
|
|
82
|
+
} from '../diagram/index.js';
|
|
83
|
+
import { canvasPng, download, fileStem, sceneSvg } from './schematic/export.js';
|
|
84
|
+
import {
|
|
85
|
+
createLayerCache,
|
|
86
|
+
morphLayers,
|
|
87
|
+
sceneLayers,
|
|
88
|
+
stackRadius,
|
|
89
|
+
type Colour,
|
|
90
|
+
type SchematicScene
|
|
91
|
+
} from './schematic/layers.js';
|
|
92
|
+
import type { FanState } from './schematic/paint.js';
|
|
93
|
+
import { DEAD, HALO, INK, LEVEL_COLOURS } from './schematic/palette.js';
|
|
94
|
+
import PickCard from './schematic/PickCard.svelte';
|
|
95
|
+
import { SchematicText, tooltip } from './schematic/text.js';
|
|
96
|
+
import { HoverPin, StoredFlag } from './schematic/view-prefs.svelte.js';
|
|
97
|
+
import {
|
|
98
|
+
applyBasemapTuning,
|
|
99
|
+
attachBasemapFallback,
|
|
100
|
+
basemapCredit,
|
|
101
|
+
type BasemapProvider,
|
|
102
|
+
type BasemapSpec
|
|
103
|
+
} from '../basemap.js';
|
|
104
|
+
|
|
105
|
+
/** Without a basemap from the map: the keyless CARTO style it used to default to. */
|
|
106
|
+
const DEFAULT_BASEMAP: BasemapSpec = {
|
|
107
|
+
primary: BASEMAP_STYLE,
|
|
108
|
+
fallback: null,
|
|
109
|
+
hideLabels: false,
|
|
110
|
+
primaryProvider: 'carto',
|
|
111
|
+
fallbackProvider: null
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let {
|
|
115
|
+
mc,
|
|
116
|
+
onselectbus,
|
|
117
|
+
onselectedge,
|
|
118
|
+
onclose,
|
|
119
|
+
basemap: basemapSpec = DEFAULT_BASEMAP
|
|
120
|
+
}: {
|
|
121
|
+
mc: MulticonductorCase;
|
|
122
|
+
onselectbus: (id: string) => void;
|
|
123
|
+
onselectedge: (id: string) => void;
|
|
124
|
+
onclose: () => void;
|
|
125
|
+
/** The background map under geographic positions: the map's own basemap, light-themed. */
|
|
126
|
+
basemap?: BasemapSpec;
|
|
127
|
+
} = $props();
|
|
128
|
+
|
|
129
|
+
type Layout = 'schematic' | 'source';
|
|
130
|
+
const COLOURS: Record<Colour, string> = {
|
|
131
|
+
none: 'Equipment and topology',
|
|
132
|
+
'voltage-band': 'Voltage within declared band',
|
|
133
|
+
'loading-heat': 'Loading, % of rating'
|
|
134
|
+
};
|
|
135
|
+
const app = getAppState();
|
|
136
|
+
const panels = getPanelLayout();
|
|
137
|
+
|
|
138
|
+
let layout = $state<Layout>('schematic');
|
|
139
|
+
let strategy = $state<SchematicStrategy>('tidy');
|
|
140
|
+
/** The geometry on screen; replaced by `show`, which animates between layouts. */
|
|
141
|
+
let shown = $state.raw<SchematicGeometry | null>(null);
|
|
142
|
+
let colour = $state<Colour>('none');
|
|
143
|
+
let width = $state(0);
|
|
144
|
+
let height = $state(0);
|
|
145
|
+
let canvas = $state<HTMLCanvasElement>();
|
|
146
|
+
let deck = $state.raw<Deck<OrthographicView> | null>(null);
|
|
147
|
+
let modules: {
|
|
148
|
+
core: typeof import('@deck.gl/core');
|
|
149
|
+
layers: typeof import('@deck.gl/layers');
|
|
150
|
+
} | null = null;
|
|
151
|
+
let camera = $state.raw<{ target: [number, number, number]; zoom: number }>({
|
|
152
|
+
target: [0, 0, 0],
|
|
153
|
+
zoom: 0
|
|
154
|
+
});
|
|
155
|
+
let labels = $state.raw<(LabelCandidate & { position: [number, number] })[]>([]);
|
|
156
|
+
/** Map tiles under geographic positions, as for the main map; they fade in and out with the
|
|
157
|
+
* layout switch. While fading out they stay pinned to the screen as the camera moves on. */
|
|
158
|
+
const basemapPref = new StoredFlag('tellegen.schematic.basemap', ['on', 'off'], true);
|
|
159
|
+
let basemapOpacity = $state(0);
|
|
160
|
+
const BASEMAP_FADE_MS = 700;
|
|
161
|
+
let mapBox = $state<HTMLDivElement>();
|
|
162
|
+
let basemap: import('maplibre-gl').Map | null = null;
|
|
163
|
+
let basemapLoading = false;
|
|
164
|
+
/** Who serves the basemap on screen, for its credit line. */
|
|
165
|
+
let basemapProvider = $state<BasemapProvider>('custom');
|
|
166
|
+
/** Landmarks placed for the current view, with their fixed-pixel glyph strokes. */
|
|
167
|
+
let marks = $state.raw<{
|
|
168
|
+
placed: Landmark[];
|
|
169
|
+
strokes: { sources: Float32Array; targets: Float32Array; owner: Int32Array };
|
|
170
|
+
} | null>(null);
|
|
171
|
+
let status = $state<string | null>('Loading the schematic renderer…');
|
|
172
|
+
|
|
173
|
+
const graph = $derived(mc.graph!);
|
|
174
|
+
const tree = $derived(displayTree(graph));
|
|
175
|
+
// The case's own copies, shared with the overview: built once per graph and once per solve.
|
|
176
|
+
const inputs = $derived(mc.overviewInputs!);
|
|
177
|
+
const source = $derived(
|
|
178
|
+
mc.view
|
|
179
|
+
? sourceLayout(graph, mc.view.buses, mc.coordsKind === 'geographic', tree, mc.view.edges)
|
|
180
|
+
: null
|
|
181
|
+
);
|
|
182
|
+
/** "Separate overlapping buses and lines": every stack and shared route stays spread. */
|
|
183
|
+
const separatePref = new StoredFlag('tellegen.schematic.separate', ['on', 'off'], false);
|
|
184
|
+
// Stacked buses: several buses at exactly the same point (MV and LV at one pole). At rest a ring
|
|
185
|
+
// and a ×n badge mark the stack; hovering it, or the keyboard cursor on one of its buses,
|
|
186
|
+
// spreads the buses on a small circle of fixed screen size, with a card listing them; clicking
|
|
187
|
+
// pins it. "Separate overlapping buses and lines" keeps every stack spread.
|
|
188
|
+
const stacks = $derived(shown && layout === 'source' ? busStacks(shown) : null);
|
|
189
|
+
const stackPin = new HoverPin();
|
|
190
|
+
let cursorStack = $state<number | null>(null);
|
|
191
|
+
/** The stack whose card shows: pinned, else hovered, else the keyboard cursor's. */
|
|
192
|
+
const activeStack = $derived(stackPin.active ?? cursorStack);
|
|
193
|
+
const spreadStacks = $derived(
|
|
194
|
+
!stacks
|
|
195
|
+
? []
|
|
196
|
+
: separatePref.value
|
|
197
|
+
? stacks.groups.map((_, g) => g)
|
|
198
|
+
: activeStack === null
|
|
199
|
+
? []
|
|
200
|
+
: [activeStack]
|
|
201
|
+
);
|
|
202
|
+
/** Pixels per drawing unit in quarter zoom steps, so spread stacks keep a fixed screen size. */
|
|
203
|
+
const stackScale = $derived(2 ** (Math.floor(camera.zoom * 4) / 4));
|
|
204
|
+
const geometry = $derived.by<SchematicGeometry>(() => {
|
|
205
|
+
if (!shown) return EMPTY_GEOMETRY;
|
|
206
|
+
if (!stacks || !spreadStacks.length) return shown;
|
|
207
|
+
const offsets = new Map<number, [number, number]>();
|
|
208
|
+
for (const g of spreadStacks) {
|
|
209
|
+
const members = stacks.groups[g];
|
|
210
|
+
spreadOffsets(members.length, stackScale, stackRadius(members.length)).forEach((o, m) =>
|
|
211
|
+
offsets.set(members[m], o)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return displaceBuses(graph, shown, offsets);
|
|
215
|
+
});
|
|
216
|
+
const basemapWanted = $derived(
|
|
217
|
+
basemapPref.value &&
|
|
218
|
+
layout === 'source' &&
|
|
219
|
+
mc.coordsKind === 'geographic' &&
|
|
220
|
+
!!source?.mercatorOrigin
|
|
221
|
+
);
|
|
222
|
+
let fadeFrame = 0;
|
|
223
|
+
$effect(() => {
|
|
224
|
+
const target = basemapWanted ? 1 : 0;
|
|
225
|
+
untrack(() => {
|
|
226
|
+
cancelAnimationFrame(fadeFrame);
|
|
227
|
+
const start = basemapOpacity;
|
|
228
|
+
if (start === target) return;
|
|
229
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
230
|
+
basemapOpacity = target;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const t0 = performance.now();
|
|
234
|
+
const step = (now: number) => {
|
|
235
|
+
const k = Math.min(1, (now - t0) / BASEMAP_FADE_MS);
|
|
236
|
+
basemapOpacity = start + (target - start) * ease(k);
|
|
237
|
+
if (k < 1) fadeFrame = requestAnimationFrame(step);
|
|
238
|
+
};
|
|
239
|
+
fadeFrame = requestAnimationFrame(step);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// The basemap map is created the first time it is wanted, and follows the schematic camera only
|
|
244
|
+
// while wanted: fading out, it stays where it was on screen as the drawing moves to its new layout.
|
|
245
|
+
$effect(() => {
|
|
246
|
+
if (!basemapWanted || !mapBox || basemap || basemapLoading) return;
|
|
247
|
+
basemapLoading = true;
|
|
248
|
+
const container = mapBox;
|
|
249
|
+
const spec = basemapSpec;
|
|
250
|
+
basemapProvider = spec.primaryProvider ?? 'custom';
|
|
251
|
+
Promise.all([
|
|
252
|
+
import('maplibre-gl'),
|
|
253
|
+
// As the map loads it: a package export keeps the worker out of the dev pre-bundle.
|
|
254
|
+
import('@tellegen/svelte/maplibre-worker').then((m) => m.maplibreWorkerUrl())
|
|
255
|
+
])
|
|
256
|
+
.then(([maplibre, workerUrl]) => {
|
|
257
|
+
maplibre.setWorkerUrl(workerUrl);
|
|
258
|
+
const origin = source?.mercatorOrigin ?? [0, 0];
|
|
259
|
+
const m = new maplibre.Map({
|
|
260
|
+
container,
|
|
261
|
+
style: spec.primary,
|
|
262
|
+
interactive: false,
|
|
263
|
+
attributionControl: false,
|
|
264
|
+
...basemapCamera(camera, origin)
|
|
265
|
+
});
|
|
266
|
+
// The same style handling as the map: labels hidden when asked, OpenFreeMap tuned, and
|
|
267
|
+
// the fallback style when the primary cannot be shown.
|
|
268
|
+
m.on('style.load', () => applyBasemapTuning(m, spec, 'light'));
|
|
269
|
+
attachBasemapFallback(m, spec, {
|
|
270
|
+
onFallback: (reason) => (basemapProvider = reason.to ?? 'custom')
|
|
271
|
+
});
|
|
272
|
+
basemap = m;
|
|
273
|
+
})
|
|
274
|
+
.catch(() => {
|
|
275
|
+
basemapLoading = false;
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
$effect(() => {
|
|
279
|
+
const origin = source?.mercatorOrigin;
|
|
280
|
+
void [width, height];
|
|
281
|
+
if (!basemapWanted || !origin) return;
|
|
282
|
+
const view = basemapCamera(camera, origin);
|
|
283
|
+
untrack(() => {
|
|
284
|
+
basemap?.resize();
|
|
285
|
+
basemap?.jumpTo(view);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
$effect(() => () => basemap?.remove());
|
|
289
|
+
const powers = $derived(mc.overviewResult ? elementPowers(mc.overviewResult) : null);
|
|
290
|
+
const snapshot = $derived(mc.overviewSnapshot);
|
|
291
|
+
/** Equipment symbols on the shown layout: branch symbols interrupt their line. */
|
|
292
|
+
const equipment = $derived(shown ? schematicEquipment(graph, geometry) : null);
|
|
293
|
+
/** Symbols draw once they are legible (a glyph about 6 px across); below that the plain lines
|
|
294
|
+
* and their kind colours carry the topology. */
|
|
295
|
+
const symbolsOn = $derived(!!equipment && equipment.unit * 2 ** camera.zoom >= 3);
|
|
296
|
+
/** The branch geometry as drawn, interrupted under symbols when they show. */
|
|
297
|
+
const drawn = $derived<SchematicGeometry>(symbolsOn && equipment ? equipment.cut : geometry);
|
|
298
|
+
/** Galvanic zones: buses joined without passing a transformer. Those without a source (the LV
|
|
299
|
+
* networks of a feeder) are tinted by their worst state: in the schematic as an outline that
|
|
300
|
+
* follows the drawing column by column, on geographic positions as a corridor along their own
|
|
301
|
+
* branches (a hull would overlap the hulls of networks interleaved with it). */
|
|
302
|
+
const zones = $derived(galvanicZones(graph, tree));
|
|
303
|
+
const zoneStates = $derived(zones.zones.map((z) => zoneState(z, snapshot)));
|
|
304
|
+
const outlined = (zone: GalvanicZone) => !zone.hasSource && zone.buses.length >= 2;
|
|
305
|
+
const zoneShapes = $derived.by(() => {
|
|
306
|
+
if (!shown || !shown.attachmentGap) return [];
|
|
307
|
+
const g = shown;
|
|
308
|
+
// Column spacing of the engine layout: any tree edge spans exactly one column.
|
|
309
|
+
let dx = 0;
|
|
310
|
+
for (let i = 0; i < graph.buses.length && !dx; i++)
|
|
311
|
+
if (tree.parent[i] !== i) dx = Math.abs(g.positions[i * 3] - g.positions[tree.parent[i] * 3]);
|
|
312
|
+
const gap = g.attachmentGap ?? 0;
|
|
313
|
+
const below = graph.buses.map((b) => {
|
|
314
|
+
const kinds = new Set(
|
|
315
|
+
Object.values(b.terminal_attachments ?? {}).flatMap((list) => list.map((a) => a.kind))
|
|
316
|
+
);
|
|
317
|
+
// The engine reserves one more slot for a bus's solid earth (perfectly grounded terminals).
|
|
318
|
+
return (kinds.size + (b.grounded?.length ? 1 : 0)) * gap;
|
|
319
|
+
});
|
|
320
|
+
if (!(dx > 0)) return [];
|
|
321
|
+
return zones.zones.flatMap((zone, z) => {
|
|
322
|
+
if (!outlined(zone)) return [];
|
|
323
|
+
const polygon = zoneOutline(zone, g.positions, below, { dx, pad: gap * 0.5 });
|
|
324
|
+
return polygon.length >= 3 ? [{ z, polygon }] : [];
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
const corridors = $derived(
|
|
328
|
+
shown && !shown.attachmentGap ? zoneCorridors(zones, shown, outlined) : []
|
|
329
|
+
);
|
|
330
|
+
/** The corridor under the pointer, as a network to emphasise. */
|
|
331
|
+
let hoveredZone = $state(-1);
|
|
332
|
+
/** Each network's voltage level (declared rating, else limits, else solved) and class. MV and
|
|
333
|
+
* above (from 1 kV phase–phase) is drawn heavier than LV, as on a paper single-line diagram,
|
|
334
|
+
* and each class has its own shade; a transformer takes its higher side. */
|
|
335
|
+
const zoneVolts = $derived(zones.zones.map((z) => zoneVoltage(z, graph, snapshot)));
|
|
336
|
+
const zoneClass = $derived(zones.zones.map((z, i) => voltageClass(z, zoneVolts[i])));
|
|
337
|
+
const zoneMv = $derived(zoneClass.map((c) => c !== 'lv'));
|
|
338
|
+
const CLASS_RANK: Record<VoltageClass, number> = { lv: 0, mv: 1, sub: 2, hv: 3 };
|
|
339
|
+
const edgeClass = $derived(
|
|
340
|
+
graph.edges.map((e, k): VoltageClass => {
|
|
341
|
+
const { from, to } = inputs.names.branches[k];
|
|
342
|
+
const at = (i: number): VoltageClass => (i >= 0 ? zoneClass[zones.busZone[i]] : 'lv');
|
|
343
|
+
if (e.kind !== 'transformer') return at(from >= 0 ? from : to);
|
|
344
|
+
return CLASS_RANK[at(from)] >= CLASS_RANK[at(to)] ? at(from) : at(to);
|
|
345
|
+
})
|
|
346
|
+
);
|
|
347
|
+
const edgeMv = $derived(Uint8Array.from(edgeClass, (c) => (c === 'lv' ? 0 : 1)));
|
|
348
|
+
/** The levels in the drawing, per class, for the key: "MV · 11 kV". */
|
|
349
|
+
const levelKey = $derived(
|
|
350
|
+
VOLTAGE_CLASSES.flatMap(({ cls, name }) => {
|
|
351
|
+
if (!zoneClass.includes(cls)) return [];
|
|
352
|
+
const levels = [
|
|
353
|
+
...new Set(
|
|
354
|
+
zoneVolts.flatMap((v, z) => (zoneClass[z] === cls && v !== null ? [levelLabel(v)] : []))
|
|
355
|
+
)
|
|
356
|
+
];
|
|
357
|
+
return [{ cls, label: levels.length ? `${name} · ${levels.slice(0, 3).join(', ')}` : name }];
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
/** Zoomed out, LV detail fades so the MV backbone, zone outlines and states read first. */
|
|
361
|
+
const zoomedOut = $derived(!symbolsOn);
|
|
362
|
+
const mvBuses = $derived(graph.buses.flatMap((_, i) => (zoneMv[zones.busZone[i]] ? [i] : [])));
|
|
363
|
+
const allBuses = $derived(graph.buses.map((_, i) => i));
|
|
364
|
+
const transformerNames = $derived.by(() => {
|
|
365
|
+
const ks = graph.edges.flatMap((e, k) => (e.kind === 'transformer' ? [k] : []));
|
|
366
|
+
const short = shortNames(
|
|
367
|
+
ks.map((k) => graph.edges[k].id),
|
|
368
|
+
16
|
|
369
|
+
);
|
|
370
|
+
return new Map(ks.map((k, j) => [k, short[j]]));
|
|
371
|
+
});
|
|
372
|
+
/** Zone tags at each outline's top-left: feeding transformer, size, and when solved the
|
|
373
|
+
* lowest voltage and highest loading. Drawn once the outline is wide enough on screen. */
|
|
374
|
+
const zoneTags = $derived(
|
|
375
|
+
[
|
|
376
|
+
...zoneShapes.map(({ z, polygon }) => {
|
|
377
|
+
const xs = polygon.map((p) => p[0]);
|
|
378
|
+
const ys = polygon.map((p) => p[1]);
|
|
379
|
+
return { z, bounds: [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] };
|
|
380
|
+
}),
|
|
381
|
+
...corridors
|
|
382
|
+
].map(({ z, bounds: [x0, y0, x1] }) => {
|
|
383
|
+
const zone = zones.zones[z];
|
|
384
|
+
const t = zone.transformers[0];
|
|
385
|
+
const { minVolts, maxLoading } = zoneSummary(zone, snapshot);
|
|
386
|
+
const v = (x: number) => (x >= 1000 ? `${(x / 1000).toFixed(2)} kV` : `${x.toFixed(1)} V`);
|
|
387
|
+
const text = [
|
|
388
|
+
t === undefined ? 'network' : (transformerNames.get(t) ?? graph.edges[t].id),
|
|
389
|
+
`${zone.buses.length} buses`,
|
|
390
|
+
...(minVolts === null ? [] : [`min ${v(minVolts)}`]),
|
|
391
|
+
...(maxLoading === null ? [] : [`${maxLoading.toFixed(0)} %`])
|
|
392
|
+
].join(' · ');
|
|
393
|
+
return {
|
|
394
|
+
z,
|
|
395
|
+
text,
|
|
396
|
+
position: [x0, y0] as [number, number],
|
|
397
|
+
width: x1 - x0
|
|
398
|
+
};
|
|
399
|
+
})
|
|
400
|
+
);
|
|
401
|
+
/** Sources, generators, IBRs and transformers stay findable when zoomed out: drawn at a fixed
|
|
402
|
+
* screen size and placed by priority (see `landmarks.ts`), handing over to the full symbols as
|
|
403
|
+
* the drawing grows. */
|
|
404
|
+
const landmarksPref = new StoredFlag('tellegen.schematic.landmarks', ['on', 'off'], true);
|
|
405
|
+
const landmarkPool = $derived(
|
|
406
|
+
shown ? landmarkCandidates(graph, geometry, equipment, zones, zoneStates, powers) : []
|
|
407
|
+
);
|
|
408
|
+
const markName = $derived.by(() => {
|
|
409
|
+
const ids = [...new Set(landmarkPool.flatMap((l) => (l.kind === 'transformer' ? [] : l.ids)))];
|
|
410
|
+
const short = shortNames(ids, 16);
|
|
411
|
+
return new Map(ids.map((id, j) => [id, short[j]]));
|
|
412
|
+
});
|
|
413
|
+
/** Zoom in quarter steps: zone tags appear and go only when this changes, not on every pan. */
|
|
414
|
+
const tagZoom = $derived(Math.floor(camera.zoom * 4) / 4);
|
|
415
|
+
/** Conductor marks need a closer view than symbols; bus names closer still. */
|
|
416
|
+
const ticksOn = $derived(!!equipment && equipment.unit * 2 ** camera.zoom >= 6);
|
|
417
|
+
const namesOn = $derived(!!equipment && equipment.unit * 2 ** camera.zoom >= 9);
|
|
418
|
+
/** Solved values beside symbols and flow arrows on branches, once there is room for them. */
|
|
419
|
+
const valuesOn = $derived(!!snapshot && ticksOn);
|
|
420
|
+
const arrows = $derived(
|
|
421
|
+
equipment && snapshot
|
|
422
|
+
? flowArrows(
|
|
423
|
+
equipment,
|
|
424
|
+
snapshot.branches.map((b) => b.ends),
|
|
425
|
+
graph.edges.map((e) => e.closed),
|
|
426
|
+
// Snapshot ends are in kW: an arrow from 1 W, not from 1 kW.
|
|
427
|
+
1e-3
|
|
428
|
+
)
|
|
429
|
+
: null
|
|
430
|
+
);
|
|
431
|
+
const live = $derived(energisation(graph));
|
|
432
|
+
const names = $derived(shortNames(graph.buses.map((b) => b.id)));
|
|
433
|
+
/** Whether the symbol key is expanded; remembered per viewer. */
|
|
434
|
+
const keyPref = new StoredFlag('tellegen.schematic.key', ['open', 'closed'], true);
|
|
435
|
+
/** Keyboard cursor: the bus the arrow keys walk from, distinct from the selection. */
|
|
436
|
+
let cursor = $state(-1);
|
|
437
|
+
let announcement = $state('');
|
|
438
|
+
const walk = $derived(treeWalk(tree, geometry.positions));
|
|
439
|
+
/** Shared-route member chosen with Tab at the cursor bus (−1 for none). */
|
|
440
|
+
let tabEdge = -1;
|
|
441
|
+
function moveCursor(to: number) {
|
|
442
|
+
if (to < 0) return;
|
|
443
|
+
tabEdge = -1;
|
|
444
|
+
cursor = to;
|
|
445
|
+
const g = stacks?.stackOf[to] ?? -1;
|
|
446
|
+
cursorStack = g >= 0 ? g : null;
|
|
447
|
+
announcement =
|
|
448
|
+
describeBus(graph, tree, walk, to, text.voltageNote(to)) +
|
|
449
|
+
(g >= 0 ? `. One of ${stacks!.groups[g].length} buses at this point` : '');
|
|
450
|
+
bringIntoView([geometry.positions[to * 3], geometry.positions[to * 3 + 1]]);
|
|
451
|
+
}
|
|
452
|
+
// Shared routes: branches drawn along exactly the same segment (MV and LV on shared poles).
|
|
453
|
+
// At rest the route carries a count badge; hovering it fans its members apart by a few pixels,
|
|
454
|
+
// tapering into the shared ends, with a card listing them; clicking pins it open. The key can
|
|
455
|
+
// keep every shared route separated.
|
|
456
|
+
const routePin = new HoverPin();
|
|
457
|
+
/** The one route whose card shows: pinned, else hovered. */
|
|
458
|
+
const activeGroup = $derived(equipment && symbolsOn ? routePin.active : null);
|
|
459
|
+
/** Routes drawn fanned out now. */
|
|
460
|
+
const fannedGroups = $derived(
|
|
461
|
+
!equipment || !symbolsOn
|
|
462
|
+
? []
|
|
463
|
+
: separatePref.value
|
|
464
|
+
? equipment.routeGroups.map((_, g) => g)
|
|
465
|
+
: activeGroup === null
|
|
466
|
+
? []
|
|
467
|
+
: [activeGroup]
|
|
468
|
+
);
|
|
469
|
+
const fannedEdges = $derived(
|
|
470
|
+
new Set(fannedGroups.flatMap((g) => equipment?.routeGroups[g] ?? []))
|
|
471
|
+
);
|
|
472
|
+
function fanned(): FannedBranch[] {
|
|
473
|
+
if (!equipment || !fannedGroups.length) return [];
|
|
474
|
+
const scale = 2 ** tagZoom;
|
|
475
|
+
return fannedGroups.flatMap((g) =>
|
|
476
|
+
fanRoute(equipment.routeGroups[g], equipment.host, geometry, scale, {
|
|
477
|
+
spreadPx: 11,
|
|
478
|
+
taperPx: 18
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const fan = $derived<FanState>({
|
|
483
|
+
edges: fannedEdges,
|
|
484
|
+
host: equipment?.host ?? null,
|
|
485
|
+
geometry
|
|
486
|
+
});
|
|
487
|
+
/** The kinds of transformer in the drawing, for the key; `unknown` without winding detail. */
|
|
488
|
+
const transformerKinds = $derived(
|
|
489
|
+
new Set(
|
|
490
|
+
graph.edges.flatMap((e, k) =>
|
|
491
|
+
e.kind === 'transformer' ? [transformerInfo(graph, k)?.kind ?? 'unknown'] : []
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
/** Key entries per transformer kind; the three-phase one shows a Δ–Yn unit as its example. */
|
|
496
|
+
const winding = (connection: 'wye' | 'delta', terminals: number) => ({
|
|
497
|
+
bus: '',
|
|
498
|
+
connection,
|
|
499
|
+
terminals: Array.from({ length: terminals }, (_, i) => String(i + 1)),
|
|
500
|
+
v_ref: 0,
|
|
501
|
+
s_rating: 0
|
|
502
|
+
});
|
|
503
|
+
const TRANSFORMER_KEY: [string, Glyph, string][] = [
|
|
504
|
+
[
|
|
505
|
+
'poly',
|
|
506
|
+
transformerGlyph(
|
|
507
|
+
{ kind: 'poly', from: winding('delta', 3), to: winding('wye', 4), label: '' },
|
|
508
|
+
3
|
|
509
|
+
),
|
|
510
|
+
'three-phase, windings Δ and Y (n: neutral out)'
|
|
511
|
+
],
|
|
512
|
+
[
|
|
513
|
+
'split',
|
|
514
|
+
transformerGlyph({ kind: 'split', from: null, to: null, label: '' }, 1),
|
|
515
|
+
'split-phase (centre tap)'
|
|
516
|
+
],
|
|
517
|
+
[
|
|
518
|
+
'single',
|
|
519
|
+
transformerGlyph({ kind: 'single', from: null, to: null, label: '' }, 1),
|
|
520
|
+
'single-phase'
|
|
521
|
+
],
|
|
522
|
+
['unknown', branchGlyph('transformer', true)!, 'transformer']
|
|
523
|
+
];
|
|
524
|
+
const legendGlyphs = $derived(
|
|
525
|
+
equipment
|
|
526
|
+
? (
|
|
527
|
+
[
|
|
528
|
+
...TRANSFORMER_KEY.filter(([k]) => transformerKinds.has(k as never)).map(
|
|
529
|
+
([, glyph, label]) => ['transformer', glyph, label] as const
|
|
530
|
+
),
|
|
531
|
+
['switch', branchGlyph('switch', true), 'switch, closed'],
|
|
532
|
+
['switch', branchGlyph('switch', false), 'switch, open'],
|
|
533
|
+
...(['source', 'generator', 'ibr', 'load', 'shunt', 'earth', 'ground'] as const).map(
|
|
534
|
+
(k) => [k, attachmentGlyph(k), ATTACHMENT_NAMES[k]] as const
|
|
535
|
+
)
|
|
536
|
+
] as [EquipmentItem['kind'], Glyph | null, string][]
|
|
537
|
+
).filter(
|
|
538
|
+
([kind, glyph, label]) =>
|
|
539
|
+
glyph &&
|
|
540
|
+
equipment.kinds.has(kind) &&
|
|
541
|
+
(kind !== 'switch' ||
|
|
542
|
+
graph.edges.some(
|
|
543
|
+
(e) => e.kind === 'switch' && e.closed === (label === 'switch, closed')
|
|
544
|
+
))
|
|
545
|
+
)
|
|
546
|
+
: []
|
|
547
|
+
);
|
|
548
|
+
/** Buses the case gives no position, in source positions. */
|
|
549
|
+
const unplaced = $derived(
|
|
550
|
+
layout === 'source' && geometry.inferred
|
|
551
|
+
? geometry.inferred.reduce((n, v) => n + (v ? 1 : 0), 0)
|
|
552
|
+
: 0
|
|
553
|
+
);
|
|
554
|
+
/** Toolbar and legend start right of the left dock, so no panel covers them. */
|
|
555
|
+
const toolLeft = $derived(panels.compact ? 12 : panels.dockWidth('left') + 24);
|
|
556
|
+
const busIndex = $derived(new Map(graph.buses.map((b, i) => [b.id, i])));
|
|
557
|
+
const selectedBus = $derived(
|
|
558
|
+
mc.selectedBusId === null ? -1 : (busIndex.get(mc.selectedBusId) ?? -1)
|
|
559
|
+
);
|
|
560
|
+
const selectedEdges = $derived(
|
|
561
|
+
new Set(
|
|
562
|
+
mc.selectedEdgeId === null
|
|
563
|
+
? []
|
|
564
|
+
: graph.edges.flatMap((e, k) => (e.id === mc.selectedEdgeId ? [k] : []))
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const hoveredBus = $derived(
|
|
569
|
+
mc.hovered?.kind === 'bus' ? (busIndex.get(mc.hovered.id) ?? -1) : -1
|
|
570
|
+
);
|
|
571
|
+
const hoveredEdges = $derived(
|
|
572
|
+
new Set(
|
|
573
|
+
mc.hovered?.kind === 'branch'
|
|
574
|
+
? graph.edges.flatMap((e, k) => (e.id === mc.hovered!.id ? [k] : []))
|
|
575
|
+
: []
|
|
576
|
+
)
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
/** Feeders of every network: the branches leaving its supply bus, on MV and LV alike. */
|
|
580
|
+
const feederSet = $derived(feeders(graph, tree, zones));
|
|
581
|
+
/** "Path to source": the hover also traces the drawn path back to the source. */
|
|
582
|
+
const pathPref = new StoredFlag('tellegen.schematic.path', ['on', 'off'], false);
|
|
583
|
+
/** Where the hovered thing sits: its network, its feeder (−1 for a supply bus, a transformer or a
|
|
584
|
+
* corridor), and the bus the path to the source starts from. */
|
|
585
|
+
const hoverPlace = $derived.by((): { zone: number; feeder: number; from: number } | null => {
|
|
586
|
+
if (hoveredZone >= 0) return { zone: hoveredZone, feeder: -1, from: -1 };
|
|
587
|
+
if (hoveredBus >= 0)
|
|
588
|
+
return {
|
|
589
|
+
zone: zones.busZone[hoveredBus],
|
|
590
|
+
feeder: feederSet.busFeeder[hoveredBus],
|
|
591
|
+
from: hoveredBus
|
|
592
|
+
};
|
|
593
|
+
for (const k of hoveredEdges) {
|
|
594
|
+
const { from, to } = inputs.names.branches[k];
|
|
595
|
+
if (from < 0 || to < 0) return null;
|
|
596
|
+
const down = tree.depth[from] >= tree.depth[to] ? from : to;
|
|
597
|
+
// A transformer shows the network it feeds; a branch its feeder and network.
|
|
598
|
+
const transformer = graph.edges[k].kind === 'transformer';
|
|
599
|
+
return {
|
|
600
|
+
zone: zones.busZone[down],
|
|
601
|
+
feeder: transformer ? -1 : feederSet.edgeFeeder[k],
|
|
602
|
+
from: down
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
});
|
|
607
|
+
const activeZone = $derived(hoverPlace?.zone ?? -1);
|
|
608
|
+
/** Where a network is supplied: the source bus of the source network, else its feeding
|
|
609
|
+
* transformer's symbol (or midway between its buses). */
|
|
610
|
+
function supplyPoint(z: number): [number, number] | null {
|
|
611
|
+
const zone = zones.zones[z];
|
|
612
|
+
const g = geometry.positions;
|
|
613
|
+
if (zone.hasSource) {
|
|
614
|
+
const i = zone.buses.find((b) => graph.buses[b].has_source);
|
|
615
|
+
return i === undefined ? null : [g[i * 3], g[i * 3 + 1]];
|
|
616
|
+
}
|
|
617
|
+
const k = zone.transformers[0];
|
|
618
|
+
if (k === undefined) return null;
|
|
619
|
+
const a = equipment?.anchors;
|
|
620
|
+
if (a && Number.isFinite(a[k * 2])) return [a[k * 2], a[k * 2 + 1]];
|
|
621
|
+
const { from, to } = inputs.names.branches[k];
|
|
622
|
+
if (from < 0 || to < 0) return null;
|
|
623
|
+
return [(g[from * 3] + g[to * 3]) / 2, (g[from * 3 + 1] + g[to * 3 + 1]) / 2];
|
|
624
|
+
}
|
|
625
|
+
const hover = $derived.by((): SchematicScene['hover'] => {
|
|
626
|
+
if (!hoverPlace || hoverPlace.zone < 0) return null;
|
|
627
|
+
const { zone: z, feeder: f, from } = hoverPlace;
|
|
628
|
+
return {
|
|
629
|
+
network: new Set(zones.zones[z].edges),
|
|
630
|
+
feeder: new Set(f >= 0 ? feederSet.feeders[f].edges : []),
|
|
631
|
+
path: new Set(pathPref.value && from >= 0 ? pathToSource(tree, from) : []),
|
|
632
|
+
supply: supplyPoint(z),
|
|
633
|
+
// Neutral, whatever the network's state: the corridors and outlines show the state, the
|
|
634
|
+
// hover shows where.
|
|
635
|
+
tint: HALO
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
/** Per-edge and per-bus colours for the active mode; results never recolour inactive branches. */
|
|
639
|
+
const edgeColours = $derived(
|
|
640
|
+
graph.edges.map((e, k): RGBA => {
|
|
641
|
+
if (!live.edges[k]) return DEAD;
|
|
642
|
+
if (!e.closed || colour === 'none' || !snapshot) return LEVEL_COLOURS[edgeClass[k]];
|
|
643
|
+
if (colour === 'loading-heat') {
|
|
644
|
+
const loading = snapshot.branches[k].loading;
|
|
645
|
+
return loading === null ? MISSING_COLOUR : loadingColour(loading);
|
|
646
|
+
}
|
|
647
|
+
const { from, to } = inputs.names.branches[k];
|
|
648
|
+
if (from < 0 || to < 0) return MISSING_COLOUR;
|
|
649
|
+
const t = worstBand(snapshot.buses[tree.depth[from] > tree.depth[to] ? from : to].pairs);
|
|
650
|
+
return t === null ? MISSING_COLOUR : bandColour(t);
|
|
651
|
+
})
|
|
652
|
+
);
|
|
653
|
+
const busColours = $derived(
|
|
654
|
+
graph.buses.map((_, i): RGBA => {
|
|
655
|
+
if (!live.buses[i]) return DEAD;
|
|
656
|
+
if (colour !== 'voltage-band' || !snapshot) return INK;
|
|
657
|
+
const t = worstBand(snapshot.buses[i].pairs);
|
|
658
|
+
return t === null ? MISSING_COLOUR : bandColour(t);
|
|
659
|
+
})
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
/** Whether `view` shows at least one bus of `g`. */
|
|
663
|
+
function showsAny(
|
|
664
|
+
g: SchematicGeometry,
|
|
665
|
+
view: { target: [number, number, number]; zoom: number }
|
|
666
|
+
) {
|
|
667
|
+
const scale = 2 ** view.zoom;
|
|
668
|
+
const [hw, hh] = [width / 2 / scale, height / 2 / scale];
|
|
669
|
+
for (let i = 0; i < g.positions.length; i += 3) {
|
|
670
|
+
const dx = g.positions[i] - view.target[0];
|
|
671
|
+
const dy = g.positions[i + 1] - view.target[1];
|
|
672
|
+
if (Math.abs(dx) <= hw && Math.abs(dy) <= hh) return true;
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Record the geographic view on the case, so the map can continue from it when this diagram is
|
|
678
|
+
// closed (TellegenMap applies it). A plain field, written here rather than on teardown: writing
|
|
679
|
+
// app state while a component is torn down left the map's deriveds stale.
|
|
680
|
+
$effect(() => {
|
|
681
|
+
const origin = source?.mercatorOrigin;
|
|
682
|
+
const view = layout === 'source' && origin && shown ? basemapCamera(camera, origin) : null;
|
|
683
|
+
untrack(() => {
|
|
684
|
+
mc.lastGeoView = view ? { center: view.center, zoom: view.zoom, bearing: 0, pitch: 0 } : null;
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
/** Screen insets of the area no panel or toolbar covers. */
|
|
689
|
+
function freeArea() {
|
|
690
|
+
return {
|
|
691
|
+
left: panels.compact ? 28 : panels.dockWidth('left') + 44,
|
|
692
|
+
right: panels.compact ? 28 : panels.dockWidth('right') + 44,
|
|
693
|
+
top: panels.top + 64,
|
|
694
|
+
bottom: panels.compact ? app.sheetInset + 24 : 88
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function fitCamera(g: SchematicGeometry) {
|
|
699
|
+
const [x0, y0, x1, y1] = g.bounds;
|
|
700
|
+
const { left, right, top, bottom } = freeArea();
|
|
701
|
+
const w = Math.max(80, width - left - right);
|
|
702
|
+
const h = Math.max(80, height - top - bottom);
|
|
703
|
+
const zoom = Math.log2(Math.min(w / Math.max(1, x1 - x0), h / Math.max(1, y1 - y0)));
|
|
704
|
+
const scale = 2 ** zoom;
|
|
705
|
+
// Centre the drawing in the free area between the docked panels.
|
|
706
|
+
const cx = (x0 + x1) / 2 - (left - right) / 2 / scale;
|
|
707
|
+
const cy = (y0 + y1) / 2 - (top - bottom) / 2 / scale;
|
|
708
|
+
// Schematic units are sized for about 1:1 to 2:1; closer, a small network's symbols balloon.
|
|
709
|
+
const max = g.attachmentGap ? 1 : 4;
|
|
710
|
+
return { target: [cx, cy, 0] as [number, number, number], zoom: Math.min(zoom, max) };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let morph: {
|
|
714
|
+
from: Float32Array;
|
|
715
|
+
cur: Float32Array;
|
|
716
|
+
s: Float32Array;
|
|
717
|
+
t: Float32Array;
|
|
718
|
+
endpoints: Int32Array;
|
|
719
|
+
start: number | null;
|
|
720
|
+
warm: boolean;
|
|
721
|
+
raf: number;
|
|
722
|
+
} | null = null;
|
|
723
|
+
|
|
724
|
+
const text = $derived(
|
|
725
|
+
new SchematicText({
|
|
726
|
+
graph,
|
|
727
|
+
zones,
|
|
728
|
+
zoneStates,
|
|
729
|
+
outlined,
|
|
730
|
+
transformerNames,
|
|
731
|
+
live,
|
|
732
|
+
equipment,
|
|
733
|
+
snapshot,
|
|
734
|
+
powers,
|
|
735
|
+
valuesOn,
|
|
736
|
+
edgeMv,
|
|
737
|
+
zoneMv,
|
|
738
|
+
inferred: layout === 'source' ? (geometry.inferred ?? null) : null,
|
|
739
|
+
feeders: feederSet,
|
|
740
|
+
tree,
|
|
741
|
+
busNames: names,
|
|
742
|
+
labelsOn: namesOn
|
|
743
|
+
})
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
/** Layer data kept between frames, so a hover redraws only what it changes. */
|
|
747
|
+
const layerCache = createLayerCache();
|
|
748
|
+
|
|
749
|
+
/** Everything a frame draws; the layers and the SVG export both read it. */
|
|
750
|
+
function scene(): SchematicScene {
|
|
751
|
+
return {
|
|
752
|
+
graph,
|
|
753
|
+
layout,
|
|
754
|
+
colour,
|
|
755
|
+
shown: shown ?? EMPTY_GEOMETRY,
|
|
756
|
+
geometry,
|
|
757
|
+
drawn,
|
|
758
|
+
symbols: symbolsOn ? equipment : null,
|
|
759
|
+
ticksOn,
|
|
760
|
+
valuesOn,
|
|
761
|
+
labelsOn: namesOn,
|
|
762
|
+
zoomedOut,
|
|
763
|
+
live,
|
|
764
|
+
edgeColours,
|
|
765
|
+
busColours,
|
|
766
|
+
edgeMv,
|
|
767
|
+
snapshot,
|
|
768
|
+
powers,
|
|
769
|
+
itemText: (d) => text.itemValue(d),
|
|
770
|
+
arrows,
|
|
771
|
+
selectedBus,
|
|
772
|
+
selectedEdges,
|
|
773
|
+
hoveredBus,
|
|
774
|
+
hoveredEdges,
|
|
775
|
+
cursor,
|
|
776
|
+
labels,
|
|
777
|
+
marks,
|
|
778
|
+
markName,
|
|
779
|
+
mvBuses,
|
|
780
|
+
allBuses,
|
|
781
|
+
zones: {
|
|
782
|
+
states: zoneStates,
|
|
783
|
+
shapes: zoneShapes,
|
|
784
|
+
tags: zoneTags,
|
|
785
|
+
tagZoom,
|
|
786
|
+
corridors
|
|
787
|
+
},
|
|
788
|
+
hover,
|
|
789
|
+
stacks,
|
|
790
|
+
spreadStacks,
|
|
791
|
+
textSource: text,
|
|
792
|
+
cache: layerCache,
|
|
793
|
+
fans: fanned(),
|
|
794
|
+
fannedGroups,
|
|
795
|
+
fan
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function layers(): Layer[] {
|
|
800
|
+
if (!modules) return [];
|
|
801
|
+
return morph ? morphLayers(modules, morph, graph.buses.length) : sceneLayers(modules, scene());
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** The stack a pick belongs to: a stacked bus, a stack's ring, or the symbol of a branch
|
|
805
|
+
* between two buses of one stack (a pole transformer, drawn on the stack's point). */
|
|
806
|
+
function pickedStack(info: PickingInfo): number | null {
|
|
807
|
+
if (!stacks || info.index < 0 || !info.layer) return null;
|
|
808
|
+
if (info.layer.id === 'stack-rings') return (info.object as { g: number }).g;
|
|
809
|
+
if (info.layer.id.startsWith('buses-')) {
|
|
810
|
+
const g = stacks.stackOf[info.object as number];
|
|
811
|
+
return g >= 0 ? g : null;
|
|
812
|
+
}
|
|
813
|
+
if (info.layer.id === 'equipment-hits') {
|
|
814
|
+
const item = info.object as EquipmentItem;
|
|
815
|
+
if (item.edge < 0) return null;
|
|
816
|
+
const e = graph.edges[item.edge];
|
|
817
|
+
const g = stacks.stackOf[busIndex.get(e.from) ?? -1] ?? -1;
|
|
818
|
+
const h = stacks.stackOf[busIndex.get(e.to) ?? -1] ?? -1;
|
|
819
|
+
return g >= 0 && g === h ? g : null;
|
|
820
|
+
}
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
/** The route a pick belongs to, if it is shared. */
|
|
824
|
+
function pickedGroup(info: PickingInfo): number | null {
|
|
825
|
+
if (!equipment || info.index < 0 || !info.layer) return null;
|
|
826
|
+
const id = info.layer.id;
|
|
827
|
+
if (id === 'route-badges') return (info.object as { g: number }).g;
|
|
828
|
+
const k =
|
|
829
|
+
id === 'fan-branches'
|
|
830
|
+
? fanEdgeAt(info.index)
|
|
831
|
+
: id.startsWith('branches-')
|
|
832
|
+
? drawn.segmentEdge[info.index]
|
|
833
|
+
: -1;
|
|
834
|
+
const g = k >= 0 ? equipment.routeOf[k] : -1;
|
|
835
|
+
return g >= 0 ? g : null;
|
|
836
|
+
}
|
|
837
|
+
/** Edge of the n-th fanned segment (three per member, in `fanned()` order). */
|
|
838
|
+
function fanEdgeAt(index: number): number {
|
|
839
|
+
const fans = fanned();
|
|
840
|
+
return fans[Math.floor(index / 3)]?.edge ?? -1;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Value labels for the view, placed by priority: selection, then severity, then magnitude. */
|
|
844
|
+
function updateLabels() {
|
|
845
|
+
const g = geometry;
|
|
846
|
+
const scale = 2 ** camera.zoom;
|
|
847
|
+
const x0 = camera.target[0] - width / 2 / scale;
|
|
848
|
+
const y0 = camera.target[1] - height / 2 / scale;
|
|
849
|
+
const screen = (x: number, y: number): [number, number] => [(x - x0) * scale, (y - y0) * scale];
|
|
850
|
+
const inView = (x: number, y: number) => {
|
|
851
|
+
const [sx, sy] = screen(x, y);
|
|
852
|
+
return sx >= 0 && sx <= width && sy >= 0 && sy <= height;
|
|
853
|
+
};
|
|
854
|
+
const candidates: (LabelCandidate & { position: [number, number] })[] = [];
|
|
855
|
+
if (selectedBus >= 0) {
|
|
856
|
+
const [x, y] = [g.positions[selectedBus * 3], g.positions[selectedBus * 3 + 1]];
|
|
857
|
+
candidates.push({
|
|
858
|
+
text: names[selectedBus],
|
|
859
|
+
state: 'normal',
|
|
860
|
+
priority: Infinity,
|
|
861
|
+
selected: true,
|
|
862
|
+
screen: screen(x, y),
|
|
863
|
+
position: [x, y]
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (snapshot && camera.zoom > -3) {
|
|
867
|
+
if (colour === 'voltage-band') {
|
|
868
|
+
for (let i = 0; i < graph.buses.length && candidates.length < 4000; i++) {
|
|
869
|
+
const [x, y] = [g.positions[i * 3], g.positions[i * 3 + 1]];
|
|
870
|
+
if (i === selectedBus || !inView(x, y)) continue;
|
|
871
|
+
const c = busLabel(snapshot, i);
|
|
872
|
+
if (c) candidates.push({ ...c, screen: screen(x, y), position: [x, y] });
|
|
873
|
+
}
|
|
874
|
+
} else if (colour === 'loading-heat') {
|
|
875
|
+
const seen = new Set<number>();
|
|
876
|
+
for (let s = 0; s < g.segmentEdge.length && candidates.length < 4000; s++) {
|
|
877
|
+
const k = g.segmentEdge[s];
|
|
878
|
+
if (seen.has(k)) continue;
|
|
879
|
+
seen.add(k);
|
|
880
|
+
const [x, y] = [
|
|
881
|
+
(g.sources[s * 3] + g.targets[s * 3]) / 2,
|
|
882
|
+
(g.sources[s * 3 + 1] + g.targets[s * 3 + 1]) / 2
|
|
883
|
+
];
|
|
884
|
+
if (!inView(x, y)) continue;
|
|
885
|
+
const c = branchLabel(snapshot, k, 'loading');
|
|
886
|
+
if (c)
|
|
887
|
+
candidates.push({
|
|
888
|
+
...c,
|
|
889
|
+
selected: selectedEdges.has(k),
|
|
890
|
+
screen: screen(x, y),
|
|
891
|
+
position: [x, y]
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (cursor >= 0 && cursor !== selectedBus) {
|
|
897
|
+
const [x, y] = [g.positions[cursor * 3], g.positions[cursor * 3 + 1]];
|
|
898
|
+
candidates.push({
|
|
899
|
+
text: names[cursor],
|
|
900
|
+
state: 'normal',
|
|
901
|
+
priority: Infinity,
|
|
902
|
+
selected: true,
|
|
903
|
+
screen: screen(x, y),
|
|
904
|
+
position: [x, y]
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
// Close in, with no result values to show, name the buses (short names; hover for the full id).
|
|
908
|
+
if (namesOn && (colour === 'none' || !snapshot))
|
|
909
|
+
for (let i = 0; i < graph.buses.length && candidates.length < 4000; i++) {
|
|
910
|
+
const [x, y] = [g.positions[i * 3], g.positions[i * 3 + 1]];
|
|
911
|
+
if (i === selectedBus || !inView(x, y)) continue;
|
|
912
|
+
candidates.push({
|
|
913
|
+
text: names[i],
|
|
914
|
+
state: 'normal',
|
|
915
|
+
priority: 0,
|
|
916
|
+
screen: screen(x, y),
|
|
917
|
+
position: [x, y]
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
labels = placeLabels(candidates, { width, height, budget: 60 });
|
|
921
|
+
if (zoomedOut && landmarksPref.value && landmarkPool.length) {
|
|
922
|
+
const placed = placeLandmarks(landmarkPool, screen, { width, height });
|
|
923
|
+
marks = { placed, strokes: landmarkStrokes(placed, 8 / scale) };
|
|
924
|
+
} else marks = null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function render() {
|
|
928
|
+
deck?.setProps({ layers: layers() });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Build the renderer once the canvas exists.
|
|
932
|
+
$effect(() => {
|
|
933
|
+
if (!canvas || deck) return;
|
|
934
|
+
const element = canvas;
|
|
935
|
+
let cancelled = false;
|
|
936
|
+
Promise.all([import('@deck.gl/core'), import('@deck.gl/layers')])
|
|
937
|
+
.then(([core, layers]) => {
|
|
938
|
+
if (cancelled) return;
|
|
939
|
+
modules = { core, layers };
|
|
940
|
+
camera = fitCamera(geometry);
|
|
941
|
+
deck = new core.Deck({
|
|
942
|
+
canvas: element,
|
|
943
|
+
width: '100%',
|
|
944
|
+
height: '100%',
|
|
945
|
+
views: new core.OrthographicView({ id: 'schematic', flipY: true }),
|
|
946
|
+
initialViewState: { ...camera, minZoom: -24, maxZoom: 8 },
|
|
947
|
+
controller: { doubleClickZoom: true },
|
|
948
|
+
pickingRadius: 6,
|
|
949
|
+
getCursor: ({ isHovering, isDragging }) =>
|
|
950
|
+
isDragging ? 'grabbing' : isHovering ? 'pointer' : 'grab',
|
|
951
|
+
onViewStateChange: ({ viewState }) => {
|
|
952
|
+
const v = viewState as OrthographicViewState;
|
|
953
|
+
camera = { target: v.target as [number, number, number], zoom: v.zoom as number };
|
|
954
|
+
},
|
|
955
|
+
onHover: (info: PickingInfo) => {
|
|
956
|
+
const id = info.layer?.id ?? '';
|
|
957
|
+
const hz =
|
|
958
|
+
info.index >= 0 && id.startsWith('zones') ? (info.object as { z: number }).z : -1;
|
|
959
|
+
if (hz !== hoveredZone) hoveredZone = hz;
|
|
960
|
+
let next: { kind: 'bus' | 'branch'; id: string } | null = null;
|
|
961
|
+
if (info.index >= 0 && id.startsWith('buses-'))
|
|
962
|
+
next = { kind: 'bus', id: graph.buses[info.object as number].id };
|
|
963
|
+
else if (info.index >= 0 && id.startsWith('branches-'))
|
|
964
|
+
next = { kind: 'branch', id: graph.edges[drawn.segmentEdge[info.index]].id };
|
|
965
|
+
else if (info.index >= 0 && id === 'fan-branches')
|
|
966
|
+
next = { kind: 'branch', id: graph.edges[fanEdgeAt(info.index)].id };
|
|
967
|
+
else if (info.index >= 0 && id === 'equipment-hits') {
|
|
968
|
+
const item = info.object as EquipmentItem;
|
|
969
|
+
next =
|
|
970
|
+
item.edge >= 0
|
|
971
|
+
? { kind: 'branch', id: graph.edges[item.edge].id }
|
|
972
|
+
: { kind: 'bus', id: graph.buses[item.bus].id };
|
|
973
|
+
}
|
|
974
|
+
if (next?.kind !== mc.hovered?.kind || next?.id !== mc.hovered?.id) mc.hovered = next;
|
|
975
|
+
const g = symbolsOn ? pickedGroup(info) : null;
|
|
976
|
+
if (g !== null) routePin.open(g);
|
|
977
|
+
else routePin.leave();
|
|
978
|
+
const sg = pickedStack(info);
|
|
979
|
+
if (sg !== null) stackPin.open(sg);
|
|
980
|
+
else stackPin.leave();
|
|
981
|
+
},
|
|
982
|
+
onClick: (info: PickingInfo) => {
|
|
983
|
+
if (info.index < 0 || !info.layer) {
|
|
984
|
+
// A click on empty canvas closes a pinned shared route or stack.
|
|
985
|
+
routePin.pinned = null;
|
|
986
|
+
stackPin.pinned = null;
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (info.layer.id === 'stack-rings') {
|
|
990
|
+
stackPin.pinned = (info.object as { g: number }).g;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// A stacked bus is ambiguous while its stack is closed: open it instead of guessing.
|
|
994
|
+
const stack = pickedStack(info);
|
|
995
|
+
if (stack !== null && !spreadStacks.includes(stack)) {
|
|
996
|
+
stackPin.pinned = stack;
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (stack !== null) stackPin.pinned = stack;
|
|
1000
|
+
if (info.layer.id === 'route-badges') {
|
|
1001
|
+
routePin.pinned = (info.object as { g: number }).g;
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (info.layer.id === 'fan-branches') {
|
|
1005
|
+
pickedHere = true;
|
|
1006
|
+
setTimeout(() => (pickedHere = false));
|
|
1007
|
+
routePin.pinned = pickedGroup(info);
|
|
1008
|
+
onselectedge(graph.edges[fanEdgeAt(info.index)].id);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
// A shared route is ambiguous when collapsed: open its members instead of guessing.
|
|
1012
|
+
const shared = info.layer.id.startsWith('branches-') ? pickedGroup(info) : null;
|
|
1013
|
+
if (shared !== null && !separatePref.value) {
|
|
1014
|
+
routePin.pinned = shared;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// Skip the fly-to for this selection; cleared after effects run, as a click on the
|
|
1018
|
+
// already-selected item changes nothing.
|
|
1019
|
+
pickedHere = true;
|
|
1020
|
+
setTimeout(() => (pickedHere = false));
|
|
1021
|
+
if (info.layer.id === 'landmark-halos') {
|
|
1022
|
+
const l = info.object as Landmark;
|
|
1023
|
+
if (l.edge >= 0) onselectedge(graph.edges[l.edge].id);
|
|
1024
|
+
else onselectbus(graph.buses[l.bus].id);
|
|
1025
|
+
bringIntoView([l.x, l.y]);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (info.layer.id.startsWith('zones')) {
|
|
1029
|
+
fitZone(zones.zones[(info.object as { z: number }).z]);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (info.layer.id.startsWith('buses-'))
|
|
1033
|
+
onselectbus(graph.buses[info.object as number].id);
|
|
1034
|
+
else if (info.layer.id.startsWith('branches-'))
|
|
1035
|
+
onselectedge(graph.edges[drawn.segmentEdge[info.index]].id);
|
|
1036
|
+
else if (info.layer.id === 'equipment-hits') {
|
|
1037
|
+
const item = info.object as EquipmentItem;
|
|
1038
|
+
if (item.edge >= 0) onselectedge(graph.edges[item.edge].id);
|
|
1039
|
+
else onselectbus(graph.buses[item.bus].id);
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
getTooltip: (info: PickingInfo) =>
|
|
1043
|
+
tooltip(info, text, {
|
|
1044
|
+
inOpenStack: (pick) => activeStack !== null && pickedStack(pick) === activeStack,
|
|
1045
|
+
segmentEdge: (index) => drawn.segmentEdge[index],
|
|
1046
|
+
symbolsOn
|
|
1047
|
+
})
|
|
1048
|
+
});
|
|
1049
|
+
// Open where the user just was: the case's own positions (the map they were looking at),
|
|
1050
|
+
// when it has them. The schematic is laid out meanwhile, so switching to it is quick.
|
|
1051
|
+
if (source) {
|
|
1052
|
+
void show('source', strategy);
|
|
1053
|
+
void target('schematic', strategy).catch(() => {});
|
|
1054
|
+
} else void show('schematic', strategy);
|
|
1055
|
+
})
|
|
1056
|
+
.catch(
|
|
1057
|
+
(e: unknown) =>
|
|
1058
|
+
(status = `The schematic could not load: ${e instanceof Error ? e.message : e}`)
|
|
1059
|
+
);
|
|
1060
|
+
return () => {
|
|
1061
|
+
cancelled = true;
|
|
1062
|
+
};
|
|
1063
|
+
});
|
|
1064
|
+
$effect(() => () => {
|
|
1065
|
+
if (morph) cancelAnimationFrame(morph.raf);
|
|
1066
|
+
stackPin.dispose();
|
|
1067
|
+
routePin.dispose();
|
|
1068
|
+
deck?.finalize();
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// Redraw when the data, colours or selection change; relabel when the camera moves.
|
|
1072
|
+
$effect(() => {
|
|
1073
|
+
void [
|
|
1074
|
+
geometry,
|
|
1075
|
+
drawn,
|
|
1076
|
+
edgeColours,
|
|
1077
|
+
busColours,
|
|
1078
|
+
selectedBus,
|
|
1079
|
+
selectedEdges,
|
|
1080
|
+
labels,
|
|
1081
|
+
cursor,
|
|
1082
|
+
valuesOn,
|
|
1083
|
+
namesOn,
|
|
1084
|
+
arrows,
|
|
1085
|
+
hoveredBus,
|
|
1086
|
+
hoveredEdges,
|
|
1087
|
+
zoomedOut,
|
|
1088
|
+
zoneTags,
|
|
1089
|
+
tagZoom,
|
|
1090
|
+
corridors,
|
|
1091
|
+
hover,
|
|
1092
|
+
marks,
|
|
1093
|
+
fannedGroups,
|
|
1094
|
+
fannedEdges,
|
|
1095
|
+
spreadStacks
|
|
1096
|
+
];
|
|
1097
|
+
if (!morph) render();
|
|
1098
|
+
});
|
|
1099
|
+
let labelFrame = 0;
|
|
1100
|
+
$effect(() => {
|
|
1101
|
+
void [
|
|
1102
|
+
camera,
|
|
1103
|
+
landmarksPref.value,
|
|
1104
|
+
landmarkPool,
|
|
1105
|
+
zoomedOut,
|
|
1106
|
+
colour,
|
|
1107
|
+
snapshot,
|
|
1108
|
+
selectedBus,
|
|
1109
|
+
selectedEdges,
|
|
1110
|
+
geometry,
|
|
1111
|
+
width,
|
|
1112
|
+
height,
|
|
1113
|
+
namesOn,
|
|
1114
|
+
cursor
|
|
1115
|
+
];
|
|
1116
|
+
if (!deck) return;
|
|
1117
|
+
cancelAnimationFrame(labelFrame);
|
|
1118
|
+
labelFrame = requestAnimationFrame(updateLabels);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
/** Set while a click in the canvas changes the selection: that selection is already in view. */
|
|
1122
|
+
let pickedHere = false;
|
|
1123
|
+
const ready = $derived(!!deck && !!shown);
|
|
1124
|
+
/** Where the selection is drawn: its bus, or its branch's symbol or conductor marks. */
|
|
1125
|
+
function selectionPoint(): [number, number] | null {
|
|
1126
|
+
const g = geometry;
|
|
1127
|
+
if (selectedBus >= 0) return [g.positions[selectedBus * 3], g.positions[selectedBus * 3 + 1]];
|
|
1128
|
+
for (const k of selectedEdges) {
|
|
1129
|
+
const a = equipment?.anchors;
|
|
1130
|
+
if (a && Number.isFinite(a[k * 2])) return [a[k * 2], a[k * 2 + 1]];
|
|
1131
|
+
const { from, to } = inputs.names.branches[k];
|
|
1132
|
+
if (from >= 0 && to >= 0)
|
|
1133
|
+
return [
|
|
1134
|
+
(g.positions[from * 3] + g.positions[to * 3]) / 2,
|
|
1135
|
+
(g.positions[from * 3 + 1] + g.positions[to * 3 + 1]) / 2
|
|
1136
|
+
];
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Fly to a selection made outside the canvas (issues, search, tables, profile) when it is off
|
|
1142
|
+
// screen or drawn too small to read, zooming in until symbols show. Panning afterwards is free.
|
|
1143
|
+
// On opening, the view continues from the map: an existing selection is centred if it is off
|
|
1144
|
+
// screen, but not zoomed into.
|
|
1145
|
+
let opened = false;
|
|
1146
|
+
$effect(() => {
|
|
1147
|
+
void [selectedBus, selectedEdges, mc.revealSeq];
|
|
1148
|
+
if (!ready) return;
|
|
1149
|
+
untrack(() => {
|
|
1150
|
+
if (pickedHere || morph) {
|
|
1151
|
+
pickedHere = false;
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const p = selectionPoint();
|
|
1155
|
+
if (p) bringIntoView(p, opened);
|
|
1156
|
+
opened = true;
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
/** Fit the view to a zone. */
|
|
1161
|
+
function fitZone(zone: GalvanicZone) {
|
|
1162
|
+
let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
1163
|
+
for (const i of zone.buses) {
|
|
1164
|
+
const [x, y] = [geometry.positions[i * 3], geometry.positions[i * 3 + 1]];
|
|
1165
|
+
[x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)];
|
|
1166
|
+
}
|
|
1167
|
+
const pad = (equipment?.unit ?? 1) * 3;
|
|
1168
|
+
setCamera(fitCamera({ ...geometry, bounds: [x0 - pad, y0 - pad, x1 + pad, y1 + pad] }), true);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/** Centre `p` in the uncovered view when it is outside it or drawn too small to read. */
|
|
1172
|
+
function bringIntoView(p: [number, number], zoomIn = true) {
|
|
1173
|
+
if (!Number.isFinite(p[0]) || !Number.isFinite(p[1])) return;
|
|
1174
|
+
const legible = equipment && zoomIn ? Math.log2(8 / equipment.unit) : camera.zoom;
|
|
1175
|
+
const zoom = Math.min(8, Math.max(camera.zoom, legible));
|
|
1176
|
+
const { left, right, top, bottom } = freeArea();
|
|
1177
|
+
const scale = 2 ** camera.zoom;
|
|
1178
|
+
const sx = (p[0] - camera.target[0]) * scale + width / 2;
|
|
1179
|
+
const sy = (p[1] - camera.target[1]) * scale + height / 2;
|
|
1180
|
+
const visible = sx > left && sx < width - right && sy > top && sy < height - bottom;
|
|
1181
|
+
if (visible && zoom === camera.zoom) return;
|
|
1182
|
+
// Centre it in the free area, not behind a docked panel.
|
|
1183
|
+
const k = 2 ** zoom;
|
|
1184
|
+
const target: [number, number, number] = [
|
|
1185
|
+
p[0] - (left - right) / 2 / k,
|
|
1186
|
+
p[1] - (top - bottom) / 2 / k,
|
|
1187
|
+
0
|
|
1188
|
+
];
|
|
1189
|
+
setCamera({ target, zoom }, true);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function setCamera(next: { target: [number, number, number]; zoom: number }, fly = false) {
|
|
1193
|
+
camera = next;
|
|
1194
|
+
const animate = fly && modules && !matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1195
|
+
deck?.setProps({
|
|
1196
|
+
initialViewState: {
|
|
1197
|
+
...next,
|
|
1198
|
+
minZoom: -24,
|
|
1199
|
+
maxZoom: 8,
|
|
1200
|
+
...(animate
|
|
1201
|
+
? {
|
|
1202
|
+
transitionDuration: 450,
|
|
1203
|
+
transitionInterpolator: new modules!.core.LinearInterpolator(['target', 'zoom'])
|
|
1204
|
+
}
|
|
1205
|
+
: {})
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// The schematic is laid out in the engine (Rust); each strategy is computed once per graph.
|
|
1211
|
+
const engineLayouts = new Map<SchematicStrategy, Promise<SchematicGeometry>>();
|
|
1212
|
+
let showSeq = 0;
|
|
1213
|
+
function target(nextLayout: Layout, nextStrategy: SchematicStrategy): Promise<SchematicGeometry> {
|
|
1214
|
+
if (nextLayout === 'source' && source) return Promise.resolve(source);
|
|
1215
|
+
let pending = engineLayouts.get(nextStrategy);
|
|
1216
|
+
if (!pending) {
|
|
1217
|
+
pending = mcSchematicLayout(graph, { strategy: nextStrategy }).then(fromEngineLayout);
|
|
1218
|
+
engineLayouts.set(nextStrategy, pending);
|
|
1219
|
+
pending.catch(() => engineLayouts.delete(nextStrategy));
|
|
1220
|
+
}
|
|
1221
|
+
return pending;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Show a layout. Buses glide from where they are on screen to the new layout along straight
|
|
1225
|
+
* chords; a warm-up frame uploads the target geometry before the clock starts. */
|
|
1226
|
+
async function show(nextLayout: Layout, nextStrategy: SchematicStrategy) {
|
|
1227
|
+
if (nextLayout === 'source' && !source) return;
|
|
1228
|
+
const seq = ++showSeq;
|
|
1229
|
+
const slow = setTimeout(() => {
|
|
1230
|
+
if (seq === showSeq) status = 'Laying out the schematic…';
|
|
1231
|
+
}, 150);
|
|
1232
|
+
let next: SchematicGeometry;
|
|
1233
|
+
try {
|
|
1234
|
+
next = await target(nextLayout, nextStrategy);
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
status = `The schematic could not be laid out: ${e instanceof Error ? e.message : e}`;
|
|
1237
|
+
return;
|
|
1238
|
+
} finally {
|
|
1239
|
+
clearTimeout(slow);
|
|
1240
|
+
}
|
|
1241
|
+
if (seq !== showSeq) return;
|
|
1242
|
+
status = null;
|
|
1243
|
+
const previous = shown;
|
|
1244
|
+
const from = { ...camera };
|
|
1245
|
+
layout = nextLayout;
|
|
1246
|
+
strategy = nextStrategy;
|
|
1247
|
+
shown = next;
|
|
1248
|
+
// Opening on geographic positions, continue from the main map's view when it shows part of
|
|
1249
|
+
// this feeder; otherwise fit the drawing.
|
|
1250
|
+
const fromMap =
|
|
1251
|
+
!previous && nextLayout === 'source' && next.mercatorOrigin && app.camera
|
|
1252
|
+
? cameraFromMap(app.camera, next.mercatorOrigin)
|
|
1253
|
+
: null;
|
|
1254
|
+
const to = fromMap && showsAny(next, fromMap) ? fromMap : fitCamera(next);
|
|
1255
|
+
setCamera(to);
|
|
1256
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1257
|
+
if (!previous || !deck || reduced || graph.buses.length > MORPH_MAX_BUSES) return;
|
|
1258
|
+
if (morph) cancelAnimationFrame(morph.raf);
|
|
1259
|
+
const before = previous.positions;
|
|
1260
|
+
const endpoints = Int32Array.from(
|
|
1261
|
+
graph.edges.flatMap((_, k) => {
|
|
1262
|
+
const { from: a, to: b } = inputs.names.branches[k];
|
|
1263
|
+
return a >= 0 && b >= 0 ? [a, b] : [];
|
|
1264
|
+
})
|
|
1265
|
+
);
|
|
1266
|
+
const n = endpoints.length / 2;
|
|
1267
|
+
morph = {
|
|
1268
|
+
from: screenAnchored(before, from, to),
|
|
1269
|
+
cur: new Float32Array(before.length),
|
|
1270
|
+
s: new Float32Array(n * 3),
|
|
1271
|
+
t: new Float32Array(n * 3),
|
|
1272
|
+
endpoints,
|
|
1273
|
+
start: null,
|
|
1274
|
+
warm: false,
|
|
1275
|
+
raf: 0
|
|
1276
|
+
};
|
|
1277
|
+
const destination = next.positions;
|
|
1278
|
+
const tick = (now: number) => {
|
|
1279
|
+
const m = morph;
|
|
1280
|
+
if (!m) return;
|
|
1281
|
+
if (!m.warm) {
|
|
1282
|
+
m.warm = true;
|
|
1283
|
+
m.raf = requestAnimationFrame(tick);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
m.start ??= now;
|
|
1287
|
+
const k = ease(Math.min(1, (now - m.start) / MORPH_MS));
|
|
1288
|
+
lerpInto(m.cur, m.from, destination, k);
|
|
1289
|
+
chordsInto(m.s, m.t, m.endpoints, m.cur);
|
|
1290
|
+
if (k < 1) {
|
|
1291
|
+
render();
|
|
1292
|
+
m.raf = requestAnimationFrame(tick);
|
|
1293
|
+
} else {
|
|
1294
|
+
morph = null;
|
|
1295
|
+
render();
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
morph.raf = requestAnimationFrame(tick);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function zoomBy(delta: number) {
|
|
1302
|
+
setCamera({ target: camera.target, zoom: Math.max(-24, Math.min(8, camera.zoom + delta)) });
|
|
1303
|
+
}
|
|
1304
|
+
const WALK: Record<string, WalkKey> = {
|
|
1305
|
+
ArrowLeft: 'parent',
|
|
1306
|
+
ArrowRight: 'child',
|
|
1307
|
+
ArrowUp: 'previous',
|
|
1308
|
+
ArrowDown: 'next',
|
|
1309
|
+
Home: 'root'
|
|
1310
|
+
};
|
|
1311
|
+
function onkeydown(e: KeyboardEvent) {
|
|
1312
|
+
const step = 80 / 2 ** camera.zoom;
|
|
1313
|
+
const pan: Record<string, [number, number]> = {
|
|
1314
|
+
ArrowLeft: [-step, 0],
|
|
1315
|
+
ArrowRight: [step, 0],
|
|
1316
|
+
ArrowUp: [0, -step],
|
|
1317
|
+
ArrowDown: [0, step]
|
|
1318
|
+
};
|
|
1319
|
+
if (WALK[e.key] && !e.shiftKey && shown && !morph) {
|
|
1320
|
+
// Walk the tree; the first key starts at the selection, or at the first source.
|
|
1321
|
+
e.preventDefault();
|
|
1322
|
+
moveCursor(
|
|
1323
|
+
cursor < 0
|
|
1324
|
+
? selectedBus >= 0
|
|
1325
|
+
? selectedBus
|
|
1326
|
+
: walk.step(-1, 'root')
|
|
1327
|
+
: walk.step(cursor, WALK[e.key])
|
|
1328
|
+
);
|
|
1329
|
+
} else if (e.key === 'Tab' && !e.shiftKey && cursor >= 0 && equipment) {
|
|
1330
|
+
// Cycle the branches on shared routes at the cursor bus; past the last, Tab moves on.
|
|
1331
|
+
const members = graph.edges.flatMap((_, k) => {
|
|
1332
|
+
const { from, to } = inputs.names.branches[k];
|
|
1333
|
+
return (from === cursor || to === cursor) && equipment!.routeOf[k] >= 0 ? [k] : [];
|
|
1334
|
+
});
|
|
1335
|
+
const next = members.indexOf(tabEdge) + 1;
|
|
1336
|
+
if (next >= members.length) {
|
|
1337
|
+
tabEdge = -1;
|
|
1338
|
+
routePin.pinned = null;
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
e.preventDefault();
|
|
1342
|
+
tabEdge = members[next];
|
|
1343
|
+
routePin.pinned = equipment.routeOf[tabEdge];
|
|
1344
|
+
mc.hovered = { kind: 'branch', id: graph.edges[tabEdge].id };
|
|
1345
|
+
announcement = `${text.branch(tabEdge)}. Enter selects; Tab for the next.`;
|
|
1346
|
+
} else if (e.key === 'Enter' && tabEdge >= 0) {
|
|
1347
|
+
e.preventDefault();
|
|
1348
|
+
onselectedge(graph.edges[tabEdge].id);
|
|
1349
|
+
} else if (e.key === 'Enter' && cursor >= 0) {
|
|
1350
|
+
e.preventDefault();
|
|
1351
|
+
onselectbus(graph.buses[cursor].id);
|
|
1352
|
+
} else if (e.key === 'Escape' && stackPin.pinned !== null) {
|
|
1353
|
+
stackPin.pinned = null;
|
|
1354
|
+
} else if (e.key === 'Escape' && routePin.pinned !== null) {
|
|
1355
|
+
routePin.pinned = null;
|
|
1356
|
+
tabEdge = -1;
|
|
1357
|
+
} else if (e.key === 'Escape' && cursor >= 0) {
|
|
1358
|
+
cursor = -1;
|
|
1359
|
+
cursorStack = null;
|
|
1360
|
+
announcement = '';
|
|
1361
|
+
} else if (pan[e.key]) {
|
|
1362
|
+
e.preventDefault();
|
|
1363
|
+
setCamera({
|
|
1364
|
+
target: [camera.target[0] + pan[e.key][0], camera.target[1] + pan[e.key][1], 0],
|
|
1365
|
+
zoom: camera.zoom
|
|
1366
|
+
});
|
|
1367
|
+
} else if (e.key === '+' || e.key === '=') zoomBy(0.5);
|
|
1368
|
+
else if (e.key === '-' || e.key === '_') zoomBy(-0.5);
|
|
1369
|
+
else if (e.key === 'f' || e.key === 'F') setCamera(fitCamera(geometry));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const layoutName = $derived(
|
|
1373
|
+
layout === 'schematic'
|
|
1374
|
+
? `Schematic (${strategy === 'tidy' ? 'tidy' : 'leaf order'} from the sources)`
|
|
1375
|
+
: mc.coordsKind === 'geographic'
|
|
1376
|
+
? 'Source positions (Web Mercator)'
|
|
1377
|
+
: 'Source positions (drawing)'
|
|
1378
|
+
);
|
|
1379
|
+
function exportTitle() {
|
|
1380
|
+
return titleBlock({
|
|
1381
|
+
dataset: mc.label,
|
|
1382
|
+
layout: layoutName,
|
|
1383
|
+
view: COLOURS[colour],
|
|
1384
|
+
camera,
|
|
1385
|
+
result: snapshot
|
|
1386
|
+
? {
|
|
1387
|
+
kind: 'solved',
|
|
1388
|
+
label: snapshot.label,
|
|
1389
|
+
iterations: snapshot.iterations,
|
|
1390
|
+
revision: `solve ${mc.result?.solve_count ?? '?'}`
|
|
1391
|
+
}
|
|
1392
|
+
: null,
|
|
1393
|
+
exported: new Date().toISOString()
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
async function exportPng() {
|
|
1397
|
+
if (!deck || !canvas) return;
|
|
1398
|
+
const blob = await canvasPng(deck, canvas, exportTitle());
|
|
1399
|
+
if (blob) download(blob, `${fileStem(mc.label)}.png`);
|
|
1400
|
+
}
|
|
1401
|
+
/** The schematic as a PowerIO diagram layer: load it with the case to reuse the layout. */
|
|
1402
|
+
function exportLayer() {
|
|
1403
|
+
const layer = diagramLayer(graph, geometry, {
|
|
1404
|
+
strategy,
|
|
1405
|
+
version: geometry.version ?? 0
|
|
1406
|
+
});
|
|
1407
|
+
download(new Blob([layer], { type: 'application/geo+json' }), `${fileStem(mc.label)}.geo.json`);
|
|
1408
|
+
}
|
|
1409
|
+
function exportSvg() {
|
|
1410
|
+
try {
|
|
1411
|
+
const svg = sceneSvg(scene(), { camera, width, height }, exportTitle());
|
|
1412
|
+
download(new Blob([svg], { type: 'image/svg+xml' }), `${fileStem(mc.label)}.svg`);
|
|
1413
|
+
} catch (e) {
|
|
1414
|
+
status = e instanceof Error ? e.message : String(e);
|
|
1415
|
+
setTimeout(() => (status = null), 4000);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
</script>
|
|
1419
|
+
|
|
1420
|
+
<div class="schematic-stage" bind:clientWidth={width} bind:clientHeight={height}>
|
|
1421
|
+
<p id="schematic-keys" class="sr-only">
|
|
1422
|
+
Arrow keys walk the network from bus to bus: left to the feeding bus, right onward, up and down
|
|
1423
|
+
between neighbours; Home returns to the source; Enter selects; Escape stops. Where lines share a
|
|
1424
|
+
route, Tab at a bus steps through them. Shift with the arrow keys pans; plus and minus zoom; F
|
|
1425
|
+
fits the drawing.
|
|
1426
|
+
</p>
|
|
1427
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions (The canvas supports keyboard panning and zooming; selection is also available from the overview.) -->
|
|
1428
|
+
<div
|
|
1429
|
+
class="canvas"
|
|
1430
|
+
role="application"
|
|
1431
|
+
aria-label="Schematic diagram"
|
|
1432
|
+
aria-describedby="schematic-keys"
|
|
1433
|
+
tabindex="0"
|
|
1434
|
+
{onkeydown}
|
|
1435
|
+
>
|
|
1436
|
+
<div class="basemap" bind:this={mapBox} style:opacity={basemapOpacity} aria-hidden="true"></div>
|
|
1437
|
+
<canvas bind:this={canvas}></canvas>
|
|
1438
|
+
<p class="sr-only" aria-live="polite">{announcement}</p>
|
|
1439
|
+
</div>
|
|
1440
|
+
<div
|
|
1441
|
+
class="toolbar"
|
|
1442
|
+
style:top="{panels.top + 8}px"
|
|
1443
|
+
style:left="{toolLeft}px"
|
|
1444
|
+
role="toolbar"
|
|
1445
|
+
aria-label="Schematic"
|
|
1446
|
+
>
|
|
1447
|
+
<div class="segmented" role="group" aria-label="Layout">
|
|
1448
|
+
<button
|
|
1449
|
+
aria-pressed={layout === 'source'}
|
|
1450
|
+
disabled={!source}
|
|
1451
|
+
title={source ? 'Positions from the case data' : 'This case has no source positions'}
|
|
1452
|
+
onclick={() => show('source', strategy)}
|
|
1453
|
+
>{mc.coordsKind === 'geographic' ? 'Geographic' : 'Source positions'}</button
|
|
1454
|
+
>
|
|
1455
|
+
<button aria-pressed={layout === 'schematic'} onclick={() => show('schematic', strategy)}
|
|
1456
|
+
>Schematic</button
|
|
1457
|
+
>
|
|
1458
|
+
</div>
|
|
1459
|
+
{#if layout === 'source' && mc.coordsKind === 'geographic'}
|
|
1460
|
+
<label class="check" title="Map tiles under the geographic positions, as on the main map"
|
|
1461
|
+
><input
|
|
1462
|
+
type="checkbox"
|
|
1463
|
+
checked={basemapPref.value}
|
|
1464
|
+
onchange={(e) => basemapPref.set(e.currentTarget.checked)}
|
|
1465
|
+
/> Basemap</label
|
|
1466
|
+
>
|
|
1467
|
+
{/if}
|
|
1468
|
+
<label class="check" title="Hovering also traces the drawn path back to the source"
|
|
1469
|
+
><input
|
|
1470
|
+
type="checkbox"
|
|
1471
|
+
checked={pathPref.value}
|
|
1472
|
+
onchange={(e) => pathPref.set(e.currentTarget.checked)}
|
|
1473
|
+
/> Path to source</label
|
|
1474
|
+
>
|
|
1475
|
+
{#if layout === 'schematic'}
|
|
1476
|
+
<label>
|
|
1477
|
+
<span class="sr-only">Schematic arrangement</span>
|
|
1478
|
+
<select
|
|
1479
|
+
value={strategy}
|
|
1480
|
+
title="Tidy packs subtrees closely; leaf order gives every leaf its own row"
|
|
1481
|
+
onchange={(e) => show('schematic', e.currentTarget.value as SchematicStrategy)}
|
|
1482
|
+
>
|
|
1483
|
+
<option value="tidy">Tidy</option>
|
|
1484
|
+
<option value="leaf-order">Leaf order</option>
|
|
1485
|
+
</select>
|
|
1486
|
+
</label>
|
|
1487
|
+
{/if}
|
|
1488
|
+
<label class="colour">
|
|
1489
|
+
<span class="sr-only">Colour by</span>
|
|
1490
|
+
<select
|
|
1491
|
+
bind:value={colour}
|
|
1492
|
+
disabled={!snapshot}
|
|
1493
|
+
title={snapshot ? 'Colour by' : 'Run the power flow to colour by results'}
|
|
1494
|
+
>
|
|
1495
|
+
{#each Object.entries(COLOURS) as [value, label] (value)}<option {value}>{label}</option
|
|
1496
|
+
>{/each}
|
|
1497
|
+
</select>
|
|
1498
|
+
</label>
|
|
1499
|
+
<button onclick={() => setCamera(fitCamera(geometry))} title="Fit the drawing (F)">Fit</button>
|
|
1500
|
+
{#if live.deadBuses}<span
|
|
1501
|
+
class="note"
|
|
1502
|
+
role="status"
|
|
1503
|
+
title="Buses with no path to a source through closed branches are drawn faint"
|
|
1504
|
+
>{live.deadBuses.toLocaleString()} bus{live.deadBuses === 1 ? '' : 'es'} not energised</span
|
|
1505
|
+
>{/if}
|
|
1506
|
+
{#if unplaced}<span
|
|
1507
|
+
class="note"
|
|
1508
|
+
role="status"
|
|
1509
|
+
title="The case gives these buses no position; each is drawn next to a bus it connects to"
|
|
1510
|
+
>{unplaced.toLocaleString()} bus{unplaced === 1 ? '' : 'es'} without a position</span
|
|
1511
|
+
>{/if}
|
|
1512
|
+
<button onclick={exportPng} title="PNG of this view with a title block">PNG</button>
|
|
1513
|
+
<button onclick={exportSvg} title="Vector SVG of the visible region with a title block"
|
|
1514
|
+
>SVG</button
|
|
1515
|
+
>
|
|
1516
|
+
<button onclick={onclose} title="Return to the map">Close</button>
|
|
1517
|
+
{#if layout === 'schematic'}<button
|
|
1518
|
+
onclick={exportLayer}
|
|
1519
|
+
title="Download this schematic as a PowerIO diagram layer (.geo.json) to reuse with the case"
|
|
1520
|
+
>Layout</button
|
|
1521
|
+
>{/if}
|
|
1522
|
+
</div>
|
|
1523
|
+
{#if colour !== 'none' && snapshot}
|
|
1524
|
+
<div class="legend" style:top="{panels.top + 50}px" style:left="{toolLeft}px">
|
|
1525
|
+
{#if colour === 'loading-heat'}
|
|
1526
|
+
Loading <span>0 %</span><i style:background={priceGradient}></i><span>≥ 100 %</span>
|
|
1527
|
+
{:else}
|
|
1528
|
+
Voltage <span>min</span><i style:background={sensGradient}></i><span>max</span> of each declared
|
|
1529
|
+
band, worst pair
|
|
1530
|
+
{/if}
|
|
1531
|
+
· grey: no limit or rating{live.deadBuses ? ' · faint: not energised' : ''}
|
|
1532
|
+
</div>
|
|
1533
|
+
{/if}
|
|
1534
|
+
{#if colour === 'none' && zoomedOut && landmarkPool.length}
|
|
1535
|
+
<details
|
|
1536
|
+
class="key"
|
|
1537
|
+
style:bottom="{panels.compact ? app.sheetInset + 12 : 24}px"
|
|
1538
|
+
style:left="{toolLeft}px"
|
|
1539
|
+
open={keyPref.value}
|
|
1540
|
+
ontoggle={(e) => keyPref.set(e.currentTarget.open)}
|
|
1541
|
+
>
|
|
1542
|
+
<summary>Key</summary>
|
|
1543
|
+
{#if levelKey.length}
|
|
1544
|
+
<ul class="symbols" aria-label="Voltage levels">
|
|
1545
|
+
{#each levelKey as level (level.cls)}
|
|
1546
|
+
<li>
|
|
1547
|
+
<svg viewBox="-2 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1548
|
+
><path
|
|
1549
|
+
class="level"
|
|
1550
|
+
style:stroke="rgb({LEVEL_COLOURS[level.cls].slice(0, 3).join(' ')})"
|
|
1551
|
+
d="M-2 0H26"
|
|
1552
|
+
/></svg
|
|
1553
|
+
>{level.label}
|
|
1554
|
+
</li>
|
|
1555
|
+
{/each}
|
|
1556
|
+
</ul>
|
|
1557
|
+
{/if}
|
|
1558
|
+
<label class="toggle"
|
|
1559
|
+
><input
|
|
1560
|
+
type="checkbox"
|
|
1561
|
+
checked={landmarksPref.value}
|
|
1562
|
+
onchange={(e) => landmarksPref.set(e.currentTarget.checked)}
|
|
1563
|
+
/> Landmarks</label
|
|
1564
|
+
>
|
|
1565
|
+
{#if landmarksPref.value}
|
|
1566
|
+
<ul class="symbols" aria-label="Landmarks">
|
|
1567
|
+
{#each [['source', 'source, always shown'], ['generator', 'generator'], ['ibr', 'large inverter-based resource'], ['transformer', 'transformer']] as [kind, label] (kind)}
|
|
1568
|
+
{#if landmarkPool.some((l) => l.kind === kind)}
|
|
1569
|
+
<li>
|
|
1570
|
+
<svg viewBox="-14 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1571
|
+
><path d={glyphPath(landmarkGlyph(kind as Landmark['kind']), 7)} /></svg
|
|
1572
|
+
>{label}
|
|
1573
|
+
</li>
|
|
1574
|
+
{/if}
|
|
1575
|
+
{/each}
|
|
1576
|
+
</ul>
|
|
1577
|
+
<p class="hint">More appear as you zoom in.</p>
|
|
1578
|
+
{/if}
|
|
1579
|
+
</details>
|
|
1580
|
+
{/if}
|
|
1581
|
+
{#if colour === 'none' && symbolsOn && legendGlyphs.length}
|
|
1582
|
+
<details
|
|
1583
|
+
class="key"
|
|
1584
|
+
style:bottom="{panels.compact ? app.sheetInset + 12 : 24}px"
|
|
1585
|
+
style:left="{toolLeft}px"
|
|
1586
|
+
open={keyPref.value}
|
|
1587
|
+
ontoggle={(e) => keyPref.set(e.currentTarget.open)}
|
|
1588
|
+
>
|
|
1589
|
+
<summary>Key</summary>
|
|
1590
|
+
{#if levelKey.length}
|
|
1591
|
+
<ul class="symbols" aria-label="Voltage levels">
|
|
1592
|
+
{#each levelKey as level (level.cls)}
|
|
1593
|
+
<li>
|
|
1594
|
+
<svg viewBox="-2 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1595
|
+
><path
|
|
1596
|
+
class="level"
|
|
1597
|
+
style:stroke="rgb({LEVEL_COLOURS[level.cls].slice(0, 3).join(' ')})"
|
|
1598
|
+
d="M-2 0H26"
|
|
1599
|
+
/></svg
|
|
1600
|
+
>{level.label}
|
|
1601
|
+
</li>
|
|
1602
|
+
{/each}
|
|
1603
|
+
</ul>
|
|
1604
|
+
{/if}
|
|
1605
|
+
<ul class="symbols" aria-label="Symbols">
|
|
1606
|
+
{#if equipment?.routeGroups.length || stacks?.groups.length}
|
|
1607
|
+
<li>
|
|
1608
|
+
<label class="toggle"
|
|
1609
|
+
><input
|
|
1610
|
+
type="checkbox"
|
|
1611
|
+
checked={separatePref.value}
|
|
1612
|
+
onchange={(e) => separatePref.set(e.currentTarget.checked)}
|
|
1613
|
+
/> Separate overlapping buses and lines</label
|
|
1614
|
+
>
|
|
1615
|
+
</li>
|
|
1616
|
+
{/if}
|
|
1617
|
+
{#if ticksOn && equipment?.ticks.edge.length}
|
|
1618
|
+
<li>
|
|
1619
|
+
<svg viewBox="-2 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1620
|
+
><path class="wire" d="M-2 0H26" /><path
|
|
1621
|
+
d={glyphPath(conductorTicks(3, true), 8, 12, 0)}
|
|
1622
|
+
/></svg
|
|
1623
|
+
>phases, ○ neutral
|
|
1624
|
+
</li>
|
|
1625
|
+
{/if}
|
|
1626
|
+
{#if valuesOn && arrows?.edge.length}
|
|
1627
|
+
<li>
|
|
1628
|
+
<svg viewBox="-2 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1629
|
+
><path class="wire" d="M-2 0H26" /><path
|
|
1630
|
+
class="flow"
|
|
1631
|
+
d="M9.6 -3.2L14.4 0L9.6 3.2"
|
|
1632
|
+
/></svg
|
|
1633
|
+
>active power flow
|
|
1634
|
+
</li>
|
|
1635
|
+
{/if}
|
|
1636
|
+
{#each legendGlyphs as [kind, glyph, label], i (i)}
|
|
1637
|
+
<li>
|
|
1638
|
+
<svg viewBox="-2 -12 28 24" width="28" height="24" aria-hidden="true"
|
|
1639
|
+
><path
|
|
1640
|
+
d={glyphPath(glyph!, 8, kind === 'transformer' || kind === 'switch' ? 12 : 2, 0)}
|
|
1641
|
+
/>{#if kind === 'transformer' || kind === 'switch'}<path
|
|
1642
|
+
class="wire"
|
|
1643
|
+
d="M-2 0H0.8M23.2 0H26"
|
|
1644
|
+
/>{:else}<path class="wire" d="M-2 -10V0H2" />{/if}</svg
|
|
1645
|
+
>{label}
|
|
1646
|
+
</li>
|
|
1647
|
+
{/each}
|
|
1648
|
+
</ul>
|
|
1649
|
+
</details>
|
|
1650
|
+
{/if}
|
|
1651
|
+
{#if activeStack !== null && stacks && shown}
|
|
1652
|
+
{@const members = stacks.groups[activeStack]}
|
|
1653
|
+
{@const k = 2 ** camera.zoom}
|
|
1654
|
+
<PickCard
|
|
1655
|
+
x={(shown.positions[members[0] * 3] - camera.target[0]) * k + width / 2}
|
|
1656
|
+
y={(shown.positions[members[0] * 3 + 1] - camera.target[1]) * k + height / 2}
|
|
1657
|
+
stage={{ width, height }}
|
|
1658
|
+
gap={stackRadius(members.length) + 10}
|
|
1659
|
+
reach={460}
|
|
1660
|
+
heading="{members.length} buses at this point"
|
|
1661
|
+
label="Buses at this point"
|
|
1662
|
+
rows={text.stackRows(members)}
|
|
1663
|
+
pin={stackPin}
|
|
1664
|
+
selected={(row) => selectedBus === row.i}
|
|
1665
|
+
onhover={(row) => (mc.hovered = row ? { kind: 'bus', id: row.id } : null)}
|
|
1666
|
+
onpick={(row) => {
|
|
1667
|
+
stackPin.pinned = activeStack;
|
|
1668
|
+
onselectbus(row.id);
|
|
1669
|
+
}}
|
|
1670
|
+
>
|
|
1671
|
+
{#snippet cells(row)}
|
|
1672
|
+
<span class="id" title={row.id}>{row.id}</span>
|
|
1673
|
+
<span>{row.level}</span>
|
|
1674
|
+
<span>{row.attached || '—'}</span>
|
|
1675
|
+
{#if row.voltage}<span>{row.voltage}</span>{/if}
|
|
1676
|
+
{/snippet}
|
|
1677
|
+
</PickCard>
|
|
1678
|
+
{/if}
|
|
1679
|
+
{#if activeGroup !== null && equipment}
|
|
1680
|
+
{@const members = equipment.routeGroups[activeGroup]}
|
|
1681
|
+
{@const middle = routeMiddle(members, equipment.host, geometry)}
|
|
1682
|
+
{#if middle}
|
|
1683
|
+
{@const k = 2 ** camera.zoom}
|
|
1684
|
+
<PickCard
|
|
1685
|
+
x={(middle[0] - camera.target[0]) * k + width / 2}
|
|
1686
|
+
y={(middle[1] - camera.target[1]) * k + height / 2}
|
|
1687
|
+
stage={{ width, height }}
|
|
1688
|
+
gap={16}
|
|
1689
|
+
reach={440}
|
|
1690
|
+
heading="{members.length} branches on this route"
|
|
1691
|
+
label="Branches on this route"
|
|
1692
|
+
rows={text.routeRows(members)}
|
|
1693
|
+
pin={routePin}
|
|
1694
|
+
selected={(row) => selectedEdges.has(row.k)}
|
|
1695
|
+
onhover={(row) => (mc.hovered = row ? { kind: 'branch', id: row.id } : null)}
|
|
1696
|
+
onpick={(row) => {
|
|
1697
|
+
routePin.pinned = activeGroup;
|
|
1698
|
+
onselectedge(row.id);
|
|
1699
|
+
}}
|
|
1700
|
+
>
|
|
1701
|
+
{#snippet cells(row)}
|
|
1702
|
+
<span class="id" title={row.id}>{row.id}</span>
|
|
1703
|
+
<span>{row.kind}</span>
|
|
1704
|
+
<span>{row.level}</span>
|
|
1705
|
+
<span>{row.wires}</span>
|
|
1706
|
+
{#if row.loading}<span class={row.state}>{row.loading}</span>{/if}
|
|
1707
|
+
{/snippet}
|
|
1708
|
+
</PickCard>
|
|
1709
|
+
{/if}
|
|
1710
|
+
{/if}
|
|
1711
|
+
{#if basemapOpacity > 0}
|
|
1712
|
+
<p
|
|
1713
|
+
class="attribution"
|
|
1714
|
+
style:opacity={basemapOpacity}
|
|
1715
|
+
style:right="{panels.compact ? 12 : panels.dockWidth('right') + 24}px"
|
|
1716
|
+
style:bottom="{panels.compact ? app.sheetInset + 8 : 8}px"
|
|
1717
|
+
>
|
|
1718
|
+
{basemapCredit(basemapProvider)}
|
|
1719
|
+
</p>
|
|
1720
|
+
{/if}
|
|
1721
|
+
{#if status}<p class="status" role="status">{status}</p>{/if}
|
|
1722
|
+
</div>
|
|
1723
|
+
|
|
1724
|
+
<style>
|
|
1725
|
+
.schematic-stage {
|
|
1726
|
+
position: absolute;
|
|
1727
|
+
inset: 0;
|
|
1728
|
+
background: var(--bg);
|
|
1729
|
+
color: var(--ink);
|
|
1730
|
+
}
|
|
1731
|
+
.canvas,
|
|
1732
|
+
canvas {
|
|
1733
|
+
position: absolute;
|
|
1734
|
+
inset: 0;
|
|
1735
|
+
width: 100%;
|
|
1736
|
+
height: 100%;
|
|
1737
|
+
}
|
|
1738
|
+
.canvas:focus-visible {
|
|
1739
|
+
outline: 2px solid var(--accent);
|
|
1740
|
+
outline-offset: -3px;
|
|
1741
|
+
}
|
|
1742
|
+
.toolbar {
|
|
1743
|
+
position: absolute;
|
|
1744
|
+
flex-wrap: wrap;
|
|
1745
|
+
max-width: calc(100% - 24px);
|
|
1746
|
+
display: flex;
|
|
1747
|
+
gap: 6px;
|
|
1748
|
+
align-items: center;
|
|
1749
|
+
padding: 5px;
|
|
1750
|
+
font-size: 11px;
|
|
1751
|
+
background: var(--surface-panel, var(--paper));
|
|
1752
|
+
border: 1px solid var(--line);
|
|
1753
|
+
border-radius: 4px;
|
|
1754
|
+
box-shadow: var(--elev-1);
|
|
1755
|
+
}
|
|
1756
|
+
.toolbar button,
|
|
1757
|
+
.toolbar select {
|
|
1758
|
+
font: inherit;
|
|
1759
|
+
color: var(--ink);
|
|
1760
|
+
background: var(--paper);
|
|
1761
|
+
border: 1px solid var(--line);
|
|
1762
|
+
border-radius: 3px;
|
|
1763
|
+
padding: 4px 8px;
|
|
1764
|
+
cursor: pointer;
|
|
1765
|
+
}
|
|
1766
|
+
.toolbar button:disabled,
|
|
1767
|
+
.toolbar select:disabled {
|
|
1768
|
+
opacity: 0.45;
|
|
1769
|
+
cursor: default;
|
|
1770
|
+
}
|
|
1771
|
+
.note {
|
|
1772
|
+
color: var(--text-secondary);
|
|
1773
|
+
padding: 0 4px;
|
|
1774
|
+
}
|
|
1775
|
+
.segmented {
|
|
1776
|
+
display: flex;
|
|
1777
|
+
}
|
|
1778
|
+
.segmented button {
|
|
1779
|
+
border-radius: 0;
|
|
1780
|
+
}
|
|
1781
|
+
.segmented button:first-child {
|
|
1782
|
+
border-radius: 3px 0 0 3px;
|
|
1783
|
+
}
|
|
1784
|
+
.segmented button:last-child {
|
|
1785
|
+
border-radius: 0 3px 3px 0;
|
|
1786
|
+
border-left: 0;
|
|
1787
|
+
}
|
|
1788
|
+
.segmented button[aria-pressed='true'] {
|
|
1789
|
+
background: var(--accent-soft);
|
|
1790
|
+
color: var(--accent);
|
|
1791
|
+
}
|
|
1792
|
+
.legend {
|
|
1793
|
+
position: absolute;
|
|
1794
|
+
display: flex;
|
|
1795
|
+
gap: 6px;
|
|
1796
|
+
align-items: center;
|
|
1797
|
+
padding: 4px 8px;
|
|
1798
|
+
font: 10px var(--font-mono);
|
|
1799
|
+
color: var(--text-secondary);
|
|
1800
|
+
background: var(--paper);
|
|
1801
|
+
border: 1px solid var(--line);
|
|
1802
|
+
border-radius: 3px;
|
|
1803
|
+
}
|
|
1804
|
+
.key {
|
|
1805
|
+
position: absolute;
|
|
1806
|
+
padding: 4px 8px;
|
|
1807
|
+
font: 10px var(--font-mono);
|
|
1808
|
+
color: var(--text-secondary);
|
|
1809
|
+
background: var(--paper);
|
|
1810
|
+
border: 1px solid var(--line);
|
|
1811
|
+
border-radius: 3px;
|
|
1812
|
+
box-shadow: var(--elev-1);
|
|
1813
|
+
}
|
|
1814
|
+
.basemap {
|
|
1815
|
+
position: absolute;
|
|
1816
|
+
inset: 0;
|
|
1817
|
+
pointer-events: none;
|
|
1818
|
+
}
|
|
1819
|
+
.attribution {
|
|
1820
|
+
position: absolute;
|
|
1821
|
+
margin: 0;
|
|
1822
|
+
padding: 1px 6px;
|
|
1823
|
+
font: 10px var(--font-mono);
|
|
1824
|
+
color: var(--text-secondary);
|
|
1825
|
+
background: color-mix(in srgb, var(--paper) 80%, transparent);
|
|
1826
|
+
border-radius: 2px;
|
|
1827
|
+
pointer-events: none;
|
|
1828
|
+
}
|
|
1829
|
+
.toolbar .check {
|
|
1830
|
+
display: flex;
|
|
1831
|
+
gap: 4px;
|
|
1832
|
+
align-items: center;
|
|
1833
|
+
}
|
|
1834
|
+
.key .toggle {
|
|
1835
|
+
display: flex;
|
|
1836
|
+
gap: 4px;
|
|
1837
|
+
align-items: center;
|
|
1838
|
+
margin-top: 4px;
|
|
1839
|
+
color: var(--ink);
|
|
1840
|
+
}
|
|
1841
|
+
.key .hint {
|
|
1842
|
+
margin: 2px 0 0;
|
|
1843
|
+
}
|
|
1844
|
+
.key summary {
|
|
1845
|
+
cursor: pointer;
|
|
1846
|
+
color: var(--ink);
|
|
1847
|
+
}
|
|
1848
|
+
.symbols {
|
|
1849
|
+
margin: 4px 0 0;
|
|
1850
|
+
padding: 0;
|
|
1851
|
+
list-style: none;
|
|
1852
|
+
}
|
|
1853
|
+
.symbols li {
|
|
1854
|
+
display: flex;
|
|
1855
|
+
gap: 4px;
|
|
1856
|
+
align-items: center;
|
|
1857
|
+
}
|
|
1858
|
+
.symbols path {
|
|
1859
|
+
fill: none;
|
|
1860
|
+
stroke: var(--ink);
|
|
1861
|
+
stroke-width: 1.1;
|
|
1862
|
+
}
|
|
1863
|
+
.symbols path.flow {
|
|
1864
|
+
stroke: var(--accent);
|
|
1865
|
+
stroke-width: 1.5;
|
|
1866
|
+
}
|
|
1867
|
+
.symbols path.level {
|
|
1868
|
+
stroke-width: 3;
|
|
1869
|
+
}
|
|
1870
|
+
.symbols path.wire {
|
|
1871
|
+
stroke: var(--text-secondary);
|
|
1872
|
+
}
|
|
1873
|
+
.legend i {
|
|
1874
|
+
display: inline-block;
|
|
1875
|
+
width: 90px;
|
|
1876
|
+
height: 7px;
|
|
1877
|
+
border-radius: 2px;
|
|
1878
|
+
}
|
|
1879
|
+
.status {
|
|
1880
|
+
position: absolute;
|
|
1881
|
+
inset: 0;
|
|
1882
|
+
display: grid;
|
|
1883
|
+
place-items: center;
|
|
1884
|
+
margin: 0;
|
|
1885
|
+
color: var(--text-secondary);
|
|
1886
|
+
pointer-events: none;
|
|
1887
|
+
}
|
|
1888
|
+
.sr-only {
|
|
1889
|
+
position: absolute;
|
|
1890
|
+
width: 1px;
|
|
1891
|
+
height: 1px;
|
|
1892
|
+
overflow: hidden;
|
|
1893
|
+
clip-path: inset(50%);
|
|
1894
|
+
white-space: nowrap;
|
|
1895
|
+
}
|
|
1896
|
+
</style>
|