@tileflow/svelte 0.1.0-alpha.14 → 0.1.0-alpha.17

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
@@ -26,4 +26,17 @@ Tileflow resolves from the map name, manifest, or explicit style props. Direct
26
26
  Tileflow props such as `center`, `zoom`, and `interactive` take priority when
27
27
  provided.
28
28
 
29
+ ## Headless capture readiness
30
+
31
+ Use `captureId` to disambiguate repeated named maps:
32
+
33
+ ```svelte
34
+ <TileflowMap map="madrid" captureId="checkout-map" />
35
+ ```
36
+
37
+ The root exposes `data-tileflow-map`, optional `data-tileflow-capture-id`, and
38
+ `data-tileflow-state="loading|idle|error"`. It becomes idle only after MapLibre idle plus two
39
+ animation frames (or image decode/load in image mode), returns to loading for new style/data work,
40
+ and marks terminal load errors. Application capture selects exactly one ready target.
41
+
29
42
  Docs: https://tileflow.dev/docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/svelte",
3
- "version": "0.1.0-alpha.14",
3
+ "version": "0.1.0-alpha.17",
4
4
  "description": "Svelte component for rendering Tileflow maps with MapLibre.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -8,11 +8,11 @@
8
8
  "homepage": "https://tileflow.dev",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/tileflow/tileflow.git",
11
+ "url": "git+https://github.com/tileflow/tileflow-sdk.git",
12
12
  "directory": "packages/svelte"
13
13
  },
14
14
  "bugs": {
15
- "url": "https://github.com/tileflow/tileflow/issues"
15
+ "url": "https://github.com/tileflow/tileflow-sdk/issues"
16
16
  },
17
17
  "keywords": [
18
18
  "tileflow",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@tileflow/core": "0.1.0-alpha.14"
45
+ "@tileflow/core": ">=0.1.0-alpha.17 <0.1.0-beta.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "maplibre-gl": ">=5",
@@ -55,6 +55,8 @@
55
55
  },
56
56
  "scripts": {
57
57
  "build": "svelte-check --tsconfig ./tsconfig.json",
58
+ "test": "node --test test/*.test.js",
59
+ "verify": "pnpm run build && pnpm run test",
58
60
  "typecheck": "svelte-check --tsconfig ./tsconfig.json",
59
61
  "lint": "svelte-check --tsconfig ./tsconfig.json"
60
62
  }
@@ -3,24 +3,21 @@
3
3
  type LngLatLike,
4
4
  type Map as MapLibreMap,
5
5
  type MapOptions as MapLibreMapOptions,
6
- type RequestParameters,
7
- type RequestTransformFunction,
8
6
  type StyleSpecification,
9
7
  } from 'maplibre-gl';
10
- import {createEventDispatcher, onMount} from 'svelte';
8
+ import {createEventDispatcher, onMount, tick} from 'svelte';
11
9
  import {
12
10
  createTileflowSessionId,
13
11
  defaultTileflowManifestUrl,
14
12
  loadTileflowManifest,
15
13
  mergeTileflowAnalytics,
14
+ normalizeTileflowCaptureId,
16
15
  normalizeTileflowStaticImageSize,
17
- resolveTileflowAnalyticsRequestUrl,
18
16
  resolveTileflowManifestMap,
19
17
  resolveTileflowMapMode,
20
18
  resolveTileflowRuntimeStyle,
21
19
  resolveTileflowStaticImageUrl,
22
20
  shouldLoadTileflowManifest,
23
- startTileflowSession,
24
21
  type MapLibreStyle,
25
22
  type TileflowAnalytics,
26
23
  type TileflowConfig,
@@ -28,6 +25,13 @@
28
25
  type TileflowProjectThemes,
29
26
  type TileflowRuntimeManifestMap,
30
27
  } from '@tileflow/core';
28
+ import {
29
+ attachTileflowMapLifecycle,
30
+ createTileflowMarkerController,
31
+ createTileflowSessionStarter,
32
+ createTileflowTransformRequest,
33
+ type TileflowMapLifecycleAttachment,
34
+ } from '@tileflow/core/browser';
31
35
 
32
36
  export let alt = '';
33
37
  export let analytics: TileflowAnalytics | undefined = undefined;
@@ -37,6 +41,7 @@
37
41
  const defaultZoom = 2;
38
42
 
39
43
  export let center: [number, number] | undefined = undefined;
44
+ export let captureId: string | undefined = undefined;
40
45
  export let className = '';
41
46
  export let config: TileflowConfig | undefined = undefined;
42
47
  export let height: number | string = 420;
@@ -57,21 +62,39 @@
57
62
  export let zoom: number | undefined = undefined;
58
63
 
59
64
  let container: HTMLDivElement;
65
+ let captureState: 'error' | 'idle' | 'loading' = 'loading';
60
66
  let imageSize: {height: number; width: number} | null = null;
61
67
  let imageResizeObserver: ResizeObserver | null = null;
62
68
  let mapInstance: MapLibreMap | null = null;
63
- let markerInstances: maplibregl.Marker[] = [];
69
+ let mapLifecycleAttachment: TileflowMapLifecycleAttachment | null = null;
64
70
  let mapResizeObserver: ResizeObserver | null = null;
65
71
  let manifestMap: TileflowRuntimeManifestMap | null = null;
66
72
  let manifestLoadId = 0;
67
73
  let mounted = false;
68
74
  let refreshRunId = 0;
75
+ let readinessRunId = 0;
69
76
 
70
77
  const dispatch = createEventDispatcher<{load: MapLibreMap}>();
71
78
  const sessionId = createTileflowSessionId();
72
- const sessionStarts = new Set<string>();
79
+ const sessionStarter = createTileflowSessionStarter({sessionId, source: 'svelte'});
80
+ const markerController = createTileflowMarkerController<
81
+ MapLibreMap,
82
+ TileflowMapMarker,
83
+ maplibregl.Marker
84
+ >({
85
+ attach(markerInstance, targetMap, definition) {
86
+ markerInstance.setLngLat(definition.coordinates).addTo(targetMap);
87
+ markerInstance.getElement().title = definition.label ?? definition.id;
88
+ },
89
+ create: (definition) =>
90
+ new maplibregl.Marker({
91
+ color: definition.color ?? '#C6A15B',
92
+ }),
93
+ remove: (markerInstance) => markerInstance.remove(),
94
+ });
73
95
 
74
96
  $: resolvedMode = resolveTileflowMapMode({imageUrl, mode, preferLocalDev});
97
+ $: resolvedCaptureId = normalizeTileflowCaptureId(captureId);
75
98
  $: isImageMode = resolvedMode === 'image';
76
99
  $: imageCenter = center ?? defaultCenter;
77
100
  $: imageZoom = zoom ?? defaultZoom;
@@ -134,6 +157,11 @@
134
157
  syncMarkers();
135
158
  }
136
159
 
160
+ $: if (mounted && isImageMode) {
161
+ runtimeImageUrl;
162
+ void markImageLoading();
163
+ }
164
+
137
165
  onMount(() => {
138
166
  mounted = true;
139
167
 
@@ -214,15 +242,28 @@
214
242
  }
215
243
 
216
244
  function destroyMap() {
245
+ readinessRunId += 1;
246
+ const lifecycleAttachment = mapLifecycleAttachment;
247
+ const currentMap = mapInstance;
248
+ mapLifecycleAttachment = null;
249
+ mapInstance = null;
217
250
  mapResizeObserver?.disconnect();
218
251
  mapResizeObserver = null;
219
- clearMarkers();
220
- mapInstance?.remove();
221
- mapInstance = null;
252
+
253
+ try {
254
+ lifecycleAttachment?.dispose();
255
+ } finally {
256
+ try {
257
+ markerController.dispose();
258
+ } finally {
259
+ currentMap?.remove();
260
+ }
261
+ }
222
262
  }
223
263
 
224
264
  function recreateMap() {
225
265
  destroyMap();
266
+ captureState = 'loading';
226
267
 
227
268
  if (!container || !runtimeStyle || isImageMode) {
228
269
  return;
@@ -236,11 +277,13 @@
236
277
  container,
237
278
  interactive: resolvedInteractive,
238
279
  style: runtime.style as StyleSpecification | string,
239
- transformRequest: createTransformRequest(
240
- () => resolvedAnalytics,
280
+ transformRequest: createTileflowTransformRequest({
281
+ always: true,
282
+ asyncAnalyticsTiming: 'request',
283
+ getAnalytics: () => resolvedAnalytics,
241
284
  sessionId,
242
- mapOptions?.transformRequest,
243
- ),
285
+ transformRequest: mapOptions?.transformRequest ?? undefined,
286
+ }),
244
287
  zoom: resolvedZoom,
245
288
  });
246
289
 
@@ -255,25 +298,29 @@
255
298
  });
256
299
  mapResizeObserver.observe(container);
257
300
 
258
- maplibreMap.on('load', () => {
259
- dispatch('load', maplibreMap);
260
- const analyticsForLoad = resolvedAnalytics;
261
- const styleId =
262
- analyticsForLoad?.styleId ?? (typeof runtime.style === 'string' ? runtime.style : map);
263
-
264
- if (analyticsForLoad?.mapId) {
265
- const sessionKey = `${sessionId}:${analyticsForLoad.mapId}:${styleId ?? ''}`;
266
-
267
- if (!sessionStarts.has(sessionKey)) {
268
- sessionStarts.add(sessionKey);
269
- startTileflowSession(analyticsForLoad, {
270
- mapId: analyticsForLoad.mapId,
271
- sessionId,
272
- source: 'svelte',
273
- styleId,
274
- });
275
- }
276
- }
301
+ mapLifecycleAttachment = attachTileflowMapLifecycle<MapLibreMap, number>({
302
+ getSession: () => {
303
+ const analyticsForLoad = resolvedAnalytics;
304
+ return {
305
+ analytics: analyticsForLoad,
306
+ styleId:
307
+ analyticsForLoad?.styleId ?? (typeof runtime.style === 'string' ? runtime.style : map),
308
+ };
309
+ },
310
+ map: maplibreMap,
311
+ onLoad: () => dispatch('load', maplibreMap),
312
+ scheduler: {
313
+ cancelFrame: (frame) => cancelAnimationFrame(frame),
314
+ requestFrame: (callback) => requestAnimationFrame(callback),
315
+ },
316
+ sessionStarter,
317
+ setState: (state) => {
318
+ captureState = state;
319
+ },
320
+ subscribe: (targetMap, event, listener) => {
321
+ const subscription = targetMap.on(event, listener);
322
+ return () => subscription.unsubscribe();
323
+ },
277
324
  });
278
325
 
279
326
  syncMarkers();
@@ -287,70 +334,48 @@
287
334
  }
288
335
 
289
336
  function syncMarkers() {
290
- clearMarkers();
291
-
292
337
  if (!mapInstance) {
338
+ markerController.clear();
293
339
  return;
294
340
  }
295
341
 
296
- markerInstances = markers.map((marker) => {
297
- const markerInstance = new maplibregl.Marker({
298
- color: marker.color ?? '#C6A15B',
299
- })
300
- .setLngLat(marker.coordinates)
301
- .addTo(mapInstance!);
302
-
303
- markerInstance.getElement().title = marker.label ?? marker.id;
304
- return markerInstance;
305
- });
342
+ markerController.replace(mapInstance, markers);
306
343
  }
307
344
 
308
- function clearMarkers() {
309
- for (const marker of markerInstances) {
310
- marker.remove();
311
- }
312
-
313
- markerInstances = [];
345
+ async function handleImageLoad(event: Event) {
346
+ await markImageReady(event.currentTarget as HTMLImageElement);
314
347
  }
315
348
 
316
- function createTransformRequest(
317
- getAnalytics: () => TileflowAnalytics | undefined,
318
- session: string,
319
- userTransformRequest?: TileflowMapOptions['transformRequest'],
320
- ): RequestTransformFunction | undefined {
321
- return (url, resourceType) => {
322
- const request = userTransformRequest?.(url, resourceType);
323
- const analyticsInput = getAnalytics();
324
-
325
- if (isPromiseLike(request)) {
326
- return request.then((resolvedRequest) =>
327
- applyTileflowAnalyticsRequest(url, resolvedRequest, analyticsInput, session) ??
328
- resolvedRequest,
329
- );
349
+ async function markImageReady(image: HTMLImageElement) {
350
+ const runId = ++readinessRunId;
351
+ try {
352
+ if (typeof image.decode === 'function') await image.decode();
353
+ } catch {
354
+ if (!image.complete || image.naturalWidth === 0) {
355
+ if (runId === readinessRunId) captureState = 'error';
356
+ return;
330
357
  }
331
-
332
- return applyTileflowAnalyticsRequest(url, request, analyticsInput, session);
333
- };
358
+ }
359
+ if (runId === readinessRunId) captureState = 'idle';
334
360
  }
335
361
 
336
- function applyTileflowAnalyticsRequest(
337
- url: string,
338
- request: RequestParameters | undefined,
339
- analyticsInput: TileflowAnalytics | undefined,
340
- session: string,
341
- ): RequestParameters | undefined {
342
- const requestUrl = request?.url ?? url;
343
- const nextUrl = resolveTileflowAnalyticsRequestUrl(requestUrl, analyticsInput, session);
344
-
345
- if (!nextUrl) {
346
- return request;
362
+ async function markImageLoading() {
363
+ const runId = ++readinessRunId;
364
+ captureState = 'loading';
365
+ await tick();
366
+ if (!mounted || runId !== readinessRunId) return;
367
+ const image = container?.querySelector('img');
368
+ if (!image?.complete) return;
369
+ if (image.naturalWidth === 0) {
370
+ captureState = 'error';
371
+ return;
347
372
  }
348
-
349
- return request ? {...request, url: nextUrl} : {url: nextUrl};
373
+ await markImageReady(image);
350
374
  }
351
375
 
352
- function isPromiseLike<T>(value: T | Promise<T> | undefined): value is Promise<T> {
353
- return Boolean(value && typeof (value as Promise<T>).then === 'function');
376
+ function handleImageError() {
377
+ readinessRunId += 1;
378
+ captureState = 'error';
354
379
  }
355
380
 
356
381
  function formatHeight(value: number | string): string {
@@ -358,12 +383,21 @@
358
383
  }
359
384
  </script>
360
385
 
361
- <div bind:this={container} class={className} style={frameStyle}>
386
+ <div
387
+ bind:this={container}
388
+ class={className}
389
+ data-tileflow-capture-id={resolvedCaptureId}
390
+ data-tileflow-map={map}
391
+ data-tileflow-state={captureState}
392
+ style={frameStyle}
393
+ >
362
394
  {#if isImageMode && runtimeImageUrl}
363
395
  <img
364
396
  {alt}
365
397
  decoding="async"
366
398
  loading={imageLoading}
399
+ on:error={handleImageError}
400
+ on:load={handleImageLoad}
367
401
  src={runtimeImageUrl}
368
402
  style="display: block; height: 100%; object-fit: cover; width: 100%;"
369
403
  />
@@ -1,5 +1,5 @@
1
- import type {SvelteComponentTyped} from 'svelte';
2
1
  import type {Map as MapLibreMap} from 'maplibre-gl';
2
+ import type {SvelteComponentTyped} from 'svelte';
3
3
  import type {TileflowMapProps} from './index';
4
4
 
5
5
  export default class TileflowMap extends SvelteComponentTyped<
package/src/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export type TileflowMapProps = {
15
15
  alt?: string;
16
16
  analytics?: TileflowAnalytics;
17
17
  center?: [number, number];
18
+ captureId?: string;
18
19
  className?: string;
19
20
  config?: TileflowConfig;
20
21
  height?: number | string;