@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.
- package/README.md +58 -0
- package/dist/cli.js +436 -0
- package/dist/cli.js.map +1 -0
- package/dist/generated/properties.json +2488 -0
- package/dist/legend-svg.js +142 -0
- package/dist/legend-svg.js.map +1 -0
- package/dist/mapbox2css/browser-host.js +27 -0
- package/dist/mapbox2css/browser-host.js.map +1 -0
- package/dist/mapbox2css/casing.js +84 -0
- package/dist/mapbox2css/casing.js.map +1 -0
- package/dist/mapbox2css/config.js +212 -0
- package/dist/mapbox2css/config.js.map +1 -0
- package/dist/mapbox2css/contour.js +61 -0
- package/dist/mapbox2css/contour.js.map +1 -0
- package/dist/mapbox2css/coverage.js +56 -0
- package/dist/mapbox2css/coverage.js.map +1 -0
- package/dist/mapbox2css/emissive.js +192 -0
- package/dist/mapbox2css/emissive.js.map +1 -0
- package/dist/mapbox2css/expression.js +606 -0
- package/dist/mapbox2css/expression.js.map +1 -0
- package/dist/mapbox2css/filter.js +285 -0
- package/dist/mapbox2css/filter.js.map +1 -0
- package/dist/mapbox2css/fold.js +588 -0
- package/dist/mapbox2css/fold.js.map +1 -0
- package/dist/mapbox2css/host.js +13 -0
- package/dist/mapbox2css/host.js.map +1 -0
- package/dist/mapbox2css/index.js +3599 -0
- package/dist/mapbox2css/index.js.map +1 -0
- package/dist/mapbox2css/narrow.js +284 -0
- package/dist/mapbox2css/narrow.js.map +1 -0
- package/dist/mapbox2css/node-host.js +36 -0
- package/dist/mapbox2css/node-host.js.map +1 -0
- package/dist/mapbox2css/placement.js +62 -0
- package/dist/mapbox2css/placement.js.map +1 -0
- package/dist/mapbox2css/png.js +264 -0
- package/dist/mapbox2css/png.js.map +1 -0
- package/dist/mapbox2css/properties.js +155 -0
- package/dist/mapbox2css/properties.js.map +1 -0
- package/dist/mapbox2css/schema.js +865 -0
- package/dist/mapbox2css/schema.js.map +1 -0
- package/dist/mapbox2css/shield.js +118 -0
- package/dist/mapbox2css/shield.js.map +1 -0
- package/dist/mapbox2css/split.js +380 -0
- package/dist/mapbox2css/split.js.map +1 -0
- package/dist/mapbox2css/sprite.js +882 -0
- package/dist/mapbox2css/sprite.js.map +1 -0
- package/dist/mapbox2css/types.js +2 -0
- package/dist/mapbox2css/types.js.map +1 -0
- package/dist/mapbox2css/variables.js +273 -0
- package/dist/mapbox2css/variables.js.map +1 -0
- package/dist/wasm.js +43 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +48 -0
- package/wasm/massif-style.mjs +2 -0
- 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
|