A mapnik vector datasource plugin that reads osmflat archives and answers bounding-box queries using the archive's spatial index.
mapnik ──loads──▶ osmflat.input (C++ MODULE)
│ src/osmflat_datasource.cpp (mapnik datasource)
│ src/osmflat_featureset.cpp (pull-based featureset)
│ include/osmflat_archive.hpp (RAII over the C API)
▼
libosmflat_capi.a (Rust staticlib, baked in)
│ rust/osmflat-capi/src/lib.rs (#[no_mangle] C ABI)
│ build.rs → cbindgen → include/osmflat_capi.hpp
▼
osmflat crate (github.com/boydjohnson/osmflat-rs, main)
find_nodes/ways_by_bounding_box, iter_tags
The Rust shim is linked as a staticlib, so the whole plugin ships as a
single osmflat.input file with no separate dylib to locate at runtime.
Corrosion makes CMake the single
driver: one cmake --build compiles the Rust staticlib (running cbindgen via
build.rs to regenerate the header), then builds and links the C++ module.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
# → build/plugins/osmflat.inputRequirements: a C++17 compiler, CMake ≥ 3.22, Rust/Cargo, mapnik 4
(libmapnik discoverable via pkg-config), and Boost headers. On
macOS/Homebrew the Boost include path is added automatically via
brew --prefix.
Mapnik 3.x won't work: mapnik::parameters::get returns std::optional in 4.x
and boost::optional in 3.x, which this plugin's init() relies on
throughout, so CMake requires libmapnik>=4.0 rather than letting the build
fail later with template errors. In practice that means Homebrew (4.2+) or
Ubuntu 26.04 LTS (4.2.1); 24.04 and 22.04 still ship mapnik 3.1, so building
there means building mapnik from source.
osmflat and osmflat-ext are git dependencies (branch main) of the Rust
crate, so a clone of this repo builds on its own — no sibling checkouts — but
the first build needs network access for cargo to fetch them.
-DOSMFLAT_STATIC_RENDER=ON builds mapnik from source (v4.2.2 by default) as a
static library with the osmflat datasource compiled in as a built-in
plugin, and links render against it. The result loads nothing at runtime — no
libmapnik, no osmflat.input, no system fonts — so it can be copied to a
machine that has never heard of mapnik:
cmake -S . -B build-static -DCMAKE_BUILD_TYPE=Release \
-DOSMFLAT_STATIC_RENDER=ON -DOSMFLAT_FULLY_STATIC=ON
cmake --build build-static --target renderOSMFLAT_FULLY_STATIC=ON adds -static (musl only: libc goes in too). On
macOS, leave it off -- libSystem can't be linked statically, so mapnik and
every third-party library go in but macOS' own dylibs and frameworks stay
dynamic; scripts/build-static-render-macos.sh builds that and fails if
otool -L shows anything outside /usr/lib or /System. Two macOS
wrinkles it handles: Homebrew's libharfbuzz.a needs graphite2, which has no
static archive, so harfbuzz is built from source with just freetype and
CoreText; and icu4c/zlib/bzip2 are keg-only, so their prefixes are passed
explicitly. The
<plugin_dir> argument stays in the CLI but is ignored — loading a .input
module would pull in a second, shared mapnik.
Mapnik's static-plugin table is compile-time, so
cmake/static-mapnik/patch-mapnik.cmake edits the fetched mapnik tree: it adds
plugins/input/osmflat, links it into libmapnik, and registers it in
datasource_cache_static.cpp. Every edit anchors on an exact upstream string
and fails loudly if mapnik moved it.
Trimmed to what render needs: AGG + PNG, freetype/harfbuzz/ICU for text,
Boost.Regex for .match()/.replace() in filters, and PROJ for reprojection.
No cairo, grid/SVG renderers, or stock input plugins.
ICU needs its data, not just its code: Boost.Regex's ICU traits load it
when a .match()/.replace() expression is parsed (without it the style
fails with "Could not initialize ICU resources"), and wrap-width labels need
its line BreakIterator ("could not create BreakIterator", and no wrapping).
Homebrew's libicudata.a carries the full data, so macOS gets it by linking.
Alpine's is a ~1 KB stub with the data in a separate icudt<ver>l.dat, so
the linux build embeds icu-data-full's .dat into render
(OSMFLAT_ICU_DATA_FILE, cmake/icu-data.S.in) and registers it with
udata_setCommonData() at startup. That copy wins over ICU_DATA and any
host ICU, so a host's different ICU version doesn't matter. It costs ~30 MB
of binary (~12 MB in the tarball); icu-data-en would be ~3 MB but drops
non-English locales and the dictionary-based line breakers for Thai, Lao,
Khmer, Burmese and CJK. render_icu_data runs with ICU_DATA pointed at
nothing, so a data dir in the build environment can't mask a binary that
lacks its own.
PROJ (9.9.0) is built from source on every platform with libtiff and libcurl
off — Alpine's and Homebrew's builds pull both in (and OpenSSL behind curl)
only to read and download datum-shift grids. Without grids, a transformation
that wants one uses PROJ's ballpark fallback: about a metre between WGS84 and
NAD83, invisible at map scale. A static PROJ embeds proj.db in the library,
and USE_ONLY_EMBEDDED_RESOURCE_FILES keeps it from looking for one on disk,
so there's no data directory to ship and the binary behaves the same
everywhere. So a Map can use any EPSG code or proj string —
test/style-relations-rd.xml draws the fixture in the Dutch national grid.
Note that in a projected CRS, scale_denominator is in metres, not the tiny
degree-based numbers +proj=longlat maps use.
(Before PROJ was in, mapnik knew only epsg:4326 / epsg:3857 by name and
classified epsg:4326 as geographic — scaling scale_denominator ~111319x
against what PROJ reports for +proj=longlat +datum=WGS84 +no_defs, which
would have silently broken every style's MaxScaleDenominator gating. With
PROJ linked, both builds classify it identically by construction.)
Fonts: $MAPNIK_FONT_DIR if set, else fonts/ beside the binary (which is how
the release tarball ships DejaVu).
scripts/build-static-render.sh does the whole thing in Alpine — installs the
static dependency archives, builds, runs ctest, and packages
dist/osmflat-render-<version>-<arch>-linux-musl.tar.gz:
podman run --rm -v "$PWD:/src" -w /src alpine:3.22 sh scripts/build-static-render.sh.github/workflows/static-render.yml runs that same script under docker run
for x86_64 and aarch64, plus the macOS script on macos-14, and uploads all
three tarballs, caching the build tree
(mapnik is ~16 min cold, ~1 min warm). .github/workflows/release.yml builds
the same three archives from a clean tree on a v* tag — the tag has to match
project(... VERSION) — smoke-tests each unpacked archive on a bare alpine
image, and publishes them with a SHA256SUMS. workflow_dispatch on that
workflow builds without publishing, to check an arch before cutting the tag.
Each archive carries render, fonts/, mapnik's LICENSE.mapnik, and a
NOTICE naming every statically linked library and how to relink.
Licensing: mapnik is LGPL-2.1, so a distributed binary with mapnik linked in must let recipients relink it against a modified mapnik — publishing these sources plus the build scripts is what covers that.
| param | required | values | meaning |
|---|---|---|---|
type |
yes | osmflat |
selects this plugin |
file |
yes | path | the *.osm.flat archive dir |
osm_type |
no | comma-separated node|way|relation|all |
primitives to emit (default all); e.g. way,relation |
ext |
no | path | Ext sidecar dir (*.osm.ext) enabling tag push-down and, if built with --multipolygons, precomputed relation ring assembly |
tags |
no | comma-separated key=value / key=* |
tag prefilter, e.g. highway=* or natural=water,natural=wood |
member_of |
no | comma-separated key=value / key=* |
relation-membership filter: emit only nodes/ways that are members of a relation matching all terms, e.g. route=train,ref=Borealis |
numeric |
no | comma-separated keys | expose these tags as numbers so [lanes] > 2 compares numerically |
order |
no | z_order|way_area|none |
draw order of returned features (default spatial); z_order for roads, way_area (desc) for areas |
simplify |
no | pixels (float, default 0.5) |
scale-aware Douglas–Peucker tolerance; geometry generalized to sub-pixel per zoom. 0 disables |
dump |
no | path | append-mode NDJSON correlation dump; see below |
The datasource is semantically neutral: nodes → points, ways → line strings
(open and closed alike — no area heuristics). The style decides fill vs. stroke
per tag (mapnik's PolygonSymbolizer fills a closed ring, LineSymbolizer
strokes it). type=multipolygon/boundary relations are assembled from their
member ways into multi_polygon geometry (outer rings + holes).
Attributes are query-driven: each feature exposes exactly the tags the active
style references ([highway], [natural], …), null-filled when absent, plus
synthetic facts: osm_id (Integer, real OSM id), osm_type
(String: node/way/relation), is_closed (Boolean), way_area (Double,
enclosed area in spherical m² for closed ways / multipolygons; 0 otherwise —
e.g. [way_area] > 1000000 for areas over 1 km²), and z_order (Integer,
osm2pgsql-style render priority = layer*10000 + bridge/tunnel band + highway class rank; also drives the order=z_order sort).
Every check below is registered with ctest (under -DBUILD_RENDER_TEST=ON),
against the checked-in fixtures, so the whole suite is one command:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_RENDER_TEST=ON
cmake --build build
ctest --test-dir build --output-on-failure # C++ side
(cd rust/osmflat-capi && cargo test) # Rust C-ABI sideThat is exactly what .github/workflows/ci.yml runs on every pull request,
inside an ubuntu:26.04 container so CI builds against the mapnik an Ubuntu
LTS user gets from apt install libmapnik-dev. The individual harnesses are
documented below — run them by hand (with other archives, bboxes or styles)
when a fixture-sized case isn't what you need.
Loads the plugin through mapnik's datasource_cache, applies test/style.xml
(base gray ways + red [highway]='primary'), and renders a PNG:
cmake -S . -B build -DBUILD_RENDER_TEST=ON
cmake --build build
./build/render ./build/plugins <style.xml> <archive_dir> out.png \
<minx> <miny> <maxx> <maxy>
# e.g. Mexico City:
./build/render ./build/plugins ./test/style.xml \
../osmflat-rs/mexico.osm.flat mexico.png -99.30 19.20 -98.95 19.60Example styles under test/ (each uses @ARCHIVE@ as the archive placeholder):
| style | shows |
|---|---|
style.xml |
base ways + red [highway]='primary' filter |
style-full.xml |
full multi-scale basemap: landcover+relation fills, buildings, road ramp w/ bridges+arrows, area/street/POI labels, city/town place labels, MaxScaleDenominator gating from neighborhood to state/country |
style-labels.xml |
line-placement street labels + POI labels (needs fonts) |
style-streets.xml |
urban street ramp: casing/fill tiers, oneway arrows, bridges, labels |
style-relations.xml |
type=multipolygon/boundary relations as filled polygons with holes |
style-borealis.xml |
member_of route highlighting: only the Amtrak Borealis member ways, labeled via [rel_ref] |
The style-streets.xml tiers were chosen from real archive counts via
osmflat-taginfo (see the project memory). Text styles need fonts registered —
the harness registers Homebrew's bundled DejaVu, overridable with
MAPNIK_FONT_DIR.
test/fixtures/ holds small real-world extracts of edge cases that stress
ring assembly beyond simple multipolygon-with-holes. Each fixture is checked
in as four forms built from the same source data:
| file | what it is |
|---|---|
<name>.osm.pbf |
the raw extract |
<name>.geojson |
osmium export, for the mapnik geojson.input plugin — an independently-implemented area assembler to cross-check against |
<name>.osm.flat/ |
osmflatc archive, for the osmflat plugin |
<name>.osm.ext/ |
osmflat-extc --taginfo sidecar (for the ext/tags push-down params) |
| fixture | why it's hard |
|---|---|
baarle-hertog |
Baarle-Hertog (Belgium) / Baarle-Nassau (Netherlands) enclave complex: a single boundary relation whose "outer" members form ~20+ disjoint closed rings (scattered exclaves), not one ring with holes. osmium export's independent area assembler agrees exactly: 25 polygons for Baarle-Hertog, matching diag_sd's ring count. One of those 25 also has 6 holes (the "counter-enclave" pattern — Dutch parcels inside a Belgian exclave), so this fixture covers hole-nesting too |
bicycle-route |
a small Dutch node-network cycle route relation (type=route, route=bicycle, 11 way members) plus ~260 nearby highway ways that are not members — the negative control needed to test that member_of actually excludes, not just includes |
Render a fixture's osmflat archive:
./build/render ./build/plugins ./test/style-relations.xml \
test/fixtures/baarle-hertog.osm.flat baarle.png 4.75 51.38 5.02 51.49compare_plugins (built alongside render under BUILD_RENDER_TEST) drives
the osmflat plugin and mapnik's geojson.input plugin directly through
datasource::features() — no rendering, no style XML — and diffs the
polygon/multi_polygon features each one assembles from a .osm.flat archive
and its .geojson sibling:
./build/compare_plugins ./build/plugins /opt/homebrew/lib/mapnik/input \
test/fixtures/baarle-hertog.osm.flat test/fixtures/baarle-hertog.geojson \
4.75 51.38 5.02 51.49It matches features by name+admin_level and compares, per matched pair:
polygon count, ring count per polygon, and per-ring shoelace area (within a
tolerance, default 1%, overridable as a trailing arg). It deliberately does
not diff rendered pixels or raw point counts — see the "byte-for-byte"
discussion in the project memory for why those are too brittle (draw-order
and ring-closing-stitch differences between two independent assemblers
change pixels/vertices without changing the shape). On baarle-hertog all
four relations match with the two osmflat/osmflat-ext sidecar's stitched
rings landing within 0.23% of osmium's independently-assembled area.
Two gotchas this harness needed to work around, in case you extend it:
- osmflat's attributes are query-driven (see mapnik-gotchas memory): a
bare
mapnik::query(bbox)won't populatename/admin_levelon osmflat features — callquery::add_property_name()for every tag you read.geojson.inputalways exposes every property regardless, so this is a no-op on that side. - The GeoJSON side returns every node/way/relation as Point/LineString/
(Multi)Polygon features in one file; only the last are comparable to
osmflat's
osm_type=relationquery, so the harness filters by geometry type (empty polygon list = skip) rather than by tag presence.
member_of has no equivalent in geojson.input (or any other stock mapnik
plugin) to diff against, so check_member_of checks the osmflat plugin's own
contract directly against a hardcoded ground-truth member list instead of
comparing two implementations:
./build/check_member_of ./build/plugins test/fixtures/bicycle-route.osm.flat \
4.64 51.35 4.74 51.43Against bicycle-route, it checks: an unfiltered osm_type=way query returns
all 274 ways in the archive; member_of=route=bicycle,ref=09-33 returns
exactly the 11 known member way ids (from the Overpass fetch, not anything
the plugin computed) each carrying rel_ref=09-33 via the forward join; and
a member_of filter matching no relation (ref=99-99) returns zero ways
rather than silently passing everything through. That last case is the
actual bug this filter could plausibly have, and only has teeth because the
fixture's ~260 non-member highway ways give it something to wrongly include.
bicycle-route.osm.flat is built with osmflatc --ids (unlike
baarle-hertog) so features carry their original OSM way id for the
ground-truth comparison.
test/fixtures/fetch-<name>.sh scripts document how each fixture was pulled
from Overpass and regenerate all four forms if OSM data changes (verified
byte-identical on rerun). They osmium sort then osmium extract --set-bounds before handing off to osmflatc — without --set-bounds the
PBF header carries no bounding box, and since osmflat_archive_envelope()
reads the bbox straight from the header (never computed from node
coordinates), the plugin's envelope collapses to (0,0,0,0) and every render
comes back silently blank, regardless of query bbox or style.
Without help, the datasource returns all primitives in the bbox and mapnik
applies <Filter>/<MaxScaleDenominator> at render time, so a state view walks
every way/node even though most are gated out.
The ext + tags params fix this: point ext at the archive's Ext sidecar
(osmflat-ext, built with osmflat-extc --taginfo) and set tags to a prefilter
that is a superset of the layer's rule filters (e.g. highway=* for a roads
layer). The plugin then intersects the sidecar's inverted-index postings with the
bbox spatial ranges (osmflat_ext::query::intersect_bbox, O(R·log k)), running
the bbox scan once and materializing only matching entities. On a ~5° state view
this cut render time from ≈32 s → ≈7.5 s with byte-identical output.
tags must stay a superset of what the rules match, or you'll drop features; it's
a performance hint, not a substitute for <Filter>. See test/style-full.xml,
whose layers set ext/tags per layer.
Route relations (type=route) carry the interesting tags (ref=Borealis,
network=Amtrak) but no drawable geometry of their own — the rails are member
ways. member_of styles those members:
<Parameter name="osm_type">way</Parameter>
<Parameter name="tags">railway=rail</Parameter>
<Parameter name="member_of">route=train,ref=Borealis</Parameter>emits only the ways that are members of a relation matching all the
member_of terms (AND — unlike tags, whose terms union), as ordinary line
geometry. The matched parent relation's tags are exposed to the style as
rel_-prefixed attributes ([rel_ref], [rel_name], …), while unprefixed
keys still read the member's own tags. One feature is emitted per
(member × matched relation) pair, so a way on two matching routes appears once
per route with that route's rel_* values. See test/style-borealis.xml.
Notes:
- Works for node members too (
osm_type=node→ stop positions). member_ofcomposes withtagsand the bbox (all must pass). It is enforced even without theextsidecar via a full relation scan (slower — the sidecar's inverted index makes the relation match cheap);tagsremains sidecar-only either way.osm_type=relationemission is unaffected: membership does not recurse, so relation-as-member (e.g. aroute_master's routes) is skipped, and area relations still render through the multipolygon path.- Keep
member_ofselective (a specific route, network, or operator): all members of every matching relation are expanded before the bbox clips them. - While
member_ofis active, a genuine OSM tag literally namedrel_*on a member is shadowed by the parent-relation redirect.
The simplify param (default 0.5 px, on) generalizes geometry to sub-pixel using
the query resolution — scale-aware, so it self-adjusts at every zoom. It's
visually lossless and reduces the vertex count mapnik rasterizes, but note it runs
after materialization, so it doesn't cut the wide-zoom bottleneck (materializing
the pushed-down features) — tightening tags is the lever for that. way_area is
computed from full-resolution geometry, before simplification.
Mapnik's SVG output has no per-feature identity — it's flattened to bare
<path>/<text> elements, so a downstream tool can't tell which path is
"Hennepin Ave" versus an anonymous fragment. dump=<path> has the datasource
append one NDJSON line per emitted feature to that path, each line a GeoJSON
Feature carrying exactly what was handed to mapnik before projection,
clipping, or symbolizing touched it:
{"type":"Feature","properties":{"osm_type":"way","osm_id":493358990,"z_order":70,"way_area":0,"is_closed":false,"highway":"primary","name":"Carretera México - Cuernavaca (Libre)","ref":"MEX 95","oneway":"no"},"geometry":{"type":"LineString","coordinates":[[-99.1649325,19.1995016],[-99.1615194,19.2015559]]}}Geometry is raw EPSG:4326 (lon/lat), matching the datasource's declared SRS —
an offline post-processor reprojects and affine-transforms it into the same
space as the rendered SVG to match dumped features back to output paths
(bbox-clipping can still split one feature into several path fragments; this
dump is what lets those be reassembled by identity instead of guessed at
geometrically).
Properties are wider than the style's own attributes: alongside whatever tags
the active style references, every feature also carries a fixed
naming/classifying allowlist (name, ref, highway, waterway, railway,
admin_level, building, …) regardless of whether any rule filters on them —
otherwise a style that only ever writes [highway] = 'primary' would never
pull name, and the dump would have nothing to call the road. This widened
fetch only feeds the dump; it does not change the mapnik attribute schema the
style sees.
The file is opened in append mode, so multiple <Layer>s (or multiple queries
against one layer) can all point dump at the same path without truncating
each other — clear the file yourself before a fresh render run. Point queries
(features_at_point, interactive lookups) never write to the dump; it's meant
to correlate one bulk render, not ad hoc queries.
The plugin logs one line per query through mapnik's own MAPNIK_LOG_DEBUG
machinery — off by default (compiled to a genuine no-op unless MAPNIK_LOG is
defined, which this plugin's CMakeLists.txt does) and gated at runtime by
the usual mapnik::logger severity, same as every other mapnik plugin's debug
output. Two lines per query, from osmflat_datasource::features() /
features_at_point() and from osmflat_featureset's destructor:
osmflat: query bbox=[-73.9,40.7575,-73.84,40.8025] osm_type=way tags=natural=bay member_of=- order=none simplify_px=0.5 style_keys=1
osmflat: featureset closed, emitted=3 features
The first line is everything the query asked for (bbox, osm_type, tags,
member_of, order, simplify, how many style keys were requested); the
second is how many features the returned cursor actually yielded, logged when
mapnik is done with it — whether it drained the cursor to exhaustion or
stopped early. Useful for confirming a tags/member_of filter is actually
selecting what you think it is, or for noticing that a layer you expected to
be empty (or non-empty) isn't, without adding throwaway [osm_id] text rules
to a style just to see what came back.
To turn it on, the caller still has to raise mapnik's log severity — this
plugin only emits at debug level, it doesn't change the global severity
itself. test/render.cpp (the smoke-test renderer, and what scripts/render.sh
in the styles repo shells out to) does this when OSMFLAT_LOG_DEBUG is set:
OSMFLAT_LOG_DEBUG=1 render.sh style.xml out.png <bbox...>
Other hosts (e.g. mapnik-config-based mod_tile setups, or your own harness)
can do the same with mapnik::logger::set_severity(mapnik::logger::debug)
before rendering.
Separately, osmflat-capi's relation assembly (assemble_multipolygon) prints
a raw eprintln! — not routed through MAPNIK_LOG at all — when a relation's
outer ways don't form a closed ring and it has to drop the relation entirely:
osmflat-mapnik-plugin: dropping relation id=Some(15624542) name="": outer ways don't form a closed ring
This is real signal (a relation that should have area got silently dropped)
but it's easy to miss since it's unconditional Rust-side stderr output, not
gated by severity like the C++ side's logging. Worth promoting to a proper
MAPNIK_LOG_WARN through the C API at some point rather than a bare
eprintln!.
Assembling a type=multipolygon/boundary relation's outer/inner ways into
closed rings is real stitching work — matching endpoints, picking the nearest
candidate when several are in range, deciding when a ring is actually
closed — and doing it live, on every single query that touches an area
relation, is more fragile than it looks: this exact algorithm had a real
premature-ring-closure bug that only showed up on one specific real,
jagged coastline (Elliott Bay, Seattle) after passing every synthetic test
thrown at it. The wider OSM rendering ecosystem doesn't do this assembly live
either — osmcoastline assembles coastline rings once, offline, and renderers
just read the already-valid result.
When the archive's ext sidecar was built with osmflat-extc --multipolygons,
this plugin does the same thing: materialize_relation reads the precomputed
rings directly (no re-stitching) instead of calling
osmflat_ext::multipolygon::assemble_multipolygon live. Confirmed
bit-for-bit identical rendering against the live path on real data (NYC
boroughs, Seattle water bodies including a polygon-with-a-hole case, and the
Elliott Bay edge case) before and after this was wired in.
One deliberate behavior difference: the precomputed sidecar doesn't carry a
relation's leftover open chains (the rare case where some, but not all, of
a relation's outer ways stitch into a closed ring) — only closed polygons.
Live assembly emits those leftovers as an extra unfilled LineString feature
(closed=false, way_area=0) so a diagnostic style can still see them; with
the precomputed sidecar active, a relation with no closed polygon simply
yields no features at all, and its "dropping relation" stderr line above
fires even for relations that live assembly wouldn't have dropped outright.
This never affects a style that filters by tag (nothing about those leftovers
was going to render anyway), but a diagnostic style that renders every
relation regardless of tags — like the water-body diagnostics used to find
the Elliott Bay and Bowery Bay issues in the first place — will see less
under --multipolygons than under live assembly. Build without
--multipolygons (or query assemble_multipolygon directly) if you need
those leftovers.
See osmflat-ext's README for the build side (osmflat-extc --multipolygons)
and osmflat_ext::multipolygon's module docs for the algorithm and its test
coverage (including a regression test for the premature-closure bug).
natural=coastline ways mark a boundary, not an area — there's no
natural=water-style polygon a renderer can just fill for "everything on the
sea side," so a style that only understands ordinary tagged areas renders open
ocean, bays, and the water side of any coastline as blank background. This
plugin fills that gap with a synthetic land layer: any <Layer> whose
Datasource tags includes the magic pair _osmflat_land=yes (real OSM data
can never carry a leading-underscore key, so this can't collide) gets back a
LineString feature per land ring, closed, with way_area set — pair it with
order=way_area and a background-color map so a plain painter's algorithm
handles nesting (islands in bays, lakes on islands) correctly with no explicit
hole/exterior pairing.
Two sidecar sources can back this, checked in order:
ext's--land-polygons(preferred when present): rings imported at build time from an external, already-closed coastline dataset — e.g. osmdata.openstreetmap.de'sland-polygonsproduct, the same oneosm2pgsql/openstreetmap-cartoproduction stacks use — reprojected from Web Mercator to WGS84. Unlike coastline ring assembly below, this dataset is already closed for mainland coastlines too (a real country's coast is an open chain thousands of kilometers long across anatural=coastlinescan of any bounded extract; there's no tile/bbox frame to close it against that isn't arbitrary), so this is what actually shows mainland USA as land rather than leaving it as open water. Traded off against--coastline: coarser geometry (the "simplified" variant), since it isn't derived from the archive's own full-resolutionnatural=coastlineways — in practice this hasn't cost visible precision even at NYC's Harlem River / the Narrows or Seattle's Puget Sound shoreline (validated against both).ext's--coastline(fallback when--land-polygonswasn't built): rings assembled from the parent archive's ownnatural=coastlineways, classified land/water by winding and sorted by area descending. Only ever produces closed rings — islands, lakes fully enclosed by coastline — because the ring assembler has nothing to close an open mainland chain against; a sidecar built with--coastlinealone will correctly show NYC's islands and inter-borough rivers as land/water but can never show a mainland coastline as land at all.
See osmflat-ext's README for both build sides and osmflat_ext::coastline /
osmflat_ext::land_polygons's module docs for the respective algorithms.
MIT (LICENSE-MIT or http://opensource.org/licenses/MIT).
MIT rather than the usual Rust-ecosystem MIT/Apache-2.0 dual license because
the static render links LGPL-2.1 mapnik, and Apache-2.0 is considered
incompatible with LGPL-2.1 for a combined work.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be licensed as above, without any additional terms or conditions.
The statically linked render archives are a separate case -- see
"Static, self-contained render" above.