@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.
- package/README.md +2 -0
- package/dist/mapbox2css/expression.js +12 -14
- package/dist/mapbox2css/expression.js.map +1 -1
- package/dist/mapbox2css/index.js +230 -90
- package/dist/mapbox2css/index.js.map +1 -1
- package/dist/mapbox2css/split.js +42 -27
- package/dist/mapbox2css/split.js.map +1 -1
- package/dist/mapbox2css/sprite.js +4 -0
- package/dist/mapbox2css/sprite.js.map +1 -1
- package/package.json +1 -1
- package/wasm/massif-style.wasm +0 -0
package/dist/mapbox2css/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
400
|
-
const suffix =
|
|
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
|
-
|
|
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}${[...
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1544
|
-
|
|
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
|
|
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
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
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
|
|
2073
|
-
*
|
|
2074
|
-
*
|
|
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
|
|
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.
|
|
2083
|
-
return
|
|
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
|
|
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
|
|
2137
|
-
coverage.approximate(`line-dasharray on "${layer.id}"
|
|
2138
|
-
+ 'dash is a multiple of the line width and
|
|
2139
|
-
+ 'ramped width or a stepped dash
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
2721
|
-
return `
|
|
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
|
|
2879
|
-
aliasSprites(all.names, all.file,
|
|
2880
|
-
const field = `[
|
|
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
|
|
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`])),
|
|
2890
|
-
return `(${plateWhen} ? ${field} :
|
|
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
|
|
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
|
|
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
|
|
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}'`, `(
|
|
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
|
-
*
|
|
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
|
|
3440
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3559
|
-
|
|
3560
|
-
const fallback =
|
|
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
|
|
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
|
|
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}
|
|
3568
|
-
return `((
|
|
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.
|
|
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);
|