Everything I know about tiled web maps
My job just spent an enormous amout of resources building out
Tiled web map (or slippy map) is a term of art for interactive web maps you can pan, tilt and zoom and where data is loaded dynamically when it's needed. This makes it possible to interact with large datasets like "every bus stop in Switzerland" or "every building in America" which would be impractical to download and render as a single image.
Early implementations used pre-built raster tiles, but modern ones tend to deliver tiled vector data that isn't turned into a visible image until it reaches the end-user's device.
Architecture
Turning raw geographic data into a useful mapping service requires the following infrastructure:
--- config: theme: 'base' themeVariables: fontFamily: "Atlas" primaryColor: "#f7f7f7" primaryTextColor: 'black' primaryBorderColor: "gray" secondaryColor: "white" --- flowchart TB data["Data"] schema["Vector Tile Schema"] tile_gen["Tile Generator"] web["User Interface"] tile_server["Tile Server"] data --> tile_gen --> tile_server --> web schema --> tile_gen Style --> web
- A set of suitable, up-to-date geographic data.
- A tile schema containing a list semantically-useful layers for your map data.
- A tile generator to parse this data, simplify it, sort it into layers according to your schema, slice it into square tiles for every zoom level, and save them to a specialised database
- A tile server that reads from the database and delivers individual tiles in response to HTTP requests
- A style document where you specify how each element in your schema should be rendered at each zoom level
- A user interface that implements controls like zooming or panning, sends well-formed requests to your tile server, parses the resulting data and draws it to the screen according to your style document.
Supporting features like search or routing have their own, separate software stacks.
Implementation
If you want to offer tiled web maps to your users, you have two options:
1. Just buy a commercial product
Many for-profit companies have implemented some or all of this architecture and sell access to it for a monthly fee.
The most popular of these is called Mapbox. They have well-groomed data for the whole planet 1, an on-demand tiling service, a tile hosting service, extremely well-designed style templates, frontend components for every platform and a web UI to marshal all of this into action. It's a good service, which is why it's used by a lot of car companies, gig economy companies and just about every blue chip media organisation in the world.
On the other hand, it's priced for enterprise use and the company has been criticised for somewhat-abruptly enclosing their previously open-source software in 2020, engaging in union-busting and supplying software to the car industry.
Google Maps, Maptiler, ArcGIS and others offer competing products with varying feature sets and pricing structures.
2. Assemble an open-source stack
If you're going to roll your own tiled mapping stack, there are few more-or-less established open-source options for each step of the pipeline:
Data
The bulk of your map data (and everyone else's) is going to be an OpenStreetMap dump of the planet. This can be enriched with a lot of other data, like NaturalEarth (for large-scale landcover), SRTM elevation data (for shaded relief), 3d building models, and whatever else seems useful.
You also need a robust way to regularly update this data: OpenStreetMap alone receives four million changes per day and other datasets also change over time.
Tile Generator
Tile server
User Interface
Maplibre.gl
Design
A MapLibre style is a document that defines the visual appearance of a map: what data to draw, the order to draw it in, and how to style the data when drawing it. A style document is a JSON object with specific root level and nested properties. 2
I think the only viable way to design a high-quality customs basemap (outside of the Mapbox editor) is to more-or-less hand-write a style document. In many ways
Use design tokens
You should limit the number of colours, type treatments and other gestures in your map style and apply them consistently.
A set of statically-defined design tokens is a good way to do this.
Tokens.js
const colors = {
roadPrimary: '#12312',
roadSecondary: '#12312',
}
Streets.js
import { colors } from "Tokens.js"
///...
{
id: 'street-motorway',
type: 'line',
'source-layer': 'streets',
paint: {
'line-color': tokens.roadPrimary,
}
}
It's useful to extend existing style objects with the spread operator.
Consider semantic groups before layers
It's a good idea to think about your map style, and structure your code, primarily in terms of semantic feature groups3 like roads
, buildings
or landcover
. Once you've established these, you can split them into physical, z-indexed layers for your map style.
This has two advantages:
- Code organisation matches design intent. You can think in semantically useful terms instead of searching by keyword. Most of the design work happens at the semantic group level, the interleaving doesn't really change much
- Layer ordering can be driven by design intent, not developer convenience You can interleave layers without loosing your mind
--- config: theme: 'base' themeVariables: fontFamily: "Atlas" primaryColor: "#f7f7f7" primaryTextColor: 'black' primaryBorderColor: "gray" secondaryColor: "white" --- block-beta columns 10 classDef admin fill:#ddd; classDef roads fill:#fff; classDef landuse fill:#fff; block:semantic:6 columns 1 columns 1 s_admin["Admin"] s_roads["Roads"] end block:actual:10 columns 1 a_admin_labels["Admin Labels"] a_roads_labels["Road Labels"] a_admin0["Countries"] a_admin1["States/Provinces"] a_admin2["Districts"] a_roads_bridge["Bridge Roads"] a_roads_bridge_case["Bridge Roads (Case)"] a_roads_surface["Surface Roads"] a_roads_surface_case["Surface Roads (Case)"] a_roads_tunnel["Tunnel Roads"] a_roads_tunnel_case["Tunnel Roads (Case)"] a_background["Background"] end class s_admin,a_admin0,a_admin1,a_admin2,a_admin_labels admin class s_buildings,a_buildings buildings class s_roads,a_roads_bridge,a_roads_bridge_case,a_roads_surface,a_roads_surface_case,a_roads_tunnel,a_roads_tunnel_case,a_roads_labels roads class s_landuse,a_land,a_ocean,a_background landuse
Javascript imports work well for this:
import { RoadsBridges, RoadsSurface, RoadsLabels } from './roads'
import { Countries, States, AdminLabels } from './admin'
export default {
RoadsSurface,
RoadsBridges,
Countries,
States,
RoadLabels,
AdminLabels,
}
Fade features in and out
You want to
Outline roads
It's a good idea to outline (or less ambiguously, case) roads and other linear features to separate them from the background and correctly distinguish intersections, bridge crossings and tunnels.
The only way to achieve this in Maplibre-GL is to duplicate the layer, give it a contrasting paint
colour, slightly increase its line-width
and sort it below the original layer.
This method produces characteristic visual artifacts when continuous line features are are made up of individual segments on different layers, like a surface road leading to a bridge:
You do this by grouping roads into tunnels, surface streets and bridges, each subdivided by your chosen roadway hierarchy. Within each group, you sort all case layers to the bottom and regular layers to the top. Finally, you set line-cap: round
on the roads and line-cap: butt
on the cases.
Use custom typefaces
- datenhub-map-fonts, see: https://github.com/versatiles-org/versatiles-fonts/issues/11 (Deprecated)
Dont' use raster layers in base maps
- Bad performance, even shaded relief should be pre-rendered to vector tiles
Conclusion
From an engineering perspective, building out your own tiled web map service isn't exactly hard: The key problems of generating, storing, serving and displaying tiled vector data have open-source solutions mostly on-par with commerical products. However, it is labour intensive: You're going to be writing a lot of plumbing code between the different layers of the stack, maintaining a significant amount of infrastructure and fixing long-tail issues in your data pipeline, tile schema and style documents pretty much indefinitely.
The key to making this viable is to limit the problem space from the outset. At my job, we decided we were only going map one country, in one language, in one (extremely minimal) map style - that
References
- Chris Amico (2024): How to make self-hosted maps that work everywhere and cost next to nothing (Muckrock)
- https://www.geofabrik.de/projects/residential_areas/index.html
- https://osmcode.org/osmium-tool/manual.html#filtering-by-tags
- https://locationiq.com/geocoding
- https://www.geofabrik.de/data/geocoding.html
- Useful OSM dumps: https://download.geofabrik.de/europe/germany.html
- https://github.com/maplibre/font-maker
- https://kschaul.com/post/2023/02/16/how-the-post-is-replacing-mapbox-with-open-source-solutions/
Mapbox (where I got this idea from) call these groups "style components". In addition to what I'm proposing here, they have component-level settings. See: William Davis (2022): Foundational Map Design: Principles and our core styles ↩︎
OpenStreetMap alone receives four million updates per day ↩︎
The term was popularised by OpenStreetMap in the mid-2000s. ↩︎