@massif-maps/style-tools 6.1.1 → 6.1.3-dev.142

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.
@@ -6,10 +6,10 @@ import { translateFilter, zoomPredicates } from './filter.js';
6
6
  import { HANDLED_ELSEWHERE, followsLine, repeatsAlongLine, resolvePlacement } from './placement.js';
7
7
  import { KNOWN_GAPS, LAYER_SYMBOLIZER, PROPERTY_MAP, VALUE_MAP } from './properties.js';
8
8
  import { PLATE_MAP, asShieldDeclaration, isShieldLayer, plateRadius } from './shield.js';
9
- import { describeFlatPlate, extractAllIconPlates, extractAllIcons, extractIcon, extractIconPlate } from './sprite.js';
9
+ import { describeFlatPlate, extractAllIconPlates, extractAllIcons, extractIcon, extractIconPlate, hasIcon } from './sprite.js';
10
10
  import { ICON_ALIASES, detectSourceSchema, mapSourceLayer, retargetLayer } from './schema.js';
11
11
  import { narrowLayer } from './narrow.js';
12
- import { collapseBranches, expandSetFilter, expandSortKey, splitLayer } from './split.js';
12
+ import { BAND_MAXZOOM, BAND_MINZOOM, collapseBranches, expandSetFilter, expandSortKey, splitLayer } from './split.js';
13
13
  import { hoistVariables, paletteHeader } from './variables.js';
14
14
  import { LIGHT_PRESET, importOnly, liveConfig, presetsOf, resolveConfig, sceneBrightness } from './config.js';
15
15
  import { ICON_PARAMS, ICON_PARAM_SCOPE, RECOLOURABLE_ICON, foldConfig, foldLayer, isPitch, toHsla } from './fold.js';
@@ -227,7 +227,9 @@ export function convert(style, table, options = {}) {
227
227
  continue;
228
228
  }
229
229
  configValues.delete(name);
230
- options.styleParams.set(name, spec.values ? { default: spec.default, values: spec.values } : spec.default);
230
+ // a bare object or array would read as a declaration or an enum, so a table keeps its `default`
231
+ const table = typeof spec.default === 'object' && spec.default !== null;
232
+ options.styleParams.set(name, spec.values || table ? { default: spec.default, ...(spec.values ? { values: spec.values } : {}) } : spec.default);
231
233
  coverage.note(`config "${name}" stays a style parameter, settable on a running map`);
232
234
  }
233
235
  if (configValues.size > 0) {
@@ -388,18 +390,21 @@ export function convert(style, table, options = {}) {
388
390
  : schemaLayer;
389
391
  // A field-driven paint value becomes one attachment per branch, and a line-sort-key one
390
392
  // per key value in draw order - see split.ts.
391
- const variants = expandSortKey(isContourLayer(layer) ? retargeted : schemaLayer, coverage)
392
- .flatMap(expandSetFilter)
393
- .flatMap((ordered) => splitLayer(ordered, coverage))
394
- .flatMap((ordered) => splitDashByZoom(ordered, coverage))
395
- .map(narrowLayer);
393
+ // An attachment per sort key, lowest first - the only split that has to ORDER its parts.
394
+ // The rest have exclusive filters, so they are rules of that one attachment: an
395
+ // attachment walks the whole source layer per tile, and its features keep the tile's
396
+ // order, as MapBox draws one layer.
397
+ const ordered = expandSortKey(isContourLayer(layer) ? retargeted : schemaLayer, coverage);
396
398
  // `massif:attachment`: layers drawing into ONE attachment, so a child project's rule on it
397
399
  // merges with whichever of them matches instead of drawing the feature a second time.
398
400
  const sharedAttachment = layer.metadata?.['massif:attachment'];
399
- variants.forEach((variant, branch) => {
400
- const suffix = variants.length > 1 ? `_b${branch + 1}` : '';
401
+ ordered.forEach((sortKeyed, branch) => {
402
+ const suffix = ordered.length > 1 ? `_b${branch + 1}` : '';
401
403
  const attachment = typeof sharedAttachment === 'string' ? attachmentName(sharedAttachment) : `${attachmentName(layer.id)}${suffix}`;
402
- emitLayer(variant, attachment, target, symbolizer, index);
404
+ expandSetFilter(sortKeyed)
405
+ .flatMap((variant) => splitLayer(variant, coverage))
406
+ .map((variant) => narrowLayer(bandDashByZoom(variant, coverage)))
407
+ .forEach((variant) => emitLayer(variant, attachment, target, symbolizer, index));
403
408
  });
404
409
  }
405
410
  });
@@ -554,9 +559,13 @@ export function convert(style, table, options = {}) {
554
559
  declarations.push(`${property}: ${value};`);
555
560
  }
556
561
  const range = Array.isArray(sdk.visibleZoomRange) ? sdk.visibleZoomRange : [];
562
+ // `massif:maxzoom-param`: the slot ends where an app says, past the standalone layer's visibleZoomRange
563
+ const maxParam = layer.metadata?.['massif:maxzoom-param'];
564
+ const hasMaxParam = typeof maxParam === 'string' && zoomOffsetLevels() === 0;
557
565
  let selector;
558
566
  try {
559
- selector = `#${layer.id}${[...zoomPredicates(range[0] || layer.minzoom, range[1] ?? layer.maxzoom),
567
+ selector = `#${layer.id}${[...(hasMaxParam ? [`[zoom < 'param::${maxParam}']`] : []),
568
+ ...zoomPredicates(range[0] || layer.minzoom, hasMaxParam ? undefined : range[1] ?? layer.maxzoom),
560
569
  ...translateFilter(layer.filter ?? null)].join('')}`;
561
570
  }
562
571
  catch (error) {
@@ -626,15 +635,18 @@ export function convert(style, table, options = {}) {
626
635
  // when the style compiles - a child project overrides it in its `constants`, at no runtime cost.
627
636
  const minConst = layer.metadata?.['massif:minzoom-const'];
628
637
  const maxConst = layer.metadata?.['massif:maxzoom-const'];
629
- if (typeof minConst === 'string' && typeof layer.minzoom === 'number')
638
+ // a zoom band of a split layer (splitIconByZoom) tests its own edge beside the constant
639
+ const bandMin = layer.metadata?.[BAND_MINZOOM];
640
+ const bandMax = layer.metadata?.[BAND_MAXZOOM];
641
+ if (typeof minConst === 'string' && typeof layer.minzoom === 'number' && bandMin === undefined)
630
642
  constants.set(minConst, Math.floor(layer.minzoom) + zoomOffsetLevels());
631
- if (typeof maxConst === 'string' && typeof layer.maxzoom === 'number')
643
+ if (typeof maxConst === 'string' && typeof layer.maxzoom === 'number' && bandMax === undefined)
632
644
  constants.set(maxConst, Math.ceil(layer.maxzoom) + zoomOffsetLevels());
633
645
  const predicates = [
634
646
  ...(hasParam ? [`[zoom >= 'param::${minParam}']`] : []),
635
647
  ...(typeof minConst === 'string' ? [`[zoom >= $${minConst}]`] : []),
636
648
  ...(typeof maxConst === 'string' ? [`[zoom < $${maxConst}]`] : []),
637
- ...zoomPredicates(paramStart || typeof minConst === 'string' ? undefined : layer.minzoom, typeof maxConst === 'string' ? undefined : layer.maxzoom),
649
+ ...zoomPredicates(paramStart || (typeof minConst === 'string' && bandMin === undefined) ? undefined : layer.minzoom, typeof maxConst === 'string' && bandMax === undefined ? undefined : layer.maxzoom),
638
650
  ...(buildings ? [buildings] : []),
639
651
  ...translateFilter(filter),
640
652
  ].map((p) => (p.startsWith('when(') ? ` ${p}` : p));
@@ -735,6 +747,16 @@ export function convert(style, table, options = {}) {
735
747
  options.styleParams.clear();
736
748
  for (const [name, value] of presetParams.get('') ?? [])
737
749
  options.styleParams.set(name, value);
750
+ // `massif:attachment` names an attachment a child project's rules join, so it keeps its name
751
+ const sharedAttachments = new Set((style.layers ?? [])
752
+ .map((layer) => layer.metadata?.['massif:attachment'])
753
+ .filter((name) => typeof name === 'string').map(attachmentName));
754
+ const merged = mergeIdenticalPaint(blocks, drawOrder, [...alternates.values()].map((result) => result.blocks), sharedAttachments);
755
+ if (merged > 0) {
756
+ coverage.note(`${merged} attachment(s) joined the one drawn just before them: same source-layer, ` +
757
+ 'and every rule of both paints the same opaque lines or fills in every preset, so their ' +
758
+ 'order cannot show and one pass over the layer draws both');
759
+ }
738
760
  let hoisted = withMap(base);
739
761
  let palette = [];
740
762
  const presetPalettes = new Map();
@@ -836,7 +858,7 @@ export function convert(style, table, options = {}) {
836
858
  for (const [preset, params] of presetParams) {
837
859
  if (preset === '')
838
860
  continue;
839
- const differing = [...params].filter(([name, value]) => shared.get(name) !== value);
861
+ const differing = [...params].filter(([name, value]) => JSON.stringify(shared.get(name)) !== JSON.stringify(value));
840
862
  if (differing.length > 0)
841
863
  presetOverrides.set(preset, Object.fromEntries(differing.sort()));
842
864
  }
@@ -1522,29 +1544,40 @@ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerI
1522
1544
  continue;
1523
1545
  }
1524
1546
  const zoom = dash.zoom;
1525
- const pattern = (layer.dashZoom === undefined ? null : dashPatternAt(value, layer.dashZoom)) ?? dash.pattern;
1547
+ const width = dashWidth(layer.paint?.['line-width']);
1526
1548
  // `[1, 0]` is MapBox's spelling of SOLID. Scaled by a line width it became a 430 px
1527
1549
  // "dash", which is a 430 px bitmap rasterized to draw an unbroken line.
1528
- if (!pattern.some((v, i) => i % 2 === 1 && v > 0)) {
1550
+ const gaps = (pattern) => pattern.some((v, i) => i % 2 === 1 && v > 0);
1551
+ if (layer.dashBands !== undefined) {
1552
+ // A banded dash reads the width in the middle of its OWN band; see bandDashByZoom.
1553
+ const frames = layer.dashBands.map(({ from, dashZoom }) => {
1554
+ const pattern = dashPatternAt(value, dashZoom) ?? dash.pattern;
1555
+ const scale = rampAt(width, dashZoom) ?? (zoom === null ? null : rampAt(width, zoom))
1556
+ ?? representativeScale(width, 1);
1557
+ const pixels = gaps(pattern) ? pixelDashes(pattern.map((v) => v * scale)).map(round).join(',') : '';
1558
+ return `(${Math.floor(from) + zoomOffsetLevels()}, '${pixels}')`;
1559
+ });
1560
+ out.push(`line-dasharray: step([zoom], ${frames.join(', ')});`);
1561
+ coverage.emit('line-dasharray');
1562
+ continue;
1563
+ }
1564
+ const pattern = dash.pattern;
1565
+ if (!gaps(pattern)) {
1529
1566
  coverage.drop(name, 'the pattern has no gap, so the line is solid', layer.id);
1530
1567
  continue;
1531
1568
  }
1532
- if (pattern !== value && layer.dashZoom === undefined) {
1569
+ if (pattern !== value) {
1533
1570
  // MapTiler ramps its path dashes over zoom and CartoCSS takes ONE pattern. The
1534
1571
  // base (the widest band, and every stop below the first) is what is on screen at
1535
1572
  // nearly every zoom; taking nothing left every footway drawn solid.
1536
1573
  coverage.approximate(`line-dasharray taken at one stop, ${pattern.join(',')}: ` +
1537
1574
  'CartoCSS takes one dash pattern, not a ramp');
1538
1575
  }
1539
- const width = dashWidth(layer.paint?.['line-width']);
1540
1576
  // At the zoom the pattern is CHOSEN at, not the mean of the width's stops: Standard's
1541
1577
  // steps ramp to 80 px by z22, so the mean is 43 and its 0.2 dash came out at 8.6 px
1542
1578
  // where gl-js draws under 2 - coarse bands instead of fine treads.
1543
- // A banded attachment reads the width in the middle of its OWN band; see splitDashByZoom.
1544
- const banded = layer.dashZoom === undefined ? null : rampAt(width, layer.dashZoom);
1545
- const scale = banded ?? (zoom === null ? null : rampAt(width, zoom))
1546
- ?? representativeScale(width, 1);
1547
- if (typeof width !== 'number' && banded === null) {
1579
+ const scale = (zoom === null ? null : rampAt(width, zoom)) ?? representativeScale(width, 1);
1580
+ if (typeof width !== 'number') {
1548
1581
  coverage.approximate(`line-dasharray scaled by ${round(scale)}, a zoom-driven ` +
1549
1582
  'line-width read at one zoom: CartoCSS takes one dash pattern, not a ramp');
1550
1583
  }
@@ -1708,7 +1741,7 @@ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerI
1708
1741
  * border falls exactly where the artwork's ring was. All three colours are style properties here,
1709
1742
  * so they are evaluated per feature, which is what gets a POI its class colour back.
1710
1743
  */
1711
- function iconPlateDeclarations(layer, icon, sized, coverage, options) {
1744
+ function iconPlateDeclarations(layer, icon, sized, coverage, options, fixedBox = false) {
1712
1745
  const params = layer.layout?.[ICON_PARAMS];
1713
1746
  if (!icon.plate || !params)
1714
1747
  return [];
@@ -1719,6 +1752,7 @@ function iconPlateDeclarations(layer, icon, sized, coverage, options) {
1719
1752
  // Radius and ring are the artwork's pixels at icon-size 1, the plate's are the screen's: at
1720
1753
  // icon-size 0.4 a ring stated 3 drew 3 px where MapLibre draws 1.2, and the disc grew with it.
1721
1754
  const sizedBy = (value) => (sized === '1' ? value : `((${value}) * (${sized}))`);
1755
+ const stated = new Map();
1722
1756
  const colour = (name, target, gate = false, size = false) => {
1723
1757
  if (params[name] === undefined)
1724
1758
  return false;
@@ -1731,6 +1765,7 @@ function iconPlateDeclarations(layer, icon, sized, coverage, options) {
1731
1765
  const value = size ? sizedBy(translated) : translated;
1732
1766
  out.push(`${target}: ${gate ? scoped(value) : value};`);
1733
1767
  coverage.emit(target);
1768
+ stated.set(target, translated);
1734
1769
  return true;
1735
1770
  };
1736
1771
  // The glyph, unless the layer states an icon-color of its own - that one is already out.
@@ -1759,6 +1794,17 @@ function iconPlateDeclarations(layer, icon, sized, coverage, options) {
1759
1794
  if (!colour('background-stroke-width', 'shield-icon-background-border-width', false, true)) {
1760
1795
  out.push(`shield-icon-background-border-width: ${sizedBy(String(round(icon.plate.borderWidth)))};`);
1761
1796
  coverage.emit('shield-icon-background-border-width');
1797
+ stated.set('shield-icon-background-border-width', String(round(icon.plate.borderWidth)));
1798
+ }
1799
+ }
1800
+ // A glyph's box is its own outline, so a plate fitted to it took each glyph's shape: the disc's box
1801
+ // is stated instead, ring included (LabelPlateStyle::size is the outer size).
1802
+ if (fixedBox) {
1803
+ const ring = stated.get('shield-icon-background-border-width');
1804
+ for (const [axis, side] of [['width', icon.width], ['height', icon.height]]) {
1805
+ const outer = ring !== undefined ? `(${round(side)} + 2 * (${ring}))` : String(round(side));
1806
+ out.push(`shield-icon-background-${axis}: ${sizedBy(outer)};`);
1807
+ coverage.emit(`shield-icon-background-${axis}`);
1762
1808
  }
1763
1809
  }
1764
1810
  // MapBox's `icon-stroke` is the outline it draws UNDER the glyph, which is exactly what the
@@ -2002,7 +2048,7 @@ function shieldImageDeclarations(layer, icon, scale, coverage, options) {
2002
2048
  // white one round a POI glyph, and it is NOT the text halo - the two are separate in
2003
2049
  // mapbox and now separate here.
2004
2050
  emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
2005
- emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false);
2051
+ emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false, options);
2006
2052
  out.push(...iconPlateDeclarations(layer, icon, sized, coverage, options));
2007
2053
  }
2008
2054
  else if (layer.layout?.[RECOLOURABLE_ICON] === true) {
@@ -2061,26 +2107,26 @@ function dashPattern(value) {
2061
2107
  return { pattern, zoom: stopZoomOf(value, pattern) };
2062
2108
  }
2063
2109
  /**
2064
- * A dashed line whose WIDTH ramps over zoom, as one attachment per zoom band.
2110
+ * A dashed line whose WIDTH ramps over zoom, as one `step([zoom], ...)` dash over zoom bands.
2065
2111
  *
2066
- * A MapBox dash length is a multiple of the line width, and CartoCSS takes ONE pattern of PIXELS per
2067
- * rule - the decoder rasterises it into a bitmap keyed by the literal string, so it cannot be a
2068
- * function of anything. A single rule is therefore only right at one zoom: Liberty's rail hatching
2112
+ * A MapBox dash length is a multiple of the line width, and CartoCSS takes a pattern of PIXELS - the
2113
+ * decoder rasterises it into a bitmap per tile, keyed by the string, so it can follow the TILE's zoom
2114
+ * but not the view's. One pattern is therefore only right at one zoom: Liberty's rail hatching
2069
2115
  * ramps its width 3 -> 8 between z15 and z20, and the one scale we could pick drew the dash 1.8x too
2070
2116
  * long at the bottom of that range and 0.7x too short at the top.
2071
2117
  *
2072
- * Banded, each attachment scales its dash by the width in the MIDDLE of its own band, and the bands
2073
- * are cut where the width DOUBLES - so the worst error inside one is a factor of sqrt(2) instead of
2074
- * the whole ramp. The outer edges keep the layer's own zoom range, so nothing stops being drawn.
2118
+ * Banded, each band scales its dash by the width in the MIDDLE of its own band, and the bands are
2119
+ * cut where the width DOUBLES - so the worst error inside one is a factor of sqrt(2) instead of the
2120
+ * whole ramp. One rule carries them all: a band per attachment was a style each, ~200 in Massif.
2075
2121
  *
2076
2122
  * It lives here rather than in split.ts because it needs `dashPattern` and `rampAt`, and moving
2077
2123
  * those would cost an import cycle for one caller.
2078
2124
  */
2079
- function splitDashByZoom(layer, coverage) {
2125
+ function bandDashByZoom(layer, coverage) {
2080
2126
  const dash = layer.paint?.['line-dasharray'];
2081
2127
  const width = dashWidth(layer.paint?.['line-width']);
2082
- if (dash === undefined || layer.dashZoom !== undefined || dashPattern(dash) === null)
2083
- return [layer];
2128
+ if (dash === undefined || layer.dashBands !== undefined || dashPattern(dash) === null)
2129
+ return layer;
2084
2130
  const zmin = layer.minzoom ?? 0;
2085
2131
  const zmax = Math.min(layer.maxzoom ?? 24, 24);
2086
2132
  // A dash the style STEPS over zoom is a rule per step: one pattern for all of them drew Standard's
@@ -2104,7 +2150,7 @@ function splitDashByZoom(layer, coverage) {
2104
2150
  cuts.add(at);
2105
2151
  }
2106
2152
  if (!cuts.size)
2107
- return [layer];
2153
+ return layer;
2108
2154
  const edges = [zmin, ...[...cuts].sort((a, b) => a - b), zmax];
2109
2155
  const bands = [];
2110
2156
  for (let i = 0; i + 1 < edges.length; i++) {
@@ -2116,28 +2162,20 @@ function splitDashByZoom(layer, coverage) {
2116
2162
  if (stops.length && !((rampAt(width, dashZoom) ?? 0) > 0) && Math.max(from, lo) < b) {
2117
2163
  dashZoom = (Math.max(from, lo) + b) / 2;
2118
2164
  }
2119
- bands.push({
2120
- ...layer,
2121
- // The ends keep whatever the layer stated, so banding never narrows what it draws.
2122
- minzoom: i === 0 ? layer.minzoom : from,
2123
- maxzoom: i + 2 === edges.length ? layer.maxzoom : to,
2124
- dashZoom,
2125
- });
2165
+ bands.push({ from, dashZoom });
2126
2166
  }
2127
2167
  // Two solid bands in a row are one: a width cut means nothing where no dash is drawn.
2128
2168
  const solid = (band) => !(dashPatternAt(dash, band.dashZoom) ?? [0, 1]).some((v, i) => i % 2 === 1 && v > 0);
2129
2169
  for (let i = bands.length - 1; i > 0; i--) {
2130
- if (solid(bands[i]) && solid(bands[i - 1])) {
2131
- bands[i - 1] = { ...bands[i - 1], maxzoom: bands[i].maxzoom };
2170
+ if (solid(bands[i]) && solid(bands[i - 1]))
2132
2171
  bands.splice(i, 1);
2133
- }
2134
2172
  }
2135
2173
  if (bands.length < 2)
2136
- return [layer];
2137
- coverage.approximate(`line-dasharray on "${layer.id}" split into ${bands.length} zoom bands: a `
2138
- + 'dash is a multiple of the line width and CartoCSS takes one pattern per rule, so a '
2139
- + 'ramped width or a stepped dash needs a rule per band');
2140
- return bands;
2174
+ return layer;
2175
+ coverage.approximate(`line-dasharray on "${layer.id}" stepped over ${bands.length} zoom bands: a `
2176
+ + 'dash is a multiple of the line width and the decoder rasterises one pattern per tile, so a '
2177
+ + 'ramped width or a stepped dash takes a pattern per band');
2178
+ return { ...layer, dashBands: bands };
2141
2179
  }
2142
2180
  /**
2143
2181
  * No dash under a pixel: drawn through a bitmap, a 0.5 px sleeper lights a pixel only where it happens to land,
@@ -2590,8 +2628,8 @@ function dynamicIconField(image) {
2590
2628
  * match(get(class), ['bed_and_breakfast', …], get(class), 'apartment', 'lodging', 'lodging')
2591
2629
  * case(get(cuisine) == 'turkish', 'kebab', …, match(get(class), …))
2592
2630
  *
2593
- * A style parameter per label IS that lookup - `[param::t-<label>]` is null when the label has no
2594
- * entry, so `??` falls through exactly as coalesce and the fallback branch do. The alternative was
2631
+ * A parameter table keyed by label IS that lookup - `get([param::t], [class])` is null when the label
2632
+ * has no entry, so `??` falls through exactly as coalesce and the fallback branch do. The alternative was
2595
2633
  * one attachment per branch, which cost a rule each and ran into MAX_VARIANTS: MapTiler's
2596
2634
  * accommodation table has nine branches, so it did not split at all and every hotel lost its icon.
2597
2635
  */
@@ -2600,13 +2638,13 @@ function dynamicIconField(image) {
2600
2638
  * the field's value at draw time, so a class OpenMapTiles spells `town_hall` cannot be rewritten in
2601
2639
  * the style - only answered for. See ICON_ALIASES.
2602
2640
  */
2603
- function aliasSprites(names, file, prefix, options) {
2641
+ function aliasSprites(names, file, table, options) {
2604
2642
  if (!options.schema)
2605
2643
  return;
2606
2644
  const have = new Set(names);
2607
2645
  for (const [alias, sprite] of Object.entries(ICON_ALIASES[options.sourceSchema ?? 'maptiler'])) {
2608
2646
  if (have.has(sprite) && !have.has(alias))
2609
- options.styleParams.set(`${prefix}${alias}`, file.get(sprite));
2647
+ setTableEntry(options, table, alias, file.get(sprite));
2610
2648
  }
2611
2649
  }
2612
2650
  function iconExpression(image, layer, coverage, options) {
@@ -2620,7 +2658,7 @@ function iconExpression(image, layer, coverage, options) {
2620
2658
  // the style's colours reach it per feature (extractIconPlate). Its own parameter table, because
2621
2659
  // the two halves live under the same NAME and a rule must not pick up the other one's file.
2622
2660
  const plateMode = layer.layout?.[ICON_PARAMS] !== undefined;
2623
- const paramPrefix = plateMode ? GLYPH_PARAM_PREFIX : ICON_PARAM_PREFIX;
2661
+ const paramTable = plateMode ? GLYPH_TABLE : ICON_TABLE;
2624
2662
  // The params may cover only some of the layer's features (see IconParamScope). The rest keep
2625
2663
  // the sheet's own artwork, so the rule has to pick per feature between a field and a raster -
2626
2664
  // which `shield-sdf` allows, being read with an expression context like every other property.
@@ -2717,8 +2755,8 @@ function iconExpression(image, layer, coverage, options) {
2717
2755
  if (matching.length === 0)
2718
2756
  return null;
2719
2757
  for (const name of matching)
2720
- options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2721
- return `[param::${paramPrefix}${prefix}[${field}]]`;
2758
+ setTableEntry(options, paramTable, name, all.file.get(name));
2759
+ return tableRead(paramTable, `concat('${prefix}', [${field}])`);
2722
2760
  };
2723
2761
  /** Every sprite written out, each under the file the rule should name it by. */
2724
2762
  let everySprite = null;
@@ -2875,19 +2913,19 @@ function iconExpression(image, layer, coverage, options) {
2875
2913
  // and the table is what gives `??` a miss to fall through on.
2876
2914
  const all = ensureEverySprite();
2877
2915
  for (const name of all.names)
2878
- options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2879
- aliasSprites(all.names, all.file, paramPrefix, options);
2880
- const field = `[param::${paramPrefix}[${node[1]}]]`;
2916
+ setTableEntry(options, paramTable, name, all.file.get(name));
2917
+ aliasSprites(all.names, all.file, paramTable, options);
2918
+ const field = tableRead(paramTable, `[${node[1]}]`);
2881
2919
  if (glyphs || !plateWhen || node[1] !== scope.field)
2882
2920
  return field;
2883
2921
  // The features the params do NOT cover: their own artwork, from the raster table, and
2884
2922
  // `shield-sdf` false for them (see plateWhen in shieldImageDeclarations).
2885
2923
  const raster = extractAllIcons(sprites.sheets, sprites.outDir, options.flattenSdf);
2886
2924
  for (const name of raster.names) {
2887
- options.styleParams.set(`${ICON_PARAM_PREFIX}${name}`, `icons/${name}.png`);
2925
+ setTableEntry(options, ICON_TABLE, name, `icons/${name}.png`);
2888
2926
  }
2889
- aliasSprites(raster.names, new Map(raster.names.map((n) => [n, `icons/${n}.png`])), ICON_PARAM_PREFIX, options);
2890
- return `(${plateWhen} ? ${field} : [param::${ICON_PARAM_PREFIX}[${node[1]}]])`;
2927
+ aliasSprites(raster.names, new Map(raster.names.map((n) => [n, `icons/${n}.png`])), ICON_TABLE, options);
2928
+ return `(${plateWhen} ? ${field} : ${tableRead(ICON_TABLE, `[${node[1]}]`)})`;
2891
2929
  }
2892
2930
  if (Array.isArray(node) && node[0] === 'coalesce') {
2893
2931
  const parts = node.slice(1).map((b) => build(b)).filter((x) => x !== null);
@@ -2937,7 +2975,7 @@ function iconExpression(image, layer, coverage, options) {
2937
2975
  const file = named(name);
2938
2976
  if (!file)
2939
2977
  continue;
2940
- options.styleParams.set(`${table}-${label}`, file);
2978
+ setTableEntry(options, table, label, file);
2941
2979
  wrote++;
2942
2980
  }
2943
2981
  }
@@ -2945,7 +2983,7 @@ function iconExpression(image, layer, coverage, options) {
2945
2983
  const tail = rest && rest !== `''` ? ` ?? ${rest}` : '';
2946
2984
  if (wrote === 0)
2947
2985
  return rest;
2948
- return `[param::${table}-[${field}]]${tail}`;
2986
+ return `${tableRead(table, `[${field}]`)}${tail}`;
2949
2987
  };
2950
2988
  const expr = build(image);
2951
2989
  // In font mode there is no sprite to measure: the glyph run is sized by the label.
@@ -3090,13 +3128,13 @@ function paramiseValues(layer, options, coverage) {
3090
3128
  const prefix = `${safeParamName(layer.id)}-${safeParamName(property)}`;
3091
3129
  for (const [labels, result] of asHex) {
3092
3130
  for (const label of labels)
3093
- options.styleParams.set(`${prefix}-${label}`, result);
3131
+ setTableEntry(options, prefix, label, result);
3094
3132
  }
3095
3133
  const fallback = tryTranslate(found.fallback, property, layer.id, coverage);
3096
3134
  if (fallback === null)
3097
3135
  continue;
3098
3136
  const sentinel = `@@param${subs.size}@@`;
3099
- subs.set(`'${sentinel}'`, `([param::${prefix}-[${found.field}]] ?? ${fallback})`);
3137
+ subs.set(`'${sentinel}'`, `(${tableRead(prefix, `[${found.field}]`)} ?? ${fallback})`);
3100
3138
  if (whole) {
3101
3139
  paint[property] = sentinel;
3102
3140
  }
@@ -3148,9 +3186,7 @@ function patternDeclarations(name, value, target, layer, coverage, options) {
3148
3186
  * about the label is unchanged. The face is resolved as a fallback of the label font, which is what
3149
3187
  * puts its glyphs in the label's own atlas.
3150
3188
  *
3151
- * The face carries one glyph per icon NAME, so a name it has none for draws no icon: an artwork
3152
- * shield (an RER roundel, a country's motorway plate) has no font equivalent and is lost here. That
3153
- * is the trade the mode is for - one font against a sheet of several hundred PNGs.
3189
+ * A name the face has no glyph for keeps its sprite (markerDeclarations), or draws no icon without one.
3154
3190
  */
3155
3191
  function fontShieldDeclarations(layer, image, coverage, options) {
3156
3192
  const font = options.iconFont;
@@ -3186,11 +3222,11 @@ function fontShieldDeclarations(layer, image, coverage, options) {
3186
3222
  emitTranslated(out, coverage, layer, 'icon-color', 'shield-icon-fill', undefined, false);
3187
3223
  emitTranslated(out, coverage, layer, 'icon-opacity', 'shield-icon-opacity', undefined, false);
3188
3224
  emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
3189
- emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false);
3225
+ emitTranslated(out, coverage, layer, 'icon-halo-width', 'shield-icon-halo-radius', undefined, false, options);
3190
3226
  // A recolourable badge keeps its disc: the plate measured off the sheet, as the sprite path does.
3191
3227
  const plate = layer.layout?.[ICON_PARAMS] !== undefined ? fontPlateSample(options) : null;
3192
3228
  if (plate)
3193
- out.push(...iconPlateDeclarations(layer, plate, sized, coverage, options));
3229
+ out.push(...iconPlateDeclarations(layer, plate, sized, coverage, options, true));
3194
3230
  out.push(...variableAnchorDeclarations(layer, coverage, options));
3195
3231
  return out;
3196
3232
  }
@@ -3212,6 +3248,26 @@ function fontPlateSample(options) {
3212
3248
  }
3213
3249
  return fontPlates.get(options);
3214
3250
  }
3251
+ /** Every name an icon-image can resolve to, when it states them outright through `match`/`case`. */
3252
+ function statedIconNames(image) {
3253
+ if (typeof image === 'string')
3254
+ return /\{[A-Za-z0-9_:-]+\}/.test(image) ? null : [image];
3255
+ if (!Array.isArray(image))
3256
+ return null;
3257
+ const outputs = image[0] === 'match' ? image.filter((_, i) => i >= 3 && (i % 2 === 1 || i === image.length - 1))
3258
+ : image[0] === 'case' ? image.filter((_, i) => i >= 2 && (i % 2 === 0 || i === image.length - 1))
3259
+ : null;
3260
+ if (!outputs)
3261
+ return null;
3262
+ const names = outputs.map((o) => statedIconNames(o));
3263
+ return names.every((n) => n !== null) ? names.flat() : null;
3264
+ }
3265
+ function spriteOnlyIcon(image, options) {
3266
+ const names = statedIconNames(image);
3267
+ if (!names || !options.sprites)
3268
+ return false;
3269
+ return names.every((n) => !options.iconFont.glyphs.has(n)) && names.some((n) => hasIcon(options.sprites.sheets, n));
3270
+ }
3215
3271
  /** MapBox's icon-* onto marker-*, once the sprite has been sliced into its own file. */
3216
3272
  function markerDeclarations(layer, coverage, options) {
3217
3273
  const image = layer.layout?.['icon-image'];
@@ -3220,6 +3276,9 @@ function markerDeclarations(layer, coverage, options) {
3220
3276
  // --icon-font replaces a shield's ARTWORK with a glyph, and needs no sheet at all. Only a
3221
3277
  // shield: an icon-only layer is a marker (a oneway arrow, a crossing), which has no glyph run.
3222
3278
  if (options.iconFont && layer.layout?.['text-field'] !== undefined) {
3279
+ // A map symbol the face has no glyph for (a peak, a town's dot) keeps its sprite.
3280
+ if (spriteOnlyIcon(image, options))
3281
+ return markerDeclarations(layer, coverage, { ...options, iconFont: undefined });
3223
3282
  return fontShieldDeclarations(layer, image, coverage, options);
3224
3283
  }
3225
3284
  if (!options.sprites) {
@@ -3411,6 +3470,11 @@ function placementPriority(layer, layerIndex, coverage) {
3411
3470
  const sortKey = layer.layout?.['symbol-sort-key'];
3412
3471
  if (sortKey === undefined)
3413
3472
  return String(base);
3473
+ // a negated key is a boost: `base + boost` rather than `base - (0 - boost)`
3474
+ if (Array.isArray(sortKey) && sortKey[0] === '-' && sortKey.length === 2) {
3475
+ const boost = tryTranslate(sortKey[1], 'symbol-sort-key', layer.id, coverage);
3476
+ return boost === null ? String(base) : `(${base} + ${boost})`;
3477
+ }
3414
3478
  const translated = tryTranslate(sortKey, 'symbol-sort-key', layer.id, coverage);
3415
3479
  // MapBox places the LOWEST key first, and the culler takes the highest priority.
3416
3480
  return translated === null ? String(base) : `(${base} - ${translated})`;
@@ -3436,10 +3500,23 @@ const DEFAULT_SYMBOL_SPACING = 250;
3436
3500
  * from 2x up, which is every phone.
3437
3501
  */
3438
3502
  const FILL_OUTLINE_WIDTH = 0.4;
3439
- /** One style parameter per sprite name, so a per-feature lookup can fall through when it misses. */
3440
- const ICON_PARAM_PREFIX = 'icon-';
3503
+ /** One table entry per sprite name, so a per-feature lookup can fall through when it misses. */
3504
+ const ICON_TABLE = 'icon';
3441
3505
  /** The glyph FIELD of a recolourable icon, a different file under the same name. */
3442
- const GLYPH_PARAM_PREFIX = 'glyph-';
3506
+ const GLYPH_TABLE = 'glyph';
3507
+ /**
3508
+ * One entry of a parameter TABLE: an object parameter read with `get([param::table], key)`, unset
3509
+ * for a key it does not name so `??` falls through. An app changes one entry as `table.key`.
3510
+ */
3511
+ function setTableEntry(options, table, key, value) {
3512
+ let spec = options.styleParams.get(table);
3513
+ if (!spec)
3514
+ options.styleParams.set(table, spec = { default: {} });
3515
+ spec.default[key] = value;
3516
+ }
3517
+ function tableRead(table, key) {
3518
+ return `get([param::${table}], ${key})`;
3519
+ }
3443
3520
  /** A value in ems of the layer's own text-size, as the pixels CartoCSS wants. */
3444
3521
  function ems(value, layer, coverage, from) {
3445
3522
  const size = layer.layout?.['text-size'] ?? DEFAULT_TEXT_SIZE;
@@ -3456,7 +3533,7 @@ function scaledSize(size, width, translated) {
3456
3533
  return translated === null ? String(round(width)) : `(${translated} * ${round(width)})`;
3457
3534
  }
3458
3535
  /** Translates a MapBox paint/layout value onto a CartoCSS property, or reports why it could not. */
3459
- function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly) {
3536
+ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly, options) {
3460
3537
  const value = layer.paint?.[from] ?? layer.layout?.[from];
3461
3538
  if (value === undefined) {
3462
3539
  if (fallback !== undefined) {
@@ -3469,7 +3546,8 @@ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly)
3469
3546
  coverage.drop(from, `a flattened bitmap needs a constant ${from}`, layer.id);
3470
3547
  return;
3471
3548
  }
3472
- const translated = name(from, layer, coverage);
3549
+ // `options`: the property may be one the style asks to keep as a project.json table (massif:params)
3550
+ const translated = (options && fieldParamTable(value, layer, from, to, coverage, options)) ?? name(from, layer, coverage);
3473
3551
  if (translated !== null) {
3474
3552
  out.push(`${to}: ${translated};`);
3475
3553
  coverage.emit(to);
@@ -3478,7 +3556,7 @@ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly)
3478
3556
  /** The translated form of a layer property, counting the drop itself when it has none. */
3479
3557
  /**
3480
3558
  * A `match` on ONE field whose branches are all constants, written as a style-parameter LOOKUP keyed
3481
- * by that field - `[param::poi-fill-bus]`, one parameter per label - instead of the ternary chain
3559
+ * by that field - `get([param::poi-fill], [class])`, one table per palette - instead of the ternary chain
3482
3560
  * the decoder would otherwise walk per feature.
3483
3561
  *
3484
3562
  * Two things come with it. A category palette becomes EDITABLE in project.json without touching the
@@ -3554,18 +3632,23 @@ function fieldParamTable(value, layer, property, target, coverage, options, suff
3554
3632
  entries.push([label, branch]);
3555
3633
  }
3556
3634
  }
3635
+ // The fallback may be a table on ANOTHER field (subclass, then class): a chain of lookups, the
3636
+ // last table keeping the plain name. Same field twice is not a chain, and keeps the ternary.
3557
3637
  const rest = value[value.length - 1];
3558
- if (typeof rest !== 'string' && typeof rest !== 'number')
3559
- return null;
3560
- const fallback = tryTranslate(rest, property, layer.id, coverage);
3638
+ const chained = Array.isArray(rest) && rest[0] === 'match' && Array.isArray(rest[1]) && rest[1][0] === 'get'
3639
+ && rest[1][1] !== field;
3640
+ const fallback = chained
3641
+ ? fieldParamTable(rest, layer, property, target, coverage, options, suffix)
3642
+ : typeof rest === 'string' || typeof rest === 'number' ? tryTranslate(rest, property, layer.id, coverage) : null;
3561
3643
  if (fallback === null)
3562
3644
  return null;
3563
- const slug = `${layer['source-layer'] ?? safeParamName(layer.id)}-${target.replace(/^(text|shield|marker)-/, '')}${suffix}`;
3645
+ const base = `${layer['source-layer'] ?? safeParamName(layer.id)}-${target.replace(/^(text|shield|marker)-/, '')}${suffix}`;
3646
+ const slug = chained ? `${base}-${field}` : base;
3564
3647
  for (const [label, branch] of entries)
3565
- options.styleParams.set(`${slug}-${label}`, paramValue(branch));
3648
+ setTableEntry(options, slug, label, paramValue(branch));
3566
3649
  coverage.note(`"${layer.id}": ${property} is a ${entries.length}-entry table in project.json ` +
3567
- `(${slug}-*), read per feature by [${field}]`);
3568
- return `(([param::${slug}-[${field}]]) ?? ${fallback})`;
3650
+ `(${slug}), read per feature by [${field}]`);
3651
+ return `((${tableRead(slug, `[${field}]`)}) ?? ${fallback})`;
3569
3652
  }
3570
3653
  /** A branch of a variant or brightness split: a table where it folds, a plain value where it is one. */
3571
3654
  function foldOrConstant(value, layer, property, target, coverage, options, suffix) {
@@ -3718,8 +3801,10 @@ export function projectEntries(drawOrder) {
3718
3801
  const runs = [];
3719
3802
  for (const { sourceLayer, attachment } of sorted) {
3720
3803
  const last = runs[runs.length - 1];
3721
- if (last && last.sourceLayer === sourceLayer)
3722
- last.attachments.push(attachment);
3804
+ if (last && last.sourceLayer === sourceLayer) {
3805
+ if (!last.attachments.includes(attachment))
3806
+ last.attachments.push(attachment);
3807
+ }
3723
3808
  else
3724
3809
  runs.push({ sourceLayer, attachments: [attachment] });
3725
3810
  }
@@ -3729,6 +3814,61 @@ export function projectEntries(drawOrder) {
3729
3814
  ? run.attachments.map((attachment) => `${run.sourceLayer}::${attachment}`)
3730
3815
  : [run.sourceLayer]));
3731
3816
  }
3817
+ /** A declaration whose stacking order could show: anything translucent, blended or textured. */
3818
+ const ORDER_VISIBLE = /opacity|comp-op|draw-once|elevation-mode|pattern|rgba\(|hsla\(|transparent|#[0-9a-fA-F]{8}\b/;
3819
+ /**
3820
+ * Adjacent attachments of one source-layer whose every rule paints the same opaque lines or fills, in
3821
+ * every preset, renamed into the first of them. Drawn one after the other or in the tile's order, the
3822
+ * pixels are the same - and a feature both match is drawn once instead of twice, the same opaque
3823
+ * pixels again. An attachment is a pass over its source layer per tile, so this is decode time saved.
3824
+ * Returns how many attachments were folded into another.
3825
+ */
3826
+ export function mergeIdenticalPaint(blocks, drawOrder, presets, keep) {
3827
+ // blocks[i] and drawOrder[i] are pushed together, so they share an index
3828
+ const sequence = drawOrder.map((entry, at) => ({ entry, at }))
3829
+ .sort((a, b) => a.entry.index - b.entry.index || a.at - b.at).map((item) => item.at);
3830
+ const groups = [];
3831
+ for (const at of sequence) {
3832
+ const { sourceLayer, attachment } = drawOrder[at];
3833
+ const last = groups[groups.length - 1];
3834
+ if (last && last.sourceLayer === sourceLayer && last.attachment === attachment)
3835
+ last.at.push(at);
3836
+ else
3837
+ groups.push({ sourceLayer, attachment, at: [at] });
3838
+ }
3839
+ const flat = (declaration) => /^(line|polygon)-/.test(declaration) && !ORDER_VISIBLE.test(declaration);
3840
+ const signature = (group) => {
3841
+ if (group.attachment === '' || keep.has(group.attachment))
3842
+ return null;
3843
+ const runs = [blocks, ...presets];
3844
+ const paints = [];
3845
+ for (const run of runs) {
3846
+ const first = run[group.at[0]].declarations;
3847
+ if (!first.every(flat))
3848
+ return null;
3849
+ const paint = JSON.stringify(first);
3850
+ if (group.at.some((at) => JSON.stringify(run[at].declarations) !== paint))
3851
+ return null;
3852
+ paints.push(paint);
3853
+ }
3854
+ return paints.join('\n');
3855
+ };
3856
+ let merged = 0;
3857
+ let current = null;
3858
+ for (const group of groups) {
3859
+ const paint = signature(group);
3860
+ if (current && paint !== null && current.paint === paint && current.sourceLayer === group.sourceLayer) {
3861
+ for (const at of group.at) {
3862
+ blocks[at].selector = blocks[at].selector.slice(0, -group.attachment.length) + current.attachment;
3863
+ drawOrder[at] = { ...drawOrder[at], attachment: current.attachment };
3864
+ }
3865
+ merged++;
3866
+ continue;
3867
+ }
3868
+ current = { sourceLayer: group.sourceLayer, attachment: group.attachment, paint };
3869
+ }
3870
+ return merged;
3871
+ }
3732
3872
  /** What the split above cost, so a project that grew entries says why. */
3733
3873
  function reportSplitLayers(drawOrder, coverage) {
3734
3874
  const entries = projectEntries(drawOrder);