@tellegen/svelte 0.4.0 → 0.4.2

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 (45) hide show
  1. package/README.md +38 -10
  2. package/dist/Sparkline.svelte +4 -4
  3. package/dist/TellegenMap.svelte +80 -66
  4. package/dist/TellegenProvider.svelte +29 -13
  5. package/dist/colors.d.ts +37 -1
  6. package/dist/colors.js +63 -0
  7. package/dist/components/AppFooter.svelte +4 -4
  8. package/dist/components/AppHeader.svelte +15 -19
  9. package/dist/components/BindingLines.svelte +10 -9
  10. package/dist/components/BusPicker.svelte +5 -6
  11. package/dist/components/ControlPanel.svelte +16 -11
  12. package/dist/components/ControlPanel.svelte.d.ts +6 -1
  13. package/dist/components/DemandSlider.svelte +26 -29
  14. package/dist/components/DiagramCanvas.svelte +16 -4
  15. package/dist/components/DisplayControls.svelte +5 -24
  16. package/dist/components/DropZone.svelte +7 -3
  17. package/dist/components/DropZone.svelte.d.ts +6 -17
  18. package/dist/components/FormulationSelector.svelte +2 -7
  19. package/dist/components/LocalCaseDetails.svelte +126 -126
  20. package/dist/components/LocalCaseDetails.svelte.d.ts +6 -1
  21. package/dist/components/MulticonductorDetails.svelte +48 -38
  22. package/dist/components/NetworkStats.svelte +14 -16
  23. package/dist/components/PanelCard.svelte +1 -1
  24. package/dist/components/PlacementCue.svelte +1 -1
  25. package/dist/components/RatingSlider.svelte +22 -23
  26. package/dist/components/RestoreDefaultsButton.svelte +2 -3
  27. package/dist/components/SensitivityReadout.svelte +9 -27
  28. package/dist/components/SolveCard.svelte +69 -35
  29. package/dist/components/TopMovers.svelte +5 -5
  30. package/dist/context.svelte.d.ts +6 -2
  31. package/dist/context.svelte.js +4 -2
  32. package/dist/controller.svelte.d.ts +7 -0
  33. package/dist/controller.svelte.js +37 -8
  34. package/dist/display.d.ts +3 -2
  35. package/dist/display.js +28 -11
  36. package/dist/format.d.ts +1 -9
  37. package/dist/format.js +1 -14
  38. package/dist/index.d.ts +1 -1
  39. package/dist/map.d.ts +1 -1
  40. package/dist/map.js +1 -1
  41. package/dist/state.svelte.d.ts +6 -1
  42. package/dist/state.svelte.js +1 -0
  43. package/dist/styles.css +3 -0
  44. package/dist/types.d.ts +1 -1
  45. 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
  }
@@ -10,8 +10,10 @@
10
10
  MAP_PALETTE,
11
11
  branchColor,
12
12
  branchWidth,
13
+ busMarkerRadius,
13
14
  busNeutral,
14
- busRadius,
15
+ busRadiusFor,
16
+ demandColor,
15
17
  priceColor,
16
18
  scalarDomain,
17
19
  sensColor,
@@ -31,7 +33,7 @@
31
33
  } from './basemap.js';
32
34
  import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
33
35
  import {
34
- CaseState,
36
+ type CaseState,
35
37
  type CameraSnapshot,
36
38
  type DisplayMode,
37
39
  type LocalCase
@@ -391,6 +393,8 @@
391
393
  if (d.previewDomain.flat) return sensFlatColor(d.previewDomain);
392
394
  return sensColor((d.preview.get(bus.id) ?? 0) / d.previewDomain.scale);
393
395
  }
396
+ if (d.scalarMode === 'demand')
397
+ return demandColor(((d.scalar.get(bus.id) ?? 0) - d.scalarLo) / (d.scalarHi - d.scalarLo));
394
398
  const mid = (d.scalarLo + d.scalarHi) / 2;
395
399
  return priceColor(((d.scalar.get(bus.id) ?? mid) - d.scalarLo) / (d.scalarHi - d.scalarLo));
396
400
  };
@@ -706,13 +710,13 @@
706
710
  if (info.layer.id === 'study-branches') {
707
711
  const flow = view?.flows?.find((f) => f.branch === id);
708
712
  return {
709
- 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)}%` : ''}`
710
714
  };
711
715
  }
712
716
  const p = view?.lmp?.find((v) => v.bus === id),
713
717
  v = view?.vm?.find((v) => v.bus === id);
714
718
  return {
715
- 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` : ''}`
716
720
  };
717
721
  }
718
722
  if (info.layer?.id.startsWith('local-subs-')) {
@@ -722,25 +726,22 @@
722
726
  const s = object as { number: number; name: string };
723
727
  const named = s.name ? ` ${esc(s.name)}` : '';
724
728
  return {
725
- 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>`
726
730
  };
727
731
  }
728
732
  const layerId = info.layer?.id;
729
733
  if (layerId?.startsWith('multi-edges-')) {
730
734
  const e = object as PlacedMultiEdge;
731
- const state = e.kind === 'switch' ? (e.closed ? ' &#8901; closed' : ' &#8901; open') : '';
735
+ const state = e.kind === 'switch' ? (e.closed ? '<br>Closed' : '<br>Open') : '';
732
736
  return {
733
- 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>`
734
738
  };
735
739
  }
736
740
  if (layerId?.startsWith('multi-buses-')) {
737
741
  const b = object as PlacedMultiBus;
738
- const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'none';
742
+ const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'None';
739
743
  return {
740
- html: `<div class="tt"><b>bus ${esc(b.id)}</b>
741
- ${b.terminals.length} terminals${b.grounded.length ? ` &#8901; ${b.grounded.length} grounded` : ''}<br>
742
- load ${b.load_kw.toFixed(0)} kW &#8901; gen ${b.gen_kw.toFixed(0)} kW<br>
743
- <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>`
744
745
  };
745
746
  }
746
747
  const c = caseOf(info);
@@ -749,17 +750,15 @@
749
750
  const b = object as NetworkBranch;
750
751
  const flow = d
751
752
  ? `${((d.loading.get(b.id) ?? 0) * 100).toFixed(0)}% of ${b.rate_mw.toFixed(0)} MW`
752
- : `rate ${b.rate_mw.toFixed(0)} MW`;
753
+ : `Rating ${b.rate_mw.toFixed(0)} MW`;
753
754
  return {
754
- 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>`
755
756
  };
756
757
  }
757
758
  const bus = object as NetworkBus;
758
759
  if (!c) {
759
760
  return {
760
- html: `<div class="tt"><b>bus ${bus.id}</b>
761
- load ${bus.demand_mw.toFixed(0)} MW &#8901; gen ${bus.gen_mw.toFixed(0)} MW<br>
762
- <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>`
763
762
  };
764
763
  }
765
764
  const price = d?.prices.get(bus.id);
@@ -769,8 +768,8 @@
769
768
  const delta = caseDeltas(c)[bus.id] ?? 0;
770
769
  const loadRow =
771
770
  delta === 0
772
- ? `load ${bus.demand_mw.toFixed(0)} MW`
773
- : `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)})`;
774
773
  const sensRow =
775
774
  sens === undefined
776
775
  ? ''
@@ -779,18 +778,15 @@
779
778
  preview === undefined
780
779
  ? ''
781
780
  : `<br>&Delta; LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
782
- const localRow =
783
- c instanceof CaseState ? '' : '<br><span style="opacity:0.6">local file</span>';
781
+ // Demand already has its own row below, so the demand colouring shows LMP here alone.
784
782
  const scalarRow =
785
- !d || d.scalarMode === 'price'
783
+ !d || d.scalarMode === 'price' || d.scalarMode === 'demand'
786
784
  ? `LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`
787
785
  : `${d.scalarLabel} ${
788
786
  scalar === undefined ? '&mdash;' : displayFmt(d.scalarMode, scalar)
789
787
  } ${d.scalarUnit}<br>LMP ${price === undefined ? '&mdash;' : fmt.format(price)} objective units/MW`;
790
788
  return {
791
- html: `<div class="tt"><b>bus ${bus.id}</b>
792
- ${scalarRow}<br>
793
- ${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>`
794
790
  };
795
791
  }
796
792
 
@@ -987,25 +983,9 @@
987
983
 
988
984
  map = m;
989
985
  overlay = o;
990
- const detachReady = onMapReady?.(m, o);
991
- // Frame the active grid once the map is ready. The initial frame request
992
- // (from the case load) can fire before the async map import finishes and
993
- // be lost; re-issuing it here ensures the first view is the active case,
994
- // not the default world view. A rebuilt map opens at the default view
995
- // too, so it restores the camera the last map saved.
996
- if (restoreCamera && app.camera) m.jumpTo(app.camera);
997
- else if (app.cameraSeq === handledCameraSeq)
998
- app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
999
- if (mapGen > 0) {
1000
- // This is a rebuilt map. Once it has held a live context without
1001
- // another loss, zero the budget so the cap only ever catches a tight
1002
- // loss loop, not recoveries spread across a long session.
1003
- stabilizeTimer = setTimeout(() => {
1004
- glRebuilds = 0;
1005
- stabilizeTimer = null;
1006
- }, 10000);
1007
- }
1008
-
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);
1009
989
  cleanup = () => {
1010
990
  clearRebuild();
1011
991
  clearStabilize();
@@ -1023,6 +1003,24 @@
1023
1003
  map = null;
1024
1004
  overlay = null;
1025
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
+ }
1026
1024
  })
1027
1025
  .catch((e) => {
1028
1026
  if (!cancelled) app.error = `map failed to load: ${e instanceof Error ? e.message : e}`;
@@ -1054,20 +1052,23 @@
1054
1052
  if (!overlay || !layerCtors) return;
1055
1053
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
1056
1054
  const layers: Layer[] = [];
1055
+ const marker = busMarkerRadius(app.busSizing);
1057
1056
  const study = app.studyView;
1058
1057
  if (study) {
1059
1058
  const s = study.solution;
1060
1059
  const mode = app.displayMode;
1061
1060
  const series =
1062
- mode === 'angle'
1063
- ? (s?.va ?? [])
1064
- : mode === 'voltage'
1065
- ? (s?.vm ??
1066
- (s?.w ?? []).map((v) => ({
1067
- ...v,
1068
- value: Math.sqrt(Math.max(0, v.value))
1069
- })))
1070
- : (s?.lmp ?? []);
1061
+ mode === 'demand'
1062
+ ? study.network.buses.map((b) => ({ bus: b.id, value: b.demand_mw }))
1063
+ : mode === 'angle'
1064
+ ? (s?.va ?? [])
1065
+ : mode === 'voltage'
1066
+ ? (s?.vm ??
1067
+ (s?.w ?? []).map((v) => ({
1068
+ ...v,
1069
+ value: Math.sqrt(Math.max(0, v.value))
1070
+ })))
1071
+ : (s?.lmp ?? []);
1071
1072
  const values = new Map(series.map((v) => [v.bus, v.value]));
1072
1073
  const domain = scalarDomain(
1073
1074
  mode,
@@ -1097,13 +1098,17 @@
1097
1098
  id: 'study-buses',
1098
1099
  data: study.network.buses,
1099
1100
  getPosition: (b) => [b.lon, b.lat],
1100
- getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
1101
- radiusUnits: 'pixels',
1101
+ getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
1102
+ radiusUnits: marker.radiusUnits,
1103
+ radiusMinPixels: marker.radiusMinPixels,
1104
+ radiusMaxPixels: marker.radiusMaxPixels,
1102
1105
  getFillColor: (b) => {
1103
1106
  const value = values.get(b.id);
1104
1107
  return value === undefined
1105
1108
  ? palette.grey
1106
- : priceColor((value - domain.lo) / Math.max(domain.hi - domain.lo, 1e-12));
1109
+ : (mode === 'demand' ? demandColor : priceColor)(
1110
+ (value - domain.lo) / Math.max(domain.hi - domain.lo, 1e-12)
1111
+ );
1107
1112
  },
1108
1113
  pickable: true,
1109
1114
  stroked: true,
@@ -1116,7 +1121,7 @@
1116
1121
  app.selectedBus = bus.id;
1117
1122
  return true;
1118
1123
  },
1119
- updateTriggers: { getFillColor: [study, mode, theme] }
1124
+ updateTriggers: { getFillColor: [study, mode, theme], getRadius: [app.busSizing] }
1120
1125
  })
1121
1126
  );
1122
1127
  overlay.setProps({ layers: [...layers, ...extraLayers] });
@@ -1163,8 +1168,10 @@
1163
1168
  id: `buses-${c.id}`,
1164
1169
  data: c.network.buses,
1165
1170
  getPosition: (b) => [b.lon, b.lat],
1166
- getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
1167
- radiusUnits: 'pixels',
1171
+ getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
1172
+ radiusUnits: marker.radiusUnits,
1173
+ radiusMinPixels: marker.radiusMinPixels,
1174
+ radiusMaxPixels: marker.radiusMaxPixels,
1168
1175
  getFillColor: busFill(c.id),
1169
1176
  stroked: true,
1170
1177
  billboard: true,
@@ -1184,6 +1191,7 @@
1184
1191
  return true;
1185
1192
  },
1186
1193
  updateTriggers: {
1194
+ getRadius: [app.busSizing],
1187
1195
  getFillColor: [display],
1188
1196
  getLineColor: [app.selectedBus, app.activeCaseId, theme],
1189
1197
  getLineWidth: [app.selectedBus, app.activeCaseId]
@@ -1237,8 +1245,10 @@
1237
1245
  id: `local-buses-${c.id}`,
1238
1246
  data: c.view.buses,
1239
1247
  getPosition: (b) => [b.lon, b.lat],
1240
- getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
1241
- radiusUnits: 'pixels',
1248
+ getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
1249
+ radiusUnits: marker.radiusUnits,
1250
+ radiusMinPixels: marker.radiusMinPixels,
1251
+ radiusMaxPixels: marker.radiusMaxPixels,
1242
1252
  getFillColor: d ? busFill(c.id) : palette.grey,
1243
1253
  stroked: true,
1244
1254
  billboard: true,
@@ -1258,6 +1268,7 @@
1258
1268
  return true;
1259
1269
  },
1260
1270
  updateTriggers: {
1271
+ getRadius: [app.busSizing],
1261
1272
  getFillColor: [display],
1262
1273
  getLineColor: [app.selectedBus, app.activeLocalId, theme],
1263
1274
  getLineWidth: [app.selectedBus, app.activeLocalId]
@@ -1351,7 +1362,7 @@
1351
1362
  id: `multi-buses-${c.id}`,
1352
1363
  data: c.view.buses,
1353
1364
  getPosition: (b) => [b.lon, b.lat],
1354
- getRadius: (b) => busRadius(Math.max(b.load_kw, b.gen_kw)),
1365
+ getRadius: (b) => busRadiusFor(app.busSizing, Math.max(b.load_kw, b.gen_kw)),
1355
1366
  radiusUnits: 'pixels',
1356
1367
  radiusMinPixels: 3,
1357
1368
  getFillColor: (b) => multiBusFill(b),
@@ -1375,6 +1386,7 @@
1375
1386
  return true;
1376
1387
  },
1377
1388
  updateTriggers: {
1389
+ getRadius: [app.busSizing],
1378
1390
  getLineColor: [selectedId, theme],
1379
1391
  getLineWidth: [selectedId]
1380
1392
  }
@@ -1523,14 +1535,14 @@
1523
1535
  {#if app.placingId && map}
1524
1536
  <button
1525
1537
  type="button"
1526
- class="place-at-center mono"
1538
+ class="place-at-center"
1527
1539
  onclick={() => {
1528
1540
  if (!map) return;
1529
1541
  const center = map.getCenter();
1530
1542
  onplacecase(center.lng, center.lat);
1531
1543
  }}
1532
1544
  >
1533
- place at map center
1545
+ Place at map center
1534
1546
  </button>
1535
1547
  {/if}
1536
1548
 
@@ -1720,6 +1732,7 @@
1720
1732
  border: 1px solid var(--accent);
1721
1733
  border-radius: 3px;
1722
1734
  color: var(--text-accent);
1735
+ font-family: inherit;
1723
1736
  font-size: 11px;
1724
1737
  cursor: pointer;
1725
1738
  box-shadow: var(--elev-cue);
@@ -1797,8 +1810,9 @@
1797
1810
  background: var(--panel) !important;
1798
1811
  border: 1px solid var(--line);
1799
1812
  color: var(--ink) !important;
1800
- font-family: var(--font-mono);
1801
- font-size: 11px;
1813
+ font-family: var(--font-display);
1814
+ font-size: 12px;
1815
+ font-variant-numeric: tabular-nums;
1802
1816
  line-height: 1.5;
1803
1817
  padding: 8px 10px !important;
1804
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
package/dist/colors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DisplayMode } from './state.svelte.js';
1
+ import type { BusSizing, DisplayMode } from './state.svelte.js';
2
2
  export type RGBA = [number, number, number, number];
3
3
  export interface SensitivityDomain {
4
4
  min: number;
@@ -10,6 +10,13 @@ export interface SensitivityDomain {
10
10
  }
11
11
  export declare const priceColor: (t: number) => RGBA;
12
12
  export declare const priceGradient: string;
13
+ /** Demand colour for a fraction `t` of the demand domain, on a square-root
14
+ * scale: bus demand is heavy tailed (a few large cities, many small towns),
15
+ * and a linear scale would paint everything but the largest buses one hue. */
16
+ export declare const demandColor: (t: number) => RGBA;
17
+ /** The demand ramp as a CSS gradient whose stops sit where the square-root
18
+ * scale puts them, so a legend bar reads linearly from its low to high label. */
19
+ export declare const demandGradient: string;
13
20
  /** Trimmed nodal value color domain: 5th to 95th percentile, with a one-unit
14
21
  * minimum span. Nodal values are heavy tailed when one binding line pins a
15
22
  * handful of buses far from the pack) so min-max scaling compresses the pack
@@ -20,6 +27,14 @@ export declare function priceDomain(values: number[]): {
20
27
  lo: number;
21
28
  hi: number;
22
29
  };
30
+ /** Demand colour domain: zero to the 98th percentile, at least 1 MW. Zero
31
+ * anchors the scale so a bus without load reads as none; the percentile keeps
32
+ * one metropolis from washing out every other bus, and the few above it clamp
33
+ * to the top of the ramp. */
34
+ export declare function demandDomain(values: number[]): {
35
+ lo: number;
36
+ hi: number;
37
+ };
23
38
  /** Center a scalar display variable (bus voltage angle, |V|) on a symmetric domain
24
39
  * with a per-mode minimum span; nodal values keep their own robust quantile domain. Shared by
25
40
  * the control panel readout and the map color scale. */
@@ -66,3 +81,24 @@ export declare function branchWidth(loading: number): number;
66
81
  /** Bus radius in px; area tracks max(load, gen) so big plants and big loads
67
82
  * both read at a glance. Shared by the map layers and the size legend. */
68
83
  export declare function busRadius(maxMw: number): number;
84
+ /** Radius in px of every bus marker under `uniform` sizing, and the most a
85
+ * uniform marker grows to on the map. */
86
+ export declare const UNIFORM_BUS_RADIUS = 4.5;
87
+ /** The least a uniform marker shrinks to on the map, in px. */
88
+ export declare const UNIFORM_BUS_MIN_RADIUS = 2;
89
+ /** The ground radius of a uniform marker on the map, in metres. */
90
+ export declare const UNIFORM_BUS_GROUND_RADIUS = 9000;
91
+ /** Bus radius in px under a sizing choice: `power` scales with max(load, gen)
92
+ * through `busRadius`, `uniform` draws every bus alike. */
93
+ export declare function busRadiusFor(sizing: BusSizing, maxMw: number): number;
94
+ /** Radius settings for the map's bus markers under a sizing choice. `power`
95
+ * sizes in pixels through `busRadius`. `uniform` gives every bus one ground
96
+ * radius, drawn between `UNIFORM_BUS_MIN_RADIUS` and `UNIFORM_BUS_RADIUS`
97
+ * pixels, so a whole country reads as a mesh of dots with its lines showing
98
+ * and a closer view as distinct buses. */
99
+ export declare function busMarkerRadius(sizing: BusSizing): {
100
+ radius: (maxMw: number) => number;
101
+ radiusUnits: 'meters' | 'pixels';
102
+ radiusMinPixels: number;
103
+ radiusMaxPixels: number;
104
+ };