@tellegen/svelte 0.5.0 → 0.5.1

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
@@ -260,13 +263,34 @@ besides its click callbacks:
260
263
  - `onMapReady(map, overlay)`: called with the MapLibre map and the deck
261
264
  overlay once they exist. It may return a cleanup, and it fires again after
262
265
  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.
266
+ and document listeners are still released when the map is torn down. A
267
+ switch to the fallback does not call it again (see below).
264
268
  - `onBasemapFallback(reason)`: the switch to the fallback happened.
265
269
  `reason.from` and `reason.to` name the two providers.
266
270
  - `onBasemapError(reason)`: no basemap can be shown. The network layers
267
271
  still draw. `reason.from` names the provider that failed last, and
268
272
  `reason.to` is `null`.
269
273
 
274
+ The switch to the fallback replaces the whole MapLibre style, so sources and
275
+ layers the host added to the map are gone after it. The network,
276
+ `extraLayers` and `projection` carry over. A host that adds its own sources
277
+ and layers adds them on every `style.load`, not once in `onMapReady`, which
278
+ runs before the first style loads, so one listener covers the primary and the
279
+ fallback:
280
+
281
+ ```ts
282
+ import type { Map as MapLibreMap } from "maplibre-gl";
283
+
284
+ function onMapReady(map: MapLibreMap) {
285
+ const addFeeders = () => {
286
+ map.addSource("feeders", { type: "geojson", data: "/feeders.geojson" });
287
+ map.addLayer({ id: "feeders", type: "line", source: "feeders" });
288
+ };
289
+ map.on("style.load", addFeeders);
290
+ return () => map.off("style.load", addFeeders);
291
+ }
292
+ ```
293
+
270
294
  `TellegenProvider` and `TellegenViewer` also take `cartoApiKey`, and pass it,
271
295
  together with `theme`, to the default shell's map. Both follow their props:
272
296
  changing either rebuilds that map in place. A SvelteKit 3 host can
@@ -56,6 +56,7 @@
56
56
  } from './transformer-icon.js';
57
57
  import { createGlobeHorizon, withGlobeHorizon } from './globe-horizon.js';
58
58
  import { foldMapBounds } from './map-bounds.js';
59
+ import { nearestLongitude, routeBounds, unwrapLongitudePath } from './antimeridian.js';
59
60
  import { getAppState, getController } from './context.svelte.js';
60
61
  import { displayFmt, fmt, rgbaCss } from './format.js';
61
62
  import { getPanelLayout } from './panels.svelte.js';
@@ -126,7 +127,9 @@
126
127
  attribution?: 'compact' | 'full';
127
128
  /** Called once the map and overlay exist, with an optional cleanup. It
128
129
  * fires again after a context-loss or basemap remount, after the previous
129
- * cleanup ran. */
130
+ * cleanup ran. It runs before the first style loads, and a switch to the
131
+ * fallback basemap does not call it again: the new style drops every
132
+ * source and layer the host added, so add those on each `style.load`. */
130
133
  onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
131
134
  /** The primary basemap failed and the fallback was requested. The
132
135
  * reason's `from` and `to` name the providers (`'carto'`,
@@ -473,7 +476,12 @@
473
476
  const branch = displayedGeometry(caseId)?.branches.find((b) => b.id === branchId);
474
477
  const path = branch?.path.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
475
478
  return branch && path && path.length >= 2
476
- ? { key: `${app.studyView?.id ?? caseId}-${branch.id}`, caseId, branchId: branch.id, path }
479
+ ? {
480
+ key: `${app.studyView?.id ?? caseId}-${branch.id}`,
481
+ caseId,
482
+ branchId: branch.id,
483
+ path: unwrapLongitudePath(path)
484
+ }
477
485
  : null;
478
486
  });
479
487
  let selectedCueEl = $state.raw<HTMLDivElement | null>(null);
@@ -496,9 +504,16 @@
496
504
  });
497
505
  let multiDetailEl = $state.raw<SVGSVGElement | null>(null);
498
506
 
507
+ /** The screen point of a coordinate in the world copy nearest the camera.
508
+ * MapLibre projects a longitude in the copy it names, so a point across the
509
+ * antimeridian from the camera would land a whole world width away. */
510
+ function projectNear(m: MapLibreMap, [lon, lat]: [number, number]) {
511
+ return m.project([nearestLongitude(lon, m.getCenter().lng), lat]);
512
+ }
513
+
499
514
  function syncSelectedCue() {
500
515
  if (!map || !selectedCueEl || !selectedBusCue) return;
501
- const point = map.project([selectedBusCue.lon, selectedBusCue.lat]);
516
+ const point = projectNear(map, [selectedBusCue.lon, selectedBusCue.lat]);
502
517
  const { clientWidth, clientHeight } = map.getContainer();
503
518
  const inRange =
504
519
  point.x >= -40 &&
@@ -522,8 +537,12 @@
522
537
  }
523
538
  const activeMap = map;
524
539
  const { clientWidth, clientHeight } = activeMap.getContainer();
540
+ // The path is unwrapped, so one shift by whole turns moves all of it to
541
+ // the world copy nearest the camera without breaking it at the seam.
542
+ const [lon0] = selectedBranchCue.path[0];
543
+ const shift = nearestLongitude(lon0, activeMap.getCenter().lng) - lon0;
525
544
  const points = selectedBranchCue.path
526
- .map(([lon, lat]) => activeMap.project([lon, lat]))
545
+ .map(([lon, lat]) => activeMap.project([lon + shift, lat]))
527
546
  .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
528
547
  if (clientWidth === 0 || clientHeight === 0 || points.length < 2) {
529
548
  selectedBranchCueEl.style.display = 'none';
@@ -589,14 +608,14 @@
589
608
  return;
590
609
  }
591
610
  const { bus, edges } = detail;
592
- const busPt = m.project([bus.lon, bus.lat]);
611
+ const busPt = projectNear(m, [bus.lon, bus.lat]);
593
612
 
594
613
  const STRAND_SPACING = 5;
595
614
  let strands = '';
596
615
  for (const e of edges) {
597
616
  const selectedIsFrom = e.from === bus.id;
598
617
  const other = selectedIsFrom ? e.path[e.path.length - 1] : e.path[0];
599
- const b = m.project(other);
618
+ const b = projectNear(m, other);
600
619
  const dx = b.x - busPt.x;
601
620
  const dy = b.y - busPt.y;
602
621
  const len = Math.hypot(dx, dy) || 1;
@@ -677,7 +696,7 @@
677
696
  }
678
697
 
679
698
  function branchBounds(path: [number, number][]): LngLatBoundsLike | null {
680
- return foldMapBounds(path);
699
+ return routeBounds(path);
681
700
  }
682
701
 
683
702
  function prefersReducedMotion(): boolean {
@@ -1077,6 +1096,12 @@
1077
1096
  // Sync deck.gl layers with app state. New layer instances diff cheaply;
1078
1097
  // updateTriggers tell deck.gl when accessor outputs changed. Host layers
1079
1098
  // (`extraLayers`) draw after tellegen's own at both exits.
1099
+ //
1100
+ // Stored routes keep longitudes in [-180, 180], so a route across the
1101
+ // antimeridian is unwrapped before drawing and each segment takes the
1102
+ // short way. On the globe deck.gl ignores `wrapLongitude` and needs the
1103
+ // unwrapped path itself; on the flat map `wrapLongitude` cuts the path at
1104
+ // ±180 so both halves draw in every world copy.
1080
1105
  $effect(() => {
1081
1106
  if (!overlay || !layerCtors) return;
1082
1107
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
@@ -1108,7 +1133,8 @@
1108
1133
  new PathLayer<NetworkBranch>({
1109
1134
  id: 'study-branches',
1110
1135
  data: study.network.branches,
1111
- getPath: (b) => b.path,
1136
+ getPath: (b) => unwrapLongitudePath(b.path),
1137
+ wrapLongitude: true,
1112
1138
  getColor: (b) => branchColor(flows.get(b.id) ?? 0, b.status === 1),
1113
1139
  getWidth: (b) => branchWidth(flows.get(b.id) ?? 0),
1114
1140
  widthUnits: 'pixels',
@@ -1163,7 +1189,8 @@
1163
1189
  new PathLayer<NetworkBranch>({
1164
1190
  id: `branches-${c.id}`,
1165
1191
  data: c.network.branches,
1166
- getPath: (b) => b.path,
1192
+ getPath: (b) => unwrapLongitudePath(b.path),
1193
+ wrapLongitude: true,
1167
1194
  // The selected branch draws in the selection blue of the bus halo cue,
1168
1195
  // wider, so the price-rating source line reads at a glance.
1169
1196
  getColor: (b) =>
@@ -1230,15 +1257,21 @@
1230
1257
  }
1231
1258
  // Local cases are grey until the browser solve returns. A .pwd display
1232
1259
  // file contributes substation points only, in a cooler slate so they
1233
- // read as diagram derived positions.
1260
+ // read as diagram derived positions. A generated grid's line taps draw
1261
+ // no marker, as their lines show them, unless one is the selected bus.
1234
1262
  for (const c of app.localCases) {
1235
1263
  if (c.view) {
1236
1264
  const d = display.get(c.id);
1265
+ const selectedTap =
1266
+ c.id === app.activeLocalId && app.selectedBus !== null && c.tapBuses.has(app.selectedBus)
1267
+ ? c.view.buses.find((b) => b.id === app.selectedBus)
1268
+ : undefined;
1237
1269
  layers.push(
1238
1270
  new PathLayer<NetworkBranch>({
1239
1271
  id: `local-branches-${c.id}`,
1240
1272
  data: c.view.branches,
1241
- getPath: (b) => b.path,
1273
+ getPath: (b) => unwrapLongitudePath(b.path),
1274
+ wrapLongitude: true,
1242
1275
  getColor: (b) =>
1243
1276
  c.id === app.activeLocalId && b.id === app.selectedBranch
1244
1277
  ? [47, 111, 187, 255]
@@ -1272,7 +1305,7 @@
1272
1305
  }),
1273
1306
  new ScatterplotLayer<NetworkBus>({
1274
1307
  id: `local-buses-${c.id}`,
1275
- data: c.view.buses,
1308
+ data: selectedTap ? [...c.markerBuses, selectedTap] : c.markerBuses,
1276
1309
  getPosition: (b) => [b.lon, b.lat],
1277
1310
  getRadius: (b) => marker.radius(Math.max(b.demand_mw, b.gen_mw)),
1278
1311
  radiusUnits: marker.radiusUnits,
@@ -1343,7 +1376,8 @@
1343
1376
  new PathLayer<PlacedMultiEdge>({
1344
1377
  id: `multi-edges-${c.id}`,
1345
1378
  data: c.view.edges,
1346
- getPath: (e) => e.path,
1379
+ getPath: (e) => unwrapLongitudePath(e.path),
1380
+ wrapLongitude: true,
1347
1381
  // The selected edge draws in the selection blue of the branch cue,
1348
1382
  // wider, so its panel detail has an obvious source on the map.
1349
1383
  getColor: (e) =>
@@ -44,7 +44,9 @@ type $$ComponentProps = {
44
44
  attribution?: 'compact' | 'full';
45
45
  /** Called once the map and overlay exist, with an optional cleanup. It
46
46
  * fires again after a context-loss or basemap remount, after the previous
47
- * cleanup ran. */
47
+ * cleanup ran. It runs before the first style loads, and a switch to the
48
+ * fallback basemap does not call it again: the new style drops every
49
+ * source and layer the host added, so add those on each `style.load`. */
48
50
  onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
49
51
  /** The primary basemap failed and the fallback was requested. The
50
52
  * 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,9 @@
1
+ import type { NetworkBus } from './api.js';
2
+ /** Ids of the buses a generated grid marks as line taps: points where a line
3
+ * is tapped, with no load or machine. tellegen-synth records the kind in
4
+ * 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 tapBusIds(moduleJson: string | null | undefined): ReadonlySet<number>;
7
+ /** `buses` less the taps, the ones the map draws as markers. The same array
8
+ * when there is no tap to drop, so the map layer keeps its data. */
9
+ export declare function withoutTaps(buses: NetworkBus[], taps: ReadonlySet<number>): NetworkBus[];
@@ -0,0 +1,33 @@
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
+ /** Ids of the buses a generated grid marks as line taps: points where a line
5
+ * is tapped, with no load or machine. tellegen-synth records the kind in
6
+ * 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 tapBusIds(moduleJson) {
9
+ const ids = new Set();
10
+ // Only a module tellegen-synth wrote is parsed a second time; a branch's
11
+ // `"tap"` ratio is in every transmission case.
12
+ if (!moduleJson?.includes(`"${SYNTH_EXTRAS}"`) || !moduleJson.includes('"tap"'))
13
+ return ids;
14
+ let buses;
15
+ try {
16
+ buses = JSON.parse(moduleJson)?.value?.data?.buses;
17
+ }
18
+ catch {
19
+ return ids;
20
+ }
21
+ if (!Array.isArray(buses))
22
+ return ids;
23
+ for (const bus of buses) {
24
+ if (typeof bus?.id === 'number' && bus.extras?.[SYNTH_EXTRAS]?.kind === 'tap')
25
+ ids.add(bus.id);
26
+ }
27
+ return ids;
28
+ }
29
+ /** `buses` less the taps, the ones the map draws as markers. The same array
30
+ * when there is no tap to drop, so the map layer keeps its data. */
31
+ export function withoutTaps(buses, taps) {
32
+ return taps.size === 0 ? buses : buses.filter((b) => !taps.has(b.id));
33
+ }
@@ -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,11 @@ 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
+ /** Buses a generated grid marks as line taps. The map draws no marker for
99
+ * them; the bus picker and the selection still reach them. */
100
+ readonly tapBuses: ReadonlySet<number>;
101
+ /** `view.buses` less the line taps: the buses the map draws as markers. */
102
+ readonly markerBuses: NetworkBus[];
98
103
  syntheticCenter: {
99
104
  lon: number;
100
105
  lat: number;
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_MC_PF_OPTIONS, summarizeMcExcludedIslands } from '@tellegen/engine';
2
+ import { tapBusIds, 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,11 @@ 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
+ /** Buses a generated grid marks as line taps. The map draws no marker for
32
+ * them; the bus picker and the selection still reach them. */
33
+ tapBuses = $derived(tapBusIds(this.studyInputJson));
34
+ /** `view.buses` less the line taps: the buses the map draws as markers. */
35
+ markerBuses = $derived(withoutTaps(this.view?.buses ?? [], this.tapBuses));
30
36
  syntheticCenter = $state.raw(undefined);
31
37
  diagram = $state.raw(null);
32
38
  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.1",
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.5.1"
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
  }