@massif-maps/style-tools 6.1.0-rc.3 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1844,6 +1844,16 @@
1844
1844
  "live": false,
1845
1845
  "baked": false
1846
1846
  },
1847
+ {
1848
+ "cartocss": "shield-z-elevate",
1849
+ "mapnik": "z-elevate",
1850
+ "symbolizer": "shield",
1851
+ "kind": "bool",
1852
+ "type": "BoolProperty",
1853
+ "default": "false",
1854
+ "live": false,
1855
+ "baked": false
1856
+ },
1847
1857
  {
1848
1858
  "cartocss": "text-allow-overlap",
1849
1859
  "mapnik": "allow-overlap",
@@ -2483,6 +2493,16 @@
2483
2493
  "default": "0.0",
2484
2494
  "live": false,
2485
2495
  "baked": false
2496
+ },
2497
+ {
2498
+ "cartocss": "text-z-elevate",
2499
+ "mapnik": "z-elevate",
2500
+ "symbolizer": "text",
2501
+ "kind": "bool",
2502
+ "type": "BoolProperty",
2503
+ "default": "false",
2504
+ "live": false,
2505
+ "baked": false
2486
2506
  }
2487
2507
  ]
2488
2508
  }
@@ -238,6 +238,8 @@ export function convert(style, table, options = {}) {
238
238
  const lights = style.lights;
239
239
  // Set while emitting, so the parameter is only declared when there is a building to gate.
240
240
  let usesBuildings = false;
241
+ // The project's `constants`: zoom ranges a child project may move, see `massif:minzoom-const`.
242
+ const constants = new Map();
241
243
  // The brightness of the style's OWN default preset, which the emissive fold measures against.
242
244
  // sceneBrightness is an ambient-only proxy, so its absolute value is not a light level - but
243
245
  // the RATIO between two presets of the same style is meaningful, and that is all this needs:
@@ -391,9 +393,13 @@ export function convert(style, table, options = {}) {
391
393
  .flatMap((ordered) => splitLayer(ordered, coverage))
392
394
  .flatMap((ordered) => splitDashByZoom(ordered, coverage))
393
395
  .map(narrowLayer);
396
+ // `massif:attachment`: layers drawing into ONE attachment, so a child project's rule on it
397
+ // merges with whichever of them matches instead of drawing the feature a second time.
398
+ const sharedAttachment = layer.metadata?.['massif:attachment'];
394
399
  variants.forEach((variant, branch) => {
395
400
  const suffix = variants.length > 1 ? `_b${branch + 1}` : '';
396
- emitLayer(variant, `${attachmentName(layer.id)}${suffix}`, target, symbolizer, index);
401
+ const attachment = typeof sharedAttachment === 'string' ? attachmentName(sharedAttachment) : `${attachmentName(layer.id)}${suffix}`;
402
+ emitLayer(variant, attachment, target, symbolizer, index);
397
403
  });
398
404
  }
399
405
  });
@@ -589,6 +595,12 @@ export function convert(style, table, options = {}) {
589
595
  return declaration;
590
596
  });
591
597
  }
598
+ // On every attachment a sort key split the layer into, so a crossing between two of them
599
+ // is drawn once too - the renderer groups consecutive styles by this name.
600
+ const drawOnce = layer.metadata?.['massif:draw-once'];
601
+ if (declarations.length > 0 && drawOnce !== undefined && drawOnce !== '') {
602
+ declarations = [...declarations, `draw-once: ${translateExpression(drawOnce)};`];
603
+ }
592
604
  if (declarations.length === 0)
593
605
  return;
594
606
  let selector;
@@ -610,9 +622,19 @@ export function convert(style, table, options = {}) {
610
622
  const minDefault = typeof minParam === 'string' ? Number(parameters.get(minParam)?.default) : NaN;
611
623
  const hasParam = typeof minParam === 'string' && zoomOffsetLevels() === 0;
612
624
  const paramStart = hasParam && typeof layer.minzoom === 'number' && Math.floor(layer.minzoom) === minDefault;
625
+ // `massif:minzoom-const` / `massif:maxzoom-const`: the zoom range is a project constant, resolved
626
+ // when the style compiles - a child project overrides it in its `constants`, at no runtime cost.
627
+ const minConst = layer.metadata?.['massif:minzoom-const'];
628
+ const maxConst = layer.metadata?.['massif:maxzoom-const'];
629
+ if (typeof minConst === 'string' && typeof layer.minzoom === 'number')
630
+ constants.set(minConst, Math.floor(layer.minzoom) + zoomOffsetLevels());
631
+ if (typeof maxConst === 'string' && typeof layer.maxzoom === 'number')
632
+ constants.set(maxConst, Math.ceil(layer.maxzoom) + zoomOffsetLevels());
613
633
  const predicates = [
614
634
  ...(hasParam ? [`[zoom >= 'param::${minParam}']`] : []),
615
- ...zoomPredicates(paramStart ? undefined : layer.minzoom, layer.maxzoom),
635
+ ...(typeof minConst === 'string' ? [`[zoom >= $${minConst}]`] : []),
636
+ ...(typeof maxConst === 'string' ? [`[zoom < $${maxConst}]`] : []),
637
+ ...zoomPredicates(paramStart || typeof minConst === 'string' ? undefined : layer.minzoom, typeof maxConst === 'string' ? undefined : layer.maxzoom),
616
638
  ...(buildings ? [buildings] : []),
617
639
  ...translateFilter(filter),
618
640
  ].map((p) => (p.startsWith('when(') ? ` ${p}` : p));
@@ -654,6 +676,8 @@ export function convert(style, table, options = {}) {
654
676
  if ((options.liveLight || lights !== undefined) && !mapBlock.some((d) => d.startsWith('colors-prelit:'))) {
655
677
  mapBlock.push('colors-prelit: 1;');
656
678
  }
679
+ // The SDK shifts every zoom by log2(app / this), so the style holds whatever TileDrawSize the app picks.
680
+ mapBlock.push(`tile-draw-size: ${options.tileDrawSize ?? 256};`);
657
681
  // The MEDIAN index, not the first. `road` has 82 layers spanning indices 3 to 130 in Mapbox
658
682
  // Standard, and its FIRST is one early tunnel layer - ordering by that sank all 82 beneath
659
683
  // landuse, so the landuse polygons painted over every road. The median puts a source-layer
@@ -739,11 +763,52 @@ export function convert(style, table, options = {}) {
739
763
  if (style.name)
740
764
  header.push(`/* Source style: ${style.name} */`);
741
765
  const render = (block) => `${block.selector} {\n${block.declarations.map((d) => ` ${d}`).join('\n')}\n}`;
766
+ // `massif:template`: every property all layers of a template state goes into `%name`, at its most
767
+ // common value; a rule keeps what differs. A child project extends it to draw a rule of its own.
768
+ const templateOf = new Map();
769
+ for (const layer of style.layers ?? []) {
770
+ const name = layer.metadata?.['massif:template'];
771
+ if (typeof name === 'string')
772
+ templateOf.set(layer.id, attachmentName(name));
773
+ }
774
+ const templateBlocks = new Map();
775
+ for (const block of ruleBlocks) {
776
+ const name = templateOf.get(block.owner);
777
+ if (name)
778
+ (templateBlocks.get(name) ?? templateBlocks.set(name, []).get(name)).push(block);
779
+ }
780
+ const renderedRules = [];
781
+ const emittedTemplates = new Set();
782
+ for (const block of ruleBlocks) {
783
+ const name = templateOf.get(block.owner);
784
+ const group = name ? templateBlocks.get(name) : [];
785
+ if (!name || group.length < 2) {
786
+ renderedRules.push(render(block));
787
+ continue;
788
+ }
789
+ const propertyOf = (d) => d.slice(0, d.indexOf(':'));
790
+ const common = group[0].declarations.map(propertyOf)
791
+ .filter((p) => group.every((other) => other.declarations.some((d) => propertyOf(d) === p)))
792
+ .map((p) => {
793
+ const counts = new Map();
794
+ for (const other of group) {
795
+ const d = other.declarations.find((x) => propertyOf(x) === p);
796
+ counts.set(d, (counts.get(d) ?? 0) + 1);
797
+ }
798
+ return [...counts].sort((a, b) => b[1] - a[1])[0][0];
799
+ });
800
+ if (!emittedTemplates.has(name)) {
801
+ emittedTemplates.add(name);
802
+ renderedRules.push(render({ selector: `%${name}`, declarations: common }));
803
+ }
804
+ renderedRules.push(render({ selector: block.selector,
805
+ declarations: [...block.declarations.filter((d) => !common.includes(d)), `@extend %${name};`] }));
806
+ }
742
807
  const mss = [
743
808
  ...header,
744
809
  '',
745
810
  ...(mapRule.declarations.length > 0 ? [render(mapRule), ''] : []),
746
- ...ruleBlocks.map(render),
811
+ ...renderedRules,
747
812
  '',
748
813
  ].join('\n');
749
814
  const variables = palette.length > 0 ? [...paletteHeader(style.name), '', ...palette, ''].join('\n') : null;
@@ -762,6 +827,7 @@ export function convert(style, table, options = {}) {
762
827
  ...(options.styleParams.size > 0
763
828
  ? { styleparameters: Object.fromEntries([...options.styleParams].sort()) }
764
829
  : {}),
830
+ ...(constants.size > 0 ? { constants: Object.fromEntries([...constants].sort()) } : {}),
765
831
  }, null, 2) + '\n';
766
832
  // Only what DIFFERS from the shared table: a preset project extends project.json, and
767
833
  // `extends` merges styleparameters, so an entry it does not restate is inherited.
@@ -1358,6 +1424,15 @@ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerI
1358
1424
  coverage.emit('text-allow-overlap');
1359
1425
  continue;
1360
1426
  }
1427
+ // On the roof of the 3D building the anchor stands in; a shield takes it through the rename below.
1428
+ if (name === 'symbol-z-elevate') {
1429
+ const translated = tryTranslate(value, name, layer.id, coverage);
1430
+ if (translated === null)
1431
+ continue;
1432
+ out.push(`text-z-elevate: ${translated};`);
1433
+ coverage.emit('text-z-elevate');
1434
+ continue;
1435
+ }
1361
1436
  // MapBox places the LOWEST sort key first; CartoCSS's culler takes the highest priority.
1362
1437
  if (name === 'symbol-sort-key')
1363
1438
  continue; // folded into the layer's priority below
@@ -1446,14 +1521,15 @@ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerI
1446
1521
  coverage.drop(name, 'no literal dash pattern to take', layer.id);
1447
1522
  continue;
1448
1523
  }
1449
- const { pattern, zoom } = dash;
1524
+ const zoom = dash.zoom;
1525
+ const pattern = (layer.dashZoom === undefined ? null : dashPatternAt(value, layer.dashZoom)) ?? dash.pattern;
1450
1526
  // `[1, 0]` is MapBox's spelling of SOLID. Scaled by a line width it became a 430 px
1451
1527
  // "dash", which is a 430 px bitmap rasterized to draw an unbroken line.
1452
1528
  if (!pattern.some((v, i) => i % 2 === 1 && v > 0)) {
1453
1529
  coverage.drop(name, 'the pattern has no gap, so the line is solid', layer.id);
1454
1530
  continue;
1455
1531
  }
1456
- if (pattern !== value) {
1532
+ if (pattern !== value && layer.dashZoom === undefined) {
1457
1533
  // MapTiler ramps its path dashes over zoom and CartoCSS takes ONE pattern. The
1458
1534
  // base (the widest band, and every stop below the first) is what is on screen at
1459
1535
  // nearly every zoom; taking nothing left every footway drawn solid.
@@ -1472,7 +1548,7 @@ function layerDeclarations(layer, symbolizer, allowed, coverage, options, layerI
1472
1548
  coverage.approximate(`line-dasharray scaled by ${round(scale)}, a zoom-driven ` +
1473
1549
  'line-width read at one zoom: CartoCSS takes one dash pattern, not a ramp');
1474
1550
  }
1475
- out.push(`line-dasharray: ${pattern.map((v) => round(v * scale)).join(',')};`);
1551
+ out.push(`line-dasharray: ${pixelDashes(pattern.map((v) => v * scale)).map(round).join(',')};`);
1476
1552
  coverage.emit('line-dasharray');
1477
1553
  continue;
1478
1554
  }
@@ -2003,50 +2079,105 @@ function dashPattern(value) {
2003
2079
  function splitDashByZoom(layer, coverage) {
2004
2080
  const dash = layer.paint?.['line-dasharray'];
2005
2081
  const width = dashWidth(layer.paint?.['line-width']);
2006
- if (dash === undefined || layer.dashZoom !== undefined || !Array.isArray(width))
2007
- return [layer];
2008
- const pattern = dashPattern(dash);
2009
- // A dash the style RAMPS states the zoom its pattern begins at, and reading the width there is
2010
- // already the targeted answer - Standard's treads depend on it. Banding is for the plain
2011
- // literal dash, which has no zoom of its own to be read at.
2012
- if (pattern === null || pattern.zoom !== null)
2082
+ if (dash === undefined || layer.dashZoom !== undefined || dashPattern(dash) === null)
2013
2083
  return [layer];
2084
+ const zmin = layer.minzoom ?? 0;
2085
+ const zmax = Math.min(layer.maxzoom ?? 24, 24);
2086
+ // A dash the style STEPS over zoom is a rule per step: one pattern for all of them drew Standard's
2087
+ // sleepers 0.3 px every 3 px at z20, a grey band, and a ferry dashed below the zoom its dash starts.
2088
+ const cuts = new Set(dashSteps(dash).filter((z) => z > zmin && z < zmax));
2014
2089
  // From the first stop that is actually DRAWN, not the first stop: a ramp starting at width 0 -
2015
2090
  // Liberty's rail hatchings start (14.5, 0) - has nothing to keep the dash in proportion to
2016
2091
  // below it. And no further than the last stop, above which the width is flat and a band would
2017
2092
  // read the same number twice.
2018
- const stops = rampStops(width);
2019
- const lo = Math.floor(Math.max(layer.minzoom ?? 0, stops.find(([, w]) => w > 0)?.[0] ?? 0));
2020
- const hi = Math.ceil(Math.min(layer.maxzoom ?? 24, stops[stops.length - 1]?.[0] ?? 24, 24));
2093
+ const stops = Array.isArray(width) ? rampStops(width) : [];
2094
+ const lo = Math.floor(Math.max(zmin, stops.find(([, w]) => w > 0)?.[0] ?? zmin));
2095
+ const hi = Math.ceil(Math.min(zmax, stops[stops.length - 1]?.[0] ?? zmax));
2021
2096
  const wLo = rampAt(width, lo);
2022
2097
  const wHi = rampAt(width, hi);
2023
- if (hi - lo < 2 || !wLo || !wHi || wLo <= 0 || wHi <= 0)
2024
- return [layer];
2025
- // Cut where the width DOUBLES, rounding up: a band spanning a 2x range is at worst sqrt(2) out
2026
- // in the middle, which is the error this is willing to keep.
2027
- const ratio = Math.max(wHi / wLo, wLo / wHi);
2028
- const bandCount = Math.min(MAX_DASH_BANDS, hi - lo, Math.ceil(Math.log2(ratio)));
2029
- if (bandCount < 2)
2098
+ if (stops.length && hi - lo >= 2 && wLo && wHi && wLo > 0 && wHi > 0) {
2099
+ // Cut where the width DOUBLES, rounding up: a band spanning a 2x range is at worst sqrt(2) out
2100
+ // in the middle, which is the error this is willing to keep.
2101
+ const bandCount = Math.min(MAX_DASH_BANDS, hi - lo, Math.ceil(Math.log2(Math.max(wHi / wLo, wLo / wHi))));
2102
+ const step = Math.max(1, Math.round((hi - lo) / bandCount));
2103
+ for (let at = lo + step; bandCount >= 2 && at < hi; at += step)
2104
+ cuts.add(at);
2105
+ }
2106
+ if (!cuts.size)
2030
2107
  return [layer];
2031
- const step = Math.max(1, Math.round((hi - lo) / bandCount));
2108
+ const edges = [zmin, ...[...cuts].sort((a, b) => a - b), zmax];
2032
2109
  const bands = [];
2033
- for (let from = lo; from < hi; from += step) {
2034
- const to = Math.min(hi, from + step);
2110
+ for (let i = 0; i + 1 < edges.length; i++) {
2111
+ const from = edges[i], to = edges[i + 1];
2112
+ // The width is read in the middle of the band up to the ramp's last stop, past a stretch where
2113
+ // it is 0 if it starts with one; the pattern anywhere inside, a band never straddles a step.
2114
+ const b = Math.min(to, hi);
2115
+ let dashZoom = from < b ? (from + b) / 2 : from;
2116
+ if (stops.length && !((rampAt(width, dashZoom) ?? 0) > 0) && Math.max(from, lo) < b) {
2117
+ dashZoom = (Math.max(from, lo) + b) / 2;
2118
+ }
2035
2119
  bands.push({
2036
2120
  ...layer,
2037
2121
  // The ends keep whatever the layer stated, so banding never narrows what it draws.
2038
- minzoom: from === lo ? layer.minzoom : from,
2039
- maxzoom: to >= hi ? layer.maxzoom : to,
2040
- dashZoom: (from + to) / 2,
2122
+ minzoom: i === 0 ? layer.minzoom : from,
2123
+ maxzoom: i + 2 === edges.length ? layer.maxzoom : to,
2124
+ dashZoom,
2041
2125
  });
2042
2126
  }
2127
+ // Two solid bands in a row are one: a width cut means nothing where no dash is drawn.
2128
+ const solid = (band) => !(dashPatternAt(dash, band.dashZoom) ?? [0, 1]).some((v, i) => i % 2 === 1 && v > 0);
2129
+ 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 };
2132
+ bands.splice(i, 1);
2133
+ }
2134
+ }
2043
2135
  if (bands.length < 2)
2044
2136
  return [layer];
2045
2137
  coverage.approximate(`line-dasharray on "${layer.id}" split into ${bands.length} zoom bands: a `
2046
2138
  + 'dash is a multiple of the line width and CartoCSS takes one pattern per rule, so a '
2047
- + 'ramped width needs a rule per band to stay in proportion');
2139
+ + 'ramped width or a stepped dash needs a rule per band');
2048
2140
  return bands;
2049
2141
  }
2142
+ /**
2143
+ * No dash under a pixel: drawn through a bitmap, a 0.5 px sleeper lights a pixel only where it happens to land,
2144
+ * and Standard's rail at z19 came out as blobs. Each dash is widened to 1 px, its gap paying for it, so the
2145
+ * period is kept.
2146
+ */
2147
+ function pixelDashes(pattern) {
2148
+ const out = [...pattern];
2149
+ for (let i = 0; i + 1 < out.length; i += 2) {
2150
+ const grow = Math.max(0, 1 - out[i]);
2151
+ const take = Math.min(grow, Math.max(0, out[i + 1] - 1));
2152
+ out[i] += take;
2153
+ out[i + 1] -= take;
2154
+ }
2155
+ return out;
2156
+ }
2157
+ /** The zooms a `step` dash switches pattern at. */
2158
+ function dashSteps(value) {
2159
+ if (!Array.isArray(value) || value[0] !== 'step' || !Array.isArray(value[1]) || value[1][0] !== 'zoom')
2160
+ return [];
2161
+ const out = [];
2162
+ for (let i = 3; i + 1 < value.length; i += 2) {
2163
+ if (typeof value[i] === 'number')
2164
+ out.push(value[i]);
2165
+ }
2166
+ return out;
2167
+ }
2168
+ /** The pattern a `step` dash draws at `zoom`, or null when it is not one. */
2169
+ function dashPatternAt(value, zoom) {
2170
+ if (!dashSteps(value).length)
2171
+ return null;
2172
+ const list = (node) => (Array.isArray(node) && node[0] === 'literal' ? node[1] : node);
2173
+ let at = list(value[2]);
2174
+ for (let i = 3; i + 1 < value.length; i += 2) {
2175
+ if (value[i] > zoom)
2176
+ break;
2177
+ at = list(value[i + 1]);
2178
+ }
2179
+ return Array.isArray(at) && at.every((v) => typeof v === 'number') ? at : null;
2180
+ }
2050
2181
  /**
2051
2182
  * The width a dash is scaled by. A width chosen per config value (`match` on `["config", ...]`) is
2052
2183
  * read at its fallback: its numbers averaged over every branch drew an e-ink track's 5 px dash 50 px.
@@ -2480,7 +2611,9 @@ function aliasSprites(names, file, prefix, options) {
2480
2611
  }
2481
2612
  function iconExpression(image, layer, coverage, options) {
2482
2613
  const sprites = options.sprites;
2483
- const slug = safeParamName(layer.id);
2614
+ // Layers sharing a template share its tables, so the icon lookup is a declaration they have in common.
2615
+ const template = layer.metadata?.['massif:template'];
2616
+ const slug = safeParamName(typeof template === 'string' ? template : layer.id);
2484
2617
  let sample = null;
2485
2618
  let tableIndex = 0;
2486
2619
  // A recolourable icon is cut in two - the glyph as a field, the disc as the shield's plate - so