@tileflow/core 0.1.0-alpha.31 → 0.1.0-alpha.32

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.
@@ -25,7 +25,7 @@ export default defineMap({
25
25
  });
26
26
  ```
27
27
 
28
- Use `tileflow preview --scene madrid-desktop` (`tileflow dev` remains an alias) for live review and
28
+ Use `tileflow preview --scene madrid-desktop` for live review and
29
29
  `tileflow capture madrid-desktop` for exact pixels and a schema-version-4 receipt containing the
30
30
  concrete theme plus Streets, data, style, renderer, and image identities. World capture resolves the selected TileJSON
31
31
  once and records the exact `world-v1` release plus descriptor/archive/data-contract hashes;
@@ -237,7 +237,10 @@ canonical `poi` layer exposes `category`, `type`, `icon`, `min_zoom`, `filter_ra
237
237
  Core validates that complete contract and never reconstructs those decisions from OpenMapTiles
238
238
  `class`, `subclass`, or `rank`. `filter_rank` is an
239
239
  integer from 0 through 5, with lower values reserved for the strongest candidates. The numeric
240
- `density` option is an inclusive threshold from 1 through 5 and defaults to 3. `size_rank` is an
240
+ `density` option is an inclusive threshold from 1 through 5 and defaults to 3. World publishes
241
+ every eligible POI at z15, its last POI zoom, and higher zooms overzoom those tiles with more room,
242
+ so the threshold rises by one rank at z16 and by two from z17. At the default density every rank is
243
+ eligible from z17, and collision placement keeps what fits. `size_rank` is an
241
244
  integer from 0 through 16 used to order eligible candidates before MapLibre performs final
242
245
  collision placement.
243
246
 
@@ -128,9 +128,7 @@ published, immutable glyph collection independent of World data. The older `/fon
128
128
  remains a mutable compatibility URL and does not make a resolved map byte-reproducible. The global
129
129
  glyph `assetSetSha256` is not the same-domain value as the per-map `assetSetSha256` in
130
130
  `build-manifest.json`.
131
- Cyberpunk and Matrix each replace the URL provider with their own packaged Oxanium directory and
132
- reference the exact local faces `Oxanium Medium` and `Oxanium SemiBold`. Baedeker and Siegfried
133
- each own a packaged directory containing Cormorant Garamond Regular, SemiBold, and Italic.
131
+ Baedeker and Siegfried each own a packaged directory containing Cormorant Garamond Regular, SemiBold, and Italic.
134
132
 
135
133
  Name the official generation deliberately, or use another OpenMapTiles-compatible vector source:
136
134
 
@@ -320,21 +318,117 @@ explicit because the data contract cannot assume a sprite name. They require pos
320
318
  `markings` evidence and reject `crossing=no`; both fields are semantic bindings, so custom schemas
321
319
  can remap them without weakening that rule.
322
320
 
321
+ Datasets may also publish the optional, decorative `canopy` layer: points inside urban parks and
322
+ gardens, at most one per cell of a grid of about 20 m that measured tree canopy covers. Each point
323
+ tells how much room a drawn grove of tree crowns has and how dense the canopy around it is. The
324
+ points are not surveyed trees, are unrelated to the optional `tree` layer, and are not search or
325
+ interaction targets. `openMapTiles()` and `tileflowWorldV1Schema()` bind the layer as `canopy` and
326
+ its numeric fields as follows:
327
+
328
+ - `canopyRank` (`rank`): a stable integer from 0 through 15. Lower ranks appear at lower zooms, and
329
+ the rank can vary artwork.
330
+ - `canopyClearanceAtZoom15` (`clearance_px_z15`): 4, 6, 8, 12, 16, or 24. This is the lower bound
331
+ of the distance from the point to the nearest edge a crown must not cover, such as a park edge,
332
+ water, a building, a plaza, a pitch, or a roadway, in pixels of a 512-pixel tile at zoom 15. A
333
+ grove fits at zoom `z` when its drawn reach in screen pixels is at most
334
+ `clearance_px_z15 × 2^(z − 15)`.
335
+ - `canopyCoverPercent` (`cover_pct`): 3, 30, or 60, the lower bound of the share, in percent, of
336
+ canopy taller than 3 m within 10 m of the point. The field is absent where no measurement exists;
337
+ treat absence as a middle density.
338
+
339
+ Features are MultiPoints, one per identical set of attributes. Native zoom 15 holds every point,
340
+ zoom 14 holds the points whose rank is below 8 at the same positions, and clients overzoom zoom 15.
341
+ `validateTileflowWorldV1Tilejson(...)` accepts a TileJSON without `canopy`; once declared, the layer
342
+ must occur exactly once, declare native z14–z15, and advertise Number `rank`, `clearance_px_z15`, and
343
+ `cover_pct`. No module draws the layer. A source without it remains valid, and render passes bound
344
+ to it draw nothing. `capabilities: {canopy: false}` removes the binding, after which a pass with
345
+ `feature: 'canopy'` fails with a missing-binding error.
346
+
347
+ Draw groves with symbol render passes, one grove of whole crowns per point, sized to its room:
348
+
349
+ <!-- docs:check -->
350
+
351
+ ```ts
352
+ import {expr, field, fixed, renderPass, token, vegetation, withRenderStack} from '@tileflow/core';
353
+
354
+ // The grove artwork reaches 32 pixels from its center at icon size 1.
355
+ const clearance = expr.get(field('canopyClearanceAtZoom15'));
356
+ // The size that fills the room at one zoom, never larger than the artwork itself.
357
+ const fit = (divisor: number) => expr.min(expr.divide(clearance, divisor), 1);
358
+
359
+ const groves = (minZoom: number, fromRank: number, toRank: number) =>
360
+ renderPass({
361
+ placement: 'above-vegetation',
362
+ feature: 'canopy',
363
+ renderer: 'symbol',
364
+ selector: {
365
+ kind: 'all',
366
+ selectors: [
367
+ {field: 'canopyRank', kind: 'compare', operator: 'gte', value: fromRank},
368
+ {field: 'canopyRank', kind: 'compare', operator: 'lt', value: toRank},
369
+ ],
370
+ },
371
+ style: {
372
+ minZoom,
373
+ icon: {
374
+ ignorePlacement: true,
375
+ image: token.image('vegetation.canopy.grove'),
376
+ overlap: 'always',
377
+ size: expr.interpolate({base: 2, kind: 'exponential'}, expr.zoom(), [
378
+ [14, fit(64)],
379
+ [15, fit(32)],
380
+ [16, fit(16)],
381
+ [17, fit(8)],
382
+ [18, fit(4)],
383
+ ]),
384
+ },
385
+ text: {
386
+ field: 'o',
387
+ font: token.font('default'),
388
+ opacity: fixed(0, {reason: 'The core text only decides placement'}),
389
+ overlap: 'cooperative',
390
+ size: fixed(8, {reason: 'Matches the solid core of the grove artwork'}),
391
+ },
392
+ },
393
+ });
394
+
395
+ export const canopyGroves = withRenderStack(vegetation(), {
396
+ groves14: groves(14, 0, 4),
397
+ groves15: groves(15, 4, 8),
398
+ groves16: groves(16, 8, 16),
399
+ });
400
+ ```
401
+
402
+ - Give each rank range its own pass with `minZoom`, so its points appear whole at their entry zoom.
403
+ MapLibre evaluates expressions that combine zoom and feature data at whole zoom levels and
404
+ interpolates between them, so a fade per feature would leave symbols half transparent. Zoom 14
405
+ tiles carry the ranks below 8, so the first pass may cover ranks 0–7.
406
+ - Size each grove from `canopyClearanceAtZoom15`. Here the size is `clearance / 32` at zoom 15 and
407
+ doubles with each zoom until the grove reaches the artwork's size, which keeps the reach within
408
+ the room.
409
+ - Collision tests an icon's whole image, including the transparent margin around the crowns. A small
410
+ invisible text the size of the grove's core therefore decides placement with
411
+ `overlap: 'cooperative'`, while the icon draws with `overlap: 'always'` and `ignorePlacement: true`.
412
+ Groves may overlap each other but give way to labels, which draw above the vegetation band and are
413
+ therefore placed first, with the default `never`.
414
+
415
+ Every theme of the map must define the image token, and the map's icon directories must provide the
416
+ artwork. Native artifacts replace `cooperative` with `allowOverlap: true`, so there groves can cover
417
+ labels; see [symbol overlap modes](shared-visual-primitives.md#symbol-overlap-modes).
418
+
323
419
  The optional `business_corridor` extension contains activity-selected source footprints below
324
420
  buildings. Its `activity_score`, `rank`, `min_zoom`, and `confidence` fields control a quiet local
325
421
  warm tint without drawing POI-radius circles. Current building footprints may carry sparse
326
422
  `building_tone=commercial|destination|active`; absence means the neutral base building. The three
327
423
  values preserve why the warm tone was selected even when a theme maps them to the same color.
328
- Building visibility is fixed by the layer zoom contract and never by optional semantics. Immutable
329
- V8.9 archives carrying `building_kind` or `has_business` remain readable as a defensive
330
- compatibility path, but new candidates do not emit those fields. The land module also exposes
424
+ Building visibility is fixed by the layer zoom contract and never by optional semantics. The land
425
+ module also exposes
331
426
  `medical`, `education`, and `government` independently instead of collapsing their already distinct
332
427
  source classes into one civic color.
333
428
 
334
429
  World and text assets are independent contracts. `tileflowWorld()` selects `world-v1/current` or an
335
- exact `releaseId + descriptorSha256`; a `glyphs` declaration contains its own complete URL. Ordinary
336
- imports of Streets, Baedeker, Cívica, Ferraris, Härad, Siegfried, Soundings, Cyberpunk, Matrix, Blueprint, and Super Tile World
337
- remain usable because each official map owns or inherits a URL or packaged-font provider.
430
+ exact `releaseId + descriptorSha256`; a `glyphs` declaration contains its own complete URL. Every
431
+ official map compiles as imported because it owns or inherits a URL or packaged-font provider.
338
432
  URL-backed maps become exact-byte reproducible
339
433
  when the immutable global base-asset set is published and their explicit URL is updated to its
340
434
  `assetSetSha256` path; no World or compiler fallback participates in that rollout. The global
@@ -0,0 +1,100 @@
1
+ # Dynamic style images
2
+
3
+ Use `registerImage` on an existing `TileflowThemeController` from `@tileflow/core/browser` when an
4
+ application draws RGBA pixels that depend on a theme or style state. The controller owns the image's
5
+ registration, refresh and restoration. The application owns its drawing and prepares asynchronous
6
+ assets before registration. This browser API is tested with MapLibre GL JS 6.13; it is not a Native
7
+ or Static Maps image contract.
8
+
9
+ Start with the [Core guide](https://github.com/tileflow/tileflow-sdk/blob/main/packages/core/README.md)
10
+ and [theme controller setup](theme-transitions-and-blends.md#use-the-core-controller).
11
+ The map's symbol layer must already refer to the same image ID in every theme. Register before its
12
+ first placement. Choose an ID that does not exist in the sprite or belong to another application
13
+ resource. Registering an image does not create a layer or add points to a source.
14
+
15
+ ## Draw an alert from state
16
+
17
+ This helper runs after browser mounting, with the controller for the existing map. The image is
18
+ 64 physical pixels at pixel ratio 2, so it occupies 32 logical pixels before the layer's icon scale.
19
+
20
+ <!-- docs:check -->
21
+
22
+ ```ts
23
+ import type {TileflowThemeController} from '@tileflow/core/browser';
24
+
25
+ export function registerAlert(controller: TileflowThemeController) {
26
+ const canvas = document.createElement('canvas');
27
+ canvas.width = canvas.height = 64;
28
+ const context = canvas.getContext('2d', {willReadFrequently: true});
29
+ if (!context) throw new Error('Canvas 2D is unavailable.');
30
+
31
+ return controller.registerImage('live-alert', {
32
+ width: 64,
33
+ height: 64,
34
+ pixelRatio: 2,
35
+ states: ['alert'],
36
+ render({theme, states}) {
37
+ context.clearRect(0, 0, 64, 64);
38
+ context.beginPath();
39
+ context.arc(32, 32, 24, 0, Math.PI * 2);
40
+ context.fillStyle = states.alert ? '#ee624e' : '#edbd53';
41
+ context.fill();
42
+ context.strokeStyle = theme === 'night' ? '#ffffff' : '#253b42';
43
+ context.lineWidth = 4;
44
+ context.stroke();
45
+ return context.getImageData(0, 0, 64, 64).data;
46
+ },
47
+ onError(error) {
48
+ console.error('Alert image could not refresh:', error);
49
+ },
50
+ });
51
+ }
52
+
53
+ export async function activateAlert(controller: TileflowThemeController) {
54
+ await controller.setState('alert', true);
55
+ }
56
+ ```
57
+
58
+ The resource receives the states listed in `states`, including intermediate values during numeric
59
+ state transitions. `theme` is the committed theme, or the nearest theme of a blend. `blend` exposes
60
+ its position and ordered theme names for continuous artwork interpolation. States not set through
61
+ this controller remain undefined; an explicit null is distinct from an unset state.
62
+
63
+ ## Update and release
64
+
65
+ Theme changes and changes to declared states schedule a refresh. Identical inputs do no work;
66
+ several changes before the next animation frame coalesce. The `setState` promise describes the
67
+ state transition, not completion of the next image frame. There is no independent animation clock.
68
+ For camera values, new application data or another external input, call the returned handle's
69
+ `refresh()`. Read those values inside `render`, and return `undefined` when the existing pixels are
70
+ still correct. Undefined output on the first render creates a transparent image.
71
+
72
+ `render` is synchronous and returns exactly `width * height * 4` RGBA bytes as a `Uint8Array` or
73
+ `Uint8ClampedArray`. The controller copies the buffer. Width and height must be integers from 1
74
+ through 2048; pixel ratio must be positive and finite. All three remain fixed until disposal.
75
+ Changing pixel ratio while keeping physical size would change logical size and collision geometry,
76
+ so choose it at registration rather than following device pixel ratio changes automatically.
77
+
78
+ A style reload restores the last valid image before placement. WebGL context loss suspends image
79
+ work; restoration replays pixels and processes pending inputs. `handle.dispose()` removes only
80
+ that resource's image and cancels its pending work. `controller.dispose()` releases all of its
81
+ images. Map removal cancels work and releases listeners. Disposal is idempotent.
82
+
83
+ Invalid registration throws before claiming a resource. Later render failures keep the last valid
84
+ pixels and call `onError`, or `console.error` when omitted. A later refresh can retry. Another image
85
+ owner is never silently overwritten. The application must not call `addImage`, `updateImage` or
86
+ `removeImage` for a registered ID. Dynamic IDs must stay identical across blended themes; mixing
87
+ them with theme-specific atlas artwork is rejected before the blend replaces the style.
88
+
89
+ ## Placement and scope
90
+
91
+ This API changes image pixels only. It does not resize icons, split layers, change padding,
92
+ anchors, sort order, overlap, filters or source features. Native symbol placement and collisions
93
+ remain governed by the authored style. Transparent pixels still occupy the image's existing
94
+ geometry: this API does not define separate visual and collision bounds.
95
+
96
+ All features using an ID share its pixels and state. Independent per-feature animation, SDF
97
+ generation, image loading, GPU composition and automatic framework props are outside this API.
98
+ Keep every selectable icon in the same symbol layer RGBA, including fallbacks; mixing these images
99
+ with SDF icons does not become supported through registration.
100
+ Profile the application's drawing cost and visible symbol rendering on its target devices.
@@ -72,8 +72,10 @@ Dev's `native-lowering-v1` preparation policy explicitly converts a fixed `{type
72
72
  projection to Native's implicit Mercator before this validator runs. This is a recorded projection
73
73
  change, not globe support or visual equivalence. One authored map serves web and native preparation;
74
74
  no native-specific Streets definition is used. Preparation adapts only finite `line-cap` and
75
- `line-dasharray` decisions into constant-property layers. Other unsupported properties have no implicit fallback. The raw validator continues to reject
76
- unlowered unsupported expressions. See the [preparation contract](https://github.com/tileflow/tileflow-sdk/blob/main/packages/dev/docs/native-artifacts.md)
75
+ `line-dasharray` decisions into constant-property layers, and replaces constant `icon-overlap` and
76
+ `text-overlap` modes with their `*-allow-overlap` equivalent. Other unsupported properties have no
77
+ implicit fallback. The raw validator continues to reject unlowered unsupported expressions and
78
+ overlap modes. See the [preparation contract](https://github.com/tileflow/tileflow-sdk/blob/main/packages/dev/docs/native-artifacts.md)
77
79
  for the grammar, expansion limits and pending native visual checks. The lowering checks establish
78
80
  per-feature selection and cap/dash values, not global feature draw order. A copied `line-sort-key`
79
81
  cannot preserve ordering between simultaneous physical branches; overlapping features may produce
@@ -81,7 +83,7 @@ a different composition even when every property value is retained.
81
83
 
82
84
  `native-build.json` uses strict schema version 2 and `preparationVersion: 'native-lowering-v1'`.
83
85
  Its `transformations` array is ordered by map/theme and binds the input/lowered style hashes,
84
- projection policy and physical layer spans. The runtime manifest remains version 1. An old v1
86
+ projection policy, physical layer spans and symbol overlap replacements. The runtime manifest remains version 1. An old v1
85
87
  build-record decoder must reject v2 rather than silently ignore the preparation evidence.
86
88
 
87
89
  Text must use a valid glyph template containing `{fontstack}` and `{range}`, or a complete static
@@ -22,6 +22,26 @@ Compounds express common cartographic structures: `AreaStyle` has `fill` and `ou
22
22
  optional `text`, `icon`, and `marker`. Geographic selection remains owned by semantic modules;
23
23
  ordinary visual styles intentionally do not accept raw source filters.
24
24
 
25
+ ## Symbol overlap modes
26
+
27
+ `text.allowOverlap` and `icon.allowOverlap` either hide a part that collides with a symbol placed
28
+ before it or always show it. `overlap` selects one of three modes instead:
29
+
30
+ - `never`: hide the part when it collides.
31
+ - `always`: show the part even when it collides.
32
+ - `cooperative`: show the part unless it collides with a symbol placed with `never`. It may overlap
33
+ symbols placed with `always` or `cooperative`.
34
+
35
+ MapLibre places symbols from the top layer down, so a symbol placed before another is usually higher
36
+ in the stack. `overlap` takes precedence over `allowOverlap` on the same part, including an
37
+ `allowOverlap` default supplied by a module or already present on a refined target, so the compiled
38
+ layer carries only `text-overlap` or `icon-overlap`. MapLibre Native has no overlap modes, so native
39
+ artifact preparation replaces the mode with the matching `text-allow-overlap` or
40
+ `icon-allow-overlap`: `false` for `never` and `true` for `always` and `cooperative`. There a
41
+ cooperative part no longer gives way to symbols placed with `never`. The
42
+ [canopy grove recipe](data-is-separate-from-design.md) uses a cooperative text to decide where
43
+ icons draw.
44
+
25
45
  ## Line-fitted text backgrounds
26
46
 
27
47
  `text.background` draws one opaque strip behind each shaped line of a label, fitted to that line's
@@ -0,0 +1,73 @@
1
+ # Style states
2
+
3
+ A theme can read a value that the application sets once for the whole style, such as whether
4
+ anything is selected. The theme reads it with `expr.globalState(name)`; the application sets it with
5
+ the theme controller's `setState(name, value)`. MapLibre calls these values global state (GL JS 5.6
6
+ or later).
7
+
8
+ ## Read a state in a theme
9
+
10
+ A state reads `null` until the application sets it, so wrap it in a typed assertion with a fallback.
11
+ To let a value move smoothly, make the state a number and interpolate on it:
12
+
13
+ <!-- docs:check -->
14
+
15
+ ```ts
16
+ import {expr} from '@tileflow/core';
17
+
18
+ // 0 shows the standing highlight, 1 the quiet plate; values between mix the two.
19
+ const selection = expr.toNumber(expr.globalState('selection'), 0);
20
+ export const captionPlate = expr.interpolate({kind: 'linear'}, selection, [
21
+ [0, '#ef5f57'],
22
+ [1, '#fff9ee'],
23
+ ]);
24
+ ```
25
+
26
+ A boolean works too, through `expr.case`, but it can only switch.
27
+
28
+ ## Set a state from the application
29
+
30
+ This example assumes an existing MapLibre map named `map` that shows the `light` theme of a
31
+ published Tileflow map:
32
+
33
+ <!-- docs:check -->
34
+
35
+ ```ts
36
+ import {createTileflowThemeController, type TileflowStyleSwitchMap} from '@tileflow/core/browser';
37
+
38
+ // A MapLibre map satisfies TileflowStyleSwitchMap.
39
+ export function selectionStates(map: TileflowStyleSwitchMap, styleUrl: string) {
40
+ const controller = createTileflowThemeController({
41
+ initial: {style: styleUrl, theme: 'light'},
42
+ map,
43
+ });
44
+ void controller.setState('selection', 0);
45
+ return {
46
+ select: () => controller.setState('selection', 1, {transition: {duration: 300}}),
47
+ clear: () => controller.setState('selection', 0, {transition: {duration: 300}}),
48
+ };
49
+ }
50
+ ```
51
+
52
+ A number eases from the value the map shows to the new one over the transition, so the plate mixes
53
+ smoothly from one colour to the other. Booleans, strings, `null`, and a number set for the first
54
+ time change at once, so set a starting value before easing it. A browser preference for
55
+ reduced motion changes at once. Each call supersedes an easing of the same state still in progress,
56
+ which resolves with `status: 'superseded'`; the new easing starts from where the old one stood.
57
+
58
+ States stay through `setTheme` and every `setBlend`: the controller writes them again after each
59
+ new style. `getState(name)` returns the value set last.
60
+
61
+ ## Keep eased values free of feature data
62
+
63
+ Keep a value that eases on a state free of feature data and feature state. MapLibre evaluates such
64
+ a value for the whole layer at each frame. A value that also reads a feature's data or state is
65
+ evaluated per feature when its tiles are laid out, so each change of the state lays them out
66
+ again: the value follows late, in steps, and each frame costs far more. Put the per-feature choice
67
+ in another layer, or let the application draw the feature it marks.
68
+
69
+ ## Cost
70
+
71
+ Each frame of an easing sets the state once, and MapLibre then evaluates again every paint value
72
+ that reads it. A state read by a few layers costs little; a state read by most layers of a map can
73
+ cost more per frame than the blend itself on slower devices.
@@ -83,7 +83,8 @@ While a blend is shown, `data-tileflow-theme` names the theme nearest to the pos
83
83
  - Colours and numbers of the ground, such as land, water, roads, buildings, and their widths and
84
84
  opacities, follow the position continuously. Colours mix in the OKLab colour space, so halfway
85
85
  between two themes looks halfway to the eye.
86
- - Patterns, the sky, and light follow the position too. Pattern artwork is mixed in its pixels.
86
+ - Patterns, the sky, and light follow the position too. Pattern artwork is mixed in its pixels;
87
+ artwork two neighbouring themes share, by name or pixel for pixel, is not uploaded again.
87
88
  - Labels, icons, and other symbols change when the nearest theme changes, with a short cross-fade.
88
89
  Light lettering on a dark plate and dark lettering on a light plate would meet in the same grey
89
90
  halfway, so symbols do not follow the position.
@@ -195,7 +196,8 @@ export async function moveThroughTheDay(map: MapLibreMap, manifestMap: TileflowR
195
196
  await controller.setBlend({position: 0.5, themes});
196
197
  // The same themes: the position applies at once.
197
198
  await controller.setBlend({position: 0.6, themes});
198
- return controller.getBlend(); // {position: 0.6, themes: ['night', 'dusk', 'day']}
199
+ // {held: false, position: 0.6, relayoutAt: [...], themes: ['night', 'dusk', 'day']}
200
+ return controller.getBlend();
199
201
  }
200
202
  ```
201
203
 
@@ -209,6 +211,8 @@ when styles need another loader.
209
211
 
210
212
  The controller also shows [map emphasis](https://github.com/tileflow/tileflow-sdk/blob/main/packages/core/docs/map-emphasis.md),
211
213
  which stays through theme changes and follows every blend position.
214
+ Its [dynamic style images](dynamic-style-images.md) receive theme, blend and declared state inputs
215
+ under one resource owner, with fixed geometry and restoration after a style reload.
212
216
 
213
217
  ## Fast sweeps on slower devices
214
218
 
@@ -219,3 +223,38 @@ changes most ground layers on every frame. Measured in Chromium on Apple silicon
219
223
  a detailed city map, such a sweep kept 76 frames a second; with the processor slowed four times, as
220
224
  on a mid-range phone, it fell to about 11, against 28 for the same camera move without the sweep.
221
225
  On slower devices, prefer short sweeps or move the position less often.
226
+
227
+ ## Camera moves and recordings
228
+
229
+ Some changes make MapLibre lay tiles out again: when the nearest theme changes, the values that
230
+ switch with it (icon names, layout values, and values that read feature data or feature state),
231
+ and each step of pattern or icon artwork that is mixed in pixels. A camera move or a screen
232
+ recording shows that work as dropped frames. `getBlend().relayoutAt` lists the positions where the
233
+ nearest theme changes over such values, so a sequence can be planned around them.
234
+
235
+ Pass `hold: true` to hold that work back. Colours, numbers, the sky, and light keep following the
236
+ position; the nearest theme, the values that switch with it, and mixed artwork wait.
237
+ `getBlend().held` reports whether anything waits. The next request without `hold` brings it in at
238
+ once, so make it while the camera rests:
239
+
240
+ ```ts
241
+ import type {TileflowThemeController} from '@tileflow/core/browser';
242
+ import type {TileflowRuntimeStyle} from '@tileflow/core/runtime';
243
+
244
+ export async function duskWhileFlying(
245
+ controller: TileflowThemeController,
246
+ themes: readonly TileflowRuntimeStyle[],
247
+ flight: Promise<void>,
248
+ ) {
249
+ // Through the camera move the ground darkens towards night with no layout work.
250
+ for (const position of [0.25, 0.5, 0.75])
251
+ await controller.setBlend({hold: true, position, themes});
252
+ await flight;
253
+ // The camera rests: labels, icons, and artwork follow at once.
254
+ await controller.setBlend({position: 0.75, themes});
255
+ }
256
+ ```
257
+
258
+ The React, Vue, and Svelte maps take the same flag in `themeBlend`: pass
259
+ `themeBlend={{hold: true, position, themes}}` while the camera moves and drop `hold` when it rests.
260
+ The React Native `Map` blends natively and does not take `hold`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/core",
3
- "version": "0.1.0-alpha.31",
3
+ "version": "0.1.0-alpha.32",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Typed Tileflow config and MapLibre style compiler.",
6
6
  "type": "module",