Theme Inheritance Patterns for Vector Tile Map Styling
Maintaining multiple map variants — dark mode, high-contrast, regional palettes, seasonal campaigns — becomes unmanageable when each style is authored from scratch. Theme inheritance solves this by establishing a three-tier hierarchy: an immutable base style, environment-specific override files, and runtime attribute expressions. Every compiled variant shares one source of truth, so a color palette change propagates to all variants through a single CI run.
This technique sits inside the broader Map Styling & Layer Synchronization workflow and pairs tightly with Dynamic Attribute Mapping for runtime bindings and with Style Validation Workflows for pre-deployment correctness checks.
Prerequisites
| Requirement | Minimum version / state |
|---|---|
| Python | 3.9+ |
deepmerge |
1.1+ |
pydantic |
2.x |
jsonschema |
4.x |
| Base style | Schema-valid MapLibre GL v8 JSON |
| Tile pipeline | Consistent source-layer names across all target tilesets |
| CI/CD | Artifact store (S3, GCS, or Cloudflare R2) with versioned URL support |
The tile generation pipeline — Tippecanoe, Martin, or a PostGIS exporter — must emit stable layer names and attribute keys. A source-layer rename mid-pipeline silently breaks every override file referencing that layer.
Core Concept: Three-Tier Hierarchy
The three tiers map to distinct responsibilities:
| Tier | File | Deployed directly? | Key content |
|---|---|---|---|
| Base | base.style.json |
Never | Color palette, typography, layer ordering, fallback expressions |
| Environment | theme.dark.json, theme.hc.json, theme.eu.json |
Only after merge | Paint/layout deltas, visibility toggles, sprite swaps |
| Runtime | Generated ["interpolate"] / ["match"] expressions |
Injected at compile time | Attribute-driven paint values from Dynamic Attribute Mapping |
The base style is the compilation anchor; it never ships to a CDN endpoint in raw form. Environment overrides must be minimal — containing only the keys that differ from the base. Runtime expressions bridge the static compilation output with live tile attributes.
The diagram below shows how the three tiers feed the CI compiler and produce independently versioned CDN artifacts:
Step-by-Step Implementation
Step 1: Author the base style
The base style contains globally applicable defaults only. Keep it lean — every key here must make sense for all variants.
{
"version": 8,
"name": "base",
"metadata": {
"pipeline:role": "base",
"pipeline:version": "1.0.0"
},
"glyphs": "https://cdn.example.com/fonts/{fontstack}/{range}.pbf",
"sprite": "https://cdn.example.com/sprites/default",
"sources": {
"openmaptiles": {
"type": "vector",
"tiles": ["https://tiles.example.com/{z}/{x}/{y}.pbf"],
"minzoom": 0,
"maxzoom": 14
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#f8f4f0" }
},
{
"id": "water",
"type": "fill",
"source": "openmaptiles",
"source-layer": "water",
"paint": { "fill-color": "#a8d5e2" }
},
{
"id": "road-primary",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": ["==", ["get", "class"], "primary"],
"paint": { "line-color": "#e8c87a", "line-width": 2 }
}
]
}
Verify: jsonschema-validate the file against the MapLibre v8 spec before committing. The MapLibre GL JSON Structure reference documents every required key.
Step 2: Write minimal environment overrides
Override files contain only the keys that differ. Arrays (like layers) use a by-id merge strategy in the compiler, so you only need to include layers whose paint/layout properties change.
// theme.dark.json
{
"name": "dark",
"metadata": { "pipeline:role": "override", "pipeline:variant": "dark" },
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#1a1a2e" }
},
{
"id": "water",
"type": "fill",
"source": "openmaptiles",
"source-layer": "water",
"paint": { "fill-color": "#16213e" }
},
{
"id": "road-primary",
"type": "line",
"source": "openmaptiles",
"source-layer": "transportation",
"filter": ["==", ["get", "class"], "primary"],
"paint": { "line-color": "#e94560", "line-width": 2 }
}
]
}
Verify: The override file must be significantly smaller than the base. If it is approaching the same size, the base is not lean enough.
Step 3: Build the deep-merge compiler
Naive dict.update() flattens nested objects and reorders layers by positional index. Use a schema-aware merge that reconciles layers arrays by id field.
import json
import hashlib
from pathlib import Path
from typing import Any
def merge_layers(base_layers: list, override_layers: list) -> list:
"""Merge layers arrays by 'id', preserving base ordering."""
index = {layer["id"]: layer.copy() for layer in base_layers}
for ol in override_layers:
lid = ol.get("id")
if lid in index:
# Deep-merge paint and layout sub-dicts; scalar keys overwrite
for key in ("paint", "layout"):
if key in ol:
index[lid].setdefault(key, {}).update(ol[key])
# Non-dict keys overwrite directly
for key, val in ol.items():
if key not in ("paint", "layout"):
index[lid][key] = val
else:
# Append new layers from override at the end
index[lid] = ol.copy()
# Preserve base ordering, then append override-only layers
base_ids = [l["id"] for l in base_layers]
extra = [v for k, v in index.items() if k not in base_ids]
return [index[lid] for lid in base_ids] + extra
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base; arrays use by-id strategy for layers."""
result = base.copy()
for key, val in override.items():
if key == "layers" and key in result:
result[key] = merge_layers(result[key], val)
elif isinstance(val, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], val)
else:
result[key] = val
return result
def compile_theme(
base_path: Path,
override_path: Path,
output_dir: Path,
) -> Path:
base_style = json.loads(base_path.read_text())
override_style = json.loads(override_path.read_text())
merged = deep_merge(base_style, override_style)
# Deterministic content hash for cache-busting
content_hash = hashlib.sha256(
json.dumps(merged, sort_keys=True).encode()
).hexdigest()[:12]
variant = override_style.get("name", override_path.stem)
out_path = output_dir / f"{variant}-{content_hash}.json"
output_dir.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(merged, indent=2, sort_keys=True))
return out_path
Verify: Run a diff between two consecutive compiled outputs for the same variant. If the content hash changes without any source file changing, the merge is non-deterministic.
Step 4: Validate every compiled output
Run two validation passes before the artifact leaves CI:
- Schema compliance against the MapLibre v8 spec JSON
- Expression linting — verify every
["get", attr]reference exists in your tile schema
import jsonschema
import requests
_SCHEMA_URL = (
"https://raw.githubusercontent.com/maplibre/maplibre-style-spec"
"/main/src/reference/v8.json"
)
def validate_style(style_path: Path, schema_cache_path: Path) -> None:
"""Raises jsonschema.ValidationError on any spec violation."""
if not schema_cache_path.exists():
schema_cache_path.write_text(requests.get(_SCHEMA_URL, timeout=10).text)
schema = json.loads(schema_cache_path.read_text())
style = json.loads(style_path.read_text())
jsonschema.validate(instance=style, schema=schema)
def audit_get_refs(style: dict, known_attrs: set[str]) -> list[str]:
"""Return a list of attribute names referenced via ['get', ...] with no tile backing."""
missing = []
def walk(node: Any) -> None:
if isinstance(node, list) and len(node) >= 2 and node[0] == "get":
attr = node[1]
if isinstance(attr, str) and attr not in known_attrs:
missing.append(attr)
elif isinstance(node, dict):
for v in node.values():
walk(v)
elif isinstance(node, list):
for item in node:
walk(item)
walk(style)
return missing
The Style Validation Workflows guide covers plugging this step into GitHub Actions with schema caching to avoid network fetches on every CI run.
Step 5: Publish with content-hash URLs and correct headers
# Compile all variants
python3 compile_themes.py \
--base themes/base.style.json \
--overrides themes/theme.*.json \
--output dist/styles/
# Upload to Cloudflare R2 (or S3)
for f in dist/styles/*.json; do
aws s3 cp "$f" "s3://example-tiles/styles/$(basename $f)" \
--content-type "application/json" \
--cache-control "public, max-age=31536000, immutable"
done
# Update the short-lived manifest that clients poll
aws s3 cp dist/styles/manifest.json s3://example-tiles/styles/manifest.json \
--content-type "application/json" \
--cache-control "public, max-age=60, must-revalidate"
Verify: Fetch the manifest URL and confirm the ETag changes after an upload. Request any compiled style artifact and confirm the Cache-Control header includes immutable.
Optimization Knobs
| Parameter | Conservative | Aggressive | Trade-off |
|---|---|---|---|
| Override depth | Base → Environment (2 levels) | Base → Region → Campaign (3 levels) | Deeper trees are harder to debug; merge time is negligible |
| Array merge strategy | By id (recommended) |
Positional index | Positional scrambles layer z-order on any insertion |
| Manifest TTL | 60 s | 300 s | Lower TTL catches style rollouts faster; slightly higher CDN origin load |
| Schema cache TTL in CI | Pin to git-tracked local copy | Fetch from spec repo HEAD | Live fetch risks breaking builds on spec repo downtime |
Integration with Adjacent Pipeline Stages
Compiled theme artifacts slot into the broader pipeline at two points:
Upstream — tile generation: The source-layer names in override files must match the layer names emitted by Tippecanoe or Martin. If the tile pipeline uses --layer=transport but the style references source-layer: transportation, every road layer silently disappears. Coordinate renames through the same CI commit that updates the base style.
Downstream — CDN and tile server: Theme JSON files are static blobs. Serve them from the same CDN origin as .pmtiles or .mbtiles tile files so that a single CDN rule covers both. When updating MBTiles containers or converting to PMTiles for HTTP range-request delivery, increment both the tile URL version and the theme manifest in the same deployment. A mismatched source URL in the compiled style causes the renderer to request tiles from a stale or deleted endpoint.
Troubleshooting
| Symptom | Diagnosis | Fix |
|---|---|---|
| Layer disappears after dark-mode override | source-layer mismatch between compiled style and tile payload |
grep -r '"source-layer"' dist/styles/dark-*.json — compare against tippecanoe --layer= values |
| Layer z-order scrambles after adding a new override | Positional array merge used instead of by-id merge |
Switch to merge_layers() shown in Step 3; re-run compile |
| Compiled hash differs across identical runs | Non-deterministic key ordering in json.dumps |
Pass sort_keys=True to every json.dumps call in the pipeline |
| Schema validation passes locally but fails in CI | Different jsonschema versions or cached schema mismatch |
Pin jsonschema==4.21.1 in requirements.txt; commit the cached schema JSON |
| Stale style served to browser after deployment | CDN serving cached manifest or Cache-Control: immutable on manifest endpoint |
Set max-age=60, must-revalidate on manifest.json; verify with curl -I |
Further Reading
Compiling Dark-Mode Tile Styles from a Base — generating light and dark variants from one base style by overriding only paint colors, with an assertion that every variant keeps identical layer ids and source-layer names so all stay schema-synchronized.
Related Pages
- MapLibre GL JSON Structure — the underlying spec that every compiled theme must satisfy, including layer ordering rules and expression syntax.
- Dynamic Attribute Mapping — generate runtime
["interpolate"]and["match"]expressions injected at the third inheritance tier. - Style Validation Workflows — CI pipeline steps for schema compliance, expression linting, and visual regression testing of compiled themes.