@@ -56,6 +56,7 @@ export interface MapboxStyleImportResult {
5656
5757/** A minimal structural view of a Mapbox GL layer, so tests need no full spec. */
5858interface 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. */
514621function 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