@tellegen/svelte 0.4.1 → 0.4.3

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 (35) hide show
  1. package/README.md +38 -10
  2. package/dist/Sparkline.svelte +4 -4
  3. package/dist/TellegenMap.svelte +41 -46
  4. package/dist/TellegenProvider.svelte +29 -13
  5. package/dist/components/AppFooter.svelte +4 -4
  6. package/dist/components/AppHeader.svelte +15 -19
  7. package/dist/components/BindingLines.svelte +10 -9
  8. package/dist/components/BusPicker.svelte +5 -6
  9. package/dist/components/ControlPanel.svelte +12 -9
  10. package/dist/components/ControlPanel.svelte.d.ts +6 -1
  11. package/dist/components/DemandSlider.svelte +24 -29
  12. package/dist/components/DisplayControls.svelte +5 -24
  13. package/dist/components/DropZone.svelte +7 -3
  14. package/dist/components/DropZone.svelte.d.ts +6 -17
  15. package/dist/components/FormulationSelector.svelte +2 -7
  16. package/dist/components/LocalCaseDetails.svelte +126 -126
  17. package/dist/components/LocalCaseDetails.svelte.d.ts +6 -1
  18. package/dist/components/MulticonductorDetails.svelte +48 -38
  19. package/dist/components/NetworkStats.svelte +14 -16
  20. package/dist/components/PanelCard.svelte +1 -1
  21. package/dist/components/PlacementCue.svelte +1 -1
  22. package/dist/components/RatingSlider.svelte +22 -23
  23. package/dist/components/RestoreDefaultsButton.svelte +2 -3
  24. package/dist/components/SensitivityReadout.svelte +9 -27
  25. package/dist/components/SolveCard.svelte +69 -35
  26. package/dist/components/TopMovers.svelte +5 -5
  27. package/dist/context.svelte.d.ts +6 -2
  28. package/dist/context.svelte.js +4 -2
  29. package/dist/controller.svelte.d.ts +7 -0
  30. package/dist/controller.svelte.js +33 -6
  31. package/dist/display.js +8 -9
  32. package/dist/format.d.ts +1 -9
  33. package/dist/format.js +0 -13
  34. package/dist/styles.css +3 -0
  35. package/package.json +10 -6
package/README.md CHANGED
@@ -3,9 +3,13 @@
3
3
  Svelte components for tellegen maps, case panels, local case files, and browser solves.
4
4
 
5
5
  ```sh
6
- npm install @tellegen/svelte
6
+ npm install @tellegen/svelte maplibre-gl @deck.gl/core @deck.gl/layers @deck.gl/maplibre
7
7
  ```
8
8
 
9
+ `maplibre-gl` and the three `@deck.gl` packages are peer dependencies: the map
10
+ shares their instances with the host through `extraLayers` and `onMapReady`, so
11
+ the app installs them and resolves one copy of each.
12
+
9
13
  Import the component and stylesheet in your app:
10
14
 
11
15
  ```svelte
@@ -41,6 +45,10 @@ remain in the browser on this path.
41
45
  - `orgLabel`
42
46
  - `showFooter`, default `true`
43
47
  - `theme`, `light` (default) or `dark`
48
+ - `cartoApiKey`, the basemap key (see [Basemap](#basemap))
49
+
50
+ `theme` and `cartoApiKey` follow their props. The others are read once, when
51
+ the viewer mounts; remount it to change them.
44
52
 
45
53
  Use `TellegenProvider` and `TellegenShell` when state should survive route changes:
46
54
 
@@ -55,6 +63,11 @@ Use `TellegenProvider` and `TellegenShell` when state should survive route chang
55
63
  </TellegenProvider>
56
64
  ```
57
65
 
66
+ The provider owns the controller and its cases. Unmounting it disposes the
67
+ controller, which frees every case's browser Study and multiconductor session
68
+ in the engine worker, so a provider that should keep its cases across pages
69
+ belongs in a layout that stays mounted.
70
+
58
71
  ## Panel layout
59
72
 
60
73
  The shell docks Network and Studies on the left, and Solver and Agent on the
@@ -123,6 +136,19 @@ inside `TellegenProvider`:
123
136
  <PanelFrame id="region" title="Region" side="left">...</PanelFrame>
124
137
  ```
125
138
 
139
+ The components show data, labels and controls, not host copy. A host that
140
+ wants a note under a local case's stats passes `ControlPanel` a `caseNote`
141
+ snippet, which receives the case; `DropZone` renders its children under the
142
+ drop prompt:
143
+
144
+ ```svelte
145
+ <ControlPanel>
146
+ {#snippet caseNote(lc)}
147
+ <p>{lc.label} is a synthetic grid.</p>
148
+ {/snippet}
149
+ </ControlPanel>
150
+ ```
151
+
126
152
  `ShellEnvironment` is renderless and keeps the compact-layout, pointer and
127
153
  viewport state in step, as the default shell does. `PanelHost` reads
128
154
  `--tellegen-dock-left`, `--tellegen-dock-right` and `--tellegen-dock-bottom`
@@ -193,7 +219,8 @@ besides its click callbacks:
193
219
  - `attribution`: `'compact'` (default) or `'full'`. There is no off switch.
194
220
  - `onMapReady(map, overlay)`: called with the MapLibre map and the deck
195
221
  overlay once they exist. It may return a cleanup, and it fires again after
196
- a context-loss or basemap rebuild.
222
+ a context-loss or basemap rebuild. If it throws, the map and its window
223
+ and document listeners are still released when the map is torn down.
197
224
  - `onBasemapFallback(reason)`: the switch to the fallback happened.
198
225
  `reason.from` and `reason.to` name the two providers.
199
226
  - `onBasemapError(reason)`: no basemap can be shown. The network layers
@@ -201,7 +228,8 @@ besides its click callbacks:
201
228
  `reason.to` is `null`.
202
229
 
203
230
  `TellegenProvider` and `TellegenViewer` also take `cartoApiKey`, and pass it,
204
- together with `theme`, to the default shell's map. A SvelteKit 3 host can
231
+ together with `theme`, to the default shell's map. Both follow their props:
232
+ changing either rebuilds that map in place. A SvelteKit 3 host can
205
233
  declare the key as an optional public build-time variable:
206
234
 
207
235
  ```ts
@@ -251,13 +279,13 @@ tiles, fonts and sprites are all fetched. The engine underneath also needs
251
279
  `script-src 'wasm-unsafe-eval'` (never `'unsafe-eval'`) and `worker-src
252
280
  'self'`, since its workers are same-origin.
253
281
 
254
- A host that passes its own deck.gl layers in `extraLayers`, or works with the
255
- MapLibre map from `onMapReady`, must resolve one copy each of
256
- `@deck.gl/core`, `@deck.gl/layers`, `@deck.gl/maplibre` and `maplibre-gl`.
257
- When two deck.gl versions load, deck.gl throws "multiple versions detected".
258
- If the host's ranges differ from this package's (`^9.4.0` for deck.gl,
259
- `^6.15.0` for maplibre-gl), point both at one version with npm `overrides`
260
- (or `resolutions` in pnpm and Yarn).
282
+ `@deck.gl/core`, `@deck.gl/layers`, `@deck.gl/maplibre` and `maplibre-gl`
283
+ are peer dependencies (`^9.4.0` for deck.gl, `^6.15.0` for maplibre-gl), so
284
+ the host's copies are the ones the map uses, including the deck.gl layers it
285
+ passes in `extraLayers` and the MapLibre map it gets from `onMapReady`. When
286
+ two deck.gl versions load, deck.gl throws "multiple versions detected"; if
287
+ another dependency pulls in a second copy, point both at one version with npm
288
+ `overrides` (or `resolutions` in pnpm and Yarn).
261
289
 
262
290
  The map needs no Vite settings, in a build or in the dev server. MapLibre's
263
291
  worker comes from a `?worker&url` import, which a production build bundles
@@ -35,7 +35,7 @@
35
35
  viewBox="0 0 {W} {H}"
36
36
  preserveAspectRatio="none"
37
37
  role="img"
38
- aria-label="residual by solver iteration"
38
+ aria-label="Residual by solver iteration"
39
39
  >
40
40
  <line class="axis" x1={L} y1={T} x2={L} y2={H - B} />
41
41
  <line class="axis" x1={L} y1={H - B} x2={W - R} y2={H - B} />
@@ -46,9 +46,9 @@
46
46
  text-anchor="middle"
47
47
  transform="rotate(-90)"
48
48
  >
49
- residual
49
+ Residual
50
50
  </text>
51
- <text class="axis-label x-label" x={W - R} y={H - 4} text-anchor="end">iterations</text>
51
+ <text class="axis-label x-label" x={W - R} y={H - 4} text-anchor="end">Iterations</text>
52
52
  <polyline {points} pathLength="1" />
53
53
  </svg>
54
54
  {/if}
@@ -69,7 +69,7 @@
69
69
 
70
70
  .axis-label {
71
71
  fill: var(--text-tertiary);
72
- font-family: var(--font-mono);
72
+ font-family: var(--font-display);
73
73
  font-size: 12px;
74
74
  letter-spacing: 0;
75
75
  }
@@ -33,7 +33,7 @@
33
33
  } from './basemap.js';
34
34
  import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
35
35
  import {
36
- CaseState,
36
+ type CaseState,
37
37
  type CameraSnapshot,
38
38
  type DisplayMode,
39
39
  type LocalCase
@@ -710,13 +710,13 @@
710
710
  if (info.layer.id === 'study-branches') {
711
711
  const flow = view?.flows?.find((f) => f.branch === id);
712
712
  return {
713
- html: `<b>branch ${id}</b><br>saved state: ${esc(app.studyView.label)}${flow ? `<br>P from ${flow.pf.toFixed(3)} MW, loading ${(100 * flow.loading).toFixed(1)}%` : ''}`
713
+ html: `<b>Branch ${id}</b><br>Saved state: ${esc(app.studyView.label)}${flow ? `<br>P from ${flow.pf.toFixed(3)} MW<br>Loading ${(100 * flow.loading).toFixed(1)}%` : ''}`
714
714
  };
715
715
  }
716
716
  const p = view?.lmp?.find((v) => v.bus === id),
717
717
  v = view?.vm?.find((v) => v.bus === id);
718
718
  return {
719
- html: `<b>bus ${id}</b><br>saved state: ${esc(app.studyView.label)}${p ? `<br>LMP ${p.value.toFixed(4)}` : ''}${v ? `<br>voltage ${v.value.toFixed(5)} pu` : ''}`
719
+ html: `<b>Bus ${id}</b><br>Saved state: ${esc(app.studyView.label)}${p ? `<br>LMP ${p.value.toFixed(4)}` : ''}${v ? `<br>Voltage ${v.value.toFixed(5)} pu` : ''}`
720
720
  };
721
721
  }
722
722
  if (info.layer?.id.startsWith('local-subs-')) {
@@ -726,25 +726,22 @@
726
726
  const s = object as { number: number; name: string };
727
727
  const named = s.name ? ` ${esc(s.name)}` : '';
728
728
  return {
729
- html: `<div class="tt"><b>substation ${Number(s.number)}</b>${named}<br><span style="opacity:0.6">.pwd diagram, approximate position</span></div>`
729
+ html: `<div class="tt"><b>Substation ${Number(s.number)}</b>${named}<br>Approximate position</div>`
730
730
  };
731
731
  }
732
732
  const layerId = info.layer?.id;
733
733
  if (layerId?.startsWith('multi-edges-')) {
734
734
  const e = object as PlacedMultiEdge;
735
- const state = e.kind === 'switch' ? (e.closed ? ' &#8901; closed' : ' &#8901; open') : '';
735
+ const state = e.kind === 'switch' ? (e.closed ? '<br>Closed' : '<br>Open') : '';
736
736
  return {
737
- html: `<div class="tt"><b>${esc(e.kind)} ${esc(e.from)}&#8201;&ndash;&#8201;${esc(e.to)}</b> ${e.n_phases}&#8202;&phi;${state}</div>`
737
+ html: `<div class="tt"><b>${esc(e.kind)} ${esc(e.from)}&#8201;&ndash;&#8201;${esc(e.to)}</b><br>${e.n_phases}&#8202;&phi;${state}</div>`
738
738
  };
739
739
  }
740
740
  if (layerId?.startsWith('multi-buses-')) {
741
741
  const b = object as PlacedMultiBus;
742
- const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'none';
742
+ const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'None';
743
743
  return {
744
- html: `<div class="tt"><b>bus ${esc(b.id)}</b>
745
- ${b.terminals.length} terminals${b.grounded.length ? ` &#8901; ${b.grounded.length} grounded` : ''}<br>
746
- load ${b.load_kw.toFixed(0)} kW &#8901; gen ${b.gen_kw.toFixed(0)} kW<br>
747
- <span style="opacity:0.6">${esc(roles)}</span></div>`
744
+ html: `<div class="tt"><b>Bus ${esc(b.id)}</b><br>${b.terminals.length} terminals${b.grounded.length ? `, ${b.grounded.length} grounded` : ''}<br>Load ${b.load_kw.toFixed(0)} kW<br>Generation ${b.gen_kw.toFixed(0)} kW<br>Attachments: ${esc(roles)}</div>`
748
745
  };
749
746
  }
750
747
  const c = caseOf(info);
@@ -753,17 +750,15 @@
753
750
  const b = object as NetworkBranch;
754
751
  const flow = d
755
752
  ? `${((d.loading.get(b.id) ?? 0) * 100).toFixed(0)}% of ${b.rate_mw.toFixed(0)} MW`
756
- : `rate ${b.rate_mw.toFixed(0)} MW`;
753
+ : `Rating ${b.rate_mw.toFixed(0)} MW`;
757
754
  return {
758
- html: `<div class="tt"><b>line ${b.from}&#8201;&ndash;&#8201;${b.to}</b> ${flow}</div>`
755
+ html: `<div class="tt"><b>Line ${b.from}&#8201;&ndash;&#8201;${b.to}</b><br>${flow}</div>`
759
756
  };
760
757
  }
761
758
  const bus = object as NetworkBus;
762
759
  if (!c) {
763
760
  return {
764
- html: `<div class="tt"><b>bus ${bus.id}</b>
765
- load ${bus.demand_mw.toFixed(0)} MW &#8901; gen ${bus.gen_mw.toFixed(0)} MW<br>
766
- <span style="opacity:0.6">local file</span></div>`
761
+ html: `<div class="tt"><b>Bus ${bus.id}</b><br>Load ${bus.demand_mw.toFixed(0)} MW<br>Generation ${bus.gen_mw.toFixed(0)} MW</div>`
767
762
  };
768
763
  }
769
764
  const price = d?.prices.get(bus.id);
@@ -773,8 +768,8 @@
773
768
  const delta = caseDeltas(c)[bus.id] ?? 0;
774
769
  const loadRow =
775
770
  delta === 0
776
- ? `load ${bus.demand_mw.toFixed(0)} MW`
777
- : `load ${(bus.demand_mw + delta).toFixed(0)} MW (${delta > 0 ? '+' : ''}${delta.toFixed(0)})`;
771
+ ? `Load ${bus.demand_mw.toFixed(0)} MW`
772
+ : `Load ${(bus.demand_mw + delta).toFixed(0)} MW (${delta > 0 ? '+' : ''}${delta.toFixed(0)})`;
778
773
  const sensRow =
779
774
  sens === undefined
780
775
  ? ''
@@ -783,8 +778,6 @@
783
778
  preview === undefined
784
779
  ? ''
785
780
  : `<br>&Delta; LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
786
- const localRow =
787
- c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
788
781
  // Demand already has its own row below, so the demand colouring shows LMP here alone.
789
782
  const scalarRow =
790
783
  !d || d.scalarMode === 'price' || d.scalarMode === 'demand'
@@ -793,9 +786,7 @@
793
786
  scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
794
787
  } ${d.scalarUnit}<br>LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`;
795
788
  return {
796
- html: `<div class="tt"><b>bus ${bus.id}</b>
797
- ${scalarRow}<br>
798
- ${loadRow} &#8901; gen ${bus.gen_mw.toFixed(0)} MW${sensRow}${previewRow}${localRow}</div>`
789
+ html: `<div class="tt"><b>Bus ${bus.id}</b><br>${scalarRow}<br>${loadRow}<br>Generation ${bus.gen_mw.toFixed(0)} MW${sensRow}${previewRow}</div>`
799
790
  };
800
791
  }
801
792
 
@@ -992,25 +983,9 @@
992
983
 
993
984
  map = m;
994
985
  overlay = o;
995
- const detachReady = onMapReady?.(m, o);
996
- // Frame the active grid once the map is ready. The initial frame request
997
- // (from the case load) can fire before the async map import finishes and
998
- // be lost; re-issuing it here ensures the first view is the active case,
999
- // not the default world view. A rebuilt map opens at the default view
1000
- // too, so it restores the camera the last map saved.
1001
- if (restoreCamera && app.camera) m.jumpTo(app.camera);
1002
- else if (app.cameraSeq === handledCameraSeq)
1003
- app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
1004
- if (mapGen > 0) {
1005
- // This is a rebuilt map. Once it has held a live context without
1006
- // another loss, zero the budget so the cap only ever catches a tight
1007
- // loss loop, not recoveries spread across a long session.
1008
- stabilizeTimer = setTimeout(() => {
1009
- glRebuilds = 0;
1010
- stabilizeTimer = null;
1011
- }, 10000);
1012
- }
1013
-
986
+ // Assign the teardown before host code runs: if onMapReady throws,
987
+ // the map and its window and document listeners are still released.
988
+ let detachReady: void | (() => void);
1014
989
  cleanup = () => {
1015
990
  clearRebuild();
1016
991
  clearStabilize();
@@ -1028,6 +1003,24 @@
1028
1003
  map = null;
1029
1004
  overlay = null;
1030
1005
  };
1006
+ detachReady = onMapReady?.(m, o);
1007
+ // Frame the active grid once the map is ready. The initial frame request
1008
+ // (from the case load) can fire before the async map import finishes and
1009
+ // be lost; re-issuing it here ensures the first view is the active case,
1010
+ // not the default world view. A rebuilt map opens at the default view
1011
+ // too, so it restores the camera the last map saved.
1012
+ if (restoreCamera && app.camera) m.jumpTo(app.camera);
1013
+ else if (app.cameraSeq === handledCameraSeq)
1014
+ app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
1015
+ if (mapGen > 0) {
1016
+ // This is a rebuilt map. Once it has held a live context without
1017
+ // another loss, zero the budget so the cap only ever catches a tight
1018
+ // loss loop, not recoveries spread across a long session.
1019
+ stabilizeTimer = setTimeout(() => {
1020
+ glRebuilds = 0;
1021
+ stabilizeTimer = null;
1022
+ }, 10000);
1023
+ }
1031
1024
  })
1032
1025
  .catch((e) => {
1033
1026
  if (!cancelled) app.error = `map failed to load: ${e instanceof Error ? e.message : e}`;
@@ -1542,14 +1535,14 @@
1542
1535
  {#if app.placingId && map}
1543
1536
  <button
1544
1537
  type="button"
1545
- class="place-at-center mono"
1538
+ class="place-at-center"
1546
1539
  onclick={() => {
1547
1540
  if (!map) return;
1548
1541
  const center = map.getCenter();
1549
1542
  onplacecase(center.lng, center.lat);
1550
1543
  }}
1551
1544
  >
1552
- place at map center
1545
+ Place at map center
1553
1546
  </button>
1554
1547
  {/if}
1555
1548
 
@@ -1739,6 +1732,7 @@
1739
1732
  border: 1px solid var(--accent);
1740
1733
  border-radius: 3px;
1741
1734
  color: var(--text-accent);
1735
+ font-family: inherit;
1742
1736
  font-size: 11px;
1743
1737
  cursor: pointer;
1744
1738
  box-shadow: var(--elev-cue);
@@ -1816,8 +1810,9 @@
1816
1810
  background: var(--panel) !important;
1817
1811
  border: 1px solid var(--line);
1818
1812
  color: var(--ink) !important;
1819
- font-family: var(--font-mono);
1820
- font-size: 11px;
1813
+ font-family: var(--font-display);
1814
+ font-size: 12px;
1815
+ font-variant-numeric: tabular-nums;
1821
1816
  line-height: 1.5;
1822
1817
  padding: 8px 10px !important;
1823
1818
  border-radius: 2px;
@@ -1,10 +1,13 @@
1
1
  <script lang="ts">
2
- import { untrack, type Snippet } from 'svelte';
2
+ import { onDestroy, untrack, type Snippet } from 'svelte';
3
3
  import {
4
+ resolveCartoApiKey,
4
5
  resolveTellegenUiConfig,
6
+ resolveTheme,
5
7
  setAppState,
6
8
  setController,
7
9
  setUiConfig,
10
+ type TellegenUiConfig,
8
11
  type TellegenUiOptions
9
12
  } from './context.svelte.js';
10
13
  import { createController } from './controller.svelte.js';
@@ -28,18 +31,28 @@
28
31
  children
29
32
  }: Props = $props();
30
33
 
31
- const config = untrack(() =>
32
- resolveTellegenUiConfig({
33
- apiBase,
34
- loadDefaultCases,
35
- docsHref,
36
- orgHref,
37
- orgLabel,
38
- showFooter,
39
- theme,
40
- cartoApiKey
41
- })
42
- );
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.
38
+ const config: TellegenUiConfig = {
39
+ ...untrack(() =>
40
+ resolveTellegenUiConfig({
41
+ apiBase,
42
+ loadDefaultCases,
43
+ docsHref,
44
+ orgHref,
45
+ orgLabel,
46
+ showFooter
47
+ })
48
+ ),
49
+ get theme() {
50
+ return resolveTheme(theme);
51
+ },
52
+ get cartoApiKey() {
53
+ return resolveCartoApiKey(cartoApiKey);
54
+ }
55
+ };
43
56
  const app = createAppState();
44
57
  const ctrl = createController(app, {
45
58
  apiBase: config.apiBase,
@@ -54,6 +67,9 @@
54
67
  setUiConfig(config);
55
68
  setAppState(app);
56
69
  setController(ctrl);
70
+ // The controller's Studies and multiconductor sessions live in the engine
71
+ // worker; release them with the provider.
72
+ onDestroy(() => ctrl.dispose());
57
73
 
58
74
  // The dark tokens in styles.css hang off `:root[data-theme='dark']`. Set it
59
75
  // only for dark, and only while mounted, so a light host is untouched. A
@@ -7,14 +7,14 @@
7
7
  let { inline = false }: { inline?: boolean } = $props();
8
8
  </script>
9
9
 
10
- <footer class="mono" class:inline>
11
- <a href="/credits/">credits</a>
10
+ <footer class:inline>
11
+ <a href="/credits/">Credits</a>
12
12
  <i class="sep"></i>
13
- <a href="/privacy/">privacy</a>
13
+ <a href="/privacy/">Privacy</a>
14
14
  {#if ctrl.showFileDropUi}
15
15
  <i class="sep filedrop-ui"></i>
16
16
  <span class="drophint filedrop-ui"
17
- ><span class="arrow">&#8675;</span> drop a case or geographic file anywhere</span
17
+ ><span class="arrow">&#8675;</span> Drop a case or geographic file anywhere</span
18
18
  >
19
19
  {/if}
20
20
  </footer>
@@ -33,7 +33,7 @@
33
33
  </svg>
34
34
  <h1>tellegen</h1>
35
35
  </div>
36
- <nav class="cases" aria-label="networks">
36
+ <nav class="cases" aria-label="Networks">
37
37
  {#each app.cases as c (c.id)}
38
38
  {@const [cname, cregion] = splitName(c.name)}
39
39
  <div class="case-chip" class:active={app.activeCaseId === c.id}>
@@ -44,14 +44,14 @@
44
44
  onclick={() => ctrl.activateCase(c.id)}
45
45
  >
46
46
  <span class="cname"
47
- >{cname}{#if c.perturbed}<i class="mark" title="demand perturbed"></i>{/if}</span
47
+ >{cname}{#if c.perturbed}<i class="mark" title="Demand changed"></i>{/if}</span
48
48
  >
49
49
  <span class="cregion mono">{c.unavailableReason ? 'Unavailable' : cregion}</span>
50
50
  </button>
51
51
  <button
52
52
  class="case-remove mono"
53
- aria-label="remove {c.name} from this browser"
54
- title="remove {c.name} from this browser"
53
+ aria-label="Remove {c.name}"
54
+ title="Remove {c.name}"
55
55
  onclick={(e) => ctrl.removeBackendCase(c, e)}>&#10005;</button
56
56
  >
57
57
  </div>
@@ -60,12 +60,12 @@
60
60
  <div class="case-chip local" class:active={app.activeLocalId === c.id}>
61
61
  <button class="case-activate" onclick={() => ctrl.activateLocal(c)}>
62
62
  <span class="cname">{c.label}</span>
63
- <span class="cregion mono">local</span>
63
+ <span class="cregion mono">Local</span>
64
64
  </button>
65
65
  <button
66
66
  class="case-remove mono"
67
- aria-label="remove {c.label}"
68
- title="remove {c.label}"
67
+ aria-label="Remove {c.label}"
68
+ title="Remove {c.label}"
69
69
  onclick={(e) => ctrl.removeLocalCase(c, e)}>&#10005;</button
70
70
  >
71
71
  </div>
@@ -74,33 +74,29 @@
74
74
  <div class="case-chip local" class:active={app.activeMultiId === c.id}>
75
75
  <button class="case-activate" onclick={() => ctrl.activateMulti(c)}>
76
76
  <span class="cname">{c.label}</span>
77
- <span class="cregion mono">multiconductor</span>
77
+ <span class="cregion mono">Multiconductor</span>
78
78
  </button>
79
79
  <button
80
80
  class="case-remove mono"
81
- aria-label="remove {c.label}"
82
- title="remove {c.label}"
81
+ aria-label="Remove {c.label}"
82
+ title="Remove {c.label}"
83
83
  onclick={(e) => ctrl.removeMultiCase(c, e)}>&#10005;</button
84
84
  >
85
85
  </div>
86
86
  {/each}
87
- <button
88
- class="ghost filedrop-ui"
89
- title="parsed in your browser; the file never uploads"
90
- onclick={() => fileInput?.click()}
91
- >
87
+ <button class="ghost filedrop-ui" onclick={() => fileInput?.click()}>
92
88
  <span class="cname"
93
89
  ><span class="arrow">&#8675;</span>{ctrl.showFileDropUi
94
- ? 'drop a case file'
95
- : 'open a case file'}</span
90
+ ? 'Drop a case file'
91
+ : 'Open a case file'}</span
96
92
  >
97
- <span class="cregion mono">case + geo files</span>
93
+ <span class="cregion mono">Case or geo files</span>
98
94
  </button>
99
95
  </nav>
100
96
  <span class="kicker mono">
101
97
  <a href={config.orgHref} target="_blank" rel="noreferrer">{config.orgLabel}</a>
102
98
  <i class="sep"></i>
103
- <a href={config.docsHref} target="_blank" rel="noreferrer">docs</a>
99
+ <a href={config.docsHref} target="_blank" rel="noreferrer">Docs</a>
104
100
  </span>
105
101
  <input
106
102
  type="file"
@@ -30,22 +30,17 @@
30
30
 
31
31
  {#if bindingLines.length > 0}
32
32
  <div class="binding-lines">
33
- <p
34
- class="mono dim head"
35
- title="lines loaded to their thermal rating; select one to see LMP sensitivity to the rating"
36
- >
37
- binding lines
38
- </p>
39
- <ul class="mono">
33
+ <p class="dim head" title="Lines loaded to their thermal rating">Binding lines</p>
34
+ <ul>
40
35
  {#each bindingLines as { branch, loading } (branch.id)}
41
36
  <li>
42
37
  <button
43
- class="mono"
44
38
  class:selected={app.selectedBranch === branch.id}
45
39
  aria-pressed={app.selectedBranch === branch.id}
46
40
  onclick={() => select(branch.id)}
47
41
  >
48
- line {branch.from}&#8201;&ndash;&#8201;{branch.to}, {Math.round(loading * 100)}%
42
+ Line <span class="mono">{branch.from}&#8201;&ndash;&#8201;{branch.to}</span>
43
+ <span class="mono load">{Math.round(loading * 100)}%</span>
49
44
  </button>
50
45
  </li>
51
46
  {/each}
@@ -72,6 +67,8 @@
72
67
  }
73
68
 
74
69
  li button {
70
+ display: flex;
71
+ gap: 4px;
75
72
  width: 100%;
76
73
  padding: 3px 6px 3px 8px;
77
74
  text-align: left;
@@ -84,6 +81,10 @@
84
81
  cursor: pointer;
85
82
  }
86
83
 
84
+ .load {
85
+ margin-left: auto;
86
+ }
87
+
87
88
  li button:hover {
88
89
  background: var(--accent-soft);
89
90
  }
@@ -89,14 +89,14 @@
89
89
  if (!e.currentTarget.contains(e.relatedTarget as Node | null)) open = false;
90
90
  }}
91
91
  >
92
- <label class="mono dim" for="bus-lookup-input">bus lookup</label>
92
+ <label class="dim" for="bus-lookup-input">Bus lookup</label>
93
93
  <input
94
94
  id="bus-lookup-input"
95
95
  class="mono"
96
96
  type="text"
97
97
  inputmode="numeric"
98
98
  autocomplete="off"
99
- placeholder="bus id"
99
+ placeholder="Bus ID"
100
100
  role="combobox"
101
101
  aria-keyshortcuts="/"
102
102
  aria-expanded={open}
@@ -114,7 +114,7 @@
114
114
  onkeydown={onKeydown}
115
115
  />
116
116
  {#if open && results.length > 0}
117
- <ul class="mono" id="bus-lookup-listbox" role="listbox" aria-label="matching buses">
117
+ <ul id="bus-lookup-listbox" role="listbox" aria-label="Matching buses">
118
118
  {#each results as bus, i (bus.id)}
119
119
  <li
120
120
  id="bus-lookup-opt-{bus.id}"
@@ -126,12 +126,12 @@
126
126
  commit(bus.id);
127
127
  }}
128
128
  >
129
- bus {bus.id}
129
+ Bus <span class="mono">{bus.id}</span>
130
130
  </li>
131
131
  {/each}
132
132
  </ul>
133
133
  {:else if open && query.trim() !== ''}
134
- <p class="mono dim empty">no bus matches "{query.trim()}"</p>
134
+ <p class="dim empty">No matching bus</p>
135
135
  {/if}
136
136
  </div>
137
137
  {/if}
@@ -156,7 +156,6 @@
156
156
  label {
157
157
  display: block;
158
158
  font-size: 12px;
159
- text-transform: uppercase;
160
159
  margin-bottom: 3px;
161
160
  }
162
161
 
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- import { tick, untrack } from 'svelte';
2
+ import { tick, untrack, type Snippet } from 'svelte';
3
3
  import { getAppState, getController, getUiConfig } from '../context.svelte.js';
4
+ import type { LocalCase } from '../state.svelte.js';
4
5
  import PanelFrame from './PanelFrame.svelte';
5
6
  import SavedNetworkDetails from './SavedNetworkDetails.svelte';
6
7
  import ModelDetails from './ModelDetails.svelte';
@@ -20,6 +21,9 @@
20
21
  import SizeLegend from './SizeLegend.svelte';
21
22
  import TopMovers from './TopMovers.svelte';
22
23
 
24
+ /** Optional host content rendered under a local case's stats. */
25
+ let { caseNote }: { caseNote?: Snippet<[LocalCase]> } = $props();
26
+
23
27
  const app = getAppState();
24
28
  const ctrl = getController();
25
29
  const config = getUiConfig();
@@ -90,7 +94,7 @@
90
94
  <SavedNetworkDetails />
91
95
  {:else}
92
96
  {#if app.parsingFile}
93
- <p class="dim mono blink">parsing&hellip;</p>
97
+ <p class="dim blink">Parsing&hellip;</p>
94
98
  {/if}
95
99
 
96
100
  <!-- Selected equipment leads on narrow screens. -->
@@ -113,7 +117,7 @@
113
117
  >
114
118
  </div>
115
119
  {/if}
116
- <LocalCaseDetails />
120
+ <LocalCaseDetails note={caseNote} />
117
121
  {/if}
118
122
  {#if app.activeMulti}
119
123
  <MulticonductorDetails />
@@ -121,18 +125,17 @@
121
125
  {#if !ctrl.networkStats}
122
126
  {#if !app.activeLocal && !app.activeMulti}
123
127
  {#if ctrl.casesLoaded && app.cases.length === 0}
124
- <p class="dim mono">
125
- {config.loadDefaultCases ? 'no default cases loaded' : 'drop a case file to begin'}
128
+ <p class="dim">
129
+ {config.loadDefaultCases ? 'No default cases loaded' : 'Drop a case file to begin'}
126
130
  </p>
127
131
  {#if config.loadDefaultCases}
128
- <button class="reset mono" onclick={ctrl.restoreDefaultCases}
129
- >restore default cases</button
132
+ <button class="reset" onclick={ctrl.restoreDefaultCases}>Restore default cases</button
130
133
  >
131
134
  {/if}
132
135
  {:else if ctrl.loadingBackendCase}
133
- <p class="dim mono blink">loading selected case&hellip;</p>
136
+ <p class="dim blink">Loading case&hellip;</p>
134
137
  {:else}
135
- <p class="dim mono blink">loading cases&hellip;</p>
138
+ <p class="dim blink">Loading cases&hellip;</p>
136
139
  {/if}
137
140
  {/if}
138
141
  {:else}
@@ -1,3 +1,8 @@
1
- declare const ControlPanel: import("svelte").Component<Record<string, never>, {}, "">;
1
+ import { type Snippet } from 'svelte';
2
+ import type { LocalCase } from '../state.svelte.js';
3
+ type $$ComponentProps = {
4
+ caseNote?: Snippet<[LocalCase]>;
5
+ };
6
+ declare const ControlPanel: import("svelte").Component<$$ComponentProps, {}, "">;
2
7
  type ControlPanel = ReturnType<typeof ControlPanel>;
3
8
  export default ControlPanel;