@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 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
@@ -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
- ? { key: `${app.studyView?.id ?? caseId}-${branch.id}`, caseId, branchId: branch.id, path }
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.project([selectedBusCue.lon, selectedBusCue.lat]);
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.project([bus.lon, bus.lat]);
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.project(other);
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 foldMapBounds(path);
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.view.buses,
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
+ }
@@ -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;
@@ -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);
@@ -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;
@@ -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.0",
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.5.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.0",
81
- "@deck.gl/layers": "^9.4.0",
82
- "@deck.gl/maplibre": "^9.4.0",
83
- "@sveltejs/package": "^3.0.1",
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.15.0",
86
- "svelte": "^5.57.2",
87
- "svelte-check": "^4.7.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.2"
90
+ "vitest": "^5.1.0"
91
91
  }
92
92
  }