Skip to content

Commit a70887c

Browse files
authored
fix(style): preserve Mapbox color classes (#1957)
* fix(style): preserve Mapbox color classes * fix(style): track applied layer stacks * fix(style): reject legacy stacked filters * fix(style): preserve stacked zoom ranges * fix(style): reject legacy not-has filters * fix(style): detect nested legacy filters * test(style): cover modern in filters * Address CodeRabbit review feedback - Treat `none` as a legacy layer filter unconditionally in `isLegacyLayerFilter`. It has no MapLibre expression counterpart, so recursing into its children accepted `["none", ["has", "class"]]` and forwarded an invalid operator into the generated `case` color expression. `all`/`any` keep recursing, since both are real expression operators. - Add a regression test covering a `none` filter wrapping an expression child.
1 parent 309b601 commit a70887c

2 files changed

Lines changed: 414 additions & 22 deletions

File tree

packages/map/src/mapbox-style-import.ts

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface MapboxStyleImportResult {
5656

5757
/** A minimal structural view of a Mapbox GL layer, so tests need no full spec. */
5858
interface RawStyleLayer {
59+
id?: unknown;
5960
type?: unknown;
6061
paint?: Record<string, unknown> | null;
6162
layout?: Record<string, unknown> | null;
@@ -107,6 +108,13 @@ function getProperty(node: unknown): string | null {
107108
return asString(array[1]);
108109
}
109110

111+
/** Match a feature-property `get`, excluding the 3-argument object lookup. */
112+
function getFeatureProperty(node: unknown): string | null {
113+
const array = asArray(node);
114+
if (!array || array.length !== 2) return null;
115+
return getProperty(array);
116+
}
117+
110118
/**
111119
* Match the field text-field / categorized input shapes the exporter emits,
112120
* returning the underlying property name:
@@ -151,7 +159,7 @@ function parseColorValue(value: unknown, warnings: string[]): ParsedColor {
151159

152160
// Unwrap the simplestyle per-feature override the exporter wraps colors in
153161
// (`["coalesce", ["get", key], base]`) and read the base renderer.
154-
if (array[0] === "coalesce" && array.length === 3) {
162+
if (array[0] === "coalesce" && array.length === 3 && getFeatureProperty(array[1]) !== null) {
155163
return parseColorValue(array[2], warnings);
156164
}
157165

@@ -325,6 +333,83 @@ function parseCase(array: unknown[]): ParsedColor {
325333
return { mode: "rule-based", rules, color: elseColor };
326334
}
327335

336+
/**
337+
* Whether a top-level layer filter uses the legacy property-name syntax, such
338+
* as `["==", "class", "park"]`. GeoLibre rules are MapLibre expressions, where
339+
* that property must instead be `["get", "class"]`.
340+
*/
341+
function isLegacyLayerFilter(filter: unknown[]): boolean {
342+
const operator = filter[0];
343+
// `none` has no expression counterpart, so it is legacy whatever it wraps.
344+
if (operator === "none") return true;
345+
if (operator === "all" || operator === "any") {
346+
return filter.slice(1).some((child) => {
347+
const array = asArray(child);
348+
return array !== null && isLegacyLayerFilter(array);
349+
});
350+
}
351+
if (operator === "!") {
352+
const child = asArray(filter[1]);
353+
return child !== null && isLegacyLayerFilter(child);
354+
}
355+
if (operator === "!has") return true;
356+
if (operator === "!in") return true;
357+
if (operator === "in") {
358+
// Modern `in` is exactly ["in", needle, haystack]. An expression-array
359+
// haystack is unambiguously modern even when the needle is a string.
360+
return filter.length !== 3 || (typeof filter[1] === "string" && !Array.isArray(filter[2]));
361+
}
362+
return (
363+
["==", "!=", ">", ">=", "<", "<="].includes(String(operator)) && typeof filter[1] === "string"
364+
);
365+
}
366+
367+
/**
368+
* Combine a stack of filtered, flat-color Mapbox layers into GeoLibre rules.
369+
* Mapbox draws later layers over earlier ones, so reverse the stack to preserve
370+
* that precedence in GeoLibre's first-match-wins rule evaluator. An off else
371+
* rule keeps features outside every source-layer filter hidden.
372+
*/
373+
function parseStackedLayerColors(
374+
layers: RawStyleLayer[],
375+
paintProperty: string,
376+
): ParsedColor | null {
377+
if (layers.length < 2) return null;
378+
const entries = layers.map((layer) => ({
379+
id: asString(layer.id),
380+
filter: asArray(layer.filter),
381+
color: asString((layer.paint ?? {})[paintProperty]),
382+
minZoom: clampZoom(layer.minzoom),
383+
maxZoom: clampZoom(layer.maxzoom),
384+
}));
385+
if (
386+
entries.some(
387+
(entry) => !entry.filter || isLegacyLayerFilter(entry.filter) || entry.color === null,
388+
)
389+
) {
390+
return null;
391+
}
392+
393+
const rules: NonNullable<LayerStyle["vectorRules"]> = entries.reverse().map((entry, index) => ({
394+
id: `import-layer-${index}`,
395+
label: entry.id ?? "",
396+
filter: JSON.stringify(entry.filter),
397+
color: entry.color!,
398+
isElse: false,
399+
...(entry.minZoom === null ? {} : { minZoom: entry.minZoom }),
400+
...(entry.maxZoom === null ? {} : { maxZoom: entry.maxZoom }),
401+
}));
402+
rules.push({
403+
id: "import-layer-else",
404+
label: "",
405+
filter: "",
406+
color: DEFAULT_LAYER_STYLE.fillColor,
407+
isElse: true,
408+
enabled: false,
409+
});
410+
return { mode: "rule-based", rules };
411+
}
412+
328413
/**
329414
* Apply a parsed color renderer to the style patch. `single`/`expression` leave
330415
* the flat fallback in `fillColor`; the attribute-driven modes carry the
@@ -347,7 +432,12 @@ function parseStrokeColor(value: unknown): string | null {
347432
// Unwrap the simplestyle per-feature override the exporter wraps line/outline
348433
// colors in (`["coalesce", ["get","stroke"], base]`) when simpleStyleEnabled,
349434
// matching parseColorValue, so the flat stroke is still recovered.
350-
if (array && array[0] === "coalesce" && array.length === 3) {
435+
if (
436+
array &&
437+
array[0] === "coalesce" &&
438+
array.length === 3 &&
439+
getFeatureProperty(array[1]) !== null
440+
) {
351441
return parseStrokeColor(array[2]);
352442
}
353443
const flat = asString(value);
@@ -510,6 +600,23 @@ function applyZoomRange(layer: RawStyleLayer, patch: Partial<Omit<LayerStyle, "l
510600
if (max !== null) patch.maxZoom = max;
511601
}
512602

603+
/** Keep the outer layer window wide enough for every recovered stacked rule. */
604+
function applyStackedZoomRange(
605+
layers: RawStyleLayer[],
606+
patch: Partial<Omit<LayerStyle, "labels">>,
607+
): void {
608+
const ranges = layers.map((layer) => {
609+
const rawMin = clampZoom(layer.minzoom);
610+
const rawMax = clampZoom(layer.maxzoom);
611+
if (rawMin !== null && rawMax !== null) {
612+
return { min: Math.min(rawMin, rawMax), max: Math.max(rawMin, rawMax) };
613+
}
614+
return { min: rawMin ?? MIN_LAYER_ZOOM, max: rawMax ?? MAX_LAYER_ZOOM };
615+
});
616+
patch.minZoom = Math.min(...ranges.map((range) => range.min));
617+
patch.maxZoom = Math.max(...ranges.map((range) => range.max));
618+
}
619+
513620
/** Build the label patch from a `symbol` layer's layout/paint. */
514621
function parseLabelLayer(layer: RawStyleLayer, warnings: string[]): Partial<LabelStyle> {
515622
const layout = layer.layout ?? {};
@@ -635,14 +742,10 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
635742
// stroke/radius. Track whether the color mode has been claimed.
636743
let colorClaimed = false;
637744

638-
// Only the first render layer of each type feeds the single GeoLibre style, so
639-
// warn when a style stacks several (a sub-styled fill, multiple label configs)
640-
// rather than dropping the extras silently.
641-
for (const type of ["fill", "fill-extrusion", "line", "circle", "symbol"]) {
642-
if (byType(type).length > 1) {
643-
warnings.push(`The style has multiple ${type} layers; only the first was imported.`);
644-
}
645-
}
745+
const stackedFill = parseStackedLayerColors(byType("fill"), "fill-color");
746+
const stackedLine = parseStackedLayerColors(byType("line"), "line-color");
747+
const stackedCircle = parseStackedLayerColors(byType("circle"), "circle-color");
748+
const appliedStackTypes = new Set<string>();
646749

647750
const [fill] = byType("fill");
648751
const [extrusion] = byType("fill-extrusion");
@@ -675,10 +778,11 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
675778
if (base !== null) patch.extrusionBase = base;
676779
applyZoomRange(extrusion, patch);
677780
} else if (fill) {
678-
matchedLayerCount += 1;
781+
matchedLayerCount += stackedFill ? byType("fill").length : 1;
782+
if (stackedFill) appliedStackTypes.add("fill");
679783
patch.extrusionEnabled = false;
680784
const paint = fill.paint ?? {};
681-
const fillColor = parseColorValue(paint["fill-color"], warnings);
785+
const fillColor = stackedFill ?? parseColorValue(paint["fill-color"], warnings);
682786
applyColorRenderer(fillColor, patch);
683787
// Only claim the shared renderer when fill-color actually yielded one, so a
684788
// fill layer with a missing/unparseable color does not block a later
@@ -693,7 +797,8 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
693797
}
694798
const outline = parseStrokeColor(paint["fill-outline-color"]);
695799
if (outline) patch.strokeColor = outline;
696-
applyZoomRange(fill, patch);
800+
if (stackedFill) applyStackedZoomRange(byType("fill"), patch);
801+
else applyZoomRange(fill, patch);
697802
}
698803

699804
if (line) {
@@ -707,8 +812,12 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
707812
// point+line export's circle claim the renderer keeps fillColor correct; a
708813
// line-only layer still claims here (its fillColor is not rendered anyway).
709814
if (!colorClaimed && !circle) {
710-
const color = parseColorValue(paint["line-color"], warnings);
815+
const color = stackedLine ?? parseColorValue(paint["line-color"], warnings);
711816
if (color.mode && color.mode !== "single") {
817+
if (stackedLine) {
818+
appliedStackTypes.add("line");
819+
matchedLayerCount += byType("line").length - 1;
820+
}
712821
// Take the renderer (mode/property/stops/rules), but not the fallback
713822
// color: line-color's baked fallback is strokeColor (already recovered
714823
// above), whereas applyColorRenderer would route it into fillColor.
@@ -719,15 +828,20 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
719828
if (paint["line-width"] !== undefined) {
720829
parseLineWidth(paint["line-width"], patch, warnings);
721830
}
722-
applyZoomRange(line, patch);
831+
if (appliedStackTypes.has("line")) applyStackedZoomRange(byType("line"), patch);
832+
else applyZoomRange(line, patch);
723833
}
724834

725835
if (circle) {
726836
matchedLayerCount += 1;
727837
patch.pointRenderer = "single";
728838
const paint = circle.paint ?? {};
729839
if (!colorClaimed) {
730-
applyColorRenderer(parseColorValue(paint["circle-color"], warnings), patch);
840+
applyColorRenderer(stackedCircle ?? parseColorValue(paint["circle-color"], warnings), patch);
841+
if (stackedCircle) {
842+
appliedStackTypes.add("circle");
843+
matchedLayerCount += byType("circle").length - 1;
844+
}
731845
colorClaimed = true;
732846
}
733847
const radius = paint["circle-radius"];
@@ -762,7 +876,8 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
762876
} else {
763877
warnUnreadableNumber(paint["circle-stroke-width"], "point stroke width", warnings);
764878
}
765-
applyZoomRange(circle, patch);
879+
if (appliedStackTypes.has("circle")) applyStackedZoomRange(byType("circle"), patch);
880+
else applyZoomRange(circle, patch);
766881
}
767882

768883
if (heatmap) {
@@ -788,6 +903,19 @@ export function parseMapboxStyle(input: unknown): MapboxStyleImportResult {
788903
labels = parseLabelLayer(symbol, warnings);
789904
}
790905

906+
// Filtered flat-color stacks can be represented exactly as rules only when
907+
// that geometry actually claims the shared renderer. Flag every other stack.
908+
for (const type of ["fill", "fill-extrusion", "line", "circle", "symbol"]) {
909+
if (byType(type).length < 2) continue;
910+
if (appliedStackTypes.has(type)) {
911+
warnings.push(
912+
`The style's multiple ${type} layers were combined as rules; paint properties other than color come from the bottom-most layer.`,
913+
);
914+
} else {
915+
warnings.push(`The style has multiple ${type} layers; only the first was imported.`);
916+
}
917+
}
918+
791919
if (matchedLayerCount === 0) {
792920
warnings.push(
793921
"No fill, line, circle, heatmap, or label layers were found; nothing was imported.",

0 commit comments

Comments
 (0)