@sjvair/map-sdk 0.1.0-beta.2 → 0.1.0-beta.3
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
|
@@ -9,10 +9,10 @@ Supported browsers: Chrome/Edge 111+, Safari 16.4+, Firefox 128+. MIT licensed.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
map-sdk is in beta. Betas are published under the `next` tag:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npm install @sjvair/map-sdk
|
|
15
|
+
npm install @sjvair/map-sdk@next
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Peer dependencies: `svelte` (^5.57) and `@sjvair/sdk`, which is published to
|
|
@@ -156,6 +156,11 @@ create one per map (adding an object that is set up on another map throws). The
|
|
|
156
156
|
ones they are built on. Presets don't fetch: the host supplies a `DataSource`. `monitorsPlugin` is
|
|
157
157
|
selectable by default, like the shape presets.
|
|
158
158
|
|
|
159
|
+
An added plugin is in `map.plugins` (and the legend and selection state) as soon as `addPlugin`
|
|
160
|
+
returns. On a map that is already showing, its `setup` runs just after the current code finishes,
|
|
161
|
+
so its layers aren't there yet on the next line. Code that must see them uses `await tick()`;
|
|
162
|
+
`flushSync()` does not run setup.
|
|
163
|
+
|
|
159
164
|
Selected points show a highlight ring and check mark by default. `selectedStyle: "enlarge"` on
|
|
160
165
|
`pointsPlugin` or `monitorsPlugin` instead draws the selected icon at 1.3x with no ring (icon
|
|
161
166
|
points only), as sjvair.com's map does today.
|
|
@@ -297,8 +302,10 @@ not selectable). `encodeRef`/`decodeRef` convert between refs and keys.
|
|
|
297
302
|
|
|
298
303
|
A plugin is an object with an `id` and a `setup(ctx)` that adds sources, layers, and images
|
|
299
304
|
through its `PluginContext` and returns a teardown. The host runs `setup` once the map's style is
|
|
300
|
-
ready and again after a basemap or theme change
|
|
301
|
-
|
|
305
|
+
ready and again after a basemap or theme change. For a plugin added to a ready map, `setup` runs
|
|
306
|
+
just after the current code finishes (a microtask), never inside the caller's code, so adding and
|
|
307
|
+
removing plugins from an `$effect` is safe. Everything added through `ctx` is removed on teardown,
|
|
308
|
+
and ids are namespaced per map. `features()` feeds the feature list, tooltip, and
|
|
302
309
|
summary; `legend()` the legend. A plugin's `tooltip` content sits under the feature's name; set
|
|
303
310
|
`tooltipShowsName` when the content shows the name itself (the monitor and collocation tooltips
|
|
304
311
|
do), and the name header is left out.
|
|
@@ -421,9 +428,10 @@ map.easeTo({ center: monitor.lngLat, minZoom: 12 });
|
|
|
421
428
|
|
|
422
429
|
## Ready, and reaching the GL map
|
|
423
430
|
|
|
424
|
-
`map.ready` is reactive: true once the attached map's style has loaded
|
|
425
|
-
for the first time,
|
|
426
|
-
|
|
431
|
+
`map.ready` is reactive: true once the attached map's style has loaded, the map has gone idle
|
|
432
|
+
for the first time, and any queued plugin setups have run; false again on detach and while a
|
|
433
|
+
basemap switch reloads the style. Adding a plugin to a ready map leaves it true. Show a load screen
|
|
434
|
+
until it is true. `map.gl` is the attached GL map (or `null`), reactive.
|
|
427
435
|
|
|
428
436
|
`maptilerMap(map)` returns it as a MapTiler `Map` (or `null` while detached). It is an escape hatch
|
|
429
437
|
for markers and popups only: never add sources or layers through it, because plugins own those.
|
|
@@ -82,8 +82,9 @@ export interface MapContext {
|
|
|
82
82
|
/** Reactive. */
|
|
83
83
|
readonly attached: boolean;
|
|
84
84
|
/**
|
|
85
|
-
* Reactive. True once the attached map's style has loaded
|
|
86
|
-
* first time; false again on `detach()` and while a
|
|
85
|
+
* Reactive. True once the attached map's style has loaded, the map has gone idle for the
|
|
86
|
+
* first time, and any queued plugin setups have run; false again on `detach()` and while a
|
|
87
|
+
* basemap switch reloads the style. Adding a plugin to a ready map leaves it true. A load
|
|
87
88
|
* screen can wait for it.
|
|
88
89
|
*/
|
|
89
90
|
readonly ready: boolean;
|
|
@@ -197,7 +198,8 @@ export interface MapContext {
|
|
|
197
198
|
updateView(camera: CameraState, bounds: Bounds): void;
|
|
198
199
|
/**
|
|
199
200
|
* For MapView only: the host's theme changed, so plugins set up again and re-read the theme
|
|
200
|
-
* colors they bake into paint and images. Does nothing while detached.
|
|
201
|
+
* colors they bake into paint and images. Does nothing while detached. Sets plugins up
|
|
202
|
+
* synchronously, so never call it from inside an effect (see `PluginHost`).
|
|
201
203
|
*/
|
|
202
204
|
themeChanged(): void;
|
|
203
205
|
/** Detaches, removes every plugin, and drops handlers. The context can't be used after. */
|
|
@@ -50,6 +50,8 @@ class MapContextImpl {
|
|
|
50
50
|
#bounds = $state.raw(null);
|
|
51
51
|
#attached = $state(false);
|
|
52
52
|
#ready = $state(false);
|
|
53
|
+
/** `markReady` came while plugin setups were queued; the queued run sets `ready`. */
|
|
54
|
+
#readyRequested = false;
|
|
53
55
|
#glState = $state.raw(null);
|
|
54
56
|
#focusedKey = $state(null);
|
|
55
57
|
#focusedPluginId = $state(null);
|
|
@@ -77,7 +79,15 @@ class MapContextImpl {
|
|
|
77
79
|
mapUid: this.uid,
|
|
78
80
|
providers: { metadata: this.metadata, messages: this.messages, format: this.format },
|
|
79
81
|
router: this.#router,
|
|
80
|
-
selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId))
|
|
82
|
+
selectedKeys: (pluginId) => selectionKeys(this.getSelection(pluginId)),
|
|
83
|
+
onSettled: () => {
|
|
84
|
+
// A setup that added a plugin queued another run; that run settles it.
|
|
85
|
+
if (this.#host.pending)
|
|
86
|
+
return;
|
|
87
|
+
if (this.#readyRequested && this.#gl)
|
|
88
|
+
this.#ready = true;
|
|
89
|
+
this.#readyRequested = false;
|
|
90
|
+
}
|
|
81
91
|
});
|
|
82
92
|
this.#router.onPick((e) => this.#pick(e));
|
|
83
93
|
}
|
|
@@ -100,7 +110,11 @@ class MapContextImpl {
|
|
|
100
110
|
return this.#glState;
|
|
101
111
|
}
|
|
102
112
|
markReady() {
|
|
103
|
-
if (this.#gl)
|
|
113
|
+
if (!this.#gl)
|
|
114
|
+
return;
|
|
115
|
+
if (this.#host.pending)
|
|
116
|
+
this.#readyRequested = true;
|
|
117
|
+
else
|
|
104
118
|
this.#ready = true;
|
|
105
119
|
}
|
|
106
120
|
get focusedKey() {
|
|
@@ -269,6 +283,7 @@ class MapContextImpl {
|
|
|
269
283
|
this.#router.attach(gl);
|
|
270
284
|
this.basemap.attach(gl, () => this.#host.restyle(), () => {
|
|
271
285
|
this.#ready = false;
|
|
286
|
+
this.#readyRequested = false;
|
|
272
287
|
this.#host.suspend();
|
|
273
288
|
});
|
|
274
289
|
this.#attached = true;
|
|
@@ -283,6 +298,7 @@ class MapContextImpl {
|
|
|
283
298
|
this.#glState = null;
|
|
284
299
|
this.#attached = false;
|
|
285
300
|
this.#ready = false;
|
|
301
|
+
this.#readyRequested = false;
|
|
286
302
|
}
|
|
287
303
|
themeChanged() {
|
|
288
304
|
if (this.#gl)
|
|
@@ -7,12 +7,23 @@ export interface PluginHostOptions {
|
|
|
7
7
|
router: InteractionRouter;
|
|
8
8
|
/** Reactive: read inside a plugin's effects. */
|
|
9
9
|
selectedKeys(pluginId: string): ReadonlySet<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Called after each queued setup run finishes (also when it had nothing to do), never after a
|
|
12
|
+
* synchronous one.
|
|
13
|
+
*/
|
|
14
|
+
onSettled?: () => void;
|
|
10
15
|
}
|
|
11
16
|
/**
|
|
12
17
|
* Runs plugins against a GL map. Plugins are set up only when the map's style is loaded, and
|
|
13
18
|
* everything they add through their `PluginContext` is removed on teardown, whatever their own
|
|
14
19
|
* teardown does. The plugin list outlives any one GL map.
|
|
15
20
|
*
|
|
21
|
+
* Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
|
|
22
|
+
* are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
|
|
23
|
+
* `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
|
|
24
|
+
* (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
|
|
25
|
+
* style load or `restyle` comes from map events, not host code, and stays synchronous.
|
|
26
|
+
*
|
|
16
27
|
* Plugins draw in list order, later on top: each plugin's layers stay together, below the first
|
|
17
28
|
* layer of the next plugin in the list that has any.
|
|
18
29
|
*/
|
|
@@ -21,9 +32,12 @@ export declare class PluginHost {
|
|
|
21
32
|
constructor(opts: PluginHostOptions);
|
|
22
33
|
/** In draw order, bottom first. Reactive. */
|
|
23
34
|
get plugins(): readonly MapPlugin[];
|
|
35
|
+
/** True while a setup run is queued and has not started. Not reactive. */
|
|
36
|
+
get pending(): boolean;
|
|
24
37
|
/**
|
|
25
|
-
* Adds `plugin` on top, or just below the plugin `before` names.
|
|
26
|
-
*
|
|
38
|
+
* Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
|
|
39
|
+
* on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
|
|
40
|
+
* another map at the moment (one plugin object per map).
|
|
27
41
|
*/
|
|
28
42
|
add(plugin: MapPlugin, before?: string): void;
|
|
29
43
|
/**
|
|
@@ -32,14 +46,21 @@ export declare class PluginHost {
|
|
|
32
46
|
*/
|
|
33
47
|
move(id: string, before?: string): void;
|
|
34
48
|
remove(id: string): void;
|
|
35
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
|
|
51
|
+
* it already is).
|
|
52
|
+
*/
|
|
36
53
|
attach(gl: GlMapLike): void;
|
|
37
54
|
/**
|
|
38
55
|
* Tears every plugin down and stops waiting for a style, before the map's style is replaced:
|
|
39
56
|
* nothing may touch a style on its way out. `restyle` sets them up again.
|
|
40
57
|
*/
|
|
41
58
|
suspend(): void;
|
|
42
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Tears down and sets up every plugin again (after a basemap change), once the style loads.
|
|
61
|
+
* On a loaded style the setup is synchronous, so only map events and theme watching call this,
|
|
62
|
+
* never code inside an effect: roots created there can stop (see the class comment).
|
|
63
|
+
*/
|
|
43
64
|
restyle(): void;
|
|
44
65
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
45
66
|
detach(): void;
|
|
@@ -19,6 +19,12 @@ function ownershipError(plugin) {
|
|
|
19
19
|
* everything they add through their `PluginContext` is removed on teardown, whatever their own
|
|
20
20
|
* teardown does. The plugin list outlives any one GL map.
|
|
21
21
|
*
|
|
22
|
+
* Setup never runs inside the caller's code. When `add` or `attach` would set plugins up, they
|
|
23
|
+
* are queued and set up in one microtask, where no effect is active: Svelte cuts loose the
|
|
24
|
+
* `$effect.root`s created inside an effect when that effect re-runs, and their effects can stop
|
|
25
|
+
* (see docs/superpowers/specs/2026-11-07-plugin-setup-outside-effects-design.md). Setup on a
|
|
26
|
+
* style load or `restyle` comes from map events, not host code, and stays synchronous.
|
|
27
|
+
*
|
|
22
28
|
* Plugins draw in list order, later on top: each plugin's layers stay together, below the first
|
|
23
29
|
* layer of the next plugin in the list that has any.
|
|
24
30
|
*/
|
|
@@ -36,6 +42,8 @@ export class PluginHost {
|
|
|
36
42
|
* a style that already loaded while `isStyleLoaded()` is false because sources are loading.
|
|
37
43
|
*/
|
|
38
44
|
#onStyleLoad = null;
|
|
45
|
+
/** True from scheduling a queued setup run until it starts. */
|
|
46
|
+
#setupQueued = false;
|
|
39
47
|
constructor(opts) {
|
|
40
48
|
this.#opts = opts;
|
|
41
49
|
}
|
|
@@ -43,6 +51,10 @@ export class PluginHost {
|
|
|
43
51
|
get plugins() {
|
|
44
52
|
return this.#plugins;
|
|
45
53
|
}
|
|
54
|
+
/** True while a setup run is queued and has not started. Not reactive. */
|
|
55
|
+
get pending() {
|
|
56
|
+
return this.#setupQueued;
|
|
57
|
+
}
|
|
46
58
|
/**
|
|
47
59
|
* The host's own reads are untracked, so calling `add`, `attach`, etc. from inside an effect
|
|
48
60
|
* doesn't make that effect depend on the plugin list.
|
|
@@ -51,8 +63,9 @@ export class PluginHost {
|
|
|
51
63
|
return untrack(() => this.#plugins);
|
|
52
64
|
}
|
|
53
65
|
/**
|
|
54
|
-
* Adds `plugin` on top, or just below the plugin `before` names.
|
|
55
|
-
*
|
|
66
|
+
* Adds `plugin` on top, or just below the plugin `before` names. It is in `plugins` at once;
|
|
67
|
+
* on a live map its setup runs in a microtask. Throws when the same plugin object is set up on
|
|
68
|
+
* another map at the moment (one plugin object per map).
|
|
56
69
|
*/
|
|
57
70
|
add(plugin, before) {
|
|
58
71
|
if (this.#list.some((p) => p.id === plugin.id)) {
|
|
@@ -62,7 +75,7 @@ export class PluginHost {
|
|
|
62
75
|
throw ownershipError(plugin);
|
|
63
76
|
this.#plugins = this.#inserted(this.#list, plugin, before);
|
|
64
77
|
if (this.#live)
|
|
65
|
-
this.#
|
|
78
|
+
this.#queueSetup();
|
|
66
79
|
}
|
|
67
80
|
/**
|
|
68
81
|
* Moves a plugin to the top, or just below the plugin `before` names, with its layers. Does
|
|
@@ -120,16 +133,22 @@ export class PluginHost {
|
|
|
120
133
|
if (!this.#list.some((p) => p.id === id))
|
|
121
134
|
return;
|
|
122
135
|
this.#teardown(id);
|
|
136
|
+
const plugin = this.#list.find((p) => p.id === id);
|
|
137
|
+
if (plugin)
|
|
138
|
+
this.#release(plugin);
|
|
123
139
|
this.#plugins = this.#list.filter((p) => p.id !== id);
|
|
124
140
|
}
|
|
125
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Uses `gl` from now on, setting up every plugin once its style is loaded (in a microtask when
|
|
143
|
+
* it already is).
|
|
144
|
+
*/
|
|
126
145
|
attach(gl) {
|
|
127
146
|
if (this.#gl === gl)
|
|
128
147
|
return;
|
|
129
148
|
if (this.#gl)
|
|
130
149
|
this.detach();
|
|
131
150
|
this.#gl = gl;
|
|
132
|
-
this.#whenStyleLoaded();
|
|
151
|
+
this.#whenStyleLoaded(false);
|
|
133
152
|
}
|
|
134
153
|
/**
|
|
135
154
|
* Tears every plugin down and stops waiting for a style, before the map's style is replaced:
|
|
@@ -141,12 +160,16 @@ export class PluginHost {
|
|
|
141
160
|
this.#cancelStyleWait();
|
|
142
161
|
this.#teardownAll();
|
|
143
162
|
}
|
|
144
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Tears down and sets up every plugin again (after a basemap change), once the style loads.
|
|
165
|
+
* On a loaded style the setup is synchronous, so only map events and theme watching call this,
|
|
166
|
+
* never code inside an effect: roots created there can stop (see the class comment).
|
|
167
|
+
*/
|
|
145
168
|
restyle() {
|
|
146
169
|
if (!this.#gl)
|
|
147
170
|
return;
|
|
148
171
|
this.#teardownAll();
|
|
149
|
-
this.#whenStyleLoaded();
|
|
172
|
+
this.#whenStyleLoaded(true);
|
|
150
173
|
}
|
|
151
174
|
/** Tears down every plugin. The gl is not touched after this returns. */
|
|
152
175
|
detach() {
|
|
@@ -157,11 +180,22 @@ export class PluginHost {
|
|
|
157
180
|
this.#teardownAll();
|
|
158
181
|
this.#gl = null;
|
|
159
182
|
}
|
|
160
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Sets every plugin up once the style is loaded. On a style that already is, `now` sets them up
|
|
185
|
+
* synchronously (only for callers that never run inside an effect); otherwise they are queued.
|
|
186
|
+
*/
|
|
187
|
+
#whenStyleLoaded(now) {
|
|
161
188
|
const gl = this.#gl;
|
|
162
189
|
this.#cancelStyleWait();
|
|
163
190
|
if (gl.isStyleLoaded()) {
|
|
164
|
-
|
|
191
|
+
if (now) {
|
|
192
|
+
this.#setupAll();
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Live already, so an `add` before the microtask joins the queued run.
|
|
196
|
+
this.#live = true;
|
|
197
|
+
this.#queueSetup();
|
|
198
|
+
}
|
|
165
199
|
return;
|
|
166
200
|
}
|
|
167
201
|
const onStyleLoad = () => {
|
|
@@ -175,6 +209,37 @@ export class PluginHost {
|
|
|
175
209
|
for (const type of STYLE_READY_EVENTS)
|
|
176
210
|
gl.on(type, onStyleLoad);
|
|
177
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Sets every plugin that isn't running up in a microtask, on whichever gl is live by then.
|
|
214
|
+
* Detach, suspend, and restyle clear `#live`, and only a loaded current gl sets it again, so
|
|
215
|
+
* the run never touches a gl that left. One run covers every request made before it starts.
|
|
216
|
+
*/
|
|
217
|
+
#queueSetup() {
|
|
218
|
+
// Claim the queued plugins now, so another map can't take one before the run.
|
|
219
|
+
for (const plugin of this.#list)
|
|
220
|
+
this.#claim(plugin);
|
|
221
|
+
if (this.#setupQueued)
|
|
222
|
+
return;
|
|
223
|
+
this.#setupQueued = true;
|
|
224
|
+
queueMicrotask(() => {
|
|
225
|
+
this.#setupQueued = false;
|
|
226
|
+
if (this.#live)
|
|
227
|
+
this.#setupAll();
|
|
228
|
+
this.#opts.onSettled?.();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Marks `plugin` as this host's, unless another map holds it. */
|
|
232
|
+
#claim(plugin) {
|
|
233
|
+
if (!ownedElsewhere(plugin, this) && Object.isExtensible(plugin)) {
|
|
234
|
+
plugin[OWNER] = this;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Releases a plugin this host claimed but isn't running (queued, then removed or left). */
|
|
238
|
+
#release(plugin) {
|
|
239
|
+
const owned = plugin;
|
|
240
|
+
if (owned[OWNER] === this && !this.#running.has(plugin.id))
|
|
241
|
+
delete owned[OWNER];
|
|
242
|
+
}
|
|
178
243
|
#cancelStyleWait() {
|
|
179
244
|
const listener = this.#onStyleLoad;
|
|
180
245
|
if (listener && this.#gl) {
|
|
@@ -202,8 +267,10 @@ export class PluginHost {
|
|
|
202
267
|
}
|
|
203
268
|
#teardownAll() {
|
|
204
269
|
this.#live = false;
|
|
205
|
-
for (const plugin of this.#list)
|
|
270
|
+
for (const plugin of this.#list) {
|
|
206
271
|
this.#teardown(plugin.id);
|
|
272
|
+
this.#release(plugin);
|
|
273
|
+
}
|
|
207
274
|
}
|
|
208
275
|
#setup(plugin) {
|
|
209
276
|
const gl = this.#gl;
|
|
@@ -225,7 +292,8 @@ export class PluginHost {
|
|
|
225
292
|
const ctx = this.#context(plugin.id, running);
|
|
226
293
|
let teardown;
|
|
227
294
|
try {
|
|
228
|
-
// Untracked
|
|
295
|
+
// Untracked, in case a plugin's setup reads state (setup runs from a microtask or a map
|
|
296
|
+
// event, never inside the caller's effect).
|
|
229
297
|
teardown = untrack(() => plugin.setup(ctx));
|
|
230
298
|
}
|
|
231
299
|
catch (error) {
|