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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +58 -0
  2. package/dist/cli.js +436 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/generated/properties.json +2488 -0
  5. package/dist/legend-svg.js +142 -0
  6. package/dist/legend-svg.js.map +1 -0
  7. package/dist/mapbox2css/browser-host.js +27 -0
  8. package/dist/mapbox2css/browser-host.js.map +1 -0
  9. package/dist/mapbox2css/casing.js +84 -0
  10. package/dist/mapbox2css/casing.js.map +1 -0
  11. package/dist/mapbox2css/config.js +212 -0
  12. package/dist/mapbox2css/config.js.map +1 -0
  13. package/dist/mapbox2css/contour.js +61 -0
  14. package/dist/mapbox2css/contour.js.map +1 -0
  15. package/dist/mapbox2css/coverage.js +56 -0
  16. package/dist/mapbox2css/coverage.js.map +1 -0
  17. package/dist/mapbox2css/emissive.js +192 -0
  18. package/dist/mapbox2css/emissive.js.map +1 -0
  19. package/dist/mapbox2css/expression.js +606 -0
  20. package/dist/mapbox2css/expression.js.map +1 -0
  21. package/dist/mapbox2css/filter.js +285 -0
  22. package/dist/mapbox2css/filter.js.map +1 -0
  23. package/dist/mapbox2css/fold.js +588 -0
  24. package/dist/mapbox2css/fold.js.map +1 -0
  25. package/dist/mapbox2css/host.js +13 -0
  26. package/dist/mapbox2css/host.js.map +1 -0
  27. package/dist/mapbox2css/index.js +3599 -0
  28. package/dist/mapbox2css/index.js.map +1 -0
  29. package/dist/mapbox2css/narrow.js +284 -0
  30. package/dist/mapbox2css/narrow.js.map +1 -0
  31. package/dist/mapbox2css/node-host.js +36 -0
  32. package/dist/mapbox2css/node-host.js.map +1 -0
  33. package/dist/mapbox2css/placement.js +62 -0
  34. package/dist/mapbox2css/placement.js.map +1 -0
  35. package/dist/mapbox2css/png.js +264 -0
  36. package/dist/mapbox2css/png.js.map +1 -0
  37. package/dist/mapbox2css/properties.js +155 -0
  38. package/dist/mapbox2css/properties.js.map +1 -0
  39. package/dist/mapbox2css/schema.js +865 -0
  40. package/dist/mapbox2css/schema.js.map +1 -0
  41. package/dist/mapbox2css/shield.js +118 -0
  42. package/dist/mapbox2css/shield.js.map +1 -0
  43. package/dist/mapbox2css/split.js +380 -0
  44. package/dist/mapbox2css/split.js.map +1 -0
  45. package/dist/mapbox2css/sprite.js +882 -0
  46. package/dist/mapbox2css/sprite.js.map +1 -0
  47. package/dist/mapbox2css/types.js +2 -0
  48. package/dist/mapbox2css/types.js.map +1 -0
  49. package/dist/mapbox2css/variables.js +273 -0
  50. package/dist/mapbox2css/variables.js.map +1 -0
  51. package/dist/wasm.js +43 -0
  52. package/dist/wasm.js.map +1 -0
  53. package/package.json +48 -0
  54. package/wasm/massif-style.mjs +2 -0
  55. package/wasm/massif-style.wasm +0 -0
@@ -0,0 +1,865 @@
1
+ function classIs(...values) {
2
+ return values.length === 1
3
+ ? ['==', ['get', 'class'], values[0]]
4
+ : ['match', ['get', 'class'], values, true, false];
5
+ }
6
+ const MAPTILER_TO_OPENMAPTILES = {
7
+ // Same name, same meaning.
8
+ water: { layer: 'water' },
9
+ waterway: { layer: 'waterway' },
10
+ building: { layer: 'building' },
11
+ housenumber: { layer: 'housenumber' },
12
+ // landcover, split by class in OpenMapTiles.
13
+ ice: { layer: 'landcover', filter: classIs('ice') },
14
+ sand: { layer: 'landcover', filter: classIs('sand') },
15
+ wood: { layer: 'landcover', filter: classIs('wood') },
16
+ forest: { layer: 'landcover', filter: classIs('wood') },
17
+ grass: { layer: 'landcover', filter: classIs('grass') },
18
+ // OpenMapTiles has no scrub CLASS - natural=scrub lands in class 'grass' with subclass 'scrub'.
19
+ scrub: { layer: 'landcover', filter: ['==', ['get', 'subclass'], 'scrub'] },
20
+ // MapTiler's 'vegetation' layer carries the crops; OpenMapTiles calls that class 'farmland'.
21
+ vegetation: { layer: 'landcover', filter: classIs('farmland') },
22
+ // landuse, likewise.
23
+ residential: { layer: 'landuse', filter: classIs('residential') },
24
+ industrial: { layer: 'landuse', filter: classIs('industrial') },
25
+ cemetery: { layer: 'landuse', filter: classIs('cemetery') },
26
+ hospital: { layer: 'landuse', filter: classIs('hospital') },
27
+ dam: { layer: 'landuse', filter: classIs('dam') },
28
+ pedestrian: { layer: 'landuse', filter: classIs('pedestrian') },
29
+ // The style's own class test (pitch/stadium) does the work, as it does for the road layers.
30
+ leisure: { layer: 'landuse' },
31
+ park: { layer: 'park' },
32
+ protected_area_major_label: { layer: 'park' },
33
+ protected_area_minor_label: { layer: 'park' },
34
+ // transportation carries every way, and its class names are the ones the MapTiler style already
35
+ // filters on, so the style's own class test keeps working after the rename.
36
+ road: { layer: 'transportation' },
37
+ pathway: { layer: 'transportation' },
38
+ railway: { layer: 'transportation' },
39
+ ferry: { layer: 'transportation', filter: classIs('ferry') },
40
+ pier: { layer: 'transportation', filter: classIs('pier') },
41
+ aerialway: { layer: 'transportation', filter: classIs('aerialway') },
42
+ bridge: { layer: 'transportation', filter: ['==', ['get', 'brunnel'], 'bridge'] },
43
+ road_label: { layer: 'transportation_name' },
44
+ pathway_label: { layer: 'transportation_name' },
45
+ aerialway_label: { layer: 'transportation_name', filter: classIs('aerialway') },
46
+ // place, split by class.
47
+ continent_label: { layer: 'place', filter: classIs('continent') },
48
+ country_label: { layer: 'place', filter: classIs('country') },
49
+ state_label: { layer: 'place', filter: classIs('state', 'province') },
50
+ city_label: { layer: 'place', filter: classIs('city') },
51
+ town_label: { layer: 'place', filter: classIs('town') },
52
+ place_label: {
53
+ layer: 'place',
54
+ // 'locality' is the OpenMapTiles class for a named place with no settlement type, which is
55
+ // most of a French mountain valley - leaving it out dropped every hamlet name on the map.
56
+ filter: classIs('village', 'hamlet', 'suburb', 'neighbourhood', 'quarter', 'isolated_dwelling', 'locality'),
57
+ },
58
+ island_label: { layer: 'place', filter: classIs('island') },
59
+ water_label: { layer: 'water_name' },
60
+ water_centroid: { layer: 'water_name' },
61
+ peak: { layer: 'mountain_peak', filter: classIs('peak') },
62
+ volcano: { layer: 'mountain_peak', filter: classIs('volcano') },
63
+ aviation: { layer: 'aeroway' },
64
+ aviation_line: { layer: 'aeroway' },
65
+ poi_station: { layer: 'poi' },
66
+ country_border: { layer: 'boundary' },
67
+ country_border_disputed: { layer: 'boundary', filter: ['==', ['get', 'disputed'], 1] },
68
+ sub_border: { layer: 'boundary' },
69
+ // Not an OpenMapTiles layer in either schema: contours come from their own source, which an app
70
+ // merges in under this name (CompositeVectorTileLayer::addVectorDataSource). Renaming it would
71
+ // break that, so it passes through.
72
+ contour: { layer: 'contour' },
73
+ };
74
+ /**
75
+ * MapBox Streets v8 road classes on OpenMapTiles `transportation`. The `_link` classes have no
76
+ * class of their own there - a ramp is the base class with `ramp = 1` - so they are predicates, and
77
+ * the base classes then have to EXCLUDE ramps or every link is drawn twice, once at link width and
78
+ * once at motorway width.
79
+ */
80
+ const ROAD_CLASS = {
81
+ motorway: 'motorway', trunk: 'trunk', primary: 'primary', secondary: 'secondary',
82
+ tertiary: 'tertiary', street: 'minor', street_limited: 'minor', service: 'service',
83
+ track: 'track', path: 'path', pedestrian: 'path', ferry: 'ferry', aerialway: 'aerialway',
84
+ major_rail: 'rail', minor_rail: 'rail', service_rail: 'rail',
85
+ // Not carried by OpenMapTiles at all.
86
+ construction: null, golf: null, turning_circle: null, turning_loop: null, intersection: null,
87
+ };
88
+ const notRamp = (klass) => ['all', ['==', ['get', 'class'], klass], ['!=', ['get', 'ramp'], 1]];
89
+ const isRamp = (klass) => ['all', ['==', ['get', 'class'], klass], ['==', ['get', 'ramp'], 1]];
90
+ const ROAD_PREDICATES = {
91
+ motorway: notRamp('motorway'), trunk: notRamp('trunk'), primary: notRamp('primary'),
92
+ secondary: notRamp('secondary'), tertiary: notRamp('tertiary'),
93
+ motorway_link: isRamp('motorway'), trunk_link: isRamp('trunk'), primary_link: isRamp('primary'),
94
+ secondary_link: isRamp('secondary'), tertiary_link: isRamp('tertiary'),
95
+ };
96
+ /** `structure = 'none'` is the ABSENCE of brunnel in OpenMapTiles, not a value of it. */
97
+ const STRUCTURE_PREDICATES = { none: ['!', ['has', 'brunnel']] };
98
+ /**
99
+ * MapBox spells a oneway as the STRING "true"; OpenMapTiles as the number 1. Without this every
100
+ * arrow layer filters on a value the tiles never hold and no oneway arrow is drawn at all.
101
+ *
102
+ * OpenMapTiles also has `-1`, a way digitised against its direction. MapBox has no counterpart, so
103
+ * those get no arrow rather than one pointing the wrong way.
104
+ */
105
+ const ONEWAY = { true: 1, false: 0 };
106
+ /**
107
+ * OpenMapTiles has no shield artwork name and no ref length, so MapBox's `shield-reflen` sprite
108
+ * name is unspellable. Pinning both draws Standard's DEFAULT plate with the ref on it, which is a
109
+ * shield; leaving them absent drew nothing. 4 is the middle of the `default-2` ... `default-6` set
110
+ * the sprite carries, so a ref of any length gets a plate near its own width.
111
+ */
112
+ const SHIELD_CONSTANTS = {
113
+ shield: 'default', reflen: 4,
114
+ shield_beta: null, shield_text_color: null, shield_text_color_beta: null,
115
+ };
116
+ /**
117
+ * A POI carries TWO things a MapBox style reads off different fields, and OpenMapTiles puts both on
118
+ * `class`: the icon NAME (MapBox's `maki`, and OpenMapTiles' class values are already maki-ish) and
119
+ * the coarse CATEGORY that picks the label colour (MapBox's own `class`). One field can serve both,
120
+ * because a category test becomes a test for the OpenMapTiles classes that belong to it - which is
121
+ * what gets a restaurant its orange back while its icon still resolves.
122
+ *
123
+ * Each class belongs to exactly ONE category: a `match` takes its first branch, so a class listed
124
+ * twice would silently answer with whichever MapBox happened to write first.
125
+ */
126
+ const POI_CATEGORY = {
127
+ food_and_drink: ['restaurant', 'fast_food', 'cafe', 'bar', 'beer', 'ice_cream'],
128
+ food_and_drink_stores: ['grocery', 'bakery', 'butcher', 'alcohol_shop', 'greengrocer'],
129
+ store_like: ['shop', 'clothing_store', 'furniture', 'hairdresser', 'laundry', 'florist',
130
+ 'jewelry_store', 'optician', 'bicycle', 'bookshop', 'gift'],
131
+ education: ['school', 'college', 'library', 'kindergarten'],
132
+ medical: ['hospital', 'pharmacy', 'doctors', 'dentist', 'veterinary'],
133
+ sport_and_leisure: ['stadium', 'pitch', 'swimming', 'golf', 'playground', 'fitness_centre'],
134
+ arts_and_entertainment: ['art_gallery', 'music', 'cinema', 'theatre', 'nightclub', 'casino'],
135
+ historic: ['castle', 'monument', 'memorial', 'ruins', 'archaeological_site'],
136
+ landmark: ['attraction', 'place_of_worship'],
137
+ lodging: ['lodging', 'campsite'],
138
+ motorist: ['fuel', 'car', 'parking', 'charging_station'],
139
+ commercial_services: ['post', 'atm', 'bank', 'town_hall', 'police', 'fire_station'],
140
+ park_like: ['park', 'garden', 'cemetery', 'dog_park'],
141
+ visitor_amenities: ['information', 'toilets', 'drinking_water', 'picnic_site', 'harbor'],
142
+ };
143
+ const POI = {
144
+ layer: 'poi',
145
+ values: { class: POI_CATEGORY },
146
+ // `maki` names the icon and `type` is the raw OSM value, which is what `subclass` holds.
147
+ fields: { maki: 'class', maki_beta: 'class', type: 'subclass', sizerank: 'rank' },
148
+ };
149
+ /**
150
+ * MapBox picks its transit stops out of the same POI layer with `mode` and `stop_type`;
151
+ * OpenMapTiles has neither, so with those tests dropped the layer labelled EVERY poi in the transit
152
+ * style - measured on device, every shop and school came out as a station and drew over the real
153
+ * POI layer. The class test is what MapBox's `mode` was saying.
154
+ */
155
+ const POI_TRANSIT = {
156
+ layer: 'poi',
157
+ filter: classIs('bus', 'railway', 'aerialway', 'harbor', 'ferry_terminal'),
158
+ fields: { maki: 'class', maki_beta: 'class', network: 'class', type: 'subclass' },
159
+ };
160
+ /**
161
+ * OpenMapTiles spells a POI class with underscores where maki - and so every MapBox sprite - uses
162
+ * hyphens, and renames a handful outright. The icon is looked up by the FIELD's value at draw time,
163
+ * so it cannot be rewritten in the style; an extra entry in the sprite table under the name the
164
+ * tiles actually carry is what makes it resolve.
165
+ */
166
+ export const ICON_ALIASES = {
167
+ maptiler: {},
168
+ mapbox: {
169
+ town_hall: 'town-hall', fast_food: 'fast-food', ice_cream: 'ice-cream',
170
+ alcohol_shop: 'alcohol-shop', art_gallery: 'art-gallery', clothing_store: 'clothing-store',
171
+ place_of_worship: 'place-of-worship', picnic_site: 'picnic-site', dog_park: 'dog-park',
172
+ drinking_water: 'drinking-water', toilets: 'toilet', railway: 'rail',
173
+ },
174
+ };
175
+ const MAPBOX_TO_OPENMAPTILES = {
176
+ water: { layer: 'water' },
177
+ waterway: { layer: 'waterway', fields: { structure: 'brunnel', type: 'subclass' },
178
+ predicates: { structure: STRUCTURE_PREDICATES } },
179
+ housenum_label: { layer: 'housenumber', fields: { house_num: 'housenumber' } },
180
+ // One source layer, two target ones: MapBox draws a road, its arrows and its name off the same
181
+ // tile layer, OpenMapTiles puts only the NAMED ways in a second one. So the split is by whether
182
+ // the layer writes text - a oneway arrow is a symbol layer too, and it needs the road geometry
183
+ // and the `oneway` field, both of which live in `transportation`.
184
+ road: [
185
+ {
186
+ layer: 'transportation',
187
+ when: (layer) => layer.type !== 'symbol' || !layer.layout?.['text-field'],
188
+ fields: { structure: 'brunnel', type: 'subclass' },
189
+ values: { class: ROAD_CLASS, oneway: ONEWAY },
190
+ predicates: { class: ROAD_PREDICATES, structure: STRUCTURE_PREDICATES },
191
+ },
192
+ {
193
+ layer: 'transportation_name',
194
+ when: (layer) => layer.type === 'symbol' && !!layer.layout?.['text-field'],
195
+ fields: { structure: 'brunnel', type: 'subclass' },
196
+ values: { class: ROAD_CLASS },
197
+ predicates: { class: ROAD_PREDICATES, structure: STRUCTURE_PREDICATES },
198
+ constants: SHIELD_CONSTANTS,
199
+ },
200
+ ],
201
+ // MapBox splits a green area by what it IS; OpenMapTiles by whether it is ground cover or a
202
+ // human use of the ground, so one MapBox layer feeds both. A mapping whose class test cannot
203
+ // match its target drops out on its own.
204
+ landuse: [
205
+ {
206
+ layer: 'landuse',
207
+ values: {
208
+ class: {
209
+ cemetery: 'cemetery', hospital: 'hospital', school: 'school',
210
+ residential: 'residential', industrial: 'industrial',
211
+ commercial_area: 'commercial', pitch: 'pitch', '*': null,
212
+ },
213
+ },
214
+ },
215
+ {
216
+ layer: 'landcover',
217
+ values: {
218
+ class: {
219
+ wood: 'wood', scrub: 'grass', grass: 'grass', agriculture: 'farmland',
220
+ sand: 'sand', glacier: 'ice', rock: 'rock', '*': null,
221
+ },
222
+ },
223
+ // OpenMapTiles keeps the raw OSM value under `subclass`, which is what MapBox's
224
+ // `type` is - and it is how a playground or a pitch is told from plain grass.
225
+ fields: { type: 'subclass' },
226
+ // leisure=park and leisure=pitch are landcover 'grass' with their own subclass there.
227
+ predicates: {
228
+ class: {
229
+ park: ['==', ['get', 'subclass'], 'park'],
230
+ pitch: ['==', ['get', 'subclass'], 'pitch'],
231
+ },
232
+ },
233
+ },
234
+ ],
235
+ landcover: {
236
+ layer: 'landcover',
237
+ values: { class: { wood: 'wood', scrub: 'grass', grass: 'grass', crop: 'farmland', snow: 'ice' } },
238
+ },
239
+ // OpenMapTiles `park` is protected areas only; a wetland has no equivalent there.
240
+ landuse_overlay: {
241
+ layer: 'park',
242
+ values: { class: { national_park: 'national_park', '*': null } },
243
+ },
244
+ building: { layer: 'building', fields: { height: 'render_height', min_height: 'render_min_height' } },
245
+ aeroway: { layer: 'aeroway', fields: { type: 'class' } },
246
+ airport_label: {
247
+ layer: 'aerodrome_label',
248
+ values: {
249
+ class: { civil: ['international', 'public', 'regional'], military: 'military', '*': null },
250
+ },
251
+ },
252
+ admin: {
253
+ layer: 'boundary',
254
+ // MapBox counts levels from the country down (0/1/2); OpenMapTiles keeps OSM's own.
255
+ values: {
256
+ admin_level: { 0: 2, 1: 4, 2: 6 },
257
+ disputed: { true: 1, false: 0 },
258
+ maritime: { true: 1, false: 0 },
259
+ },
260
+ },
261
+ place_label: {
262
+ layer: 'place',
263
+ // symbolrank and OpenMapTiles' rank are both "lower is more important" but not the same
264
+ // scale; renaming keeps the major/minor split working, which dropping it would not.
265
+ fields: { symbolrank: 'rank' },
266
+ values: {
267
+ class: {
268
+ country: 'country', state: ['state', 'province'],
269
+ settlement: ['city', 'town', 'village', 'hamlet', 'isolated_dwelling', 'locality'],
270
+ settlement_subdivision: ['suburb', 'quarter', 'neighbourhood'],
271
+ // Every `disputed_*` class: OpenMapTiles has no worldview, so there is no such
272
+ // feature to draw and the layer that only asks for them drops out.
273
+ '*': null,
274
+ },
275
+ },
276
+ },
277
+ // MapBox's one label layer is three OpenMapTiles ones, told apart by the class the style tests.
278
+ natural_label: [
279
+ {
280
+ layer: 'water_name',
281
+ values: {
282
+ class: {
283
+ bay: 'lake', sea: 'ocean', ocean: 'ocean', water: 'lake', reservoir: 'lake',
284
+ dock: 'dock', river: 'river', canal: 'river', stream: 'river', '*': null,
285
+ },
286
+ },
287
+ },
288
+ {
289
+ layer: 'mountain_peak',
290
+ values: { class: { landform: ['peak', 'volcano'], '*': null } },
291
+ fields: { elevation_m: 'ele' },
292
+ },
293
+ { layer: 'place', values: { class: { continent: 'continent', '*': null } } },
294
+ ],
295
+ poi_label: POI,
296
+ transit_stop_label: POI_TRANSIT,
297
+ };
298
+ const TABLES = {
299
+ maptiler: MAPTILER_TO_OPENMAPTILES,
300
+ mapbox: MAPBOX_TO_OPENMAPTILES,
301
+ };
302
+ /** Why a source layer with no entry has none, so the report says something useful. */
303
+ const NO_EQUIVALENT = {
304
+ maptiler: {
305
+ archipelago_label: 'OpenMapTiles place has no archipelago class',
306
+ country_disputed_label: 'OpenMapTiles place carries no worldview fields',
307
+ },
308
+ mapbox: {
309
+ structure: 'OpenMapTiles carries no gates, fences, crosswalks or land structures',
310
+ motorway_junction: 'OpenMapTiles has no junction layer',
311
+ indoor_structure: 'OpenMapTiles carries no indoor mapping',
312
+ wind_turbine: 'OpenMapTiles has no wind turbine layer',
313
+ tree: 'OpenMapTiles carries no individual trees',
314
+ hillshade: "MapBox's vector hillshade has no OpenMapTiles counterpart - use a DEM layer",
315
+ },
316
+ };
317
+ /**
318
+ * Fields the source schema has and the target does not, per TARGET layer. A style tests these all
319
+ * the time - MapTiler gates every place label on `iso_a2`, MapBox Standard gates every POI on
320
+ * `filterrank` - and against a tileset that never carries them the test can only fail, so the layer
321
+ * draws nothing at all. That is strictly worse than not testing, so the test is dropped and
322
+ * reported, and the layer draws too much rather than nothing.
323
+ */
324
+ const MISSING_FIELDS = {
325
+ maptiler: {
326
+ place: ['iso_a2', 'iso_3166_1', 'worldview:ch', 'capital'],
327
+ boundary: ['worldview:ch'],
328
+ park: ['iso_a2'],
329
+ transportation_name: ['route_1', 'route_2', 'network'],
330
+ },
331
+ mapbox: {
332
+ // Every layer: MapBox gates most of its labels on the map's worldview, which no
333
+ // OpenMapTiles tileset carries at all.
334
+ '*': ['worldview'],
335
+ place: ['filterrank', 'text_anchor', 'type'],
336
+ boundary: ['iso_3166_1'],
337
+ poi: ['filterrank', 'mode', 'stop_type'],
338
+ aerodrome_label: ['filterrank', 'sizerank'],
339
+ water_name: ['filterrank', 'sizerank'],
340
+ mountain_peak: ['filterrank', 'sizerank', 'elevation_ft'],
341
+ transportation: ['iso_3166_1', 'shield', 'shield_beta', 'shield_text_color',
342
+ 'shield_text_color_beta', 'reflen', 'bike_lane'],
343
+ // shield / reflen are pinned to the default plate by SHIELD_CONSTANTS, not dropped.
344
+ transportation_name: ['iso_3166_1', 'len'],
345
+ building: ['underground', 'extrude', 'type'],
346
+ landuse: ['sizerank', 'type'],
347
+ landcover: ['sizerank'],
348
+ park: ['sizerank', 'type', 'filterrank'],
349
+ aeroway: ['ref'],
350
+ },
351
+ };
352
+ /** Existence tests, which have an answer we can give; everything else is a comparison. */
353
+ function existenceTest(node, missing) {
354
+ const [head, ...args] = node;
355
+ if ((head === 'has' || head === '!has') && typeof args[0] === 'string' && missing.includes(args[0])) {
356
+ return { field: args[0], present: head === 'has' };
357
+ }
358
+ if ((head === '==' || head === '!=') && args.length === 2 && args[1] === null) {
359
+ const field = fieldOf(args[0]);
360
+ if (field && missing.includes(field))
361
+ return { field, present: head === '!=' };
362
+ }
363
+ return null;
364
+ }
365
+ /**
366
+ * The field a test reads, in either the legacy or the expression spelling. Casts are transparent:
367
+ * Standard writes `["<=", ["number", ["get", "filterrank"]], ...]`, and stopping at the `number`
368
+ * left the whole test standing on a field the tiles do not carry.
369
+ */
370
+ const CASTS = new Set(['number', 'to-number', 'string', 'to-string', 'boolean', 'to-boolean']);
371
+ function fieldOf(key) {
372
+ if (typeof key === 'string')
373
+ return key;
374
+ if (!Array.isArray(key))
375
+ return null;
376
+ if (key[0] === 'get' && typeof key[1] === 'string')
377
+ return key[1];
378
+ if (typeof key[0] === 'string' && CASTS.has(key[0]) && key.length === 2)
379
+ return fieldOf(key[1]);
380
+ return null;
381
+ }
382
+ const COMPARISONS = new Set(['==', '!=', '<', '<=', '>', '>=', 'in', '!in', 'match']);
383
+ /** Whether a test's key is a plain field the target DOES carry. */
384
+ function presentField(key, missing) {
385
+ const field = fieldOf(key);
386
+ return field !== null && !missing.includes(field);
387
+ }
388
+ /** The first missing field a subtree reads, if any. Only `get`/`has`: a bare string may be a value. */
389
+ function missingFieldRead(node, missing) {
390
+ if (!Array.isArray(node))
391
+ return null;
392
+ if ((node[0] === 'get' || node[0] === 'has') && typeof node[1] === 'string'
393
+ && missing.includes(node[1])) {
394
+ return node[1];
395
+ }
396
+ for (const child of node) {
397
+ const found = missingFieldRead(child, missing);
398
+ if (found)
399
+ return found;
400
+ }
401
+ return null;
402
+ }
403
+ /**
404
+ * Drop every test on a missing field and simplify the filter around it. Substituting a bare `true`
405
+ * is not enough - `["all", true, ...]` is not a filter and the whole layer is then dropped as
406
+ * malformed - so a constant is folded into its parent instead.
407
+ *
408
+ * Returns `false` when the whole filter collapses to it: the layer can then never match.
409
+ */
410
+ export function dropMissingFieldTests(filter, targetLayer, source, onDrop) {
411
+ const table = MISSING_FIELDS[source];
412
+ const missing = [...(table['*'] ?? []), ...(table[targetLayer] ?? [])];
413
+ if (missing.length === 0 || filter === null || filter === undefined)
414
+ return filter;
415
+ const walk = (node) => {
416
+ if (!Array.isArray(node))
417
+ return node;
418
+ const existence = existenceTest(node, missing);
419
+ if (existence) {
420
+ onDrop(existence.field);
421
+ return existence.present;
422
+ }
423
+ if (node[0] === 'all' || node[0] === 'any') {
424
+ const all = node[0] === 'all';
425
+ const kept = [];
426
+ for (const child of node.slice(1)) {
427
+ const value = walk(child);
428
+ if (value === true) {
429
+ if (!all)
430
+ return true; // one true satisfies an any
431
+ continue; // and is free inside an all
432
+ }
433
+ if (value === false) {
434
+ if (all)
435
+ return false; // one false kills an all
436
+ continue; // and is free inside an any
437
+ }
438
+ kept.push(value);
439
+ }
440
+ if (kept.length === 0)
441
+ return all; // an empty all is true, an empty any is false
442
+ if (kept.length === 1)
443
+ return kept[0];
444
+ return [node[0], ...kept];
445
+ }
446
+ if (node[0] === '!' && node.length === 2) {
447
+ const value = walk(node[1]);
448
+ if (typeof value === 'boolean')
449
+ return !value;
450
+ return ['!', value];
451
+ }
452
+ // A comparison that READS a field the tiles do not carry can only ever fail. Answering it
453
+ // TRUE is the lesser wrong: the layer then draws everything it would have filtered, which
454
+ // is visible and reported, where answering false draws nothing and reads as a broken style.
455
+ //
456
+ // The whole subtree counts, not just the left operand: Standard gates its landuse polygons
457
+ // on `(0 + sizerank) - <a zoom ramp> <= 14`, and stopping at the operand left that standing
458
+ // on a field OpenMapTiles has none of.
459
+ // A test whose OWN key is a field the tiles do have keeps standing, whatever its branches
460
+ // read - `match class ... -> <a worldview test>` is a class test, and only the branch is
461
+ // unanswerable. Recursion below takes care of that one.
462
+ if (typeof node[0] === 'string' && COMPARISONS.has(node[0]) && !presentField(node[1], missing)) {
463
+ const read = missingFieldRead(node, missing);
464
+ if (read) {
465
+ onDrop(read);
466
+ return true;
467
+ }
468
+ }
469
+ // MapBox buries the worldview test inside the OUTPUT of another `match`, so stopping at the
470
+ // boolean combinators left it standing and every country label vanished. Recurse into
471
+ // everything; a `true` substituted inside a `case` or a `match` output is still valid there.
472
+ const walked = node.map((item) => walk(item));
473
+ return sameAnswer(walked) ?? walked;
474
+ };
475
+ const result = walk(filter);
476
+ if (result === true)
477
+ return null; // nothing left to test
478
+ return result; // false reaches the caller as a filter that never matches
479
+ }
480
+ /**
481
+ * A `case` or `match` all of whose branches now answer the same constant, which is what is left of a
482
+ * worldview test once the field is gone. Folding it matters: a filter that comes out a plain
483
+ * comparison becomes a bracketed CartoCSS predicate instead of a per-feature `when()`.
484
+ */
485
+ function sameAnswer(node) {
486
+ const head = node[0];
487
+ const first = head === 'case' ? 2 : head === 'match' ? 3 : -1;
488
+ if (first < 0 || node.length < first + 2)
489
+ return null;
490
+ const outputs = [node[node.length - 1]];
491
+ for (let i = first; i < node.length - 1; i += 2)
492
+ outputs.push(node[i]);
493
+ if (outputs.every((o) => o === true))
494
+ return true;
495
+ if (outputs.every((o) => o === false))
496
+ return false;
497
+ return null;
498
+ }
499
+ /** Rename a field everywhere in a value: filter tests, expressions and `{token}` text fields. */
500
+ export function renameField(value, from, to) {
501
+ if (Array.isArray(value)) {
502
+ // The legacy spelling names the field as a bare string in argument 1.
503
+ if (typeof value[0] === 'string' && value[1] === from && value[0] !== 'get') {
504
+ return [value[0], to, ...value.slice(2)];
505
+ }
506
+ if (value[0] === 'get' && value[1] === from) {
507
+ return ['get', to];
508
+ }
509
+ return value.map((item) => renameField(item, from, to));
510
+ }
511
+ if (typeof value === 'string' && value.includes(`{${from}}`)) {
512
+ return value.split(`{${from}}`).join(`{${to}}`);
513
+ }
514
+ if (value && typeof value === 'object') {
515
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renameField(item, from, to)]));
516
+ }
517
+ return value;
518
+ }
519
+ /** What one source value becomes: nothing, one target value, or several. */
520
+ function mapped(table, value) {
521
+ if (value === null || typeof value === 'object')
522
+ return { hit: false };
523
+ const entry = table[String(value)] ?? (String(value) in table ? null : table['*']);
524
+ if (entry === undefined)
525
+ return { hit: false };
526
+ if (entry === null)
527
+ return { hit: true, values: [] };
528
+ return { hit: true, values: Array.isArray(entry) ? entry : [entry] };
529
+ }
530
+ /**
531
+ * Pin a field the target does not carry to a fixed value: `["get", f]` becomes it, `["has", f]`
532
+ * becomes whether it is one. Everywhere, because the name of a road shield's sprite is spelled in
533
+ * the LAYOUT, not the filter.
534
+ */
535
+ function applyConstants(node, constants) {
536
+ if (!Array.isArray(node)) {
537
+ if (node && typeof node === 'object') {
538
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, applyConstants(item, constants)]));
539
+ }
540
+ return node;
541
+ }
542
+ if ((node[0] === 'get' || node[0] === 'has') && typeof node[1] === 'string' && node[1] in constants) {
543
+ const value = constants[node[1]];
544
+ return node[0] === 'has' ? value !== null : value;
545
+ }
546
+ return node.map((item) => applyConstants(item, constants));
547
+ }
548
+ /**
549
+ * Fold what a pinned field leaves behind. Substituting alone is not enough: MapBox spells its
550
+ * shield name `coalesce(concat(shield_beta, "-", reflen), ...)` behind a `has shield_beta` gate, and
551
+ * a literal `null` left inside that concat reached the SDK as `concat(null, '-')` - "Unsupported
552
+ * binary operation", which fails the whole stylesheet, not just the layer.
553
+ */
554
+ function foldConstant(node) {
555
+ if (!Array.isArray(node)) {
556
+ if (node && typeof node === 'object') {
557
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, foldConstant(item)]));
558
+ }
559
+ return node;
560
+ }
561
+ const folded = node.map((item) => foldConstant(item));
562
+ const head = folded[0];
563
+ const args = folded.slice(1);
564
+ if (head === 'concat')
565
+ return args.some((v) => v === null) ? null : folded;
566
+ if (CASTS.has(String(head)) && folded.length === 2 && !Array.isArray(args[0])) {
567
+ const value = args[0];
568
+ if (value === null)
569
+ return head === 'to-boolean' || head === 'boolean' ? false : null;
570
+ if (head === 'to-string' || head === 'string')
571
+ return String(value);
572
+ if (head === 'to-boolean' || head === 'boolean')
573
+ return Boolean(value);
574
+ if (head === 'to-number' || head === 'number')
575
+ return Number(value);
576
+ }
577
+ if (head === 'coalesce') {
578
+ const kept = args.filter((v) => v !== null);
579
+ if (kept.length === 0)
580
+ return null;
581
+ if (kept.length === 1)
582
+ return kept[0];
583
+ return ['coalesce', ...kept];
584
+ }
585
+ if (head === 'all' || head === 'any') {
586
+ const all = head === 'all';
587
+ const kept = [];
588
+ for (const arg of args) {
589
+ if (arg === all)
590
+ continue; // true in an all, false in an any: free
591
+ if (typeof arg === 'boolean')
592
+ return arg; // the other one decides it outright
593
+ kept.push(arg);
594
+ }
595
+ if (kept.length === 0)
596
+ return all;
597
+ if (kept.length === 1)
598
+ return kept[0];
599
+ return [head, ...kept];
600
+ }
601
+ if (head === '!' && folded.length === 2 && typeof args[0] === 'boolean')
602
+ return !args[0];
603
+ if (head === 'case' && folded.length >= 4) {
604
+ const kept = ['case'];
605
+ for (let i = 1; i < folded.length - 1; i += 2) {
606
+ if (folded[i] === false)
607
+ continue;
608
+ // A branch that always fires ENDS the chain - but only answers outright when nothing
609
+ // undecided stands before it.
610
+ if (folded[i] === true) {
611
+ return kept.length === 1 ? folded[i + 1] : [...kept, folded[i + 1]];
612
+ }
613
+ kept.push(folded[i], folded[i + 1]);
614
+ }
615
+ kept.push(folded[folded.length - 1]);
616
+ return kept.length === 2 ? kept[1] : kept;
617
+ }
618
+ if (head === 'match' && folded.length >= 4 && args[0] === null)
619
+ return folded[folded.length - 1];
620
+ // `reflen <= 6` on a pinned reflen is a constant, and left standing it is a per-feature when().
621
+ if (typeof head === 'string' && COMPARISONS.has(head) && folded.length === 3
622
+ && !Array.isArray(args[0]) && !Array.isArray(args[1])
623
+ && args[0] !== null && args[1] !== null && head !== 'match') {
624
+ const [a, b] = args;
625
+ switch (head) {
626
+ case '==': return a === b;
627
+ case '!=': return a !== b;
628
+ case '<': return a < b;
629
+ case '<=': return a <= b;
630
+ case '>': return a > b;
631
+ case '>=': return a >= b;
632
+ default: break;
633
+ }
634
+ }
635
+ return folded;
636
+ }
637
+ /** Several source values often share one target one - major_rail and minor_rail are both `rail`. */
638
+ function dedupe(values) {
639
+ const seen = new Set();
640
+ return values.filter((v) => {
641
+ const key = JSON.stringify(v);
642
+ if (seen.has(key))
643
+ return false;
644
+ seen.add(key);
645
+ return true;
646
+ });
647
+ }
648
+ /**
649
+ * Rewrite a field's VALUES to what the target schema calls them, wherever the field is read - the
650
+ * filter and the paint alike, because a style picks a road's colour with a `match` on the class it
651
+ * also filters on.
652
+ */
653
+ export function remapValues(node, values) {
654
+ if (!Array.isArray(node)) {
655
+ if (node && typeof node === 'object') {
656
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, remapValues(item, values)]));
657
+ }
658
+ return node;
659
+ }
660
+ const head = node[0];
661
+ const field = typeof head === 'string' ? fieldOf(node[1]) : null;
662
+ const table = field ? values[field] : undefined;
663
+ if (table && (head === '==' || head === '!=')) {
664
+ const result = mapped(table, node[2]);
665
+ if (result.hit) {
666
+ const equal = head === '==';
667
+ const unique = dedupe(result.values);
668
+ if (unique.length === 0)
669
+ return equal ? false : true;
670
+ if (unique.length === 1)
671
+ return [head, node[1], unique[0]];
672
+ const each = unique.map((v) => [equal ? '==' : '!=', node[1], v]);
673
+ return [equal ? 'any' : 'all', ...each];
674
+ }
675
+ }
676
+ if (table && (head === 'in' || head === '!in')) {
677
+ const out = [];
678
+ for (const value of node.slice(2)) {
679
+ const result = mapped(table, value);
680
+ if (!result.hit)
681
+ out.push(value);
682
+ else
683
+ out.push(...result.values);
684
+ }
685
+ const unique = dedupe(out);
686
+ if (unique.length === 0)
687
+ return head === 'in' ? false : true;
688
+ return [head, node[1], ...unique];
689
+ }
690
+ // `match` is both a filter and a paint value; its labels are at the odd positions and the last
691
+ // argument is the default. A branch whose every label drops takes its output with it.
692
+ if (table && head === 'match' && node.length >= 4) {
693
+ const out = ['match', remapValues(node[1], values)];
694
+ for (let i = 2; i < node.length - 1; i += 2) {
695
+ const labels = Array.isArray(node[i]) ? node[i] : [node[i]];
696
+ const kept = [];
697
+ for (const label of labels) {
698
+ const result = mapped(table, label);
699
+ if (!result.hit)
700
+ kept.push(label);
701
+ else
702
+ kept.push(...result.values);
703
+ }
704
+ const unique = dedupe(kept);
705
+ if (unique.length === 0)
706
+ continue;
707
+ out.push(unique.length === 1 && !Array.isArray(node[i]) ? unique[0] : unique);
708
+ out.push(remapValues(node[i + 1], values));
709
+ }
710
+ out.push(remapValues(node[node.length - 1], values));
711
+ // Nothing but the default left: a `match` needs at least one branch, so hand back the
712
+ // default itself - which for a filter is the `false` that drops the layer.
713
+ return (out.length === 3 ? out[2] : out);
714
+ }
715
+ return node.map((item) => remapValues(item, values));
716
+ }
717
+ /**
718
+ * Replace `field = value` tests the target expresses with a SECOND field. Only the equality
719
+ * spellings, wherever they appear: those are boolean contexts, and a `match` whose outputs are not
720
+ * booleans is a value table, not a test, so it is left to the value map.
721
+ */
722
+ function applyPredicates(node, predicates) {
723
+ if (!Array.isArray(node)) {
724
+ if (node && typeof node === 'object') {
725
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, applyPredicates(item, predicates)]));
726
+ }
727
+ return node;
728
+ }
729
+ const head = node[0];
730
+ const field = typeof head === 'string' ? fieldOf(node[1]) : null;
731
+ const table = field ? predicates[field] : undefined;
732
+ if (table && (head === '==' || head === '!=')) {
733
+ const clause = table[String(node[2])];
734
+ if (clause !== undefined)
735
+ return head === '==' ? clause : ['!', clause];
736
+ }
737
+ if (table && (head === 'in' || head === '!in')) {
738
+ const clauses = node.slice(2).map((v) => table[String(v)] ?? ['==', node[1], v]);
739
+ if (node.slice(2).some((v) => table[String(v)] !== undefined)) {
740
+ const any = clauses.length === 1 ? clauses[0] : ['any', ...clauses];
741
+ return head === 'in' ? any : ['!', any];
742
+ }
743
+ }
744
+ if (table && head === 'match' && node.length >= 4 && node[node.length - 1] === false) {
745
+ // The expression spelling of `in`: match these labels -> true, anything else -> false.
746
+ const clauses = [];
747
+ let rewrote = false;
748
+ for (let i = 2; i < node.length - 1; i += 2) {
749
+ if (node[i + 1] !== true)
750
+ return node.map((item) => applyPredicates(item, predicates));
751
+ for (const label of Array.isArray(node[i]) ? node[i] : [node[i]]) {
752
+ const clause = table[String(label)];
753
+ if (clause !== undefined) {
754
+ clauses.push(clause);
755
+ rewrote = true;
756
+ }
757
+ else
758
+ clauses.push(['==', node[1], label]);
759
+ }
760
+ }
761
+ if (rewrote)
762
+ return clauses.length === 1 ? clauses[0] : ['any', ...clauses];
763
+ }
764
+ // A `match` that picks a VALUE cannot carry a predicate as a label, so it becomes a `case`.
765
+ // This is what a road's width is: `match class [motorway] 3.2 ... [motorway_link] 0.8`, and
766
+ // left alone the link branch is unreachable on a tileset where a ramp IS a motorway - every
767
+ // ramp drew at full motorway width.
768
+ if (table && head === 'match' && node.length >= 4) {
769
+ const labelsOf = (i) => (Array.isArray(node[i]) ? node[i] : [node[i]]);
770
+ let needed = false;
771
+ for (let i = 2; i < node.length - 1; i += 2) {
772
+ if (labelsOf(i).some((l) => table[String(l)] !== undefined))
773
+ needed = true;
774
+ }
775
+ if (needed) {
776
+ const out = ['case'];
777
+ for (let i = 2; i < node.length - 1; i += 2) {
778
+ const clauses = labelsOf(i).map((l) => table[String(l)] ?? ['==', node[1], l]);
779
+ out.push(clauses.length === 1 ? clauses[0] : ['any', ...clauses]);
780
+ out.push(applyPredicates(node[i + 1], predicates));
781
+ }
782
+ out.push(applyPredicates(node[node.length - 1], predicates));
783
+ return out;
784
+ }
785
+ }
786
+ return node.map((item) => applyPredicates(item, predicates));
787
+ }
788
+ /** Source layers only one of the two vocabularies has, which is how a style names itself. */
789
+ const SIGNATURE = {
790
+ mapbox: ['place_label', 'poi_label', 'natural_label', 'landuse_overlay', 'motorway_junction',
791
+ 'admin', 'structure', 'transit_stop_label', 'airport_label', 'housenum_label'],
792
+ maptiler: ['forest', 'grass', 'scrub', 'vegetation', 'city_label', 'town_label',
793
+ 'country_border', 'sub_border', 'poi_station', 'housenumber', 'peak', 'volcano',
794
+ 'road_label', 'aviation', 'water_label'],
795
+ };
796
+ /**
797
+ * Which vocabulary the style's source layers are written in. Only a CLEAR winner counts: a tie is
798
+ * returned as null so the caller can ask, rather than retargeting half the style at the wrong
799
+ * table and drawing the wrong features.
800
+ */
801
+ export function detectSourceSchema(sourceLayers) {
802
+ const names = new Set(sourceLayers);
803
+ const score = (schema) => SIGNATURE[schema].filter((n) => names.has(n)).length;
804
+ const mapbox = score('mapbox');
805
+ const maptiler = score('maptiler');
806
+ if (mapbox >= 2 && mapbox > maptiler)
807
+ return 'mapbox';
808
+ if (maptiler >= 2 && maptiler > mapbox)
809
+ return 'maptiler';
810
+ return null;
811
+ }
812
+ export function mapSourceLayer(sourceLayer, schema, source) {
813
+ if (schema !== 'openmaptiles')
814
+ return { mappings: [{ layer: sourceLayer }] };
815
+ const entry = TABLES[source][sourceLayer];
816
+ if (entry)
817
+ return { mappings: Array.isArray(entry) ? entry : [entry] };
818
+ return { mappings: [], why: NO_EQUIVALENT[source][sourceLayer] ?? 'no OpenMapTiles equivalent' };
819
+ }
820
+ /** The layer's own filter with the mapping's extra clause ANDed in. */
821
+ export function withMappingFilter(layer, mapping) {
822
+ const own = layer.filter ?? null;
823
+ if (!mapping.filter)
824
+ return own;
825
+ return own === null ? mapping.filter : ['all', mapping.filter, own];
826
+ }
827
+ /**
828
+ * The layer as the target schema would have written it, or null when its filter can never match
829
+ * there - which is how one MapBox layer feeding several targets picks the ones it belongs to.
830
+ *
831
+ * Order matters: predicates and value maps are keyed by the SOURCE field names, so both run before
832
+ * the rename, and dropping a test on a field the target lacks runs before the rename too.
833
+ */
834
+ export function retargetLayer(layer, mapping, source, notes) {
835
+ if (mapping.when && !mapping.when(layer))
836
+ return null;
837
+ const filter = withMappingFilter(layer, mapping);
838
+ let out = { ...layer, filter: (filter ?? undefined) };
839
+ if (mapping.constants) {
840
+ out = foldConstant(applyConstants(out, mapping.constants));
841
+ }
842
+ if (mapping.predicates) {
843
+ out = applyPredicates(out, mapping.predicates);
844
+ }
845
+ if (mapping.values) {
846
+ out = remapValues(out, mapping.values);
847
+ }
848
+ const merged = dropMissingFieldTests(out.filter ?? null, mapping.layer, source, notes.dropped);
849
+ if (merged === false)
850
+ return null;
851
+ // A layer that had a filter and has none left now matches EVERY feature of its target. That is
852
+ // how Standard's transit stops came to label every shop and school: their `mode`/`stop_type`
853
+ // tests are all OpenMapTiles has no counterpart for, so nothing was left to select on.
854
+ if (out.filter !== undefined && merged === null) {
855
+ notes.approximate('every test in its filter was on a field the target does not carry, ' +
856
+ 'so it now draws the whole layer');
857
+ }
858
+ out = { ...out, filter: (merged ?? undefined) };
859
+ for (const [from, to] of Object.entries(mapping.fields ?? {})) {
860
+ out = renameField(out, from, to);
861
+ notes.approximate(`"${from}" read as the target's "${to}"`);
862
+ }
863
+ return out;
864
+ }
865
+ //# sourceMappingURL=schema.js.map