OpenStreetMap rendered like it's 1999.
Cartopolis turns present-day OpenStreetMap data into a completely static late-1990s web map. An offline Python step compiles a .pbf extract into compact binary vector packs. The browser fetches them, rasterizes every pane without antialiasing, and exports a genuine GIF89a in the classic 216-color web-safe palette.
The style is not a theme or a filter — no beige overlay, no pixelation pass. It emerges from a deliberately restricted raster pipeline: integer pixel rounding with no antialiasing, the 216-color palette with zero dithering, aggressive Douglas–Peucker geometry simplification. Why and how: blog post.
Live: seriot.ch/cartopolis — the whole of Switzerland as static files.
pip3 install pillow shapely pyrosm
curl -fLO https://download.geofabrik.de/europe/switzerland-latest.osm.pbf
python3 python/bake.py --pbf switzerland-latest.osm.pbf \
--bbox 5.9,45.8,10.5,47.85 --center 46.8,8.2 \
--name Switzerland --outdir cartopolis
cd cartopolis && python3 -m http.server 8098 # fetch() needs http://Upload the cartopolis/ folder to any static host. You get smooth drag-to-pan with inertia (the 2005 slippy-map trick applied to 1999 cartography — pre-rendered aliased tiles translated by whole pixels, labels re-placed when the map settles), discrete zoom levels, search (places and streets with per-town disambiguation), click-to-recenter, full-screen, an Aerial Photo view (current swisstopo imagery, saved as PNG), and "Save as GIF" — a real GIF89a from the project's own encoder, not a canvas screenshot.
A whole-Switzerland bake takes roughly 15 minutes and produces about 500 MB of packs; a Lac Léman arc is ~46 MB. For production hosting (immutable caching, pre-compressed indexes, Range requests), see cartopolis/README.txt and the committed htaccess / nginx-example.conf.
Posters: add &w=4000&h=3000 (max 4000 per side) to any map URL. The saved GIF is twice the requested pane: an 8000×6000 poster, still web-safe.
Exact scale: add &mpp=6.153 to override the current zoom level's scale (Web Mercator meters per pixel); data tier and styling stay those of &z=.
The compiler streams the extract one 0.25° cell at a time, quantizes every coordinate to a canonical 0.25 m grid (identical in Python and JavaScript), simplifies geometry per zoom level, and writes compact binary packs plus a few JSON indexes. Features that span many cells (large lakes, forests) are stored once and referenced.
raster.jsdraws every primitive without antialiasing. Coordinates round on a world-aligned pixel grid and features rasterize anchored to their own first vertex, so the same feature lands on identical pixels in every view — rendering is translation-invariant.client.jsfetches packs (preferring HTTP Range requests), renders world-aligned 256 px geometry tiles in painter's order, places labels from a pre-baked glyph atlas over the settled viewport, and draws the chrome.index.htmlholds the camera: dragging just re-blits cached tiles at integer offsets (no rendering per frame); when motion stops, labels and chrome re-flow. Posters over ~3 MP render as one pane.gifenc.jsLZW-encodes the indexed pixels into a web-safe-palette GIF89a when saving.
Labels are typeset from bit-packed glyph atlases produced at bake time. No system fonts, no antialiasing, no kerning. The same atlases define the collision boxes, so the layout is deterministic.
The JavaScript renderer is the single source of truth. Its output is locked byte-for-byte by committed golden GIFs at every zoom level.
pip3 install pillow shapely pyrosm pytest
# optional but recommended for country-scale bakes:
brew install osmium-tool # or apt install osmium-toolRun everything from the repository root. Geofabrik publishes daily extracts; the bake crops them cell by cell (cached under cache/). For a custom area you can pre-crop with osmium extract.
Tests are hermetic:
python3 -m pytest python/tests -qThe main test renders a committed Bern fixture with the committed JavaScript sources and asserts byte-identical GIFs against golden files; a second one composes each pane from independently rendered tiles and asserts the result is byte-identical to the directly rendered pane (the invariance the tile cache is built on). After an intentional visual change, regenerate the goldens with python3 python/tests/make_goldens.py and eyeball the diffs before committing.
Cartopolis does not claim to emulate any particular 1999 product. The look is a reconstruction of the visible constraints of that era (primarily inspired by MapQuest and Yahoo! Maps, with traces of DeLorme and European road-atlas DNA). The blog post compares a pane side by side with an archived 1999 MapQuest map of the same town — including where the reconstruction is stricter than the original and where it is cruder.
One baking edge case to know about: each feature is owned by the 0.25° cell containing the first vertex of its outline, so a large lake whose outline starts outside --bbox belongs to no baked cell and silently disappears from the map. The bake detects lake-sized casualties and prints a warning with the offending coordinate — if you see it, widen --bbox to include that point.
Map data © OpenStreetMap contributors (ODbL). Aerial imagery © swisstopo (SWISSIMAGE). Cartography © 2026 Cartopolis.

