@tellegen/svelte 0.4.7 → 0.5.1

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 (36) hide show
  1. package/README.md +25 -1
  2. package/dist/TellegenMap.svelte +47 -13
  3. package/dist/TellegenMap.svelte.d.ts +3 -1
  4. package/dist/antimeridian.d.ts +21 -0
  5. package/dist/antimeridian.js +40 -0
  6. package/dist/basemap.d.ts +10 -0
  7. package/dist/basemap.js +20 -0
  8. package/dist/bus-kinds.d.ts +9 -0
  9. package/dist/bus-kinds.js +33 -0
  10. package/dist/components/DiagramCanvas.svelte +5 -1
  11. package/dist/components/McModelQualification.svelte +67 -0
  12. package/dist/components/McModelQualification.svelte.d.ts +8 -0
  13. package/dist/components/McNetworkScope.svelte +90 -0
  14. package/dist/components/McNetworkScope.svelte.d.ts +9 -0
  15. package/dist/components/McPfSettings.svelte +158 -0
  16. package/dist/components/McPfSettings.svelte.d.ts +9 -0
  17. package/dist/components/McResults.svelte +16 -1
  18. package/dist/components/McResults.svelte.d.ts +1 -0
  19. package/dist/components/McWorkbench.svelte +2 -0
  20. package/dist/components/MulticonductorDetails.svelte +25 -6
  21. package/dist/components.d.ts +3 -0
  22. package/dist/components.js +3 -0
  23. package/dist/controller.svelte.d.ts +9 -5
  24. package/dist/controller.svelte.js +83 -18
  25. package/dist/diagram/schematic-equipment.d.ts +4 -3
  26. package/dist/diagram/schematic-equipment.js +13 -1
  27. package/dist/diagram/snapshot.d.ts +6 -3
  28. package/dist/diagram/snapshot.js +56 -35
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.js +3 -0
  31. package/dist/map-bounds.d.ts +8 -0
  32. package/dist/map-bounds.js +21 -8
  33. package/dist/multiconductor.js +38 -4
  34. package/dist/state.svelte.d.ts +33 -8
  35. package/dist/state.svelte.js +35 -5
  36. package/package.json +10 -10
package/README.md CHANGED
@@ -231,6 +231,9 @@ The map switches to the fallback in three cases:
231
231
  - its tiles answer 401, 403 or 429;
232
232
  - tile fetches keep failing with nothing arriving.
233
233
 
234
+ Only the basemap style's own sources count. A source the host adds to the map
235
+ never triggers the switch, and its errors are logged to the console.
236
+
234
237
  Whenever an OpenFreeMap style renders, as primary or as fallback,
235
238
  `openFreeMapEdits` tunes it towards CARTO's look. Road lines start at zoom 8.
236
239
  In the dark theme, water and waterways take Dark Matter's colours and land
@@ -260,13 +263,34 @@ besides its click callbacks:
260
263
  - `onMapReady(map, overlay)`: called with the MapLibre map and the deck
261
264
  overlay once they exist. It may return a cleanup, and it fires again after
262
265
  a context-loss or basemap rebuild. If it throws, the map and its window
263
- and document listeners are still released when the map is torn down.
266
+ and document listeners are still released when the map is torn down. A
267
+ switch to the fallback does not call it again (see below).
264
268
  - `onBasemapFallback(reason)`: the switch to the fallback happened.
265
269
  `reason.from` and `reason.to` name the two providers.
266
270
  - `onBasemapError(reason)`: no basemap can be shown. The network layers
267
271
  still draw. `reason.from` names the provider that failed last, and
268
272
  `reason.to` is `null`.
269
273
 
274
+ The switch to the fallback replaces the whole MapLibre style, so sources and
275
+ layers the host added to the map are gone after it. The network,
276
+ `extraLayers` and `projection` carry over. A host that adds its own sources
277
+ and layers adds them on every `style.load`, not once in `onMapReady`, which
278
+ runs before the first style loads, so one listener covers the primary and the
279
+ fallback:
280
+
281
+ ```ts
282
+ import type { Map as MapLibreMap } from "maplibre-gl";
283
+
284
+ function onMapReady(map: MapLibreMap) {
285
+ const addFeeders = () => {
286
+ map.addSource("feeders", { type: "geojson", data: "/feeders.geojson" });
287
+ map.addLayer({ id: "feeders", type: "line", source: "feeders" });
288
+ };
289
+ map.on("style.load", addFeeders);
290
+ return () => map.off("style.load", addFeeders);
291
+ }
292
+ ```
293
+
270
294
  `TellegenProvider` and `TellegenViewer` also take `cartoApiKey`, and pass it,
271
295
  together with `theme`, to the default shell's map. Both follow their props:
272
296
  changing either rebuilds that map in place. A SvelteKit 3 host can
@@ -56,6 +56,7 @@
56
56
  } from './transformer-icon.js';
57
57
  import { createGlobeHorizon, withGlobeHorizon } from './globe-horizon.js';
58
58
  import { foldMapBounds } from './map-bounds.js';
59
+ import { nearestLongitude, routeBounds, unwrapLongitudePath } from './antimeridian.js';
59
60
  import { getAppState, getController } from './context.svelte.js';
60
61
  import { displayFmt, fmt, rgbaCss } from './format.js';
61
62
  import { getPanelLayout } from './panels.svelte.js';
@@ -126,7 +127,9 @@
126
127
  attribution?: 'compact' | 'full';
127
128
  /** Called once the map and overlay exist, with an optional cleanup. It
128
129
  * fires again after a context-loss or basemap remount, after the previous
129
- * cleanup ran. */
130
+ * cleanup ran. It runs before the first style loads, and a switch to the
131
+ * fallback basemap does not call it again: the new style drops every
132
+ * source and layer the host added, so add those on each `style.load`. */
130
133
  onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
131
134
  /** The primary basemap failed and the fallback was requested. The
132
135
  * reason's `from` and `to` name the providers (`'carto'`,
@@ -473,7 +476,12 @@
473
476
  const branch = displayedGeometry(caseId)?.branches.find((b) => b.id === branchId);
474
477
  const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
475
478
  return branch && path && path.length >= 2
476
- ? { key: `${app.studyView?.id ?? caseId}-${branch.id}`, caseId, branchId: branch.id, path }
479
+ ? {
480
+ key: `${app.studyView?.id ?? caseId}-${branch.id}`,
481
+ caseId,
482
+ branchId: branch.id,
483
+ path: unwrapLongitudePath(path)
484
+ }
477
485
  : null;
478
486
  });
479
487
  let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
@@ -496,9 +504,16 @@
496
504
  });
497
505
  let multiDetailEl = $state.raw<SVGSVGElement | null>(null);
498
506
 
507
+ /** The screen point of a coordinate in the world copy nearest the camera.
508
+ * MapLibre projects a longitude in the copy it names, so a point across the
509
+ * antimeridian from the camera would land a whole world width away. */
510
+ function projectNear(m: MapLibreMap, [lon, lat]: [number, number]) {
511
+ return m.project([nearestLongitude(lon, m.getCenter().lng), lat]);
512
+ }
513
+
499
514
  function syncSelectedCue() {
500
515
  if (!map || !selectedCueEl || !selectedBusCue) return;
501
- const point = map.project([selectedBusCue.lon, selectedBusCue.lat]);
516
+ const point = projectNear(map, [selectedBusCue.lon, selectedBusCue.lat]);
502
517
  const { clientWidth, clientHeight } = map.getContainer();
503
518
  const inRange =
504
519
  point.x >= -40 &&
@@ -522,8 +537,12 @@
522
537
  }
523
538
  const activeMap = map;
524
539
  const { clientWidth, clientHeight } = activeMap.getContainer();
540
+ // The path is unwrapped, so one shift by whole turns moves all of it to
541
+ // the world copy nearest the camera without breaking it at the seam.
542
+ const [lon0] = selectedBranchCue.path[0];
543
+ const shift = nearestLongitude(lon0, activeMap.getCenter().lng) - lon0;
525
544
  const points = selectedBranchCue.path
526
- .map(([lon, lat]) => activeMap.project([lon, lat]))
545
+ .map(([lon, lat]) => activeMap.project([lon + shift, lat]))
527
546
  .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
528
547
  if (clientWidth === 0 || clientHeight === 0 || points.length < 2) {
529
548
  selectedBranchCueEl.style.display = 'none';
@@ -589,14 +608,14 @@
589
608
  return;
590
609
  }
591
610
  const { bus, edges } = detail;
592
- const busPt = m.project([bus.lon, bus.lat]);
611
+ const busPt = projectNear(m, [bus.lon, bus.lat]);
593
612
 
594
613
  const STRAND_SPACING = 5;
595
614
  let strands = '';
596
615
  for (const e of edges) {
597
616
  const selectedIsFrom = e.from === bus.id;
598
617
  const other = selectedIsFrom ? e.path[e.path.length - 1] : e.path[0];
599
- const b = m.project(other);
618
+ const b = projectNear(m, other);
600
619
  const dx = b.x - busPt.x;
601
620
  const dy = b.y - busPt.y;
602
621
  const len = Math.hypot(dx, dy) || 1;
@@ -677,7 +696,7 @@
677
696
  }
678
697
 
679
698
  function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
680
- return foldMapBounds(path);
699
+ return routeBounds(path);
681
700
  }
682
701
 
683
702
  function prefersReducedMotion(): boolean {
@@ -1077,6 +1096,12 @@
1077
1096
  // Sync deck.gl layers with app state. New layer instances diff cheaply;
1078
1097
  // updateTriggers tell deck.gl when accessor outputs changed. Host layers
1079
1098
  // (`extraLayers`) draw after tellegen's own at both exits.
1099
+ //
1100
+ // Stored routes keep longitudes in [-180, 180], so a route across the
1101
+ // antimeridian is unwrapped before drawing and each segment takes the
1102
+ // short way. On the globe deck.gl ignores `wrapLongitude` and needs the
1103
+ // unwrapped path itself; on the flat map `wrapLongitude` cuts the path at
1104
+ // ±180 so both halves draw in every world copy.
1080
1105
  $effect(() => {
1081
1106
  if (!overlay || !layerCtors) return;
1082
1107
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
@@ -1108,7 +1133,8 @@
1108
1133
  new PathLayer<NetworkBranch>({
1109
1134
  id: 'study-branches',
1110
1135
  data: study.network.branches,
1111
- getPath: (b) => b.path,
1136
+ getPath: (b) => unwrapLongitudePath(b.path),
1137
+ wrapLongitude: true,
1112
1138
  getColor: (b) => branchColor(flows.get(b.id) ?? 0, b.status === 1),
1113
1139
  getWidth: (b) => branchWidth(flows.get(b.id) ?? 0),
1114
1140
  widthUnits: 'pixels',
@@ -1163,7 +1189,8 @@
1163
1189
  new PathLayer<NetworkBranch>({
1164
1190
  id: `branches-${c.id}`,
1165
1191
  data: c.network.branches,
1166
- getPath: (b) => b.path,
1192
+ getPath: (b) => unwrapLongitudePath(b.path),
1193
+ wrapLongitude: true,
1167
1194
  // The selected branch draws in the selection blue of the bus halo cue,
1168
1195
  // wider, so the price-rating source line reads at a glance.
1169
1196
  getColor: (b) =>
@@ -1230,15 +1257,21 @@
1230
1257
  }
1231
1258
  // Local cases are grey until the browser solve returns. A .pwd display
1232
1259
  // file contributes substation points only, in a cooler slate so they
1233
- // read as diagram derived positions.
1260
+ // read as diagram derived positions. A generated grid's line taps draw
1261
+ // no marker, as their lines show them, unless one is the selected bus.
1234
1262
  for (const c of app.localCases) {
1235
1263
  if (c.view) {
1236
1264
  const d = display.get(c.id);
1265
+ const selectedTap =
1266
+ c.id === app.activeLocalId && app.selectedBus !== null && c.tapBuses.has(app.selectedBus)
1267
+ ? c.view.buses.find((b) => b.id === app.selectedBus)
1268
+ : undefined;
1237
1269
  layers.push(
1238
1270
  new PathLayer<NetworkBranch>({
1239
1271
  id: `local-branches-${c.id}`,
1240
1272
  data: c.view.branches,
1241
- getPath: (b) => b.path,
1273
+ getPath: (b) => unwrapLongitudePath(b.path),
1274
+ wrapLongitude: true,
1242
1275
  getColor: (b) =>
1243
1276
  c.id === app.activeLocalId && b.id === app.selectedBranch
1244
1277
  ? [47, 111, 187, 255]
@@ -1272,7 +1305,7 @@
1272
1305
  }),
1273
1306
  new ScatterplotLayer<NetworkBus>({
1274
1307
  id: `local-buses-${c.id}`,
1275
- data: c.view.buses,
1308
+ data: selectedTap ? [...c.markerBuses, selectedTap] : c.markerBuses,
1276
1309
  getPosition: (b) => [b.lon, b.lat],
1277
1310
  getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
1278
1311
  radiusUnits: marker.radiusUnits,
@@ -1343,7 +1376,8 @@
1343
1376
  new PathLayer<PlacedMultiEdge>({
1344
1377
  id: `multi-edges-${c.id}`,
1345
1378
  data: c.view.edges,
1346
- getPath: (e) => e.path,
1379
+ getPath: (e) => unwrapLongitudePath(e.path),
1380
+ wrapLongitude: true,
1347
1381
  // The selected edge draws in the selection blue of the branch cue,
1348
1382
  // wider, so its panel detail has an obvious source on the map.
1349
1383
  getColor: (e) =>
@@ -44,7 +44,9 @@ type $$ComponentProps = {
44
44
  attribution?: 'compact' | 'full';
45
45
  /** Called once the map and overlay exist, with an optional cleanup. It
46
46
  * fires again after a context-loss or basemap remount, after the previous
47
- * cleanup ran. */
47
+ * cleanup ran. It runs before the first style loads, and a switch to the
48
+ * fallback basemap does not call it again: the new style drops every
49
+ * source and layer the host added, so add those on each `style.load`. */
48
50
  onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
49
51
  /** The primary basemap failed and the fallback was requested. The
50
52
  * reason's `from` and `to` name the providers (`'carto'`,
@@ -0,0 +1,21 @@
1
+ /** Coordinate helpers for geographic paths drawn across the antimeridian.
2
+ *
3
+ * Stored longitudes stay in [-180, 180]. A drawn path is unwrapped so each
4
+ * vertex lies within 180° of the one before, and every segment takes the
5
+ * short way across ±180 instead of crossing the whole map.
6
+ */
7
+ import { type MapBounds } from './map-bounds.js';
8
+ export type LngLat = readonly [number, number];
9
+ /** `lon` moved by whole turns of 360° to lie within 180° of `reference`.
10
+ * Returned unchanged when either value is not finite. */
11
+ export declare function nearestLongitude(lon: number, reference: number): number;
12
+ /** Return a display path whose consecutive longitudes differ by at most 180°.
13
+ * The returned longitudes may lie outside [-180, 180]; use only for drawing,
14
+ * never for serialization or exported GeoJSON. A vertex whose longitude is
15
+ * not finite is copied as it is and does not anchor the next one. */
16
+ export declare function unwrapLongitudePath(path: readonly LngLat[]): [number, number][];
17
+ /** Bounds of a stored route around its short arc, so a seam-crossing route
18
+ * frames the few degrees it spans rather than the whole world. West may lie
19
+ * below -180 or east above 180. Vertices `foldMapBounds` ignores are ignored
20
+ * here too. */
21
+ export declare function routeBounds(path: readonly LngLat[]): MapBounds | null;
@@ -0,0 +1,40 @@
1
+ /** Coordinate helpers for geographic paths drawn across the antimeridian.
2
+ *
3
+ * Stored longitudes stay in [-180, 180]. A drawn path is unwrapped so each
4
+ * vertex lies within 180° of the one before, and every segment takes the
5
+ * short way across ±180 instead of crossing the whole map.
6
+ */
7
+ import { isMapPoint, spanBounds } from './map-bounds.js';
8
+ /** `lon` moved by whole turns of 360° to lie within 180° of `reference`.
9
+ * Returned unchanged when either value is not finite. */
10
+ export function nearestLongitude(lon, reference) {
11
+ const delta = lon - reference;
12
+ if (!Number.isFinite(delta) || Math.abs(delta) <= 180)
13
+ return lon;
14
+ return lon - 360 * Math.round(delta / 360);
15
+ }
16
+ /** Return a display path whose consecutive longitudes differ by at most 180°.
17
+ * The returned longitudes may lie outside [-180, 180]; use only for drawing,
18
+ * never for serialization or exported GeoJSON. A vertex whose longitude is
19
+ * not finite is copied as it is and does not anchor the next one. */
20
+ export function unwrapLongitudePath(path) {
21
+ const out = [];
22
+ let previous = null;
23
+ for (const [rawLongitude, latitude] of path) {
24
+ if (!Number.isFinite(rawLongitude)) {
25
+ out.push([rawLongitude, latitude]);
26
+ continue;
27
+ }
28
+ const longitude = previous === null ? rawLongitude : nearestLongitude(rawLongitude, previous);
29
+ out.push([longitude, latitude]);
30
+ previous = longitude;
31
+ }
32
+ return out;
33
+ }
34
+ /** Bounds of a stored route around its short arc, so a seam-crossing route
35
+ * frames the few degrees it spans rather than the whole world. West may lie
36
+ * below -180 or east above 180. Vertices `foldMapBounds` ignores are ignored
37
+ * here too. */
38
+ export function routeBounds(path) {
39
+ return spanBounds(unwrapLongitudePath(path.filter(isMapPoint)));
40
+ }
package/dist/basemap.d.ts CHANGED
@@ -157,6 +157,10 @@ export interface BasemapMapLike {
157
157
  }): unknown;
158
158
  /** MapLibre types this `boolean | void`; only `true` counts as loaded. */
159
159
  isStyleLoaded(): boolean | void;
160
+ /** Read at every `style.load` to learn which sources the basemap style
161
+ * brings, before a host adds its own. Without it, every source counts as
162
+ * the basemap's. */
163
+ getStyle?(): StyleSpecification | undefined;
160
164
  }
161
165
  export interface BasemapFallbackHandlers {
162
166
  /** The primary failed and the fallback style was requested. `from` is the
@@ -185,6 +189,12 @@ export interface BasemapFallbackOptions {
185
189
  * 5. everything else is logged the way MapLibre logs an unhandled error,
186
190
  * because registering this listener silences that default.
187
191
  *
192
+ * Rules 2 and 3 only count errors from the basemap style's own sources: the
193
+ * sources `map.getStyle()` lists when that style's `style.load` reaches this
194
+ * listener, so attach it before any listener that adds sources. A source the
195
+ * host adds later, through `onMapReady` for instance, falls under rule 5 and
196
+ * never switches the basemap.
197
+ *
188
198
  * After the switch the same rules report `onFailure` instead of switching
189
199
  * again, as they do when `spec.fallback` is `null`. Returns a detach
190
200
  * function. */
package/dist/basemap.js CHANGED
@@ -169,6 +169,12 @@ function errorStatus(error) {
169
169
  * 5. everything else is logged the way MapLibre logs an unhandled error,
170
170
  * because registering this listener silences that default.
171
171
  *
172
+ * Rules 2 and 3 only count errors from the basemap style's own sources: the
173
+ * sources `map.getStyle()` lists when that style's `style.load` reaches this
174
+ * listener, so attach it before any listener that adds sources. A source the
175
+ * host adds later, through `onMapReady` for instance, falls under rule 5 and
176
+ * never switches the basemap.
177
+ *
172
178
  * After the switch the same rules report `onFailure` instead of switching
173
179
  * again, as they do when `spec.fallback` is `null`. Returns a detach
174
180
  * function. */
@@ -176,7 +182,14 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
176
182
  const now = options.now ?? (() => Date.now());
177
183
  const primaryProvider = spec.primaryProvider ?? 'custom';
178
184
  const fallbackProvider = spec.fallback === null ? null : (spec.fallbackProvider ?? 'custom');
185
+ /** The source ids of the style on screen, or `null` while unknown, when
186
+ * every source counts as the basemap's. */
187
+ const styleSources = () => {
188
+ const style = map.getStyle?.();
189
+ return style ? new Set(Object.keys(style.sources ?? {})) : null;
190
+ };
179
191
  let styleLoaded = map.isStyleLoaded() === true;
192
+ let basemapSources = styleLoaded ? styleSources() : null;
180
193
  let fellBack = false;
181
194
  let failed = false;
182
195
  let tileFailures = 0;
@@ -196,6 +209,7 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
196
209
  }
197
210
  fellBack = true;
198
211
  styleLoaded = false;
212
+ basemapSources = null;
199
213
  tileFailures = 0;
200
214
  tileArrived = false;
201
215
  handlers.onFallback?.({ ...reason, from: primaryProvider, to: fallbackProvider });
@@ -206,6 +220,7 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
206
220
  };
207
221
  const onStyleLoad = () => {
208
222
  styleLoaded = true;
223
+ basemapSources = styleSources();
209
224
  };
210
225
  const onSourceData = (event) => {
211
226
  if (event?.tile || event?.isSourceLoaded === true)
@@ -227,6 +242,11 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
227
242
  passThrough(event);
228
243
  return;
229
244
  }
245
+ // A host's own source failing says nothing about the basemap.
246
+ if (basemapSources !== null && !basemapSources.has(sourceId)) {
247
+ passThrough(event);
248
+ return;
249
+ }
230
250
  if ((status !== undefined && FORBIDDEN_STATUSES.has(status)) ||
231
251
  FORBIDDEN_MESSAGE.test(message)) {
232
252
  fallBack({ kind: 'source-forbidden', message, status: status ?? 0, sourceId });
@@ -0,0 +1,9 @@
1
+ import type { NetworkBus } from './api.js';
2
+ /** Ids of the buses a generated grid marks as line taps: points where a line
3
+ * is tapped, with no load or machine. tellegen-synth records the kind in
4
+ * each bus row's `extras.tellegen_synth.kind`; the map view does not carry
5
+ * it. Empty for any other module, and for text that does not parse. */
6
+ export declare function tapBusIds(moduleJson: string | null | undefined): ReadonlySet<number>;
7
+ /** `buses` less the taps, the ones the map draws as markers. The same array
8
+ * when there is no tap to drop, so the map layer keeps its data. */
9
+ export declare function withoutTaps(buses: NetworkBus[], taps: ReadonlySet<number>): NetworkBus[];
@@ -0,0 +1,33 @@
1
+ /** The `extras` key under which tellegen-synth records each element's
2
+ * annotation in the modules it writes. */
3
+ const SYNTH_EXTRAS = 'tellegen_synth';
4
+ /** Ids of the buses a generated grid marks as line taps: points where a line
5
+ * is tapped, with no load or machine. tellegen-synth records the kind in
6
+ * each bus row's `extras.tellegen_synth.kind`; the map view does not carry
7
+ * it. Empty for any other module, and for text that does not parse. */
8
+ export function tapBusIds(moduleJson) {
9
+ const ids = new Set();
10
+ // Only a module tellegen-synth wrote is parsed a second time; a branch's
11
+ // `"tap"` ratio is in every transmission case.
12
+ if (!moduleJson?.includes(`"${SYNTH_EXTRAS}"`) || !moduleJson.includes('"tap"'))
13
+ return ids;
14
+ let buses;
15
+ try {
16
+ buses = JSON.parse(moduleJson)?.value?.data?.buses;
17
+ }
18
+ catch {
19
+ return ids;
20
+ }
21
+ if (!Array.isArray(buses))
22
+ return ids;
23
+ for (const bus of buses) {
24
+ if (typeof bus?.id === 'number' && bus.extras?.[SYNTH_EXTRAS]?.kind === 'tap')
25
+ ids.add(bus.id);
26
+ }
27
+ return ids;
28
+ }
29
+ /** `buses` less the taps, the ones the map draws as markers. The same array
30
+ * when there is no tap to drop, so the map layer keeps its data. */
31
+ export function withoutTaps(buses, taps) {
32
+ return taps.size === 0 ? buses : buses.filter((b) => !taps.has(b.id));
33
+ }
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { mcNetworkScopeLabel, mcLoadModelLabel } from '@tellegen/engine';
2
3
  import { untrack } from 'svelte';
3
4
  import { on } from 'svelte/events';
4
5
  import type { Network, NetworkBus } from '../api.js';
@@ -395,7 +396,10 @@
395
396
  >
396
397
  </div>
397
398
  </div>
398
- <p class="coordinate-label">Diagram coordinates</p>
399
+ <p class="coordinate-label">
400
+ Diagram coordinates{#if multiconductor?.result}
401
+ · {mcLoadModelLabel(multiconductor.result.load_model)} · {mcNetworkScopeLabel(multiconductor.result.network_scope)}{/if}
402
+ </p>
399
403
  </div>
400
404
 
401
405
  <style>
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import { mcLoadModelLabel, type McLoadModelAssessment } from '@tellegen/engine';
3
+ let { model, compact = false }: { model?: McLoadModelAssessment | null; compact?: boolean } =
4
+ $props();
5
+ </script>
6
+
7
+ <aside class:warning={model?.status !== 'original'} aria-label="Load-model qualification">
8
+ <strong>{mcLoadModelLabel(model)}</strong>
9
+ {#if model?.status === 'modified'}
10
+ <p>
11
+ The calculation converged using modified load equations on {model.modified_branch_count}
12
+ load {model.modified_branch_count === 1 ? 'branch' : 'branches'}. These values are not a
13
+ verified solution of the original load model. The qualification is retained when saving or
14
+ exporting.
15
+ </p>
16
+ {:else if model?.status !== 'original'}
17
+ <p>
18
+ These results have no verified solution of the original load equations. Rerun to assess the
19
+ load model.
20
+ </p>
21
+ {/if}
22
+ {#if model && !compact}
23
+ <details>
24
+ <summary>Model assessment and settings used</summary>
25
+ <p>
26
+ Original-model KCL residual: {model.original_kcl_residual === null
27
+ ? 'undefined at this voltage'
28
+ : `${model.original_kcl_residual.toExponential(3)} A`}. Scaled residual: {model.original_scaled_kcl_residual ===
29
+ null
30
+ ? 'unavailable'
31
+ : model.original_scaled_kcl_residual.toPrecision(4)} (at most 1 passes).
32
+ </p>
33
+ <p>
34
+ Load-model envelope {model.options.voltage_envelope ? 'enabled' : 'disabled'}; Vlow {model
35
+ .options.v_low_pu}, Vmin {model.options.v_min_pu}, Vmax {model.options.v_max_pu} pu. These thresholds
36
+ change load equations; they are not operating voltage limits.
37
+ </p>
38
+ <p>
39
+ Maximum iterations {model.options.max_iterations}; damping {model.options.damping};
40
+ voltage-change tolerance {model.options.tolerance} V; zero-voltage tolerance {model.options
41
+ .zero_voltage_tolerance} V; KCL tolerances {model.options.absolute_kcl_tolerance} A absolute
42
+ and {model.options.relative_kcl_tolerance} relative.
43
+ </p>
44
+ </details>
45
+ {/if}
46
+ </aside>
47
+
48
+ <style>
49
+ aside {
50
+ margin: 0.75rem 0;
51
+ padding: 0.65rem;
52
+ border: 1px solid var(--line);
53
+ border-radius: 4px;
54
+ font-size: 12px;
55
+ }
56
+ .warning {
57
+ border-left: 4px solid #b7791f;
58
+ }
59
+ p {
60
+ margin: 0.4rem 0;
61
+ line-height: 1.5;
62
+ }
63
+ summary {
64
+ cursor: pointer;
65
+ margin-top: 0.4rem;
66
+ }
67
+ </style>
@@ -0,0 +1,8 @@
1
+ import { type McLoadModelAssessment } from '@tellegen/engine';
2
+ type $$ComponentProps = {
3
+ model?: McLoadModelAssessment | null;
4
+ compact?: boolean;
5
+ };
6
+ declare const McModelQualification: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type McModelQualification = ReturnType<typeof McModelQualification>;
8
+ export default McModelQualification;
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import {
3
+ mcNetworkScopeLabel,
4
+ type McNetworkScope as NetworkScope,
5
+ type McExcludedIsland
6
+ } from '@tellegen/engine';
7
+ let {
8
+ scope,
9
+ islands = [],
10
+ preview = false
11
+ }: {
12
+ scope?: NetworkScope | null;
13
+ islands?: McExcludedIsland[];
14
+ preview?: boolean;
15
+ } = $props();
16
+ </script>
17
+
18
+ {#if scope}
19
+ <aside aria-label="Network calculation scope">
20
+ <strong>{preview ? 'Unpowered islands detected' : mcNetworkScopeLabel(scope)}</strong>
21
+ {#if preview}<p>
22
+ {scope.excluded_island_count}
23
+ {scope.excluded_island_count === 1 ? 'island has' : 'islands have'} no voltage source.
24
+ </p>{/if}
25
+ <p>
26
+ {scope.excluded_bus_count}
27
+ {scope.excluded_bus_count === 1 ? 'bus' : 'buses'} and {scope.excluded_equipment_count}
28
+ equipment
29
+ {scope.excluded_equipment_count === 1 ? 'record' : 'records'}
30
+ {preview
31
+ ? 'will be excluded when you solve the energized network'
32
+ : 'are excluded from the calculation'}
33
+ because their {scope.excluded_island_count === 1 ? 'island has' : 'islands have'} no voltage source.
34
+ The full network remains available for inspection. Excluded equipment has no calculated result;
35
+ its voltage and power are not reported as zero.
36
+ </p>
37
+ {#if scope.excluded_load_count}<p>
38
+ {scope.excluded_load_count}
39
+ {scope.excluded_load_count === 1 ? 'load has' : 'loads have'} no voltage source and {preview
40
+ ? 'will be omitted'
41
+ : scope.excluded_load_count === 1
42
+ ? 'is absent'
43
+ : 'are absent'} from solved totals.
44
+ </p>{/if}
45
+ {#if islands.length}
46
+ <details>
47
+ <summary
48
+ >{preview
49
+ ? 'Islands and equipment to exclude'
50
+ : 'Excluded islands and equipment'}</summary
51
+ >
52
+ {#each islands as island, i}
53
+ <h4>Island {i + 1}: no voltage source</h4>
54
+ <p>Buses: {island.buses.join(', ')}</p>
55
+ <ul>
56
+ {#each island.equipment as item}<li>
57
+ {item.kind}: {item.name}
58
+ </li>{/each}
59
+ </ul>
60
+ {/each}
61
+ </details>
62
+ {/if}
63
+ </aside>
64
+ {/if}
65
+
66
+ <style>
67
+ aside {
68
+ margin: 0.75rem 0;
69
+ padding: 0.65rem;
70
+ border: 1px solid var(--line);
71
+ border-left: 4px solid #b7791f;
72
+ border-radius: 4px;
73
+ font-size: 12px;
74
+ overflow-wrap: anywhere;
75
+ }
76
+ p {
77
+ margin: 0.4rem 0;
78
+ line-height: 1.5;
79
+ }
80
+ summary {
81
+ cursor: pointer;
82
+ }
83
+ details {
84
+ max-height: 20rem;
85
+ overflow: auto;
86
+ }
87
+ h4 {
88
+ margin: 0.75rem 0 0.25rem;
89
+ }
90
+ </style>
@@ -0,0 +1,9 @@
1
+ import { type McNetworkScope as NetworkScope, type McExcludedIsland } from '@tellegen/engine';
2
+ type $$ComponentProps = {
3
+ scope?: NetworkScope | null;
4
+ islands?: McExcludedIsland[];
5
+ preview?: boolean;
6
+ };
7
+ declare const McNetworkScope: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type McNetworkScope = ReturnType<typeof McNetworkScope>;
9
+ export default McNetworkScope;