@massif-maps/style-tools 6.1.2 → 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.
@@ -9,7 +9,7 @@ import { PLATE_MAP, asShieldDeclaration, isShieldLayer, plateRadius } from './sh
9
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
  }
@@ -2015,7 +2048,7 @@ function shieldImageDeclarations(layer, icon, scale, coverage, options) {
2015
2048
  // white one round a POI glyph, and it is NOT the text halo - the two are separate in
2016
2049
  // mapbox and now separate here.
2017
2050
  emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
2018
- 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);
2019
2052
  out.push(...iconPlateDeclarations(layer, icon, sized, coverage, options));
2020
2053
  }
2021
2054
  else if (layer.layout?.[RECOLOURABLE_ICON] === true) {
@@ -2074,26 +2107,26 @@ function dashPattern(value) {
2074
2107
  return { pattern, zoom: stopZoomOf(value, pattern) };
2075
2108
  }
2076
2109
  /**
2077
- * 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.
2078
2111
  *
2079
- * A MapBox dash length is a multiple of the line width, and CartoCSS takes ONE pattern of PIXELS per
2080
- * rule - the decoder rasterises it into a bitmap keyed by the literal string, so it cannot be a
2081
- * 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
2082
2115
  * ramps its width 3 -> 8 between z15 and z20, and the one scale we could pick drew the dash 1.8x too
2083
2116
  * long at the bottom of that range and 0.7x too short at the top.
2084
2117
  *
2085
- * Banded, each attachment scales its dash by the width in the MIDDLE of its own band, and the bands
2086
- * are cut where the width DOUBLES - so the worst error inside one is a factor of sqrt(2) instead of
2087
- * 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.
2088
2121
  *
2089
2122
  * It lives here rather than in split.ts because it needs `dashPattern` and `rampAt`, and moving
2090
2123
  * those would cost an import cycle for one caller.
2091
2124
  */
2092
- function splitDashByZoom(layer, coverage) {
2125
+ function bandDashByZoom(layer, coverage) {
2093
2126
  const dash = layer.paint?.['line-dasharray'];
2094
2127
  const width = dashWidth(layer.paint?.['line-width']);
2095
- if (dash === undefined || layer.dashZoom !== undefined || dashPattern(dash) === null)
2096
- return [layer];
2128
+ if (dash === undefined || layer.dashBands !== undefined || dashPattern(dash) === null)
2129
+ return layer;
2097
2130
  const zmin = layer.minzoom ?? 0;
2098
2131
  const zmax = Math.min(layer.maxzoom ?? 24, 24);
2099
2132
  // A dash the style STEPS over zoom is a rule per step: one pattern for all of them drew Standard's
@@ -2117,7 +2150,7 @@ function splitDashByZoom(layer, coverage) {
2117
2150
  cuts.add(at);
2118
2151
  }
2119
2152
  if (!cuts.size)
2120
- return [layer];
2153
+ return layer;
2121
2154
  const edges = [zmin, ...[...cuts].sort((a, b) => a - b), zmax];
2122
2155
  const bands = [];
2123
2156
  for (let i = 0; i + 1 < edges.length; i++) {
@@ -2129,28 +2162,20 @@ function splitDashByZoom(layer, coverage) {
2129
2162
  if (stops.length && !((rampAt(width, dashZoom) ?? 0) > 0) && Math.max(from, lo) < b) {
2130
2163
  dashZoom = (Math.max(from, lo) + b) / 2;
2131
2164
  }
2132
- bands.push({
2133
- ...layer,
2134
- // The ends keep whatever the layer stated, so banding never narrows what it draws.
2135
- minzoom: i === 0 ? layer.minzoom : from,
2136
- maxzoom: i + 2 === edges.length ? layer.maxzoom : to,
2137
- dashZoom,
2138
- });
2165
+ bands.push({ from, dashZoom });
2139
2166
  }
2140
2167
  // Two solid bands in a row are one: a width cut means nothing where no dash is drawn.
2141
2168
  const solid = (band) => !(dashPatternAt(dash, band.dashZoom) ?? [0, 1]).some((v, i) => i % 2 === 1 && v > 0);
2142
2169
  for (let i = bands.length - 1; i > 0; i--) {
2143
- if (solid(bands[i]) && solid(bands[i - 1])) {
2144
- bands[i - 1] = { ...bands[i - 1], maxzoom: bands[i].maxzoom };
2170
+ if (solid(bands[i]) && solid(bands[i - 1]))
2145
2171
  bands.splice(i, 1);
2146
- }
2147
2172
  }
2148
2173
  if (bands.length < 2)
2149
- return [layer];
2150
- coverage.approximate(`line-dasharray on "${layer.id}" split into ${bands.length} zoom bands: a `
2151
- + 'dash is a multiple of the line width and CartoCSS takes one pattern per rule, so a '
2152
- + 'ramped width or a stepped dash needs a rule per band');
2153
- 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 };
2154
2179
  }
2155
2180
  /**
2156
2181
  * No dash under a pixel: drawn through a bitmap, a 0.5 px sleeper lights a pixel only where it happens to land,
@@ -2603,8 +2628,8 @@ function dynamicIconField(image) {
2603
2628
  * match(get(class), ['bed_and_breakfast', …], get(class), 'apartment', 'lodging', 'lodging')
2604
2629
  * case(get(cuisine) == 'turkish', 'kebab', …, match(get(class), …))
2605
2630
  *
2606
- * A style parameter per label IS that lookup - `[param::t-<label>]` is null when the label has no
2607
- * 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
2608
2633
  * one attachment per branch, which cost a rule each and ran into MAX_VARIANTS: MapTiler's
2609
2634
  * accommodation table has nine branches, so it did not split at all and every hotel lost its icon.
2610
2635
  */
@@ -2613,13 +2638,13 @@ function dynamicIconField(image) {
2613
2638
  * the field's value at draw time, so a class OpenMapTiles spells `town_hall` cannot be rewritten in
2614
2639
  * the style - only answered for. See ICON_ALIASES.
2615
2640
  */
2616
- function aliasSprites(names, file, prefix, options) {
2641
+ function aliasSprites(names, file, table, options) {
2617
2642
  if (!options.schema)
2618
2643
  return;
2619
2644
  const have = new Set(names);
2620
2645
  for (const [alias, sprite] of Object.entries(ICON_ALIASES[options.sourceSchema ?? 'maptiler'])) {
2621
2646
  if (have.has(sprite) && !have.has(alias))
2622
- options.styleParams.set(`${prefix}${alias}`, file.get(sprite));
2647
+ setTableEntry(options, table, alias, file.get(sprite));
2623
2648
  }
2624
2649
  }
2625
2650
  function iconExpression(image, layer, coverage, options) {
@@ -2633,7 +2658,7 @@ function iconExpression(image, layer, coverage, options) {
2633
2658
  // the style's colours reach it per feature (extractIconPlate). Its own parameter table, because
2634
2659
  // the two halves live under the same NAME and a rule must not pick up the other one's file.
2635
2660
  const plateMode = layer.layout?.[ICON_PARAMS] !== undefined;
2636
- const paramPrefix = plateMode ? GLYPH_PARAM_PREFIX : ICON_PARAM_PREFIX;
2661
+ const paramTable = plateMode ? GLYPH_TABLE : ICON_TABLE;
2637
2662
  // The params may cover only some of the layer's features (see IconParamScope). The rest keep
2638
2663
  // the sheet's own artwork, so the rule has to pick per feature between a field and a raster -
2639
2664
  // which `shield-sdf` allows, being read with an expression context like every other property.
@@ -2730,8 +2755,8 @@ function iconExpression(image, layer, coverage, options) {
2730
2755
  if (matching.length === 0)
2731
2756
  return null;
2732
2757
  for (const name of matching)
2733
- options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2734
- return `[param::${paramPrefix}${prefix}[${field}]]`;
2758
+ setTableEntry(options, paramTable, name, all.file.get(name));
2759
+ return tableRead(paramTable, `concat('${prefix}', [${field}])`);
2735
2760
  };
2736
2761
  /** Every sprite written out, each under the file the rule should name it by. */
2737
2762
  let everySprite = null;
@@ -2888,19 +2913,19 @@ function iconExpression(image, layer, coverage, options) {
2888
2913
  // and the table is what gives `??` a miss to fall through on.
2889
2914
  const all = ensureEverySprite();
2890
2915
  for (const name of all.names)
2891
- options.styleParams.set(`${paramPrefix}${name}`, all.file.get(name));
2892
- aliasSprites(all.names, all.file, paramPrefix, options);
2893
- 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]}]`);
2894
2919
  if (glyphs || !plateWhen || node[1] !== scope.field)
2895
2920
  return field;
2896
2921
  // The features the params do NOT cover: their own artwork, from the raster table, and
2897
2922
  // `shield-sdf` false for them (see plateWhen in shieldImageDeclarations).
2898
2923
  const raster = extractAllIcons(sprites.sheets, sprites.outDir, options.flattenSdf);
2899
2924
  for (const name of raster.names) {
2900
- options.styleParams.set(`${ICON_PARAM_PREFIX}${name}`, `icons/${name}.png`);
2925
+ setTableEntry(options, ICON_TABLE, name, `icons/${name}.png`);
2901
2926
  }
2902
- aliasSprites(raster.names, new Map(raster.names.map((n) => [n, `icons/${n}.png`])), ICON_PARAM_PREFIX, options);
2903
- 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]}]`)})`;
2904
2929
  }
2905
2930
  if (Array.isArray(node) && node[0] === 'coalesce') {
2906
2931
  const parts = node.slice(1).map((b) => build(b)).filter((x) => x !== null);
@@ -2950,7 +2975,7 @@ function iconExpression(image, layer, coverage, options) {
2950
2975
  const file = named(name);
2951
2976
  if (!file)
2952
2977
  continue;
2953
- options.styleParams.set(`${table}-${label}`, file);
2978
+ setTableEntry(options, table, label, file);
2954
2979
  wrote++;
2955
2980
  }
2956
2981
  }
@@ -2958,7 +2983,7 @@ function iconExpression(image, layer, coverage, options) {
2958
2983
  const tail = rest && rest !== `''` ? ` ?? ${rest}` : '';
2959
2984
  if (wrote === 0)
2960
2985
  return rest;
2961
- return `[param::${table}-[${field}]]${tail}`;
2986
+ return `${tableRead(table, `[${field}]`)}${tail}`;
2962
2987
  };
2963
2988
  const expr = build(image);
2964
2989
  // In font mode there is no sprite to measure: the glyph run is sized by the label.
@@ -3103,13 +3128,13 @@ function paramiseValues(layer, options, coverage) {
3103
3128
  const prefix = `${safeParamName(layer.id)}-${safeParamName(property)}`;
3104
3129
  for (const [labels, result] of asHex) {
3105
3130
  for (const label of labels)
3106
- options.styleParams.set(`${prefix}-${label}`, result);
3131
+ setTableEntry(options, prefix, label, result);
3107
3132
  }
3108
3133
  const fallback = tryTranslate(found.fallback, property, layer.id, coverage);
3109
3134
  if (fallback === null)
3110
3135
  continue;
3111
3136
  const sentinel = `@@param${subs.size}@@`;
3112
- subs.set(`'${sentinel}'`, `([param::${prefix}-[${found.field}]] ?? ${fallback})`);
3137
+ subs.set(`'${sentinel}'`, `(${tableRead(prefix, `[${found.field}]`)} ?? ${fallback})`);
3113
3138
  if (whole) {
3114
3139
  paint[property] = sentinel;
3115
3140
  }
@@ -3197,7 +3222,7 @@ function fontShieldDeclarations(layer, image, coverage, options) {
3197
3222
  emitTranslated(out, coverage, layer, 'icon-color', 'shield-icon-fill', undefined, false);
3198
3223
  emitTranslated(out, coverage, layer, 'icon-opacity', 'shield-icon-opacity', undefined, false);
3199
3224
  emitTranslated(out, coverage, layer, 'icon-halo-color', 'shield-icon-halo-fill', undefined, false);
3200
- 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);
3201
3226
  // A recolourable badge keeps its disc: the plate measured off the sheet, as the sprite path does.
3202
3227
  const plate = layer.layout?.[ICON_PARAMS] !== undefined ? fontPlateSample(options) : null;
3203
3228
  if (plate)
@@ -3475,10 +3500,23 @@ const DEFAULT_SYMBOL_SPACING = 250;
3475
3500
  * from 2x up, which is every phone.
3476
3501
  */
3477
3502
  const FILL_OUTLINE_WIDTH = 0.4;
3478
- /** One style parameter per sprite name, so a per-feature lookup can fall through when it misses. */
3479
- 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';
3480
3505
  /** The glyph FIELD of a recolourable icon, a different file under the same name. */
3481
- 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
+ }
3482
3520
  /** A value in ems of the layer's own text-size, as the pixels CartoCSS wants. */
3483
3521
  function ems(value, layer, coverage, from) {
3484
3522
  const size = layer.layout?.['text-size'] ?? DEFAULT_TEXT_SIZE;
@@ -3495,7 +3533,7 @@ function scaledSize(size, width, translated) {
3495
3533
  return translated === null ? String(round(width)) : `(${translated} * ${round(width)})`;
3496
3534
  }
3497
3535
  /** Translates a MapBox paint/layout value onto a CartoCSS property, or reports why it could not. */
3498
- function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly) {
3536
+ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly, options) {
3499
3537
  const value = layer.paint?.[from] ?? layer.layout?.[from];
3500
3538
  if (value === undefined) {
3501
3539
  if (fallback !== undefined) {
@@ -3508,7 +3546,8 @@ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly)
3508
3546
  coverage.drop(from, `a flattened bitmap needs a constant ${from}`, layer.id);
3509
3547
  return;
3510
3548
  }
3511
- 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);
3512
3551
  if (translated !== null) {
3513
3552
  out.push(`${to}: ${translated};`);
3514
3553
  coverage.emit(to);
@@ -3517,7 +3556,7 @@ function emitTranslated(out, coverage, layer, from, to, fallback, constantOnly)
3517
3556
  /** The translated form of a layer property, counting the drop itself when it has none. */
3518
3557
  /**
3519
3558
  * A `match` on ONE field whose branches are all constants, written as a style-parameter LOOKUP keyed
3520
- * 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
3521
3560
  * the decoder would otherwise walk per feature.
3522
3561
  *
3523
3562
  * Two things come with it. A category palette becomes EDITABLE in project.json without touching the
@@ -3593,18 +3632,23 @@ function fieldParamTable(value, layer, property, target, coverage, options, suff
3593
3632
  entries.push([label, branch]);
3594
3633
  }
3595
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.
3596
3637
  const rest = value[value.length - 1];
3597
- if (typeof rest !== 'string' && typeof rest !== 'number')
3598
- return null;
3599
- 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;
3600
3643
  if (fallback === null)
3601
3644
  return null;
3602
- 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;
3603
3647
  for (const [label, branch] of entries)
3604
- options.styleParams.set(`${slug}-${label}`, paramValue(branch));
3648
+ setTableEntry(options, slug, label, paramValue(branch));
3605
3649
  coverage.note(`"${layer.id}": ${property} is a ${entries.length}-entry table in project.json ` +
3606
- `(${slug}-*), read per feature by [${field}]`);
3607
- return `(([param::${slug}-[${field}]]) ?? ${fallback})`;
3650
+ `(${slug}), read per feature by [${field}]`);
3651
+ return `((${tableRead(slug, `[${field}]`)}) ?? ${fallback})`;
3608
3652
  }
3609
3653
  /** A branch of a variant or brightness split: a table where it folds, a plain value where it is one. */
3610
3654
  function foldOrConstant(value, layer, property, target, coverage, options, suffix) {
@@ -3757,8 +3801,10 @@ export function projectEntries(drawOrder) {
3757
3801
  const runs = [];
3758
3802
  for (const { sourceLayer, attachment } of sorted) {
3759
3803
  const last = runs[runs.length - 1];
3760
- if (last && last.sourceLayer === sourceLayer)
3761
- last.attachments.push(attachment);
3804
+ if (last && last.sourceLayer === sourceLayer) {
3805
+ if (!last.attachments.includes(attachment))
3806
+ last.attachments.push(attachment);
3807
+ }
3762
3808
  else
3763
3809
  runs.push({ sourceLayer, attachments: [attachment] });
3764
3810
  }
@@ -3768,6 +3814,61 @@ export function projectEntries(drawOrder) {
3768
3814
  ? run.attachments.map((attachment) => `${run.sourceLayer}::${attachment}`)
3769
3815
  : [run.sourceLayer]));
3770
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
+ }
3771
3872
  /** What the split above cost, so a project that grew entries says why. */
3772
3873
  function reportSplitLayers(drawOrder, coverage) {
3773
3874
  const entries = projectEntries(drawOrder);