@sjvair/map-sdk 0.1.0-beta.4 → 0.1.0-beta.5
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 +35 -1
- package/dist/core/create-map.svelte.d.ts +17 -2
- package/dist/core/create-map.svelte.js +108 -5
- package/dist/core/plugin.d.ts +13 -0
- package/dist/core/shape-view.d.ts +27 -0
- package/dist/core/shape-view.js +323 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/plugins/shapes.svelte.js +18 -2
- package/dist/ui/MapTooltip.svelte +17 -4
- package/dist/ui/feature-list.d.ts +4 -1
- package/dist/ui/feature-list.js +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -374,6 +374,19 @@ function sitesPlugin(sites: Sites): MapPlugin {
|
|
|
374
374
|
}
|
|
375
375
|
```
|
|
376
376
|
|
|
377
|
+
An item for a shape (a polygon or line) can carry its `geometry` and, optionally, its `bbox`
|
|
378
|
+
(`[west, south, east, north]`, from `geometryBbox(geometry)`). The list and the summary then count
|
|
379
|
+
it as in view when the shape overlaps the view, not only when its `lngLat` is inside it: a smoke
|
|
380
|
+
plume that fills the screen is listed even though its center is off-screen. Measure `bbox` once per
|
|
381
|
+
data load (both are tested on every move); without it the box is computed each time. Focusing such
|
|
382
|
+
a shape from the list while its `lngLat` is off-screen leaves the camera where it is, and its
|
|
383
|
+
tooltip pins at a point of the shape in view: the view's center (beside the list panel) when the
|
|
384
|
+
shape covers it, else a point inside its visible part. `map.focusedAnchor` is where the focused
|
|
385
|
+
tooltip pins, and `map.anchorFor(item)` gives that point for any item without moving the camera
|
|
386
|
+
(the hover tooltip uses it). `intersectsBounds(item, bounds)` is the in-view test. `shapesPlugin`
|
|
387
|
+
and the presets built on it set `geometry` and `bbox` already. Bounds across the antimeridian are
|
|
388
|
+
not handled.
|
|
389
|
+
|
|
377
390
|
A plugin can also add a MapLibre custom layer, such as a wind layer from a library you add
|
|
378
391
|
yourself, with `ctx.addCustomLayer(local, create)`. The host calls `create` with the namespaced id
|
|
379
392
|
(the layer must use it) and adds the object it returns as is, in the plugin's draw-order slot. It is
|
|
@@ -440,7 +453,8 @@ and `getSelectionMode(id)` are reactive.
|
|
|
440
453
|
|
|
441
454
|
`map.on("featureclick", ({ pluginId, ref, lngLat }) => …)` fires for every click on a feature,
|
|
442
455
|
whatever the mode, and also for a pick in the feature list, so keyboard users get the same event;
|
|
443
|
-
`lngLat` is the click point
|
|
456
|
+
`lngLat` is the click point. For a list pick it's where the item's tooltip pins: the item's
|
|
457
|
+
position, or for a shape whose label point is off-screen, a point of the shape in view. It returns an unsubscribe
|
|
444
458
|
function. For your own context menu, `map.featuresAt({ x, y })` returns the hits under
|
|
445
459
|
a canvas point, topmost first, and `map.featureForElement(element)` returns the hit for a row of
|
|
446
460
|
the feature list (an element with `data-mapsdk-ref`). Both return nothing while the map is
|
|
@@ -534,6 +548,26 @@ local sjvair.com stack at `http://localhost:8000`.
|
|
|
534
548
|
|
|
535
549
|
## Changes
|
|
536
550
|
|
|
551
|
+
### 0.1.0-beta.5
|
|
552
|
+
|
|
553
|
+
- **Shapes that cover the view are in view.** The feature list and the view summary count a shape
|
|
554
|
+
when it overlaps the view, not only when its center (label point) is inside it. A smoke plume or
|
|
555
|
+
a big region that fills the screen, with its center off-screen, now has a list row and is
|
|
556
|
+
counted. Points are unchanged.
|
|
557
|
+
- **Focusing such a shape doesn't move the map.** Before, focusing a row panned to center the
|
|
558
|
+
shape's label point (a plume's could be off Baja). Now the camera stays, and the tooltip pins at
|
|
559
|
+
a point of the shape in view, with no move event. A shape whose label point is on screen still
|
|
560
|
+
pans to it.
|
|
561
|
+
- **Hover tooltips** in `"pinned"` mode follow the same rule: a hovered shape whose label point is
|
|
562
|
+
off-screen pins its tooltip inside the view instead of at the map's edge. If the map moves while
|
|
563
|
+
the pointer rests on the shape, the pin is found again when the map settles.
|
|
564
|
+
- **`featureclick` from the list** (Enter on a row) reports that same in-view point for such a
|
|
565
|
+
shape, instead of its off-screen label point. A handler that pans to `lngLat` no longer sends
|
|
566
|
+
the map off to the shape's far-away center. Points and on-screen label points report their
|
|
567
|
+
position, as before.
|
|
568
|
+
- **New API (all optional):** `FeatureListItem.geometry` and `.bbox`, `MapContext.focusedAnchor`
|
|
569
|
+
and `.anchorFor(item)`, `geometryBbox`, `intersectsBounds`, and the `Bbox` type.
|
|
570
|
+
|
|
537
571
|
### 0.1.0-beta.4
|
|
538
572
|
|
|
539
573
|
Defaults from monitor-map 5.0's parity review against sjvair.com's map (4.3.0). Every map gets
|
|
@@ -5,7 +5,7 @@ import type { FeatureRef, SelectionMode, SpatialSelection } from "../data/types.
|
|
|
5
5
|
import { BasemapState, type BasemapConfig } from "./basemap.svelte.js";
|
|
6
6
|
import type { GlMapLike } from "./gl.js";
|
|
7
7
|
import { type Hovered } from "./interactions.svelte.js";
|
|
8
|
-
import type { FeatureHit, MapPlugin } from "./plugin.js";
|
|
8
|
+
import type { FeatureHit, FeatureListItem, MapPlugin } from "./plugin.js";
|
|
9
9
|
export interface CameraState {
|
|
10
10
|
center: [number, number];
|
|
11
11
|
zoom: number;
|
|
@@ -52,7 +52,10 @@ export interface PluginOptions {
|
|
|
52
52
|
export type FeatureClickHandler = (e: {
|
|
53
53
|
pluginId: string;
|
|
54
54
|
ref: FeatureRef;
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* The click point. For a pick in the feature list, where the item's tooltip pins: its position,
|
|
57
|
+
* or for a shape whose label point is off-screen, a point of the shape in view.
|
|
58
|
+
*/
|
|
56
59
|
lngLat: [number, number];
|
|
57
60
|
}) => void;
|
|
58
61
|
/** Where a camera move ends up. `zoom` wins over `minZoom`. */
|
|
@@ -185,6 +188,18 @@ export interface MapContext {
|
|
|
185
188
|
readonly focusedKey: string | null;
|
|
186
189
|
/** Reactive. The plugin `focusFeature` was given with `focusedKey`, or `null`. */
|
|
187
190
|
readonly focusedPluginId: string | null;
|
|
191
|
+
/**
|
|
192
|
+
* Reactive. Where the focused feature's tooltip pins, set by `focusFeature` (`null` without
|
|
193
|
+
* focus): its `lngLat`, or for a shape that overlaps the view with its `lngLat` off-screen, a
|
|
194
|
+
* point of the shape in the part of the view the list panel leaves clear. Fixed at focus
|
|
195
|
+
* time, so it stays on the feature if the map then moves.
|
|
196
|
+
*/
|
|
197
|
+
readonly focusedAnchor: [number, number] | null;
|
|
198
|
+
/**
|
|
199
|
+
* Where `item`'s tooltip pins in the current view, without moving the camera: its `lngLat`,
|
|
200
|
+
* or for a shape (`geometry`) with its `lngLat` off-screen, a point of the shape in view.
|
|
201
|
+
*/
|
|
202
|
+
anchorFor(item: FeatureListItem): [number, number];
|
|
188
203
|
/** For MapView only: uses `gl` from now on. */
|
|
189
204
|
attach(gl: GlMapLike): void;
|
|
190
205
|
/** For MapView only: stops using the current GL map. */
|
|
@@ -7,6 +7,7 @@ import { applyPick, selectionKeys } from "../data/selection.js";
|
|
|
7
7
|
import { BasemapState } from "./basemap.svelte.js";
|
|
8
8
|
import { InteractionRouter } from "./interactions.svelte.js";
|
|
9
9
|
import { PluginHost } from "./plugin-host.svelte.js";
|
|
10
|
+
import { intersectsBounds, shapeAnchor } from "./shape-view.js";
|
|
10
11
|
/** Monitor-map's start view. */
|
|
11
12
|
const DEFAULT_VIEW = {
|
|
12
13
|
center: [-119.7987626619462, 36.76272050981146],
|
|
@@ -36,6 +37,7 @@ function sameSelection(a, b) {
|
|
|
36
37
|
return a === b;
|
|
37
38
|
return (a.kind === b.kind && a.ids.length === b.ids.length && a.ids.every((id, i) => id === b.ids[i]));
|
|
38
39
|
}
|
|
40
|
+
const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
|
|
39
41
|
function prefersReducedMotion() {
|
|
40
42
|
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
41
43
|
}
|
|
@@ -55,6 +57,7 @@ class MapContextImpl {
|
|
|
55
57
|
#glState = $state.raw(null);
|
|
56
58
|
#focusedKey = $state(null);
|
|
57
59
|
#focusedPluginId = $state(null);
|
|
60
|
+
#focusedAnchor = $state.raw(null);
|
|
58
61
|
#selections = new SvelteMap();
|
|
59
62
|
#featureClickHandlers = new Set();
|
|
60
63
|
#router = new InteractionRouter();
|
|
@@ -63,6 +66,11 @@ class MapContextImpl {
|
|
|
63
66
|
#destroyed = false;
|
|
64
67
|
#listInset = 0;
|
|
65
68
|
#listInsetSide = "left";
|
|
69
|
+
/** An animated ease that `focusFeature` started is still running (cleared on `moveend`). */
|
|
70
|
+
#focusEasing = false;
|
|
71
|
+
#onMoveEnd = () => {
|
|
72
|
+
this.#focusEasing = false;
|
|
73
|
+
};
|
|
66
74
|
constructor(options) {
|
|
67
75
|
const initialView = copyView(options.config.initialView ?? DEFAULT_VIEW);
|
|
68
76
|
this.config = {
|
|
@@ -123,6 +131,9 @@ class MapContextImpl {
|
|
|
123
131
|
get focusedPluginId() {
|
|
124
132
|
return this.#focusedPluginId;
|
|
125
133
|
}
|
|
134
|
+
get focusedAnchor() {
|
|
135
|
+
return this.#focusedAnchor;
|
|
136
|
+
}
|
|
126
137
|
get hovered() {
|
|
127
138
|
return this.#router.hovered;
|
|
128
139
|
}
|
|
@@ -178,9 +189,15 @@ class MapContextImpl {
|
|
|
178
189
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
179
190
|
if (!found)
|
|
180
191
|
return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
const { item } = found;
|
|
193
|
+
// Where the row's tooltip pins: the focused anchor for the focused row, else a point of a
|
|
194
|
+
// shape in view (a point's own position for points and on-screen label points).
|
|
195
|
+
const focused = untrack(() => this.#focusedKey) === refKey &&
|
|
196
|
+
(untrack(() => this.#focusedPluginId) ?? pluginId) === pluginId;
|
|
197
|
+
const anchor = (focused && untrack(() => this.#focusedAnchor)) || this.anchorFor(item);
|
|
198
|
+
this.#emitClick(pluginId, item.ref, anchor);
|
|
199
|
+
if (item.selectable)
|
|
200
|
+
this.#applyPick(pluginId, item.ref, additive);
|
|
184
201
|
}
|
|
185
202
|
clearSelection(pluginId) {
|
|
186
203
|
if (this.#destroyed)
|
|
@@ -225,19 +242,101 @@ class MapContextImpl {
|
|
|
225
242
|
return;
|
|
226
243
|
this.#focusedKey = refKey;
|
|
227
244
|
this.#focusedPluginId = refKey === null ? null : (pluginId ?? null);
|
|
245
|
+
this.#focusedAnchor = null;
|
|
228
246
|
const gl = this.#gl;
|
|
229
247
|
if (!gl || refKey === null)
|
|
230
248
|
return;
|
|
231
249
|
const found = untrack(() => this.#findItem(refKey, pluginId));
|
|
232
250
|
if (!found)
|
|
233
251
|
return;
|
|
252
|
+
const { item } = found;
|
|
253
|
+
const place = this.#placeFor(item, gl);
|
|
254
|
+
this.#focusedAnchor = place.anchor;
|
|
255
|
+
if (!place.pan) {
|
|
256
|
+
// Stays where it is now: an earlier focus's ease that is still running stops here.
|
|
257
|
+
// With nothing running, no move at all.
|
|
258
|
+
if (this.#focusEasing) {
|
|
259
|
+
const { lng, lat } = gl.getCenter();
|
|
260
|
+
gl.easeTo({ center: [lng, lat], duration: 0 });
|
|
261
|
+
this.#focusEasing = false;
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const reduce = prefersReducedMotion();
|
|
234
266
|
gl.easeTo({
|
|
235
|
-
center:
|
|
267
|
+
center: place.anchor,
|
|
236
268
|
// The middle of the part the panel leaves clear. An offset, not padding: MapLibre keeps
|
|
237
269
|
// padding for later moves (zooms, the reported center), an offset applies to this one.
|
|
238
270
|
...(this.#listInset > 0 && { offset: [this.#listOffsetX(), 0] }),
|
|
239
|
-
...(
|
|
271
|
+
...(reduce && { duration: 0 })
|
|
240
272
|
});
|
|
273
|
+
// Set after the call: stopping an earlier move fires its `moveend` inside `easeTo`.
|
|
274
|
+
this.#focusEasing = !reduce;
|
|
275
|
+
}
|
|
276
|
+
anchorFor(item) {
|
|
277
|
+
const gl = this.#gl;
|
|
278
|
+
if (!gl || !item.geometry)
|
|
279
|
+
return item.lngLat;
|
|
280
|
+
const { live, clear, liveCenter, clearCenter } = this.#views(gl);
|
|
281
|
+
if (inRect(item.lngLat, live) || !intersectsBounds(item, live))
|
|
282
|
+
return item.lngLat;
|
|
283
|
+
return (shapeAnchor(item.geometry, clear, clearCenter) ??
|
|
284
|
+
shapeAnchor(item.geometry, live, liveCenter) ??
|
|
285
|
+
item.lngLat);
|
|
286
|
+
}
|
|
287
|
+
/** The live view, and the part of it the list panel leaves clear, with their centers. */
|
|
288
|
+
#views(gl) {
|
|
289
|
+
const b = gl.getBounds();
|
|
290
|
+
const live = {
|
|
291
|
+
west: b.getWest(),
|
|
292
|
+
south: b.getSouth(),
|
|
293
|
+
east: b.getEast(),
|
|
294
|
+
north: b.getNorth()
|
|
295
|
+
};
|
|
296
|
+
const center = gl.getCenter();
|
|
297
|
+
const width = untrack(() => gl.getCanvas().clientWidth) || 0;
|
|
298
|
+
const share = width > 0 ? Math.min(Math.max(this.#listInset / width, 0), 0.99) : 0;
|
|
299
|
+
const cut = share * (live.east - live.west);
|
|
300
|
+
const clear = this.#listInsetSide === "left"
|
|
301
|
+
? { ...live, west: live.west + cut }
|
|
302
|
+
: { ...live, east: live.east - cut };
|
|
303
|
+
return {
|
|
304
|
+
live,
|
|
305
|
+
clear,
|
|
306
|
+
liveCenter: [center.lng, center.lat],
|
|
307
|
+
clearCenter: [(clear.west + clear.east) / 2, center.lat]
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Where focus puts `item`: `pan: false` keeps the camera (a shape in view whose `lngLat` is
|
|
312
|
+
* off-screen, with a point in the clear part); otherwise ease to center `anchor`.
|
|
313
|
+
*/
|
|
314
|
+
#placeFor(item, gl) {
|
|
315
|
+
const { geometry, lngLat } = item;
|
|
316
|
+
if (!geometry)
|
|
317
|
+
return { anchor: lngLat, pan: true };
|
|
318
|
+
const { live, clear, liveCenter, clearCenter } = this.#views(gl);
|
|
319
|
+
if (inRect(lngLat, live))
|
|
320
|
+
return { anchor: lngLat, pan: true };
|
|
321
|
+
if (intersectsBounds(item, live)) {
|
|
322
|
+
const inClear = shapeAnchor(geometry, clear, clearCenter);
|
|
323
|
+
if (inClear)
|
|
324
|
+
return { anchor: inClear, pan: false };
|
|
325
|
+
const inLive = shapeAnchor(geometry, live, liveCenter);
|
|
326
|
+
if (inLive)
|
|
327
|
+
return { anchor: inLive, pan: true };
|
|
328
|
+
}
|
|
329
|
+
const settled = untrack(() => this.#bounds);
|
|
330
|
+
if (settled && intersectsBounds(item, settled)) {
|
|
331
|
+
const middle = [
|
|
332
|
+
(settled.west + settled.east) / 2,
|
|
333
|
+
(settled.south + settled.north) / 2
|
|
334
|
+
];
|
|
335
|
+
const inSettled = shapeAnchor(geometry, settled, middle);
|
|
336
|
+
if (inSettled)
|
|
337
|
+
return { anchor: inSettled, pan: true };
|
|
338
|
+
}
|
|
339
|
+
return { anchor: lngLat, pan: true };
|
|
241
340
|
}
|
|
242
341
|
#cameraOptions(target, gl) {
|
|
243
342
|
const zoom = target.zoom ??
|
|
@@ -281,6 +380,7 @@ class MapContextImpl {
|
|
|
281
380
|
this.#glState = gl;
|
|
282
381
|
this.#host.attach(gl);
|
|
283
382
|
this.#router.attach(gl);
|
|
383
|
+
gl.on("moveend", this.#onMoveEnd);
|
|
284
384
|
this.basemap.attach(gl, () => this.#host.restyle(), () => {
|
|
285
385
|
this.#ready = false;
|
|
286
386
|
this.#readyRequested = false;
|
|
@@ -291,6 +391,8 @@ class MapContextImpl {
|
|
|
291
391
|
detach() {
|
|
292
392
|
if (!this.#gl)
|
|
293
393
|
return;
|
|
394
|
+
this.#gl.off("moveend", this.#onMoveEnd);
|
|
395
|
+
this.#focusEasing = false;
|
|
294
396
|
this.#router.detach();
|
|
295
397
|
this.basemap.detach();
|
|
296
398
|
this.#host.detach();
|
|
@@ -326,6 +428,7 @@ class MapContextImpl {
|
|
|
326
428
|
this.#featureClickHandlers.clear();
|
|
327
429
|
this.#focusedKey = null;
|
|
328
430
|
this.#focusedPluginId = null;
|
|
431
|
+
this.#focusedAnchor = null;
|
|
329
432
|
this.#destroyed = true;
|
|
330
433
|
}
|
|
331
434
|
/** A click on a feature: report it, then apply it to the top hit's plugin's selection. */
|
package/dist/core/plugin.d.ts
CHANGED
|
@@ -53,6 +53,19 @@ export interface FeatureListItem {
|
|
|
53
53
|
*/
|
|
54
54
|
truncate?: boolean;
|
|
55
55
|
lngLat: [number, number];
|
|
56
|
+
/**
|
|
57
|
+
* The feature's shape. When given, the item is in view (the feature list, the view summary)
|
|
58
|
+
* when the shape overlaps the view, not only when `lngLat` is inside it, and focusing or
|
|
59
|
+
* hovering it while `lngLat` is off-screen pins its tooltip at a point of the shape in view
|
|
60
|
+
* (focus then leaves the camera where it is).
|
|
61
|
+
*/
|
|
62
|
+
geometry?: Geometry;
|
|
63
|
+
/**
|
|
64
|
+
* `geometry`'s bounding box, `[west, south, east, north]` (`geometryBbox`). Optional; computed
|
|
65
|
+
* from `geometry` when missing. A plugin listing many shapes should measure it once per data
|
|
66
|
+
* load, since the list and summary test every item on every move.
|
|
67
|
+
*/
|
|
68
|
+
bbox?: [number, number, number, number];
|
|
56
69
|
selectable: boolean;
|
|
57
70
|
/**
|
|
58
71
|
* A short text about a feature with no level (e.g. "Level 2 charger"), shown where its level
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Geometry } from "geojson";
|
|
2
|
+
import type { Bounds } from "./create-map.svelte.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shapes against the view: a geometry's bounding box, whether it overlaps the view, and a point of
|
|
5
|
+
* it inside the view. Coordinates are planar lng/lat (as `labelPoint`); no antimeridian handling.
|
|
6
|
+
*/
|
|
7
|
+
/** `[west, south, east, north]`. */
|
|
8
|
+
export type Bbox = [west: number, south: number, east: number, north: number];
|
|
9
|
+
/** The geometry's bounding box, or `null` when it has no coordinates. */
|
|
10
|
+
export declare function geometryBbox(geometry: Geometry): Bbox | null;
|
|
11
|
+
/** Inside an outer ring and outside its holes, for any polygon of the geometry. */
|
|
12
|
+
export declare function containsPoint(geometry: Geometry, p: [number, number]): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
|
|
15
|
+
* then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
|
|
16
|
+
*/
|
|
17
|
+
export declare function intersectsBounds(item: {
|
|
18
|
+
lngLat: [number, number];
|
|
19
|
+
geometry?: Geometry;
|
|
20
|
+
bbox?: Bbox;
|
|
21
|
+
}, bounds: Bounds): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
|
|
24
|
+
* inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
|
|
25
|
+
* middle of the longest visible piece); `null` when nothing of it is visible.
|
|
26
|
+
*/
|
|
27
|
+
export declare function shapeAnchor(geometry: Geometry, view: Bounds, center: [number, number]): [number, number] | null;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/** The geometry's bounding box, or `null` when it has no coordinates. */
|
|
2
|
+
export function geometryBbox(geometry) {
|
|
3
|
+
let west = Infinity;
|
|
4
|
+
let south = Infinity;
|
|
5
|
+
let east = -Infinity;
|
|
6
|
+
let north = -Infinity;
|
|
7
|
+
const add = (p) => {
|
|
8
|
+
if (p[0] < west)
|
|
9
|
+
west = p[0];
|
|
10
|
+
if (p[0] > east)
|
|
11
|
+
east = p[0];
|
|
12
|
+
if (p[1] < south)
|
|
13
|
+
south = p[1];
|
|
14
|
+
if (p[1] > north)
|
|
15
|
+
north = p[1];
|
|
16
|
+
};
|
|
17
|
+
const walk = (g) => {
|
|
18
|
+
switch (g.type) {
|
|
19
|
+
case "Point":
|
|
20
|
+
add(g.coordinates);
|
|
21
|
+
break;
|
|
22
|
+
case "MultiPoint":
|
|
23
|
+
case "LineString":
|
|
24
|
+
g.coordinates.forEach(add);
|
|
25
|
+
break;
|
|
26
|
+
case "MultiLineString":
|
|
27
|
+
case "Polygon":
|
|
28
|
+
g.coordinates.forEach((ring) => ring.forEach(add));
|
|
29
|
+
break;
|
|
30
|
+
case "MultiPolygon":
|
|
31
|
+
g.coordinates.forEach((poly) => poly.forEach((ring) => ring.forEach(add)));
|
|
32
|
+
break;
|
|
33
|
+
case "GeometryCollection":
|
|
34
|
+
g.geometries.forEach(walk);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
walk(geometry);
|
|
39
|
+
return west <= east ? [west, south, east, north] : null;
|
|
40
|
+
}
|
|
41
|
+
const inRect = (p, b) => p[0] >= b.west && p[0] <= b.east && p[1] >= b.south && p[1] <= b.north;
|
|
42
|
+
/** The polygons of a geometry (each an outer ring and holes), or `null` for a non-polygon. */
|
|
43
|
+
function polygonsOf(g) {
|
|
44
|
+
if (g.type === "Polygon")
|
|
45
|
+
return [g.coordinates];
|
|
46
|
+
if (g.type === "MultiPolygon")
|
|
47
|
+
return g.coordinates;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function linesOf(g) {
|
|
51
|
+
if (g.type === "LineString")
|
|
52
|
+
return [g.coordinates];
|
|
53
|
+
if (g.type === "MultiLineString")
|
|
54
|
+
return g.coordinates;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** Ring edges, closing an unclosed ring. Fewer than 3 points: no area, so no edges. */
|
|
58
|
+
function ringEdges(ring) {
|
|
59
|
+
const n = ring.length;
|
|
60
|
+
if (n < 3)
|
|
61
|
+
return [];
|
|
62
|
+
const edges = [];
|
|
63
|
+
for (let i = 0; i < n - 1; i++)
|
|
64
|
+
edges.push([ring[i], ring[i + 1]]);
|
|
65
|
+
const [a, z] = [ring[0], ring[n - 1]];
|
|
66
|
+
if (a[0] !== z[0] || a[1] !== z[1])
|
|
67
|
+
edges.push([z, a]);
|
|
68
|
+
return edges;
|
|
69
|
+
}
|
|
70
|
+
function inRing(p, ring) {
|
|
71
|
+
let inside = false;
|
|
72
|
+
for (const [a, b] of ringEdges(ring)) {
|
|
73
|
+
if (a[1] > p[1] !== b[1] > p[1]) {
|
|
74
|
+
const x = ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0];
|
|
75
|
+
if (p[0] < x)
|
|
76
|
+
inside = !inside;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return inside;
|
|
80
|
+
}
|
|
81
|
+
function inPolygon(p, polygon) {
|
|
82
|
+
const [outer, ...holes] = polygon;
|
|
83
|
+
return !!outer && inRing(p, outer) && !holes.some((hole) => inRing(p, hole));
|
|
84
|
+
}
|
|
85
|
+
/** Inside an outer ring and outside its holes, for any polygon of the geometry. */
|
|
86
|
+
export function containsPoint(geometry, p) {
|
|
87
|
+
return polygonsOf(geometry)?.some((polygon) => inPolygon(p, polygon)) ?? false;
|
|
88
|
+
}
|
|
89
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
90
|
+
const onSegment = (p, a, b) => Math.min(a[0], b[0]) <= p[0] &&
|
|
91
|
+
p[0] <= Math.max(a[0], b[0]) &&
|
|
92
|
+
Math.min(a[1], b[1]) <= p[1] &&
|
|
93
|
+
p[1] <= Math.max(a[1], b[1]);
|
|
94
|
+
/** Whether segments ab and cd meet, touching and collinear overlaps included. */
|
|
95
|
+
function segmentsMeet(a, b, c, d) {
|
|
96
|
+
const d1 = cross(c, d, a);
|
|
97
|
+
const d2 = cross(c, d, b);
|
|
98
|
+
const d3 = cross(a, b, c);
|
|
99
|
+
const d4 = cross(a, b, d);
|
|
100
|
+
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
|
101
|
+
return true;
|
|
102
|
+
return ((d1 === 0 && onSegment(a, c, d)) ||
|
|
103
|
+
(d2 === 0 && onSegment(b, c, d)) ||
|
|
104
|
+
(d3 === 0 && onSegment(c, a, b)) ||
|
|
105
|
+
(d4 === 0 && onSegment(d, a, b)));
|
|
106
|
+
}
|
|
107
|
+
function rectCorners(b) {
|
|
108
|
+
return [
|
|
109
|
+
[b.west, b.south],
|
|
110
|
+
[b.east, b.south],
|
|
111
|
+
[b.east, b.north],
|
|
112
|
+
[b.west, b.north]
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
function segmentHitsRect(a, b, r) {
|
|
116
|
+
if (inRect(a, r) || inRect(b, r))
|
|
117
|
+
return true;
|
|
118
|
+
const c = rectCorners(r);
|
|
119
|
+
return c.some((p, i) => segmentsMeet(a, b, p, c[(i + 1) % 4]));
|
|
120
|
+
}
|
|
121
|
+
function polygonHitsRect(polygon, r) {
|
|
122
|
+
const edges = polygon.flatMap(ringEdges);
|
|
123
|
+
if (edges.length === 0)
|
|
124
|
+
return false;
|
|
125
|
+
if (edges.some(([a]) => inRect(a, r)))
|
|
126
|
+
return true;
|
|
127
|
+
if (rectCorners(r).some((p) => inPolygon(p, polygon)))
|
|
128
|
+
return true;
|
|
129
|
+
return edges.some(([a, b]) => segmentHitsRect(a, b, r));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Whether the item is in view: a shape (`geometry`) when it intersects `bounds` (bbox pre-check,
|
|
133
|
+
* then the exact test; holes count), anything else when its `lngLat` is inside. Edges included.
|
|
134
|
+
*/
|
|
135
|
+
export function intersectsBounds(item, bounds) {
|
|
136
|
+
const { geometry } = item;
|
|
137
|
+
const polygons = geometry && polygonsOf(geometry);
|
|
138
|
+
const lines = geometry && linesOf(geometry);
|
|
139
|
+
if (!geometry || (!polygons && !lines && geometry.type !== "Point")) {
|
|
140
|
+
return inRect(item.lngLat, bounds);
|
|
141
|
+
}
|
|
142
|
+
if (geometry.type === "Point")
|
|
143
|
+
return inRect(geometry.coordinates, bounds);
|
|
144
|
+
const box = item.bbox ?? geometryBbox(geometry);
|
|
145
|
+
if (!box ||
|
|
146
|
+
box[0] > bounds.east ||
|
|
147
|
+
box[2] < bounds.west ||
|
|
148
|
+
box[1] > bounds.north ||
|
|
149
|
+
box[3] < bounds.south) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
if (polygons)
|
|
153
|
+
return polygons.some((polygon) => polygonHitsRect(polygon, bounds));
|
|
154
|
+
return lines.some((line) => line.some((p, i) => i === 0 ? line.length === 1 && inRect(p, bounds) : segmentHitsRect(line[i - 1], p, bounds)));
|
|
155
|
+
}
|
|
156
|
+
/** Sutherland–Hodgman: the ring clipped to the rectangle (open, no repeated first point). */
|
|
157
|
+
function clipRing(ring, r) {
|
|
158
|
+
let pts = ringEdges(ring).map(([a]) => [a[0], a[1]]);
|
|
159
|
+
const sides = [
|
|
160
|
+
[(p) => p[0] >= r.west, (a, b) => atX(a, b, r.west)],
|
|
161
|
+
[(p) => p[0] <= r.east, (a, b) => atX(a, b, r.east)],
|
|
162
|
+
[(p) => p[1] >= r.south, (a, b) => atY(a, b, r.south)],
|
|
163
|
+
[(p) => p[1] <= r.north, (a, b) => atY(a, b, r.north)]
|
|
164
|
+
];
|
|
165
|
+
for (const [keep, cut] of sides) {
|
|
166
|
+
const out = [];
|
|
167
|
+
for (let i = 0; i < pts.length; i++) {
|
|
168
|
+
const cur = pts[i];
|
|
169
|
+
const prev = pts[(i + pts.length - 1) % pts.length];
|
|
170
|
+
if (keep(cur)) {
|
|
171
|
+
if (!keep(prev))
|
|
172
|
+
out.push(cut(prev, cur));
|
|
173
|
+
out.push(cur);
|
|
174
|
+
}
|
|
175
|
+
else if (keep(prev)) {
|
|
176
|
+
out.push(cut(prev, cur));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
pts = out;
|
|
180
|
+
if (pts.length === 0)
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
return pts;
|
|
184
|
+
}
|
|
185
|
+
const atX = (a, b, x) => [
|
|
186
|
+
x,
|
|
187
|
+
a[1] + ((b[1] - a[1]) * (x - a[0])) / (b[0] - a[0])
|
|
188
|
+
];
|
|
189
|
+
const atY = (a, b, y) => [
|
|
190
|
+
a[0] + ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]),
|
|
191
|
+
y
|
|
192
|
+
];
|
|
193
|
+
function openArea(pts) {
|
|
194
|
+
let area = 0;
|
|
195
|
+
for (let i = 0; i < pts.length; i++) {
|
|
196
|
+
const [a, b] = [pts[i], pts[(i + 1) % pts.length]];
|
|
197
|
+
area += a[0] * b[1] - b[0] * a[1];
|
|
198
|
+
}
|
|
199
|
+
return Math.abs(area) / 2;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A point well inside the clipped polygon (outer ring, then holes): the midpoint of the widest
|
|
203
|
+
* interior span of a horizontal line through its middle. When that line has none (a C shape,
|
|
204
|
+
* bands around a hole), the widest span among lines between neighboring vertex heights.
|
|
205
|
+
*/
|
|
206
|
+
function polygonInteriorPoint(rings) {
|
|
207
|
+
const ys = [...new Set(rings.flatMap((ring) => ring.map((p) => p[1])))].sort((a, b) => a - b);
|
|
208
|
+
if (ys.length < 2)
|
|
209
|
+
return null;
|
|
210
|
+
const middle = widestSpan(rings, ys, [(ys[0] + ys[ys.length - 1]) / 2]);
|
|
211
|
+
if (middle)
|
|
212
|
+
return middle;
|
|
213
|
+
const gaps = [];
|
|
214
|
+
for (let i = 1; i < ys.length; i++)
|
|
215
|
+
gaps.push((ys[i - 1] + ys[i]) / 2);
|
|
216
|
+
return widestSpan(rings, ys, gaps);
|
|
217
|
+
}
|
|
218
|
+
/** The midpoint of the widest interior span on any of the horizontal `lines`. */
|
|
219
|
+
function widestSpan(rings, ys, lines) {
|
|
220
|
+
let best = null;
|
|
221
|
+
let width = 0;
|
|
222
|
+
for (let y of lines) {
|
|
223
|
+
// On a vertex height the crossings double up; step off it.
|
|
224
|
+
if (ys.includes(y))
|
|
225
|
+
y += (ys[ys.length - 1] - ys[0]) * 1e-9;
|
|
226
|
+
const xs = [];
|
|
227
|
+
for (const ring of rings) {
|
|
228
|
+
for (let i = 0; i < ring.length; i++) {
|
|
229
|
+
const [a, b] = [ring[i], ring[(i + 1) % ring.length]];
|
|
230
|
+
if (a[1] > y !== b[1] > y)
|
|
231
|
+
xs.push(atY(a, b, y)[0]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
xs.sort((a, b) => a - b);
|
|
235
|
+
for (let i = 0; i + 1 < xs.length; i += 2) {
|
|
236
|
+
if (xs[i + 1] - xs[i] > width) {
|
|
237
|
+
width = xs[i + 1] - xs[i];
|
|
238
|
+
best = [(xs[i] + xs[i + 1]) / 2, y];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return best;
|
|
243
|
+
}
|
|
244
|
+
/** Liang–Barsky: the segment clipped to the rectangle, or `null` when it misses. */
|
|
245
|
+
function clipSegment(a, b, r) {
|
|
246
|
+
const dx = b[0] - a[0];
|
|
247
|
+
const dy = b[1] - a[1];
|
|
248
|
+
let t0 = 0;
|
|
249
|
+
let t1 = 1;
|
|
250
|
+
const checks = [
|
|
251
|
+
[-dx, a[0] - r.west],
|
|
252
|
+
[dx, r.east - a[0]],
|
|
253
|
+
[-dy, a[1] - r.south],
|
|
254
|
+
[dy, r.north - a[1]]
|
|
255
|
+
];
|
|
256
|
+
for (const [p, q] of checks) {
|
|
257
|
+
if (p === 0) {
|
|
258
|
+
if (q < 0)
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
const t = q / p;
|
|
263
|
+
if (p < 0)
|
|
264
|
+
t0 = Math.max(t0, t);
|
|
265
|
+
else
|
|
266
|
+
t1 = Math.min(t1, t);
|
|
267
|
+
if (t0 > t1)
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
[a[0] + t0 * dx, a[1] + t0 * dy],
|
|
273
|
+
[a[0] + t1 * dx, a[1] + t1 * dy]
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* A point of the shape inside `view`: `center` when the shape contains it; otherwise a point well
|
|
278
|
+
* inside the shape's visible part (polygons: the largest clipped one, never in a hole; lines: the
|
|
279
|
+
* middle of the longest visible piece); `null` when nothing of it is visible.
|
|
280
|
+
*/
|
|
281
|
+
export function shapeAnchor(geometry, view, center) {
|
|
282
|
+
const polygons = polygonsOf(geometry);
|
|
283
|
+
if (polygons) {
|
|
284
|
+
if (inRect(center, view) && containsPoint(geometry, center))
|
|
285
|
+
return center;
|
|
286
|
+
// Visible parts, largest first; the first that yields a point wins.
|
|
287
|
+
const parts = [];
|
|
288
|
+
for (const polygon of polygons) {
|
|
289
|
+
if (!polygon[0] || ringEdges(polygon[0]).length === 0)
|
|
290
|
+
continue;
|
|
291
|
+
const rings = polygon.map((ring) => clipRing(ring, view));
|
|
292
|
+
const area = rings[0].length >= 3 ? openArea(rings[0]) : 0;
|
|
293
|
+
if (area > 0)
|
|
294
|
+
parts.push({ rings: rings.filter((r, i) => i === 0 || r.length >= 3), area });
|
|
295
|
+
}
|
|
296
|
+
parts.sort((a, b) => b.area - a.area);
|
|
297
|
+
for (const { rings } of parts) {
|
|
298
|
+
const at = polygonInteriorPoint(rings);
|
|
299
|
+
if (at)
|
|
300
|
+
return at;
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
const lines = linesOf(geometry);
|
|
305
|
+
if (lines) {
|
|
306
|
+
let best = null;
|
|
307
|
+
let length = -1;
|
|
308
|
+
for (const line of lines) {
|
|
309
|
+
for (let i = 1; i < line.length; i++) {
|
|
310
|
+
const piece = clipSegment(line[i - 1], line[i], view);
|
|
311
|
+
if (!piece)
|
|
312
|
+
continue;
|
|
313
|
+
const len = Math.hypot(piece[1][0] - piece[0][0], piece[1][1] - piece[0][1]);
|
|
314
|
+
if (len > length) {
|
|
315
|
+
length = len;
|
|
316
|
+
best = [(piece[0][0] + piece[1][0]) / 2, (piece[0][1] + piece[1][1]) / 2];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return best;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { legendSection } from "./ui/legend-data.js";
|
|
|
12
12
|
export type { LegendBar as LegendBarData, LegendSection } from "./ui/legend-data.js";
|
|
13
13
|
export { rowLabel } from "./ui/feature-list.js";
|
|
14
14
|
export type { SortBy } from "./ui/feature-list.js";
|
|
15
|
+
export { geometryBbox, intersectsBounds } from "./core/shape-view.js";
|
|
16
|
+
export type { Bbox } from "./core/shape-view.js";
|
|
15
17
|
export { pointsPlugin } from "./plugins/points.svelte.js";
|
|
16
18
|
export type { ClusterInfo, ClusterLayerArgs, PointsOptions, PointsTooltipEnv } from "./plugins/points.svelte.js";
|
|
17
19
|
export type { ClusterLayers } from "./plugins/points-clusters.js";
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ export { default as ViewSummary } from "./ui/ViewSummary.svelte";
|
|
|
10
10
|
export { default as MapControls } from "./ui/MapControls.svelte";
|
|
11
11
|
export { legendSection } from "./ui/legend-data.js";
|
|
12
12
|
export { rowLabel } from "./ui/feature-list.js";
|
|
13
|
+
export { geometryBbox, intersectsBounds } from "./core/shape-view.js";
|
|
13
14
|
export { pointsPlugin } from "./plugins/points.svelte.js";
|
|
14
15
|
export { colorExpression, levelKeyExpression, opacityExpression } from "./plugins/style.js";
|
|
15
16
|
export { shapesPlugin } from "./plugins/shapes.svelte.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { geometryBbox } from "../core/shape-view.js";
|
|
1
2
|
import { untrack } from "svelte";
|
|
2
3
|
import { decodeRef, selectionId } from "../data/refs.js";
|
|
3
4
|
import { levelFor, NO_LEVEL } from "../data/scale.js";
|
|
@@ -88,6 +89,17 @@ export function shapesPlugin(options) {
|
|
|
88
89
|
/** Plain mode ignores the source's scale. */
|
|
89
90
|
const currentScale = () => (choropleth ? scales.current() : NO_SCALE);
|
|
90
91
|
const legend = () => (choropleth ? scales.legend() : null);
|
|
92
|
+
// Each feature's label point and bbox, measured once per feature object (once per data load):
|
|
93
|
+
// `features()` runs on every move. Per plugin instance, so two maps share nothing.
|
|
94
|
+
const measured = new WeakMap();
|
|
95
|
+
const measure = (feature) => {
|
|
96
|
+
let m = measured.get(feature);
|
|
97
|
+
if (!m) {
|
|
98
|
+
m = { lngLat: labelPoint(feature.geometry), bbox: geometryBbox(feature.geometry) };
|
|
99
|
+
measured.set(feature, m);
|
|
100
|
+
}
|
|
101
|
+
return m;
|
|
102
|
+
};
|
|
91
103
|
function features() {
|
|
92
104
|
const collection = source.features;
|
|
93
105
|
if (!collection)
|
|
@@ -101,8 +113,10 @@ export function shapesPlugin(options) {
|
|
|
101
113
|
if (!props || !feature.geometry)
|
|
102
114
|
continue;
|
|
103
115
|
const ref = decodeRef(props.refKey);
|
|
104
|
-
|
|
105
|
-
|
|
116
|
+
if (!ref)
|
|
117
|
+
continue;
|
|
118
|
+
const { lngLat, bbox } = measure(feature);
|
|
119
|
+
if (!lngLat)
|
|
106
120
|
continue;
|
|
107
121
|
items.push({
|
|
108
122
|
refKey: props.refKey,
|
|
@@ -113,6 +127,8 @@ export function shapesPlugin(options) {
|
|
|
113
127
|
units: scale?.units ?? "",
|
|
114
128
|
precision,
|
|
115
129
|
lngLat,
|
|
130
|
+
geometry: feature.geometry,
|
|
131
|
+
...(bbox && { bbox }),
|
|
116
132
|
selectable: selectable && selectionId(ref) !== null
|
|
117
133
|
});
|
|
118
134
|
}
|
|
@@ -70,11 +70,24 @@
|
|
|
70
70
|
// A found item anchors the tooltip at its point (stable while the pointer moves over it).
|
|
71
71
|
// In pointer mode a hovered hit is anchored at the pointer instead (a focused item stays
|
|
72
72
|
// pinned).
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// A hovered shape whose point is off-screen pins at a point of it in view, found once per
|
|
74
|
+
// hovered feature (keyed on primitives), so pointer moves within it don't move the tooltip.
|
|
75
|
+
// It's found again when the map settles (`bounds`), so a pan or zoom under a resting pointer
|
|
76
|
+
// doesn't leave it off-screen.
|
|
77
|
+
const hoverKey = $derived(
|
|
78
|
+
!map.focusedKey && item && hitPluginId ? `${hitPluginId}\n${item.refKey}` : null
|
|
77
79
|
);
|
|
80
|
+
const hoverAnchor = $derived.by(() => {
|
|
81
|
+
if (hoverKey === null) return null;
|
|
82
|
+
void map.bounds;
|
|
83
|
+
return untrack(() => (item?.geometry ? map.anchorFor(item) : null));
|
|
84
|
+
});
|
|
85
|
+
const anchor = $derived.by((): [number, number] | null => {
|
|
86
|
+
if (mode === "pointer" && !map.focusedKey) return map.hovered?.lngLat ?? null;
|
|
87
|
+
// A focused feature that left the data has no item, so no tooltip.
|
|
88
|
+
if (map.focusedKey) return item ? (map.focusedAnchor ?? item.lngLat) : null;
|
|
89
|
+
return hoverAnchor ?? item?.lngLat ?? map.hovered?.lngLat ?? null;
|
|
90
|
+
});
|
|
78
91
|
const projected = $derived(anchor ? project(anchor) : null);
|
|
79
92
|
// Centered above the point; flipped below it near the top edge; inside the container.
|
|
80
93
|
const position = $derived.by(() => {
|
|
@@ -16,7 +16,10 @@ export declare const MENU_EVENT_WINDOW_MS = 1000;
|
|
|
16
16
|
* separator and different rows never share a fragment.
|
|
17
17
|
*/
|
|
18
18
|
export declare function optionIdPart(pluginId: string, refKey: string): string;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
|
|
21
|
+
* its point is inside. No antimeridian handling (the Valley).
|
|
22
|
+
*/
|
|
20
23
|
export declare function inView(items: FeatureListItem[], bounds: Bounds): FeatureListItem[];
|
|
21
24
|
/**
|
|
22
25
|
* The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
|
package/dist/ui/feature-list.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { intersectsBounds } from "../core/shape-view.js";
|
|
1
2
|
import { NO_LEVEL } from "../data/scale.js";
|
|
2
3
|
import { formatValue } from "./value-format.js";
|
|
3
4
|
/**
|
|
@@ -14,11 +15,12 @@ export function optionIdPart(pluginId, refKey) {
|
|
|
14
15
|
const escape = (text) => text.replace(/[^A-Za-z0-9]/g, (c) => `_${c.codePointAt(0).toString(16)}_`);
|
|
15
16
|
return `${escape(pluginId)}--${escape(refKey)}`;
|
|
16
17
|
}
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Items in `bounds`, edges included: a shape (`geometry`) when it overlaps them, anything else when
|
|
20
|
+
* its point is inside. No antimeridian handling (the Valley).
|
|
21
|
+
*/
|
|
18
22
|
export function inView(items, bounds) {
|
|
19
|
-
return items.filter((
|
|
20
|
-
return lng >= bounds.west && lng <= bounds.east && lat >= bounds.south && lat <= bounds.north;
|
|
21
|
-
});
|
|
23
|
+
return items.filter((item) => intersectsBounds(item, bounds));
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* The item's level, or null for `NO_LEVEL`. A level with an empty color is still shown, by its
|