@tellegen/svelte 0.4.3 → 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 (105) 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/FormulationSelector.svelte +1 -1
  11. package/dist/components/LocalCaseDetails.svelte +18 -3
  12. package/dist/components/McResults.svelte +18 -3
  13. package/dist/components/McWorkbench.svelte +688 -0
  14. package/dist/components/McWorkbench.svelte.d.ts +38 -0
  15. package/dist/components/MultiWire.svelte +136 -0
  16. package/dist/components/MultiWire.svelte.d.ts +7 -0
  17. package/dist/components/MulticonductorDetails.svelte +7 -0
  18. package/dist/components/OmniSearch.svelte +163 -0
  19. package/dist/components/OmniSearch.svelte.d.ts +14 -0
  20. package/dist/components/ResultsTable.svelte +259 -0
  21. package/dist/components/ResultsTable.svelte.d.ts +13 -0
  22. package/dist/components/SchematicCanvas.svelte +1896 -0
  23. package/dist/components/SchematicCanvas.svelte.d.ts +13 -0
  24. package/dist/components/SolveCard.svelte +6 -0
  25. package/dist/components/schematic/PickCard.svelte +126 -0
  26. package/dist/components/schematic/PickCard.svelte.d.ts +56 -0
  27. package/dist/components/schematic/export.d.ts +21 -0
  28. package/dist/components/schematic/export.js +110 -0
  29. package/dist/components/schematic/layers.d.ts +161 -0
  30. package/dist/components/schematic/layers.js +756 -0
  31. package/dist/components/schematic/paint.d.ts +28 -0
  32. package/dist/components/schematic/paint.js +69 -0
  33. package/dist/components/schematic/palette.d.ts +34 -0
  34. package/dist/components/schematic/palette.js +54 -0
  35. package/dist/components/schematic/text.d.ts +83 -0
  36. package/dist/components/schematic/text.js +190 -0
  37. package/dist/components/schematic/view-prefs.svelte.d.ts +30 -0
  38. package/dist/components/schematic/view-prefs.svelte.js +77 -0
  39. package/dist/components.d.ts +1 -0
  40. package/dist/components.js +1 -0
  41. package/dist/context.svelte.d.ts +2 -0
  42. package/dist/context.svelte.js +2 -0
  43. package/dist/controller.svelte.d.ts +33 -5
  44. package/dist/controller.svelte.js +173 -20
  45. package/dist/diagram/basemap.d.ts +28 -0
  46. package/dist/diagram/basemap.js +39 -0
  47. package/dist/diagram/bus-stacks.d.ts +19 -0
  48. package/dist/diagram/bus-stacks.js +99 -0
  49. package/dist/diagram/colour-modes.d.ts +18 -0
  50. package/dist/diagram/colour-modes.js +33 -0
  51. package/dist/diagram/connections.d.ts +30 -0
  52. package/dist/diagram/connections.js +129 -0
  53. package/dist/diagram/display-tree.d.ts +21 -0
  54. package/dist/diagram/display-tree.js +65 -0
  55. package/dist/diagram/energisation.d.ts +10 -0
  56. package/dist/diagram/energisation.js +31 -0
  57. package/dist/diagram/feeders.d.ts +30 -0
  58. package/dist/diagram/feeders.js +57 -0
  59. package/dist/diagram/galvanic-zones.d.ts +60 -0
  60. package/dist/diagram/galvanic-zones.js +210 -0
  61. package/dist/diagram/graph-inputs.d.ts +18 -0
  62. package/dist/diagram/graph-inputs.js +103 -0
  63. package/dist/diagram/index.d.ts +28 -0
  64. package/dist/diagram/index.js +28 -0
  65. package/dist/diagram/landmarks.d.ts +50 -0
  66. package/dist/diagram/landmarks.js +153 -0
  67. package/dist/diagram/layout-morph.d.ts +15 -0
  68. package/dist/diagram/layout-morph.js +32 -0
  69. package/dist/diagram/multi-wire.d.ts +44 -0
  70. package/dist/diagram/multi-wire.js +65 -0
  71. package/dist/diagram/phase-bars.d.ts +35 -0
  72. package/dist/diagram/phase-bars.js +44 -0
  73. package/dist/diagram/results-table.d.ts +47 -0
  74. package/dist/diagram/results-table.js +173 -0
  75. package/dist/diagram/schematic-equipment.d.ts +133 -0
  76. package/dist/diagram/schematic-equipment.js +645 -0
  77. package/dist/diagram/schematic-layout.d.ts +51 -0
  78. package/dist/diagram/schematic-layout.js +240 -0
  79. package/dist/diagram/search.d.ts +36 -0
  80. package/dist/diagram/search.js +48 -0
  81. package/dist/diagram/shared-routes.d.ts +21 -0
  82. package/dist/diagram/shared-routes.js +40 -0
  83. package/dist/diagram/short-names.d.ts +11 -0
  84. package/dist/diagram/short-names.js +54 -0
  85. package/dist/diagram/snapshot.d.ts +115 -0
  86. package/dist/diagram/snapshot.js +242 -0
  87. package/dist/diagram/summary.d.ts +65 -0
  88. package/dist/diagram/summary.js +144 -0
  89. package/dist/diagram/svg-export.d.ts +80 -0
  90. package/dist/diagram/svg-export.js +108 -0
  91. package/dist/diagram/tree-walk.d.ts +13 -0
  92. package/dist/diagram/tree-walk.js +83 -0
  93. package/dist/diagram/value-labels.d.ts +24 -0
  94. package/dist/diagram/value-labels.js +69 -0
  95. package/dist/diagram/view-state.d.ts +12 -0
  96. package/dist/diagram/view-state.js +22 -0
  97. package/dist/diagram/voltage-levels.d.ts +14 -0
  98. package/dist/diagram/voltage-levels.js +20 -0
  99. package/dist/index.d.ts +1 -0
  100. package/dist/index.js +1 -0
  101. package/dist/state.svelte.d.ts +37 -1
  102. package/dist/state.svelte.js +35 -0
  103. package/dist/transformer-icon.d.ts +22 -13
  104. package/dist/transformer-icon.js +23 -13
  105. package/package.json +2 -2
package/README.md CHANGED
@@ -39,6 +39,7 @@ remain in the browser on this path.
39
39
  `TellegenViewer` accepts:
40
40
 
41
41
  - `apiBase`, default `/api`
42
+ - `acOpfWasmUrl`, default `null` (optional experimental AC OPF asset)
42
43
  - `loadDefaultCases`, default `true`
43
44
  - `docsHref`
44
45
  - `orgHref`
@@ -68,6 +69,20 @@ controller, which frees every case's browser Study and multiconductor session
68
69
  in the engine worker, so a provider that should keep its cases across pages
69
70
  belongs in a layout that stays mounted.
70
71
 
72
+ ## Experimental AC OPF
73
+
74
+ A development host can pass `acOpfWasmUrl="/experimental-acopf/tellegen_acopf_wasi.wasm"`
75
+ to `TellegenViewer` or `TellegenProvider`. The asset must be built and served
76
+ separately; the selector enables AC OPF only after a worker probe succeeds.
77
+ See the [local setup and release gates](../../docs/src/acopf-pounce-decision.md#experimental-browser-abi).
78
+
79
+ Balanced networks and stored canonical AC OPF instances are supported at the
80
+ base operating point. Imported instances retain their objective and constraints.
81
+ Demand/rating editing, LMPs, sensitivities, and saving/exporting the AC OPF case
82
+ are unavailable. Geographic sidecars and layout downloads remain available.
83
+ Without the optional worker, imported AC OPF instances remain viewable but
84
+ cannot be solved. No server solve is substituted for AC OPF.
85
+
71
86
  ## Panel layout
72
87
 
73
88
  The shell docks Network and Studies on the left, and Solver and Agent on the
@@ -366,6 +381,11 @@ package metadata. The packed smoke test installs the generated tarballs into a
366
381
  temporary Svelte consumer and builds it so missing exports, styles, or wasm
367
382
  assets fail before publish.
368
383
 
384
+ Unmounting `TellegenProvider` (including through `TellegenViewer`) disposes its
385
+ controller and terminates pending AC OPF probes and solves, including background
386
+ cases. Custom hosts that construct a `Controller` directly must call
387
+ `controller.dispose()` when their owning view is destroyed.
388
+
369
389
  ## Citing
370
390
 
371
391
  If you use this package in published work, please cite the tellegen paper:
@@ -2,7 +2,7 @@
2
2
  import 'maplibre-gl/dist/maplibre-gl.css';
3
3
  import { tick, untrack } from 'svelte';
4
4
  import type { Layer, PickingInfo } from '@deck.gl/core';
5
- import type { IconLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
5
+ import type { IconLayer, IconLayerProps, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
6
6
  import type { MapLibreOverlay } from '@deck.gl/maplibre';
7
7
  import type { LngLatBoundsLike, Map as MapLibreMap } from 'maplibre-gl';
8
8
  import type { NetworkBranch, NetworkBus } from './api.js';
@@ -22,9 +22,8 @@
22
22
  type SensitivityDomain
23
23
  } from './colors.js';
24
24
  import {
25
+ applyBasemapTuning,
25
26
  attachBasemapFallback,
26
- isOpenFreeMapStyle,
27
- openFreeMapEdits,
28
27
  resolveBasemap,
29
28
  type BasemapFallbackReason,
30
29
  type BasemapStyle,
@@ -50,13 +49,18 @@
50
49
  type TransformerMark,
51
50
  multiDiagramNetwork
52
51
  } from './multiconductor.js';
53
- import { transformerIcon } from './transformer-icon.js';
52
+ import {
53
+ TRANSFORMER_ICON,
54
+ transformerIconAtlas,
55
+ transformerIconMapping
56
+ } from './transformer-icon.js';
54
57
  import { foldMapBounds } from './map-bounds.js';
55
58
  import { getAppState, getController } from './context.svelte.js';
56
59
  import { displayFmt, fmt, rgbaCss } from './format.js';
57
60
  import { getPanelLayout } from './panels.svelte.js';
58
61
  import type { RGBA } from './colors.js';
59
62
  import DiagramCanvas from './components/DiagramCanvas.svelte';
63
+ import SchematicCanvas from './components/SchematicCanvas.svelte';
60
64
 
61
65
  const DEFAULT_CAMERA: CameraSnapshot = { center: [-85, 36], zoom: 4.5, bearing: 0, pitch: 0 };
62
66
 
@@ -148,6 +152,24 @@
148
152
  const diagramNetwork = $derived(
149
153
  multiNetwork ?? (displayedNetwork?.coordinate_space === 'diagram' ? displayedNetwork : null)
150
154
  );
155
+ /** A multiconductor case shown as a schematic replaces both the map and the diagram canvas. */
156
+ const schematicCase = $derived(
157
+ !app.studyView && app.activeMulti?.schematic && app.activeMulti.graph ? app.activeMulti : null
158
+ );
159
+ // When a case closes its own diagram (Close, Show map), the map continues from the diagram's
160
+ // geographic view. Switching to another case does not: the map frames the new case instead.
161
+ let shownSchematic: typeof schematicCase = null;
162
+ $effect(() => {
163
+ const now = schematicCase;
164
+ const before = shownSchematic;
165
+ shownSchematic = now;
166
+ untrack(() => {
167
+ if (before && !now && app.activeMulti === before && !before.schematic && before.lastGeoView) {
168
+ app.camera = before.lastGeoView;
169
+ app.cameraHandoff = true;
170
+ }
171
+ });
172
+ });
151
173
  const diagramCaseId = $derived(
152
174
  app.studyView?.caseId ?? diagramMulti?.id ?? diagramCase?.id ?? 'diagram'
153
175
  );
@@ -187,6 +209,11 @@
187
209
  const basemapPrimaryProvider = $derived(basemap.primaryProvider);
188
210
  const basemapFallbackProvider = $derived(basemap.fallbackProvider);
189
211
  const palette = $derived(MAP_PALETTE[theme]);
212
+ /** The diagram draws on light paper, so its background map is the light basemap whatever the
213
+ * map's theme; a host's own style is used as given. */
214
+ const diagramBasemap = $derived(
215
+ resolveBasemap({ mapStyle, fallbackMapStyle, theme: 'light', hideBasemapLabels, cartoApiKey })
216
+ );
190
217
  // Set once a map has existed in this component instance: a rebuild (context
191
218
  // loss or basemap change) restores the camera instead of reframing.
192
219
  let hadMap = false;
@@ -875,23 +902,13 @@
875
902
  // shows every layer again, so both are re-applied on each load.
876
903
  const applyStyleExtras = () => {
877
904
  m.setProjection({ type: untrack(() => projection) });
878
- const style = m.getStyle();
879
- if (spec.hideLabels) {
880
- for (const layer of style.layers) {
881
- if (layer.type === 'symbol') m.setLayoutProperty(layer.id, 'visibility', 'none');
882
- }
883
- }
884
905
  // OpenFreeMap renders as the primary or as the fallback; either
885
906
  // way it is tuned towards CARTO's quieter look.
886
- if (!isOpenFreeMapStyle(style)) return;
887
- const edits = openFreeMapEdits(
888
- style,
907
+ applyBasemapTuning(
908
+ m,
909
+ spec,
889
910
  untrack(() => theme)
890
911
  );
891
- for (const edit of edits) {
892
- if (edit.kind === 'paint') m.setPaintProperty(edit.layer, edit.property, edit.value);
893
- else m.setLayerZoomRange(edit.layer, edit.minzoom, edit.maxzoom);
894
- }
895
912
  };
896
913
  m.on('style.load', applyStyleExtras);
897
914
  const detachFallback = attachBasemapFallback(m, spec, {
@@ -1004,12 +1021,16 @@
1004
1021
  overlay = null;
1005
1022
  };
1006
1023
  detachReady = onMapReady?.(m, o);
1024
+ // A view closing onto this map (the diagram, from geographic positions) hands it its
1025
+ // camera to continue from.
1026
+ const handedOff = app.cameraHandoff;
1027
+ app.cameraHandoff = false;
1007
1028
  // Frame the active grid once the map is ready. The initial frame request
1008
1029
  // (from the case load) can fire before the async map import finishes and
1009
1030
  // be lost; re-issuing it here ensures the first view is the active case,
1010
1031
  // not the default world view. A rebuilt map opens at the default view
1011
1032
  // too, so it restores the camera the last map saved.
1012
- if (restoreCamera && app.camera) m.jumpTo(app.camera);
1033
+ if ((restoreCamera || handedOff) && app.camera) m.jumpTo(app.camera);
1013
1034
  else if (app.cameraSeq === handledCameraSeq)
1014
1035
  app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
1015
1036
  if (mapGen > 0) {
@@ -1345,7 +1366,11 @@
1345
1366
  new IconLayer<TransformerMark>({
1346
1367
  id: `multi-xfmr-${c.id}`,
1347
1368
  data: transformerMarks(c.view.edges),
1348
- getIcon: () => transformerIcon(),
1369
+ // deck.gl makes a texture from any image, canvas included, but types
1370
+ // iconAtlas as a URL or a Texture.
1371
+ iconAtlas: transformerIconAtlas() as unknown as IconLayerProps['iconAtlas'],
1372
+ iconMapping: transformerIconMapping,
1373
+ getIcon: () => TRANSFORMER_ICON,
1349
1374
  getPosition: (m) => m.position,
1350
1375
  getAngle: (m) => m.angle,
1351
1376
  billboard: false,
@@ -1455,7 +1480,7 @@
1455
1480
  $effect(() => {
1456
1481
  const seq = app.cameraSeq;
1457
1482
  const snapshot = app.cameraRequest;
1458
- if (diagramNetwork || !map || !snapshot || seq === handledCameraSeq) return;
1483
+ if (diagramNetwork || schematicCase || !map || !snapshot || seq === handledCameraSeq) return;
1459
1484
  handledCameraSeq = seq;
1460
1485
  handledFrameSeq = app.frameSeq;
1461
1486
  map.jumpTo(snapshot);
@@ -1467,7 +1492,7 @@
1467
1492
  // until its cue exists; anything unframeable settles immediately.
1468
1493
  $effect(() => {
1469
1494
  const seq = app.frameSeq;
1470
- if (diagramNetwork || !map || seq === handledFrameSeq) return;
1495
+ if (diagramNetwork || schematicCase || !map || seq === handledFrameSeq) return;
1471
1496
  const m = map;
1472
1497
  const target = app.frameTarget;
1473
1498
  const settle = () => app.settleFrame();
@@ -1497,7 +1522,23 @@
1497
1522
  });
1498
1523
  </script>
1499
1524
 
1500
- {#if diagramNetwork}
1525
+ {#if schematicCase}
1526
+ <!-- One canvas per graph: its layouts, camera and geometry belong to that graph, so another
1527
+ case, or the same case with coordinates attached, starts afresh. -->
1528
+ {#key schematicCase.graph}
1529
+ <SchematicCanvas
1530
+ mc={schematicCase}
1531
+ onselectbus={(id) => {
1532
+ if (id !== schematicCase.selectedBusId) onmultibusclick(schematicCase.id, id);
1533
+ }}
1534
+ onselectedge={(id) => {
1535
+ if (id !== schematicCase.selectedEdgeId) onmultiedgeclick?.(schematicCase.id, id);
1536
+ }}
1537
+ onclose={() => (schematicCase.schematic = false)}
1538
+ basemap={diagramBasemap}
1539
+ />
1540
+ {/key}
1541
+ {:else if diagramNetwork}
1501
1542
  <DiagramCanvas
1502
1543
  network={diagramNetwork}
1503
1544
  multiconductor={diagramMulti}
@@ -21,6 +21,7 @@
21
21
 
22
22
  let {
23
23
  apiBase,
24
+ acOpfWasmUrl,
24
25
  loadDefaultCases,
25
26
  docsHref,
26
27
  orgHref,
@@ -31,14 +32,15 @@
31
32
  children
32
33
  }: Props = $props();
33
34
 
34
- // Read once, at mount: `apiBase` and `loadDefaultCases` configure the
35
- // controller this provider owns, and the links and footer are fixed copy.
36
- // `theme` and `cartoApiKey` stay live, so a host can switch them and the
37
- // root attribute and the default shell's map follow.
35
+ // Read once, at mount: `apiBase`, `acOpfWasmUrl` and `loadDefaultCases`
36
+ // configure the controller this provider owns, and the links and footer are
37
+ // fixed copy. `theme` and `cartoApiKey` stay live, so a host can switch them
38
+ // and the root attribute and the default shell's map follow.
38
39
  const config: TellegenUiConfig = {
39
40
  ...untrack(() =>
40
41
  resolveTellegenUiConfig({
41
42
  apiBase,
43
+ acOpfWasmUrl,
42
44
  loadDefaultCases,
43
45
  docsHref,
44
46
  orgHref,
@@ -56,6 +58,7 @@
56
58
  const app = createAppState();
57
59
  const ctrl = createController(app, {
58
60
  apiBase: config.apiBase,
61
+ acOpfWasmUrl: config.acOpfWasmUrl ?? undefined,
59
62
  loadDefaultCases: config.loadDefaultCases
60
63
  });
61
64
  // A host that opens its own cases never calls load(), so the shell must not
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
2
+ import { onMount, untrack } from 'svelte';
3
3
  import AppFooter from './components/AppFooter.svelte';
4
4
  import NotificationCenter from './components/NotificationCenter.svelte';
5
5
  import AppHeader from './components/AppHeader.svelte';
@@ -19,6 +19,14 @@
19
19
 
20
20
  let dragDepth = 0;
21
21
 
22
+ // The overview and the diagram read the active case's whole operating point once per solve.
23
+ $effect(() => {
24
+ const c = app.activeMulti;
25
+ void c?.result?.solve_count;
26
+ void c?.mcFullResult;
27
+ return untrack(() => ctrl.followOverview(c));
28
+ });
29
+
22
30
  // ShellEnvironment drops the affordance when no fine pointer is attached;
23
31
  // forget any drag that was in flight so the next one counts from zero.
24
32
  $effect(() => {
@@ -40,6 +48,7 @@
40
48
  });
41
49
 
42
50
  onMount(() => {
51
+ ctrl.probeAcOpf();
43
52
  // The provider already marked the shell loaded when default cases are
44
53
  // off, and load() itself is gated; this only frames whatever is there.
45
54
  if (config.loadDefaultCases) {
@@ -5,6 +5,7 @@
5
5
 
6
6
  let {
7
7
  apiBase,
8
+ acOpfWasmUrl,
8
9
  loadDefaultCases,
9
10
  docsHref,
10
11
  orgHref,
@@ -17,6 +18,7 @@
17
18
 
18
19
  <TellegenProvider
19
20
  {apiBase}
21
+ {acOpfWasmUrl}
20
22
  {loadDefaultCases}
21
23
  {docsHref}
22
24
  {orgHref}
package/dist/basemap.d.ts CHANGED
@@ -189,4 +189,18 @@ export interface BasemapFallbackOptions {
189
189
  * again, as they do when `spec.fallback` is `null`. Returns a detach
190
190
  * function. */
191
191
  export declare function attachBasemapFallback(map: BasemapMapLike, spec: BasemapSpec, handlers?: BasemapFallbackHandlers, options?: BasemapFallbackOptions): () => void;
192
+ /** The parts of a MapLibre map `applyBasemapTuning` touches. */
193
+ export interface TunableMap {
194
+ getStyle(): StyleSpecification;
195
+ setLayoutProperty(layer: string, name: string, value: unknown): unknown;
196
+ setPaintProperty(layer: string, name: string, value: unknown): unknown;
197
+ setLayerZoomRange(layer: string, minzoom: number, maxzoom: number): unknown;
198
+ }
199
+ /** After a style loads: hide its labels when the spec asks, and tune an OpenFreeMap style (as the
200
+ * primary or the fallback) towards CARTO's quieter look. `setStyle` undoes both, so run this on
201
+ * every `style.load`. */
202
+ export declare function applyBasemapTuning(map: TunableMap, spec: BasemapSpec, theme: BasemapTheme): void;
203
+ /** A credit line for a basemap provider, for views that draw their own instead of MapLibre's
204
+ * attribution control. */
205
+ export declare function basemapCredit(provider: BasemapProvider): string;
192
206
  export {};
package/dist/basemap.js CHANGED
@@ -257,3 +257,30 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
257
257
  map.off('error', onError);
258
258
  };
259
259
  }
260
+ /** After a style loads: hide its labels when the spec asks, and tune an OpenFreeMap style (as the
261
+ * primary or the fallback) towards CARTO's quieter look. `setStyle` undoes both, so run this on
262
+ * every `style.load`. */
263
+ export function applyBasemapTuning(map, spec, theme) {
264
+ const style = map.getStyle();
265
+ if (spec.hideLabels)
266
+ for (const layer of style.layers)
267
+ if (layer.type === 'symbol')
268
+ map.setLayoutProperty(layer.id, 'visibility', 'none');
269
+ if (!isOpenFreeMapStyle(style))
270
+ return;
271
+ for (const edit of openFreeMapEdits(style, theme)) {
272
+ if (edit.kind === 'paint')
273
+ map.setPaintProperty(edit.layer, edit.property, edit.value);
274
+ else
275
+ map.setLayerZoomRange(edit.layer, edit.minzoom, edit.maxzoom);
276
+ }
277
+ }
278
+ /** A credit line for a basemap provider, for views that draw their own instead of MapLibre's
279
+ * attribution control. */
280
+ export function basemapCredit(provider) {
281
+ return provider === 'carto'
282
+ ? '© CARTO © OpenStreetMap contributors'
283
+ : provider === 'openfreemap'
284
+ ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap contributors'
285
+ : '© OpenStreetMap contributors';
286
+ }
package/dist/colors.d.ts CHANGED
@@ -75,7 +75,12 @@ export declare function sensFlatColor(domain: Pick<SensitivityDomain, 'mean'>):
75
75
  export declare function previewScaleFor(domain: Pick<SensitivityDomain, 'scale' | 'flat'>, maxAbsStep: number): number | null;
76
76
  /** Zero anchored sensitivity domain with a flat column guard. */
77
77
  export declare function sensitivityDomain(values: number[]): SensitivityDomain;
78
- /** Branch color by loading fraction: warm gray, amber past 0.6, red past 0.9. */
78
+ /** Loading, as a fraction of rating, from which a branch needs attention. Both loading scales
79
+ * share it. An optimal power flow enforces ratings, so its map draws congestion: red from here,
80
+ * because it cannot exceed 1. A multiconductor power flow does not enforce them, so its overview
81
+ * warns from here and calls a violation only above 1 (`LOADING_LIMIT` in `diagram/summary.ts`). */
82
+ export declare const LOADING_ATTENTION = 0.9;
83
+ /** Branch color by loading fraction: warm gray, amber past 0.6, red from `LOADING_ATTENTION`. */
79
84
  export declare function branchColor(loading: number, inService: boolean): RGBA;
80
85
  export declare function branchWidth(loading: number): number;
81
86
  /** Bus radius in px; area tracks max(load, gen) so big plants and big loads
package/dist/colors.js CHANGED
@@ -177,14 +177,21 @@ export function sensitivityDomain(values) {
177
177
  flat
178
178
  };
179
179
  }
180
- /** Branch color by loading fraction: warm gray, amber past 0.6, red past 0.9. */
180
+ /** Loading, as a fraction of rating, from which a branch needs attention. Both loading scales
181
+ * share it. An optimal power flow enforces ratings, so its map draws congestion: red from here,
182
+ * because it cannot exceed 1. A multiconductor power flow does not enforce them, so its overview
183
+ * warns from here and calls a violation only above 1 (`LOADING_LIMIT` in `diagram/summary.ts`). */
184
+ export const LOADING_ATTENTION = 0.9;
185
+ /** Where the optimal power flow map starts shading a branch towards congestion. */
186
+ const LOADING_ELEVATED = 0.6;
187
+ /** Branch color by loading fraction: warm gray, amber past 0.6, red from `LOADING_ATTENTION`. */
181
188
  export function branchColor(loading, inService) {
182
189
  if (!inService)
183
190
  return [197, 191, 178, 110];
184
- if (loading < 0.6)
191
+ if (loading < LOADING_ELEVATED)
185
192
  return [138, 131, 117, 200];
186
- if (loading < 0.9)
187
- return lerpColor([138, 131, 117, 210], [212, 116, 34, 240], (loading - 0.6) / 0.3);
193
+ if (loading < LOADING_ATTENTION)
194
+ return lerpColor([138, 131, 117, 210], [212, 116, 34, 240], (loading - LOADING_ELEVATED) / (LOADING_ATTENTION - LOADING_ELEVATED));
188
195
  return [179, 38, 30, 250];
189
196
  }
190
197
  export function branchWidth(loading) {
@@ -19,7 +19,7 @@
19
19
  >
20
20
  {#each FORMULATIONS as f (f.id)}
21
21
  {@const unavailable =
22
- f.disabled ||
22
+ (f.disabled && !(f.id === 'acopf' && ctrl.acOpfAvailable && !c.perturbed)) ||
23
23
  ('declaredFormulation' in c &&
24
24
  c.declaredFormulation != null &&
25
25
  c.declaredFormulation !== f.id)}
@@ -154,15 +154,22 @@
154
154
  {/if}
155
155
  {@render note?.(lc)}
156
156
  {#if lc.summary && lc.studyInputJson}
157
+ {@const exportUnavailable = ctrl.caseExportUnavailableReason(lc)}
157
158
  <div class="case-actions">
158
- <button class="reset" disabled={busy} onclick={() => saveCase(lc)}>Save case</button>
159
+ <button
160
+ class="reset"
161
+ disabled={busy || !!exportUnavailable}
162
+ title={exportUnavailable ?? undefined}
163
+ onclick={() => saveCase(lc)}>Save case</button
164
+ >
159
165
  {#if lc.formulation === 'dcopf' && lc.solution}
160
166
  <button class="reset" disabled={busy} onclick={() => saveSolution(lc)}>Save solution</button
161
167
  >
162
168
  {/if}
163
169
  <button
164
170
  class="reset"
165
- disabled={busy}
171
+ disabled={busy || !!exportUnavailable}
172
+ title={exportUnavailable ?? undefined}
166
173
  aria-expanded={exportOpen}
167
174
  aria-controls="case-export-formats"
168
175
  onclick={() => (exportOpen = !exportOpen)}
@@ -175,7 +182,9 @@
175
182
  </button>
176
183
  {/if}
177
184
  </div>
178
- {#if exportOpen}
185
+ {#if exportUnavailable}
186
+ <p class="unavailable">{exportUnavailable}</p>
187
+ {:else if exportOpen}
179
188
  <ul class="case-actions" id="case-export-formats" aria-label="Export formats">
180
189
  {#each EXPORT_FORMATS as f (f.token)}
181
190
  <li>
@@ -266,4 +275,10 @@
266
275
  .case-actions .reset {
267
276
  margin-top: 0;
268
277
  }
278
+
279
+ .unavailable {
280
+ margin: 8px 0 0;
281
+ font-size: 12px;
282
+ color: var(--text-secondary);
283
+ }
269
284
  </style>
@@ -40,6 +40,17 @@
40
40
  });
41
41
  let busChoice = $state('');
42
42
  let edgeChoice = $state('');
43
+ /** One entry per equipment id. A multi-winding transformer projects to several graph edges
44
+ * that share its id (one per winding pair), so the edges are grouped by id. */
45
+ const equipmentOptions = $derived.by(() => {
46
+ const byId = new Map<string, string[]>();
47
+ for (const edge of graph?.edges ?? []) {
48
+ const ends = byId.get(edge.id) ?? [];
49
+ ends.push(`${edge.from} - ${edge.to}`);
50
+ byId.set(edge.id, ends);
51
+ }
52
+ return [...byId].map(([id, ends]) => ({ id, label: `${ends.join(', ')}, ${id}` }));
53
+ });
43
54
  const busId = $derived(
44
55
  selectedBus ??
45
56
  (graph?.buses.some((b) => b.id === busChoice) ? busChoice : graph?.buses[0]?.id) ??
@@ -127,7 +138,11 @@
127
138
  </div>
128
139
  <div>
129
140
  <dt>Load voltage range</dt>
130
- <dd>{summary.min_voltage_pu == null || summary.max_voltage_pu == null ? 'n/a' : `${fixed(summary.min_voltage_pu)}–${fixed(summary.max_voltage_pu)} pu`}</dd>
141
+ <dd>
142
+ {summary.min_voltage_pu == null || summary.max_voltage_pu == null
143
+ ? 'n/a'
144
+ : `${fixed(summary.min_voltage_pu)}–${fixed(summary.max_voltage_pu)} pu`}
145
+ </dd>
131
146
  </div>
132
147
  <div>
133
148
  <dt>Source P / Q</dt>
@@ -228,8 +243,8 @@
228
243
  selectedEdge = null;
229
244
  portPage = 0;
230
245
  }}
231
- ><option value="">All equipment</option>{#each graph?.edges ?? [] as edge (edge.id)}<option
232
- value={edge.id}>{edge.from} - {edge.to}, {edge.id}</option
246
+ ><option value="">All equipment</option>{#each equipmentOptions as option, i (i)}<option
247
+ value={option.id}>{option.label}</option
233
248
  >{/each}</select
234
249
  ></label
235
250
  >