@tellegen/svelte 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +20 -0
  2. package/dist/TellegenMap.svelte +63 -22
  3. package/dist/TellegenProvider.svelte +7 -4
  4. package/dist/TellegenShell.svelte +10 -1
  5. package/dist/TellegenViewer.svelte +2 -0
  6. package/dist/basemap.d.ts +14 -0
  7. package/dist/basemap.js +27 -0
  8. package/dist/colors.d.ts +6 -1
  9. package/dist/colors.js +11 -4
  10. package/dist/components/DemandSlider.svelte +0 -2
  11. package/dist/components/FormulationSelector.svelte +1 -1
  12. package/dist/components/LocalCaseDetails.svelte +18 -3
  13. package/dist/components/McResults.svelte +18 -3
  14. package/dist/components/McWorkbench.svelte +688 -0
  15. package/dist/components/McWorkbench.svelte.d.ts +38 -0
  16. package/dist/components/MultiWire.svelte +136 -0
  17. package/dist/components/MultiWire.svelte.d.ts +7 -0
  18. package/dist/components/MulticonductorDetails.svelte +7 -0
  19. package/dist/components/OmniSearch.svelte +163 -0
  20. package/dist/components/OmniSearch.svelte.d.ts +14 -0
  21. package/dist/components/ResultsTable.svelte +259 -0
  22. package/dist/components/ResultsTable.svelte.d.ts +13 -0
  23. package/dist/components/SchematicCanvas.svelte +1896 -0
  24. package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
  25. package/dist/components/SolveCard.svelte +6 -0
  26. package/dist/components/schematic/PickCard.svelte +126 -0
  27. package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
  28. package/dist/components/schematic/export.d.ts +21 -0
  29. package/dist/components/schematic/export.js +110 -0
  30. package/dist/components/schematic/layers.d.ts +161 -0
  31. package/dist/components/schematic/layers.js +756 -0
  32. package/dist/components/schematic/paint.d.ts +28 -0
  33. package/dist/components/schematic/paint.js +69 -0
  34. package/dist/components/schematic/palette.d.ts +34 -0
  35. package/dist/components/schematic/palette.js +54 -0
  36. package/dist/components/schematic/text.d.ts +83 -0
  37. package/dist/components/schematic/text.js +190 -0
  38. package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
  39. package/dist/components/schematic/view-prefs.svelte.js +77 -0
  40. package/dist/components.d.ts +1 -0
  41. package/dist/components.js +1 -0
  42. package/dist/context.svelte.d.ts +2 -0
  43. package/dist/context.svelte.js +2 -0
  44. package/dist/controller.svelte.d.ts +33 -5
  45. package/dist/controller.svelte.js +173 -20
  46. package/dist/diagram/basemap.d.ts +28 -0
  47. package/dist/diagram/basemap.js +39 -0
  48. package/dist/diagram/bus-stacks.d.ts +19 -0
  49. package/dist/diagram/bus-stacks.js +99 -0
  50. package/dist/diagram/colour-modes.d.ts +18 -0
  51. package/dist/diagram/colour-modes.js +33 -0
  52. package/dist/diagram/connections.d.ts +30 -0
  53. package/dist/diagram/connections.js +129 -0
  54. package/dist/diagram/display-tree.d.ts +21 -0
  55. package/dist/diagram/display-tree.js +65 -0
  56. package/dist/diagram/energisation.d.ts +10 -0
  57. package/dist/diagram/energisation.js +31 -0
  58. package/dist/diagram/feeders.d.ts +30 -0
  59. package/dist/diagram/feeders.js +57 -0
  60. package/dist/diagram/galvanic-zones.d.ts +60 -0
  61. package/dist/diagram/galvanic-zones.js +210 -0
  62. package/dist/diagram/graph-inputs.d.ts +18 -0
  63. package/dist/diagram/graph-inputs.js +103 -0
  64. package/dist/diagram/index.d.ts +28 -0
  65. package/dist/diagram/index.js +28 -0
  66. package/dist/diagram/landmarks.d.ts +50 -0
  67. package/dist/diagram/landmarks.js +153 -0
  68. package/dist/diagram/layout-morph.d.ts +15 -0
  69. package/dist/diagram/layout-morph.js +32 -0
  70. package/dist/diagram/multi-wire.d.ts +44 -0
  71. package/dist/diagram/multi-wire.js +65 -0
  72. package/dist/diagram/phase-bars.d.ts +35 -0
  73. package/dist/diagram/phase-bars.js +44 -0
  74. package/dist/diagram/results-table.d.ts +47 -0
  75. package/dist/diagram/results-table.js +173 -0
  76. package/dist/diagram/schematic-equipment.d.ts +133 -0
  77. package/dist/diagram/schematic-equipment.js +645 -0
  78. package/dist/diagram/schematic-layout.d.ts +51 -0
  79. package/dist/diagram/schematic-layout.js +240 -0
  80. package/dist/diagram/search.d.ts +36 -0
  81. package/dist/diagram/search.js +48 -0
  82. package/dist/diagram/shared-routes.d.ts +21 -0
  83. package/dist/diagram/shared-routes.js +40 -0
  84. package/dist/diagram/short-names.d.ts +11 -0
  85. package/dist/diagram/short-names.js +54 -0
  86. package/dist/diagram/snapshot.d.ts +115 -0
  87. package/dist/diagram/snapshot.js +242 -0
  88. package/dist/diagram/summary.d.ts +65 -0
  89. package/dist/diagram/summary.js +144 -0
  90. package/dist/diagram/svg-export.d.ts +80 -0
  91. package/dist/diagram/svg-export.js +108 -0
  92. package/dist/diagram/tree-walk.d.ts +13 -0
  93. package/dist/diagram/tree-walk.js +83 -0
  94. package/dist/diagram/value-labels.d.ts +24 -0
  95. package/dist/diagram/value-labels.js +69 -0
  96. package/dist/diagram/view-state.d.ts +12 -0
  97. package/dist/diagram/view-state.js +22 -0
  98. package/dist/diagram/voltage-levels.d.ts +14 -0
  99. package/dist/diagram/voltage-levels.js +20 -0
  100. package/dist/index.d.ts +1 -0
  101. package/dist/index.js +1 -0
  102. package/dist/state.svelte.d.ts +37 -1
  103. package/dist/state.svelte.js +35 -0
  104. package/dist/transformer-icon.d.ts +22 -13
  105. package/dist/transformer-icon.js +23 -13
  106. 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>