@massif-maps/style-tools 6.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +58 -0
  2. package/dist/cli.js +436 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/generated/properties.json +2488 -0
  5. package/dist/legend-svg.js +142 -0
  6. package/dist/legend-svg.js.map +1 -0
  7. package/dist/mapbox2css/browser-host.js +27 -0
  8. package/dist/mapbox2css/browser-host.js.map +1 -0
  9. package/dist/mapbox2css/casing.js +84 -0
  10. package/dist/mapbox2css/casing.js.map +1 -0
  11. package/dist/mapbox2css/config.js +212 -0
  12. package/dist/mapbox2css/config.js.map +1 -0
  13. package/dist/mapbox2css/contour.js +61 -0
  14. package/dist/mapbox2css/contour.js.map +1 -0
  15. package/dist/mapbox2css/coverage.js +56 -0
  16. package/dist/mapbox2css/coverage.js.map +1 -0
  17. package/dist/mapbox2css/emissive.js +192 -0
  18. package/dist/mapbox2css/emissive.js.map +1 -0
  19. package/dist/mapbox2css/expression.js +606 -0
  20. package/dist/mapbox2css/expression.js.map +1 -0
  21. package/dist/mapbox2css/filter.js +285 -0
  22. package/dist/mapbox2css/filter.js.map +1 -0
  23. package/dist/mapbox2css/fold.js +588 -0
  24. package/dist/mapbox2css/fold.js.map +1 -0
  25. package/dist/mapbox2css/host.js +13 -0
  26. package/dist/mapbox2css/host.js.map +1 -0
  27. package/dist/mapbox2css/index.js +3599 -0
  28. package/dist/mapbox2css/index.js.map +1 -0
  29. package/dist/mapbox2css/narrow.js +284 -0
  30. package/dist/mapbox2css/narrow.js.map +1 -0
  31. package/dist/mapbox2css/node-host.js +36 -0
  32. package/dist/mapbox2css/node-host.js.map +1 -0
  33. package/dist/mapbox2css/placement.js +62 -0
  34. package/dist/mapbox2css/placement.js.map +1 -0
  35. package/dist/mapbox2css/png.js +264 -0
  36. package/dist/mapbox2css/png.js.map +1 -0
  37. package/dist/mapbox2css/properties.js +155 -0
  38. package/dist/mapbox2css/properties.js.map +1 -0
  39. package/dist/mapbox2css/schema.js +865 -0
  40. package/dist/mapbox2css/schema.js.map +1 -0
  41. package/dist/mapbox2css/shield.js +118 -0
  42. package/dist/mapbox2css/shield.js.map +1 -0
  43. package/dist/mapbox2css/split.js +380 -0
  44. package/dist/mapbox2css/split.js.map +1 -0
  45. package/dist/mapbox2css/sprite.js +882 -0
  46. package/dist/mapbox2css/sprite.js.map +1 -0
  47. package/dist/mapbox2css/types.js +2 -0
  48. package/dist/mapbox2css/types.js.map +1 -0
  49. package/dist/mapbox2css/variables.js +273 -0
  50. package/dist/mapbox2css/variables.js.map +1 -0
  51. package/dist/wasm.js +43 -0
  52. package/dist/wasm.js.map +1 -0
  53. package/package.json +48 -0
  54. package/wasm/massif-style.mjs +2 -0
  55. package/wasm/massif-style.wasm +0 -0
@@ -0,0 +1,3599 @@
1
+ import { isContourLayer, rewriteContourFields, rewriteContourFilter } from './contour.js';
2
+ import { foldCasings } from './casing.js';
3
+ import { Coverage } from './coverage.js';
4
+ import { Untranslatable, expandTokens, setTileDrawSize, translateExpression, zoomInput, zoomOffsetLevels } from './expression.js';
5
+ import { translateFilter, zoomPredicates } from './filter.js';
6
+ import { HANDLED_ELSEWHERE, followsLine, repeatsAlongLine, resolvePlacement } from './placement.js';
7
+ import { KNOWN_GAPS, LAYER_SYMBOLIZER, PROPERTY_MAP, VALUE_MAP } from './properties.js';
8
+ import { PLATE_MAP, asShieldDeclaration, isShieldLayer, plateRadius } from './shield.js';
9
+ import { describeFlatPlate, extractAllIconPlates, extractAllIcons, extractIcon, extractIconPlate } from './sprite.js';
10
+ import { ICON_ALIASES, detectSourceSchema, mapSourceLayer, retargetLayer } from './schema.js';
11
+ import { narrowLayer } from './narrow.js';
12
+ import { collapseBranches, expandSetFilter, expandSortKey, splitLayer } from './split.js';
13
+ import { hoistVariables, paletteHeader } from './variables.js';
14
+ import { LIGHT_PRESET, importOnly, liveConfig, presetsOf, resolveConfig, sceneBrightness } from './config.js';
15
+ import { ICON_PARAMS, ICON_PARAM_SCOPE, RECOLOURABLE_ICON, foldConfig, foldLayer, isPitch, toHsla } from './fold.js';
16
+ import { applyLighting, emissiveDefault, emissiveForLayerType, emissiveProperty, groundRadiance, lightingFactor } from './emissive.js';
17
+ /** The palette is a stylesheet of its own, and has to be listed before the rules that read it. */
18
+ export const VARIABLES_FILE = 'variables.mss';
19
+ /** Two passes share a stylesheet only if they emitted the same rules with the same properties. */
20
+ function aligns(a, b) {
21
+ if (a.blocks.length !== b.blocks.length || a.mapBlock.length !== b.mapBlock.length)
22
+ return false;
23
+ const property = (declaration) => declaration.slice(0, declaration.indexOf(':'));
24
+ const same = (x, y) => x.length === y.length && x.every((d, i) => property(d) === property(y[i]));
25
+ if (!same(a.mapBlock, b.mapBlock))
26
+ return false;
27
+ return a.blocks.every((block, i) => block.selector === b.blocks[i].selector && same(block.declarations, b.blocks[i].declarations));
28
+ }
29
+ /** MapBox text-anchor -> the CartoCSS (horizontal, vertical) alignment pair. */
30
+ const TEXT_ANCHOR = {
31
+ center: ['middle', 'middle'],
32
+ left: ['left', 'middle'],
33
+ right: ['right', 'middle'],
34
+ top: ['middle', 'top'],
35
+ bottom: ['middle', 'bottom'],
36
+ 'top-left': ['left', 'top'],
37
+ 'top-right': ['right', 'top'],
38
+ 'bottom-left': ['left', 'bottom'],
39
+ 'bottom-right': ['right', 'bottom'],
40
+ };
41
+ /**
42
+ * The `lights` block resolved to the five numbers the ground radiance is a function of. An
43
+ * intensity may be a zoom ramp, so it takes its representative constant - the top of the ramp,
44
+ * which is what every zoom a building is drawn at gets.
45
+ */
46
+ export function sceneLights(lights, fold) {
47
+ const folded = fold(lights);
48
+ if (!Array.isArray(folded))
49
+ return null;
50
+ const of = (type) => {
51
+ const light = folded.find((l) => !!l && typeof l === 'object'
52
+ && l.type === type);
53
+ return (light?.properties ?? undefined);
54
+ };
55
+ const ambient = of('ambient');
56
+ if (!ambient)
57
+ return null;
58
+ // The DIRECTIONAL one is optional: a style may state an ambient alone, and MapBox's radiance
59
+ // simply has no directional term then. Requiring both left such a style unlit at every preset.
60
+ const directional = of('directional') ?? {};
61
+ const number = (value, fallback) => {
62
+ const reduced = representativeConstant(value);
63
+ return typeof reduced === 'number' ? reduced : fallback;
64
+ };
65
+ const direction = (() => {
66
+ const value = directional.direction;
67
+ const list = Array.isArray(value) && value[0] === 'literal' ? value[1] : value;
68
+ return Array.isArray(list) && list.length === 2 && list.every((v) => typeof v === 'number')
69
+ ? list : null;
70
+ })();
71
+ if (!ambient || typeof ambient.color !== 'string')
72
+ return null;
73
+ const lit = typeof directional.color === 'string' && direction !== null;
74
+ return {
75
+ ambientColour: ambient.color,
76
+ ambientIntensity: number(ambient.intensity, 1),
77
+ directionalColour: lit ? directional.color : 'hsl(0, 0%, 0%)',
78
+ directionalIntensity: lit ? number(directional.intensity, 1) : 0,
79
+ directionalPolar: lit ? direction[1] : 0,
80
+ };
81
+ }
82
+ /**
83
+ * One constant out of a value that branches, for the properties where a per-feature answer is not
84
+ * available: an anchor and an offset are baked into the label's layout when the rule is built.
85
+ *
86
+ * The branch taken is the LAST - the highest step key, a case's fallback. That is the detailed end
87
+ * of the ramp and what most features get: Standard anchors a POI by
88
+ * `step(sizerank, "center", 5, "top")` and every POI in a Paris tile has sizerank 16.
89
+ */
90
+ function representativeConstant(value) {
91
+ if (value === null || value === undefined)
92
+ return null;
93
+ if (typeof value === 'string' || typeof value === 'number')
94
+ return value;
95
+ if (!Array.isArray(value))
96
+ return null;
97
+ const head = value[0];
98
+ if (head === 'literal')
99
+ return (value[1] ?? null);
100
+ // `step` and `interpolate` both end on a value; `interpolate` carries a curve and an input
101
+ // first, which changes nothing about where the last one is.
102
+ if ((head === 'step' || head === 'interpolate') && value.length >= 4) {
103
+ return representativeConstant(value[value.length - 1]);
104
+ }
105
+ if ((head === 'case' || head === 'match') && value.length >= 4) {
106
+ return representativeConstant(value[value.length - 1]);
107
+ }
108
+ // A plain array of scalars is already the value - an offset pair.
109
+ if (value.every((v) => typeof v === 'number'))
110
+ return value;
111
+ return null;
112
+ }
113
+ /** Attachment names are identifiers in the CartoCSS grammar. */
114
+ function attachmentName(layerId) {
115
+ const cleaned = layerId.replace(/[^A-Za-z0-9_]/g, '_');
116
+ return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
117
+ }
118
+ /**
119
+ * Which emissive property a translated declaration carries its light on, LONGEST PREFIX FIRST.
120
+ *
121
+ * A pattern is its own symbolizer, and a property alone is enough to CREATE one: emitting
122
+ * `polygon-emissive-strength` on a rule whose only fill is a `polygon-pattern-file` would raise a
123
+ * second, grey polygon under the pattern. The background is a Map setting rather than a symbolizer
124
+ * but takes the same term - and it is the largest surface on the map, so leaving it out left a lit
125
+ * map on an unlit sheet of paper.
126
+ */
127
+ /** The label emissive properties `--label-emissive` caps, and their halo counterparts. */
128
+ const LABEL_EMISSIVE = new Set(['text-emissive-strength', 'icon-emissive-strength']);
129
+ const HALO_TARGETS = {
130
+ 'text-emissive-strength': 'text-halo-emissive-strength',
131
+ 'shield-emissive-strength': 'shield-halo-emissive-strength',
132
+ 'marker-emissive-strength': 'marker-halo-emissive-strength',
133
+ };
134
+ /** The emissive properties `--geometry-emissive` floors: everything that is not a label. */
135
+ const GEOMETRY_EMISSIVE = new Set(['fill-emissive-strength', 'line-emissive-strength',
136
+ 'background-emissive-strength', 'circle-emissive-strength']);
137
+ /** MapBox's own default for unstated GEOMETRY is 0; a floor lifts it off the floor. */
138
+ function geometryFloor(source, value, floor) {
139
+ if (floor === undefined || source === null || !GEOMETRY_EMISSIVE.has(source))
140
+ return value;
141
+ return Math.max(value ?? 0, floor);
142
+ }
143
+ function haloTargetOf(target) {
144
+ return HALO_TARGETS[target] ?? null;
145
+ }
146
+ /**
147
+ * A label's emissive, never above `cap`.
148
+ *
149
+ * A stated RAMP is reduced to its representative value first and then capped, so the ramp's shape
150
+ * is lost - the label dims by the scene light rather than by its own curve over it. That is the
151
+ * trade the cap makes: Standard states a flat 1 on its shields, and nothing short of overriding
152
+ * that makes them follow the light at all.
153
+ */
154
+ function capStops(stated, cap, lookup) {
155
+ const value = stated === undefined ? 1
156
+ : typeof stated === 'number' ? stated
157
+ : representativeEmissive(stated, representativeScale(stated, 1), lookup) ?? 1;
158
+ const capped = Math.max(0, Math.min(cap, value));
159
+ return capped >= 1 ? null : String(round(capped));
160
+ }
161
+ const EMISSIVE_TARGETS = [
162
+ ['polygon-pattern-', 'polygon-pattern-emissive-strength'],
163
+ ['line-pattern-', 'line-pattern-emissive-strength'],
164
+ ['polygon-', 'polygon-emissive-strength'],
165
+ ['line-', 'line-emissive-strength'],
166
+ ['background-', 'background-emissive-strength'],
167
+ // An EXTRUSION states its own, and until `building-emissive-strength` existed there was nowhere
168
+ // to put it: the value was looked up (EMISSIVE_SOURCE maps `building-` to
169
+ // fill-extrusion-emissive-strength) and then dropped, leaving every building on the Map block's
170
+ // one global. Standard lights a lit window and a plain facade differently, and both came out
171
+ // the same.
172
+ ['building-', 'building-emissive-strength'],
173
+ // A shield's icon and its text are one symbolizer here, so they take one value - mapbox's
174
+ // text-emissive-strength, which is the one its own shields state.
175
+ ['shield-', 'shield-emissive-strength'],
176
+ ['marker-', 'marker-emissive-strength'],
177
+ ['text-', 'text-emissive-strength'],
178
+ ];
179
+ /**
180
+ * Targets the SDK already has a FALLBACK for, so an unstated value must stay unstated rather than
181
+ * take mapbox's property default.
182
+ *
183
+ * Everywhere else the default has to be written down, because this SDK draws an unstated colour as
184
+ * authored where mapbox would light it. An extrusion is the exception: it falls back to the Map
185
+ * block's `building-emissive-strength`, so emitting mapbox's default 0 here would pin every
186
+ * converted building to 0 and the global would stop working.
187
+ */
188
+ const EMISSIVE_NO_DEFAULT = new Set(['building-emissive-strength']);
189
+ export function convert(style, table, options = {}) {
190
+ options = { ...options, styleParams: options.styleParams ?? new Map() };
191
+ // Module state, so it has to be set on every call and not only the first - the tests convert
192
+ // many styles in one process.
193
+ setTileDrawSize(options.tileDrawSize ?? 256);
194
+ const coverage = new Coverage();
195
+ const allowed = new Map(table.properties.map((p) => [p.cartocss, p]));
196
+ // A style may be configurable (Mapbox Standard). Its config is resolved to constants BEFORE
197
+ // anything is translated: CartoCSS has no `let`/`to-hsla`/`at`, so a colour left reading its
198
+ // config converts to nothing at all. See fold.ts.
199
+ const importsOnly = importOnly(style);
200
+ if (importsOnly)
201
+ coverage.drop('the whole style', importsOnly, 'imports');
202
+ // Which vocabulary the style's own source layers are in. Only read when retargeting, and only
203
+ // guessed at when the caller did not say - a wrong table draws the wrong features everywhere.
204
+ const detected = options.schema
205
+ ? detectSourceSchema((style.layers ?? []).map((l) => l['source-layer'] ?? ''))
206
+ : null;
207
+ if (options.schema && !options.sourceSchema) {
208
+ if (!detected) {
209
+ throw new Error('cannot tell which schema this style is written against; ' +
210
+ 'pass --source-schema mapbox or --source-schema maptiler');
211
+ }
212
+ coverage.approximate(`source layers read as the ${detected} schema`);
213
+ }
214
+ const sourceSchema = options.sourceSchema ?? detected ?? 'maptiler';
215
+ options = { ...options, sourceSchema };
216
+ const { parameters, undeclared } = resolveConfig(style);
217
+ for (const name of undeclared) {
218
+ coverage.approximate(`config "${name}" is read but declared in no schema; took what it was read as`);
219
+ }
220
+ const configValues = new Map([...parameters].map(([name, spec]) => [name, spec.default]));
221
+ for (const [name, value] of Object.entries(options.config ?? {}))
222
+ configValues.set(name, value);
223
+ for (const name of liveConfig(style)) {
224
+ const spec = parameters.get(name);
225
+ if (!spec) {
226
+ coverage.approximate(`config "${name}" is asked to stay live but the style never reads it`);
227
+ continue;
228
+ }
229
+ configValues.delete(name);
230
+ options.styleParams.set(name, spec.values ? { default: spec.default, values: spec.values } : spec.default);
231
+ coverage.note(`config "${name}" stays a style parameter, settable on a running map`);
232
+ }
233
+ if (configValues.size > 0) {
234
+ coverage.approximate(`${configValues.size} config values baked in` +
235
+ (configValues.has(LIGHT_PRESET) ? ` (${LIGHT_PRESET} = ${String(configValues.get(LIGHT_PRESET))})` : ''));
236
+ }
237
+ // The scene's brightness is a style value here, not a runtime one - see config.ts.
238
+ const lights = style.lights;
239
+ // Set while emitting, so the parameter is only declared when there is a building to gate.
240
+ let usesBuildings = false;
241
+ // The brightness of the style's OWN default preset, which the emissive fold measures against.
242
+ // sceneBrightness is an ambient-only proxy, so its absolute value is not a light level - but
243
+ // the RATIO between two presets of the same style is meaningful, and that is all this needs:
244
+ // the default preset comes out unlit (factor 1) and every darker one follows it down.
245
+ const defaultBrightness = lights === undefined ? null
246
+ : sceneBrightness(lights, (node) => foldConfig(node, new Map([...parameters].map(([name, spec]) => [name, spec.default]))));
247
+ // Emitting is a function of the config, because a configurable style is converted ONCE PER
248
+ // PRESET: folding preserves structure (see fold.ts), so the runs line up block for block and
249
+ // only their literals differ - which is what lets one style.mss carry a palette per preset.
250
+ function emitAll(values, coverage) {
251
+ const brightness = lights === undefined ? null : sceneBrightness(lights, (node) => foldConfig(node, values));
252
+ // MapBox's own light, resolved for THIS preset - what a flat surface facing up receives.
253
+ const radiance = groundRadiance(lights === undefined ? null
254
+ : sceneLights(lights, (node) => foldConfig(node, values)));
255
+ // Under --live-light the SDK resolves the brightness itself, per frame (view::brightness), so
256
+ // the ramps over it stay live instead of freezing at the preset the conversion was run for.
257
+ const scene = brightness === null ? {} : { brightness, liveBrightness: options.liveLight === true };
258
+ // Always, even for a style with no config of its own: the viewport terms fold here too, and a
259
+ // filter left testing the pitch is untranslatable, which drops the whole LAYER.
260
+ // Which side of MapBox's 3D switch each layer is on, read BEFORE the fold: folded with the
261
+ // default config the flat pair resolves to `visibility: none` and is dropped (buildings3DGate).
262
+ const buildings3D = new Map();
263
+ for (const layer of style.layers ?? []) {
264
+ const gate = buildings3DGate(layer);
265
+ if (gate)
266
+ buildings3D.set(layer.id, gate);
267
+ }
268
+ const layers = (style.layers ?? []).map((raw) => {
269
+ const layer = applyMassifExtras(raw);
270
+ const folded = foldLayer(layer, values, scene);
271
+ if (!buildings3D.has(layer.id))
272
+ return folded;
273
+ // The `buildings` parameter carries the switch now, so the visibility must not.
274
+ const { visibility, ...layout } = folded.layout ?? {};
275
+ return { ...folded, layout };
276
+ });
277
+ if (options.foldCasings) {
278
+ const { layers: merged, folded, skipped } = foldCasings(layers);
279
+ if (skipped.length > 0) {
280
+ coverage.approximate(`${skipped.length} casing layer(s) left unfolded because their fill ` +
281
+ `states a line-sort-key (${skipped.map((f) => f.fill).join(', ')}); folded, the casing ` +
282
+ 'would draw per class and land on the fill of the road beside it');
283
+ }
284
+ layers.splice(0, layers.length, ...merged);
285
+ if (folded.length > 0) {
286
+ coverage.approximate(`${folded.length} casing layer(s) folded into their fill as line-border-* ` +
287
+ `(${folded.map((f) => f.casing).join(', ')}); the casing now draws per class, not under every fill`);
288
+ }
289
+ }
290
+ const mapBlock = [];
291
+ // Kept as selector + declarations rather than joined text: the palette pass rewrites the
292
+ // declarations after every layer is in, when it can tell a shared colour from a layer's own.
293
+ const blocks = [];
294
+ // Source-layer name -> every MapBox layer index that draws it. One project entry pulls ALL of
295
+ // them, so the whole source-layer has to sit at one depth and the question is which.
296
+ const positions = new Map();
297
+ const drawOrder = [];
298
+ // The TARGET source layer of every layer that made it through, in style order. Kept separately
299
+ // because --schema renames them, and the interleaving check has to see what was emitted.
300
+ const emitted = [];
301
+ // One map has one answer for each building setting; the first layer to state it wins.
302
+ const buildingSettingsSeen = new Set();
303
+ layers.forEach((layer, index) => {
304
+ if (layer.type === 'background') {
305
+ mapBlock.push(...light(layer, backgroundProperties(layer, coverage)));
306
+ return;
307
+ }
308
+ // A fill-extrusion's LOOK is a Map setting here, not a symbolizer property: the SDK lights
309
+ // and bevels every building at once. Taken from the layers that draw the BUILDING source
310
+ // layer only - Standard's indoor walls state their own ambient occlusion and come first,
311
+ // so reading every extrusion gave the whole map the shading of an indoor floor plan.
312
+ if (layer.type === 'fill-extrusion' && BUILDING_LAYER.test(layer['source-layer'] ?? '')) {
313
+ mapBlock.push(...buildingMapSettings(layer, buildingSettingsSeen, coverage, options.buildingHeightRamp, options.aoFollowsHeight, lights !== undefined));
314
+ }
315
+ // The only 3D model worth standing in for is a TREE: Standard draws the whole `tree`
316
+ // source-layer as models from z15, and dropping it left every park a bare green slab.
317
+ // A building or a wind turbine has no such reduction, so those are still dropped.
318
+ if (layer.type === 'model' && layer['source-layer'] !== 'tree') {
319
+ coverage.drop(`layer type "model"`, 'no 3D models, and no 2D stand-in for this one', layer.id);
320
+ return;
321
+ }
322
+ // CartoCSS draws no raster: a hillshade becomes a composite slot the app fills with its DEM
323
+ // (CompositeVectorTileLayer), configured with the values `massif:sdk-layer` tuned for the SDK.
324
+ if (layer.type === 'hillshade' && emitHillshadeSlot(layer, index)) {
325
+ return;
326
+ }
327
+ const symbolizer = LAYER_SYMBOLIZER[layer.type];
328
+ if (!symbolizer) {
329
+ coverage.drop(`layer type "${layer.type}"`, 'unsupported layer type', layer.id);
330
+ return;
331
+ }
332
+ const sourceLayer = layer['source-layer'];
333
+ if (!sourceLayer) {
334
+ coverage.drop(`layer "${layer.id}"`, 'no source-layer', layer.id);
335
+ return;
336
+ }
337
+ // Retargeting at another tile schema is a rename plus a filter clause - what the source
338
+ // schema said by splitting into layers, the target says with a `class` field. One source
339
+ // layer can feed SEVERAL target ones (MapBox draws a road and its name off the same one);
340
+ // each keeps only the part of the filter that can match it.
341
+ const targets = [];
342
+ if (options.schema) {
343
+ const { mappings, why } = mapSourceLayer(sourceLayer, options.schema, sourceSchema);
344
+ if (mappings.length === 0) {
345
+ coverage.drop(`source-layer "${sourceLayer}"`, why ?? 'no equivalent', layer.id);
346
+ return;
347
+ }
348
+ // The RAW layer, then folded again per target. foldLayer records which FIELD a
349
+ // recolourable icon is keyed by, as a bare name; retargeting the folded layer renamed
350
+ // the expression and left that name behind, so every POI fell back to flat sprite
351
+ // artwork and lost its category-coloured plate. Folding after the rename costs one
352
+ // extra fold per target and keeps the two in step.
353
+ const raw = (style.layers ?? [])[index];
354
+ for (const mapping of mappings) {
355
+ const retargetedLayer = retargetLayer(raw, mapping, sourceSchema, {
356
+ dropped: (field) => coverage.approximate(`"${field}" test dropped on "${layer.id}": the target schema has no such field, ` +
357
+ 'and a test on it can only fail, which would draw nothing at all'),
358
+ approximate: (what) => coverage.approximate(`on "${layer.id}": ${what}`),
359
+ });
360
+ if (!retargetedLayer)
361
+ continue;
362
+ const folded = foldLayer(retargetedLayer, values, scene);
363
+ // Same visibility strip the pre-folded pass does: the `buildings` parameter carries
364
+ // the 3D switch now, so the layer's own visibility must not.
365
+ const { visibility, ...layout } = buildings3D.has(layer.id) ? (folded.layout ?? {}) : {};
366
+ targets.push({
367
+ target: mapping.layer,
368
+ layer: buildings3D.has(layer.id) ? { ...folded, layout } : folded,
369
+ });
370
+ }
371
+ if (targets.length === 0) {
372
+ coverage.drop(`layer "${layer.id}"`, 'its filter can never match the target schema', layer.id);
373
+ return;
374
+ }
375
+ }
376
+ else {
377
+ targets.push({ target: sourceLayer, layer });
378
+ }
379
+ for (const { target, layer: schemaLayer } of targets) {
380
+ (positions.get(target) ?? positions.set(target, []).get(target)).push(index);
381
+ emitted.push(target);
382
+ // The contour schemas name the elevation differently; renaming it here means the filter,
383
+ // the label and every paint expression all see the field the tiles actually carry.
384
+ const retargeted = options.contour
385
+ ? rewriteContourFields(schemaLayer, options.contour)
386
+ : schemaLayer;
387
+ // A field-driven paint value becomes one attachment per branch, and a line-sort-key one
388
+ // per key value in draw order - see split.ts.
389
+ const variants = expandSortKey(isContourLayer(layer) ? retargeted : schemaLayer, coverage)
390
+ .flatMap(expandSetFilter)
391
+ .flatMap((ordered) => splitLayer(ordered, coverage))
392
+ .flatMap((ordered) => splitDashByZoom(ordered, coverage))
393
+ .map(narrowLayer);
394
+ variants.forEach((variant, branch) => {
395
+ const suffix = variants.length > 1 ? `_b${branch + 1}` : '';
396
+ emitLayer(variant, `${attachmentName(layer.id)}${suffix}`, target, symbolizer, index);
397
+ });
398
+ }
399
+ });
400
+ /**
401
+ * Mapbox lights its 2D layers; we draw their colours as authored. Folding the layer's own
402
+ * emissive-strength into the colour is what makes a night preset read as night - see
403
+ * emissive.ts, which is also where the limits of the approximation are written down.
404
+ */
405
+ function light(layer, declarations) {
406
+ // The emissive branch does NOT need the scene light: it emits what a layer STATES (or what
407
+ // mapbox defaults it to), for the renderer to light. A style with no `lights` block of its
408
+ // own - MapTiler Streets states none - is exactly the case that needs it, because mapbox
409
+ // defaults geometry to 0 where this SDK draws an unstated colour as authored.
410
+ if (radiance === null && !options.liveLight)
411
+ return declarations;
412
+ // Under --live-light the colour stays as the style authored it and the emissive rides along
413
+ // instead, for the renderer to apply. A value of 1 is never emitted: it is the default on
414
+ // both sides, and it is what most of Standard's labels resolve to.
415
+ if (options.liveLight) {
416
+ const emitted = new Set();
417
+ const extra = [];
418
+ for (const declaration of declarations) {
419
+ const property = declaration.slice(0, declaration.indexOf(':'));
420
+ const target = EMISSIVE_TARGETS.find(([prefix]) => property.startsWith(prefix))?.[1] ?? null;
421
+ if (target === null || emitted.has(target))
422
+ continue;
423
+ const source = emissiveProperty(property);
424
+ const stated = source ? layer.paint?.[source] : undefined;
425
+ if (stated === undefined && EMISSIVE_NO_DEFAULT.has(target))
426
+ continue;
427
+ // A CAP, not just a default: Standard states 1 on its shields deliberately, so a
428
+ // style that wants them to follow the light has to be able to say so over the top.
429
+ const cap = LABEL_EMISSIVE.has(source ?? '') ? options.labelEmissive : undefined;
430
+ if (cap !== undefined) {
431
+ const stops = capStops(stated, cap, (name) => values.get(name));
432
+ if (stops !== null) {
433
+ emitted.add(target);
434
+ extra.push(`${target}: ${stops};`);
435
+ }
436
+ const halo = haloTargetOf(target);
437
+ if (halo !== null && options.haloEmissive !== undefined) {
438
+ extra.push(`${halo}: ${round(Math.max(0, options.haloEmissive))};`);
439
+ }
440
+ continue;
441
+ }
442
+ if (stated !== undefined && typeof stated !== 'number') {
443
+ // Standard states most of these as a ramp over the scene brightness, which
444
+ // --live-light keeps as `[view::brightness]` - so the strength itself follows
445
+ // the hour rather than freezing at the preset the conversion was run for.
446
+ const translated = tryTranslate(stated, source, layer.id, coverage);
447
+ if (translated !== null && translated !== '1') {
448
+ emitted.add(target);
449
+ extra.push(`${target}: ${translated};`);
450
+ continue;
451
+ }
452
+ }
453
+ const emissive = stated === undefined
454
+ ? geometryFloor(source, emissiveDefault(source, options.labelEmissive), options.geometryEmissive)
455
+ : typeof stated === 'number' ? stated
456
+ : representativeEmissive(stated, representativeScale(stated, 1), (name) => values.get(name));
457
+ if (emissive === null || emissive >= 1)
458
+ continue;
459
+ emitted.add(target);
460
+ extra.push(`${target}: ${round(Math.max(0, emissive))};`);
461
+ }
462
+ return [...declarations, ...extra];
463
+ }
464
+ if (radiance === null)
465
+ return declarations;
466
+ return declarations.map((declaration) => {
467
+ const colon = declaration.indexOf(':');
468
+ const property = declaration.slice(0, colon);
469
+ // Map settings are not symbolizer properties, so they are not in the table.
470
+ const known = allowed.get(property);
471
+ if (known ? known.kind !== 'color' : !property.endsWith('-color'))
472
+ return declaration;
473
+ // A `model` layer's colour is the canopy's, so it reads the model's own strength.
474
+ // The LAYER TYPE wins where the property name would mislead - a circle's colour and a
475
+ // fill's outline both translate to names whose mapbox counterpart the layer never states.
476
+ const source = layer.type === 'model' ? 'model-emissive-strength'
477
+ : emissiveForLayerType(layer.type) ?? emissiveProperty(property);
478
+ const stated = source ? layer.paint?.[source] : undefined;
479
+ // Unstated takes MAPBOX's default for that property, which is 1 for a label and 0 for
480
+ // geometry - see EMISSIVE_DEFAULT. A zoom ramp has no single answer, so it takes the
481
+ // mean of its stops, as every other zoom-driven value baked in here does.
482
+ const value = declaration.slice(colon + 1, declaration.lastIndexOf(';')).trim();
483
+ // A ZOOM-ramped emissive keeps its ramp: Standard fades six layers - land, water,
484
+ // hillshade, recreation-polygon and the two ferries - between a lit and an unlit form
485
+ // over zoom, and land is the one that shows: 0.1 below z13 and 0 above z14, so a night
486
+ // map is markedly lighter zoomed out than zoomed in. Collapsed to one number the whole
487
+ // world got the zoomed-IN value and every low zoom came out too dark.
488
+ const stops = zoomStops(stated);
489
+ if (stops && !ZOOM_RAMP.test(value)) {
490
+ const lit = stops.map(([zoom, emissive]) => {
491
+ const factor = lightingFactor(emissive, radiance);
492
+ return [zoom, factor === null ? value : applyLighting(value, factor)];
493
+ });
494
+ if (lit.some(([, colour]) => colour !== value)) {
495
+ const frames = lit.map(([zoom, colour]) => `(${round(zoom - 1)}, ${colour})`).join(', ');
496
+ return `${property}: linear(([view::zoom] - 1), ${frames});`;
497
+ }
498
+ return declaration;
499
+ }
500
+ // Unstated takes MAPBOX's default for that property, which is 1 for a label and 0 for
501
+ // geometry - see EMISSIVE_DEFAULT.
502
+ // The mean of the positive numbers is the LAST resort, not the first: it is what this
503
+ // used to do for everything, and it is still better than the property default when the
504
+ // expression cannot be resolved at all.
505
+ const emissive = stated === undefined ? emissiveDefault(source)
506
+ : typeof stated === 'number' ? stated
507
+ : representativeEmissive(stated, representativeScale(stated, 1), (name) => values.get(name));
508
+ const factor = lightingFactor(emissive, radiance);
509
+ if (factor === null)
510
+ return declaration;
511
+ const lit = applyLighting(value, factor);
512
+ return lit === value ? declaration : `${property}: ${lit};`;
513
+ });
514
+ }
515
+ /**
516
+ * An emissive strength stated as an `interpolate(zoom, ...)` ramp, as its (zoom, value) pairs.
517
+ * Null for anything else - a constant, a ramp over something other than zoom, or a ramp whose
518
+ * values are still expressions after the fold.
519
+ */
520
+ function zoomStops(value) {
521
+ if (!Array.isArray(value) || value[0] !== 'interpolate')
522
+ return null;
523
+ const input = value[2];
524
+ if (!Array.isArray(input) || input[0] !== 'zoom')
525
+ return null;
526
+ const stops = [];
527
+ for (let i = 3; i + 1 < value.length; i += 2) {
528
+ const zoom = value[i];
529
+ const stop = representativeConstant(value[i + 1]);
530
+ if (typeof zoom !== 'number' || typeof stop !== 'number')
531
+ return null;
532
+ stops.push([zoom, stop]);
533
+ }
534
+ return stops.length >= 2 ? stops : null;
535
+ }
536
+ function emitHillshadeSlot(layer, layerIndex) {
537
+ const sdk = layer.metadata?.['massif:sdk-layer'];
538
+ if (!sdk || typeof sdk !== 'object' || sdk.type !== 'hillshade')
539
+ return false;
540
+ const declarations = [];
541
+ for (const [key, property] of [['opacity', 'hillshade-opacity'], ['exaggeration', 'hillshade-exaggeration']]) {
542
+ if (sdk[key] === undefined)
543
+ continue;
544
+ const value = tryTranslate(sdk[key], property, layer.id, coverage);
545
+ if (value !== null)
546
+ declarations.push(`${property}: ${value};`);
547
+ }
548
+ const range = Array.isArray(sdk.visibleZoomRange) ? sdk.visibleZoomRange : [];
549
+ let selector;
550
+ try {
551
+ selector = `#${layer.id}${[...zoomPredicates(range[0] || layer.minzoom, range[1] ?? layer.maxzoom),
552
+ ...translateFilter(layer.filter ?? null)].join('')}`;
553
+ }
554
+ catch (error) {
555
+ coverage.drop(`filter on "${layer.id}"`, describe(error), layer.id);
556
+ return true;
557
+ }
558
+ blocks.push({ selector, owner: layer.id, declarations });
559
+ drawOrder.push({ sourceLayer: layer.id, attachment: '', index: layerIndex });
560
+ return true;
561
+ }
562
+ function emitLayer(layer, attachment, sourceLayer, symbolizer, layerIndex) {
563
+ const paramised = paramiseValues(layer, options, coverage);
564
+ const extrusion = symbolizer === 'building'
565
+ ? flattenExtrusionOpacity(paramised.layer) : { layer: paramised.layer, opacity: null };
566
+ let declarations = layerDeclarations(extrusion.layer, symbolizer, allowed, coverage, options, layerIndex);
567
+ // The style's own opacity, as a parameter rather than a literal - see flattenExtrusionOpacity.
568
+ if (extrusion.opacity !== null) {
569
+ const stated = options.styleParams.get(OPACITY_PARAM);
570
+ // One parameter for every extrusion, so a style asking for two different alphas gets the
571
+ // first of them everywhere. No source style does; say so rather than pick silently.
572
+ if (typeof stated === 'number' && stated !== extrusion.opacity) {
573
+ coverage.approximate(`"${layer.id}" asks for fill-extrusion-opacity ${extrusion.opacity} `
574
+ + `where another extrusion asks for ${stated}: both draw at ${stated}, the `
575
+ + `${OPACITY_PARAM} parameter being one knob for all of them`);
576
+ }
577
+ else {
578
+ options.styleParams.set(OPACITY_PARAM, extrusion.opacity);
579
+ }
580
+ declarations = declarations.map((declaration) => (declaration.startsWith('building-fill-opacity:')
581
+ ? `building-fill-opacity: [param::${OPACITY_PARAM}];` : declaration));
582
+ }
583
+ if (paramised.subs.size > 0) {
584
+ declarations = declarations.map((declaration) => {
585
+ for (const [sentinel, lookup] of paramised.subs)
586
+ declaration = declaration.split(sentinel).join(lookup);
587
+ return declaration;
588
+ });
589
+ }
590
+ if (declarations.length === 0)
591
+ return;
592
+ let selector;
593
+ try {
594
+ // `selector = *predicate` with a skipper, so bracketed tests can abut the layer name -
595
+ // but `when(...)` is a bare word and would glue onto it (`#aviationwhen(...)`).
596
+ let filter = layer.filter ?? null;
597
+ if (options.contour && isContourLayer(layer)) {
598
+ filter = rewriteContourFilter(filter, options.contour, () => coverage.approximate(`contour nth_line test rewritten as div >= ${options.contour.majorDiv}: ` +
599
+ 'only the major/minor split survives, the base interval is not in the style'));
600
+ }
601
+ const buildings = buildingPredicate(symbolizer, sourceLayer, buildings3D.get(layer.id) === 'flat');
602
+ if (buildings)
603
+ usesBuildings = true;
604
+ // `massif:minzoom-param`: the zoom this layer starts at is a parameter an app sets, tested by
605
+ // every band. The band starting AT its default drops its own start, so an app may go lower;
606
+ // a layer starting below its default keeps that floor, the lowest an app may go.
607
+ const minParam = layer.metadata?.['massif:minzoom-param'];
608
+ const minDefault = typeof minParam === 'string' ? Number(parameters.get(minParam)?.default) : NaN;
609
+ const hasParam = typeof minParam === 'string' && zoomOffsetLevels() === 0;
610
+ const paramStart = hasParam && typeof layer.minzoom === 'number' && Math.floor(layer.minzoom) === minDefault;
611
+ const predicates = [
612
+ ...(hasParam ? [`[zoom >= 'param::${minParam}']`] : []),
613
+ ...zoomPredicates(paramStart ? undefined : layer.minzoom, layer.maxzoom),
614
+ ...(buildings ? [buildings] : []),
615
+ ...translateFilter(filter),
616
+ ].map((p) => (p.startsWith('when(') ? ` ${p}` : p));
617
+ selector = `#${sourceLayer}${predicates.join('')}::${attachment}`;
618
+ }
619
+ catch (error) {
620
+ const why = error instanceof Untranslatable ? error.what : String(error);
621
+ coverage.drop(`filter on "${layer.id}"`, `untranslatable filter: ${why}`, layer.id);
622
+ return;
623
+ }
624
+ // An EXTRUSION is lit by the renderer, not baked: mapbox draws it through apply_lighting
625
+ // and its `fill-extrusion-emissive-strength` is 0, so folding the scene light in here as
626
+ // well multiplied it twice - once at conversion, once per fragment.
627
+ //
628
+ // Under --live-light nothing is folded for ANY layer - light() only appends the emissive
629
+ // for the renderer to apply - so the bypass would cost the extrusion its own strength and
630
+ // leave it on the Map block's one global. Standard lights a lit window and a plain facade
631
+ // differently, and both came out the same.
632
+ const renderLit = symbolizer === 'building' && options.liveLight !== true;
633
+ blocks.push({ selector, owner: layer.id, declarations: renderLit ? declarations : light(layer, declarations) });
634
+ drawOrder.push({ sourceLayer, attachment, index: layerIndex });
635
+ }
636
+ if (usesBuildings) {
637
+ options.styleParams.set(BUILDINGS_PARAM, BUILDINGS_3D);
638
+ options.styleParams.set(TILT_DROP_PARAM, TILT_DROP_PERCENT);
639
+ options.styleParams.set(AO_PARAM, 1);
640
+ // Whatever the layers did not state, so a converted style is not left on the SDK's own
641
+ // (OSM-tuned) building lighting - see BUILDING_MAP_DEFAULTS.
642
+ if (lights !== undefined) {
643
+ mapBlock.push(...buildingLightSettings(lights, buildingSettingsSeen, coverage, (node) => foldConfig(node, values)));
644
+ }
645
+ else if (!buildingSettingsSeen.has(HEIGHT_VIEW_SCALE)) {
646
+ // Not a light: without it `building_tilt_drop` is declared and drives nothing.
647
+ mapBlock.push(`${HEIGHT_VIEW_SCALE}: ${HEIGHT_TILT_RAMP};`);
648
+ }
649
+ }
650
+ // Lit here or by the renderer at colour evaluation, the 2D colours carry the light either way;
651
+ // left to the building defaults, a style with no `lights` had its ground lit a second time.
652
+ if ((options.liveLight || lights !== undefined) && !mapBlock.some((d) => d.startsWith('colors-prelit:'))) {
653
+ mapBlock.push('colors-prelit: 1;');
654
+ }
655
+ // The MEDIAN index, not the first. `road` has 82 layers spanning indices 3 to 130 in Mapbox
656
+ // Standard, and its FIRST is one early tunnel layer - ordering by that sank all 82 beneath
657
+ // landuse, so the landuse polygons painted over every road. The median puts a source-layer
658
+ // where most of its layers actually are, which is the depth that misorders the fewest.
659
+ const order = new Map([...positions].map(([name, at]) => {
660
+ const sorted = [...at].sort((a, b) => a - b);
661
+ return [name, sorted[sorted.length >> 1]];
662
+ }));
663
+ reportSplitLayers(drawOrder, coverage);
664
+ reportSources(style, layers, coverage);
665
+ return { mapBlock, blocks, order, drawOrder, brightness };
666
+ }
667
+ const base = emitAll(configValues, coverage);
668
+ if (base.brightness !== null) {
669
+ coverage.approximate(options.liveLight
670
+ ? '["measure-light", "brightness"] left live as [view::brightness], which the SDK resolves ' +
671
+ 'per frame; a ramp over it whose stops are per-feature expressions still snaps to the nearer end'
672
+ : `["measure-light", "brightness"] resolved to ${base.brightness.toFixed(2)} from ` +
673
+ "the style's own ambient light; our renderer cannot measure it back, and a ramp over it " +
674
+ 'whose stops are per-feature expressions snaps to the nearer end');
675
+ }
676
+ const { mapBlock, blocks, drawOrder } = base;
677
+ // The Map block goes through the palette pass with the rules: a variant that recolours the
678
+ // water and not the background behind it is not a variant anybody wants.
679
+ const withMap = (result) => [{ owner: 'map', declarations: result.mapBlock }, ...result.blocks];
680
+ // Every preset is converted, and all of them are hoisted TOGETHER. A variable then stands for
681
+ // the same sites in each one, so the palettes share `style.mss` and differ only in their
682
+ // values. Hoisting each preset on its own instead let a colour two layers happen to share by
683
+ // day - and not by night - name itself differently in the two files.
684
+ const presetConfigs = new Map();
685
+ for (const preset of options.presets ?? presetsOf(parameters)) {
686
+ if (preset === configValues.get(LIGHT_PRESET))
687
+ continue;
688
+ presetConfigs.set(preset, new Map(configValues).set(LIGHT_PRESET, preset));
689
+ }
690
+ // A parameter TABLE is a per-preset value like a palette entry: poi-label's colours are a
691
+ // per-class table whose entries ramp with the scene brightness. Written into the shared
692
+ // project they were simply overwritten by whichever preset was emitted last - which is how
693
+ // every preset ended up drawing its POIs in the NIGHT colours.
694
+ const presetParams = new Map();
695
+ presetParams.set('', new Map(options.styleParams));
696
+ const alternates = new Map();
697
+ for (const [preset, values] of presetConfigs) {
698
+ options.styleParams.clear();
699
+ const result = emitAll(values, new Coverage());
700
+ presetParams.set(preset, new Map(options.styleParams));
701
+ // Only a run that lines up block for block can share the rules; folding is written to
702
+ // guarantee that, and this is the check that it held.
703
+ if (aligns(base, result))
704
+ alternates.set(preset, result);
705
+ else
706
+ coverage.drop(`preset "${preset}"`, 'its layers do not line up with the default preset', preset);
707
+ }
708
+ // Back to the default preset's table, which is what the shared project carries.
709
+ options.styleParams.clear();
710
+ for (const [name, value] of presetParams.get('') ?? [])
711
+ options.styleParams.set(name, value);
712
+ let hoisted = withMap(base);
713
+ let palette = [];
714
+ const presetPalettes = new Map();
715
+ let defaultPreset = null;
716
+ if (options.variables !== false) {
717
+ const result = hoistVariables(hoisted, allowed, [...alternates.values()].map(withMap), (style.metadata)?.['massif:palette-names']);
718
+ hoisted = result.blocks;
719
+ palette = result.palette;
720
+ for (const [index, preset] of [...alternates.keys()].entries()) {
721
+ presetPalettes.set(preset, [...paletteHeader(style.name, preset), '', ...result.alternates[index], ''].join('\n'));
722
+ }
723
+ // The DEFAULT preset gets a named project too, so all four are picked the same way -
724
+ // CompiledStyleSet takes a style NAME and finds <name>.json, and "project" is not one.
725
+ const fallback = configValues.get(LIGHT_PRESET);
726
+ if (presetPalettes.size > 0 && typeof fallback === 'string')
727
+ defaultPreset = fallback;
728
+ }
729
+ const selectors = ['Map', ...blocks.map((block) => block.selector)];
730
+ const [mapRule, ...ruleBlocks] = hoisted.map((block, index) => ({ ...block, selector: selectors[index] }));
731
+ const header = [
732
+ '/* Generated by massif-style mapbox2css. Do not edit by hand:',
733
+ ' re-run the converter against the source style instead. */',
734
+ ];
735
+ if (palette.length > 0)
736
+ header.push(`/* Colours, fonts and shared sizes live in ${VARIABLES_FILE}. */`);
737
+ if (style.name)
738
+ header.push(`/* Source style: ${style.name} */`);
739
+ const render = (block) => `${block.selector} {\n${block.declarations.map((d) => ` ${d}`).join('\n')}\n}`;
740
+ const mss = [
741
+ ...header,
742
+ '',
743
+ ...(mapRule.declarations.length > 0 ? [render(mapRule), ''] : []),
744
+ ...ruleBlocks.map(render),
745
+ '',
746
+ ].join('\n');
747
+ const variables = palette.length > 0 ? [...paletteHeader(style.name), '', ...palette, ''].join('\n') : null;
748
+ // loadMapProject REVERSES this array (layerNames.insert(begin)), and MapBox layers run
749
+ // bottom-to-top, so the project list is the draw order reversed.
750
+ const projectLayers = projectEntries(drawOrder).reverse();
751
+ const styles = variables ? [VARIABLES_FILE, 'style.mss'] : ['style.mss'];
752
+ // `fonts` is for whoever has to CARRY the project - the decoder finds them by scanning the
753
+ // package for <style>/fonts/ and needs no list, but a project served over HTTP cannot be
754
+ // listed, so the preview reads this to know what to fetch.
755
+ const fonts = options.fonts ?? [];
756
+ const project = JSON.stringify({
757
+ styles,
758
+ layers: projectLayers,
759
+ ...(fonts.length > 0 ? { fonts } : {}),
760
+ ...(options.styleParams.size > 0
761
+ ? { styleparameters: Object.fromEntries([...options.styleParams].sort()) }
762
+ : {}),
763
+ }, null, 2) + '\n';
764
+ // Only what DIFFERS from the shared table: a preset project extends project.json, and
765
+ // `extends` merges styleparameters, so an entry it does not restate is inherited.
766
+ const shared = presetParams.get('') ?? new Map();
767
+ const presetOverrides = new Map();
768
+ for (const [preset, params] of presetParams) {
769
+ if (preset === '')
770
+ continue;
771
+ const differing = [...params].filter(([name, value]) => shared.get(name) !== value);
772
+ if (differing.length > 0)
773
+ presetOverrides.set(preset, Object.fromEntries(differing.sort()));
774
+ }
775
+ return { mss, project, coverage, variables, presets: presetPalettes, defaultPreset, presetOverrides };
776
+ }
777
+ /**
778
+ * MapBox states a building's shading per LAYER; the SDK shades every building at once, from the Map
779
+ * block (`CartoCSSMapLoader`'s float settings). The names line up one for one, so the value is
780
+ * carried verbatim - a zoom ramp included, since these are FloatFunctionProperty and evaluated per
781
+ * frame. A boolean becomes 1/0 for the same reason.
782
+ *
783
+ * Left out because the SDK has no equivalent: `flood-light-*` (a per-building light source),
784
+ * `cutoff-fade-range`. `emissive-strength` is approximated into the colour
785
+ * instead - see emissive.ts.
786
+ */
787
+ /**
788
+ * How far the extrusions are flattened as the camera turns onto the map. Tilt 90 is straight DOWN
789
+ * in this SDK, and at that angle a tall building covers the streets it stands in and reads as a
790
+ * stain rather than a building; scaling it away is what keeps a top-down 3D map legible.
791
+ *
792
+ * Nothing happens before tilt 80 - a 3D camera at 55-75 must be untouched - and the drop is a
793
+ * PERCENTAGE an app can change at any time: `building_tilt_drop` is a style parameter, so setting
794
+ * it is a redraw, not a re-decode. 90 leaves a tenth of the height, enough to read the storeys.
795
+ *
796
+ * `view::tilt` is a view-state variable, so the ramp costs no re-decode either: the property is
797
+ * re-read every frame (MapRenderer::collectStyleEnvironment) against that frame's camera.
798
+ */
799
+ /**
800
+ * A live switch over the ground AO, so the contact shadows can be turned off on a running map
801
+ * without a re-decode - a style parameter is a redraw. It multiplies the intensity, so 0 is off and
802
+ * 1 is whatever the style asked for.
803
+ */
804
+ const AO_PARAM = 'building_ao';
805
+ const TILT_DROP_PARAM = 'building_tilt_drop';
806
+ const TILT_DROP_PERCENT = 90;
807
+ // `* 0.01`, not `/ 100`: the parameter is declared as a whole number and the two would be an
808
+ // INTEGER division - 90/100 = 0, and the flattening silently did nothing.
809
+ const HEIGHT_TILT_RAMP = `1 - ([param::${TILT_DROP_PARAM}] * 0.01) * linear([view::tilt], (80, 0), (90, 1))`;
810
+ /** Where it goes: the DRAWN height only. The shadow caster keeps the building's real height. */
811
+ const HEIGHT_VIEW_SCALE = 'building-height-view-scale';
812
+ /**
813
+ * MapBox raises the ground AO between its z17 and z17.8. Those are 512 px tiles, so on this SDK's
814
+ * own scale that is z18-z18.8: the contact shadows only appear once you are on top of a building,
815
+ * where they are what makes it stand on the ground rather than float. Shifted one level earlier;
816
+ * -2 puts them at z16.
817
+ */
818
+ /** A value that already ramps over zoom; combining two ramps is not expressible here. */
819
+ const ZOOM_RAMP = /\[view::zoom\]/;
820
+ const AO_GROUND_ZOOM_SHIFT = -1;
821
+ /** Move an `interpolate(..., ["zoom"], ...)` ramp's key frames, leaving its values alone. */
822
+ function shiftZoomStops(value, delta) {
823
+ if (!Array.isArray(value) || value[0] !== 'interpolate')
824
+ return value;
825
+ const input = value[2];
826
+ if (!Array.isArray(input) || input[0] !== 'zoom')
827
+ return value;
828
+ return value.map((item, i) => (i >= 3 && i % 2 === 1 && typeof item === 'number' ? item + delta : item));
829
+ }
830
+ /**
831
+ * What MapBox itself uses when a layer states nothing. The SDK's own defaults are tuned for the
832
+ * hand-written OSM style - ambient 0.35, ao-intensity 0.2, ao-attenuation 1.7 - and a converted
833
+ * MapBox style that inherits them comes out markedly darker than the browser draws it. Stating
834
+ * MapBox's defaults explicitly is what keeps the two comparable.
835
+ */
836
+ const BUILDING_MAP_DEFAULTS = {
837
+ // No MapBox equivalent: gl-js has no tilt term. See HEIGHT_TILT_RAMP.
838
+ [HEIGHT_VIEW_SCALE]: HEIGHT_TILT_RAMP,
839
+ // gl-js clamps a wall's shading to a FLOOR of about 0.7 (fill_extrusion.vertex.glsl mixes
840
+ // 0.7..0.98 by light intensity); the SDK's wall factor is `1 - gradient` at the foot, so 0.3
841
+ // puts the foot in the same place. Its own default of 0.65 takes it to 0.35 - less than half
842
+ // the brightness gl-js gives it, which is why our side walls read so much darker.
843
+ 'building-vertical-gradient': '0.3',
844
+ 'building-ao-intensity': '0', // gl-js: ambient-occlusion-intensity 0
845
+ 'building-ao-ground-radius': '3', // gl-js: ambient-occlusion-ground-radius 3
846
+ // NOT gl-js's 0.69: that number lives in a different formula. See groundAttenuation.
847
+ 'building-ao-ground-attenuation': '1.75',
848
+ 'building-edge-radius': '0', // gl-js: edge-radius 0
849
+ 'building-rounded-roof': '1', // gl-js: rounded-roof true
850
+ // gl-js has no timed fade for an extrusion: a style that wants one ramps
851
+ // `fill-extrusion-opacity` over zoom, as Standard does between z15 and z15.3. The SDK fades
852
+ // every geometry in with its tile, which put a second, timed fade on top of that one.
853
+ 'building-fade-on-appear': '0',
854
+ // The 2D colours carry the light by the time the terrain surface samples them, so it must not
855
+ // light them a second time. True in BOTH modes: folded in here by default, and applied by the
856
+ // renderer at colour evaluation - which runs before the drape bake - under --live-light. Says
857
+ // nothing about the shadows, which a pre-lit style still receives.
858
+ 'colors-prelit': '1',
859
+ };
860
+ const BUILDING_MAP_SETTINGS = {
861
+ 'fill-extrusion-vertical-gradient': 'building-vertical-gradient',
862
+ 'fill-extrusion-ambient-occlusion-intensity': 'building-ao-intensity',
863
+ 'fill-extrusion-ambient-occlusion-ground-radius': 'building-ao-ground-radius',
864
+ 'fill-extrusion-ambient-occlusion-ground-attenuation': 'building-ao-ground-attenuation',
865
+ 'fill-extrusion-edge-radius': 'building-edge-radius',
866
+ 'fill-extrusion-rounded-roof': 'building-rounded-roof',
867
+ // Standard grows its buildings out of the ground as they appear - 0 at z15, 1 at z15.3 - and
868
+ // this is how: a ZOOM ramp on the height, not a timed animation, so it is the same at every
869
+ // visit. Dropped, every extrusion snapped to full height the moment its layer switched on.
870
+ 'fill-extrusion-vertical-scale': 'building-height-scale',
871
+ };
872
+ /**
873
+ * MapBox's ground-attenuation is not the SDK's, and the two run in opposite directions.
874
+ *
875
+ * gl-js takes 0..1 and documents it as "lower values give a more crisp look" (default 0.69). The
876
+ * SDK's is the EXPONENT k of `(1 - d)^k` over the normalized distance from the wall
877
+ * (`polygon3dGroundFsh`), so a HIGHER value is the crisp one and its own default is 1.75. Carried
878
+ * verbatim, MapBox's 0.69 landed as a very low exponent: the band spread to the full radius and
879
+ * lost its contrast, which is exactly "too large, and less accentuated".
880
+ *
881
+ * The map is `k = 1.75 * 0.69 / a`: monotone, the right way round, and it puts each renderer's own
882
+ * default on the other's. It is a fit between two curves, not a conversion - a value far from 0.69
883
+ * only keeps the direction, not the profile.
884
+ */
885
+ function groundAttenuation(value, layerId, coverage) {
886
+ if (typeof value !== 'number' || !(value > 0)) {
887
+ coverage.approximate(`"${layerId}" drives ambient-occlusion-ground-attenuation with an ` +
888
+ 'expression; the SDK\'s is an exponent in a different formula and only a constant can ' +
889
+ 'be mapped onto it, so the default is kept');
890
+ return null;
891
+ }
892
+ return String(Math.round((1.75 * 0.69 / value) * 100) / 100);
893
+ }
894
+ /**
895
+ * The ground AO's INTENSITY, ramped over the same zoom window as its radius.
896
+ *
897
+ * MapBox states a constant (0.15) beside a radius that ramps in, so the contact shadow arrives at
898
+ * full strength the moment the radius leaves zero - which reads as a hard switch, not as buildings
899
+ * settling onto the ground. Given the radius ramp, the intensity gets the same keyframes from 0.
900
+ * A constant radius leaves the intensity alone.
901
+ */
902
+ function rampLikeGroundAO(intensity, layer) {
903
+ if (typeof intensity !== 'number')
904
+ return intensity;
905
+ const radius = layer.paint?.['fill-extrusion-ambient-occlusion-ground-radius'];
906
+ const shifted = shiftZoomStops(radius, AO_GROUND_ZOOM_SHIFT);
907
+ if (!Array.isArray(shifted) || shifted[0] !== 'interpolate')
908
+ return intensity;
909
+ const stops = shifted.slice(3).filter((_, i) => i % 2 === 0);
910
+ if (stops.length < 2)
911
+ return intensity;
912
+ const first = stops[0];
913
+ const last = stops[stops.length - 1];
914
+ return ['interpolate', ['linear'], ['zoom'], first, 0, last, intensity];
915
+ }
916
+ /**
917
+ * A 3D building's OPACITY, taken as a constant and carried as a STYLE PARAMETER.
918
+ *
919
+ * Standard fades an extrusion in by ramping `fill-extrusion-opacity` alongside its height. We draw
920
+ * the shadow map from the buildings at their FULL cast whatever their alpha, so during the fade the
921
+ * shadows are already at full strength and visible straight THROUGH the half-transparent walls that
922
+ * cast them. The height ramp alone is the better fade: a building grows out of the ground opaque,
923
+ * and its shadow grows with it. So the ramp is flattened to one number.
924
+ *
925
+ * That number is the style's, not ours. It used to be forced to 1 - the 3D pass draws with blending
926
+ * off, and MapTiler's 0.4 turned a city into a wash of half-buildings showing through each other -
927
+ * but forcing it also threw away what the style meant by it: maplibre draws Liberty's buildings at
928
+ * 0.8, which blends a fifth of the pale background back through every wall and is a good part of
929
+ * why ours read darker than the browser's. A parameter states the style's value and lets an app
930
+ * take it back to 1 as a redraw rather than a re-decode.
931
+ */
932
+ function flattenExtrusionOpacity(layer) {
933
+ const opacity = layer.paint?.['fill-extrusion-opacity'];
934
+ // A fade over the camera angle is the style's own live value, not one to flatten.
935
+ if (Array.isArray(opacity) && opacity[0] === 'interpolate' && isPitch(opacity[2]))
936
+ return { layer, opacity: null };
937
+ let value = opacity;
938
+ if (Array.isArray(opacity) && opacity[0] === 'interpolate') {
939
+ const constant = representativeConstant(opacity);
940
+ if (constant === null)
941
+ return { layer, opacity: null };
942
+ value = constant;
943
+ }
944
+ if (typeof value !== 'number')
945
+ return { layer, opacity: null };
946
+ const clamped = Math.min(1, Math.max(0, value));
947
+ return {
948
+ layer: value === opacity ? layer : { ...layer, paint: { ...layer.paint, 'fill-extrusion-opacity': clamped } },
949
+ opacity: clamped,
950
+ };
951
+ }
952
+ /** The style parameter a converted extrusion's opacity is carried as. */
953
+ const OPACITY_PARAM = 'building_opacity';
954
+ /**
955
+ * A layer's `metadata["massif:paint"]` / `["massif:layout"]`, merged over its real paint and layout,
956
+ * and `["massif:filter"]`, ANDed onto its real filter.
957
+ *
958
+ * A hand-written source style has to stay a VALID MapLibre style - the preview draws it with
959
+ * maplibre beside the SDK, and that comparison is the whole point of the file. But half of what is
960
+ * worth taking from Mapbox Standard is GL v3 only: `fill-extrusion-edge-radius`,
961
+ * `-vertical-scale`, `-ambient-occlusion-*`. Written into paint, maplibre rejects the style and the
962
+ * reference pane goes blank.
963
+ *
964
+ * `metadata` is the style spec's own escape hatch - arbitrary, and ignored by every renderer. So a
965
+ * property maplibre will not accept goes there, and this lifts it back out for the converter, which
966
+ * then treats it exactly as if Standard had stated it. Converting a real MapBox style is unaffected:
967
+ * it states these in paint, where they are legal for it.
968
+ *
969
+ * `massif:filter` is the same escape hatch for a test maplibre refuses outright - `["config", …]`
970
+ * is one, legal only inside an imported fragment and rejected in a filter anywhere - so a style
971
+ * that carries a second layer set behind a runtime switch keeps drawing its DEFAULT set in the
972
+ * reference pane, which is what a comparison wants.
973
+ */
974
+ function applyMassifExtras(layer) {
975
+ const metadata = layer.metadata;
976
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
977
+ return layer;
978
+ const paint = metadata['massif:paint'];
979
+ const layout = metadata['massif:layout'];
980
+ const filter = metadata['massif:filter'];
981
+ const object = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
982
+ const extraPaint = object(paint);
983
+ const extraLayout = object(layout);
984
+ const extraFilter = Array.isArray(filter) ? filter : null;
985
+ if (!extraPaint && !extraLayout && !extraFilter)
986
+ return layer;
987
+ return {
988
+ ...layer,
989
+ paint: extraPaint ? { ...layer.paint, ...extraPaint } : layer.paint,
990
+ layout: extraLayout ? { ...layer.layout, ...extraLayout } : layer.layout,
991
+ filter: extraFilter
992
+ ? (layer.filter === undefined ? extraFilter : ['all', layer.filter, extraFilter])
993
+ : layer.filter,
994
+ };
995
+ }
996
+ function buildingMapSettings(layer, seen, coverage, ramp, aoFollowsHeight, lit) {
997
+ const out = [];
998
+ // Under lights gl-js ignores the vertical gradient and darkens a wall's foot with its faux AO
999
+ // (fill_extrusion.fragment.glsl): (1 - 0.08 I)(1 - 0.9 I) at the foot, nearly gone ~6 m up at its
1000
+ // 3 m wall radius. The SDK's linear ramp over that height is the closest it draws.
1001
+ const wallAO = layer.paint?.['fill-extrusion-ambient-occlusion-intensity'];
1002
+ if (lit && typeof wallAO === 'number' && wallAO > 0 && !seen.has('building-vertical-gradient')) {
1003
+ seen.add('building-vertical-gradient');
1004
+ out.push(`building-vertical-gradient: ${round(1 - (1 - 0.08 * wallAO) * (1 - 0.9 * wallAO))};`, 'building-vertical-gradient-height: 6;');
1005
+ coverage.emit('building-vertical-gradient');
1006
+ }
1007
+ for (const [from, to] of Object.entries(BUILDING_MAP_SETTINGS)) {
1008
+ // `fill-extrusion-edge-radius` is a LAYOUT property, not a paint one - reading only paint
1009
+ // dropped Standard's 0.4 bevel silently.
1010
+ const raw = layer.paint?.[from] ?? layer.layout?.[from];
1011
+ if (raw === undefined || seen.has(to))
1012
+ continue;
1013
+ const value = to === 'building-ao-ground-radius' ? shiftZoomStops(raw, AO_GROUND_ZOOM_SHIFT)
1014
+ : to === 'building-ao-intensity' ? rampLikeGroundAO(raw, layer)
1015
+ : raw;
1016
+ const translated = to === 'building-ao-ground-attenuation'
1017
+ ? groundAttenuation(value, layer.id, coverage)
1018
+ : typeof value === 'boolean'
1019
+ ? (value ? '1' : '0')
1020
+ : tryTranslate(value, from, layer.id, coverage);
1021
+ if (translated === null)
1022
+ continue;
1023
+ seen.add(to);
1024
+ // --ao-follows-height: the contact shadow belongs to the building standing in it, so it
1025
+ // fades on the SAME ramp that lays the walls down - a flattened city otherwise keeps a
1026
+ // full-strength dark ring around every footprint, the stain the drop exists to remove.
1027
+ // Opt-in, and in the STYLE rather than the renderer: whether the shadow outlives the walls
1028
+ // is the style's call. Both are view-state expressions, so neither costs a re-decode.
1029
+ out.push(to === 'building-ao-intensity'
1030
+ ? `${to}: [param::${AO_PARAM}] * (${translated})${aoFollowsHeight ? ` * (${HEIGHT_TILT_RAMP})` : ''};`
1031
+ : `${to}: ${translated};`);
1032
+ coverage.emit(to);
1033
+ }
1034
+ // A style that states no vertical scale pops its buildings in at full height. Standard grows
1035
+ // them over a third of a zoom level, and the ramp is placed at the extrusion layer's own
1036
+ // minzoom - which is where they first exist to grow.
1037
+ if (ramp && !seen.has('building-height-scale')) {
1038
+ const minZoom = typeof layer.minzoom === 'number' ? layer.minzoom : 15;
1039
+ seen.add('building-height-scale');
1040
+ out.push(`building-height-scale: linear(${zoomInput()}, (${round(minZoom)}, 0), (${round(minZoom + 0.3)}, 1));`);
1041
+ coverage.emit('building-height-scale');
1042
+ }
1043
+ return out;
1044
+ }
1045
+ /**
1046
+ * The building settings a converted MapBox style still needs after its own paint is read: MapBox's
1047
+ * defaults for what it left unstated, and its LIGHTS as the SDK's two building light knobs. The
1048
+ * lights block is MapBox's whole lighting model, and `building-ambient` / `building-light-intensity`
1049
+ * are the same two numbers - the SDK's own 0.35 ambient is what made a white building read grey.
1050
+ */
1051
+ function buildingLightSettings(lights, seen, coverage, fold) {
1052
+ const out = [];
1053
+ // The intensity may be a zoom RAMP, and these are FloatFunctionProperty, so it goes through
1054
+ // whole. Requiring a plain number left Standard's directional light unstated - its 0.2 is a
1055
+ // flat `interpolate(zoom, …)` - and the SDK fell back to 1.0, five times as strong, which is
1056
+ // what made every wall so much darker than the browser draws it.
1057
+ const intensityOf = (type) => {
1058
+ const folded = fold(lights);
1059
+ if (!Array.isArray(folded))
1060
+ return null;
1061
+ const light = folded.find((l) => !!l && typeof l === 'object'
1062
+ && l.type === type);
1063
+ const intensity = light?.properties?.intensity;
1064
+ if (intensity === undefined)
1065
+ return null;
1066
+ if (typeof intensity === 'number')
1067
+ return String(round(intensity));
1068
+ try {
1069
+ return translateExpression(intensity);
1070
+ }
1071
+ catch {
1072
+ return null;
1073
+ }
1074
+ };
1075
+ for (const [type, name] of [['ambient', 'building-ambient'], ['directional', 'building-light-intensity']]) {
1076
+ if (seen.has(name))
1077
+ continue;
1078
+ const intensity = intensityOf(type);
1079
+ if (intensity === null)
1080
+ continue;
1081
+ seen.add(name);
1082
+ out.push(`${name}: ${intensity};`);
1083
+ coverage.emit(name);
1084
+ }
1085
+ // WHERE the sun is, not just how strong. MapBox states it per preset - day is `[180, 20]`,
1086
+ // 20 degrees off the zenith - and without it the app's own sun lit the buildings: at the
1087
+ // demo's default the roofs came out at 86% of their colour where the browser draws them at
1088
+ // ~100%, which reads as "the building colour is wrong" and is not.
1089
+ const direction = (() => {
1090
+ const folded = fold(lights);
1091
+ if (!Array.isArray(folded))
1092
+ return null;
1093
+ const light = folded.find((l) => !!l && typeof l === 'object'
1094
+ && l.type === 'directional');
1095
+ const value = light?.properties?.direction;
1096
+ const list = Array.isArray(value) && value[0] === 'literal' ? value[1] : value;
1097
+ return Array.isArray(list) && list.length === 2 && list.every((v) => typeof v === 'number')
1098
+ ? list : null;
1099
+ })();
1100
+ // The light's COLOUR, which is what actually separates the presets. Their intensities barely
1101
+ // move - dawn 0.75 against day's 0.8 - while the ambient goes from white to a warm cream at
1102
+ // dawn and to hsl(228, 27%, 29%) at dusk, with an orange sun against it. Without these, dawn
1103
+ // rendered as day and dusk as a washed-out day, and a roof kept the tone of the wall beside it
1104
+ // because a white ambient at 0.8 drowns the directional term that separates them.
1105
+ const colourOf = (type) => {
1106
+ const folded = fold(lights);
1107
+ if (!Array.isArray(folded))
1108
+ return null;
1109
+ const light = folded.find((l) => !!l && typeof l === 'object'
1110
+ && l.type === type);
1111
+ const colour = light?.properties?.color;
1112
+ return typeof colour === 'string' ? colour : null;
1113
+ };
1114
+ for (const [type, name] of [['ambient', 'ambient-color'], ['directional', 'sun-color']]) {
1115
+ if (seen.has(name))
1116
+ continue;
1117
+ const colour = colourOf(type);
1118
+ if (colour === null)
1119
+ continue;
1120
+ seen.add(name);
1121
+ out.push(`${name}: ${colour};`);
1122
+ coverage.emit(name);
1123
+ }
1124
+ if (direction && !seen.has('sun-azimuth')) {
1125
+ // MapBox's pair is [azimuthal, polar]: the azimuth runs clockwise from north, as the SDK's
1126
+ // does, and the polar angle is measured from straight UP, so the altitude is its complement.
1127
+ seen.add('sun-azimuth');
1128
+ out.push(`sun-azimuth: ${round(direction[0])};`, `sun-altitude: ${round(90 - direction[1])};`);
1129
+ coverage.emit('sun-azimuth');
1130
+ coverage.emit('sun-altitude');
1131
+ }
1132
+ // gl-js clamps a wall's shading to `mix(0.7, 0.98, 1 - lightIntensity)`
1133
+ // (fill_extrusion.vertex.glsl), so the floor RISES as the directional light weakens: at
1134
+ // Standard's 0.2 it is 0.92, not the 0.7 the constant alone suggests. The SDK's wall factor is
1135
+ // `1 - gradient` at the foot, so this is the same floor expressed its way. Measured: with a
1136
+ // flat 0.3 the foot sat at 0.63 of the roof where the browser keeps it near 0.9.
1137
+ const rawDirectional = (() => {
1138
+ const folded = fold(lights);
1139
+ if (!Array.isArray(folded))
1140
+ return undefined;
1141
+ const light = folded.find((l) => !!l && typeof l === 'object'
1142
+ && l.type === 'directional');
1143
+ return light?.properties?.intensity;
1144
+ })();
1145
+ // A flat ramp is still one number - representativeConstant takes the branch most features get.
1146
+ const reduced = rawDirectional === undefined ? null : representativeConstant(rawDirectional);
1147
+ const constant = typeof reduced === 'number' ? reduced : null;
1148
+ if (!seen.has('building-vertical-gradient') && constant !== null && Number.isFinite(constant)) {
1149
+ const floor = 0.7 + (1 - Math.min(Math.max(constant, 0), 1)) * 0.28;
1150
+ seen.add('building-vertical-gradient');
1151
+ out.push(`building-vertical-gradient: ${round(1 - floor)};`);
1152
+ }
1153
+ for (const [name, value] of Object.entries(BUILDING_MAP_DEFAULTS)) {
1154
+ if (seen.has(name))
1155
+ continue;
1156
+ seen.add(name);
1157
+ out.push(`${name}: ${value};`);
1158
+ }
1159
+ return out;
1160
+ }
1161
+ /**
1162
+ * The style parameter that turns buildings off, flat, or 3D - the same three states, spelled the
1163
+ * same way, as the hand-written styles under `assets/style`: `['param::buildings'>0]` gates the
1164
+ * footprint and `>1` the extrusion. An app then has one knob for every converted style, and can
1165
+ * drop the 3D pass on a device that cannot afford it without editing the CartoCSS.
1166
+ *
1167
+ * It defaults to 3D, so a converted style draws what its source drew until an app says otherwise.
1168
+ */
1169
+ const BUILDINGS_PARAM = 'buildings';
1170
+ const BUILDINGS_3D = 2;
1171
+ /** A source layer of footprints, whatever the schema calls it. */
1172
+ const BUILDING_LAYER = /building/i;
1173
+ function buildingPredicate(symbolizer, sourceLayer, flat) {
1174
+ // The FLAT variant is the one MapBox draws when 3D is off, so it is the middle state and has
1175
+ // to be shut off above it too - without the upper bound both variants drew at `buildings: 2`.
1176
+ if (flat)
1177
+ return `['param::${BUILDINGS_PARAM}'>0]['param::${BUILDINGS_PARAM}'<2]`;
1178
+ if (symbolizer === 'building')
1179
+ return `['param::${BUILDINGS_PARAM}'>1]`;
1180
+ return BUILDING_LAYER.test(sourceLayer) ? `['param::${BUILDINGS_PARAM}'>0]` : null;
1181
+ }
1182
+ /**
1183
+ * Which side of MapBox's 3D switch a layer is on, from its `visibility` alone.
1184
+ *
1185
+ * Standard draws footprints and extrusions as two layers over the same source, each gated on
1186
+ * `["case", ["all", ["config", "show3dBuildings"], ["config", "show3dObjects"]], …]`. Folded with
1187
+ * the default config - 3D on - the flat pair resolved to `"none"` and was dropped outright, so
1188
+ * `buildings: 1` left the map with no buildings at all rather than with their footprints.
1189
+ *
1190
+ * Null for anything whose visibility does not turn on that switch: it is then a plain
1191
+ * visibility and folding it is right.
1192
+ */
1193
+ function buildings3DGate(layer) {
1194
+ const visibility = layer.layout?.visibility;
1195
+ if (!Array.isArray(visibility) || visibility[0] !== 'case' || visibility.length !== 4)
1196
+ return null;
1197
+ const names = new Set();
1198
+ const walk = (node) => {
1199
+ if (!Array.isArray(node))
1200
+ return;
1201
+ if (node[0] === 'config' && typeof node[1] === 'string')
1202
+ names.add(node[1]);
1203
+ node.forEach((item) => walk(item));
1204
+ };
1205
+ walk(visibility[1]);
1206
+ if (!names.has('show3dBuildings') && !names.has('show3dObjects'))
1207
+ return null;
1208
+ if (visibility[2] === 'visible' && visibility[3] === 'none')
1209
+ return '3d';
1210
+ if (visibility[2] === 'none' && visibility[3] === 'visible')
1211
+ return 'flat';
1212
+ return null;
1213
+ }
1214
+ function backgroundProperties(layer, coverage) {
1215
+ const out = [];
1216
+ for (const [name, value] of Object.entries(layer.paint ?? {})) {
1217
+ const target = PROPERTY_MAP.background[name];
1218
+ if (!target) {
1219
+ coverage.drop(name, KNOWN_GAPS[name] ?? 'not mapped', layer.id);
1220
+ continue;
1221
+ }
1222
+ const translated = tryTranslate(value, name, layer.id, coverage);
1223
+ if (translated !== null) {
1224
+ out.push(`${target}: ${translated};`);
1225
+ coverage.emit(target);
1226
+ }
1227
+ }
1228
+ return out;
1229
+ }
1230
+ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerIndex = 0) {
1231
+ const table = PROPERTY_MAP[layer.type] ?? {};
1232
+ const out = [];
1233
+ // A symbol layer's icon is a SECOND symbolizer in the same rule, so marker-* declarations sit
1234
+ // beside the text-* ones rather than replacing them - unless the icon is a road shield, whose
1235
+ // sprite is picked per feature and is drawn as a plate behind the text instead.
1236
+ const isShield = layer.type === 'symbol' && isShieldLayer(layer);
1237
+ const plates = layer.type === 'symbol' && !isShield ? flatPlateShield(layer, options) : null;
1238
+ const iconDeclarations = layer.type === 'model'
1239
+ ? canopyDeclarations(layer, coverage)
1240
+ : layer.type !== 'symbol'
1241
+ ? []
1242
+ : isShield
1243
+ ? plateDeclarations(layer, coverage)
1244
+ : plates
1245
+ ? flatPlateDeclarations(layer, plates, options, coverage)
1246
+ : markerDeclarations(layer, coverage, options);
1247
+ if (symbolizer === 'text') {
1248
+ out.push(`text-placement: '${resolvePlacement(layer, 'text')}';`);
1249
+ coverage.emit('text-placement');
1250
+ }
1251
+ for (const [name, value] of Object.entries({ ...layer.layout, ...layer.paint })) {
1252
+ // A CASING, not a line: `line-gap-width` is the road it runs either side of, and
1253
+ // `line-width` is the casing on ONE side. Both are folded into the width here and the
1254
+ // fill layer above covers the middle, which is how a casing has always been drawn in
1255
+ // CartoCSS. Dropped, the casing drew as a solid band the full width of the road - which
1256
+ // is every road in Mapbox Standard, 28 layers of them, and the reason ours came out as
1257
+ // lavender slabs where the browser draws a white road with a thin edge.
1258
+ if (name === 'visibility') {
1259
+ if (value === 'none')
1260
+ return []; // the whole layer is off
1261
+ continue;
1262
+ }
1263
+ if (HANDLED_ELSEWHERE.has(name))
1264
+ continue;
1265
+ // text-field may be the legacy "{field}" token form rather than an expression.
1266
+ if (name === 'text-field' && typeof value === 'string' && value.includes('{')) {
1267
+ out.push(`text-name: ${expandTokens(value)};`);
1268
+ coverage.emit('text-name');
1269
+ continue;
1270
+ }
1271
+ // A text-field that branches per country (MapBox's shields slice the ref) is worth keeping
1272
+ // as its fallback rather than losing the label: the fallback IS the name for most features.
1273
+ if (name === 'text-field') {
1274
+ const text = tryTranslate(value, name, layer.id, coverage) ?? retryCollapsed(value, layer, coverage);
1275
+ if (text === null)
1276
+ continue;
1277
+ out.push(`text-name: ${text};`);
1278
+ coverage.emit('text-name');
1279
+ continue;
1280
+ }
1281
+ // MapBox names one anchor; CartoCSS splits it into a horizontal and a vertical alignment.
1282
+ // The senses agree - see the default derivation in TextSymbolizer.cpp, where a text pushed
1283
+ // right (dx > 0) defaults to alignment 'left', i.e. the edge nearest the anchor.
1284
+ if (name === 'text-anchor') {
1285
+ const constant = typeof value === 'string' ? value : representativeConstant(value);
1286
+ if (typeof constant !== 'string') {
1287
+ coverage.drop(name, 'anchor branches on something with no constant form', layer.id);
1288
+ continue;
1289
+ }
1290
+ if (constant !== value) {
1291
+ coverage.approximate(`text-anchor on "${layer.id}" branches; took "${constant}"`);
1292
+ }
1293
+ const anchor = TEXT_ANCHOR[constant];
1294
+ if (!anchor) {
1295
+ coverage.drop(name, `unknown anchor "${constant}"`, layer.id);
1296
+ continue;
1297
+ }
1298
+ out.push(`text-horizontal-alignment: '${anchor[0]}';`, `text-vertical-alignment: '${anchor[1]}';`);
1299
+ coverage.emit('text-horizontal-alignment');
1300
+ coverage.emit('text-vertical-alignment');
1301
+ continue;
1302
+ }
1303
+ // Everything MapBox measures in ems of the text size. CartoCSS takes pixels, so each one
1304
+ // is multiplied by text-size - the expression form included, or a zoom-driven size would
1305
+ // silently pin the value to one zoom.
1306
+ if (name === 'text-offset') {
1307
+ const pair = Array.isArray(value) && value.length === 2 && value.every((v) => typeof v === 'number')
1308
+ ? value
1309
+ : representativeConstant(value);
1310
+ if (!Array.isArray(pair) || pair.length !== 2) {
1311
+ coverage.drop(name, 'offset branches on something with no constant form', layer.id);
1312
+ continue;
1313
+ }
1314
+ if (pair !== value) {
1315
+ coverage.approximate(`text-offset on "${layer.id}" branches; took [${pair.join(', ')}]`);
1316
+ }
1317
+ const dx = ems(pair[0], layer, coverage, name);
1318
+ const dy = ems(pair[1], layer, coverage, name);
1319
+ if (dx === null || dy === null)
1320
+ continue;
1321
+ out.push(`text-dx: ${dx};`, `text-dy: ${dy};`);
1322
+ coverage.emit('text-dx');
1323
+ coverage.emit('text-dy');
1324
+ // An offset TRANSLATES the text in MapBox, but with no alignment given the SDK reads a
1325
+ // non-zero dx/dy as the anchor instead (TextSymbolizer::getFormatterOptions), so
1326
+ // MapTiler's text-offset [0, 0.05] hung every road ref off its shield's bottom edge.
1327
+ // Pin the anchor the offset moves - MapBox's default, centre.
1328
+ if (layer.layout?.['text-anchor'] === undefined) {
1329
+ out.push(`text-horizontal-alignment: 'middle';`, `text-vertical-alignment: 'middle';`);
1330
+ coverage.emit('text-horizontal-alignment');
1331
+ coverage.emit('text-vertical-alignment');
1332
+ }
1333
+ continue;
1334
+ }
1335
+ // text-max-width is 10 ems by default; taken as pixels it wrapped every name onto one word
1336
+ // per line. A line-placed label is laid out along the line and MapLibre never wraps it.
1337
+ if (name === 'text-max-width') {
1338
+ if (followsLine(layer)) {
1339
+ out.push('text-wrap-width: 0;');
1340
+ coverage.emit('text-wrap-width');
1341
+ continue;
1342
+ }
1343
+ const width = ems(value, layer, coverage, name);
1344
+ if (width === null)
1345
+ continue;
1346
+ out.push(`text-wrap-width: ${width};`);
1347
+ coverage.emit('text-wrap-width');
1348
+ continue;
1349
+ }
1350
+ // The modern spelling of text-allow-overlap. 'cooperative' has no equivalent and is the
1351
+ // conservative 'never' here. icon-overlap belongs to the marker and is handled with it -
1352
+ // a lone marker-* declaration builds a marker with no file, which draws the default blue
1353
+ // ellipse over every feature of the layer.
1354
+ if (name === 'text-overlap') {
1355
+ out.push(`text-allow-overlap: ${value === 'always'};`);
1356
+ coverage.emit('text-allow-overlap');
1357
+ continue;
1358
+ }
1359
+ // MapBox places the LOWEST sort key first; CartoCSS's culler takes the highest priority.
1360
+ if (name === 'symbol-sort-key')
1361
+ continue; // folded into the layer's priority below
1362
+ if (name === 'line-sort-key')
1363
+ continue; // became this attachment's place in the order - see split.ts
1364
+ // MapBox's text-opacity fades the WHOLE label; CartoCSS's fades only the fill, and the halo
1365
+ // keeps its own. A style that hides a label with `step(zoom, 0, …, 13, 1)` was leaving the
1366
+ // halo at full opacity - a white ghost of the name at every zoom it should not be at.
1367
+ if (name === 'text-opacity') {
1368
+ const translated = tryTranslate(value, name, layer.id, coverage);
1369
+ if (translated === null)
1370
+ continue;
1371
+ out.push(`text-opacity: ${translated};`, `text-halo-opacity: ${translated};`);
1372
+ coverage.emit('text-opacity');
1373
+ coverage.emit('text-halo-opacity');
1374
+ continue;
1375
+ }
1376
+ // MapBox grows a label's collision box by text-padding on every side and tests the grown
1377
+ // boxes against each other, which is what `collision-padding` is. It used to arrive as a
1378
+ // minimum-distance, which is a different thing - that one only separates labels of the same
1379
+ // GROUP (the text hash), so two different road shields were held apart by nothing, and it
1380
+ // doubles as the repeat floor, so a line-placed label could not be given one at all.
1381
+ if ((name === 'text-padding' || name === 'icon-padding')
1382
+ && (symbolizer === 'text' || symbolizer === 'shield')) {
1383
+ const translated = tryTranslate(value, name, layer.id, coverage);
1384
+ if (translated === null)
1385
+ continue;
1386
+ const property = symbolizer === 'shield' ? 'shield-collision-padding' : 'text-collision-padding';
1387
+ if (out.some((declaration) => declaration.startsWith(`${property}:`)))
1388
+ continue; // icon-padding after text-padding
1389
+ out.push(`${property}: (${collisionPad(options)} * ${translated});`);
1390
+ coverage.emit(property);
1391
+ continue;
1392
+ }
1393
+ // A circle's RADIUS is a marker's WIDTH: carried as it was, every circle drew at half its size.
1394
+ if (name === 'circle-radius') {
1395
+ const translated = tryTranslate(value, name, layer.id, coverage);
1396
+ if (translated === null)
1397
+ continue;
1398
+ // and a circle never collides in MapLibre: a marker that did would vanish under a label
1399
+ out.push(`marker-width: (2 * ${translated});`, 'marker-allow-overlap: true;');
1400
+ coverage.emit('marker-width');
1401
+ coverage.emit('marker-allow-overlap');
1402
+ continue;
1403
+ }
1404
+ if (name === 'text-letter-spacing') {
1405
+ const spacing = ems(value, layer, coverage, name);
1406
+ if (spacing === null)
1407
+ continue;
1408
+ out.push(`text-character-spacing: ${spacing};`);
1409
+ coverage.emit('text-character-spacing');
1410
+ continue;
1411
+ }
1412
+ // text-line-height is a TOTAL line height in ems; text-line-spacing is what is added on top
1413
+ // of the font's own, which already is MapBox's 1.2 default.
1414
+ if (name === 'text-line-height' && typeof value === 'number') {
1415
+ const spacing = ems(value - 1.2, layer, coverage, name);
1416
+ if (spacing === null)
1417
+ continue;
1418
+ out.push(`text-line-spacing: ${spacing};`);
1419
+ coverage.emit('text-line-spacing');
1420
+ continue;
1421
+ }
1422
+ // A pattern names a SPRITE, and the raw name reached the decoder as a file name that
1423
+ // never existed (`misc:construction_pattern`): the sheet qualifier only says where to LOOK.
1424
+ if (name === 'fill-pattern' || name === 'line-pattern') {
1425
+ out.push(...patternDeclarations(name, value, table[name], layer, coverage, options));
1426
+ continue;
1427
+ }
1428
+ // MapBox DISABLES fill-color under a fill-pattern and fades the pattern with fill-opacity;
1429
+ // the pattern is its own symbolizer here, with its own fill and opacity, so a polygon-fill
1430
+ // beside it would paint a solid layer under the hatch and polygon-opacity would fade only
1431
+ // that. MapTiler's construction areas are a 0.15 hatch and drew fully saturated.
1432
+ if (layer.paint?.['fill-pattern'] !== undefined && (name === 'fill-color' || name === 'fill-opacity')) {
1433
+ if (name === 'fill-color') {
1434
+ coverage.drop(name, 'MapBox disables fill-color under a fill-pattern', layer.id);
1435
+ continue;
1436
+ }
1437
+ emitTranslated(out, coverage, layer, 'fill-opacity', 'polygon-pattern-opacity', undefined, false);
1438
+ continue;
1439
+ }
1440
+ // MapBox dash lengths are multiples of the line width; CartoCSS's are pixels.
1441
+ if (name === 'line-dasharray') {
1442
+ const dash = dashPattern(value);
1443
+ if (dash === null) {
1444
+ coverage.drop(name, 'no literal dash pattern to take', layer.id);
1445
+ continue;
1446
+ }
1447
+ const { pattern, zoom } = dash;
1448
+ // `[1, 0]` is MapBox's spelling of SOLID. Scaled by a line width it became a 430 px
1449
+ // "dash", which is a 430 px bitmap rasterized to draw an unbroken line.
1450
+ if (!pattern.some((v, i) => i % 2 === 1 && v > 0)) {
1451
+ coverage.drop(name, 'the pattern has no gap, so the line is solid', layer.id);
1452
+ continue;
1453
+ }
1454
+ if (pattern !== value) {
1455
+ // MapTiler ramps its path dashes over zoom and CartoCSS takes ONE pattern. The
1456
+ // base (the widest band, and every stop below the first) is what is on screen at
1457
+ // nearly every zoom; taking nothing left every footway drawn solid.
1458
+ coverage.approximate(`line-dasharray taken at one stop, ${pattern.join(',')}: ` +
1459
+ 'CartoCSS takes one dash pattern, not a ramp');
1460
+ }
1461
+ const width = dashWidth(layer.paint?.['line-width']);
1462
+ // At the zoom the pattern is CHOSEN at, not the mean of the width's stops: Standard's
1463
+ // steps ramp to 80 px by z22, so the mean is 43 and its 0.2 dash came out at 8.6 px
1464
+ // where gl-js draws under 2 - coarse bands instead of fine treads.
1465
+ // A banded attachment reads the width in the middle of its OWN band; see splitDashByZoom.
1466
+ const banded = layer.dashZoom === undefined ? null : rampAt(width, layer.dashZoom);
1467
+ const scale = banded ?? (zoom === null ? null : rampAt(width, zoom))
1468
+ ?? representativeScale(width, 1);
1469
+ if (typeof width !== 'number' && banded === null) {
1470
+ coverage.approximate(`line-dasharray scaled by ${round(scale)}, a zoom-driven ` +
1471
+ 'line-width read at one zoom: CartoCSS takes one dash pattern, not a ramp');
1472
+ }
1473
+ out.push(`line-dasharray: ${pattern.map((v) => round(v * scale)).join(',')};`);
1474
+ coverage.emit('line-dasharray');
1475
+ continue;
1476
+ }
1477
+ // A fill's outline is a second symbolizer on the same rule, not a polygon property.
1478
+ if (name === 'fill-outline-color') {
1479
+ const translated = tryTranslate(value, name, layer.id, coverage);
1480
+ if (translated !== null) {
1481
+ out.push(`line-color: ${translated};`, `line-width: ${FILL_OUTLINE_WIDTH};`);
1482
+ coverage.emit('line-color');
1483
+ coverage.approximate(`fill-outline-color drawn as a line of width ${FILL_OUTLINE_WIDTH}: MapBox's is a ` +
1484
+ '1-DEVICE-pixel hairline (gl.LINES) and a CartoCSS width scales with the display, ' +
1485
+ 'so no constant is right at every dpi');
1486
+ }
1487
+ continue;
1488
+ }
1489
+ // Placed with the icon instead (variableAnchorDeclarations): each says where the text goes
1490
+ // relative to one, so on a layer with no icon there is nothing for them to describe.
1491
+ if (VARIABLE_ANCHOR_LAYOUT.has(name)) {
1492
+ if (layer.layout?.['icon-image'] === undefined) {
1493
+ coverage.drop(name, 'positions the text against an icon, and this layer has none', layer.id);
1494
+ }
1495
+ continue;
1496
+ }
1497
+ // One occlusion opacity per LABEL: it is a TextSymbolizer property here, so a symbol's
1498
+ // icon and its text share it. MapBox states the pair together where it states both
1499
+ // (Standard's natural-point-label sets 0 twice), so the icon's is taken only where the
1500
+ // text states none - and an icon-only layer is a marker, which is not a label.
1501
+ //
1502
+ // Only a STATED value is carried, which is MapBox's own meaning: absent is "occluded by
1503
+ // the terrain alone", the SDK's default, and 0 is "occluded by 3D content too". That is
1504
+ // what makes Standard's road and water names go behind a building while its POI labels
1505
+ // stay drawn - and what stops every label paying for the occlusion pass.
1506
+ if (name === 'icon-occlusion-opacity') {
1507
+ if (layer.paint?.['text-occlusion-opacity'] !== undefined) {
1508
+ coverage.drop(name, 'the label carries one occlusion opacity, taken from text-occlusion-opacity', layer.id);
1509
+ continue;
1510
+ }
1511
+ if (layer.layout?.['text-field'] === undefined) {
1512
+ coverage.drop(name, 'an icon-only layer draws a marker, which is not a label', layer.id);
1513
+ continue;
1514
+ }
1515
+ const translated = tryTranslate(value, name, layer.id, coverage);
1516
+ if (translated === null)
1517
+ continue;
1518
+ out.push(`text-occlusion-opacity: ${translated};`);
1519
+ coverage.emit('text-occlusion-opacity');
1520
+ continue;
1521
+ }
1522
+ const target = table[name];
1523
+ if (!target) {
1524
+ // An extrusion's LOOK is a Map setting here, and buildingMapSettings has already taken
1525
+ // it (see BUILDING_MAP_SETTINGS). Reporting it dropped as well says a bevel or an
1526
+ // ambient occlusion was thrown away when it is in the Map block.
1527
+ if (!(symbolizer === 'building' && name in BUILDING_MAP_SETTINGS)) {
1528
+ coverage.drop(name, KNOWN_GAPS[name] ?? 'not mapped', layer.id);
1529
+ }
1530
+ continue;
1531
+ }
1532
+ if (!allowed.has(target)) {
1533
+ // The table above claims a property the decoder does not bind - a bug here, not in the
1534
+ // source style. Say so rather than blaming the style.
1535
+ coverage.drop(name, `maps to "${target}", absent from the generated allowlist`, layer.id);
1536
+ continue;
1537
+ }
1538
+ // A per-category palette belongs in project.json, not baked into the rule.
1539
+ const paramTable = fieldParamTable(value, layer, name, target, coverage, options);
1540
+ if (paramTable !== null) {
1541
+ out.push(`${target}: ${paramTable};`);
1542
+ coverage.emit(target);
1543
+ continue;
1544
+ }
1545
+ const translated = tryTranslate(value, name, layer.id, coverage);
1546
+ if (translated === null)
1547
+ continue;
1548
+ out.push(`${target}: ${remapValue(target, translated)};`);
1549
+ coverage.emit(target);
1550
+ }
1551
+ if (symbolizer === 'text') {
1552
+ const priority = placementPriority(layer, layerIndex, coverage);
1553
+ if (priority !== null) {
1554
+ out.push(`text-placement-priority: ${priority};`);
1555
+ coverage.emit('text-placement-priority');
1556
+ }
1557
+ }
1558
+ // text-padding is 2 px on every MapBox layer that states nothing, and the culler's own floor is
1559
+ // one label unit. A line-placed label gets it too now: it pads the collision box, where the old
1560
+ // minimum-distance would have disabled the repeat floor the decoder needs.
1561
+ const padded = symbolizer === 'shield' ? 'shield-collision-padding' : 'text-collision-padding';
1562
+ if ((symbolizer === 'text' || symbolizer === 'shield') && !out.some((d) => d.startsWith(`${padded}:`))) {
1563
+ out.push(`${padded}: ${collisionPad(options) * DEFAULT_TEXT_PADDING};`);
1564
+ coverage.emit(padded);
1565
+ }
1566
+ // MapBox repeats a line label every 250 px whether or not the layer says so; CartoCSS's spacing
1567
+ // defaults to 0, which is ONE label for the whole line - so a long road got its name once and a
1568
+ // contour ring got it once, where MapTiler writes it the length of both.
1569
+ if (symbolizer === 'text' && repeatsAlongLine(layer) && !out.some((d) => d.startsWith('text-spacing:'))) {
1570
+ out.push(`text-spacing: ${DEFAULT_SYMBOL_SPACING};`);
1571
+ coverage.emit('text-spacing');
1572
+ }
1573
+ // MapBox's spacing is a SCREEN distance it keeps over the whole line; the decoder walks it in
1574
+ // tile units and restarts per FEATURE, so a road cut into many short ways got a shield on each
1575
+ // one - six "D 41" where MapTiler draws two. The culler is what measures across tiles and
1576
+ // features, so the same distance is handed to it as the floor between two labels of the group.
1577
+ const spacingDecl = out.find((d) => d.startsWith('text-spacing:'));
1578
+ if (symbolizer === 'text' && spacingDecl && !out.some((d) => d.startsWith('text-min-distance:'))) {
1579
+ out.push(`text-min-distance: ${spacingDecl.slice('text-spacing:'.length).trim().replace(/;$/, '')};`);
1580
+ coverage.emit('text-min-distance');
1581
+ }
1582
+ // MapBox wraps at 10 ems whether or not the layer says so; CartoCSS's wrap-width defaults to 0,
1583
+ // which is no wrapping at all - so an unstated max-width has to be written out.
1584
+ if (symbolizer === 'text' && !followsLine(layer) && !out.some((d) => d.startsWith('text-wrap-width:'))) {
1585
+ const width = ems(DEFAULT_TEXT_MAX_WIDTH, layer, coverage, 'text-max-width');
1586
+ if (width !== null) {
1587
+ out.push(`text-wrap-width: ${width};`);
1588
+ coverage.emit('text-wrap-width');
1589
+ }
1590
+ }
1591
+ // Wrap BEFORE the word that would not fit, which is what MapBox does and what `wrap-width`
1592
+ // means. CartoCSS defaults the other way and breaks AFTER it: `splitLines` measures the word it
1593
+ // has just finished, so a line overshoots by one word and - because the test only runs at a
1594
+ // wrap character - the LAST word never moves at all. "10TH ARRONDISSEMENT" and
1595
+ // "Strasbourg - Saint-Denis" stayed on one line where mapbox-gl puts them on two.
1596
+ const wrapDecl = out.find((d) => d.startsWith('text-wrap-width:'));
1597
+ if (wrapDecl && !/text-wrap-width:\s*0\s*;/.test(wrapDecl)) {
1598
+ out.push('text-wrap-before: true;');
1599
+ coverage.emit('text-wrap-before');
1600
+ }
1601
+ // text-name is what makes a text symbolizer exist at all; without it every other text property
1602
+ // is dropped with the rule - but an icon-only layer still has its marker to draw.
1603
+ if (symbolizer === 'text' && !out.some((d) => d.startsWith('text-name:'))) {
1604
+ // An icon stands on its own; a plate is a background FOR text and would be a floating box.
1605
+ if (isShield) {
1606
+ coverage.drop(`layer "${layer.id}"`, 'shield with no usable text-field, so no plate either', layer.id);
1607
+ return [];
1608
+ }
1609
+ if (out.length > 0 && iconDeclarations.length === 0) {
1610
+ coverage.drop(`layer "${layer.id}"`, 'symbol layer with neither text-field nor a usable icon', layer.id);
1611
+ }
1612
+ return iconDeclarations;
1613
+ }
1614
+ // An icon AND text is ONE symbol in MapBox. Emitted as a marker beside a text label they are
1615
+ // two labels that collide, and the marker wins - a city dot with no name beside it. The shield
1616
+ // is the one-label construct, so the whole rule is renamed into it.
1617
+ // Any shield-* declaration: a sprite (shield-file) or a font icon (shield-icon-name).
1618
+ if (iconDeclarations.some((d) => d.startsWith('shield-'))) {
1619
+ const renamed = out.map(asShieldDeclaration).filter((d) => d !== null);
1620
+ return [...renamed, ...iconDeclarations];
1621
+ }
1622
+ return [...out, ...iconDeclarations];
1623
+ }
1624
+ /**
1625
+ * The disc under a recolourable icon, as the shield's icon PLATE.
1626
+ *
1627
+ * MapBox draws these as ONE image whose `params` colour a disc, its ring and the glyph, and the
1628
+ * sheet bakes only the icon's own defaults into its flat render. The glyph is the distance field
1629
+ * above - extractIconPlate cropped it to the disc's own box - so the plate needs no padding and its
1630
+ * border falls exactly where the artwork's ring was. All three colours are style properties here,
1631
+ * so they are evaluated per feature, which is what gets a POI its class colour back.
1632
+ */
1633
+ function iconPlateDeclarations(layer, icon, sized, coverage, options) {
1634
+ const params = layer.layout?.[ICON_PARAMS];
1635
+ if (!icon.plate || !params)
1636
+ return [];
1637
+ const out = [];
1638
+ // Where the plate covers only some features, its colours are transparent for the others - a
1639
+ // plate with no fill and no border draws nothing (TileLabel::Style::Plate::draws).
1640
+ const scoped = (value) => (icon.plateWhen ? `(${icon.plateWhen} ? ${value} : transparent)` : value);
1641
+ // Radius and ring are the artwork's pixels at icon-size 1, the plate's are the screen's: at
1642
+ // icon-size 0.4 a ring stated 3 drew 3 px where MapLibre draws 1.2, and the disc grew with it.
1643
+ const sizedBy = (value) => (sized === '1' ? value : `((${value}) * (${sized}))`);
1644
+ const colour = (name, target, gate = false, size = false) => {
1645
+ if (params[name] === undefined)
1646
+ return false;
1647
+ // `"massif:params": ["icon-image"]` puts the icon's own palette in project.json too, so the
1648
+ // disc, its ring and the glyph are tuned in the same place as the label's colour.
1649
+ const translated = fieldParamTable(params[name], layer, 'icon-image', target, coverage, options)
1650
+ ?? tryTranslate(params[name], `icon-image params.${name}`, layer.id, coverage);
1651
+ if (translated === null)
1652
+ return false;
1653
+ const value = size ? sizedBy(translated) : translated;
1654
+ out.push(`${target}: ${gate ? scoped(value) : value};`);
1655
+ coverage.emit(target);
1656
+ return true;
1657
+ };
1658
+ // The glyph, unless the layer states an icon-color of its own - that one is already out.
1659
+ if (layer.paint?.['icon-color'] === undefined)
1660
+ colour('icon', 'shield-icon-fill');
1661
+ colour('background', 'shield-icon-background-fill', true);
1662
+ // A shape the STYLE states wins over the one measured off the drawing. Radius alone spells every
1663
+ // plate there is - half the box is a circle, a few pixels a rounded square, 0 a rectangle - so a
1664
+ // transit badge needs no artwork of its own, and one sheet of discs covers the lot. Measured
1665
+ // otherwise, which is what a Standard sheet of real roundels wants.
1666
+ if (!colour('radius', 'shield-icon-background-radius', false, true)) {
1667
+ out.push(`shield-icon-background-radius: ${sizedBy(String(round(icon.plate.radius)))};`);
1668
+ coverage.emit('shield-icon-background-radius');
1669
+ }
1670
+ // Both paddings default to a text plate's, which would grow the disc off its own artwork.
1671
+ out.push('shield-icon-background-padding-x: 0;', 'shield-icon-background-padding-y: 0;');
1672
+ coverage.emit('shield-icon-background-padding-x');
1673
+ coverage.emit('shield-icon-background-padding-y');
1674
+ // Only with a colour to draw it in. `icon-background-border-fill` defaults to BLACK, and
1675
+ // LabelPlateStyle::hasBorder is colour-and-width, so a width on its own drew an opaque black
1676
+ // ring round the artwork - a peak's mountain glyph in a black circle, since Standard's
1677
+ // natural_point_label states no `background-stroke` at all.
1678
+ if (icon.plate.borderWidth > 0 && colour('background-stroke', 'shield-icon-background-border-fill', true)) {
1679
+ // Stated wins over measured, as `radius` does: the ring is measured off the artwork, and one
1680
+ // sheet of identical discs therefore rings a small badge as heavily as a full circle.
1681
+ if (!colour('background-stroke-width', 'shield-icon-background-border-width', false, true)) {
1682
+ out.push(`shield-icon-background-border-width: ${sizedBy(String(round(icon.plate.borderWidth)))};`);
1683
+ coverage.emit('shield-icon-background-border-width');
1684
+ }
1685
+ }
1686
+ // MapBox's `icon-stroke` is the outline it draws UNDER the glyph, which is exactly what the
1687
+ // SDK grows from a distance field - so it is the icon HALO, not the plate's border (that one is
1688
+ // the disc's ring). Standard sets it transparent while its POI background is a circle, so this
1689
+ // is what a style asking for `backgroundPointOfInterestLabels: none` gets: a coloured glyph
1690
+ // with a white outline and no disc at all.
1691
+ if (params['icon-stroke'] !== undefined && icon.plate.strokeWidth > 0) {
1692
+ colour('icon-stroke', 'shield-icon-halo-fill');
1693
+ out.push(`shield-icon-halo-radius: ${round(icon.plate.strokeWidth)};`);
1694
+ coverage.emit('shield-icon-halo-radius');
1695
+ }
1696
+ return out;
1697
+ }
1698
+ /**
1699
+ * A road shield as the plate CartoCSS draws behind a label: the sprite is tinted by icon-color and
1700
+ * outlined by its halo, and `text-background-*` is exactly that without needing the image. See
1701
+ * shield.ts for what makes a layer one.
1702
+ */
1703
+ function plateDeclarations(layer, coverage) {
1704
+ const out = [];
1705
+ for (const [from, to] of PLATE_MAP) {
1706
+ emitTranslated(out, coverage, layer, from, to, undefined, false);
1707
+ }
1708
+ out.push(plateRadius());
1709
+ coverage.emit('text-background-radius');
1710
+ coverage.note(`"${layer.id}": shield sprite drawn as a text background plate, so the ` +
1711
+ 'country-specific artwork is lost but the ref stays readable');
1712
+ return out;
1713
+ }
1714
+ /** MapBox's own default when a layer states no icon-text-fit-padding. */
1715
+ const DEFAULT_FIT_PADDING = [0, 0, 0, 0];
1716
+ /**
1717
+ * Rebuild an icon-image expression with every image NAME replaced by whatever `of` returns for it.
1718
+ * Null when a name could be built rather than chosen - a `concat` of a field - because then the set
1719
+ * of images the layer can draw is not knowable here.
1720
+ */
1721
+ function mapImageNames(image, of) {
1722
+ if (typeof image === 'string')
1723
+ return of(image);
1724
+ if (!Array.isArray(image))
1725
+ return null;
1726
+ const head = image[0];
1727
+ const rebuild = (indices) => {
1728
+ const out = [...image];
1729
+ for (const i of indices) {
1730
+ const mapped = mapImageNames(image[i], of);
1731
+ if (mapped === null)
1732
+ return null;
1733
+ out[i] = mapped;
1734
+ }
1735
+ return out;
1736
+ };
1737
+ if (head === 'image' || head === 'to-string')
1738
+ return mapImageNames(image[1], of);
1739
+ if (head === 'coalesce')
1740
+ return rebuild(image.map((_, i) => i).slice(1));
1741
+ if (head === 'case' && image.length >= 4 && image.length % 2 === 0) {
1742
+ const branches = [];
1743
+ for (let i = 2; i < image.length; i += 2)
1744
+ branches.push(i);
1745
+ return rebuild([...branches, image.length - 1]);
1746
+ }
1747
+ if (head === 'match' && image.length >= 5 && image.length % 2 === 1) {
1748
+ const branches = [];
1749
+ for (let i = 3; i < image.length; i += 2)
1750
+ branches.push(i);
1751
+ return rebuild([...branches, image.length - 1]);
1752
+ }
1753
+ return null;
1754
+ }
1755
+ /**
1756
+ * The plate's colour per feature, as the ternary that picked its image - with the leaf image name
1757
+ * replaced by the colour read off that artwork.
1758
+ *
1759
+ * A branch whose CONDITION has no CartoCSS form is skipped rather than fatal, the same way the
1760
+ * sprite path treats one: MapTiler picks a shield on `slice(ref, 2, 3)` in places, and dropping the
1761
+ * whole expression for it would cost every road its plate where dropping the branch costs one
1762
+ * country its colour.
1763
+ */
1764
+ function plateTernary(image, colourOf, onSkip) {
1765
+ const leaf = (node) => {
1766
+ if (typeof node === 'string')
1767
+ return colourOf(node);
1768
+ if (Array.isArray(node) && node.length === 2
1769
+ && (node[0] === 'image' || node[0] === 'string' || node[0] === 'to-string')) {
1770
+ return leaf(node[1]);
1771
+ }
1772
+ return null;
1773
+ };
1774
+ const build = (node) => {
1775
+ const direct = leaf(node);
1776
+ if (direct !== null)
1777
+ return direct;
1778
+ if (!Array.isArray(node))
1779
+ return null;
1780
+ const pairs = [];
1781
+ if (node[0] === 'case' && node.length >= 4 && node.length % 2 === 0) {
1782
+ for (let i = 1; i < node.length - 1; i += 2)
1783
+ pairs.push([node[i], node[i + 1]]);
1784
+ }
1785
+ else if (node[0] === 'match' && node.length >= 5 && node.length % 2 === 1) {
1786
+ const input = node[1];
1787
+ for (let i = 2; i < node.length - 1; i += 2) {
1788
+ const labels = Array.isArray(node[i]) ? node[i] : [node[i]];
1789
+ const test = labels.length === 1
1790
+ ? ['==', input, labels[0]]
1791
+ : ['any', ...labels.map((l) => ['==', input, l])];
1792
+ pairs.push([test, node[i + 1]]);
1793
+ }
1794
+ }
1795
+ else {
1796
+ return null;
1797
+ }
1798
+ let expr = build(node[node.length - 1]);
1799
+ if (expr === null)
1800
+ return null;
1801
+ for (const [condition, value] of [...pairs].reverse()) {
1802
+ const colour = build(value);
1803
+ let test = null;
1804
+ try {
1805
+ test = colour === null ? null : translateExpression(condition);
1806
+ }
1807
+ catch {
1808
+ test = null;
1809
+ }
1810
+ if (test === null) {
1811
+ onSkip();
1812
+ continue;
1813
+ }
1814
+ expr = `((${test}) ? ${colour} : ${expr})`;
1815
+ }
1816
+ return expr;
1817
+ };
1818
+ return build(image);
1819
+ }
1820
+ /**
1821
+ * A shield whose every image is a flat plate - see describeFlatPlate. The SDK draws that without a
1822
+ * bitmap at all, so the artwork is read for its colours and thrown away; only a shield whose
1823
+ * outline carries meaning (a US interstate) keeps its sprite.
1824
+ */
1825
+ function flatPlateShield(layer, options) {
1826
+ const layout = layer.layout ?? {};
1827
+ const fit = layout['icon-text-fit'];
1828
+ if (!options.sprites || layout['text-field'] === undefined)
1829
+ return null;
1830
+ // The image has to BE the text's background, not something beside it.
1831
+ if (fit === undefined || fit === 'none')
1832
+ return null;
1833
+ const plates = [];
1834
+ const image = layout['icon-image'];
1835
+ const mapped = mapImageNames(image, (name) => {
1836
+ const plate = describeFlatPlate(options.sprites.sheets, name);
1837
+ if (!plate)
1838
+ return null;
1839
+ plates.push(plate);
1840
+ return name;
1841
+ });
1842
+ if (mapped === null || plates.length === 0)
1843
+ return null;
1844
+ // A shape this builder cannot write out keeps its sprite rather than losing its colour.
1845
+ return plateTernary(image, () => '#000000', () => { }) === null ? null : plates;
1846
+ }
1847
+ /**
1848
+ * A flat plate as the label's own background: the colours come from the artwork, per feature,
1849
+ * through the same expression that picked the image.
1850
+ */
1851
+ function flatPlateDeclarations(layer, plates, options, coverage) {
1852
+ const sheets = options.sprites.sheets;
1853
+ const image = (layer.layout ?? {})['icon-image'];
1854
+ const colourOf = (pick) => plateTernary(image, (name) => pick(describeFlatPlate(sheets, name)), () => {
1855
+ coverage.approximate(`one plate branch on "${layer.id}" has no CartoCSS form and is ` +
1856
+ 'skipped: those features take the next branch that matches');
1857
+ });
1858
+ const out = [`text-background-fill: ${colourOf((p) => p.fill)};`];
1859
+ coverage.emit('text-background-fill');
1860
+ if (plates.some((p) => p.borderWidth > 0)) {
1861
+ out.push(`text-background-border-fill: ${colourOf((p) => p.border)};`);
1862
+ out.push(`text-background-border-width: ${round(Math.max(...plates.map((p) => p.borderWidth)))};`);
1863
+ coverage.emit('text-background-border-fill');
1864
+ coverage.emit('text-background-border-width');
1865
+ }
1866
+ out.push(`text-background-radius: ${round(Math.max(...plates.map((p) => p.radius)))};`);
1867
+ coverage.emit('text-background-radius');
1868
+ // MapBox grows the image to the text and pads by icon-text-fit-padding; the plate is grown by
1869
+ // the decoder, so the same numbers become its padding. [top, right, bottom, left].
1870
+ const fit = ((layer.layout ?? {})['icon-text-fit-padding'] ?? DEFAULT_FIT_PADDING);
1871
+ out.push(`text-background-padding-x: ${round((fit[1] + fit[3]) / 2)};`);
1872
+ out.push(`text-background-padding-y: ${round((fit[0] + fit[2]) / 2)};`);
1873
+ coverage.emit('text-background-padding-x');
1874
+ coverage.emit('text-background-padding-y');
1875
+ coverage.emit('icon-text-fit');
1876
+ coverage.emit('icon-text-fit-padding');
1877
+ coverage.emit('icon-image');
1878
+ coverage.note(`"${layer.id}": every image is a flat plate, so the colours are read off the ` +
1879
+ 'artwork and the label draws its own background - no sprite ships');
1880
+ return out;
1881
+ }
1882
+ /**
1883
+ * The image half of a shield. ShieldSymbolizer draws the bitmap at its own size - there is no
1884
+ * equivalent of marker-width - so icon-size cannot be carried, and unlock-image is what lets the
1885
+ * text move off the icon (a POI name sits below its pin).
1886
+ */
1887
+ function shieldImageDeclarations(layer, icon, scale, coverage, options) {
1888
+ // shield-placement comes from the renamed text-placement - one label, one placement.
1889
+ // A per-feature name arrives as a whole expression (see dynamicIconDeclarations); a constant
1890
+ // one is just a path and takes the url() around it here.
1891
+ const fileValue = icon.file.startsWith('@@EXPR@@')
1892
+ ? icon.file.slice('@@EXPR@@'.length) : `url('${icon.file}')`;
1893
+ const out = [`shield-file: ${fileValue};`, 'shield-unlock-image: true;'];
1894
+ coverage.emit('shield-file');
1895
+ coverage.emit('shield-unlock-image');
1896
+ // icon-size is a zoom ramp of its own - mapbox animates an icon independently of its name - and
1897
+ // shield-image-scale takes a function, so the ramp goes through whole rather than collapsed to
1898
+ // a mean. Divided by the sheet's pixelRatio: a 2x sheet is drawn at half scale to come out the
1899
+ // size the style asked for.
1900
+ const sized = translateExpression(layer.layout?.['icon-size'] ?? 1);
1901
+ // The divisor is spelled as a float on purpose: `/` between two INTEGERS truncates
1902
+ // (Expression.cpp's DivOperator), so a default icon-size of 1 over a 2x sheet came out `(1) / 2`
1903
+ // = 0 and every icon of thirteen POI layers drew at zero size.
1904
+ const drawScale = `((${sized}) / ${icon.pixelRatio.toFixed(1)})`;
1905
+ out.push(`shield-image-scale: ${drawScale};`);
1906
+ coverage.emit('shield-image-scale');
1907
+ // A distance field stays crisp at any size and takes its colour from the style, so an SDF
1908
+ // sprite goes in as one rather than being resolved to pixels and tinted here.
1909
+ //
1910
+ // Only a sprite the SHEET calls a field. `shield-sdf` makes the renderer read the red channel
1911
+ // as signed distance (GlyphMode::SDF), so a colour bitmap handed to it comes out as whatever
1912
+ // its red channel happened to cross the edge on: Standard's transit icons are a blue disc with
1913
+ // a white glyph, and they drew as the glyph alone with the disc gone - the colours inverted.
1914
+ if (icon.sdf) {
1915
+ // Per FEATURE where the plate only covers some of them: a distance field for those, the
1916
+ // sheet's own artwork for the rest. `sdf` is a BoolProperty read with an expression
1917
+ // context, so the one rule can say both - which is what keeps Paris's own metro roundel
1918
+ // (white disc, blue ring, blue M) instead of recolouring it as a generic transit square.
1919
+ out.push(`shield-sdf: ${icon.plateWhen ?? 'true'};`);
1920
+ coverage.emit('shield-sdf');
1921
+ emitTranslated(out, coverage, layer, 'icon-color', 'shield-icon-fill', undefined, false);
1922
+ emitTranslated(out, coverage, layer, 'icon-opacity', 'shield-icon-opacity', undefined, false);
1923
+ // The icon's own outline. It is what draws the dark ring round a white city dot and the
1924
+ // white one round a POI glyph, and it is NOT the text halo - the two are separate in
1925
+ // mapbox and now separate here.
1926
+ emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
1927
+ emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false);
1928
+ out.push(...iconPlateDeclarations(layer, icon, sized, coverage, options));
1929
+ }
1930
+ else if (layer.layout?.[RECOLOURABLE_ICON] === true) {
1931
+ // A recolourable sprite whose artwork is NOT a disc with a glyph on it (extractIconPlate
1932
+ // took it apart where it is): the sheet ships one flat render with the icon's own default
1933
+ // params and nothing here can tint it per feature.
1934
+ coverage.approximate(`"${layer.id}" recolours its icon per feature; drawn with the ` +
1935
+ "sprite sheet's own colours, so a per-class tint is lost");
1936
+ }
1937
+ out.push(...variableAnchorDeclarations(layer, coverage, options));
1938
+ // Locked to the image, the text is centred ON it. MapBox anchors the TEXT and leaves the icon
1939
+ // on the point, so the image is moved clear by half its height instead - a city name sits above
1940
+ // its dot, a POI name below its pin, and neither is drawn over the other.
1941
+ //
1942
+ // An ANCHORED shield needs none of it: the culler places the text against the icon's own edge
1943
+ // and mirrors dx/dy as the gap, so moving the image too would double the separation.
1944
+ const clearance = out.some((d) => d.startsWith('shield-anchors:'))
1945
+ ? 0 : iconClearance(layer, icon, scale);
1946
+ if (clearance !== 0) {
1947
+ out.push(`shield-dy: ${clearance};`);
1948
+ coverage.emit('shield-dy');
1949
+ }
1950
+ return out;
1951
+ }
1952
+ /** Every literal dash pattern inside a value, in the order MapBox states them. */
1953
+ function dashPatterns(value) {
1954
+ if (Array.isArray(value) && value.length > 0 && value.every((v) => typeof v === 'number')) {
1955
+ return [value];
1956
+ }
1957
+ if (Array.isArray(value)) {
1958
+ return value.flatMap((part) => dashPatterns(part));
1959
+ }
1960
+ if (value && typeof value === 'object') {
1961
+ const stops = value.stops;
1962
+ if (Array.isArray(stops))
1963
+ return stops.flatMap((s) => dashPatterns(s[1]));
1964
+ }
1965
+ return [];
1966
+ }
1967
+ /**
1968
+ * The one dash pattern to draw, out of whatever MapBox states - CartoCSS takes a pattern, not a
1969
+ * ramp. The LAST one that actually dashes, with the zoom it starts at: MapTiler's disputed border
1970
+ * ramps from `[1, 0]` (a SOLID line, below z5) to `[3, 2, 0.1, 2]`, and taking the base there would
1971
+ * draw it solid at every zoom anyone looks at. Where several stops dash - Standard's steps go
1972
+ * `[0.2, 0.2]` at z17 then `[0.1, 0.1]` at z19 - the last is the one whose line is widest, and a
1973
+ * dash is a multiple of that width, so it is also the one that lands closest across the range.
1974
+ * Null when no literal pattern is reachable at all.
1975
+ */
1976
+ function dashPattern(value) {
1977
+ const patterns = dashPatterns(value);
1978
+ if (!patterns.length)
1979
+ return null;
1980
+ const dashes = (p) => p.length > 1 && p.some((v, i) => i % 2 === 1 && v > 0);
1981
+ const dashing = patterns.filter(dashes);
1982
+ const pattern = dashing.length ? dashing[dashing.length - 1] : patterns[0];
1983
+ return { pattern, zoom: stopZoomOf(value, pattern) };
1984
+ }
1985
+ /**
1986
+ * A dashed line whose WIDTH ramps over zoom, as one attachment per zoom band.
1987
+ *
1988
+ * A MapBox dash length is a multiple of the line width, and CartoCSS takes ONE pattern of PIXELS per
1989
+ * rule - the decoder rasterises it into a bitmap keyed by the literal string, so it cannot be a
1990
+ * function of anything. A single rule is therefore only right at one zoom: Liberty's rail hatching
1991
+ * ramps its width 3 -> 8 between z15 and z20, and the one scale we could pick drew the dash 1.8x too
1992
+ * long at the bottom of that range and 0.7x too short at the top.
1993
+ *
1994
+ * Banded, each attachment scales its dash by the width in the MIDDLE of its own band, and the bands
1995
+ * are cut where the width DOUBLES - so the worst error inside one is a factor of sqrt(2) instead of
1996
+ * the whole ramp. The outer edges keep the layer's own zoom range, so nothing stops being drawn.
1997
+ *
1998
+ * It lives here rather than in split.ts because it needs `dashPattern` and `rampAt`, and moving
1999
+ * those would cost an import cycle for one caller.
2000
+ */
2001
+ function splitDashByZoom(layer, coverage) {
2002
+ const dash = layer.paint?.['line-dasharray'];
2003
+ const width = dashWidth(layer.paint?.['line-width']);
2004
+ if (dash === undefined || layer.dashZoom !== undefined || !Array.isArray(width))
2005
+ return [layer];
2006
+ const pattern = dashPattern(dash);
2007
+ // A dash the style RAMPS states the zoom its pattern begins at, and reading the width there is
2008
+ // already the targeted answer - Standard's treads depend on it. Banding is for the plain
2009
+ // literal dash, which has no zoom of its own to be read at.
2010
+ if (pattern === null || pattern.zoom !== null)
2011
+ return [layer];
2012
+ // From the first stop that is actually DRAWN, not the first stop: a ramp starting at width 0 -
2013
+ // Liberty's rail hatchings start (14.5, 0) - has nothing to keep the dash in proportion to
2014
+ // below it. And no further than the last stop, above which the width is flat and a band would
2015
+ // read the same number twice.
2016
+ const stops = rampStops(width);
2017
+ const lo = Math.floor(Math.max(layer.minzoom ?? 0, stops.find(([, w]) => w > 0)?.[0] ?? 0));
2018
+ const hi = Math.ceil(Math.min(layer.maxzoom ?? 24, stops[stops.length - 1]?.[0] ?? 24, 24));
2019
+ const wLo = rampAt(width, lo);
2020
+ const wHi = rampAt(width, hi);
2021
+ if (hi - lo < 2 || !wLo || !wHi || wLo <= 0 || wHi <= 0)
2022
+ return [layer];
2023
+ // Cut where the width DOUBLES, rounding up: a band spanning a 2x range is at worst sqrt(2) out
2024
+ // in the middle, which is the error this is willing to keep.
2025
+ const ratio = Math.max(wHi / wLo, wLo / wHi);
2026
+ const bandCount = Math.min(MAX_DASH_BANDS, hi - lo, Math.ceil(Math.log2(ratio)));
2027
+ if (bandCount < 2)
2028
+ return [layer];
2029
+ const step = Math.max(1, Math.round((hi - lo) / bandCount));
2030
+ const bands = [];
2031
+ for (let from = lo; from < hi; from += step) {
2032
+ const to = Math.min(hi, from + step);
2033
+ bands.push({
2034
+ ...layer,
2035
+ // The ends keep whatever the layer stated, so banding never narrows what it draws.
2036
+ minzoom: from === lo ? layer.minzoom : from,
2037
+ maxzoom: to >= hi ? layer.maxzoom : to,
2038
+ dashZoom: (from + to) / 2,
2039
+ });
2040
+ }
2041
+ if (bands.length < 2)
2042
+ return [layer];
2043
+ coverage.approximate(`line-dasharray on "${layer.id}" split into ${bands.length} zoom bands: a `
2044
+ + 'dash is a multiple of the line width and CartoCSS takes one pattern per rule, so a '
2045
+ + 'ramped width needs a rule per band to stay in proportion');
2046
+ return bands;
2047
+ }
2048
+ /**
2049
+ * The width a dash is scaled by. A width chosen per config value (`match` on `["config", ...]`) is
2050
+ * read at its fallback: its numbers averaged over every branch drew an e-ink track's 5 px dash 50 px.
2051
+ */
2052
+ function dashWidth(width) {
2053
+ if (Array.isArray(width) && width[0] === 'match' && Array.isArray(width[1]) && width[1][0] === 'config') {
2054
+ return dashWidth(width[width.length - 1]);
2055
+ }
2056
+ return width;
2057
+ }
2058
+ /** A zoom ramp's `(zoom, value)` stops, in order. Empty for anything that is not one. */
2059
+ function rampStops(expr) {
2060
+ if (!Array.isArray(expr) || (expr[0] !== 'interpolate' && expr[0] !== 'step'))
2061
+ return [];
2062
+ const stops = [];
2063
+ for (let i = 3; i + 1 < expr.length; i += 2) {
2064
+ const value = stopNumber(expr[i + 1]);
2065
+ if (typeof expr[i] === 'number' && value !== null)
2066
+ stops.push([expr[i], value]);
2067
+ }
2068
+ return stops;
2069
+ }
2070
+ /** Past this the rule count costs more than the dash proportions are worth. */
2071
+ const MAX_DASH_BANDS = 4;
2072
+ /** The zoom a `step` ramp switches to this pattern at, so the line width can be read there. */
2073
+ function stopZoomOf(value, pattern) {
2074
+ if (!Array.isArray(value) || value[0] !== 'step')
2075
+ return null;
2076
+ const same = (node) => Array.isArray(node) && node.length === pattern.length
2077
+ && node.every((v, i) => v === pattern[i]);
2078
+ for (let i = 3; i + 1 < value.length; i += 2) {
2079
+ const stop = value[i + 1];
2080
+ const list = Array.isArray(stop) && stop[0] === 'literal' ? stop[1] : stop;
2081
+ if (same(list) && typeof value[i] === 'number')
2082
+ return value[i];
2083
+ }
2084
+ return null;
2085
+ }
2086
+ /**
2087
+ * One stop of a zoom ramp as a number: the value itself, or the FALLBACK branch of a data-driven
2088
+ * one. Standard's cycleway width is `interpolate(zoom, 12, match(type, piste, 0.5, 0), 18, match(…,
2089
+ * 4, 2), 22, match(…, 40, 20))`, and a stop that was not a plain number sank the whole ramp - the
2090
+ * dash then fell back to the mean of every literal in it, 13.3 px against the 1.3 gl-js draws at the
2091
+ * zoom the pattern starts. The fallback is the branch nearly every feature takes; a piste is the
2092
+ * exception, and one pattern cannot serve both.
2093
+ */
2094
+ function stopNumber(value) {
2095
+ if (typeof value === 'number')
2096
+ return value;
2097
+ if (!Array.isArray(value))
2098
+ return null;
2099
+ if (value[0] === 'literal')
2100
+ return stopNumber(value[1]);
2101
+ if (value[0] === 'match' || value[0] === 'case')
2102
+ return stopNumber(value[value.length - 1]);
2103
+ return null;
2104
+ }
2105
+ /**
2106
+ * A zoom ramp read at ONE zoom, or null when the shape is not one this can evaluate.
2107
+ *
2108
+ * The mean of a ramp's stops is no use for a dash: Standard's steps run `12, 0, 18, 6, 22, 80`, so
2109
+ * the mean is 43 px - a width nothing on screen ever has - and MapBox's `0.2` dash came out at
2110
+ * 8.6 px where gl-js draws well under 2.
2111
+ */
2112
+ function rampAt(expr, zoom) {
2113
+ if (typeof expr === 'number')
2114
+ return expr;
2115
+ if (!Array.isArray(expr))
2116
+ return null;
2117
+ if (expr[0] === 'step') {
2118
+ let value = expr[2];
2119
+ for (let i = 3; i + 1 < expr.length; i += 2) {
2120
+ if (typeof expr[i] !== 'number' || expr[i] > zoom)
2121
+ break;
2122
+ value = expr[i + 1];
2123
+ }
2124
+ return stopNumber(value);
2125
+ }
2126
+ if (expr[0] !== 'interpolate')
2127
+ return null;
2128
+ const kind = expr[1];
2129
+ const base = Array.isArray(kind) && kind[0] === 'exponential' && typeof kind[1] === 'number'
2130
+ ? kind[1] : 1;
2131
+ const stops = [];
2132
+ for (let i = 3; i + 1 < expr.length; i += 2) {
2133
+ const value = stopNumber(expr[i + 1]);
2134
+ if (typeof expr[i] === 'number' && value !== null) {
2135
+ stops.push([expr[i], value]);
2136
+ }
2137
+ }
2138
+ if (!stops.length)
2139
+ return null;
2140
+ if (zoom <= stops[0][0])
2141
+ return stops[0][1];
2142
+ if (zoom >= stops[stops.length - 1][0])
2143
+ return stops[stops.length - 1][1];
2144
+ for (let i = 1; i < stops.length; i++) {
2145
+ const [z0, v0] = stops[i - 1];
2146
+ const [z1, v1] = stops[i];
2147
+ if (zoom > z1)
2148
+ continue;
2149
+ const span = z1 - z0;
2150
+ const t = base === 1 || span === 0
2151
+ ? (zoom - z0) / span
2152
+ : (Math.pow(base, zoom - z0) - 1) / (Math.pow(base, span) - 1);
2153
+ return v0 + t * (v1 - v0);
2154
+ }
2155
+ return null;
2156
+ }
2157
+ /** The layout properties variableAnchorDeclarations owns, so the generic loop leaves them alone. */
2158
+ const VARIABLE_ANCHOR_LAYOUT = new Set([
2159
+ 'text-variable-anchor', 'text-optional', 'text-radial-offset', 'text-justify',
2160
+ ]);
2161
+ /** MapBox's variable anchor -> the SDK's, which spells the corners without the hyphen. */
2162
+ const VARIABLE_ANCHORS = new Set([
2163
+ 'center', 'left', 'right', 'top', 'bottom',
2164
+ 'top-left', 'top-right', 'bottom-left', 'bottom-right',
2165
+ ]);
2166
+ /**
2167
+ * The two spellings are OPPOSITES. MapBox names the part of the TEXT nearest the anchor, so
2168
+ * `bottom` puts the text ABOVE the point; the SDK names the SIDE the text is laid out on
2169
+ * (`vt::LabelAnchor`, "which side of its anchor a label's text is laid out on"), so `bottom` puts it
2170
+ * BELOW. Carried across name-for-name, every anchored label sat on the wrong side of its icon - and
2171
+ * it read as correct in one pane at a time, since the two errors cancel when only one is looked at.
2172
+ */
2173
+ function oppositeAnchor(anchor) {
2174
+ const flip = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
2175
+ return anchor.split('-').map((part) => flip[part] ?? part).join('');
2176
+ }
2177
+ /**
2178
+ * MapBox tries each of `text-variable-anchor` in turn and keeps the first side the label fits on,
2179
+ * falling back to the icon alone when `text-optional` allows it. `ShieldSymbolizer` does the same
2180
+ * thing from the same list, so the four properties that describe it map straight across - all of
2181
+ * them shield-only, because a side to place the text on presupposes an icon to place it beside.
2182
+ */
2183
+ function variableAnchorDeclarations(layer, coverage, options) {
2184
+ const layout = layer.layout ?? {};
2185
+ const out = [];
2186
+ const variable = layout['text-variable-anchor'];
2187
+ // --shield-anchors, for a style that states none. Standard is one: every POI name sits under its
2188
+ // icon and is dropped WITH it when it does not fit, where mapbox-gl would keep the icon.
2189
+ if (!Array.isArray(variable) && options.shieldAnchors) {
2190
+ out.push(`shield-anchors: '${options.shieldAnchors}';`);
2191
+ coverage.emit('shield-anchors');
2192
+ if (layout['text-optional'] === undefined) {
2193
+ out.push('shield-text-optional: true;');
2194
+ coverage.emit('shield-text-optional');
2195
+ }
2196
+ coverage.note(`"${layer.id}": text anchored to the icon's free side (--shield-anchors), ` +
2197
+ 'which the source style does not ask for');
2198
+ }
2199
+ if (Array.isArray(variable)) {
2200
+ const anchors = variable.filter((a) => typeof a === 'string' && VARIABLE_ANCHORS.has(a));
2201
+ if (anchors.length < variable.length) {
2202
+ coverage.drop('text-variable-anchor', 'unknown anchor in the list', layer.id);
2203
+ }
2204
+ if (anchors.length) {
2205
+ out.push(`shield-anchors: '${anchors.map(oppositeAnchor).join(',')}';`);
2206
+ coverage.emit('shield-anchors');
2207
+ }
2208
+ }
2209
+ // Without this a label that fits on no side is dropped WITH its icon.
2210
+ if (layout['text-optional'] === true) {
2211
+ out.push('shield-text-optional: true;');
2212
+ coverage.emit('shield-text-optional');
2213
+ }
2214
+ else if (layout['text-optional'] !== undefined && layout['text-optional'] !== false) {
2215
+ coverage.drop('text-optional', 'only a literal true is carried', layer.id);
2216
+ }
2217
+ // The icon placed whatever it covers. The shield is one label, so its name overlaps too, where
2218
+ // MapBox would drop an optional name that collides.
2219
+ if (layout['icon-allow-overlap'] === true) {
2220
+ out.push('shield-allow-overlap: true;');
2221
+ coverage.emit('shield-allow-overlap');
2222
+ }
2223
+ // Carried as MapBox's own property, not as dx: it is measured from the ANCHOR to the near edge
2224
+ // of the text, on the chosen side's axis and nothing across it (evaluateVariableOffset). Read as
2225
+ // dx the SDK added the icon's half-width to every side gap, and slid a name centred under its
2226
+ // icon one dx to the right. Only read when no text-offset states it, as MapBox does.
2227
+ const radial = layout['text-radial-offset'];
2228
+ if (typeof radial === 'number' && radial !== 0 && layout['text-offset'] === undefined) {
2229
+ const gap = ems(radial, layer, coverage, 'text-radial-offset');
2230
+ if (gap !== null) {
2231
+ out.push(`shield-text-radial-offset: ${gap};`);
2232
+ coverage.emit('shield-text-radial-offset');
2233
+ }
2234
+ }
2235
+ else if (radial !== undefined && typeof radial !== 'number') {
2236
+ coverage.drop('text-radial-offset', 'only a literal offset is carried', layer.id);
2237
+ }
2238
+ // How a WRAPPED label justifies itself once a side is chosen; 'auto' follows that side.
2239
+ const justify = layout['text-justify'];
2240
+ if (typeof justify === 'string') {
2241
+ const align = justify === 'center' ? 'middle' : justify;
2242
+ out.push(`shield-text-horizontal-alignment: '${align}';`);
2243
+ coverage.emit('shield-text-horizontal-alignment');
2244
+ }
2245
+ else if (justify !== undefined) {
2246
+ coverage.drop('text-justify', 'only a literal justification is carried', layer.id);
2247
+ }
2248
+ return out;
2249
+ }
2250
+ /**
2251
+ * Which way, and how far, the image moves so the text does not land on it.
2252
+ *
2253
+ * MapBox measures `text-offset` from the anchor the ICON also sits on, so an offset smaller than
2254
+ * half the icon leaves the two overlapping - a city dot at 0.15 em landed on its own name's
2255
+ * descender. The SDK centres the text on the IMAGE instead, so what it needs is the offset TOPPED
2256
+ * UP to half the icon, never the two added: a POI states 0.8 em, already clear of its pin, and
2257
+ * adding half an icon on top of that floated the name well below it.
2258
+ */
2259
+ /** A value as authored when it is a constant, else the branch representativeConstant picks. */
2260
+ function literalOrRepresentative(value) {
2261
+ if (value === undefined || !Array.isArray(value))
2262
+ return value;
2263
+ // A plain [x, y] offset is a literal, not an expression.
2264
+ if (value.every((v) => typeof v === 'number'))
2265
+ return value;
2266
+ return representativeConstant(value) ?? undefined;
2267
+ }
2268
+ /**
2269
+ * The INK's own size, with the transparent margin padField added round an SDF field taken back off.
2270
+ * The quad is drawn at the padded size and that is what marker-width wants; what the text has to
2271
+ * clear is the artwork.
2272
+ */
2273
+ function artworkSize(icon) {
2274
+ return [icon.width - 2 * icon.padding, icon.height - 2 * icon.padding];
2275
+ }
2276
+ function iconClearance(layer, icon, scale) {
2277
+ // Both may BRANCH - Standard's poi-label states its anchor as a step over zoom and its offset
2278
+ // as a case over the feature - and read literally each one falls back to "no anchor", which
2279
+ // skipped the top-up entirely and left every POI name sitting on its own icon.
2280
+ const anchor = literalOrRepresentative(layer.layout?.['text-anchor']);
2281
+ const offset = literalOrRepresentative(layer.layout?.['text-offset']);
2282
+ const dy = Array.isArray(offset) && typeof offset[1] === 'number' ? offset[1] : 0;
2283
+ if (layer.layout?.['text-radial-offset'] !== undefined)
2284
+ return 0;
2285
+ // The offset is in ems of the text size, the icon in pixels, so they meet at a representative
2286
+ // size - the same approximation representativeScale makes for a zoom-driven icon-size.
2287
+ const emPixels = Math.abs(dy) * representativeScale(layer.layout?.['text-size'], 16);
2288
+ // 'bottom' anchors the text's bottom edge, so the text is ABOVE and the icon goes below.
2289
+ const below = anchor === 'bottom';
2290
+ const above = anchor === 'top';
2291
+ if (!below && !above)
2292
+ return 0;
2293
+ const shortfall = artworkSize(icon)[1] * scale / 2 - emPixels;
2294
+ if (shortfall <= 0)
2295
+ return 0;
2296
+ // HALF the shortfall, and the half is empirical.
2297
+ //
2298
+ // By the model it should be 0: the SDK honours the anchor (TextFormatter::layoutLines puts the
2299
+ // block's top edge on the offset point when alignment.y is +1), which is exactly what MapBox
2300
+ // means by `text-anchor: top`, so the stated offset already clears the icon on both sides.
2301
+ // Measured against Standard at the Louvre, 0 sits a shade tight and the full shortfall pushes
2302
+ // the name visibly further off its icon than mapbox-gl does - our extracted sprite is drawn
2303
+ // larger than MapBox draws the same icon, and the shortfall is computed from ours.
2304
+ return round((below ? 1 : -1) * shortfall * 0.5);
2305
+ }
2306
+ /**
2307
+ * One number to bake an icon-size into the bitmap with. A zoom ramp has no single answer, so it
2308
+ * takes the mean of its stops - the icon is then right in the middle of the range and a little off
2309
+ * at both ends, which beats drawing every sprite at full size.
2310
+ */
2311
+ /**
2312
+ * An emissive strength out of a value that branches.
2313
+ *
2314
+ * NOT `representativeScale`: that one averages the strictly POSITIVE numbers it finds, which is
2315
+ * right for a size (0 means unset) and wrong for an emissive, where 0 is the whole point. Standard
2316
+ * writes the roads' as `0.4 + case(isHighClass, 0.2, 0)`; averaging gave 0.3, so every ordinary
2317
+ * road at dusk came out a sixth too dark, and the water's `interpolate(zoom, ..., 0)` lost its 0
2318
+ * stop entirely and kept 0.1 - which is why the water polygon and the waterway line, drawn with the
2319
+ * same colour by mapbox, disagreed by 5 L points at night.
2320
+ *
2321
+ * This takes the branch most features get (the last one, as `representativeConstant` does) and
2322
+ * folds the arithmetic around it, so the roads resolve to 0.4 and the water to 0.
2323
+ */
2324
+ function representativeEmissive(value, fallback, config = () => undefined) {
2325
+ const bound = new Map();
2326
+ const fold = (node) => {
2327
+ if (typeof node === 'number')
2328
+ return node;
2329
+ if (!Array.isArray(node))
2330
+ return null;
2331
+ const head = node[0];
2332
+ if (head === 'literal')
2333
+ return fold((node[1] ?? null));
2334
+ // `let` binds name/value pairs and evaluates its last argument; `var` reads one back.
2335
+ // Standard writes the ROADS' emissive that way, and without this the whole expression was
2336
+ // unresolvable - which fell back to the property default of 0 and drew every road black.
2337
+ if (head === 'let' && node.length >= 2) {
2338
+ for (let i = 1; i + 1 < node.length - 1; i += 2) {
2339
+ if (typeof node[i] === 'string')
2340
+ bound.set(node[i], node[i + 1]);
2341
+ }
2342
+ return fold(node[node.length - 1]);
2343
+ }
2344
+ if (head === 'var' && typeof node[1] === 'string') {
2345
+ const value = bound.get(node[1]);
2346
+ return value === undefined ? null : fold(value);
2347
+ }
2348
+ if (head === 'to-boolean' || head === 'number')
2349
+ return fold((node[1] ?? null));
2350
+ // A style knob the emissive is built from - Standard's roads are `roadsBrightness + …`.
2351
+ if (head === 'config' && typeof node[1] === 'string') {
2352
+ const value = config(node[1]);
2353
+ return value === undefined ? null : fold(value);
2354
+ }
2355
+ // The detailed end of a ramp, a case's fallback - what a feature in a dense tile gets.
2356
+ if ((head === 'step' || head === 'interpolate' || head === 'case' || head === 'match') && node.length >= 4) {
2357
+ return fold(node[node.length - 1]);
2358
+ }
2359
+ if ((head === '+' || head === '-' || head === '*' || head === '/') && node.length === 3) {
2360
+ const a = fold(node[1]);
2361
+ const b = fold(node[2]);
2362
+ if (a === null || b === null)
2363
+ return null;
2364
+ return head === '+' ? a + b : head === '-' ? a - b : head === '*' ? a * b : (b === 0 ? null : a / b);
2365
+ }
2366
+ if ((head === 'min' || head === 'max') && node.length >= 3) {
2367
+ const parts = node.slice(1).map((n) => fold(n));
2368
+ if (parts.some((n) => n === null))
2369
+ return null;
2370
+ return head === 'min' ? Math.min(...parts) : Math.max(...parts);
2371
+ }
2372
+ return null;
2373
+ };
2374
+ const folded = value === undefined ? null : fold(value);
2375
+ return folded === null ? fallback : folded;
2376
+ }
2377
+ function representativeScale(size, fallback = 1) {
2378
+ if (size === undefined)
2379
+ return fallback;
2380
+ if (typeof size === 'number')
2381
+ return size;
2382
+ const numbers = [];
2383
+ const walk = (value) => {
2384
+ if (typeof value === 'number')
2385
+ numbers.push(value);
2386
+ else if (Array.isArray(value))
2387
+ value.forEach((item) => walk(item));
2388
+ else if (value && typeof value === 'object')
2389
+ Object.values(value).forEach((item) => walk(item));
2390
+ };
2391
+ // Drop the zoom keys: in `interpolate(…, 6, 0.6, 14, 0.7)` only every second number is a size.
2392
+ // interpolate(…, zoom, z0, v0, z1, v1) puts its values at 4, 6, …; step(zoom, v0, z1, v1) at 2, 4, …
2393
+ if (Array.isArray(size) && (size[0] === 'interpolate' || size[0] === 'step')) {
2394
+ for (let i = size[0] === 'step' ? 2 : 4; i < size.length; i += 2)
2395
+ walk(size[i]);
2396
+ }
2397
+ else {
2398
+ walk(size);
2399
+ }
2400
+ const sizes = numbers.filter((n) => n > 0);
2401
+ return sizes.length === 0 ? fallback : sizes.reduce((a, b) => a + b, 0) / sizes.length;
2402
+ }
2403
+ /**
2404
+ * The field a data-driven `icon-image` names its sprite from, or null when it names a constant.
2405
+ *
2406
+ * MapTiler writes `coalesce(image(subclass), image(class), image('dot'))`: the first name that IS
2407
+ * in the sprite wins. CartoCSS has no coalesce, so only the FIRST field is used - a POI whose
2408
+ * subclass has no sprite gets its label without an icon, where MapTiler falls back to the dot.
2409
+ */
2410
+ function dynamicIconField(image) {
2411
+ // `["image", …]` is unwrapped in fold.ts (the SDK cannot tint a sprite per feature), and the
2412
+ // type assertions around a name carry nothing, so both are seen through here rather than being
2413
+ // matched on. Standard names a POI icon `case(has(maki_beta), coalesce(maki_beta, maki), maki)`.
2414
+ const fieldOf = (node) => {
2415
+ if (!Array.isArray(node))
2416
+ return null;
2417
+ if (node[0] === 'image' || node[0] === 'string' || node[0] === 'to-string') {
2418
+ return fieldOf(node[1]);
2419
+ }
2420
+ return node[0] === 'get' && typeof node[1] === 'string' ? node[1] : null;
2421
+ };
2422
+ const constantOf = (node) => {
2423
+ if (Array.isArray(node) && node[0] === 'image' && typeof node[1] === 'string')
2424
+ return node[1];
2425
+ return typeof node === 'string' ? node : null;
2426
+ };
2427
+ const fields = [];
2428
+ let fallback = null;
2429
+ // Only the VALUE branches name an icon; a condition tests something else entirely.
2430
+ const gather = (node) => {
2431
+ if (Array.isArray(node) && node[0] === 'coalesce') {
2432
+ node.slice(1).forEach((branch) => gather(branch));
2433
+ return;
2434
+ }
2435
+ if (Array.isArray(node) && node[0] === 'case' && node.length >= 4) {
2436
+ for (let i = 2; i + 1 < node.length; i += 2)
2437
+ gather(node[i]);
2438
+ gather(node[node.length - 1]);
2439
+ return;
2440
+ }
2441
+ const field = fieldOf(node);
2442
+ if (field !== null) {
2443
+ if (!fields.includes(field))
2444
+ fields.push(field);
2445
+ return;
2446
+ }
2447
+ fallback = constantOf(node) ?? fallback;
2448
+ };
2449
+ gather(image);
2450
+ return fields.length ? { fields, fallback } : null;
2451
+ }
2452
+ /**
2453
+ * An `icon-image` that names its sprite from the feature, as a chain of parameter lookups.
2454
+ *
2455
+ * MapTiler writes these three ways, and all three are lookups on ONE field:
2456
+ * coalesce(image(subclass), image(class), image('dot'))
2457
+ * match(get(class), ['bed_and_breakfast', …], get(class), 'apartment', 'lodging', 'lodging')
2458
+ * case(get(cuisine) == 'turkish', 'kebab', …, match(get(class), …))
2459
+ *
2460
+ * A style parameter per label IS that lookup - `[param::t-<label>]` is null when the label has no
2461
+ * entry, so `??` falls through exactly as coalesce and the fallback branch do. The alternative was
2462
+ * one attachment per branch, which cost a rule each and ran into MAX_VARIANTS: MapTiler's
2463
+ * accommodation table has nine branches, so it did not split at all and every hotel lost its icon.
2464
+ */
2465
+ /**
2466
+ * The sprite table again under the names the TARGET tiles use. A per-feature icon is looked up by
2467
+ * the field's value at draw time, so a class OpenMapTiles spells `town_hall` cannot be rewritten in
2468
+ * the style - only answered for. See ICON_ALIASES.
2469
+ */
2470
+ function aliasSprites(names, file, prefix, options) {
2471
+ if (!options.schema)
2472
+ return;
2473
+ const have = new Set(names);
2474
+ for (const [alias, sprite] of Object.entries(ICON_ALIASES[options.sourceSchema ?? 'maptiler'])) {
2475
+ if (have.has(sprite) && !have.has(alias))
2476
+ options.styleParams.set(`${prefix}${alias}`, file.get(sprite));
2477
+ }
2478
+ }
2479
+ function iconExpression(image, layer, coverage, options) {
2480
+ const sprites = options.sprites;
2481
+ const slug = safeParamName(layer.id);
2482
+ let sample = null;
2483
+ let tableIndex = 0;
2484
+ // A recolourable icon is cut in two - the glyph as a field, the disc as the shield's plate - so
2485
+ // the style's colours reach it per feature (extractIconPlate). Its own parameter table, because
2486
+ // the two halves live under the same NAME and a rule must not pick up the other one's file.
2487
+ const plateMode = layer.layout?.[ICON_PARAMS] !== undefined;
2488
+ const paramPrefix = plateMode ? GLYPH_PARAM_PREFIX : ICON_PARAM_PREFIX;
2489
+ // The params may cover only some of the layer's features (see IconParamScope). The rest keep
2490
+ // the sheet's own artwork, so the rule has to pick per feature between a field and a raster -
2491
+ // which `shield-sdf` allows, being read with an expression context like every other property.
2492
+ const scope = layer.layout?.[ICON_PARAM_SCOPE];
2493
+ const plateWhen = scope
2494
+ ? `(${scope.labels.map((l) => `[${scope.field}] = '${l}'`).join(' || ')})`
2495
+ : null;
2496
+ // What the layer NAMES outright, and what a whole-sheet lookup could reach. Separate because
2497
+ // one rule states one plate geometry: Standard's transit roundel is a rounded square and its
2498
+ // POI icons are circles, and a median over the sheet gives the transit rule the POI's radius.
2499
+ const namedPlates = [];
2500
+ const sheetPlates = [];
2501
+ // And their SIZES, for the same reason: `sample` is whichever sprite was reached first, and its
2502
+ // height is what iconClearance moves the image by. Standard's POI sheet spans a 20 px glyph and
2503
+ // a 54 px badge, so the first one taken hung every natural_point_label name a badge's height off
2504
+ // its own icon.
2505
+ const namedSizes = [];
2506
+ const sheetSizes = [];
2507
+ // A `match` states its sprite names as LABELS even when the branch resolves per feature, and a
2508
+ // rule carries one plate geometry: Standard's transit roundel is a rounded square while its POI
2509
+ // icons are circles, so a median over the whole sheet gives the transit rule a circle's radius.
2510
+ // Only the geometry is taken here - the name still comes from the whole-sheet table.
2511
+ // --icon-font: the table holds a CHARACTER of the icon face per name instead of a file path, so
2512
+ // every shape below (a match table, a whole-sheet lookup, a `{token}` prefix) resolves the same
2513
+ // way and nothing is cut out of the sheet at all.
2514
+ const glyphs = options.iconFont?.glyphs;
2515
+ const notePlate = (name) => {
2516
+ if (glyphs || !plateMode)
2517
+ return;
2518
+ const icon = extractIconPlate(sprites.sheets, name, sprites.outDir);
2519
+ if (icon?.plate)
2520
+ namedPlates.push(icon.plate);
2521
+ if (icon)
2522
+ namedSizes.push(artworkSize(icon));
2523
+ };
2524
+ const named = (name) => {
2525
+ if (glyphs)
2526
+ return glyphs.get(name) ?? null;
2527
+ // In plate mode the rule declares `shield-sdf`, and that is a statement about EVERY file it
2528
+ // can name: a raster cut handed to it is read as a distance field and comes out inverted.
2529
+ // So a sprite with no field is not named at all - it draws nothing rather than a blob.
2530
+ const icon = plateMode
2531
+ ? extractIconPlate(sprites.sheets, name, sprites.outDir)
2532
+ : extractIcon(sprites.sheets, name, sprites.outDir, options.flattenSdf, undefined, 1);
2533
+ if (!icon)
2534
+ return null;
2535
+ if (icon.plate)
2536
+ namedPlates.push(icon.plate);
2537
+ namedSizes.push(artworkSize(icon));
2538
+ if (!sample || (icon.plate && !sample.plate))
2539
+ sample = icon;
2540
+ return icon.file;
2541
+ };
2542
+ // The labels a condition accepts for one field, or null when it tests something else.
2543
+ const labelsOf = (condition, field) => {
2544
+ if (!Array.isArray(condition))
2545
+ return null;
2546
+ const [op, a, b] = condition;
2547
+ if (op === '==' && Array.isArray(a) && a[0] === 'get' && a[1] === field && typeof b === 'string')
2548
+ return [b];
2549
+ if (op === 'in' && Array.isArray(a) && a[0] === 'get' && a[1] === field
2550
+ && Array.isArray(b) && b[0] === 'literal' && Array.isArray(b[1])) {
2551
+ return b[1].every((l) => typeof l === 'string') ? b[1] : null;
2552
+ }
2553
+ if (op === 'any' || op === 'all') {
2554
+ const parts = condition.slice(1).map((c) => labelsOf(c, field));
2555
+ if (op === 'any' && parts.every((x) => x !== null))
2556
+ return parts.flat();
2557
+ }
2558
+ return null;
2559
+ };
2560
+ // The field a case's first condition tests, so the rest can be checked against it.
2561
+ const fieldOfCase = (condition) => {
2562
+ const walk = (node) => {
2563
+ if (!Array.isArray(node))
2564
+ return null;
2565
+ if (node[0] === 'get' && typeof node[1] === 'string')
2566
+ return node[1];
2567
+ for (const child of node.slice(1)) {
2568
+ const found = walk(child);
2569
+ if (found)
2570
+ return found;
2571
+ }
2572
+ return null;
2573
+ };
2574
+ return walk(condition);
2575
+ };
2576
+ // A name built from a literal prefix and ONE field: `road_{ref_length}` and its expression
2577
+ // spelling `concat('road_', get(ref_length))`. The parameter name is built the same way, so the
2578
+ // lookup lands on icon-road_3 - which is what gets a junction its real shield artwork.
2579
+ const prefixed = (prefix, field) => {
2580
+ const all = ensureEverySprite();
2581
+ const matching = all.names.filter((n) => n.startsWith(prefix));
2582
+ if (matching.length === 0)
2583
+ return null;
2584
+ for (const name of matching)
2585
+ options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2586
+ return `[param::${paramPrefix}${prefix}[${field}]]`;
2587
+ };
2588
+ /** Every sprite written out, each under the file the rule should name it by. */
2589
+ let everySprite = null;
2590
+ const ensureEverySprite = () => {
2591
+ if (glyphs)
2592
+ return { names: [...glyphs.keys()], file: glyphs };
2593
+ if (!everySprite) {
2594
+ const file = new Map();
2595
+ let best;
2596
+ if (plateMode) {
2597
+ // ONLY the sprites that split. A rule that declares `shield-sdf` says it of every
2598
+ // file it can name, so seeding this from the raster cut and overwriting the ones
2599
+ // that split left a third of the table pointing at colour bitmaps - each read as a
2600
+ // distance field, and each drawn as an inverted blob.
2601
+ const glyphs = extractAllIconPlates(sprites.sheets, sprites.outDir);
2602
+ for (const { name, icon } of glyphs.entries) {
2603
+ file.set(name, icon.file);
2604
+ if (icon.plate)
2605
+ sheetPlates.push(icon.plate);
2606
+ sheetSizes.push(artworkSize(icon));
2607
+ }
2608
+ if (glyphs.skipped.length > 0) {
2609
+ coverage.approximate(`"${layer.id}" resolves its icon through the whole sheet, ` +
2610
+ `and ${glyphs.skipped.length} of its sprites are not a disc with a glyph on ` +
2611
+ "it - MapBox's generic pin among them. Those are left out, so a feature " +
2612
+ 'naming one draws its label alone');
2613
+ }
2614
+ best = glyphs.sample;
2615
+ }
2616
+ else {
2617
+ const raster = extractAllIcons(sprites.sheets, sprites.outDir, options.flattenSdf);
2618
+ for (const name of raster.names)
2619
+ file.set(name, `icons/${name}.png`);
2620
+ best = raster.sample;
2621
+ }
2622
+ everySprite = { names: [...file.keys()], file };
2623
+ if (!sample || (best?.plate && !sample.plate))
2624
+ sample = best;
2625
+ }
2626
+ return everySprite;
2627
+ };
2628
+ /**
2629
+ * A case or match no ONE-field table can carry, written out as a ternary instead.
2630
+ *
2631
+ * MapTiler picks a shield's artwork on `class` AND `iso_a2` AND the route network at once, so
2632
+ * the branch conditions read several fields - which a ternary handles and a parameter name
2633
+ * cannot. Only the NAMES need the table treatment, and each branch spells its own.
2634
+ */
2635
+ const asTernary = (node) => {
2636
+ if (!Array.isArray(node))
2637
+ return null;
2638
+ const pairs = [];
2639
+ let tail;
2640
+ if (node[0] === 'case' && node.length >= 4 && node.length % 2 === 0) {
2641
+ for (let i = 1; i + 1 < node.length; i += 2)
2642
+ pairs.push([node[i], node[i + 1]]);
2643
+ tail = node[node.length - 1];
2644
+ }
2645
+ else if (node[0] === 'match' && node.length >= 5 && node.length % 2 === 1) {
2646
+ const input = node[1];
2647
+ for (let i = 2; i + 1 < node.length; i += 2) {
2648
+ const labels = Array.isArray(node[i]) ? node[i] : [node[i]];
2649
+ for (const label of labels)
2650
+ if (typeof label === 'string')
2651
+ notePlate(label);
2652
+ const test = labels.length === 1
2653
+ ? ['==', input, labels[0]]
2654
+ : ['any', ...labels.map((l) => ['==', input, l])];
2655
+ pairs.push([test, node[i + 1]]);
2656
+ }
2657
+ tail = node[node.length - 1];
2658
+ }
2659
+ else {
2660
+ return null;
2661
+ }
2662
+ let expr = build(tail);
2663
+ if (expr === null)
2664
+ return null;
2665
+ for (const [condition, value] of [...pairs].reverse()) {
2666
+ // A branch that cannot be spelled is SKIPPED, not fatal. One of MapTiler's shield
2667
+ // branches tests `slice(ref, 2, 3)`, which is not a prefix and has no CartoCSS form -
2668
+ // and dropping the whole expression for it cost every shield its artwork, where
2669
+ // dropping the branch costs one country its variant.
2670
+ const name = build(value);
2671
+ let test = null;
2672
+ try {
2673
+ test = name === null ? null : translateExpression(condition);
2674
+ }
2675
+ catch {
2676
+ test = null;
2677
+ }
2678
+ if (test === null) {
2679
+ coverage.approximate(`one icon-image branch on "${layer.id}" has no CartoCSS form ` +
2680
+ 'and is skipped: those features take the next branch that matches');
2681
+ continue;
2682
+ }
2683
+ expr = `((${test}) ? ${name} : ${expr})`;
2684
+ }
2685
+ return expr;
2686
+ };
2687
+ const build = (node) => {
2688
+ // The type assertions and coercions carry nothing but their value, and Standard wraps every
2689
+ // POI icon name in them - `case(has(maki_beta), to-string(coalesce(string(maki_beta),
2690
+ // string(maki))), string(maki))`. Unseen, every branch built to null and the whole
2691
+ // expression with it, which is why a POI drew its label and no icon.
2692
+ if (Array.isArray(node) && node.length === 2
2693
+ && (node[0] === 'string' || node[0] === 'to-string' || node[0] === 'number')) {
2694
+ return build(node[1]);
2695
+ }
2696
+ if (typeof node === 'string') {
2697
+ const token = node.match(/^([^{}]*)\{([A-Za-z0-9_:-]+)\}$/);
2698
+ if (token)
2699
+ return prefixed(token[1], token[2]);
2700
+ const file = named(node);
2701
+ return file ? `'${file}'` : `''`;
2702
+ }
2703
+ if (Array.isArray(node) && node[0] === 'concat') {
2704
+ const parts = node.slice(1);
2705
+ // A leading 'sheet:' only chose a sprite sheet. Every sheet is written out under bare
2706
+ // names now (see extractAllIcons), so the prefix is dropped and the rest resolved.
2707
+ if (typeof parts[0] === 'string' && /:$/.test(parts[0]) && parts.length === 2) {
2708
+ return build(parts[1]);
2709
+ }
2710
+ // Otherwise the pieces spell a file NAME. mapnik interpolates every [field] in a
2711
+ // string, so the path carries them directly - which is what lets a name read two
2712
+ // fields (`AL-highway_2` is iso_a2 and ref_length) where a parameter lookup, holding
2713
+ // one, cannot.
2714
+ const spelled = parts.map((part) => {
2715
+ let piece = part;
2716
+ while (Array.isArray(piece) && piece[0] === 'to-string')
2717
+ piece = piece[1];
2718
+ if (typeof piece === 'string' || typeof piece === 'number')
2719
+ return String(piece);
2720
+ if (Array.isArray(piece) && piece[0] === 'get' && typeof piece[1] === 'string')
2721
+ return `[${piece[1]}]`;
2722
+ return null;
2723
+ });
2724
+ if (spelled.some((piece) => piece === null))
2725
+ return null;
2726
+ // A spelled name is a FILE path mapnik interpolates the fields into; an icon face has no
2727
+ // such thing, so the name has to come out of the table instead.
2728
+ if (glyphs) {
2729
+ coverage.drop('icon-image', `"${layer.id}" names its sprite by spelling a path ` +
2730
+ '(--icon-font has no per-feature file name to spell)', layer.id);
2731
+ return null;
2732
+ }
2733
+ ensureEverySprite();
2734
+ return `url('icons/${spelled.join('')}.png')`;
2735
+ }
2736
+ if (Array.isArray(node) && node[0] === 'image')
2737
+ return build(node[1]);
2738
+ if (Array.isArray(node) && node[0] === 'get' && typeof node[1] === 'string') {
2739
+ // Named after the value itself - the global one-parameter-per-sprite table covers it,
2740
+ // and the table is what gives `??` a miss to fall through on.
2741
+ const all = ensureEverySprite();
2742
+ for (const name of all.names)
2743
+ options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2744
+ aliasSprites(all.names, all.file, paramPrefix, options);
2745
+ const field = `[param::${paramPrefix}[${node[1]}]]`;
2746
+ if (glyphs || !plateWhen || node[1] !== scope.field)
2747
+ return field;
2748
+ // The features the params do NOT cover: their own artwork, from the raster table, and
2749
+ // `shield-sdf` false for them (see plateWhen in shieldImageDeclarations).
2750
+ const raster = extractAllIcons(sprites.sheets, sprites.outDir, options.flattenSdf);
2751
+ for (const name of raster.names) {
2752
+ options.styleParams.set(`${ICON_PARAM_PREFIX}${name}`, `icons/${name}.png`);
2753
+ }
2754
+ aliasSprites(raster.names, new Map(raster.names.map((n) => [n, `icons/${n}.png`])), ICON_PARAM_PREFIX, options);
2755
+ return `(${plateWhen} ? ${field} : [param::${ICON_PARAM_PREFIX}[${node[1]}]])`;
2756
+ }
2757
+ if (Array.isArray(node) && node[0] === 'coalesce') {
2758
+ const parts = node.slice(1).map((b) => build(b)).filter((x) => x !== null);
2759
+ return parts.length ? parts.join(' ?? ') : null;
2760
+ }
2761
+ // match / case both become one table on one field, plus whatever their fallback is.
2762
+ let field = null;
2763
+ const branches = [];
2764
+ let fallback = null;
2765
+ if (Array.isArray(node) && node[0] === 'match' && node.length >= 5 && node.length % 2 === 1) {
2766
+ const input = node[1];
2767
+ if (!Array.isArray(input) || input[0] !== 'get' || typeof input[1] !== 'string')
2768
+ return asTernary(node);
2769
+ field = input[1];
2770
+ for (let i = 2; i + 1 < node.length; i += 2) {
2771
+ const raw = Array.isArray(node[i]) ? node[i] : [node[i]];
2772
+ if (!raw.every((l) => typeof l === 'string' || typeof l === 'number'))
2773
+ return asTernary(node);
2774
+ branches.push({ labels: raw.map(String), value: node[i + 1] });
2775
+ }
2776
+ fallback = node[node.length - 1];
2777
+ }
2778
+ else if (Array.isArray(node) && node[0] === 'case' && node.length >= 4 && node.length % 2 === 0) {
2779
+ field = fieldOfCase(node[1]);
2780
+ if (!field)
2781
+ return asTernary(node);
2782
+ for (let i = 1; i + 1 < node.length; i += 2) {
2783
+ const labels = labelsOf(node[i], field);
2784
+ if (!labels)
2785
+ return asTernary(node);
2786
+ branches.push({ labels, value: node[i + 1] });
2787
+ }
2788
+ fallback = node[node.length - 1];
2789
+ }
2790
+ else {
2791
+ return asTernary(node);
2792
+ }
2793
+ const table = `${slug}-t${tableIndex++}`;
2794
+ let wrote = 0;
2795
+ for (const { labels, value } of branches) {
2796
+ for (const label of labels) {
2797
+ notePlate(label);
2798
+ const selfNamed = Array.isArray(value) && value[0] === 'get' && value[1] === field;
2799
+ const name = selfNamed ? label : (typeof value === 'string' ? value : null);
2800
+ if (name === null)
2801
+ continue;
2802
+ const file = named(name);
2803
+ if (!file)
2804
+ continue;
2805
+ options.styleParams.set(`${table}-${label}`, file);
2806
+ wrote++;
2807
+ }
2808
+ }
2809
+ const rest = fallback === null ? null : build(fallback);
2810
+ const tail = rest && rest !== `''` ? ` ?? ${rest}` : '';
2811
+ if (wrote === 0)
2812
+ return rest;
2813
+ return `[param::${table}-[${field}]]${tail}`;
2814
+ };
2815
+ const expr = build(image);
2816
+ // In font mode there is no sprite to measure: the glyph run is sized by the label.
2817
+ if (glyphs)
2818
+ return expr ? `(${expr})` : null;
2819
+ if (!expr || !sample)
2820
+ return null;
2821
+ // One rule, one plate: the geometry is a declaration, not a per-feature lookup. The MEDIAN of
2822
+ // what the layer's icons measure - Standard's POI sheet is one disc size and its transit sheet
2823
+ // another, and each is a rule of its own, so the spread inside one is a rounding.
2824
+ const median = (values) => values.slice().sort((a, b) => a - b)[values.length >> 1];
2825
+ const plates = namedPlates.length > 0 ? namedPlates : sheetPlates;
2826
+ if (plates.length > 0) {
2827
+ sample = {
2828
+ ...sample,
2829
+ plateWhen: plateWhen ?? undefined,
2830
+ plate: {
2831
+ radius: median(plates.map((p) => p.radius)),
2832
+ borderWidth: median(plates.map((p) => p.borderWidth)),
2833
+ strokeWidth: median(plates.map((p) => p.strokeWidth)),
2834
+ },
2835
+ };
2836
+ }
2837
+ // The size the same way, and as the ARTWORK's - what a feature actually draws, and what the
2838
+ // text has to clear.
2839
+ const sizes = namedSizes.length > 0 ? namedSizes : sheetSizes;
2840
+ if (sizes.length > 0) {
2841
+ sample = {
2842
+ ...sample,
2843
+ width: median(sizes.map(([w]) => w)),
2844
+ height: median(sizes.map(([, h]) => h)),
2845
+ padding: 0,
2846
+ };
2847
+ }
2848
+ options.iconSample = sample;
2849
+ coverage.note(`"${layer.id}": icon named per feature, resolved through style parameters`);
2850
+ return `(${expr})`;
2851
+ }
2852
+ /** A style parameter name is a bare identifier - a layer id is not. */
2853
+ /**
2854
+ * A colour as a hex literal, or null when it is not one this understands.
2855
+ *
2856
+ * A style parameter carries a plain VALUE, and the SDK parses it back with parseColor - which does
2857
+ * not take `hsl(...)`. Left as MapTiler writes them, every parameter failed to parse and took its
2858
+ * whole rule down with it (the water in a probe style simply stopped drawing). Hex is the form that
2859
+ * survives the round trip.
2860
+ */
2861
+ function colourToHex(value) {
2862
+ if (typeof value !== 'string')
2863
+ return null;
2864
+ const text = value.trim();
2865
+ if (/^#[0-9a-fA-F]{6}$/.test(text))
2866
+ return text.toLowerCase();
2867
+ if (/^#[0-9a-fA-F]{3}$/.test(text)) {
2868
+ return `#${text.slice(1).split('').map((c) => c + c).join('')}`.toLowerCase();
2869
+ }
2870
+ const hex = (r, g, b) => `#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`;
2871
+ const rgb = text.match(/^rgba?\(([^)]+)\)$/i);
2872
+ if (rgb) {
2873
+ const parts = rgb[1].split(',').map((v) => parseFloat(v));
2874
+ if (parts.length < 3 || parts.some((v) => Number.isNaN(v)))
2875
+ return null;
2876
+ if (parts.length > 3 && parts[3] !== 1)
2877
+ return null; // alpha has no hex form parseColor takes
2878
+ return hex(parts[0], parts[1], parts[2]);
2879
+ }
2880
+ const hsl = text.match(/^hsla?\(([^)]+)\)$/i);
2881
+ if (hsl) {
2882
+ const parts = hsl[1].split(',').map((v) => parseFloat(v));
2883
+ if (parts.length < 3 || parts.some((v) => Number.isNaN(v)))
2884
+ return null;
2885
+ if (parts.length > 3 && parts[3] !== 1)
2886
+ return null;
2887
+ const [h, sPct, lPct] = parts;
2888
+ const sat = sPct / 100, light = lPct / 100;
2889
+ const c = (1 - Math.abs(2 * light - 1)) * sat;
2890
+ const hp = (((h % 360) + 360) % 360) / 60;
2891
+ const x = c * (1 - Math.abs((hp % 2) - 1));
2892
+ const m = light - c / 2;
2893
+ const [r, g, b] = hp < 1 ? [c, x, 0] : hp < 2 ? [x, c, 0] : hp < 3 ? [0, c, x]
2894
+ : hp < 4 ? [0, x, c] : hp < 5 ? [x, 0, c] : [c, 0, x];
2895
+ return hex((r + m) * 255, (g + m) * 255, (b + m) * 255);
2896
+ }
2897
+ return null;
2898
+ }
2899
+ /** Below this a nested ternary is smaller and easier to read than a table of parameters. */
2900
+ const MIN_PARAM_CASES = 8;
2901
+ /**
2902
+ * A `match` over ONE field whose results are all constants, or null for any other shape.
2903
+ *
2904
+ * MapTiler keys a road shield's colour off `iso_a2` this way - 54 countries on `Highway shields`
2905
+ * alone, and the same again for its halo. As a ternary that is the largest expression in the style
2906
+ * and the decoder walks it per feature; as a style parameter per country it is one lookup.
2907
+ */
2908
+ function constantMatchOnField(node) {
2909
+ if (!Array.isArray(node) || node[0] !== 'match')
2910
+ return null;
2911
+ const input = node[1];
2912
+ if (!(Array.isArray(input) && input[0] === 'get' && typeof input[1] === 'string'))
2913
+ return null;
2914
+ const cases = [];
2915
+ for (let i = 2; i + 1 < node.length; i += 2) {
2916
+ const raw = node[i];
2917
+ const labels = Array.isArray(raw) ? raw : [raw];
2918
+ if (!labels.every((l) => typeof l === 'string'))
2919
+ return null;
2920
+ const result = node[i + 1];
2921
+ if (typeof result !== 'string' && typeof result !== 'number')
2922
+ return null;
2923
+ cases.push([labels, result]);
2924
+ }
2925
+ if (cases.length < MIN_PARAM_CASES)
2926
+ return null;
2927
+ return { field: input[1], cases, fallback: node[node.length - 1] };
2928
+ }
2929
+ /**
2930
+ * Turns the single-field `match` in a property value into a style-parameter lookup, in place.
2931
+ *
2932
+ * The rewritten value carries a SENTINEL string where the match was, so the whole expression can go
2933
+ * through the normal translation - conditions, nesting and all - and the lookup is substituted into
2934
+ * the finished declaration afterwards. That keeps this out of every emitter's signature.
2935
+ */
2936
+ function paramiseValues(layer, options, coverage) {
2937
+ const subs = new Map();
2938
+ const paint = { ...(layer.paint ?? {}) };
2939
+ let changed = false;
2940
+ for (const [property, value] of Object.entries(paint)) {
2941
+ // The whole value, or the FALLBACK of a case whose own conditions read several fields -
2942
+ // which is exactly how MapTiler writes it: a handful of network special cases over a long
2943
+ // per-country table.
2944
+ const whole = constantMatchOnField(value);
2945
+ const isCase = Array.isArray(value) && value[0] === 'case' && value.length >= 4;
2946
+ const tail = isCase ? constantMatchOnField(value[value.length - 1]) : null;
2947
+ const found = whole ?? tail;
2948
+ if (!found)
2949
+ continue;
2950
+ // Only when every result survives as hex - a value the SDK cannot parse back takes its
2951
+ // whole rule down, so a table with one bad entry is not worth the trade.
2952
+ const asHex = found.cases.map(([labels, result]) => [labels, typeof result === 'number' ? result : colourToHex(result)]);
2953
+ if (asHex.some(([, result]) => result === null))
2954
+ continue;
2955
+ const prefix = `${safeParamName(layer.id)}-${safeParamName(property)}`;
2956
+ for (const [labels, result] of asHex) {
2957
+ for (const label of labels)
2958
+ options.styleParams.set(`${prefix}-${label}`, result);
2959
+ }
2960
+ const fallback = tryTranslate(found.fallback, property, layer.id, coverage);
2961
+ if (fallback === null)
2962
+ continue;
2963
+ const sentinel = `@@param${subs.size}@@`;
2964
+ subs.set(`'${sentinel}'`, `([param::${prefix}-[${found.field}]] ?? ${fallback})`);
2965
+ if (whole) {
2966
+ paint[property] = sentinel;
2967
+ }
2968
+ else {
2969
+ const rewritten = [...value];
2970
+ rewritten[rewritten.length - 1] = sentinel;
2971
+ paint[property] = rewritten;
2972
+ }
2973
+ changed = true;
2974
+ coverage.approximate(`${property} on "${layer.id}" reads ${found.cases.length} cases of ` +
2975
+ `[${found.field}] from style parameters instead of a nested ternary`);
2976
+ }
2977
+ return { layer: changed ? { ...layer, paint } : layer, subs };
2978
+ }
2979
+ function safeParamName(id) {
2980
+ return id.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
2981
+ }
2982
+ /**
2983
+ * A fill or line pattern: a sprite sliced out to its own file, like a marker's. MapBox may qualify
2984
+ * the name with the sheet it lives in and CartoCSS wants a path, so the qualifier is dropped and
2985
+ * the sprite written under its bare name - which is also where every other sheet's icons land.
2986
+ */
2987
+ function patternDeclarations(name, value, target, layer, coverage, options) {
2988
+ if (!target)
2989
+ return [];
2990
+ if (typeof value !== 'string') {
2991
+ coverage.drop(name, 'a data-driven pattern names no one sprite', layer.id);
2992
+ return [];
2993
+ }
2994
+ if (!options.sprites) {
2995
+ coverage.drop(name, 'no sprite loaded (pass --sprite or let the style provide one)', layer.id);
2996
+ return [];
2997
+ }
2998
+ const bare = value.includes(':') ? value.slice(value.indexOf(':') + 1) : value;
2999
+ // Flattened: a pattern is painted as it stands, and there is no pattern-sdf to tint one with.
3000
+ const icon = extractIcon(options.sprites.sheets, value, options.sprites.outDir, true, undefined, 1, bare);
3001
+ if (!icon) {
3002
+ coverage.drop(name, `"${value}" is not in the sprite`, layer.id);
3003
+ return [];
3004
+ }
3005
+ coverage.emit(target);
3006
+ return [`${target}: url('${icon.file}');`];
3007
+ }
3008
+ /**
3009
+ * A shield's icon as a GLYPH of an icon font (--icon-font), instead of a sprite.
3010
+ *
3011
+ * `shield-icon-name` is the run ShieldSymbolizer shapes from `shield-icon-face-name` and draws
3012
+ * BEFORE the text, on the anchor - the same place the bitmap would have gone, so everything else
3013
+ * about the label is unchanged. The face is resolved as a fallback of the label font, which is what
3014
+ * puts its glyphs in the label's own atlas.
3015
+ *
3016
+ * The face carries one glyph per icon NAME, so a name it has none for draws no icon: an artwork
3017
+ * shield (an RER roundel, a country's motorway plate) has no font equivalent and is lost here. That
3018
+ * is the trade the mode is for - one font against a sheet of several hundred PNGs.
3019
+ */
3020
+ function fontShieldDeclarations(layer, image, coverage, options) {
3021
+ const font = options.iconFont;
3022
+ // A constant name is looked up outright; anything data-driven goes through the same parameter
3023
+ // table the sprite lookup builds, holding characters instead of file paths.
3024
+ const constant = typeof image === 'string' && !/\{[A-Za-z0-9_:-]+\}/.test(image);
3025
+ const glyph = constant
3026
+ ? (font.glyphs.has(image) ? `'${font.glyphs.get(image)}'` : null)
3027
+ : iconExpression(image, layer, coverage, options);
3028
+ if (glyph === null) {
3029
+ coverage.drop('icon-image', constant
3030
+ ? `"${image}" has no glyph in the "${font.face}" icon face`
3031
+ : `no glyph name of the "${font.face}" icon face could be resolved from it`, layer.id);
3032
+ return [];
3033
+ }
3034
+ const out = [`shield-icon-name: ${glyph};`, `shield-icon-face-name: '${font.face}';`];
3035
+ coverage.emit('shield-icon-name');
3036
+ coverage.emit('shield-icon-face-name');
3037
+ // MapBox's icon-size SCALES a sprite; a glyph is sized in pixels. Given --icon-font-size, the
3038
+ // glyph's height in sprite pixels, it scales the same way; otherwise as a multiple of the text size.
3039
+ const sized = translateExpression(layer.layout?.['icon-size'] ?? 1);
3040
+ if (font.size !== undefined) {
3041
+ out.push(`shield-icon-size: ((${sized}) * ${font.size});`);
3042
+ coverage.emit('shield-icon-size');
3043
+ }
3044
+ else {
3045
+ const size = representativeScale(layer.layout?.['icon-size']);
3046
+ if (size !== 1) {
3047
+ out.push(`shield-icon-size: ${round(size * representativeScale(layer.layout?.['text-size'], DEFAULT_TEXT_SIZE))};`);
3048
+ coverage.emit('shield-icon-size');
3049
+ }
3050
+ }
3051
+ emitTranslated(out, coverage, layer, 'icon-color', 'shield-icon-fill', undefined, false);
3052
+ emitTranslated(out, coverage, layer, 'icon-opacity', 'shield-icon-opacity', undefined, false);
3053
+ emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
3054
+ emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false);
3055
+ // A recolourable badge keeps its disc: the plate measured off the sheet, as the sprite path does.
3056
+ const plate = layer.layout?.[ICON_PARAMS] !== undefined ? fontPlateSample(options) : null;
3057
+ if (plate)
3058
+ out.push(...iconPlateDeclarations(layer, plate, sized, coverage, options));
3059
+ out.push(...variableAnchorDeclarations(layer, coverage, options));
3060
+ return out;
3061
+ }
3062
+ const fontPlates = new WeakMap();
3063
+ /** The first badge of the sheet that splits into a disc and a glyph, measured once per conversion. */
3064
+ function fontPlateSample(options) {
3065
+ if (!options.sprites || !options.iconFont)
3066
+ return null;
3067
+ if (!fontPlates.has(options)) {
3068
+ let found = null;
3069
+ for (const name of options.iconFont.glyphs.keys()) {
3070
+ const icon = extractIconPlate(options.sprites.sheets, name, options.sprites.outDir);
3071
+ if (icon?.plate) {
3072
+ found = icon;
3073
+ break;
3074
+ }
3075
+ }
3076
+ fontPlates.set(options, found);
3077
+ }
3078
+ return fontPlates.get(options);
3079
+ }
3080
+ /** MapBox's icon-* onto marker-*, once the sprite has been sliced into its own file. */
3081
+ function markerDeclarations(layer, coverage, options) {
3082
+ const image = layer.layout?.['icon-image'];
3083
+ if (image === undefined)
3084
+ return [];
3085
+ // --icon-font replaces a shield's ARTWORK with a glyph, and needs no sheet at all. Only a
3086
+ // shield: an icon-only layer is a marker (a oneway arrow, a crossing), which has no glyph run.
3087
+ if (options.iconFont && layer.layout?.['text-field'] !== undefined) {
3088
+ return fontShieldDeclarations(layer, image, coverage, options);
3089
+ }
3090
+ if (!options.sprites) {
3091
+ coverage.drop('icon-image', 'no sprite loaded (pass --sprite or let the style provide one)', layer.id);
3092
+ return [];
3093
+ }
3094
+ // MapTiler names a POI's icon from the feature. The SDK resolves shield-file per feature and
3095
+ // mapnik interpolates [field] inside a string, so the whole sheet is written out and the field
3096
+ // goes in the file name - see dynamicIconField.
3097
+ // A legacy token name (`road_{ref_length}`) is data-driven too, even though it is a string.
3098
+ if (typeof image !== 'string' || /\{[A-Za-z0-9_:-]+\}/.test(image)) {
3099
+ const expr = iconExpression(image, layer, coverage, options);
3100
+ if (expr && options.iconSample) {
3101
+ const scale = layer.layout?.['text-field'] !== undefined
3102
+ ? representativeScale(layer.layout?.['icon-size']) : 1;
3103
+ return shieldImageDeclarations(layer, { ...options.iconSample, file: `@@EXPR@@${expr}` }, scale, coverage, options);
3104
+ }
3105
+ }
3106
+ if (typeof image !== 'string') {
3107
+ coverage.drop('icon-image', 'data-driven icon name', layer.id);
3108
+ return [];
3109
+ }
3110
+ // With text beside it the icon becomes a SHIELD - one label, no collision with its own name.
3111
+ // ShieldSymbolizer has no `sdf`, so the field is resolved and the style's icon-color baked in.
3112
+ const asShield = layer.layout?.['text-field'] !== undefined;
3113
+ // A shield is SHIPPED at the sprite's own resolution and shrunk by shield-image-scale. Baking
3114
+ // icon-size into the bitmap instead resampled the distance field into fewer texels than it
3115
+ // needs, so the smaller the icon the blockier and softer it drew.
3116
+ const scale = asShield ? representativeScale(layer.layout?.['icon-size']) : 1;
3117
+ const icon = extractIcon(options.sprites.sheets, image, options.sprites.outDir, options.flattenSdf, undefined, 1);
3118
+ if (!icon) {
3119
+ coverage.drop('icon-image', `"${image}" is not in the sprite`, layer.id);
3120
+ return [];
3121
+ }
3122
+ if (asShield)
3123
+ return shieldImageDeclarations(layer, icon, scale, coverage, options);
3124
+ const out = [
3125
+ `marker-file: url('${icon.file}');`,
3126
+ `marker-placement: '${resolvePlacement(layer, 'icon')}';`,
3127
+ ];
3128
+ coverage.emit('marker-file');
3129
+ coverage.emit('marker-placement');
3130
+ // `symbol-spacing` reaches an icon-only layer nowhere else: the table maps it to text-spacing,
3131
+ // and a layer with no text-field drops every text-* declaration it built. MarkersSymbolizer's
3132
+ // own default is 100 against MapBox's 250, so every oneway arrow was drawn two and a half times
3133
+ // too often. One tile unit is one CSS pixel at the tile's own zoom, so the number carries over
3134
+ // unscaled - the same reason text-spacing takes it raw.
3135
+ if (repeatsAlongLine(layer)) {
3136
+ const spacing = layer.layout?.['symbol-spacing'];
3137
+ if (spacing !== undefined && typeof spacing !== 'number') {
3138
+ coverage.approximate(`symbol-spacing on "${layer.id}" read at one stop: ` +
3139
+ 'marker-spacing is a static property, not a ramp');
3140
+ }
3141
+ out.push(`marker-spacing: ${round(representativeScale(spacing, DEFAULT_SYMBOL_SPACING))};`);
3142
+ coverage.emit('marker-spacing');
3143
+ }
3144
+ const sdf = icon.sdf && !options.flattenSdf;
3145
+ if (sdf) {
3146
+ out.push('marker-sdf: true;');
3147
+ coverage.emit('marker-sdf');
3148
+ }
3149
+ // icon-size scales the sprite's own size. `marker-width` is read at DECODE (it sizes the
3150
+ // raster), so a zoom-driven size is evaluated per tile zoom rather than per frame - which is
3151
+ // how a raster marker has to behave anyway, and is why it is carried here for a plain sprite
3152
+ // too. Dropping it left the marker at its native size: Standard draws a crosswalk at
3153
+ // icon-size 0.2 from a 61 px sprite, so every crossing came out five times too big.
3154
+ const size = layer.layout?.['icon-size'];
3155
+ const sizeExpr = size === undefined
3156
+ ? String(round(icon.width))
3157
+ : scaledSize(size, icon.width, name('icon-size', layer, coverage));
3158
+ out.push(`marker-width: ${sizeExpr};`);
3159
+ coverage.emit('marker-width');
3160
+ // An SDF icon carries no colour of its own, so marker-color IS the icon colour and MapBox
3161
+ // defaults it to black. A plain sprite already has its colours and must not be tinted.
3162
+ if (icon.sdf) {
3163
+ emitTranslated(out, coverage, layer, 'icon-color', 'marker-color', '#000000', !sdf);
3164
+ }
3165
+ emitTranslated(out, coverage, layer, 'icon-opacity', 'marker-opacity', undefined, !sdf);
3166
+ // Halos are grown from the field, so they only exist in SDF mode. The halo carries its own
3167
+ // opacity, so icon-opacity has to reach it too or a faded-out icon keeps a solid outline.
3168
+ if (sdf) {
3169
+ emitTranslated(out, coverage, layer, 'icon-halo-color', 'marker-halo-fill', undefined, false);
3170
+ emitTranslated(out, coverage, layer, 'icon-halo-width', 'marker-halo-radius', undefined, false);
3171
+ emitTranslated(out, coverage, layer, 'icon-opacity', 'marker-halo-opacity', undefined, false);
3172
+ }
3173
+ const overlap = layer.layout?.['icon-overlap'];
3174
+ if (layer.layout?.['icon-allow-overlap'] === true || overlap !== undefined) {
3175
+ out.push(`marker-allow-overlap: ${layer.layout?.['icon-allow-overlap'] === true || overlap === 'always'};`);
3176
+ coverage.emit('marker-allow-overlap');
3177
+ // `marker-clip` defaults to allow-overlap in MarkersSymbolizer, and a clipped marker is
3178
+ // tile GEOMETRY, stencil-masked to its tile: a oneway arrow sitting on a tile edge lost the
3179
+ // half that overhangs. A MapBox symbol is a label whatever its collision setting.
3180
+ out.push('marker-clip: false;');
3181
+ coverage.emit('marker-clip');
3182
+ }
3183
+ out.push(...markerRotation(layer, coverage));
3184
+ return out;
3185
+ }
3186
+ /**
3187
+ * A tree, drawn as its canopy.
3188
+ *
3189
+ * Standard draws the `tree` source-layer as 3D models from z15 and there is nothing to port; a
3190
+ * filled dot in the greenspace colour is what is left, and it is what the parks were missing.
3191
+ *
3192
+ * The size is a GROUND size, a crown about 3 m across, so the dots keep their spacing as the
3193
+ * map zooms: base-2 between two stops five levels apart is exactly the doubling curve. The SDK's
3194
+ * zoom counts 256 unscaled-DPI units per tile, so one unit at mapbox z15 is 17.8 m and the two
3195
+ * stops follow from that. `marker-width` is a width, so these are diameters. A real crown is
3196
+ * wider, but a flat disc reads heavier than the textured canopy MapBox draws and 8 m of it
3197
+ * filled the parks in.
3198
+ *
3199
+ * The outline is the canopy colour darkened by 18 points of lightness, which is what separates one
3200
+ * tree from the next where they touch; a marker's own default line is near-black and drew every
3201
+ * tree as an outlined ring instead.
3202
+ *
3203
+ * `allow-overlap` with `clip` ON is deliberate and the one place the two are not emitted together
3204
+ * (see markerDeclarations): a tile carries hundreds of trees, and pushing those through the label
3205
+ * culler would cost far more than the half-tree an occasional tile edge cuts.
3206
+ */
3207
+ function canopyDeclarations(layer, coverage) {
3208
+ const out = [
3209
+ 'marker-width: exponential(2, ([view::zoom] - 1), (15, 2), (20, 24));',
3210
+ 'marker-allow-overlap: true;',
3211
+ 'marker-clip: true;',
3212
+ ];
3213
+ coverage.emit('marker-width');
3214
+ coverage.emit('marker-allow-overlap');
3215
+ coverage.emit('marker-clip');
3216
+ const color = layer.paint?.['model-color'];
3217
+ const translated = color === undefined ? null : tryTranslate(color, 'model-color', layer.id, coverage);
3218
+ if (translated === null) {
3219
+ out.push('marker-line-width: 0;');
3220
+ }
3221
+ else {
3222
+ out.push(`marker-line-color: darken(${translated}, 0.18);`, 'marker-line-width: 1;');
3223
+ coverage.emit('marker-line-color');
3224
+ }
3225
+ coverage.emit('marker-line-width');
3226
+ return out;
3227
+ }
3228
+ /**
3229
+ * `icon-rotate`, which turns a marker on the MAP.
3230
+ *
3231
+ * A rotation is a `marker-transform`, and its angle is an expression, so it is read per feature -
3232
+ * which is what Standard's crosswalks need: they are points carrying a `direction` in degrees, and
3233
+ * unrotated every one of them drew its stripes across the same axis whatever way its street ran.
3234
+ *
3235
+ * MarkersSymbolizer switches a marker with a rotation to the POINT orientation, so it lies flat and
3236
+ * turns with the map. That is `icon-rotation-alignment: map` and nothing else, so a layer that
3237
+ * rotates against the VIEWPORT is reported rather than silently laid down - the SDK has no
3238
+ * screen-space rotation for a billboard.
3239
+ */
3240
+ function markerRotation(layer, coverage) {
3241
+ const rotate = layer.layout?.['icon-rotate'];
3242
+ if (rotate === undefined || rotate === 0)
3243
+ return [];
3244
+ if (layer.layout?.['icon-rotation-alignment'] !== 'map') {
3245
+ coverage.drop('icon-rotate', 'the SDK turns a marker on the map, not against the viewport, ' +
3246
+ 'and this layer does not state icon-rotation-alignment: map', layer.id);
3247
+ return [];
3248
+ }
3249
+ const translated = tryTranslate(rotate, 'icon-rotate', layer.id, coverage);
3250
+ if (translated === null)
3251
+ return [];
3252
+ coverage.emit('marker-transform');
3253
+ // No sign flip. MapBox's icon-rotate turns clockwise from north, and `rotate()` is applied in
3254
+ // the tile's own frame, where y grows DOWNWARD - so a positive angle about +z already reads
3255
+ // clockwise on screen. Negated, every crossing sat at twice its own angle off its street.
3256
+ return [`marker-transform: rotate(${translated});`];
3257
+ }
3258
+ function round(value) {
3259
+ return Math.round(value * 100) / 100;
3260
+ }
3261
+ /**
3262
+ * What decides which of two colliding labels survives.
3263
+ *
3264
+ * MapBox's `symbol-sort-key` orders symbols only WITHIN a layer - between layers the style's own
3265
+ * order decides, later winning. The culler here compares one priority across every layer at once
3266
+ * and only falls back to the layer index, so a village with `rank 1` (priority -1) beat a town with
3267
+ * `rank 12` (-12) whatever layer each came from: Annecy's neighbours were drawn and Rumilly was not.
3268
+ *
3269
+ * Folding the layer's position in as the leading term restores MapBox's meaning - the sort key
3270
+ * still separates labels inside a layer, and it can no longer reach across one. The stride only has
3271
+ * to exceed the range a sort key spans (MapTiler's widest is the capital's -1000).
3272
+ */
3273
+ const LAYER_PRIORITY_STRIDE = 100000;
3274
+ function placementPriority(layer, layerIndex, coverage) {
3275
+ const base = layerIndex * LAYER_PRIORITY_STRIDE;
3276
+ const sortKey = layer.layout?.['symbol-sort-key'];
3277
+ if (sortKey === undefined)
3278
+ return String(base);
3279
+ const translated = tryTranslate(sortKey, 'symbol-sort-key', layer.id, coverage);
3280
+ // MapBox places the LOWEST key first, and the culler takes the highest priority.
3281
+ return translated === null ? String(base) : `(${base} - ${translated})`;
3282
+ }
3283
+ /**
3284
+ * What one unit of MapBox's text-padding is worth as a collision padding: one for one, since both
3285
+ * grow the box on every side. --label-spacing scales it for a map that wants thinning beyond what
3286
+ * the style asks for.
3287
+ */
3288
+ function collisionPad(options) {
3289
+ return options.labelSpacing ?? 1;
3290
+ }
3291
+ /** MapBox defaults for a layer that never states them. */
3292
+ const DEFAULT_TEXT_SIZE = 16;
3293
+ const DEFAULT_TEXT_MAX_WIDTH = 10;
3294
+ const DEFAULT_TEXT_PADDING = 2;
3295
+ const DEFAULT_SYMBOL_SPACING = 250;
3296
+ /**
3297
+ * Width of the line that stands in for `fill-outline-color`. MapBox draws that outline with
3298
+ * `gl.LINES`, so it is one DEVICE pixel whatever the display; a CartoCSS width is multiplied by the
3299
+ * pixel scale, so `1` came out ~3 px on a 2.75x phone and small buildings were solid outline
3300
+ * (measured at La Clusaz z14.5: 7456 outline pixels against 1747 of fill). This reads as a hairline
3301
+ * from 2x up, which is every phone.
3302
+ */
3303
+ const FILL_OUTLINE_WIDTH = 0.4;
3304
+ /** One style parameter per sprite name, so a per-feature lookup can fall through when it misses. */
3305
+ const ICON_PARAM_PREFIX = 'icon-';
3306
+ /** The glyph FIELD of a recolourable icon, a different file under the same name. */
3307
+ const GLYPH_PARAM_PREFIX = 'glyph-';
3308
+ /** A value in ems of the layer's own text-size, as the pixels CartoCSS wants. */
3309
+ function ems(value, layer, coverage, from) {
3310
+ const size = layer.layout?.['text-size'] ?? DEFAULT_TEXT_SIZE;
3311
+ if (typeof value === 'number' && typeof size === 'number')
3312
+ return String(round(value * size));
3313
+ const valueExpr = typeof value === 'number' ? String(round(value)) : tryTranslate(value, from, layer.id, coverage);
3314
+ const sizeExpr = typeof size === 'number' ? String(size) : tryTranslate(size, 'text-size', layer.id, coverage);
3315
+ return valueExpr === null || sizeExpr === null ? null : `(${valueExpr} * ${sizeExpr})`;
3316
+ }
3317
+ /** icon-size is a multiplier on the sprite's own width. */
3318
+ function scaledSize(size, width, translated) {
3319
+ if (typeof size === 'number')
3320
+ return String(round(width * size));
3321
+ return translated === null ? String(round(width)) : `(${translated} * ${round(width)})`;
3322
+ }
3323
+ /** Translates a MapBox paint/layout value onto a CartoCSS property, or reports why it could not. */
3324
+ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly) {
3325
+ const value = layer.paint?.[from] ?? layer.layout?.[from];
3326
+ if (value === undefined) {
3327
+ if (fallback !== undefined) {
3328
+ out.push(`${to}: ${fallback};`);
3329
+ coverage.emit(to);
3330
+ }
3331
+ return;
3332
+ }
3333
+ if (constantOnly && typeof value !== 'string' && typeof value !== 'number') {
3334
+ coverage.drop(from, `a flattened bitmap needs a constant ${from}`, layer.id);
3335
+ return;
3336
+ }
3337
+ const translated = name(from, layer, coverage);
3338
+ if (translated !== null) {
3339
+ out.push(`${to}: ${translated};`);
3340
+ coverage.emit(to);
3341
+ }
3342
+ }
3343
+ /** The translated form of a layer property, counting the drop itself when it has none. */
3344
+ /**
3345
+ * A `match` on ONE field whose branches are all constants, written as a style-parameter LOOKUP keyed
3346
+ * by that field - `[param::poi-fill-bus]`, one parameter per label - instead of the ternary chain
3347
+ * the decoder would otherwise walk per feature.
3348
+ *
3349
+ * Two things come with it. A category palette becomes EDITABLE in project.json without touching the
3350
+ * generated stylesheet, which is where a style's colours want to live; and sixty string comparisons
3351
+ * per POI become one lookup. The fallback stays in the rule, so a class the table does not name
3352
+ * still draws - `??` is what a parameter miss falls through on, as the icon table already relies on.
3353
+ *
3354
+ * OPT-IN, per property, through the style's own metadata:
3355
+ *
3356
+ * "metadata": { "massif:params": ["text-color"] }
3357
+ *
3358
+ * which keeps the layer a valid MapLibre style (metadata is ignored by every renderer) and leaves
3359
+ * every other converted style byte-identical. A table is worth it for a palette the author means to
3360
+ * tune and not for the two-branch colour ramp on a road, and only the author knows which is which.
3361
+ */
3362
+ function fieldParamTable(value, layer, property, target, coverage, options, suffix = '') {
3363
+ if (!options.styleParams || !Array.isArray(value))
3364
+ return null;
3365
+ const asked = layer.metadata?.['massif:params'];
3366
+ if (!Array.isArray(asked) || !asked.includes(property))
3367
+ return null;
3368
+ // A day/night pair of tables: `measure-light` over one table per stop, so a palette that follows
3369
+ // the hour is still a lookup per feature rather than the ternary chain it would otherwise become.
3370
+ const input = value[2];
3371
+ if (value[0] === 'interpolate' && Array.isArray(input) && input[0] === 'measure-light' && input[1] === 'brightness') {
3372
+ const stops = [];
3373
+ for (let i = 3; i + 1 < value.length; i += 2) {
3374
+ const last = i + 2 >= value.length;
3375
+ const table = foldOrConstant(value[i + 1], layer, property, target, coverage, options, last ? suffix : `${suffix}-b${Math.round(Number(value[i]) * 100)}`);
3376
+ if (table === null)
3377
+ return null;
3378
+ stops.push(`(${round(Number(value[i]))}, ${table})`);
3379
+ }
3380
+ return `linear([view::brightness], ${stops.join(', ')})`;
3381
+ }
3382
+ if (value[0] !== 'match')
3383
+ return null;
3384
+ // A palette per VARIANT: a match on a live config whose every branch folds becomes one set of
3385
+ // tables per branch, picked by a parameter test - a per-draw comparison, then one lookup.
3386
+ const on = value[1];
3387
+ if (Array.isArray(on) && on[0] === 'config' && typeof on[1] === 'string' && value.length % 2 === 1) {
3388
+ const branches = [];
3389
+ for (let i = 2; i + 1 < value.length; i += 2) {
3390
+ const labels = (Array.isArray(value[i]) ? value[i] : [value[i]]);
3391
+ if (!labels.every((l) => typeof l === 'string' && /^[A-Za-z0-9_]+$/.test(l)))
3392
+ return null;
3393
+ const table = foldOrConstant(value[i + 1], layer, property, target, coverage, options, `${suffix}-${labels[0]}`);
3394
+ if (table === null)
3395
+ return null;
3396
+ const test = labels.map((l) => `[param::${on[1]}] = '${l}'`).join(' || ');
3397
+ branches.push(`(${test}) ? ${table}`);
3398
+ }
3399
+ const rest = foldOrConstant(value[value.length - 1], layer, property, target, coverage, options, suffix);
3400
+ if (rest === null)
3401
+ return null;
3402
+ return `(${branches.join(' : ')} : ${rest})`;
3403
+ }
3404
+ if (value.length < 5 || value.length % 2 === 0)
3405
+ return null;
3406
+ const key = value[1];
3407
+ if (!Array.isArray(key) || key[0] !== 'get' || typeof key[1] !== 'string')
3408
+ return null;
3409
+ const field = key[1];
3410
+ const entries = [];
3411
+ for (let i = 2; i + 1 < value.length; i += 2) {
3412
+ const labels = Array.isArray(value[i]) ? value[i] : [value[i]];
3413
+ const branch = value[i + 1];
3414
+ if (typeof branch !== 'string' && typeof branch !== 'number')
3415
+ return null;
3416
+ for (const label of labels) {
3417
+ if (typeof label !== 'string' || !/^[A-Za-z0-9_]+$/.test(label))
3418
+ return null;
3419
+ entries.push([label, branch]);
3420
+ }
3421
+ }
3422
+ const rest = value[value.length - 1];
3423
+ if (typeof rest !== 'string' && typeof rest !== 'number')
3424
+ return null;
3425
+ const fallback = tryTranslate(rest, property, layer.id, coverage);
3426
+ if (fallback === null)
3427
+ return null;
3428
+ const slug = `${layer['source-layer'] ?? safeParamName(layer.id)}-${target.replace(/^(text|shield|marker)-/, '')}${suffix}`;
3429
+ for (const [label, branch] of entries)
3430
+ options.styleParams.set(`${slug}-${label}`, paramValue(branch));
3431
+ coverage.note(`"${layer.id}": ${property} is a ${entries.length}-entry table in project.json ` +
3432
+ `(${slug}-*), read per feature by [${field}]`);
3433
+ return `(([param::${slug}-[${field}]]) ?? ${fallback})`;
3434
+ }
3435
+ /** A branch of a variant or brightness split: a table where it folds, a plain value where it is one. */
3436
+ function foldOrConstant(value, layer, property, target, coverage, options, suffix) {
3437
+ if (typeof value === 'string' || typeof value === 'number')
3438
+ return tryTranslate(value, property, layer.id, coverage);
3439
+ return fieldParamTable(value, layer, property, target, coverage, options, suffix);
3440
+ }
3441
+ /**
3442
+ * A parameter value as the DECODER will read it. A colour goes in as hex: a style parameter is a
3443
+ * plain string that `parseColor` has to read at runtime, and its grammar knows `#rrggbb`, `rgb()`
3444
+ * and the CSS names but NOT `hsl()` - which the CartoCSS compiler does know, so an hsl() literal in
3445
+ * a rule is fine and the same literal in a parameter silently lost every POI its colour.
3446
+ */
3447
+ function paramValue(branch) {
3448
+ if (typeof branch !== 'string')
3449
+ return branch;
3450
+ const hsla = toHsla(branch);
3451
+ if (!hsla)
3452
+ return branch;
3453
+ const [h, s, l, a] = hsla;
3454
+ if (a < 1)
3455
+ return branch; // an alpha has no hex spelling here; rgba() parses, so leave it
3456
+ const c = (1 - Math.abs(2 * l / 100 - 1)) * (s / 100);
3457
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
3458
+ const m = l / 100 - c / 2;
3459
+ const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
3460
+ : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
3461
+ const hex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
3462
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
3463
+ }
3464
+ function name(property, layer, coverage) {
3465
+ const value = layer.paint?.[property] ?? layer.layout?.[property];
3466
+ if (value === undefined)
3467
+ return null;
3468
+ return tryTranslate(value, property, layer.id, coverage);
3469
+ }
3470
+ /** Second chance for a value one of whose branches has no CartoCSS form: keep the fallback branch. */
3471
+ function retryCollapsed(value, layer, coverage) {
3472
+ const collapsed = collapseBranches(value);
3473
+ if (JSON.stringify(collapsed) === JSON.stringify(value))
3474
+ return null;
3475
+ const translated = tryTranslate(collapsed, 'text-field', layer.id, coverage);
3476
+ if (translated !== null) {
3477
+ coverage.approximate(`text-field on "${layer.id}" kept only its fallback branch: another ` +
3478
+ 'branch has no CartoCSS form, and no label at all is worse than the common one');
3479
+ }
3480
+ return translated;
3481
+ }
3482
+ /**
3483
+ * text-font is a LIST of faces in preference order and CartoCSS takes one name. A `match` over the
3484
+ * feature wraps each list in `["literal", [...]]`, and after the split collapses that branch the
3485
+ * value is the literal itself - reading element 0 then emitted the face name `literal`, which
3486
+ * resolves to nothing and drew eight layers in the fallback font.
3487
+ */
3488
+ function firstFontName(value) {
3489
+ if (Array.isArray(value) && value[0] === 'literal' && Array.isArray(value[1])) {
3490
+ return firstFontName(value[1]);
3491
+ }
3492
+ return Array.isArray(value) && typeof value[0] === 'string' ? value[0] : undefined;
3493
+ }
3494
+ function tryTranslate(value, name, layerId, coverage) {
3495
+ const notes = [];
3496
+ try {
3497
+ const fontList = name === 'text-font' ? firstFontName(value) : undefined;
3498
+ const translated = fontList !== undefined
3499
+ ? translateExpression(fontList, notes)
3500
+ : translateExpression(value, notes);
3501
+ notes.forEach((note) => coverage.approximate(note));
3502
+ return translated;
3503
+ }
3504
+ catch (error) {
3505
+ coverage.drop(name, describe(error), layerId);
3506
+ return null;
3507
+ }
3508
+ }
3509
+ function remapValue(target, translated) {
3510
+ // The two disagree on which side is positive: mapbox offsets a line to the RIGHT of its
3511
+ // direction of travel, mapnik to the LEFT. Left as-is, a cycleway drawn beside its road lands
3512
+ // on the wrong side of it. CartoCSS has no unary minus before a parenthesised value, hence
3513
+ // `0 - x` rather than `-x`.
3514
+ if (target === 'line-offset')
3515
+ return `(0 - (${translated}))`;
3516
+ const map = VALUE_MAP[target];
3517
+ if (!map)
3518
+ return translated;
3519
+ const bare = translated.replace(/^'|'$/g, '');
3520
+ return map[bare] !== undefined ? `'${map[bare]}'` : translated;
3521
+ }
3522
+ /**
3523
+ * A CartoCSS project is ONE datasource; a MapBox style may draw from several tilesets and says so
3524
+ * per layer. Flattened into one project the difference disappears, and every source-layer that came
3525
+ * from another tileset silently draws nothing - which is what hid MapTiler topo-v4's peaks, since
3526
+ * `peak` and `volcano` live in its `landform` source and not in the planet tiles the rest uses.
3527
+ *
3528
+ * There is nothing to translate here: the app has to point a layer, or a composite slot, at each
3529
+ * tileset. Naming them is what turns "the peaks are missing" into a wiring question.
3530
+ */
3531
+ function reportSources(style, layers, coverage) {
3532
+ const bySource = new Map();
3533
+ for (const layer of layers) {
3534
+ const source = layer.source;
3535
+ const sourceLayer = layer['source-layer'];
3536
+ if (!source || !sourceLayer)
3537
+ continue;
3538
+ if (!bySource.has(source))
3539
+ bySource.set(source, new Set());
3540
+ bySource.get(source).add(sourceLayer);
3541
+ }
3542
+ if (bySource.size < 2)
3543
+ return;
3544
+ const urlOf = (name) => {
3545
+ const source = style.sources?.[name];
3546
+ return source?.url ?? source?.tiles?.[0] ?? '';
3547
+ };
3548
+ const biggest = [...bySource.entries()].sort((a, b) => b[1].size - a[1].size)[0][0];
3549
+ const others = [...bySource.entries()]
3550
+ .filter(([name]) => name !== biggest)
3551
+ .map(([name, set]) => ` "${name}" (${[...set].sort().join(', ')})${urlOf(name) ? ` <- ${urlOf(name)}` : ''}`);
3552
+ coverage.note(`the style draws from ${bySource.size} tilesets and a CartoCSS project has one. "${biggest}" ` +
3553
+ 'is the biggest and is what the project assumes; every layer below needs its own datasource ' +
3554
+ 'or composite slot in the app, or it draws nothing:\n' + others.join('\n'));
3555
+ }
3556
+ /**
3557
+ * The project's `layers`, in MapBox's own draw order.
3558
+ *
3559
+ * A bare entry pulls EVERY attachment of that source-layer, so a layer MapBox interleaves with
3560
+ * another cannot be placed by one entry: Standard draws its pedestrian areas at index 3, its parks
3561
+ * at 4 and its road casings at 60, all on two source-layers, and any single depth for `road` puts
3562
+ * the pedestrian slab either over the park or under the casings.
3563
+ *
3564
+ * An entry may name ONE attachment (`road::pedestrian_polygon`), so a source-layer that MapBox
3565
+ * splits is emitted once per RUN of consecutive attachments. A layer that forms one run keeps its
3566
+ * bare entry, which is every layer of a style that interleaves nothing.
3567
+ */
3568
+ export function projectEntries(drawOrder) {
3569
+ const sorted = drawOrder.map((entry, at) => ({ entry, at }))
3570
+ .sort((a, b) => a.entry.index - b.entry.index || a.at - b.at)
3571
+ .map((item) => item.entry);
3572
+ const runs = [];
3573
+ for (const { sourceLayer, attachment } of sorted) {
3574
+ const last = runs[runs.length - 1];
3575
+ if (last && last.sourceLayer === sourceLayer)
3576
+ last.attachments.push(attachment);
3577
+ else
3578
+ runs.push({ sourceLayer, attachments: [attachment] });
3579
+ }
3580
+ const split = new Set(runs.map((run) => run.sourceLayer)
3581
+ .filter((name, at, all) => all.indexOf(name) !== at));
3582
+ return runs.flatMap((run) => (split.has(run.sourceLayer)
3583
+ ? run.attachments.map((attachment) => `${run.sourceLayer}::${attachment}`)
3584
+ : [run.sourceLayer]));
3585
+ }
3586
+ /** What the split above cost, so a project that grew entries says why. */
3587
+ function reportSplitLayers(drawOrder, coverage) {
3588
+ const entries = projectEntries(drawOrder);
3589
+ const split = new Set(entries.filter((entry) => entry.includes('::')).map((entry) => entry.split('::')[0]));
3590
+ if (split.size > 0) {
3591
+ coverage.note(`${split.size} source-layer(s) are drawn at more than one depth, because MapBox ` +
3592
+ 'interleaves them with another: ' + [...split].sort().join(', ') + '. Each is one ' +
3593
+ 'project entry per run of attachments, which is what keeps its draw order.');
3594
+ }
3595
+ }
3596
+ function describe(error) {
3597
+ return error instanceof Untranslatable ? `untranslatable: ${error.what}` : String(error);
3598
+ }
3599
+ //# sourceMappingURL=index.js.map