@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.
- package/README.md +38 -10
- package/dist/Sparkline.svelte +4 -4
- package/dist/TellegenMap.svelte +80 -66
- package/dist/TellegenProvider.svelte +29 -13
- package/dist/colors.d.ts +37 -1
- package/dist/colors.js +63 -0
- package/dist/components/AppFooter.svelte +4 -4
- package/dist/components/AppHeader.svelte +15 -19
- package/dist/components/BindingLines.svelte +10 -9
- package/dist/components/BusPicker.svelte +5 -6
- package/dist/components/ControlPanel.svelte +16 -11
- package/dist/components/ControlPanel.svelte.d.ts +6 -1
- package/dist/components/DemandSlider.svelte +26 -29
- package/dist/components/DiagramCanvas.svelte +16 -4
- package/dist/components/DisplayControls.svelte +5 -24
- package/dist/components/DropZone.svelte +7 -3
- package/dist/components/DropZone.svelte.d.ts +6 -17
- package/dist/components/FormulationSelector.svelte +2 -7
- package/dist/components/LocalCaseDetails.svelte +126 -126
- package/dist/components/LocalCaseDetails.svelte.d.ts +6 -1
- package/dist/components/MulticonductorDetails.svelte +48 -38
- package/dist/components/NetworkStats.svelte +14 -16
- package/dist/components/PanelCard.svelte +1 -1
- package/dist/components/PlacementCue.svelte +1 -1
- package/dist/components/RatingSlider.svelte +22 -23
- package/dist/components/RestoreDefaultsButton.svelte +2 -3
- package/dist/components/SensitivityReadout.svelte +9 -27
- package/dist/components/SolveCard.svelte +69 -35
- package/dist/components/TopMovers.svelte +5 -5
- package/dist/context.svelte.d.ts +6 -2
- package/dist/context.svelte.js +4 -2
- package/dist/controller.svelte.d.ts +7 -0
- package/dist/controller.svelte.js +37 -8
- package/dist/display.d.ts +3 -2
- package/dist/display.js +28 -11
- package/dist/format.d.ts +1 -9
- package/dist/format.js +1 -14
- package/dist/index.d.ts +1 -1
- package/dist/map.d.ts +1 -1
- package/dist/map.js +1 -1
- package/dist/state.svelte.d.ts +6 -1
- package/dist/state.svelte.js +1 -0
- package/dist/styles.css +3 -0
- package/dist/types.d.ts +1 -1
- 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.
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
package/dist/Sparkline.svelte
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
viewBox="0 0 {W} {H}"
|
|
36
36
|
preserveAspectRatio="none"
|
|
37
37
|
role="img"
|
|
38
|
-
aria-label="
|
|
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
|
-
|
|
49
|
+
Residual
|
|
50
50
|
</text>
|
|
51
|
-
<text class="axis-label x-label" x={W - R} y={H - 4} text-anchor="end">
|
|
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-
|
|
72
|
+
font-family: var(--font-display);
|
|
73
73
|
font-size: 12px;
|
|
74
74
|
letter-spacing: 0;
|
|
75
75
|
}
|
package/dist/TellegenMap.svelte
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
MAP_PALETTE,
|
|
11
11
|
branchColor,
|
|
12
12
|
branchWidth,
|
|
13
|
+
busMarkerRadius,
|
|
13
14
|
busNeutral,
|
|
14
|
-
|
|
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>
|
|
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>
|
|
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>
|
|
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 ? '
|
|
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)} – ${esc(e.to)}</b
|
|
737
|
+
html: `<div class="tt"><b>${esc(e.kind)} ${esc(e.from)} – ${esc(e.to)}</b><br>${e.n_phases} φ${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(', ') : '
|
|
742
|
+
const roles = b.attachmentKinds.length ? b.attachmentKinds.join(', ') : 'None';
|
|
739
743
|
return {
|
|
740
|
-
html: `<div class="tt"><b>
|
|
741
|
-
${b.terminals.length} terminals${b.grounded.length ? ` ⋅ ${b.grounded.length} grounded` : ''}<br>
|
|
742
|
-
load ${b.load_kw.toFixed(0)} kW ⋅ 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
|
-
: `
|
|
753
|
+
: `Rating ${b.rate_mw.toFixed(0)} MW`;
|
|
753
754
|
return {
|
|
754
|
-
html: `<div class="tt"><b>
|
|
755
|
+
html: `<div class="tt"><b>Line ${b.from} – ${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>
|
|
761
|
-
load ${bus.demand_mw.toFixed(0)} MW ⋅ 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
|
-
? `
|
|
773
|
-
: `
|
|
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>Δ LMP ${preview >= 0 ? '+' : ''}${preview.toExponential(2)} objective units/MW`;
|
|
782
|
-
|
|
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 ? '—' : fmt.format(price)} objective units/MW`
|
|
787
785
|
: `${d.scalarLabel} ${
|
|
788
786
|
scalar === undefined ? '—' : displayFmt(d.scalarMode, scalar)
|
|
789
787
|
} ${d.scalarUnit}<br>LMP ${price === undefined ? '—' : fmt.format(price)} objective units/MW`;
|
|
790
788
|
return {
|
|
791
|
-
html: `<div class="tt"><b>
|
|
792
|
-
${scalarRow}<br>
|
|
793
|
-
${loadRow} ⋅ 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
|
-
|
|
991
|
-
//
|
|
992
|
-
|
|
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 === '
|
|
1063
|
-
? (
|
|
1064
|
-
: mode === '
|
|
1065
|
-
? (s?.
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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) =>
|
|
1101
|
-
radiusUnits:
|
|
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
|
-
:
|
|
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) =>
|
|
1167
|
-
radiusUnits:
|
|
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) =>
|
|
1241
|
-
radiusUnits:
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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-
|
|
1801
|
-
font-size:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
};
|