roadzilla
v0.1.0
Published
Sophisticated road rendering for MapLibre map stylesheets
Maintainers
Readme
roadzilla
Roadzilla generates road layers for MapLibre stylesheets to achieve sophisticated road cartography.

Overview
Roads are one of the hardest parts of a general-purpose map style to get right. Good maps render them with two different strategies depending on zoom. At high zoom, roads draw in physical order: overpasses cross over the roads beneath them, tunnels pass underneath. At low zoom, physical accuracy is less important than visual hierarchy, so major roads are drawn on top of minor roads to ensure that the most important links in the network appear uninterrupted.
Each strategy takes many MapLibre style layers to implement. Physical ordering needs a separate pass per physical layer, and within each pass every road's case (outline) must draw before any road's core (fill) so that at-grade intersections look connected. The hierarchical scheme needs its own set of layers with careful minzoom/maxzoom management, since a road class may climb the hierarchy as you zoom in (a primary road might start out grouped with minor roads, then get promoted) before handing off to physical rendering.
Roadzilla generates all of these layers from a compact config. It returns them in groups, one per physical layer, which you interleave with the rest of your style: tunnels below buildings, bridges above, and so on. By default, Roadzilla is compatible with vector tiles in the Sourdough tile schema for OpenStreetMap data, but by overriding some config values you can adapt it to work with most other schemas too. The colors, widths, zoom thresholds, and road class hierarchy assignments are all configurable.
Roadzilla is a library, not a complete style. It assumes you are already building your stylesheet with JavaScript or TypeScript rather than maintaining raw JSON. See the Sourdough examples repo for patterns on how to do this.
Installation
npm install roadzillaRoadzilla has no runtime dependencies and comes with TypeScript types.
Usage
import roadzilla from "roadzilla";
const roads = roadzilla({ layers: [-1, 0, 1] });
const style = {
version: 8,
name: "My Custom Map Style",
sources: { sourdough: { type: "vector", url: "https://..." } },
layers: [
// background, landuse, water...
...roads[0], // tunnels (layer <= -1), drawn under buildings
// buildings, barriers, trails...
...roads[1], // surface roads (layer == 0 or null), plus the low-zoom hierarchy
...roads[2], // bridges (layer >= 1)
// labels, POIs...
],
};The return value is an array with one group of layers per entry in
config.layers. Insert each group wherever it belongs in your layer stack.
How it works
Roadzilla's config is built around a few core ideas:
Kinds are the road classes you want to render: motorway, residential,
service, and so on. Each feature's kind is determined by an expression, typically
["get", "highway"] or ["get", "class"] or similar. Each kind can have distinct
colors, line widths, and other styling.
Families are the levels of the low-zoom hierarchy. Each kind's config says
which families it belongs to and at what zoom it joins each one. For example,
the default config has primary: { minor: 7, major: 9 }: primary roads first
appear at z7 as minor roads, then get promoted to the major level at z9. Each
family has a maxzoom at which it dissolves, releasing its member kinds into
the physical order regime.
Layer buckets are the physically ordered groups used at high zoom. You choose
which OSM layer values get their own bucket (e.g. layers: [-1, 0, 1]); values
that are outside the range get clamped to the endpoints. This trades a little visual
accuracy for a smaller and more performant stylesheet. Within a bucket, all cases
draw before all cores, which makes at-grade intersections look visually connected.
bridge and tunnel tags play no part in physical ordering; that is layer's
job. They are instead available to data-driven paint expressions, so you can dim
tunnel cores or give bridges thicker casing (the defaults do both).
Roadzilla also renders cul-de-sacs, turning loops, and mini roundabouts as circle
layers slotted into the surface (layer = 0) bucket. See turnings below.
Configuration
Every config property is optional. The example below shows all of them, set to their default values.
const roads = roadzilla({
// Physical layer buckets: consecutive integers in ascending order. Roads
// with layer values outside this range are clamped into the buckets at each
// end. OSM elements can technically have any layer value, but in practice
// nearly all roads fall between -2 and +2.
layers: [-1, 0, 1],
// Source and source-layer of the roads data in the vector tileset.
source: "sourdough",
sourceLayer: "highways",
// Prefix for generated layer ids. Give each call a distinct prefix if you
// use roadzilla more than once in the same style.
idPrefix: "road",
// Expressions for reading a road feature's kind and physical layer.
getKind: ["get", "highway"],
getLayer: ["coalesce", ["get", "layer"], 0],
// Low-zoom hierarchy levels, in draw order (first is bottom-most). Each
// family dissolves into physical rendering at its maxzoom. `cased: false`
// renders a family as bare cores with no outline. Replaces the default
// wholesale; spread `defaults.families` to extend it instead.
families: [
{ name: "minor", maxzoom: 15, cased: false },
{ name: "major", maxzoom: 13 },
{ name: "arterial", maxzoom: 13 },
],
// Road kinds to render, and which low-zoom families they belong to. A value
// like { minor: 7, major: 9 } means that road kind first appears at z7 in
// the minor family, and is promoted to the major family at z9.
kinds: {
service: { minor: 12 },
busway: { minor: 12 },
living_street: { minor: 11, major: 14 },
pedestrian: { minor: 11, major: 14 },
unclassified: { minor: 11, major: 14 },
residential: { minor: 11, major: 14 },
tertiary_link: { major: 11 },
secondary_link: { major: 11 },
primary_link: { major: 11 },
trunk_link: { arterial: 11 },
motorway_link: { arterial: 11 },
tertiary: { minor: 10, major: 11 },
secondary: { minor: 9, major: 10 },
primary: { minor: 7, major: 9 },
trunk: { arterial: 3 },
motorway: { arterial: 3 },
},
// The color, width, and cap properties below all accept either a MapLibre
// expression (which applies to highways of all kinds) or a function which
// takes highway kind and returns such an expression (which will be applied
// just to that kind). Object lookup tables are also supported as a shorthand
// for the function variant (keys are highway kinds, values are expressions).
// Core (fill) color. The default dims tunnels and tints motorways and trunks.
coreColor: (highway) => {
const base =
highway.includes("motorway") || highway.includes("trunk")
? "hsl(220, 20%, 85%)"
: "hsl(0, 0%, 100%)";
const dim = "hsl(220, 15%, 92%)";
return ["case", ["has", "tunnel"], dim, base];
},
// Case (outline) color. The default darkens bridges.
caseColor: (highway) => {
const base =
highway.includes("motorway") || highway.includes("trunk")
? "hsl(220, 20%, 75%)"
: "hsl(220, 20%, 85%)";
const bridge = "hsl(220, 20%, 65%)";
return ["case", ["has", "bridge"], bridge, base];
},
// By default, Roadzilla scales the widths of roads automatically as you zoom
// in, with each road appearing as a hairline and increasing to its target
// width at z18. The target width varies by the factor in the table below,
// which can be customized by overriding it in the config.
widthFactor: {
motorway: 1.5,
trunk: 1.4,
primary: 1.3,
secondary: 1.2,
tertiary: 1.1,
motorway_link: 0.7,
trunk_link: 0.6,
primary_link: 0.5,
secondary_link: 0.4,
tertiary_link: 0.4,
service: 0.5,
busway: 0.5,
pedestrian: 0.875,
default: 1.0,
},
// Alternately, you can define your own coreWidth function which returns
// a MapLibre expression. widthFactor is ignored in this case and you can
// control the width exactly as you see fit for each road class.
coreWidth: (highway) => {
return ["interpolate", ["linear"], ["zoom"], ...];
}
// Case width, in pixels per side of the road. The default thickens bridge
// casings slightly.
caseWidth: [
"interpolate", ["linear"], ["zoom"],
6, 0,
18, ["case", ["has", "bridge"], 2.0, 1.5],
],
// line-cap for cases and cores in physical rendering. (Low-zoom family
// rendering always uses round caps currently)
//
// Data-driven line-cap values, including the default caseCap below, require
// MapLibre GL JS >= 5.22 and are not yet supported in MapLibre Native.
caseCap: {
motorway: "butt",
trunk: "butt",
primary: "butt",
secondary: "butt",
tertiary: "butt",
motorway_link: "butt",
trunk_link: "butt",
primary_link: "butt",
secondary_link: "butt",
tertiary_link: "butt",
default: ["case", ["has", "bridge"], "butt", "round"],
},
coreCap: "round",
// Circle rendering for turning circles, mini roundabouts, etc. Set turnings:
// false to disable.
turnings: {
// radius in meters (turning circles are rendered at constant physical size)
radius: 8,
// zoom where circles first appear; if omitted, defaults to the zoom
// where the last family dissolves (15 with the default families)
minzoom: 15,
// kinds (returned by getKind) which should be rendered as circles
kinds: ["turning_circle", "turning_loop", "mini_roundabout"],
// subset of kinds which should get a center dot
island: ["turning_loop", "mini_roundabout"],
// dot radius (fraction of total radius)
islandRatio: 0.35,
},
});The defaults export holds the full default config, so you can build on
individual pieces of it, e.g. roadzilla({ kinds: { ...defaults.kinds, service: 14 } }).
Using other tile schemas
Roadzilla assumes a Sourdough-shaped schema by default, but should work with most
other schemas if you override everything that reads feature properties: getKind,
getLayer, kinds, widthFactor, the bridge/tunnel checks inside
coreColor/caseColor/caseWidth, and turnings.kinds. For example, with
OpenMapTiles you would set getKind: ["get", "class"] and declare kinds using
OpenMapTiles class names.
Design notes
Roadzilla's default config draws inspiration from HighRoad, by Michal Migurski, Nathaniel Kelso and Geraldine Sarmiento of Stamen Design.
HighRoad helped popularize the treatment of roads that has become standard in digital maps today: physical order at high zoom, hierarchy order at low zoom, with three distinct classes in the hierarchy at every zoom level.
Roadzilla can be customized to use fewer or more families (hierarchy levels), but three is a good choice for most maps. The flexibility mainly exists so you can choose which kinds land in each level and when they appear, in order to achieve a more or less dense road visualization on your map.
License
This code is available under the ISC license; see the LICENSE file for details.
