@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,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
|