@tellegen/svelte 0.5.1 → 0.5.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 +7 -0
- package/dist/TellegenMap.svelte +81 -8
- package/dist/TellegenMap.svelte.d.ts +5 -0
- package/dist/bus-kinds.d.ts +9 -3
- package/dist/bus-kinds.js +27 -13
- package/dist/declutter.d.ts +52 -0
- package/dist/declutter.js +95 -0
- package/dist/state.svelte.d.ts +4 -0
- package/dist/state.svelte.js +6 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -260,6 +260,13 @@ besides its click callbacks:
|
|
|
260
260
|
- `extraLayers`: deck.gl layers drawn after tellegen's own, in the same
|
|
261
261
|
overlay.
|
|
262
262
|
- `attribution`: `'compact'` (default) or `'full'`. There is no off switch.
|
|
263
|
+
- `declutterMarkers`: default `true`. At low zoom the map draws one bus
|
|
264
|
+
marker per crowded spot, about 16 pixels across. It keeps a generator
|
|
265
|
+
before a substation before any other bus, as recorded in a grid made by
|
|
266
|
+
tellegen-synth, then the bus with the most generation and demand. Zooming
|
|
267
|
+
in shows the rest. The selected bus always draws, and every bus is still
|
|
268
|
+
in the case: the bus picker finds it and a line's tooltip names it.
|
|
269
|
+
`false` draws every marker at every zoom.
|
|
263
270
|
- `onMapReady(map, overlay)`: called with the MapLibre map and the deck
|
|
264
271
|
overlay once they exist. It may return a cleanup, and it fires again after
|
|
265
272
|
a context-loss or basemap rebuild. If it throws, the map and its window
|
package/dist/TellegenMap.svelte
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
type MapTheme
|
|
32
32
|
} from './basemap.js';
|
|
33
33
|
import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
|
|
34
|
+
import { declutterBuses, declutterZoom, withBus } from './declutter.js';
|
|
34
35
|
import {
|
|
35
36
|
type CaseState,
|
|
36
37
|
type CameraSnapshot,
|
|
@@ -84,6 +85,7 @@
|
|
|
84
85
|
cartoApiKey,
|
|
85
86
|
extraLayers = [],
|
|
86
87
|
attribution = 'compact',
|
|
88
|
+
declutterMarkers = true,
|
|
87
89
|
onMapReady,
|
|
88
90
|
onBasemapFallback,
|
|
89
91
|
onBasemapError
|
|
@@ -125,6 +127,11 @@
|
|
|
125
127
|
/** Attribution control mode. There is no off switch: tile licences
|
|
126
128
|
* require the credit on screen. */
|
|
127
129
|
attribution?: 'compact' | 'full';
|
|
130
|
+
/** At low zoom, draw one bus marker per crowded spot: a generator before
|
|
131
|
+
* a substation before any other bus, then the larger bus. Zooming in
|
|
132
|
+
* shows the rest, and a selected bus always draws. `false` draws every
|
|
133
|
+
* marker at every zoom. */
|
|
134
|
+
declutterMarkers?: boolean;
|
|
128
135
|
/** Called once the map and overlay exist, with an optional cleanup. It
|
|
129
136
|
* fires again after a context-loss or basemap remount, after the previous
|
|
130
137
|
* cleanup ran. It runs before the first style loads, and a switch to the
|
|
@@ -232,6 +239,11 @@
|
|
|
232
239
|
let mapGen = $state(0);
|
|
233
240
|
// Cap automatic remounts so sustained GPU pressure cannot loop forever.
|
|
234
241
|
let glRebuilds = 0;
|
|
242
|
+
// The zoom the bus markers are thinned at, in quarter steps, so panning
|
|
243
|
+
// never thins them again. A zoom gesture reads it at most every
|
|
244
|
+
// MARKER_ZOOM_SAMPLE_MS, and the end of every zoom reads it at once.
|
|
245
|
+
let markerZoom = $state(declutterZoom(DEFAULT_CAMERA.zoom));
|
|
246
|
+
const MARKER_ZOOM_SAMPLE_MS = 150;
|
|
235
247
|
|
|
236
248
|
type LayerCtors = {
|
|
237
249
|
PathLayer: typeof PathLayer;
|
|
@@ -441,6 +453,52 @@
|
|
|
441
453
|
return primary ? attachmentColor(primary) : busNeutral;
|
|
442
454
|
}
|
|
443
455
|
|
|
456
|
+
// The buses each bus layer could draw at the current zoom: with
|
|
457
|
+
// `declutterMarkers`, one per crowded spot (see declutterBuses). A generated
|
|
458
|
+
// grid's line taps are left out before thinning, and its bus kinds rank the
|
|
459
|
+
// buses. Selection is not read here, so selecting a bus thins nothing again.
|
|
460
|
+
const thinnedMarkers = $derived.by(() => {
|
|
461
|
+
const zoom = markerZoom;
|
|
462
|
+
const thin = (buses: NetworkBus[], kinds?: ReadonlyMap<number, string>) =>
|
|
463
|
+
declutterMarkers ? declutterBuses(buses, { zoom, kinds }) : buses;
|
|
464
|
+
const sets = new Map<string, NetworkBus[]>();
|
|
465
|
+
if (app.studyView) {
|
|
466
|
+
sets.set('study-buses', thin(app.studyView.network.buses));
|
|
467
|
+
return sets;
|
|
468
|
+
}
|
|
469
|
+
for (const c of app.cases) if (c.network) sets.set(`buses-${c.id}`, thin(c.network.buses));
|
|
470
|
+
for (const c of app.localCases) {
|
|
471
|
+
if (c.view) sets.set(`local-buses-${c.id}`, thin(c.markerBuses, c.busKinds));
|
|
472
|
+
}
|
|
473
|
+
return sets;
|
|
474
|
+
});
|
|
475
|
+
// The data of each bus layer: its thinned buses, plus the selected bus when
|
|
476
|
+
// thinning (or a line tap's missing marker) left it out, so it keeps its
|
|
477
|
+
// marker and selection ring. The arrays keep their identity until the zoom
|
|
478
|
+
// bucket or the selection changes them, so deck.gl does not rebuild the
|
|
479
|
+
// layer's attributes on every redraw.
|
|
480
|
+
const busMarkers = $derived.by(() => {
|
|
481
|
+
const selected = app.selectedBus;
|
|
482
|
+
const sets = new Map<string, NetworkBus[]>();
|
|
483
|
+
const add = (id: string, all: NetworkBus[], pinned: number | null) => {
|
|
484
|
+
const shown = thinnedMarkers.get(id);
|
|
485
|
+
if (shown) sets.set(id, withBus(shown, all, pinned));
|
|
486
|
+
};
|
|
487
|
+
if (app.studyView) {
|
|
488
|
+
add('study-buses', app.studyView.network.buses, selected);
|
|
489
|
+
return sets;
|
|
490
|
+
}
|
|
491
|
+
for (const c of app.cases) {
|
|
492
|
+
if (c.network)
|
|
493
|
+
add(`buses-${c.id}`, c.network.buses, c.id === app.activeCaseId ? selected : null);
|
|
494
|
+
}
|
|
495
|
+
for (const c of app.localCases) {
|
|
496
|
+
if (c.view)
|
|
497
|
+
add(`local-buses-${c.id}`, c.view.buses, c.id === app.activeLocalId ? selected : null);
|
|
498
|
+
}
|
|
499
|
+
return sets;
|
|
500
|
+
});
|
|
501
|
+
|
|
444
502
|
// Layers whose click selects something (a bus or a branch); the overlay-level
|
|
445
503
|
// click handler must not treat those clicks as "empty map" and clear the selection.
|
|
446
504
|
function isSelectableLayer(layerId: string | undefined): boolean {
|
|
@@ -967,6 +1025,18 @@
|
|
|
967
1025
|
if (document.visibilityState === 'visible') repaint();
|
|
968
1026
|
};
|
|
969
1027
|
const syncCues = () => scheduleCueSync();
|
|
1028
|
+
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1029
|
+
const readZoom = () => {
|
|
1030
|
+
if (zoomTimer !== null) {
|
|
1031
|
+
clearTimeout(zoomTimer);
|
|
1032
|
+
zoomTimer = null;
|
|
1033
|
+
}
|
|
1034
|
+
markerZoom = declutterZoom(m.getZoom());
|
|
1035
|
+
};
|
|
1036
|
+
const sampleZoom = () => {
|
|
1037
|
+
zoomTimer ??= setTimeout(readZoom, MARKER_ZOOM_SAMPLE_MS);
|
|
1038
|
+
};
|
|
1039
|
+
readZoom();
|
|
970
1040
|
const saveCamera = () => {
|
|
971
1041
|
const center = m.getCenter();
|
|
972
1042
|
app.camera = {
|
|
@@ -1024,6 +1094,9 @@
|
|
|
1024
1094
|
m.on('load', syncCues);
|
|
1025
1095
|
m.on('move', syncCues);
|
|
1026
1096
|
m.on('resize', syncCues);
|
|
1097
|
+
// 'zoom' does not fire while the camera only pans.
|
|
1098
|
+
m.on('zoom', sampleZoom);
|
|
1099
|
+
m.on('zoomend', readZoom);
|
|
1027
1100
|
|
|
1028
1101
|
map = m;
|
|
1029
1102
|
overlay = o;
|
|
@@ -1039,6 +1112,9 @@
|
|
|
1039
1112
|
m.off('load', syncCues);
|
|
1040
1113
|
m.off('move', syncCues);
|
|
1041
1114
|
m.off('resize', syncCues);
|
|
1115
|
+
m.off('zoom', sampleZoom);
|
|
1116
|
+
m.off('zoomend', readZoom);
|
|
1117
|
+
if (zoomTimer !== null) clearTimeout(zoomTimer);
|
|
1042
1118
|
m.off('style.load', applyStyleExtras);
|
|
1043
1119
|
detachFallback();
|
|
1044
1120
|
detachReady?.();
|
|
@@ -1151,7 +1227,7 @@
|
|
|
1151
1227
|
}),
|
|
1152
1228
|
new ScatterplotLayer<NetworkBus>({
|
|
1153
1229
|
id: 'study-buses',
|
|
1154
|
-
data: study.network.buses,
|
|
1230
|
+
data: busMarkers.get('study-buses') ?? study.network.buses,
|
|
1155
1231
|
getPosition: (b) => [b.lon, b.lat],
|
|
1156
1232
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1157
1233
|
radiusUnits: marker.radiusUnits,
|
|
@@ -1222,7 +1298,7 @@
|
|
|
1222
1298
|
}),
|
|
1223
1299
|
new ScatterplotLayer<NetworkBus>({
|
|
1224
1300
|
id: `buses-${c.id}`,
|
|
1225
|
-
data: c.network.buses,
|
|
1301
|
+
data: busMarkers.get(`buses-${c.id}`) ?? c.network.buses,
|
|
1226
1302
|
getPosition: (b) => [b.lon, b.lat],
|
|
1227
1303
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1228
1304
|
radiusUnits: marker.radiusUnits,
|
|
@@ -1258,14 +1334,11 @@
|
|
|
1258
1334
|
// Local cases are grey until the browser solve returns. A .pwd display
|
|
1259
1335
|
// file contributes substation points only, in a cooler slate so they
|
|
1260
1336
|
// read as diagram derived positions. A generated grid's line taps draw
|
|
1261
|
-
// no marker, as their lines show them, unless one is the selected bus
|
|
1337
|
+
// no marker, as their lines show them, unless one is the selected bus
|
|
1338
|
+
// (see busMarkers).
|
|
1262
1339
|
for (const c of app.localCases) {
|
|
1263
1340
|
if (c.view) {
|
|
1264
1341
|
const d = display.get(c.id);
|
|
1265
|
-
const selectedTap =
|
|
1266
|
-
c.id === app.activeLocalId && app.selectedBus !== null && c.tapBuses.has(app.selectedBus)
|
|
1267
|
-
? c.view.buses.find((b) => b.id === app.selectedBus)
|
|
1268
|
-
: undefined;
|
|
1269
1342
|
layers.push(
|
|
1270
1343
|
new PathLayer<NetworkBranch>({
|
|
1271
1344
|
id: `local-branches-${c.id}`,
|
|
@@ -1305,7 +1378,7 @@
|
|
|
1305
1378
|
}),
|
|
1306
1379
|
new ScatterplotLayer<NetworkBus>({
|
|
1307
1380
|
id: `local-buses-${c.id}`,
|
|
1308
|
-
data:
|
|
1381
|
+
data: busMarkers.get(`local-buses-${c.id}`) ?? c.markerBuses,
|
|
1309
1382
|
getPosition: (b) => [b.lon, b.lat],
|
|
1310
1383
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1311
1384
|
radiusUnits: marker.radiusUnits,
|
|
@@ -42,6 +42,11 @@ type $$ComponentProps = {
|
|
|
42
42
|
/** Attribution control mode. There is no off switch: tile licences
|
|
43
43
|
* require the credit on screen. */
|
|
44
44
|
attribution?: 'compact' | 'full';
|
|
45
|
+
/** At low zoom, draw one bus marker per crowded spot: a generator before
|
|
46
|
+
* a substation before any other bus, then the larger bus. Zooming in
|
|
47
|
+
* shows the rest, and a selected bus always draws. `false` draws every
|
|
48
|
+
* marker at every zoom. */
|
|
49
|
+
declutterMarkers?: boolean;
|
|
45
50
|
/** Called once the map and overlay exist, with an optional cleanup. It
|
|
46
51
|
* fires again after a context-loss or basemap remount, after the previous
|
|
47
52
|
* cleanup ran. It runs before the first style loads, and a switch to the
|
package/dist/bus-kinds.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { NetworkBus } from './api.js';
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
* each bus row's `extras.tellegen_synth.kind`; the map view does not carry
|
|
2
|
+
/** Each bus's kind as a generated grid records it, by bus id: `generator`,
|
|
3
|
+
* `substation`, `converter`, `load` or `tap`. tellegen-synth writes the kind
|
|
4
|
+
* in each bus row's `extras.tellegen_synth.kind`; the map view does not carry
|
|
5
5
|
* it. Empty for any other module, and for text that does not parse. */
|
|
6
|
+
export declare function readBusKinds(moduleJson: string | null | undefined): ReadonlyMap<number, string>;
|
|
7
|
+
/** Ids of the buses in `kinds` that are line taps: points where a line is
|
|
8
|
+
* tapped, with no load or machine. */
|
|
9
|
+
export declare function tapsOf(kinds: ReadonlyMap<number, string>): ReadonlySet<number>;
|
|
10
|
+
/** Ids of the buses a generated grid marks as line taps. Empty for any other
|
|
11
|
+
* module, and for text that does not parse. */
|
|
6
12
|
export declare function tapBusIds(moduleJson: string | null | undefined): ReadonlySet<number>;
|
|
7
13
|
/** `buses` less the taps, the ones the map draws as markers. The same array
|
|
8
14
|
* when there is no tap to drop, so the map layer keeps its data. */
|
package/dist/bus-kinds.js
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
1
1
|
/** The `extras` key under which tellegen-synth records each element's
|
|
2
2
|
* annotation in the modules it writes. */
|
|
3
3
|
const SYNTH_EXTRAS = 'tellegen_synth';
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
* each bus row's `extras.tellegen_synth.kind`; the map view does not carry
|
|
4
|
+
/** Each bus's kind as a generated grid records it, by bus id: `generator`,
|
|
5
|
+
* `substation`, `converter`, `load` or `tap`. tellegen-synth writes the kind
|
|
6
|
+
* in each bus row's `extras.tellegen_synth.kind`; the map view does not carry
|
|
7
7
|
* it. Empty for any other module, and for text that does not parse. */
|
|
8
|
-
export function
|
|
9
|
-
const
|
|
10
|
-
// Only a module tellegen-synth wrote is parsed a second time
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return ids;
|
|
8
|
+
export function readBusKinds(moduleJson) {
|
|
9
|
+
const kinds = new Map();
|
|
10
|
+
// Only a module tellegen-synth wrote is parsed a second time.
|
|
11
|
+
if (!moduleJson?.includes(`"${SYNTH_EXTRAS}"`))
|
|
12
|
+
return kinds;
|
|
14
13
|
let buses;
|
|
15
14
|
try {
|
|
16
15
|
buses = JSON.parse(moduleJson)?.value?.data?.buses;
|
|
17
16
|
}
|
|
18
17
|
catch {
|
|
19
|
-
return
|
|
18
|
+
return kinds;
|
|
20
19
|
}
|
|
21
20
|
if (!Array.isArray(buses))
|
|
22
|
-
return
|
|
21
|
+
return kinds;
|
|
23
22
|
for (const bus of buses) {
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const kind = bus?.extras?.[SYNTH_EXTRAS]?.kind;
|
|
24
|
+
if (typeof bus?.id === 'number' && typeof kind === 'string')
|
|
25
|
+
kinds.set(bus.id, kind);
|
|
26
26
|
}
|
|
27
|
+
return kinds;
|
|
28
|
+
}
|
|
29
|
+
/** Ids of the buses in `kinds` that are line taps: points where a line is
|
|
30
|
+
* tapped, with no load or machine. */
|
|
31
|
+
export function tapsOf(kinds) {
|
|
32
|
+
const ids = new Set();
|
|
33
|
+
for (const [id, kind] of kinds)
|
|
34
|
+
if (kind === 'tap')
|
|
35
|
+
ids.add(id);
|
|
27
36
|
return ids;
|
|
28
37
|
}
|
|
38
|
+
/** Ids of the buses a generated grid marks as line taps. Empty for any other
|
|
39
|
+
* module, and for text that does not parse. */
|
|
40
|
+
export function tapBusIds(moduleJson) {
|
|
41
|
+
return tapsOf(readBusKinds(moduleJson));
|
|
42
|
+
}
|
|
29
43
|
/** `buses` less the taps, the ones the map draws as markers. The same array
|
|
30
44
|
* when there is no tap to drop, so the map layer keeps its data. */
|
|
31
45
|
export function withoutTaps(buses, taps) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Bus fields the declutter reads. `NetworkBus` has all of them. */
|
|
2
|
+
export interface DeclutterBus {
|
|
3
|
+
id: number;
|
|
4
|
+
lon: number;
|
|
5
|
+
lat: number;
|
|
6
|
+
demand_mw: number;
|
|
7
|
+
gen_mw: number;
|
|
8
|
+
}
|
|
9
|
+
export interface DeclutterOptions {
|
|
10
|
+
/** Map zoom the cells are laid out at. Pass a bucketed zoom
|
|
11
|
+
* (`declutterZoom`) so small camera moves keep the same result. */
|
|
12
|
+
zoom: number;
|
|
13
|
+
/** Cell side in CSS pixels. Each cell keeps one bus. */
|
|
14
|
+
cellPx?: number;
|
|
15
|
+
/** Bus kinds by id, as tellegen-synth records them (`generator`,
|
|
16
|
+
* `substation`, `converter`, `load`, `tap`). Without them, buses rank by
|
|
17
|
+
* power alone. */
|
|
18
|
+
kinds?: ReadonlyMap<number, string>;
|
|
19
|
+
}
|
|
20
|
+
/** Default cell side in CSS pixels: about twice the diameter of the marker
|
|
21
|
+
* for a bus of up to about 10 MW. */
|
|
22
|
+
export declare const DECLUTTER_CELL_PX = 16;
|
|
23
|
+
/** Zoom step the map lays its cells out at. */
|
|
24
|
+
export declare const DECLUTTER_ZOOM_STEP = 0.25;
|
|
25
|
+
/** `zoom` rounded down to a whole `step`, so panning and small zooms land on
|
|
26
|
+
* the same value and leave the thinned markers as they are. */
|
|
27
|
+
export declare function declutterZoom(zoom: number, step?: number): number;
|
|
28
|
+
/** How strongly a bus kind claims its cell: a generator before a substation
|
|
29
|
+
* or converter station, before anything else. */
|
|
30
|
+
export declare function kindRank(kind: string | undefined): number;
|
|
31
|
+
/** The buses a map at `zoom` draws markers for, so that a crowded grid does
|
|
32
|
+
* not cover the map. Each bus is placed in Web Mercator world pixels (at a
|
|
33
|
+
* MapLibre zoom the world is 512·2^zoom CSS pixels square) and binned into
|
|
34
|
+
* square cells of `cellPx`; each cell keeps its strongest bus. A generator
|
|
35
|
+
* outranks a substation or converter, which outranks any other bus. Then the
|
|
36
|
+
* larger |generation| + |demand| wins, then the lower id, so the result does
|
|
37
|
+
* not depend on the input order. The selection is not an input: `withBus`
|
|
38
|
+
* adds a selected bus back on top, so selecting one never hides another.
|
|
39
|
+
*
|
|
40
|
+
* The kept buses come back in input order. When no two buses share a cell,
|
|
41
|
+
* the input array itself comes back, so a map layer keeps its data. Longitudes
|
|
42
|
+
* wrap, so 190 and -170 share a cell, and the cells tile the world with ±180
|
|
43
|
+
* on a cell edge, so the seam thins like any other place. Latitudes clamp to
|
|
44
|
+
* the Mercator limit. A bus without finite coordinates is kept, as there is no
|
|
45
|
+
* cell to place it in. */
|
|
46
|
+
export declare function declutterBuses<T extends DeclutterBus>(buses: T[], options: DeclutterOptions): T[];
|
|
47
|
+
/** `shown` with bus `id` from `all` added at the end when it is missing, so a
|
|
48
|
+
* selected bus that the map thinned out, or a line tap it never draws, still
|
|
49
|
+
* gets its marker. `shown` itself when there is nothing to add. */
|
|
50
|
+
export declare function withBus<T extends {
|
|
51
|
+
id: number;
|
|
52
|
+
}>(shown: T[], all: readonly T[], id: number | null | undefined): T[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Default cell side in CSS pixels: about twice the diameter of the marker
|
|
2
|
+
* for a bus of up to about 10 MW. */
|
|
3
|
+
export const DECLUTTER_CELL_PX = 16;
|
|
4
|
+
/** Zoom step the map lays its cells out at. */
|
|
5
|
+
export const DECLUTTER_ZOOM_STEP = 0.25;
|
|
6
|
+
// The Web Mercator world is this many CSS pixels across at zoom 0, as MapLibre
|
|
7
|
+
// and deck.gl lay it out.
|
|
8
|
+
const WORLD_PX = 512;
|
|
9
|
+
// Web Mercator's latitude limit, where the square world ends.
|
|
10
|
+
const MAX_LAT = 85.0511287798066;
|
|
11
|
+
/** `zoom` rounded down to a whole `step`, so panning and small zooms land on
|
|
12
|
+
* the same value and leave the thinned markers as they are. */
|
|
13
|
+
export function declutterZoom(zoom, step = DECLUTTER_ZOOM_STEP) {
|
|
14
|
+
// Snap first, so 4.75 / 0.25 does not land a hair under 19.
|
|
15
|
+
return Math.floor(Math.round((zoom / step) * 1e6) / 1e6) * step;
|
|
16
|
+
}
|
|
17
|
+
/** How strongly a bus kind claims its cell: a generator before a substation
|
|
18
|
+
* or converter station, before anything else. */
|
|
19
|
+
export function kindRank(kind) {
|
|
20
|
+
if (kind === 'generator')
|
|
21
|
+
return 2;
|
|
22
|
+
if (kind === 'substation' || kind === 'converter')
|
|
23
|
+
return 1;
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
function busPower(bus) {
|
|
27
|
+
const power = Math.abs(bus.gen_mw) + Math.abs(bus.demand_mw);
|
|
28
|
+
return Number.isFinite(power) ? power : 0;
|
|
29
|
+
}
|
|
30
|
+
/** The buses a map at `zoom` draws markers for, so that a crowded grid does
|
|
31
|
+
* not cover the map. Each bus is placed in Web Mercator world pixels (at a
|
|
32
|
+
* MapLibre zoom the world is 512·2^zoom CSS pixels square) and binned into
|
|
33
|
+
* square cells of `cellPx`; each cell keeps its strongest bus. A generator
|
|
34
|
+
* outranks a substation or converter, which outranks any other bus. Then the
|
|
35
|
+
* larger |generation| + |demand| wins, then the lower id, so the result does
|
|
36
|
+
* not depend on the input order. The selection is not an input: `withBus`
|
|
37
|
+
* adds a selected bus back on top, so selecting one never hides another.
|
|
38
|
+
*
|
|
39
|
+
* The kept buses come back in input order. When no two buses share a cell,
|
|
40
|
+
* the input array itself comes back, so a map layer keeps its data. Longitudes
|
|
41
|
+
* wrap, so 190 and -170 share a cell, and the cells tile the world with ±180
|
|
42
|
+
* on a cell edge, so the seam thins like any other place. Latitudes clamp to
|
|
43
|
+
* the Mercator limit. A bus without finite coordinates is kept, as there is no
|
|
44
|
+
* cell to place it in. */
|
|
45
|
+
export function declutterBuses(buses, options) {
|
|
46
|
+
const { zoom, cellPx = DECLUTTER_CELL_PX, kinds } = options;
|
|
47
|
+
if (buses.length < 2 || !Number.isFinite(zoom) || !(cellPx > 0))
|
|
48
|
+
return buses;
|
|
49
|
+
const world = WORLD_PX * 2 ** zoom;
|
|
50
|
+
// A whole number of columns, so the cells tile the world without a sliver
|
|
51
|
+
// at the seam.
|
|
52
|
+
const columns = Math.max(1, Math.round(world / cellPx));
|
|
53
|
+
// Past about zoom 21 a 16 px cell is under a metre across, and its key
|
|
54
|
+
// would outgrow exact integers. Nothing needs thinning there.
|
|
55
|
+
if (!Number.isSafeInteger(columns * columns))
|
|
56
|
+
return buses;
|
|
57
|
+
const cell = world / columns;
|
|
58
|
+
const rank = (bus) => (kinds ? kindRank(kinds.get(bus.id)) : 0);
|
|
59
|
+
const outranks = (a, b) => {
|
|
60
|
+
const byKind = rank(a) - rank(b);
|
|
61
|
+
if (byKind !== 0)
|
|
62
|
+
return byKind > 0;
|
|
63
|
+
const byPower = busPower(a) - busPower(b);
|
|
64
|
+
if (byPower !== 0)
|
|
65
|
+
return byPower > 0;
|
|
66
|
+
return a.id < b.id;
|
|
67
|
+
};
|
|
68
|
+
const winners = new Map();
|
|
69
|
+
for (const bus of buses) {
|
|
70
|
+
const { lon, lat } = bus;
|
|
71
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat))
|
|
72
|
+
continue;
|
|
73
|
+
const x = ((((lon + 180) % 360) + 360) % 360) / 360;
|
|
74
|
+
const phi = (Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)) * Math.PI) / 180;
|
|
75
|
+
const y = 0.5 - Math.log(Math.tan(Math.PI / 4 + phi / 2)) / (2 * Math.PI);
|
|
76
|
+
const column = Math.floor((x * world) / cell) % columns;
|
|
77
|
+
const row = Math.min(columns - 1, Math.max(0, Math.floor((y * world) / cell)));
|
|
78
|
+
const key = row * columns + column;
|
|
79
|
+
const held = winners.get(key);
|
|
80
|
+
if (!held || outranks(bus, held))
|
|
81
|
+
winners.set(key, bus);
|
|
82
|
+
}
|
|
83
|
+
const kept = new Set(winners.values());
|
|
84
|
+
const shown = buses.filter((bus) => kept.has(bus) || !Number.isFinite(bus.lon) || !Number.isFinite(bus.lat));
|
|
85
|
+
return shown.length === buses.length ? buses : shown;
|
|
86
|
+
}
|
|
87
|
+
/** `shown` with bus `id` from `all` added at the end when it is missing, so a
|
|
88
|
+
* selected bus that the map thinned out, or a line tap it never draws, still
|
|
89
|
+
* gets its marker. `shown` itself when there is nothing to add. */
|
|
90
|
+
export function withBus(shown, all, id) {
|
|
91
|
+
if (id === null || id === undefined || shown.some((bus) => bus.id === id))
|
|
92
|
+
return shown;
|
|
93
|
+
const bus = all.find((b) => b.id === id);
|
|
94
|
+
return bus ? [...shown, bus] : shown;
|
|
95
|
+
}
|
package/dist/state.svelte.d.ts
CHANGED
|
@@ -95,6 +95,10 @@ export declare class LocalCase {
|
|
|
95
95
|
coordsKind: CoordsKind | undefined;
|
|
96
96
|
/** Map geometry when the file carried or received coordinates. */
|
|
97
97
|
view: LocalView | null;
|
|
98
|
+
/** Each bus's kind in a generated grid, by bus id (`generator`,
|
|
99
|
+
* `substation`, `converter`, `load`, `tap`); empty for any other case. At
|
|
100
|
+
* low zoom the map keeps generators and substations over other buses. */
|
|
101
|
+
readonly busKinds: ReadonlyMap<number, string>;
|
|
98
102
|
/** Buses a generated grid marks as line taps. The map draws no marker for
|
|
99
103
|
* them; the bus picker and the selection still reach them. */
|
|
100
104
|
readonly tapBuses: ReadonlySet<number>;
|
package/dist/state.svelte.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_MC_PF_OPTIONS, summarizeMcExcludedIslands } from '@tellegen/engine';
|
|
2
|
-
import {
|
|
2
|
+
import { readBusKinds, tapsOf, withoutTaps } from './bus-kinds.js';
|
|
3
3
|
import { DEFAULT_FORMULATION } from '@tellegen/engine';
|
|
4
4
|
import { DiagnosticLog, errorEntry } from './log.svelte.js';
|
|
5
5
|
import { graphInputs } from './diagram/graph-inputs.js';
|
|
@@ -28,9 +28,13 @@ export class LocalCase {
|
|
|
28
28
|
coordsKind = $state.raw(undefined);
|
|
29
29
|
/** Map geometry when the file carried or received coordinates. */
|
|
30
30
|
view = $state.raw(null);
|
|
31
|
+
/** Each bus's kind in a generated grid, by bus id (`generator`,
|
|
32
|
+
* `substation`, `converter`, `load`, `tap`); empty for any other case. At
|
|
33
|
+
* low zoom the map keeps generators and substations over other buses. */
|
|
34
|
+
busKinds = $derived(readBusKinds(this.studyInputJson));
|
|
31
35
|
/** Buses a generated grid marks as line taps. The map draws no marker for
|
|
32
36
|
* them; the bus picker and the selection still reach them. */
|
|
33
|
-
tapBuses = $derived(
|
|
37
|
+
tapBuses = $derived(tapsOf(this.busKinds));
|
|
34
38
|
/** `view.buses` less the line taps: the buses the map draws as markers. */
|
|
35
39
|
markerBuses = $derived(withoutTaps(this.view?.buses ?? [], this.tapBuses));
|
|
36
40
|
syntheticCenter = $state.raw(undefined);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tellegen/svelte",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Svelte components for tellegen power system maps, local case files, and browser solves.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"pack:dry-run": "npm pack --dry-run --cache ../../node_modules/.cache/npm-pack"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@tellegen/engine": "^0.
|
|
70
|
+
"@tellegen/engine": "^0.6.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@deck.gl/core": "^9.4.0",
|