@tellegen/svelte 0.5.0 → 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 +32 -1
- package/dist/TellegenMap.svelte +122 -15
- package/dist/TellegenMap.svelte.d.ts +8 -1
- package/dist/antimeridian.d.ts +21 -0
- package/dist/antimeridian.js +40 -0
- package/dist/basemap.d.ts +10 -0
- package/dist/basemap.js +20 -0
- package/dist/bus-kinds.d.ts +15 -0
- package/dist/bus-kinds.js +47 -0
- package/dist/declutter.d.ts +52 -0
- package/dist/declutter.js +95 -0
- package/dist/map-bounds.d.ts +8 -0
- package/dist/map-bounds.js +21 -8
- package/dist/state.svelte.d.ts +9 -0
- package/dist/state.svelte.js +10 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -231,6 +231,9 @@ The map switches to the fallback in three cases:
|
|
|
231
231
|
- its tiles answer 401, 403 or 429;
|
|
232
232
|
- tile fetches keep failing with nothing arriving.
|
|
233
233
|
|
|
234
|
+
Only the basemap style's own sources count. A source the host adds to the map
|
|
235
|
+
never triggers the switch, and its errors are logged to the console.
|
|
236
|
+
|
|
234
237
|
Whenever an OpenFreeMap style renders, as primary or as fallback,
|
|
235
238
|
`openFreeMapEdits` tunes it towards CARTO's look. Road lines start at zoom 8.
|
|
236
239
|
In the dark theme, water and waterways take Dark Matter's colours and land
|
|
@@ -257,16 +260,44 @@ besides its click callbacks:
|
|
|
257
260
|
- `extraLayers`: deck.gl layers drawn after tellegen's own, in the same
|
|
258
261
|
overlay.
|
|
259
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.
|
|
260
270
|
- `onMapReady(map, overlay)`: called with the MapLibre map and the deck
|
|
261
271
|
overlay once they exist. It may return a cleanup, and it fires again after
|
|
262
272
|
a context-loss or basemap rebuild. If it throws, the map and its window
|
|
263
|
-
and document listeners are still released when the map is torn down.
|
|
273
|
+
and document listeners are still released when the map is torn down. A
|
|
274
|
+
switch to the fallback does not call it again (see below).
|
|
264
275
|
- `onBasemapFallback(reason)`: the switch to the fallback happened.
|
|
265
276
|
`reason.from` and `reason.to` name the two providers.
|
|
266
277
|
- `onBasemapError(reason)`: no basemap can be shown. The network layers
|
|
267
278
|
still draw. `reason.from` names the provider that failed last, and
|
|
268
279
|
`reason.to` is `null`.
|
|
269
280
|
|
|
281
|
+
The switch to the fallback replaces the whole MapLibre style, so sources and
|
|
282
|
+
layers the host added to the map are gone after it. The network,
|
|
283
|
+
`extraLayers` and `projection` carry over. A host that adds its own sources
|
|
284
|
+
and layers adds them on every `style.load`, not once in `onMapReady`, which
|
|
285
|
+
runs before the first style loads, so one listener covers the primary and the
|
|
286
|
+
fallback:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import type { Map as MapLibreMap } from "maplibre-gl";
|
|
290
|
+
|
|
291
|
+
function onMapReady(map: MapLibreMap) {
|
|
292
|
+
const addFeeders = () => {
|
|
293
|
+
map.addSource("feeders", { type: "geojson", data: "/feeders.geojson" });
|
|
294
|
+
map.addLayer({ id: "feeders", type: "line", source: "feeders" });
|
|
295
|
+
};
|
|
296
|
+
map.on("style.load", addFeeders);
|
|
297
|
+
return () => map.off("style.load", addFeeders);
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
270
301
|
`TellegenProvider` and `TellegenViewer` also take `cartoApiKey`, and pass it,
|
|
271
302
|
together with `theme`, to the default shell's map. Both follow their props:
|
|
272
303
|
changing either rebuilds that map in place. A SvelteKit 3 host can
|
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,
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
} from './transformer-icon.js';
|
|
57
58
|
import { createGlobeHorizon, withGlobeHorizon } from './globe-horizon.js';
|
|
58
59
|
import { foldMapBounds } from './map-bounds.js';
|
|
60
|
+
import { nearestLongitude, routeBounds, unwrapLongitudePath } from './antimeridian.js';
|
|
59
61
|
import { getAppState, getController } from './context.svelte.js';
|
|
60
62
|
import { displayFmt, fmt, rgbaCss } from './format.js';
|
|
61
63
|
import { getPanelLayout } from './panels.svelte.js';
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
cartoApiKey,
|
|
84
86
|
extraLayers = [],
|
|
85
87
|
attribution = 'compact',
|
|
88
|
+
declutterMarkers = true,
|
|
86
89
|
onMapReady,
|
|
87
90
|
onBasemapFallback,
|
|
88
91
|
onBasemapError
|
|
@@ -124,9 +127,16 @@
|
|
|
124
127
|
/** Attribution control mode. There is no off switch: tile licences
|
|
125
128
|
* require the credit on screen. */
|
|
126
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;
|
|
127
135
|
/** Called once the map and overlay exist, with an optional cleanup. It
|
|
128
136
|
* fires again after a context-loss or basemap remount, after the previous
|
|
129
|
-
* cleanup ran.
|
|
137
|
+
* cleanup ran. It runs before the first style loads, and a switch to the
|
|
138
|
+
* fallback basemap does not call it again: the new style drops every
|
|
139
|
+
* source and layer the host added, so add those on each `style.load`. */
|
|
130
140
|
onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
|
|
131
141
|
/** The primary basemap failed and the fallback was requested. The
|
|
132
142
|
* reason's `from` and `to` name the providers (`'carto'`,
|
|
@@ -229,6 +239,11 @@
|
|
|
229
239
|
let mapGen = $state(0);
|
|
230
240
|
// Cap automatic remounts so sustained GPU pressure cannot loop forever.
|
|
231
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;
|
|
232
247
|
|
|
233
248
|
type LayerCtors = {
|
|
234
249
|
PathLayer: typeof PathLayer;
|
|
@@ -438,6 +453,52 @@
|
|
|
438
453
|
return primary ? attachmentColor(primary) : busNeutral;
|
|
439
454
|
}
|
|
440
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
|
+
|
|
441
502
|
// Layers whose click selects something (a bus or a branch); the overlay-level
|
|
442
503
|
// click handler must not treat those clicks as "empty map" and clear the selection.
|
|
443
504
|
function isSelectableLayer(layerId: string | undefined): boolean {
|
|
@@ -473,7 +534,12 @@
|
|
|
473
534
|
const branch = displayedGeometry(caseId)?.branches.find((b) => b.id === branchId);
|
|
474
535
|
const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
|
475
536
|
return branch && path && path.length >= 2
|
|
476
|
-
? {
|
|
537
|
+
? {
|
|
538
|
+
key: `${app.studyView?.id ?? caseId}-${branch.id}`,
|
|
539
|
+
caseId,
|
|
540
|
+
branchId: branch.id,
|
|
541
|
+
path: unwrapLongitudePath(path)
|
|
542
|
+
}
|
|
477
543
|
: null;
|
|
478
544
|
});
|
|
479
545
|
let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
|
|
@@ -496,9 +562,16 @@
|
|
|
496
562
|
});
|
|
497
563
|
let multiDetailEl = $state.raw<SVGSVGElement | null>(null);
|
|
498
564
|
|
|
565
|
+
/** The screen point of a coordinate in the world copy nearest the camera.
|
|
566
|
+
* MapLibre projects a longitude in the copy it names, so a point across the
|
|
567
|
+
* antimeridian from the camera would land a whole world width away. */
|
|
568
|
+
function projectNear(m: MapLibreMap, [lon, lat]: [number, number]) {
|
|
569
|
+
return m.project([nearestLongitude(lon, m.getCenter().lng), lat]);
|
|
570
|
+
}
|
|
571
|
+
|
|
499
572
|
function syncSelectedCue() {
|
|
500
573
|
if (!map || !selectedCueEl || !selectedBusCue) return;
|
|
501
|
-
const point = map
|
|
574
|
+
const point = projectNear(map, [selectedBusCue.lon, selectedBusCue.lat]);
|
|
502
575
|
const { clientWidth, clientHeight } = map.getContainer();
|
|
503
576
|
const inRange =
|
|
504
577
|
point.x >= -40 &&
|
|
@@ -522,8 +595,12 @@
|
|
|
522
595
|
}
|
|
523
596
|
const activeMap = map;
|
|
524
597
|
const { clientWidth, clientHeight } = activeMap.getContainer();
|
|
598
|
+
// The path is unwrapped, so one shift by whole turns moves all of it to
|
|
599
|
+
// the world copy nearest the camera without breaking it at the seam.
|
|
600
|
+
const [lon0] = selectedBranchCue.path[0];
|
|
601
|
+
const shift = nearestLongitude(lon0, activeMap.getCenter().lng) - lon0;
|
|
525
602
|
const points = selectedBranchCue.path
|
|
526
|
-
.map(([lon, lat]) => activeMap.project([lon, lat]))
|
|
603
|
+
.map(([lon, lat]) => activeMap.project([lon + shift, lat]))
|
|
527
604
|
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
528
605
|
if (clientWidth === 0 || clientHeight === 0 || points.length < 2) {
|
|
529
606
|
selectedBranchCueEl.style.display = 'none';
|
|
@@ -589,14 +666,14 @@
|
|
|
589
666
|
return;
|
|
590
667
|
}
|
|
591
668
|
const { bus, edges } = detail;
|
|
592
|
-
const busPt = m
|
|
669
|
+
const busPt = projectNear(m, [bus.lon, bus.lat]);
|
|
593
670
|
|
|
594
671
|
const STRAND_SPACING = 5;
|
|
595
672
|
let strands = '';
|
|
596
673
|
for (const e of edges) {
|
|
597
674
|
const selectedIsFrom = e.from === bus.id;
|
|
598
675
|
const other = selectedIsFrom ? e.path[e.path.length - 1] : e.path[0];
|
|
599
|
-
const b = m
|
|
676
|
+
const b = projectNear(m, other);
|
|
600
677
|
const dx = b.x - busPt.x;
|
|
601
678
|
const dy = b.y - busPt.y;
|
|
602
679
|
const len = Math.hypot(dx, dy) || 1;
|
|
@@ -677,7 +754,7 @@
|
|
|
677
754
|
}
|
|
678
755
|
|
|
679
756
|
function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
|
|
680
|
-
return
|
|
757
|
+
return routeBounds(path);
|
|
681
758
|
}
|
|
682
759
|
|
|
683
760
|
function prefersReducedMotion(): boolean {
|
|
@@ -948,6 +1025,18 @@
|
|
|
948
1025
|
if (document.visibilityState === 'visible') repaint();
|
|
949
1026
|
};
|
|
950
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();
|
|
951
1040
|
const saveCamera = () => {
|
|
952
1041
|
const center = m.getCenter();
|
|
953
1042
|
app.camera = {
|
|
@@ -1005,6 +1094,9 @@
|
|
|
1005
1094
|
m.on('load', syncCues);
|
|
1006
1095
|
m.on('move', syncCues);
|
|
1007
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);
|
|
1008
1100
|
|
|
1009
1101
|
map = m;
|
|
1010
1102
|
overlay = o;
|
|
@@ -1020,6 +1112,9 @@
|
|
|
1020
1112
|
m.off('load', syncCues);
|
|
1021
1113
|
m.off('move', syncCues);
|
|
1022
1114
|
m.off('resize', syncCues);
|
|
1115
|
+
m.off('zoom', sampleZoom);
|
|
1116
|
+
m.off('zoomend', readZoom);
|
|
1117
|
+
if (zoomTimer !== null) clearTimeout(zoomTimer);
|
|
1023
1118
|
m.off('style.load', applyStyleExtras);
|
|
1024
1119
|
detachFallback();
|
|
1025
1120
|
detachReady?.();
|
|
@@ -1077,6 +1172,12 @@
|
|
|
1077
1172
|
// Sync deck.gl layers with app state. New layer instances diff cheaply;
|
|
1078
1173
|
// updateTriggers tell deck.gl when accessor outputs changed. Host layers
|
|
1079
1174
|
// (`extraLayers`) draw after tellegen's own at both exits.
|
|
1175
|
+
//
|
|
1176
|
+
// Stored routes keep longitudes in [-180, 180], so a route across the
|
|
1177
|
+
// antimeridian is unwrapped before drawing and each segment takes the
|
|
1178
|
+
// short way. On the globe deck.gl ignores `wrapLongitude` and needs the
|
|
1179
|
+
// unwrapped path itself; on the flat map `wrapLongitude` cuts the path at
|
|
1180
|
+
// ±180 so both halves draw in every world copy.
|
|
1080
1181
|
$effect(() => {
|
|
1081
1182
|
if (!overlay || !layerCtors) return;
|
|
1082
1183
|
const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
|
|
@@ -1108,7 +1209,8 @@
|
|
|
1108
1209
|
new PathLayer<NetworkBranch>({
|
|
1109
1210
|
id: 'study-branches',
|
|
1110
1211
|
data: study.network.branches,
|
|
1111
|
-
getPath: (b) => b.path,
|
|
1212
|
+
getPath: (b) => unwrapLongitudePath(b.path),
|
|
1213
|
+
wrapLongitude: true,
|
|
1112
1214
|
getColor: (b) => branchColor(flows.get(b.id) ?? 0, b.status === 1),
|
|
1113
1215
|
getWidth: (b) => branchWidth(flows.get(b.id) ?? 0),
|
|
1114
1216
|
widthUnits: 'pixels',
|
|
@@ -1125,7 +1227,7 @@
|
|
|
1125
1227
|
}),
|
|
1126
1228
|
new ScatterplotLayer<NetworkBus>({
|
|
1127
1229
|
id: 'study-buses',
|
|
1128
|
-
data: study.network.buses,
|
|
1230
|
+
data: busMarkers.get('study-buses') ?? study.network.buses,
|
|
1129
1231
|
getPosition: (b) => [b.lon, b.lat],
|
|
1130
1232
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1131
1233
|
radiusUnits: marker.radiusUnits,
|
|
@@ -1163,7 +1265,8 @@
|
|
|
1163
1265
|
new PathLayer<NetworkBranch>({
|
|
1164
1266
|
id: `branches-${c.id}`,
|
|
1165
1267
|
data: c.network.branches,
|
|
1166
|
-
getPath: (b) => b.path,
|
|
1268
|
+
getPath: (b) => unwrapLongitudePath(b.path),
|
|
1269
|
+
wrapLongitude: true,
|
|
1167
1270
|
// The selected branch draws in the selection blue of the bus halo cue,
|
|
1168
1271
|
// wider, so the price-rating source line reads at a glance.
|
|
1169
1272
|
getColor: (b) =>
|
|
@@ -1195,7 +1298,7 @@
|
|
|
1195
1298
|
}),
|
|
1196
1299
|
new ScatterplotLayer<NetworkBus>({
|
|
1197
1300
|
id: `buses-${c.id}`,
|
|
1198
|
-
data: c.network.buses,
|
|
1301
|
+
data: busMarkers.get(`buses-${c.id}`) ?? c.network.buses,
|
|
1199
1302
|
getPosition: (b) => [b.lon, b.lat],
|
|
1200
1303
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1201
1304
|
radiusUnits: marker.radiusUnits,
|
|
@@ -1230,7 +1333,9 @@
|
|
|
1230
1333
|
}
|
|
1231
1334
|
// Local cases are grey until the browser solve returns. A .pwd display
|
|
1232
1335
|
// file contributes substation points only, in a cooler slate so they
|
|
1233
|
-
// read as diagram derived positions.
|
|
1336
|
+
// read as diagram derived positions. A generated grid's line taps draw
|
|
1337
|
+
// no marker, as their lines show them, unless one is the selected bus
|
|
1338
|
+
// (see busMarkers).
|
|
1234
1339
|
for (const c of app.localCases) {
|
|
1235
1340
|
if (c.view) {
|
|
1236
1341
|
const d = display.get(c.id);
|
|
@@ -1238,7 +1343,8 @@
|
|
|
1238
1343
|
new PathLayer<NetworkBranch>({
|
|
1239
1344
|
id: `local-branches-${c.id}`,
|
|
1240
1345
|
data: c.view.branches,
|
|
1241
|
-
getPath: (b) => b.path,
|
|
1346
|
+
getPath: (b) => unwrapLongitudePath(b.path),
|
|
1347
|
+
wrapLongitude: true,
|
|
1242
1348
|
getColor: (b) =>
|
|
1243
1349
|
c.id === app.activeLocalId && b.id === app.selectedBranch
|
|
1244
1350
|
? [47, 111, 187, 255]
|
|
@@ -1272,7 +1378,7 @@
|
|
|
1272
1378
|
}),
|
|
1273
1379
|
new ScatterplotLayer<NetworkBus>({
|
|
1274
1380
|
id: `local-buses-${c.id}`,
|
|
1275
|
-
data: c.
|
|
1381
|
+
data: busMarkers.get(`local-buses-${c.id}`) ?? c.markerBuses,
|
|
1276
1382
|
getPosition: (b) => [b.lon, b.lat],
|
|
1277
1383
|
getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
|
|
1278
1384
|
radiusUnits: marker.radiusUnits,
|
|
@@ -1343,7 +1449,8 @@
|
|
|
1343
1449
|
new PathLayer<PlacedMultiEdge>({
|
|
1344
1450
|
id: `multi-edges-${c.id}`,
|
|
1345
1451
|
data: c.view.edges,
|
|
1346
|
-
getPath: (e) => e.path,
|
|
1452
|
+
getPath: (e) => unwrapLongitudePath(e.path),
|
|
1453
|
+
wrapLongitude: true,
|
|
1347
1454
|
// The selected edge draws in the selection blue of the branch cue,
|
|
1348
1455
|
// wider, so its panel detail has an obvious source on the map.
|
|
1349
1456
|
getColor: (e) =>
|
|
@@ -42,9 +42,16 @@ 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
|
-
* cleanup ran.
|
|
52
|
+
* cleanup ran. It runs before the first style loads, and a switch to the
|
|
53
|
+
* fallback basemap does not call it again: the new style drops every
|
|
54
|
+
* source and layer the host added, so add those on each `style.load`. */
|
|
48
55
|
onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
|
|
49
56
|
/** The primary basemap failed and the fallback was requested. The
|
|
50
57
|
* reason's `from` and `to` name the providers (`'carto'`,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Coordinate helpers for geographic paths drawn across the antimeridian.
|
|
2
|
+
*
|
|
3
|
+
* Stored longitudes stay in [-180, 180]. A drawn path is unwrapped so each
|
|
4
|
+
* vertex lies within 180° of the one before, and every segment takes the
|
|
5
|
+
* short way across ±180 instead of crossing the whole map.
|
|
6
|
+
*/
|
|
7
|
+
import { type MapBounds } from './map-bounds.js';
|
|
8
|
+
export type LngLat = readonly [number, number];
|
|
9
|
+
/** `lon` moved by whole turns of 360° to lie within 180° of `reference`.
|
|
10
|
+
* Returned unchanged when either value is not finite. */
|
|
11
|
+
export declare function nearestLongitude(lon: number, reference: number): number;
|
|
12
|
+
/** Return a display path whose consecutive longitudes differ by at most 180°.
|
|
13
|
+
* The returned longitudes may lie outside [-180, 180]; use only for drawing,
|
|
14
|
+
* never for serialization or exported GeoJSON. A vertex whose longitude is
|
|
15
|
+
* not finite is copied as it is and does not anchor the next one. */
|
|
16
|
+
export declare function unwrapLongitudePath(path: readonly LngLat[]): [number, number][];
|
|
17
|
+
/** Bounds of a stored route around its short arc, so a seam-crossing route
|
|
18
|
+
* frames the few degrees it spans rather than the whole world. West may lie
|
|
19
|
+
* below -180 or east above 180. Vertices `foldMapBounds` ignores are ignored
|
|
20
|
+
* here too. */
|
|
21
|
+
export declare function routeBounds(path: readonly LngLat[]): MapBounds | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Coordinate helpers for geographic paths drawn across the antimeridian.
|
|
2
|
+
*
|
|
3
|
+
* Stored longitudes stay in [-180, 180]. A drawn path is unwrapped so each
|
|
4
|
+
* vertex lies within 180° of the one before, and every segment takes the
|
|
5
|
+
* short way across ±180 instead of crossing the whole map.
|
|
6
|
+
*/
|
|
7
|
+
import { isMapPoint, spanBounds } from './map-bounds.js';
|
|
8
|
+
/** `lon` moved by whole turns of 360° to lie within 180° of `reference`.
|
|
9
|
+
* Returned unchanged when either value is not finite. */
|
|
10
|
+
export function nearestLongitude(lon, reference) {
|
|
11
|
+
const delta = lon - reference;
|
|
12
|
+
if (!Number.isFinite(delta) || Math.abs(delta) <= 180)
|
|
13
|
+
return lon;
|
|
14
|
+
return lon - 360 * Math.round(delta / 360);
|
|
15
|
+
}
|
|
16
|
+
/** Return a display path whose consecutive longitudes differ by at most 180°.
|
|
17
|
+
* The returned longitudes may lie outside [-180, 180]; use only for drawing,
|
|
18
|
+
* never for serialization or exported GeoJSON. A vertex whose longitude is
|
|
19
|
+
* not finite is copied as it is and does not anchor the next one. */
|
|
20
|
+
export function unwrapLongitudePath(path) {
|
|
21
|
+
const out = [];
|
|
22
|
+
let previous = null;
|
|
23
|
+
for (const [rawLongitude, latitude] of path) {
|
|
24
|
+
if (!Number.isFinite(rawLongitude)) {
|
|
25
|
+
out.push([rawLongitude, latitude]);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const longitude = previous === null ? rawLongitude : nearestLongitude(rawLongitude, previous);
|
|
29
|
+
out.push([longitude, latitude]);
|
|
30
|
+
previous = longitude;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** Bounds of a stored route around its short arc, so a seam-crossing route
|
|
35
|
+
* frames the few degrees it spans rather than the whole world. West may lie
|
|
36
|
+
* below -180 or east above 180. Vertices `foldMapBounds` ignores are ignored
|
|
37
|
+
* here too. */
|
|
38
|
+
export function routeBounds(path) {
|
|
39
|
+
return spanBounds(unwrapLongitudePath(path.filter(isMapPoint)));
|
|
40
|
+
}
|
package/dist/basemap.d.ts
CHANGED
|
@@ -157,6 +157,10 @@ export interface BasemapMapLike {
|
|
|
157
157
|
}): unknown;
|
|
158
158
|
/** MapLibre types this `boolean | void`; only `true` counts as loaded. */
|
|
159
159
|
isStyleLoaded(): boolean | void;
|
|
160
|
+
/** Read at every `style.load` to learn which sources the basemap style
|
|
161
|
+
* brings, before a host adds its own. Without it, every source counts as
|
|
162
|
+
* the basemap's. */
|
|
163
|
+
getStyle?(): StyleSpecification | undefined;
|
|
160
164
|
}
|
|
161
165
|
export interface BasemapFallbackHandlers {
|
|
162
166
|
/** The primary failed and the fallback style was requested. `from` is the
|
|
@@ -185,6 +189,12 @@ export interface BasemapFallbackOptions {
|
|
|
185
189
|
* 5. everything else is logged the way MapLibre logs an unhandled error,
|
|
186
190
|
* because registering this listener silences that default.
|
|
187
191
|
*
|
|
192
|
+
* Rules 2 and 3 only count errors from the basemap style's own sources: the
|
|
193
|
+
* sources `map.getStyle()` lists when that style's `style.load` reaches this
|
|
194
|
+
* listener, so attach it before any listener that adds sources. A source the
|
|
195
|
+
* host adds later, through `onMapReady` for instance, falls under rule 5 and
|
|
196
|
+
* never switches the basemap.
|
|
197
|
+
*
|
|
188
198
|
* After the switch the same rules report `onFailure` instead of switching
|
|
189
199
|
* again, as they do when `spec.fallback` is `null`. Returns a detach
|
|
190
200
|
* function. */
|
package/dist/basemap.js
CHANGED
|
@@ -169,6 +169,12 @@ function errorStatus(error) {
|
|
|
169
169
|
* 5. everything else is logged the way MapLibre logs an unhandled error,
|
|
170
170
|
* because registering this listener silences that default.
|
|
171
171
|
*
|
|
172
|
+
* Rules 2 and 3 only count errors from the basemap style's own sources: the
|
|
173
|
+
* sources `map.getStyle()` lists when that style's `style.load` reaches this
|
|
174
|
+
* listener, so attach it before any listener that adds sources. A source the
|
|
175
|
+
* host adds later, through `onMapReady` for instance, falls under rule 5 and
|
|
176
|
+
* never switches the basemap.
|
|
177
|
+
*
|
|
172
178
|
* After the switch the same rules report `onFailure` instead of switching
|
|
173
179
|
* again, as they do when `spec.fallback` is `null`. Returns a detach
|
|
174
180
|
* function. */
|
|
@@ -176,7 +182,14 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
|
|
|
176
182
|
const now = options.now ?? (() => Date.now());
|
|
177
183
|
const primaryProvider = spec.primaryProvider ?? 'custom';
|
|
178
184
|
const fallbackProvider = spec.fallback === null ? null : (spec.fallbackProvider ?? 'custom');
|
|
185
|
+
/** The source ids of the style on screen, or `null` while unknown, when
|
|
186
|
+
* every source counts as the basemap's. */
|
|
187
|
+
const styleSources = () => {
|
|
188
|
+
const style = map.getStyle?.();
|
|
189
|
+
return style ? new Set(Object.keys(style.sources ?? {})) : null;
|
|
190
|
+
};
|
|
179
191
|
let styleLoaded = map.isStyleLoaded() === true;
|
|
192
|
+
let basemapSources = styleLoaded ? styleSources() : null;
|
|
180
193
|
let fellBack = false;
|
|
181
194
|
let failed = false;
|
|
182
195
|
let tileFailures = 0;
|
|
@@ -196,6 +209,7 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
|
|
|
196
209
|
}
|
|
197
210
|
fellBack = true;
|
|
198
211
|
styleLoaded = false;
|
|
212
|
+
basemapSources = null;
|
|
199
213
|
tileFailures = 0;
|
|
200
214
|
tileArrived = false;
|
|
201
215
|
handlers.onFallback?.({ ...reason, from: primaryProvider, to: fallbackProvider });
|
|
@@ -206,6 +220,7 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
|
|
|
206
220
|
};
|
|
207
221
|
const onStyleLoad = () => {
|
|
208
222
|
styleLoaded = true;
|
|
223
|
+
basemapSources = styleSources();
|
|
209
224
|
};
|
|
210
225
|
const onSourceData = (event) => {
|
|
211
226
|
if (event?.tile || event?.isSourceLoaded === true)
|
|
@@ -227,6 +242,11 @@ export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
|
|
|
227
242
|
passThrough(event);
|
|
228
243
|
return;
|
|
229
244
|
}
|
|
245
|
+
// A host's own source failing says nothing about the basemap.
|
|
246
|
+
if (basemapSources !== null && !basemapSources.has(sourceId)) {
|
|
247
|
+
passThrough(event);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
230
250
|
if ((status !== undefined && FORBIDDEN_STATUSES.has(status)) ||
|
|
231
251
|
FORBIDDEN_MESSAGE.test(message)) {
|
|
232
252
|
fallBack({ kind: 'source-forbidden', message, status: status ?? 0, sourceId });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NetworkBus } from './api.js';
|
|
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
|
+
* 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. */
|
|
12
|
+
export declare function tapBusIds(moduleJson: string | null | undefined): ReadonlySet<number>;
|
|
13
|
+
/** `buses` less the taps, the ones the map draws as markers. The same array
|
|
14
|
+
* when there is no tap to drop, so the map layer keeps its data. */
|
|
15
|
+
export declare function withoutTaps(buses: NetworkBus[], taps: ReadonlySet<number>): NetworkBus[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The `extras` key under which tellegen-synth records each element's
|
|
2
|
+
* annotation in the modules it writes. */
|
|
3
|
+
const SYNTH_EXTRAS = 'tellegen_synth';
|
|
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
|
+
* it. Empty for any other module, and for text that does not parse. */
|
|
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;
|
|
13
|
+
let buses;
|
|
14
|
+
try {
|
|
15
|
+
buses = JSON.parse(moduleJson)?.value?.data?.buses;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return kinds;
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(buses))
|
|
21
|
+
return kinds;
|
|
22
|
+
for (const bus of buses) {
|
|
23
|
+
const kind = bus?.extras?.[SYNTH_EXTRAS]?.kind;
|
|
24
|
+
if (typeof bus?.id === 'number' && typeof kind === 'string')
|
|
25
|
+
kinds.set(bus.id, kind);
|
|
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);
|
|
36
|
+
return ids;
|
|
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
|
+
}
|
|
43
|
+
/** `buses` less the taps, the ones the map draws as markers. The same array
|
|
44
|
+
* when there is no tap to drop, so the map layer keeps its data. */
|
|
45
|
+
export function withoutTaps(buses, taps) {
|
|
46
|
+
return taps.size === 0 ? buses : buses.filter((b) => !taps.has(b.id));
|
|
47
|
+
}
|
|
@@ -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/map-bounds.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export type MapBounds = [[number, number], [number, number]];
|
|
2
|
+
/** Whether a point can be framed on a Web Mercator map: finite, longitude in
|
|
3
|
+
* [-180, 180], latitude inside the projection's ±85.05° limit. */
|
|
4
|
+
export declare function isMapPoint([lon, lat]: readonly [number, number]): boolean;
|
|
2
5
|
/** Fold finite Web Mercator coordinates into map bounds. Invalid points are
|
|
3
6
|
* ignored so one malformed row cannot hide an otherwise usable network, and a
|
|
4
7
|
* single surviving point still yields a box `fitBounds` can frame. */
|
|
5
8
|
export declare function foldMapBounds(points: Iterable<readonly [number, number]>): MapBounds | null;
|
|
9
|
+
/** The box around points already checked, or `null` when there are none. An
|
|
10
|
+
* axis that collapsed to a single value is padded so `fitBounds` can frame
|
|
11
|
+
* it. Longitudes are taken as they are, so an unwrapped route may give a west
|
|
12
|
+
* edge below -180 or an east edge above 180. */
|
|
13
|
+
export declare function spanBounds(points: Iterable<readonly [number, number]>): MapBounds | null;
|
package/dist/map-bounds.js
CHANGED
|
@@ -2,23 +2,36 @@
|
|
|
2
2
|
* zero-span box makes `fitBounds` divide by zero, clamp to the map's maximum
|
|
3
3
|
* zoom, and slam the camera onto one point instead of framing it. */
|
|
4
4
|
const DEGENERATE_PAD_DEGREES = 0.005;
|
|
5
|
+
/** Whether a point can be framed on a Web Mercator map: finite, longitude in
|
|
6
|
+
* [-180, 180], latitude inside the projection's ±85.05° limit. */
|
|
7
|
+
export function isMapPoint([lon, lat]) {
|
|
8
|
+
return (Number.isFinite(lon) &&
|
|
9
|
+
Number.isFinite(lat) &&
|
|
10
|
+
lon >= -180 &&
|
|
11
|
+
lon <= 180 &&
|
|
12
|
+
lat >= -85.05 &&
|
|
13
|
+
lat <= 85.05);
|
|
14
|
+
}
|
|
5
15
|
/** Fold finite Web Mercator coordinates into map bounds. Invalid points are
|
|
6
16
|
* ignored so one malformed row cannot hide an otherwise usable network, and a
|
|
7
17
|
* single surviving point still yields a box `fitBounds` can frame. */
|
|
8
18
|
export function foldMapBounds(points) {
|
|
19
|
+
const kept = [];
|
|
20
|
+
for (const point of points)
|
|
21
|
+
if (isMapPoint(point))
|
|
22
|
+
kept.push(point);
|
|
23
|
+
return spanBounds(kept);
|
|
24
|
+
}
|
|
25
|
+
/** The box around points already checked, or `null` when there are none. An
|
|
26
|
+
* axis that collapsed to a single value is padded so `fitBounds` can frame
|
|
27
|
+
* it. Longitudes are taken as they are, so an unwrapped route may give a west
|
|
28
|
+
* edge below -180 or an east edge above 180. */
|
|
29
|
+
export function spanBounds(points) {
|
|
9
30
|
let minLon = Infinity;
|
|
10
31
|
let minLat = Infinity;
|
|
11
32
|
let maxLon = -Infinity;
|
|
12
33
|
let maxLat = -Infinity;
|
|
13
34
|
for (const [lon, lat] of points) {
|
|
14
|
-
if (!Number.isFinite(lon) ||
|
|
15
|
-
!Number.isFinite(lat) ||
|
|
16
|
-
lon < -180 ||
|
|
17
|
-
lon > 180 ||
|
|
18
|
-
lat < -85.05 ||
|
|
19
|
-
lat > 85.05) {
|
|
20
|
-
continue;
|
|
21
|
-
}
|
|
22
35
|
minLon = Math.min(minLon, lon);
|
|
23
36
|
minLat = Math.min(minLat, lat);
|
|
24
37
|
maxLon = Math.max(maxLon, lon);
|
package/dist/state.svelte.d.ts
CHANGED
|
@@ -95,6 +95,15 @@ 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>;
|
|
102
|
+
/** Buses a generated grid marks as line taps. The map draws no marker for
|
|
103
|
+
* them; the bus picker and the selection still reach them. */
|
|
104
|
+
readonly tapBuses: ReadonlySet<number>;
|
|
105
|
+
/** `view.buses` less the line taps: the buses the map draws as markers. */
|
|
106
|
+
readonly markerBuses: NetworkBus[];
|
|
98
107
|
syntheticCenter: {
|
|
99
108
|
lon: number;
|
|
100
109
|
lat: number;
|
package/dist/state.svelte.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_MC_PF_OPTIONS, summarizeMcExcludedIslands } from '@tellegen/engine';
|
|
2
|
+
import { readBusKinds, tapsOf, withoutTaps } from './bus-kinds.js';
|
|
2
3
|
import { DEFAULT_FORMULATION } from '@tellegen/engine';
|
|
3
4
|
import { DiagnosticLog, errorEntry } from './log.svelte.js';
|
|
4
5
|
import { graphInputs } from './diagram/graph-inputs.js';
|
|
@@ -27,6 +28,15 @@ export class LocalCase {
|
|
|
27
28
|
coordsKind = $state.raw(undefined);
|
|
28
29
|
/** Map geometry when the file carried or received coordinates. */
|
|
29
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));
|
|
35
|
+
/** Buses a generated grid marks as line taps. The map draws no marker for
|
|
36
|
+
* them; the bus picker and the selection still reach them. */
|
|
37
|
+
tapBuses = $derived(tapsOf(this.busKinds));
|
|
38
|
+
/** `view.buses` less the line taps: the buses the map draws as markers. */
|
|
39
|
+
markerBuses = $derived(withoutTaps(this.view?.buses ?? [], this.tapBuses));
|
|
30
40
|
syntheticCenter = $state.raw(undefined);
|
|
31
41
|
diagram = $state.raw(null);
|
|
32
42
|
displayMode = $state('geographic');
|
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",
|
|
@@ -77,16 +77,16 @@
|
|
|
77
77
|
"svelte": ">=5.30 <6"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@deck.gl/core": "^9.4.
|
|
81
|
-
"@deck.gl/layers": "^9.4.
|
|
82
|
-
"@deck.gl/maplibre": "^9.4.
|
|
83
|
-
"@sveltejs/package": "^3.0.
|
|
80
|
+
"@deck.gl/core": "^9.4.1",
|
|
81
|
+
"@deck.gl/layers": "^9.4.1",
|
|
82
|
+
"@deck.gl/maplibre": "^9.4.1",
|
|
83
|
+
"@sveltejs/package": "^3.0.2",
|
|
84
84
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
85
|
-
"maplibre-gl": "^6.
|
|
86
|
-
"svelte": "^5.57.
|
|
87
|
-
"svelte-check": "^4.7.
|
|
85
|
+
"maplibre-gl": "^6.16.0",
|
|
86
|
+
"svelte": "^5.57.3",
|
|
87
|
+
"svelte-check": "^4.7.8",
|
|
88
88
|
"typescript": "^6.0.3",
|
|
89
89
|
"vite": "^8.3.1",
|
|
90
|
-
"vitest": "^5.0
|
|
90
|
+
"vitest": "^5.1.0"
|
|
91
91
|
}
|
|
92
92
|
}
|