A map style compiler in Python
- 7 minutes read - 1365 wordsGenerating Mapbox GL GL style JSON from Python code instead of editing 3,000-line JSON files by hand. Hierarchical style resolution, a Django-style filter DSL, injection-based layer ordering, and a Go sprite generator called from Python via ctypes.
The problem with style JSON
A Mapbox GL GL style is a single JSON file that describes everything about how a map looks. Background color, road widths at every zoom level, label fonts, icon placement, landcover tints, building extrusion heights. All of it.
A real production style runs to thousands of lines. Ours had over 150 layers. Roads alone need six layer variants each (tunnel casing, tunnel fill, road casing, road fill, bridge casing, bridge fill), and there are eight road classes. That’s 48 road layers before you count labels, oneway arrows, or rail.
Editing this by hand is slow and error-prone. Change the motorway color and you need to find it in six places. Add a new road class and you need to insert layers at exactly the right positions between tunnels and bridges. Reorder a layer and you break z-ordering for everything above it.
Python instead of JSON
The style compiler replaces hand-edited JSON with Python code. You define layers, colors, filters, and style properties in Python. The compiler resolves everything and emits a Mapbox GL GL style JSON file.
python -m map_style --style=streets -o style/streets_classic.jsonThe output is a standard Mapbox GL GL style: the renderer doesn’t know or care that it was generated. But the source is modular, typed, and DRY.
Layer definitions
A road is defined once. ClassicRoadDataNode generates all six variants:
ClassicRoadDataNode(
"motorway",
classes=["motorway"],
feature_type="road.highway.motorway",
extra_filter=~Q(ramp__eq=1),
casing_feature_type="road.highway.motorway",
)This produces:
tunnel_motorway_casing: tunnel stroketunnel_motorway: tunnel fillroad_motorway_casing: road strokeroad_motorway: road fillbridge_motorway_casing: bridge strokebridge_motorway: bridge fill
Each variant gets the correct brunnel filter automatically. Tunnel layers filter on brunnel == "tunnel", bridge on brunnel == "bridge", normal roads on brunnel not in ["bridge", "tunnel"].
Rail works the same way: ClassicRailDataNode generates tunnel, road, and bridge variants with optional hatching layers for the cross-ties pattern.
Injection points
With 150+ layers, ordering is everything. Roads must render above landcover. Bridges must render above buildings. Labels above everything. Getting one layer out of place breaks the visual hierarchy.
Instead of maintaining an explicit ordered list, the system uses injection points: named slots that define the rendering order:
class InjectionsNames(Enum):
TUNNEL_ROAD_CASING = "tunnel_road_casing"
TUNNEL_ROAD = "tunnel_road"
ROAD_AREA = "road_area"
ROAD_CASING = "road_casing"
ROAD = "road"
BUILDING = "_building_injection"
BRIDGE_ROAD_CASING = "bridge_road_casing"
BRIDGE_ROAD = "bridge_road"
BUILDING_3D = "building_3d"
LABEL = "labels"
POI_SYMBOL = "poi_symbol"Layers insert themselves before a named injection point:
stack.add_layer_before(InjectionsNames.ROAD, Layer("road_motorway", ...))
stack.add_layer_before(InjectionsNames.BRIDGE_ROAD, Layer("bridge_motorway", ...))The Stack maintains the order. Injection points themselves are stripped from the final output. They’re scaffolding, not layers. Adding a new road class doesn’t require knowing the index of every other layer. You just say “this goes in the ROAD group” and the system handles placement.
Filter DSL
Mapbox GL filters are nested JSON arrays. Writing them by hand is unreadable:
["all", ["==", ["get", "class"], "motorway"], ["!", ["in", ["get", "brunnel"], ["literal", ["bridge", "tunnel"]]]]]The compiler uses Django-style Q objects instead:
Q(class__eq="motorway") & ~Q(brunnel__in=["bridge", "tunnel"])& means AND, | means OR, ~ means NOT. The ExpressionSet class compiles Q trees to Mapbox GL expressions:
OPERATORS = {
"eq": "==", "neq": "!=",
"gt": ">", "gte": ">=",
"lt": "<", "lte": "<=",
}
# Q(class__in=["motorway"]) becomes:
# ["in", ["get", "class"], ["literal", ["motorway"]]]
# Q(brunnel__exists=False) becomes:
# ["!", ["has", "brunnel"]]Filters compose naturally. The ramp filter for motorway links:
Q(class__in=["motorway"]) & Q(ramp__eq=1)The non-ramp filter for main motorways:
Q(class__in=["motorway"]) & ~Q(ramp__eq=1)The Case class builds conditional expressions for layers that vary by feature class, like different icon colors per POI type:
case = Case({"restaurant": Q(class__eq="restaurant"), "hospital": Q(class__eq="hospital")})
case.resolve({"restaurant": "#e74c3c", "hospital": "#3498db"}, default="#666")
# ["case", ["==", ["get", "class"], "restaurant"], "#e74c3c",
# ["==", ["get", "class"], "hospital"], "#3498db", "#666"]Hierarchical style resolution
A StyleTree organizes paint and layout properties in a dot-separated hierarchy. Child selectors inherit from parents:
styles = {
"road": StyleElement(paint=LinePaint(line_cap="round")),
"road.highway": StyleElement(paint=LinePaint(line_color=ROAD_HIGHWAY_FILL_COLOR, line_width=4)),
"road.highway.motorway": StyleElement(paint=LinePaint(line_width=6)),
}Resolving road.highway.motorway walks the tree from root to leaf, collecting all StyleElement instances along the path. Properties merge: the motorway gets line_cap="round" from road, line_color="#fc8" from road.highway, and line_width=6 from its own node.
def resolve(self, selector_path: str) -> list[StyleElement]:
nodes = self.get_node_path(selector_path)
elements: list[StyleElement] = []
for node in nodes:
elements += node.elements
return elementsSame pattern as CSS cascade, but explicit. You see exactly what inherits from where by reading the selector paths. No specificity wars.
Colors
The color module parses hex, RGB, RGBA, HSL, and HTML color names. More usefully, it does HSL-based transformations:
Color("#fc8").darken(-0.3).to_rgba()
# Shifts lightness down by 30%, used for deriving label colors from fill colorsAll colors live in one file per style:
BACKGROUND_COLOR = "rgba(252, 247, 229, 1)"
WATER_COLOR = "rgba(134, 204, 250, 1)"
ROAD_HIGHWAY_FILL_COLOR = "#fc8"
ROAD_HIGHWAY_STROKE_COLOR = "#e9ac77"
ROAD_MINOR_FILL_COLOR = "#fff"
ROAD_MINOR_STROKE_COLOR = "hsl(35, 6%, 80%)"Change a color constant, regenerate, and every layer using it updates. No grep through JSON.
Pydantic models for the style spec
Every Mapbox GL layer type has a corresponding Pydantic v2 model with strict validation:
class LinePaint(BaseModel):
model_config = ConfigDict(populate_by_name=True)
line_color: str | list[Any] | None = Field(None, alias="line-color")
line_width: float | list[Any] | None = Field(None, alias="line-width")
line_opacity: float | list[Any] | None = Field(None, alias="line-opacity")
line_dasharray: list[float] | None = Field(None, alias="line-dasharray")
line_cap: str | None = Field(None, alias="line-cap")
# ...The alias parameter handles the mismatch between Python identifiers (underscores) and Mapbox GL property names (hyphens). line_color in Python becomes line-color in JSON output.
Pydantic catches type errors at definition time, not when the renderer fails to parse the JSON. Pass a string where a number is expected and you get an immediate error.
Sprite generation
Map icons (POI markers, highway shields, oneway arrows) are packed into a sprite sheet: a single PNG with a JSON manifest mapping icon names to pixel regions.
The sprite generator is written in Go for performance. Python calls it through ctypes:
lib = ctypes.CDLL(lib_path)
lib.GenerateSprite.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
lib.GenerateSprite.restype = ctypes.c_char_p
result = lib.GenerateSprite(
icon_library_path.encode(),
output_path.encode(),
pixel_ratio, # 1 for sprite.png, 2 for [email protected]
)The Go library handles SVG rasterization, SDF (signed distance field) generation for runtime-colorable icons, bin-packing into a texture atlas, and both 1x and 2x output.
Template-based icon names resolve from filter context. When a layer uses icon-image: "{class}_15", the compiler extracts which class values the filter allows and registers each concrete icon (restaurant_15, hospital_15, etc.) for sprite generation.
POI metadata
POI layers carry metadata beyond what Mapbox GL needs. The Woosmap API uses it to support dynamic styling at runtime:
class POIMetadata:
filter: list[Any]
symbol_color: str
text_color: str
icon: str
visible: boolThis metadata embeds in the layer’s metadata field (which Mapbox GL ignores but passes through). The API can query which POI categories exist, their colors, and their visibility, without parsing the style expressions.
Feature types follow a hierarchy: poi.school, poi.sports_complex, transit.station.rail. The LayerRegistry maps these to the concrete Mapbox GL layers they affect, enabling Google Maps-style feature type styling through the Woosmap SDK.
Multiple styles
The architecture supports multiple style variants from the same infrastructure. streets_classic is the production style. streets_satellite reuses the same layer definitions, road nodes, and filter DSL with a different color palette tuned for satellite imagery overlay: higher contrast labels, semi-transparent fills, no background color.
Adding a new style variant means writing a new color file and adjusting a few layer properties. The layer structure, injection points, and filter expressions stay the same.
What this gets you
The streets_classic style has ~150 renderable layers. The Python source is organized across about a dozen files: colors, roads, mapping, style tree, POI definitions. Each file is under 300 lines.
The equivalent hand-maintained JSON was one file, thousands of lines, with duplicated filter expressions and no inheritance. Every road color change required finding and editing six layer definitions. Every new road class required inserting layers at the correct positions relative to all other layers.
Now it’s: change a constant in colors.py, run the compiler, get a valid style JSON. Type-checked, with the layer ordering handled by injection points instead of manual index management.