geopbf
Repo layout note — development happens in the ortho-earth monorepo (
packages/geopbf); this standalone repo is a read-only mirror synced on each release. Issues are welcome here; patches land in the monorepo. / 開発はモノレポ側で行い、ここはリリースごとに同期される公開ミラーです(Issue歓迎・変更はモノレポへ)。
Carry geometry as geometry. GeoPBF is a compact binary container for geospatial features that keeps shapes as shapes — instead of flattening them into draw-only tiles — plus Gint, a derived topology layer, readable by a GPU vertex shader, that makes the data answerable: point-in-polygon identify, shared-edge topology, choropleth painting by feature id.
Everything runs in the browser: format conversion in workers, topology baking in WASM, no servers, no API keys. Zero runtime dependencies. This is the data layer under ortho-earth, where 1,900+ Japanese municipal polygons identify in 0.5–4 ms on an ordinary laptop.
Contents — 1. Data model · 2. Quick start · 3. File size · 4. Command line · 5. Vector tiles, GeoParquet, GeoPackage · 6. COG · 7. Editing · 8. Map library integrations · 9. Storage injection · 10. Runtime requirements
1. Data model
1.1 What is in the file
A GeoPBF file is a protobuf message with a header (name, key dictionary, precision, provenance fields) and a flat
array of features. Coordinates are quantized to an integer grid of 10^-precision degrees and stored
delta-encoded as packed signed varints; property keys live in one global dictionary and are referenced by index.
Precision is a per-file parameter (1–9, default 6 ≈ 0.11 m).
| Header field | Meaning |
|---|---|
NAME |
dataset name |
KEYS |
global dictionary of property names |
PRECISION |
coordinate grid, 10^-n degrees (default 6) |
DESCRIPTION / LICENSE / ATTRIBUTION |
provenance, carried inside the file |
MIN_ZOOM / MAX_ZOOM |
intended draw range (optional) |
BUFS |
pool for binary values (Blob / ImageData) |
Property values are typed: null, bool, integer, double, string, date, color, JSON, bbox, blob, image. A whole file may
additionally be gzipped; readers detect the 1f 8b signature rather than trusting the extension. Full wire format:
pbf spec.md.
1.2 What is derived
Two structures the library is known for are not in the wire format — they are built at read time, so a minimal reader (the GDAL/OGR driver, for instance) implements neither:
| Built by | Lives in | |
|---|---|---|
| Arcs — shared-boundary topology | analyzeTopology() |
memory |
| Gint — 64-bit Morton coordinates carrying LOD ranks | topology() → GintBUF (WASM) |
memory / GPU buffer |
Gint packs each vertex into 8 bytes: a Morton-interleaved position plus, in the low 6 bits, a Visvalingam-Whyatt
rank. Level of detail is then a comparison, not a re-simplification — a vertex is drawn at zoom z when
rank >= 3·(21 − z) — and because neighbouring polygons share one arc, both sides of a border always keep the
identical vertex list. No slivers, no gaps, at any zoom, with no per-zoom geometry baked anywhere.
z threshold vertices kept (npx geopbf lod ne_10m_admin_0_countries.geopbf)
0 63 9,742 1.8% #
4 51 38,330 7.0% ###
8 39 395,448 72.1% #############################
21 0 548,469 100.0% ########################################
2. Quick start
npm i geopbf
import { createGeopbf } from "geopbf";
const geopbf = createGeopbf();
// From anything: GeoJSON object, File (drag & drop), or URL
const pbf = await geopbf(featureCollection, { gint: true });
const pbf2 = await geopbf(file); // .geojson .ndjson/.geojsonl .zip(shp) .kml .kmz .gpx .gml .fgb .topojson (.gz OK)
const pbf3 = await geopbf("https://example.com/data.zip#layer.shp");
pbf.geojson // → FeatureCollection (round-trip)
pbf.features // → features array
pbf.arrayBuffer // → the GeoPBF binary (store it, ship it, re-load it)
pbf.contain(lng, lat) // → which feature contains this point (smallest-wins)
| Converters (worker per format, lazily loaded) | GeoJSON · NDJSON / GeoJSON Text Sequence · Shapefile (zip) · SpatiaLite · DXF · KML/KMZ · GPX · CZML (Cesium) · GML · FlatGeobuf · TopoJSON · MOJ 登記所備付地図 — in, and back out (geojsonFile, kmzFile, gpxFile, czmlFile, gmlFile, fgbFile, shapeFile, topojsonFile). GPX keeps per-point <ele>/<time> of tracks and routes as same-length array properties (ele, time; nested per <trkseg> for MultiLineString) and marks <rte> with route: true, so a track round-trips with its timestamps. CZML uses the same convention: static position → Point (ele = height), a time-sampled position → LineString + time array, polyline / polygon (+ holes) / rectangle → line / polygon, ECEF cartesian → WGS84 lon/lat; everything else in the packet (billboard, label, model, path, styles, time-varying properties) is kept verbatim in a czml property and unfolded on export, so a static CZML round-trips and a GPX track exports as a sampled position. The document clock is not kept (Cesium derives one from availability) |
Topology ({ gint: true }) |
typed-array buffer of arcs, features and neighbour topology, baked in WASM; usable from a GPU vertex shader or from plain JS |
| Feature ops | centroid, area, lineLength, getBbox, classify, map/filter clones, CSV/property tables |
| Export | PMTiles (MVT), GeoParquet, GeoJSON, TopoJSON, FlatGeobuf, KMZ, GPX, CZML, GML, Shapefile |
3. File size
Three public datasets of different shape and scale, each measured end to end on one machine. Every row holds the same features; sizes are decimal MB, and the two caveats on coordinate precision are spelled out under the table.
| NE 10m admin_0 countries, v5.1.1 |
KSJ N03 (2026) Japanese municipalities |
TIGER 2024 ZCTA5 US ZIP code areas |
|
|---|---|---|---|
| features | 258 | 125,130 | 33,791 |
| vertices | 548,469 | 15,669,416 | 51,258,797 |
| coordinate grid | 10⁻⁶° (0.11 m) | 10⁻⁶° (0.11 m) | 10⁻⁵° (1.1 m) |
Shapefile (.shp+.dbf+.shx) |
9.7 | 282.8 | 825.3 |
Shapefile + .prj, zipped −9 |
4.9 | 174.8 | 530.5 |
GML (.xml) |
— | 580.7 | — |
| GML + gzip −9 | — | 123.2 | — |
| GeoJSON | 13.3 | 579.6 | 1,089.6 |
| GeoJSON + gzip −9 | 4.7 | 138.6 | 265.8 |
| FlatGeobuf | 9.3 | 266.9 | 826.8 |
| FlatGeobuf + gzip −9 | 5.4 | 156.3 | 421.9 |
| GeoParquet (WKB, zstd, STR order, bbox column) | 4.5 | 124.5 | 333.7 |
| GeoPBF | 3.3 | 48.6 | 117.8 |
| GeoPBF + gzip −9 (the distribution form) | 2.7 | 34.4 | 95.2 |
| bytes per vertex, Shapefile | 17.7 | 18.1 | 16.1 |
| bytes per vertex, Shapefile + zip | 9.0 | 11.2 | 10.3 |
| bytes per vertex, GeoJSON | 24.2 | 37.0 | 21.3 |
| bytes per vertex, GeoJSON + gzip | 8.6 | 8.8 | 5.2 |
| bytes per vertex, FlatGeobuf | 17.0 | 17.0 | 16.1 |
| bytes per vertex, FlatGeobuf + gzip | 9.8 | 10.0 | 8.2 |
| bytes per vertex, GeoPBF | 6.0 | 3.1 | 2.3 |
| bytes per vertex, GeoPBF + gzip | 4.9 | 2.2 | 1.9 |
| GeoJSON ÷ GeoPBF, both raw | 4.1× | 11.9× | 9.2× |
| GeoJSON ÷ GeoPBF, both gzipped | 1.8× | 4.0× | 2.8× |
Reproduce with npx geopbf enc <in.geojson> <out.geopbf> and npx geopbf parquet; the FlatGeobuf rows use the
official flatgeobuf npm writer on the same features, and the zipped-shapefile row is .shp+.dbf+.shx+.prj
packed here at deflate −9 so that all three datasets are measured the same way. The shipped archives differ in what
else they contain and are not directly comparable: Natural Earth 4.9 MB (adds .cpg and an HTML README), TIGER
528.8 MB (adds .cpg and two ISO metadata XML), KSJ N03 803.2 MB (carries GML and shapefile and GeoJSON in one
archive).
Two caveats on precision. The shapefiles are the original distributions and hold full double coordinates, while the GeoJSON/GeoPBF/FlatGeobuf rows sit on the file's stated grid — but a shapefile spends a fixed 16 bytes per point whatever the precision, so its size rows are unaffected. The ZCTA GeoJSON was written on the same 10⁻⁵° grid as the GeoPBF, so within that column no text-or-binary row has a precision advantage over another.
Compare like with like. Gzipped against gzipped, GeoPBF is 1.8–4.0× smaller than GeoJSON — not the 5–17× the raw rows suggest, and every format here is normally shipped compressed. Compression does most of its work on text (GeoJSON keeps 24–36 % of its bytes, GML 21 %), a fair amount on the double-based binaries (shapefile 51–64 %, FlatGeobuf 51–59 %: consecutive coordinates share the high-order bytes of their doubles, even though the low mantissa bits are incompressible noise), and least on GeoPBF (71–82 %), which has already removed by construction the redundancy the compressor goes looking for. The uncompressed rows are what a decoder walks through in memory; the compressed rows are what crosses the network.
Why it lands where it does. Shapefile and FlatGeobuf land in the same place — 16–18 bytes per vertex — for the same reason: both store two IEEE-754 doubles per point regardless of what the geometry looks like, and the spread between them is record headers and attribute encoding, not coordinates. Two decades of format design separate the two, and the geometry section did not move. GeoPBF stores a delta on an integer grid, so its byte cost falls with vertex density: a boundary sampled every few metres spends 2–3 bytes per vertex uncompressed, while sparse geometry (the Natural Earth countries, whose 258 features also carry 168 attribute columns) spends more. The header dictionary means the attribute cost is paid once per key rather than once per feature.
GeoPBF is a container, not an index: unlike FlatGeobuf it has no packed Hilbert R-tree and no support for partial reads — spatial queries come from Gint after a whole-file load, which is the trade this format makes deliberately.
And the derived layer. Gint costs 8 bytes per arc vertex plus the feature, polygon and neighbour index streams — about 8.2–8.5 bytes per arc vertex in total. It is held in memory or uploaded to the GPU, and never written to the file:
| NE 10m admin_0 | N03 2026 | ZCTA5 2024 | |
|---|---|---|---|
| arcs | 4,658 | 129,034 | 102,782 |
| arc vertices (after shared-border folding) | 480,217 (88 %) | 13,028,137 (83 %) | 28,312,663 (55 %) |
| GintBUF | 4.1 MB | 110.4 MB | 232.0 MB |
| bake time (WASM, 1 core) | 0.14 s | 3.0 s | 6.9 s |
The folding ratio is a property of the data: ZIP code areas tile the country, so more than half of all vertices are on a border two features share and are stored once.
4. Command line
The library is browser-first, but the core encoder/decoder runs on plain Node — so the package also ships a small CLI. No build step, no GDAL, no extra dependencies.
npx geopbf enc ne_10m_admin_0_countries.geojson countries.geopbf # 13.3 MB -> 2.7 MB, gzipped by default
npx geopbf enc in.geojson out.geopbf --precision 7 --no-gzip # 1 cm grid, raw (un-gzipped) GeoPBF
npx geopbf info countries.geopbf # features, vertices, precision, header fields
npx geopbf dec countries.geopbf back.geojson # round trip
npx geopbf lod countries.geopbf # what Gint would actually draw, per zoom
npx geopbf pmtiles countries.geopbf countries.pmtiles --maxzoom 10 # → PMTiles (MVT), simplified per zoom from Gint
npx geopbf parquet countries.geopbf countries.parquet # → GeoParquet (WKB + bbox)
npx geopbf parquet2pbf in.parquet out.geopbf # ← GeoParquet, from anyone's writer
npx geopbf gpkg2pbf roads.gpkg # list the layers of a GeoPackage
npx geopbf gpkg2pbf roads.gpkg roads.geopbf --layer roads # ← GeoPackage, one layer (own SQLite reader, no GDAL)
npx geopbf spatialite2pbf city.sqlite parcels.geopbf --layer parcels # ← SpatiaLite, one layer (compressed / TinyPoint geometries too)
npx geopbf dxf2pbf plan.dxf plan.geopbf --crs 6677 # ← DXF (no CRS inside) with EPSG code: JPR IX, UTM, lon/lat, WKT
npx geopbf gdb2pbf city.gdb # list the feature classes of a File Geodatabase
npx geopbf gdb2pbf city.gdb.zip parcels.geopbf --layer 筆界 # ← FileGDB (directory or zip), 平面直角座標系/UTM → lon/lat
npx geopbf gdb2pbf old.gdb out.geopbf --tky2jgd tky2jgd.bin.gz --patchjgd patchjgd.bin.gz # 日本測地系 → JGD2011
npx geopbf csv2pbf stations.csv stations.geopbf # ← CSV/TSV/XLSX with lon/lat or WKT columns (non-UTF-8 → Shift_JIS)
npx geopbf csv2pbf legacy.csv out.geopbf --fallback-encoding windows-1252 # other legacy encodings; --encoding forces one
npx geopbf csv2pbf book.xlsx parcels.geopbf --sheet 筆 --wkt geometry
npx geopbf cog info https://…/TCI.tif # remote COG structure over HTTP Range
Output is gzipped by default, matching the GDAL driver's COMPRESS=GZIP and the usual distribution form; pass
--no-gzip for a raw file. Gzip input is detected by signature, not by extension, for every command including
enc. For inputs other than GeoJSON, GeoParquet, GeoPackage, File Geodatabase and tables — PostGIS and everything
else GDAL reads — use the GDAL/OGR driver (ogr2ogr -f GeoPBF,
needs GDAL ≥ 3.12), or the browser workers in src/index.js.
CLI messages and --help are in English. Set GEOPBF_LANG=ja (or a Japanese LANG / LC_ALL) for Japanese; the
library itself (error messages, console warnings) is English only.
5. Vector tiles and GeoParquet
geopbf/pmtiles and geopbf/geoparquet take a GeoPBF (plus its Gint) out the other side: a PMTiles archive of
Mapbox Vector Tiles, or a GeoParquet file. The embarrassingly parallel stages — projection, per-zoom rank
filtering, integer→double conversion, bbox reduction — run on the GPU when WebGPU is present, and on a CPU path with
the same integer arithmetic when it is not. Zero new dependencies; the CLI runs on plain Node. Read
§5.5 before expecting the GPU to change the wall-clock.
npx geopbf pmtiles countries.geopbf countries.pmtiles --maxzoom 10 # bakes Gint with WASM, then tiles from it
npx geopbf pmtiles countries.geopbf countries.pmtiles --gint countries.gint --gpu # reuse a baked Gint
npx geopbf parquet countries.geopbf countries.parquet # WKB + bbox covering column
import { toPMTiles } from "geopbf/pmtiles";
import { toGeoParquet } from "geopbf/geoparquet";
const pbf = await geopbf(file, { gint: true }); // browser: Gint is baked by the worker as usual
const { buffer, stats } = await toPMTiles(pbf, { maxZoom: 12 }); // stats.engine → "gpu" | "cpu"
const pq = await toGeoParquet(pbf); // pq.buffer → .parquet bytes, pq.geo → the "geo" metadata
5.1 Tiles come from Gint, not from the raw features
This is the same derived buffer ortho-earth draws on the GPU. Shared borders are single arcs and every vertex carries
its VW rank, so per-zoom simplification is a rank >= threshold filter — the 63 − 3·(z + log2(extent/256)) rule
ortho-core uses at draw time — and two neighbouring polygons are simplified to the identical vertex list.
project— Morton decode → fixed-point Web MercatorX32/Y32(32-bit, whole world). Once per vertex, not once per zoom.lod— one dispatch over(arc × zoom): keeprank >= threshold, shift to tile space, drop consecutive duplicates, write compacted coordinates (count pass + write pass). All zooms in a single round trip.- Worker pool (browser
Worker/ Nodeworker_threads, one code path) — each(zoom × tile-column range)job stitches rings and lines from the arcs (polyStream/lineStream), bisects them into tiles with buffer, encodes MVT, gzips and content-hashes its tiles. The main thread merges by content key and packs PMTiles v3: Hilbert tile ids, run-length plus content de-duplication for interior tiles, leaf directories once the root exceeds 16 KB. Sharding only prunes the bisection tree, so the output is byte-identical at any worker count (workers: 0runs inline).
The assembly stage is typed-array based, stops bisecting as soon as a sub-range of tiles is provably interior to a
polygon (one range event instead of one clip per tile), encodes the attribute section once per feature, and hashes
tile content before copying it. Attributes reach the workers as one typed-array table (key dictionary, UTF-8 string
dictionary, per-feature entry list) rather than a million small arrays: worker start-up for 1,000,000 features
dropped from 16 s to 1.6 s, and string bytes are written into the tiles as they are, without a second UTF-8 encoding.
Compression is one code path with no pako — node:zlib in Node, CompressionStream driven through its
writer/reader directly in browsers (≈4× cheaper per tile than the Blob→Response idiom).
Points skip the clipping tree entirely (tile index by shift, buffer copies to neighbours) and are thinned at lower
zooms like tippecanoe's -r: dropRate 2.5 keeps 1/2.5 of the points per zoom step below maxZoom, chosen by a
hash of the feature id so the kept sets nest. dropRate: 1 keeps every point in every tile.
5.2 Simplification is calibrated to tippecanoe's, per arc
The Gint rank is a Visvalingam area; tippecanoe drops vertices by Douglas-Peucker distance (1 tile unit, -S 1).
The two do not map to each other by a constant — a VW area threshold keeps far more vertices on long, gently curved
segments than DP does, and none on tiny islands where DP keeps 3 — so a fixed rank rule came out 3 % (countries) to
31 % (ZIP areas) larger than tippecanoe.
simplification (default 1, in tile units) fixes this the honest way: one Douglas-Peucker decomposition per arc
yields, for every vertex, the tolerance at which it would survive; for each arc and zoom the rank threshold is then
chosen so that VW keeps as many vertices as DP would.
| DP (tippecanoe) | VW (calibrated) | |
|---|---|---|
| NE 10m countries, z0 | 46,442 | 47,985 |
| NE 10m countries, z10 | 471,774 | 471,927 |
| Canada, z0 | 7,234 | 7,649 |
| USA, z0 | 2,466 | 2,725 |
Which vertices survive is still decided by rank, so shared borders stay identical on both sides. The calibration runs
on the CPU and costs 0.1 s on 480k vertices, 4.2 s on 29M; the per-(arc, zoom) thresholds then feed the same
GPU/CPU kernel, byte-identical either way. simplification: false restores the fixed rule; lodBias shifts the
calibrated thresholds (+3 ≈ one rank step ≈ 1.26× coarser linearly).
Tile quality follows tippecanoe's defaults where they matter. Polygon components smaller than tinyPolygon tile
units² at a zoom — measured on the full-resolution geometry, so components the simplification collapsed count too —
are replaced by one placeholder square per accumulated threshold of area, which keeps atolls and archipelagos visible
as dots at z2 instead of disappearing; sub-threshold clip fragments and holes are dropped; the clipper removes the
zero-width spurs Sutherland-Hodgman leaves along tile edges. tinyPolygon: 0 turns the reduction off.
5.3 Benchmarks
Same 4-core box, same inputs, tippecanoe v2.82, PMTiles out.
| Input | Zooms | geopbf | tippecanoe | |
|---|---|---|---|---|
| NE 10m countries 258 polygons / 480k arc vertices |
z0–10 | time size tiles |
≈6.5 s (enc 0.4 + Gint 0.5 + tiles 5.3–5.9) 151.0 MB 573,896 |
58 s 147 MB 573,885 |
| NE 10m roads 56,600 lines / 709k vertices |
z0–10 | time size tiles |
10.0 s (+2.2 s enc + Gint) 72.8 MB 108,688 |
16.2 s 69.7 MB 108,682 |
| TIGER ZCTA5 (2010 vintage — a different file from §3) 33,092 polygons / 52M vertices (28.9M after folding) |
z0–12 | time size tiles |
47 s 251 MB 238,322 |
251 s 246 MB 238,319 |
| 1,000,000 synthetic points, 4 attributes | z0–10 | time size |
14.6 s 51 MB (default dropRate) |
31 s 42 MB |
| same, every point kept | z0–10 | time size |
42 s 270 MB |
65 s / 221 MB (-r1) |
GDAL's PMTiles driver (3.12) took 780 s for the countries job.
The tile sets agree to within a handful of tiles: countries, 13 tiles only in geopbf and 2 only in tippecanoe, all
at z6–10, and interior tiles are byte-for-byte the same size apart from the layer name and the feature id geopbf
writes — which is the whole of the remaining 2.7 % size difference. Roads: every line reaching a tile edge continues
in the neighbouring tile (3,001 of 3,001 checked at z8), and the only features tippecanoe keeps that geopbf does not
are 15 small closed loops the rank filter collapses at z0 (extent ≤ 3 tile units). ZCTA5 2010: the same tile set within 7
tiles, per-zoom sizes within 1–2 % from z7 up (z12: 102.0 vs 100.6 MB), indistinguishable in MapLibre at z3/z7/z11,
and byte-identical on re-run (322 MB with simplification: false).
Countries z0–10 in more detail on 4 cores: 5.3–5.9 s with 4 workers (the default is one per core; 3 → 5.8 s, 5–7 → 5.5–6.8 s), 16–18 s single-threaded, of which zlib is ≈8 s (85k unique tiles, ~90 µs each regardless of level). Chromium z0–8 takes ≈5.7 s with 3 workers.
GeoParquet on the same data:
| geopbf | geopandas | |
|---|---|---|
| ZCTA5 2010 (52M vertices), zstd | 28.5 s / 437 MB | 54 s / 542 MB (gzip) |
| 1,000,000 points, STR order + bbox column | ≈12 s / 45.6 MB | — |
| 1,000,000 points, no bbox column | 21.4 MB | 4.9 s / 20.2 MB (unsorted, no bbox) |
geopbf writes 608 MB for that ZCTA5 with gzip rather than zstd: Node's zlib is the Chromium fork whose 4-byte hash misses the short matches a stream of WKB doubles is full of (0.73 vs 0.64 ratio against stock zlib on the same bytes), which is why zstd is the default codec in Node. The Parquet stage for 1M points is 7 s of the 12 s, down from 11 s before columns were transposed once into contiguous arrays and dictionary-encoded.
5.4 Exactness is the design rule
Every kernel is integer-only. The Mercator latitude uses a 2¹³·10⁻⁷° table with an exact slope column (error ≤ 3
units of 2⁻³²); longitude uses exact 64-bit division emulated in 32-bit. So the GPU output is not "close to" the CPU
output, it is byte-identical, and scripts/verify-convert-gpu.mjs proves it on every kernel through headless
Chromium — on SwiftShader, so it runs in CI without a GPU.
GeoParquet takes the GeoPBF integers directly (no Gint round-trip, so coordinates are exactly the file's values): the
GPU converts i / 10^precision to IEEE-754 doubles by long division with round-to-nearest-even, bit-identical to
JavaScript's own division, and reduces per-feature bboxes; the CPU assembles WKB and writes Parquet (Thrift compact
footer, PLAIN or dictionary pages, RLE definition levels, GZIP or zstd, geo metadata 1.1 with a bbox covering
column and min/max/null-count statistics on every column). Low-cardinality columns are dictionary-encoded per row
group (RLE_DICTIONARY, chosen when distinct values are at most half the rows). Readable by pyarrow, DuckDB, GDAL
and GeoPandas.
Rows are spatially ordered (order, default "str"): feature bbox centres are packed Sort-Tile-Recursive style
— sorted by x, cut into √P slices of rowGroupSize multiples, each slice sorted by y — so every row group's bbox
statistics cover a disjoint patch and a reader that pushes an area filter down (DuckDB, pyarrow datasets, GeoPandas)
fetches only the row groups that intersect it. This follows Kanahiro Iguchi's Spatial sort for well-packed
GeoParquet (CNG Japan 2026); replicated here on 1,000,000 points in 50 row groups:
order |
row-group bbox overlap ratio | candidate row groups, city-sized query |
|---|---|---|
none (input order; row index = feature id) |
24.5 | 50 |
morton (the Gint-native key, cheapest) |
0.87 | 3–4 |
hilbert |
0.28 | 2 |
str (default) |
0.00 | 1–2 |
The bbox column is written by default because it is what the pruning keys on. For point-only data it merely
repeats the coordinates, so bboxColumn: "auto" drops it there (half the size, no pruning); false drops it always.
And back again. fromGeoParquet(u8) / geopbf parquet2pbf turns a GeoParquet file into a GeoPBF: a
dependency-free Parquet reader (Thrift compact footer, DataPage v1/v2, PLAIN and dictionary encodings, none/gzip/zstd
and a built-in snappy decoder for pyarrow's default) plus a WKB parser (all seven types, EWKB flags, Z/M dropped). A
file geopbf wrote comes back bit-identical — coordinates are round(x·10^precision) with the precision recorded in
the file — and files written by geopandas, pyarrow (v2 pages, zstd) and DuckDB read back to the same features
(Natural Earth: all 258 identical across the three writers). ZCTA5 2010 (33,092 features, 52M vertices, zstd) comes back
in 72 s — 12 s to read, 60 s to encode — with every feature identical to the original. The CRS must be lon/lat
(CRS84 / EPSG:4326); anything else is refused unless ignoreCrs. Rows without a geometry are dropped and counted
(stats.droppedGeometries) — a GeoPBF feature always carries a shape, as it does on the GeoPackage, FlatGeobuf and KML
paths. PMTiles has no such inverse: tiles are simplified
and quantized, so the best one could do is an approximate reassembly, which this package does not attempt.
Read only what you need. openParquet(src) from geopbf/parquet opens a Parquet or GeoParquet file without
downloading it: src may be a URL, a File/Blob, a Uint8Array or a { read(from, len), size } you provide. It
fetches the footer first (two small Range requests: bytes=0-0 for the total length, then the last 64 KB — both in the CORS-safelisted form, so no preflight) and returns the schema, the geo metadata, and one
entry per row group with its byte size and column statistics (min/max). pq.select({ bbox }) uses the statistics of
the bbox covering column to name the row groups that touch a viewport — on a file written with order: "str" a
Tokyo-sized view of an 8,000-feature test file touches 32 of 63 row groups and reads 110 KB of 505 KB — and
pq.readRowGroup(g, { columns }) fetches only those column chunks, coalescing neighbouring byte ranges into as few
requests as possible (the same source as the COG reader). Hosts that ignore Range get the whole file once and
everything else works the same; files without statistics simply select every row group (pruned: false).
fromGeoParquet(url) rides on the same reader, so it too stops holding whole files.
zstd in the browser. Browsers have no zstd (DecompressionStream("zstd") is specified but not shipped), and geopbf
keeps zero runtime dependencies, so it takes a decoder from you instead (Node uses node:zlib first). Inject it in the
thread that reads — inside the worker if you read in a worker:
import { setZstdDecoder } from "geopbf/parquet";
setZstdDecoder(async u8 => (await import("fzstd")).decompress(u8)); // loaded only when a zstd page appears
5.5 Where the GPU is, honestly
The kernels that run on the GPU are projection, the per-zoom rank filter with its count/prefix-sum/write, the integer→double conversion and the bbox reduction. Everything after them — ring assembly, clipping, MVT encoding, gzip, PMTiles/Parquet writing — is CPU work in workers, and it dominates. Measured shares of the GPU-able stages on the CPU path (4-core Node):
| Job | GPU-able stages | Total | Share |
|---|---|---|---|
| NE countries, 480k vertices, z0–10 | 0.16 s | 5.6 s | 3 % |
| 200,000 synthetic parcels / 8.2M vertices (4.4M after folding), z0–16, 43,880 tiles / 185 MB | 2.1 s | 41.5 s | 5 % |
| 1,000,000 synthetic points, z0–10 | 0.9 s | 14.6 s | 6 % |
By Amdahl's law a GPU that made those stages free would shave at most that much. The honest reading is that the GPU is a nicety for the browser — it keeps the main thread free and scales flatly with vertex count — not the reason the converter is fast. The speed comes from Gint (a rank filter instead of per-zoom simplification, one arc per shared border) and from the assembly stage.
Real-GPU timings could not be measured in the CI container. On SwiftShader (a software Vulkan) the GPU path is slower
than the CPU path, as expected, and produces identical bytes (scripts/verify-convert-gpu.mjs --bench). Chromium on
the synthetic parcel map, z0–12 (--bench big_raw --maxzoom 12): CPU path project+LOD 0.8 s + write 0.9 s of 28.7 s;
SwiftShader path 1.5 s + 1.4 s, bytes identical; GeoParquet kernels 0.2 s on the CPU vs 2.1 s on SwiftShader,
identical output.
Node has no navigator.gpu. npm i webgpu (Dawn) gives the CLI a real adapter; without it, --gpu reports the
fallback and runs the CPU path — same bytes out. Deno's built-in WebGPU works as is. In the browser everything is
automatic. gpu: false forces CPU; a GPU object (e.g. from the webgpu package) can be passed as gpu.
5.6 Options
toPMTiles(pbf, { gint, minZoom = 0, maxZoom = 14, extent = 4096, buffer = 80, layer, simplification = 1,
lodBias = 0, dropRate = 2.5, tinyPolygon = 2, tinyLine = 0, include, exclude, excludeAll,
tileCompression = "gzip", gpu, workers, onProgress })
toGeoParquet(pbf, { codec, level = 9, rowGroupSize = 65536, pageSize, order = "str", bboxColumn = true,
geometryName = "geometry", include, exclude, excludeAll, gpu })
codec is "zstd" / "gzip" / "none" and defaults to zstd where the runtime has it (Node 22.15+) and gzip
otherwise. include / exclude / excludeAll select attributes — tiles and Parquet columns alike — and correspond
to tippecanoe's -y / -x / -X.
5.7 GeoPackage in (read-only)
geopbf/gpkg reads a .gpkg directly — in the browser (drop the file, or geopbf(file)) and in Node — with a
small read-only SQLite reader of its own (geopbf/sqlite, ~200 lines: B-tree pages, overflow chains, record
serial types, UTF-8/UTF-16). No sql.js, no WASM, still zero dependencies. It walks the table B-tree of one feature
layer, unwraps the GeoPackageBinary header and hands the WKB to the same decoder GeoParquet uses.
import { readGeoPackage, fromGeoPackage } from "geopbf/gpkg";
const { layers } = readGeoPackage(u8); // [{ table, geometryType, crs, count, columns }]
const { pbf, stats } = await fromGeoPackage(u8, { layer: "roads" }); // layer omitted → first feature layer
| CRS | EPSG:4326 / CRS84 / undefined-geographic pass through; EPSG:3857 is converted back to lon/lat; otherwise gpkg_spatial_ref_sys.definition goes through the same WKT classifier as FileGDB (§5.9, §5.10), so plane-rectangular and Tokyo Datum files work; anything left throws unless ignoreCrs |
| Geometry | all seven WKB types, either byte order, with or without envelope; Z/M dropped; NULL / empty / extension geometries are dropped and counted (stats.droppedGeometries) |
| Attributes | SQLite values as they are; declared BOOLEAN → bool, DATE/DATETIME/TIMESTAMP → Date; BLOB columns skipped (stats.skipped); integers beyond 2^53 kept as strings |
| Not read | indexes and R-trees (not needed for a full scan), views, WITHOUT ROWID tables, un-checkpointed WAL |
Raster GeoPackages (tile pyramids) are opened as what they are — a z/x/y tile store — rather than converted:
import { openGpkgTiles } from "geopbf/gpkg";
const t = openGpkgTiles(u8, "std"); // { zooms, xyz, count, bboxLonLat, matrices, get(z,x,y), has(z,x,y), mimeOf }
const png = t.get(14, 14553, 6452); // Uint8Array — createImageBitmap(new Blob([png])) in the browser
xyz is true when the matrix set is the EPSG:3857 world grid (256 px, 2^z × 2^z), in which case tile_row equals the
XYZ y; other grids expose their matrices for the caller to map. The index (z/x/y → rowid) is built once by decoding
only the first columns of each row, so the tile blobs are never copied until get asks for one.
MBTiles is the same idea with a TMS row order: openMBTiles(u8) from geopbf/mbtiles returns the same store
shape (xyz is always true, rows are flipped for you), reads both the plain tiles table and the map + images
view layout, and hands MVT tiles back as the gzip-wrapped bytes they are stored as.
Writing GeoPackage is deliberately not here — ogr2ogr -f GPKG from the GDAL driver does it, and building a SQLite
file by hand is where a dependency would start to earn its keep.
5.8 Tables: CSV, TSV, XLSX
Most files people actually have are spreadsheets. geopbf/table turns one into features when it has either two
coordinate columns or one WKT column — in the browser (drop a .csv / .tsv / .xlsx) or with geopbf csv2pbf.
import { fromTable } from "geopbf/table";
const { pbf, stats } = await fromTable(u8, { lon: "経度", lat: "緯度" }); // or { wkt: "geometry" }, or let it detect
| Columns | detected by name (lon/lng/longitude/経度/x and lat/latitude/緯度/y; wkt/geometry/geom/shape), or by a first-row value that starts with POINT(…; name them explicitly to override |
| Text | UTF-8 with or without BOM, UTF-16 with BOM; anything that fails strict UTF-8 is read with fallbackEncoding (default shift_jis; e.g. windows-1252), or force one with encoding — the browser's own TextDecoder, no dependency |
| CSV | RFC 4180 quoting, quoted newlines, delimiter sniffed among , \t ; | |
| XLSX | first sheet or sheet; sharedStrings, inline strings, numbers, booleans, formula cached values; dates stay as serial numbers |
| Values | numeric-looking strings become numbers except those with a leading zero ("01" stays a string — municipality codes survive), true/false become booleans, empty cells are absent; dates are never guessed |
| Rows without a usable geometry | dropped and counted (stats.droppedGeometries) |
5.9 File Geodatabase (read-only)
The format most government data actually lives in. geopbf/filegdb reads an Esri File Geodatabase — the .gdb
directory, or that directory zipped, which is how it travels — with no SDK and no GDAL: the .gdbtable /
.gdbtablx layout as documented by the OpenFileGDB work (ArcGIS 10.x files, version 3 and 4). Drop the zip in the
browser, or geopbf gdb2pbf.
import { openFileGDB, fromFileGDB, gdbSourceFromFiles } from "geopbf/filegdb";
const source = gdbSourceFromFiles(zipEntries); // File objects, or { names, read(name, offset?, length?) }
const { layers } = await openFileGDB(source); // feature classes: name, geometryType, crs, rows, fields
const { pbf, stats } = await fromFileGDB(source, { layer: "筆界" });
| Fields | int16/32/64, float32/64, string, datetime (→ Date), OBJECTID, GUID/GlobalID (→ {…} string), XML; binary columns skipped; raster tables refused |
| Geometry | point, multipoint, polyline, polygon (rings regrouped into Polygon/MultiPolygon by orientation and containment); Z/M dropped; curves are flattened to their vertices and counted (stats.curves); multipatch and empty shapes are dropped and counted |
| CRS | read from the field definition's WKT: WGS 84 / JGD2011 / JGD2000 / ITRF / ETRS89 / NAD83 / GDA pass through; Transverse Mercator (平面直角座標系 I–XIX, UTM) and Web Mercator are converted to lon/lat with geopbf/proj (Krüger series, mm-level); other projections and old datums (Tokyo) are refused unless ignoreCrs |
| Unknown SRS | passed through as lon/lat when the extent fits, and reported as crsUnknown |
| Datum | Tokyo Datum and JGD2000 are shifted onto JGD2011 — see below |
| Rows | deleted rows skipped; sparse 1024-row blocks handled; only the catalog and the chosen layer's two files are read |
geopbf/proj is small on purpose: it answers "can this be put back on the globe without a library?" for the
projections that cover Japanese administrative data, and nothing more. The same classifier reads a GeoPackage's
gpkg_spatial_ref_sys.definition, so a .gpkg in EPSG:6677 or EPSG:4301 comes back on the globe too.
5.10 Datum shifts (Japan)
Old Japanese data is not only in a different projection but on a different datum, and the difference is far
larger than the projection error: 400 m for Tokyo Datum, up to 5.7 m in Tohoku between JGD2000 and JGD2011.
geopbf/datum handles both with the GSI mesh parameter files, in one small binary format:
| Chain | Parameter file | Accuracy | Outside its area |
|---|---|---|---|
| Tokyo Datum → JGD2000 | TKY2JGD.par |
10–20 cm | falls back to the built-in 3-parameter Helmert (≈10 m) |
| JGD2000 → JGD2011 | touhokutaiheiyouoki2011.par (2011 Tohoku earthquake) |
cm | no shift — the two are identical there by definition |
node scripts/bake-datum-grid.mjs TKY2JGD.par tky2jgd.bin # 1.97 MB, 0.63 MB gzipped
node scripts/bake-datum-grid.mjs touhokutaiheiyouoki2011.par patchjgd.bin # 0.75 MB, 0.44 MB gzipped
Host the two .bin.gz files anywhere and pass their URLs (or the bytes) as tky2jgd / patchjgd — to
fromFileGDB, fromGeoPackage, createGeopbf, or as --tky2jgd / --patchjgd on the CLI. They are loaded only
when a layer actually needs them. Without them nothing fails: Tokyo Datum still converts by Helmert (and
stats.datumApprox says so), and JGD2000 stays JGD2000. The chains compose, so a Tokyo Datum plane-rectangular
layer with both grids present goes Bessel TM → lon/lat → JGD2000 → JGD2011 in one call, and stats.datum reports
how many vertices each grid actually covered.
The baked format is a 2nd-mesh block index plus Int16 residuals, with the residual step chosen from the data (1e-5″ for PatchJGD, 1e-3″ for nationwide TKY2JGD, whose island-to-island jumps are large). Values are read by bilinear interpolation of the 3rd-mesh corners, exactly as GSI's own tools do.
5.11 FlatGeobuf in (v3, v1.13)
geopbf(file) / geopbf(url) read any FlatGeobuf v3 file — the ones written by the official flatgeobuf
libraries and by GDAL, not only geopbf's own fgbFile() output. geopbf/fgb exposes the reader for Node and
the browser:
import { fromFlatGeobuf, readFlatGeobufHeader } from "geopbf/fgb";
const { pbf, stats } = await fromFlatGeobuf(u8); // stats: features, droppedGeometries, columns, crs, reprojected …
- Geometry type comes from the feature, or from the header when the file has a single type (the usual case).
- The packed Hilbert R-tree is skipped (
index_node_sizedefaults to 16 when the field is absent);features_count = 0(unknown) is read to the end. - Attributes are decoded by column type: Byte … Double, String, Json (parsed when valid), DateTime (kept as the ISO string). Binary columns are skipped; 64-bit integers outside the safe range become strings.
- CRS: none / 0 / EPSG:4326 / CRS84 are taken as lon/lat; EPSG:3857 and the projections
geopbf/projknows (Japan plane rectangular etc.) are converted to lon/lat; anything else throws unlessignoreCrsis passed. - Z/M are dropped (GeoPBF is 2D). Features without geometry, and curve / surface types, are dropped and counted.
- FlatGeobuf v2 files are rejected with an explicit error.
The test fixtures are the official test/data samples (BSD-2-Clause), tests/t-fgb.mjs.
6. COG — Cloud Optimized GeoTIFF
Rasters, the same way: a COG is a static file read by HTTP Range requests — no tile server, no preprocessing. The reader is hand-written pure JS (zero new dependencies): one 64 KB range request fetches the whole header, tile requests are sorted and coalesced (adjacent ranges merge into one request), decode and reprojection run in a worker pool, and decoded tiles sit in a byte-budgeted LRU. JPEG/WebP tiles go through the browser's native (hardware) decoder.
import { openCog } from "geopbf/cog";
const cog = await openCog("https://…/TCI.tif"); // 1 range request, header parsed
cog.bboxLL; // [w,s,e,n] in WGS84
const bm = await cog.renderXYZ(14, x, y); // ImageBitmap, warped to Web Mercator
cog.metrics(); // {ttfhMs, rangeRequests, coalescedFrom, …}
MapLibre and Leaflet, one line each (host libraries are not imported by geopbf):
import { cogProtocol } from "geopbf/maplibre-cog";
maplibregl.addProtocol("cog", cogProtocol);
map.addSource("x", { type: "raster", tiles: ["cog://https://…/TCI.tif/{z}/{x}/{y}"], tileSize: 256 });
import { cogGridLayer } from "geopbf/leaflet-cog";
(await cogGridLayer(L, "https://…/TCI.tif")).addTo(map);
npx geopbf cog info https://…/TCI.tif --bench # structure + measured numbers (Node fetch + Range)
npx geopbf cog png https://…/TCI.tif out.png # quick-look render
Supported subset (public-COG mainstream; everything else fails with an explicit error):
| Layout | tiled and stripped TIFF, BigTIFF |
| Compression | none · deflate · LZW · JPEG · WebP (JPEG/WebP decode in browser only) · predictor 2 |
| Samples | uint8 RGB(A) · RGB+NIR (4th band drawn as alpha only when ExtraSamples says so) · palette · single-band uint8/16 · int16 · float32 (auto percentile stretch, GDAL_NODATA → transparent) · 2-band gray+extra (first band drawn by default; e.g. Tellus PALSAR-2 HH/HV) |
| CRS | EPSG:4326 · EPSG:3857 · UTM 326xx–327xx (Krüger n-series, nm-accurate) |
Single- and two-band rasters take rendering options (the same object for browser and Node):
openCog(url, { stretch: [0, 32] }); // fixed value range instead of the auto 2–98 percentile
openCog(url, { colormap: "thermal" }); // single band → colour LUT ("thermal" built in, or a Uint8Array(256×3))
openCog(url, { composite: "dualpol" }); // 2-band SAR → R=b0, G=b1, B=b0−b1 false colour (forest green, built-up magenta, water black)
openCog(url, { cache, cacheKey: "scene-id" }); // second-level cache for compressed tile bytes ({get,set}); keyed by cacheKey or ETag
For anything beyond that, gdal_translate -of COG first. Sources without CORS: inject a proxy via
openCog(url, { fetch }). Node reads the same core via geopbf/cog/core (DOM-free).
7. Editing
geopbf/edit (v1.3) is the editing core battle-tested in
geoedit — pure data modules, no DOM, worker-safe, Node-testable.
import { buildTopology, createModel } from "geopbf/edit";
const topo = buildTopology(featureCollection, 6); // grid 10^-6 deg; shared borders become single arcs
const model = createModel(topo);
const addr = model.addrOf(eid, pathIdx, vertIdx); // stable address {eid, path, vi}
const { arcId, idx } = model.resolveAddr(addr);
model.moveVertex(arcId, idx, lng, lat); // one arc, N features: neighbors move together
model.toGeoJSON(); // → FeatureCollection (round-trip)
model.stats(); // → { features, arcs, vertices }
buildTopology(fc, gridExp) |
extract shared-edge topology; a border edit moves both features at once |
createModel(topo) |
vertex move/insert/delete, feature add/delete, holes, translate; command objects (applyCmd/invertCmd) with re-extraction-stable addresses, so undo/redo survives topology rebuilds |
createLargeModel(pbf) |
edit tens of millions of vertices in place on the GeoPBF bytes + Gint buffer — no full extraction, no OOM |
createSnapIndex(gridExp, deref) |
grid-linked snapping |
createHistory() |
undo/redo stack |
smoothRing / smoothGeom |
Spherical Catmull-Rom subdivision (control points as unit vectors, interpolated in 3D and renormalized — rotation-invariant, no pole/antimeridian artifacts), shared by the editor and @spline playback so the curve is the same everywhere |
geopbf/edit/sphere |
perfect-sphere geometry: slerp / gcMidpoint / gcDistanceDeg (great circles), quatBetween / rotateLL (rotation about the globe center), smallCircle, gcCentroid (edge-weighted spherical centroid = the axis the editor's wheel-rotation spins about). createModel exposes featureVerts + rotateFeature(eid, q, base) = move a feature as a rigid figure on the sphere (command {op:"rot"}; undo restores the snapshot exactly) |
Granular imports: geopbf/edit/model, geopbf/edit/large-model, geopbf/edit/topo-extract, geopbf/edit/snap,
geopbf/edit/history, geopbf/edit/spline, geopbf/edit/sphere, geopbf/edit/draw, geopbf/edit/imagequad.
Images placed by four corners (v1.11, geopbf/edit/imagequad). An image laid on the ground (an old map, a scanned plan, a photo)
is an ordinary feature: a 4-vertex Polygon whose ring runs top-left → top-right → bottom-right → bottom-left, with the image as a
Blob in the @image property (stored once in the file's buffer pool, like @icon). Because it is just a polygon, the editor's vertex
drag, move and rotate are the corner editing. buildTopology makes the first vertex of an @image outer ring a node, so the ring
start (= which corner is top-left) survives sharing an edge with a neighbour.
cornersOf(geometry) |
the four corners [TL, TR, BR, BL]; a ring rewound counter-clockwise is restored with its start kept. null if not 4 vertices |
quadMapping(corners) |
projective transform (homography) image uv ⇄ Web Mercator: H, Hi, bbox, uvToLonLat — a trapezoid fits without shear; continuous across the antimeridian |
drawImageQuad(ctx, img, corners, project) |
draw on a canvas 2D (triangle grid, affine per cell); DOM-free, works in workers |
placeCorners(center, widthM, aspect) / quadPolygon(corners) |
north-up corners for a new image / the closed Polygon |
isImageFeature(f) |
Polygon + 4 corners + @image |
Edge semantics (Gint v5). Consecutive vertices are joined by great circles on a perfect sphere: the Gint bake inserts
great-circle anchors so that no rendered chord spans more than 1°, the antimeridian cut already uses the spherical crossing, and
the editor draws, snaps and moves on the same sphere (moving = rotation about the globe center, circles = spherical small circles, rectangles = the four corners are taken on the sphere from the diagonal — point-symmetric about the diagonal's great-circle midpoint in its gnomonic tangent plane, so they lie on one small circle and opposite sides are equal — not lon/lat corners).
This differs from RFC 7946, which defines segments as straight lines in longitude/latitude — a two-vertex "parallel" such as a 49°N
border will bow poleward; add intermediate vertices if you need a rhumb-like edge. Rings that enclose a pole (they cross the
antimeridian once) are closed through the pole on encode ([±180,lat] → [±180,±90] → [∓180,±90] → [∓180,lat], the RFC 7946
pole convention), and an edge whose great circle passes over the pole gets anchors on both sides of the longitude jump.
8. Map library integrations
None of these import the host library; each is a thin adapter you register yourself. All of them share one loader
(gzip detection, noeval property decoding, sanitizing) and the same { fetch, sanitize, signal } options.
8.1 MapLibre GL JS
geopbf/maplibre supplies GeoPBF files to MapLibre as GeoJSON sources. (Data supply only; this is not a
MapLibre-compatible rendering layer for the Gint engine.)
import maplibregl from "maplibre-gl";
import { geopbfProtocol } from "geopbf/maplibre";
maplibregl.addProtocol("geopbf", geopbfProtocol);
map.addSource("rail", { type: "geojson",
data: "geopbf://https://api.ortho-earth.com/bucket/GIS/pbf/N02-25_RailroadSection" });
- URL contract — pass the inner URL absolute (
geopbf://https://…), as with pmtiles. MapLibre normalizes source URLs throughnew URL(), which corrupts relative forms likegeopbf://../x; the handler repairs the one mangling absolute URLs suffer (https//losing its colon), but relative paths cannot be recovered. - Whole-file gzip is detected by magic bytes and decompressed transparently.
- Properties are sanitized to survive MapLibre's JSON round-trip to its worker:
Date→ ISO string, BBOX → plain array, FUNC → source string (decoded withnoeval, so nonew Function— CSP-safe), Blob/ImageData values dropped.makeGeopbfProtocol({ sanitize: false })opts out. - Metadata — name / description / license / attribution / minZoom / maxZoom travel inside the file, but a
protocol handler cannot set source attribution or layer zoom range. Use
loadGeopbf(url)to get{ geojson, ...meta }and wire them intoaddSource/addLayeryourself, ormakeGeopbfProtocol({ onMeta }). For feature-state, setpromoteIdon the source (GeoPBF features carry noid). - A GeoPBF file is one whole dataset, not z/x/y tiles, so MapLibre's built-in geojson-vt does the tiling and simplification. Comfortable up to tens of MB of resulting GeoJSON; for very large datasets, tile it first with §5.
See examples/maplibre.html for a standalone demo (base map + protocol source + loadGeopbf metadata wiring).
8.2 Leaflet
geopbf/leaflet provides an L.GeoJSON subclass. Register it explicitly (works with ESM and the CDN global L):
import L from "leaflet"; // or the CDN global
import { extendLeaflet } from "geopbf/leaflet";
extendLeaflet(L);
L.geoPBF("geopbf://https://api.ortho-earth.com/bucket/GIS/pbf/N02-25_RailroadSection", {
style: { color: "#3564c0" }, // plus any L.GeoJSON option
})
.on("load", e => console.log(e.meta)) // "error" on failure
.addTo(map);
Unlike MapLibre's protocol handler, a Leaflet layer owns its attribution, so the file header's attribution is wired
in automatically (an explicit options.attribution wins). getMeta() returns the header metadata after load;
await layer.whenReady() awaits it. See examples/leaflet.html.
8.3 OpenLayers
geopbf/openlayers provides a VectorSource loader factory — no ol import on our side; pass your format instance:
import VectorSource from "ol/source/Vector.js";
import GeoJSON from "ol/format/GeoJSON.js";
import { makeGeopbfLoader } from "geopbf/openlayers";
const source = new VectorSource({
loader: makeGeopbfLoader("geopbf://https://…/N02-25_RailroadSection", new GeoJSON(), {
onMeta: meta => console.log(meta), // header metadata; onError for failures
}),
});
Features are reprojected to the view projection automatically (featureProjection), and the header's attribution
is wired into the source (an explicit attributions option wins). See examples/openlayers.html.
8.4 deck.gl / loaders.gl
geopbf/loaders exports a loaders.gl Loader, which plugs into deck.gl and other loaders.gl consumers directly:
import { GeoJsonLayer } from "@deck.gl/layers";
import { GeoPBFLoader } from "geopbf/loaders";
new GeoJsonLayer({ data: "https://…/N02-25_RailroadSection", loaders: [GeoPBFLoader] });
parse returns a GeoJSON FeatureCollection with the header metadata attached as geopbfMeta; pass
loadOptions: { geopbf: { sanitize: false } } to opt out of property sanitizing. See examples/deckgl.html.
8.5 Cesium, D3, and everything else
Libraries that accept a GeoJSON object directly need no plugin at all — geopbf/load exposes the shared loader the
four integrations above are built on:
import { loadGeopbf } from "geopbf/load";
// Cesium
const r = await loadGeopbf("geopbf://https://…/N02-25_RailroadSection");
viewer.dataSources.add(await Cesium.GeoJsonDataSource.load(r.geojson));
if (r.attribution) viewer.creditDisplay.addStaticCredit(new Cesium.Credit(r.attribution));
Same deal for D3 (d3.geoPath over r.geojson), Observable notebooks, or anything else that eats GeoJSON.
loadGeopbf returns { geojson, name, description, license, attribution, minZoom, maxZoom }. See
examples/cesium.html.
Worker entry (v1.12)
geopbf runs conversions, parsing, COG decoding and PMTiles tile writing in its own module worker (geopbf/worker, one
entry; the role is the Worker's name: decoder:<format>, encoder:<format>, geopbf:cog, geopbf:tile). Bundlers
build every worker separately, so an app that has its own worker ends up shipping — and loading — geopbf's core twice.
Hand geopbf a factory and it runs inside your entry instead:
// main thread
createGeopbf(apiBase, { workerFactory: role =>
new Worker(new URL("./worker.js", import.meta.url), /* @vite-ignore */ { type: "module", name: role }) });
// (or setWorkerFactory(fn) from "geopbf"; return null to fall back to the built-in worker)
// your worker.js
if (/^(decoder|encoder|geopbf):/.test(self.name)) import("geopbf/worker"); // it reads self.name
To drop geopbf's own worker from the bundle as well, alias the one file that constructs it
(new Worker appears nowhere else) to the no-op version — Vite:
resolve: { alias: [{ find: /^\.\.?\/(modules\/)?builtinWorkers\.js$/,
replacement: require.resolve("geopbf/no-builtin-workers") }] }
With the alias your factory must always return a Worker. ortho-japan does both: 10 worker builds became 6 and the duplicated code in its bundle went from 493 KB to 256 KB.
9. Storage injection
Out of the box, createGeopbf() fetches plainly and re-converts on every load — correct, dependency-free,
cache-less. If you have your own storage layer (IndexedDB cache, remote bucket, proxied fetch), inject it:
createGeopbf(apiBase, { bucket: myProvider });
// myProvider(apiBase, options) → { Bucket, Cache, Fetch }
Everything else — conversion, Gint, identify — is identical either way.
10. Runtime requirements
No runtime dependencies. The protobuf wire reader/writer is built in (geopbf/pbf); the last external package,
pbf, was dropped in 1.6. Compression and decompression everywhere — gzip/deflate for
GeoPBF, ZIP/Shapefile/MOJ decoders, COG tiles, PMTiles and Parquet — go through the platform's native codecs
(CompressionStream/DecompressionStream in browsers and workers, node:zlib in Node); pako was removed in 1.5.
Requires a browser with CompressionStream (all evergreen browsers). zstd in Node needs 22.15+.
Workers are declared as new Worker(new URL("./…", import.meta.url), { type: "module" }) and the WASM ships as a
regular asset — Vite and other modern bundlers handle both natively, no plugins. One setting is required in the
consumer's Vite config, because the workers use dynamic imports internally and Vite's default iife worker format
rejects them:
// vite.config.js
export default { worker: { format: "es" } };
License
MIT. The format is meant to spread — build on it freely. Format spec and technical notes: ortho-earth.com/docs/geopbf.html