Protocol Buffers-based geospatial binary format.
Lightweight Β· Universal converter Β· ArrayBuffer-native β the wire format for browser GIS
GeoJSON is readable and convenient, but has critical limitations at scale. As a text format it is expensive to transfer, slow to parse, and blocks the browser's main thread. Shapefile is widely adopted but its multi-file structure is ill-suited for the Web. Tiled MVT fragments data by zoom level, increasing round-trip overhead.
GeoPBF is a binary format designed as the communication layer for browser GIS. Protocol Buffers compression efficiency, coordinate delta-encoding, and an ArrayBuffer-native design make transfer, decoding, and inter-Worker handoff uniformly fast.
A GeoPBF file consists of two parts: a Header Section and a Body Section (FARRAY).
| Field | Tag | Type | Description |
|---|---|---|---|
| GTYPE | 8 | Varint | 0:Point 1:MPoint 2:Line 3:MLine 4:Poly 5:MPoly 6:Collection |
| LENGTH | 9 | Packed Varint | Vertex counts for rings and multi-part geometries |
| COORDS | 10 | Packed SVarint | Delta-compressed coordinate stream Xβ,Yβ,ΞXβ,ΞYβ,... (see below) |
| INDEX | 12 | Packed Varint | Indices into the global KEYS dictionary |
| VALUE | 11 | Repeated Message | NULL/BOOL/INTEGER/FLOAT/STRING/DATE/COLOR/JSON/BLOB/IMAGE |
Metadata such as NAME, DESCRIPTION, and LICENSE is stored independently in the header, allowing direct binary-slice writes without touching the feature body. Renaming a dataset or updating license information on a multi-gigabyte file completes instantly, with no re-encoding required.
Storing coordinates as floating-point text like GeoJSON means the longitude
139.7412345 alone costs 11 bytes.
GeoPBF reduces this in two stages.
The coordinate difference between adjacent vertices is typically just a few hundred metres, so after integer scaling the delta values are small. Varint encodes small integers in fewer bytes, giving GeoPBF a property that is ideal for geographic data: denser vertex clusters compress more aggressively.
A GeoJSON "coordinates":[139.741234,35.681234] pair is roughly 30 bytes (text).
GeoPBF's delta-Varint representation costs 8 bytes for the first vertex and
2β3 bytes for every subsequent one.
Across the three datasets measured in Β§8.1 that is a 75β92% reduction
against raw GeoJSON. Layering gzip via CompressionStream removes a further
18β29% β comparatively little, precisely because the redundancy gzip looks for has already
been squeezed out by the encoding.
GeoPBF is not merely a storage format β it acts as a conversion hub among major GIS formats, with decoding (import) and encoding (export) paths for all of them. Placing GeoPBF at the center enables any-to-any format conversion in a single step.
| Format | Read | Write | Notes |
|---|---|---|---|
| GeoJSON | β | β | File / Blob / Object all accepted |
| Shapefile | β (.zip) | β (.zip) | Multi-file bundle handled as a single .zip |
| KMZ / KML | β | β | Google Earth compatible |
| GML | β | β | OGC standard |
| GPX | β | β | GPS tracks & waypoints |
| FlatGeoBuf | β | β | Streaming-friendly binary |
| TopoJSON | β | β | Topology-preserving I/O |
| MOJ η»θ¨ζεδ»ε°ε³ | β (.zip) | β | Japanese cadastral parcels; JGD2011 plane-rectangular (19 zones) β WGS84 on decode. Opt in with { format: "moj" }, since the extension is also .zip |
| GeoParquet (v1.5) | β | β | WKB + bbox covering column, spatially ordered rows. Dependency-free reader accepts geopandas / pyarrow / DuckDB output β Β§13 |
| PMTiles (MVT) (v1.5) | β | β | Tiles are cut from Gint, so per-zoom simplification is a rank filter β Β§13. No inverse: tiles are simplified and quantized |
| GeoPBF | β (native) | β (native) | Native fast-load |
| Gint | β (decode) | β (encode) | Morton + VW extension (see Β§6) |
The first eight rows go through the auto-detecting entry point geopbf(input). GeoParquet and
PMTiles are large enough to be worth loading on demand, so they live behind their own subpath exports
(geopbf/geoparquet, geopbf/pmtiles) and are covered in Β§13. Rasters travel a
separate path again: geopbf/cog reads Cloud Optimized GeoTIFF by HTTP Range without converting
it at all (Β§12). Everything here also has a CLI equivalent β npx geopbf enc / dec / pmtiles / parquet /
parquet2pbf β running on plain Node with no GDAL.
Whatever you pass to geopbf(input) β a File, Blob,
ArrayBuffer, or plain Object β the library auto-detects the format
from the file extension, MIME type, or magic bytes and routes to the correct decoder. Accepted:
.geopbf .pbf .geojson .json .topojson .fgb .zip .kml .kmz .gpx .gml .xml, each
optionally .gz (gzip is recognised by signature, not extension). The caller never has
to think about format. The one exception is the MOJ cadastral archive, whose .zip
extension collides with Shapefile and so is selected with { format: "moj" }. A File
that cannot be decoded raises an explicit error rather than silently yielding an empty
FeatureCollection.
// Any format, the same API
const pbf = await geopbf(file); // File (Shapefile .zip, GeoJSON, KMZ ...)
const pbf = await geopbf(arrayBuffer); // ArrayBuffer (fetch response, etc.)
const pbf = await geopbf(geojsonObject); // GeoJSON object directly
const pbf = await geopbf(zip, { format: "moj" }); // cadastral .zip β plane-rectangular β WGS84
// Export to any format
const geojson = pbf.geojson; // β GeoJSON object
const topojson = pbf.topojson; // β TopoJSON (topology-preserving)
const shp = pbf.shape(); // β Shapefile (.zip Blob)
const kmz = pbf.kmz(); // β KMZ Blob
const gml = pbf.gml(); // β GML string
const fgb = await pbf.fgb(); // β FlatGeoBuf ArrayBuffer
// v1.5 β the two large formats, behind their own subpath exports (Β§13)
import { toPMTiles } from "geopbf/pmtiles";
import { toGeoParquet, fromGeoParquet } from "geopbf/geoparquet";
const { buffer } = await toPMTiles(pbf, { maxZoom: 12 }); // β PMTiles (MVT) bytes
const pq = await toGeoParquet(pbf); // β GeoParquet bytes
const back = await fromGeoParquet(u8); // β GeoParquet, incl. geopandas/DuckDB output
// Save as native .geopbf (gzip-compressed)
await pbf.save('coastline'); // β downloads coastline.geopbf
The antimeridian (Β±180Β° longitude) is a well-known trap in geographic data. A polygon that straddles it β for example a geometry spanning the Bering Sea β is stored as a single ring in most formats, with coordinates jumping from +179Β° to β179Β°. Renderers, spatial indexes, and clipping operations all break silently on such data.
GeoPBF treats antimeridian correctness as a format invariant: every geometry stored in GeoPBF is guaranteed to be antimeridian-cut. No straddling polygon can exist inside the format.
setFeature() calls antimeridianFeature(q) on every feature
before writing it to the buffer β regardless of the source format.
GeoJSON, Shapefile, KMZ, GML, GPX, FlatGeoBuf, TopoJSON β all go through the same path.
The caller never needs to pre-process geometries.
The cut is not a naive Β±180Β° coordinate clamp.
antimeridianCut() computes the precise latitude at which each crossing segment
intersects the antimeridian using the spherical formula β
essential near the poles where meridian convergence is significant.
After cutting, each sub-polygon is a valid closed ring with vertices inserted at the exact intersection latitude.
// Crossing detection: sign change + span > 180Β° means antimeridian crossing (not date line jump)
// p[i][0] * p[i+1][0] < 0 && abs(p[i][0] - p[i+1][0]) > 180
// Spherical intersection latitude (great-circle formula)
// x = sin(Ξlat/2)Β·sin(avgLng)Β·cos(halfDeltaLng) - sin(avgLat)Β·cos(avgLng)Β·sin(halfDeltaLng)
// z = cos(lat0)Β·cos(lat1)Β·sin(Ξlng)
// intersectLat = atan2(x, |z|) β geometrically exact on the sphere
Morton codes require coordinates in a bounded domain. A geometry that straddles Β±180Β° would produce Morton codes that jump across the entire integer space, breaking binary search and spatial proximity guarantees. By cutting at encode time, all Morton codes in a GeoPBF tile are spatially coherent with no special-casing needed downstream.
GeoPBF's internal representation is consistently an ArrayBuffer.
This is a deliberate design choice for deep integration with the browser's parallel processing layer (Web Workers),
yielding three important properties.
// ArrayBuffer moves from main thread β Worker with zero copy
// (ownership transfer, not a copy β no extra memory consumed)
worker.postMessage(pbf.arrayBuffer, [pbf.arrayBuffer]);
// After transfer pbf.arrayBuffer is detached (main thread can no longer access it)
// Worker side
onmessage = ({ data: buf }) => {
const view = new DataView(buf); // begin decoding immediately
postMessage(result, [result]); // return zero-copy
};
// Expand decoded gint coordinate data into a SharedArrayBuffer
// β multiple render Workers can read concurrently with no copies
const sab = new SharedArrayBuffer(buf.byteLength);
new Uint8Array(sab).set(new Uint8Array(buf));
// N tile workers run LOD filter + draw in parallel
tileWorkers.forEach(w => w.postMessage({ sab, zoom, viewport }));
// On save: browser-native gzip (no external library needed)
let blob = new Blob([buf]);
blob = await (new Response(
blob.stream().pipeThrough(new CompressionStream("gzip"))
)).blob();
postMessage(new File([blob], `${name}.geopbf`, { type: "application/x-geopbf" }));
GeoJSON is an object graph (JSON). Before a Worker can touch it you need
JSON.stringify β transfer β JSON.parse β
a full serialize/deserialize cycle that costs hundreds of milliseconds on large datasets.
GeoPBF is an ArrayBuffer from the very start, so it moves between Workers
via zero-copy transfer, and the receiver decodes by advancing a pointer.
The main thread is never blocked.
Applying gint encoding to GeoPBF packs each vertex's coordinates into a single 64-bit integer that simultaneously stores both the Morton code and the VW weight.
Packing where (Morton) and which LOD (VW rank) into a single 64-bit integer means both spatial queries and LOD filtering reduce to simple integer arithmetic.
// L1 / L2 check (most-significant bit)
const isAnchor = (code >> 63n) === 1n;
// Extract Morton code (L2: upper 58 bits)
const mortonCode = code & 0x7FFFFFFFFFFFFFF8n; // bits 3-62
// Extract VW weight rank (L2: lower 6 bits)
const vwRank = Number(code & 0x3Fn); // bits 0-5 β 0β63
// LOD filter at zoom z (rank threshold corresponding to 1pxΒ²)
const minRank = rankFromZoom(z); // lower threshold at higher zoom (more vertices)
const visible = isAnchor || vwRank >= minRank;
Three public datasets of different shape and scale, each converted and measured end to end on one machine. Every row holds the same features; sizes are decimal MB.
| Format | 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 |
| β +.prj, zipped β9 | 4.9 | 174.8 | 530.5 |
| GML (.xml) | β | 580.7 | β |
| β + gzip β9 | β | 123.2 | β |
| GeoJSON | 13.3 | 579.6 | 1,089.6 |
| β + gzip β9 | 4.7 | 138.6 | 265.8 |
| FlatGeobuf | 9.3 | 266.9 | 826.8 |
| β + gzip β9 | 5.4 | 156.3 | 421.9 |
| GeoParquet (WKB, zstd, bbox) | 4.5 | 124.5 | 333.7 |
| GeoPBF | 3.3 | 48.6 | 117.8 |
| GeoPBF + gzip β9 (distribution form) | 2.7 | 34.4 | 95.2 |
Per vertex, raw β compressed. This is where the formats separate:
| Bytes per vertex | NE 10m admin_0 | KSJ N03 | TIGER ZCTA5 |
|---|---|---|---|
| Shapefile | 17.7 β 9.0 | 18.1 β 11.2 | 16.1 β 10.3 |
| GeoJSON | 24.2 β 8.6 | 37.0 β 8.8 | 21.3 β 5.2 |
| FlatGeobuf | 17.0 β 9.8 | 17.0 β 10.0 | 16.1 β 8.2 |
| GeoPBF | 6.0 β 4.9 | 3.1 β 2.2 | 2.3 β 1.9 |
Both spend 16β18 bytes per vertex, for the same reason: two IEEE-754 doubles per point, whatever the geometry looks like. 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 instead, so its cost falls with vertex density: 2β3 bytes per vertex on a boundary sampled every few metres, more on sparse geometry with many attribute columns.
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.
Reproduce with npx geopbf enc 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 at deflate β9 so that all three datasets are measured the same way β the shipped
archives differ in what else they carry (Natural Earth 4.9 MB with an HTML README, TIGER 528.8 MB with ISO
metadata XML, KSJ N03 803.2 MB with GML and shapefile and GeoJSON in one archive) and are not
directly comparable. The shapefiles hold the original full-precision doubles, but a shapefile spends a fixed
16 bytes per point whatever the precision, so its size rows are unaffected.
| Metric | GeoJSON | Shapefile | MVT (tiles) | GeoPBF |
|---|---|---|---|---|
| Encoding | Text UTF-8 | Binary (multi-file) | PBF binary | PBF + delta + Varint |
| File count | 1 | .shp + .dbf + .shx + β¦ | zoom Γ tile count | 1 |
| Transfer efficiency | Low (large text) | Medium | Medium (zoom-split) | High (binary + gzip) |
| Worker transfer | JSON serialize required | Conversion required | ArrayBuffer | ArrayBuffer zero-copy |
| LOD support | None | None | Per-zoom files | VW weight built-in |
| Spatial index | None | .shx (linear) | Tile coords | None in the file (Morton 64-bit built in memory on load) |
| Format conversion | External library needed | External library needed | Limited | 12 formats built-in |
| Topology | None | None | None | Arc sharing Β· boundary integrity |
// ββ Load βββββββββββββββββββββββββββββββββββββββββββββββ
const pbf = await geopbf(input, options);
// input: File | Blob | ArrayBuffer | GeoJSON object | URL string
// options: { name, gint, nocache, clean }
// gint: false β skip topology encode (gint conversion)
// nocache: true β bypass IndexedDB cache and re-fetch
// clean: true β run topology integrity clean pass
// ββ Data access ββββββββββββββββββββββββββββββββββββββββ
pbf.length // feature count
pbf.keys // attribute name array ['name', 'rank', ...]
pbf.arrayBuffer // raw ArrayBuffer (for Worker transfer)
pbf.geojson // convert to GeoJSON FeatureCollection
pbf.topojson // convert to TopoJSON (topology-preserving)
pbf.getFeature(i) // decode only the i-th feature
// ββ Export βββββββββββββββββββββββββββββββββββββββββββββ
await pbf.save('name') // download as .geopbf (gzip)
pbf.shape() // Shapefile .zip
pbf.kmz() // KMZ
pbf.gml() // GML string
pbf.gpx() // GPX string
await pbf.fgb() // FlatGeoBuf ArrayBuffer
// ββ O(1) metadata update βββββββββββββββββββββββββββββββ
pbf.header({ name: 'New Name', description: '...', license: 'MIT' });
// ββ Render βββββββββββββββββββββββββββββββββββββββββββββ
pbf.draw(canvasCtx, { fill: '#5a8050', stroke: '#a8d47e' });
Since v1.1.0 the npm package ships ready-made adapters for the major web map libraries.
None of them import the host library β each is a zero-dependency subpath export
built on one shared loader: whole-file gzip is detected by magic bytes, decoding avoids
new Function (CSP-safe noeval mode), and properties are mapped to be
JSON-safe (Date β ISO string, bbox β plain array, binary values dropped) so they
survive each library's worker handoff.
| Import | Shape | For |
|---|---|---|
geopbf/maplibre |
addProtocol handler | MapLibre GL JS (v4+) |
geopbf/leaflet |
L.GeoJSON subclass | Leaflet (1.x) |
geopbf/openlayers |
VectorSource loader factory | OpenLayers (6+) |
geopbf/loaders |
loaders.gl Loader object | deck.gl Β· kepler.gl |
geopbf/load |
plain loadGeopbf(url) |
Cesium Β· D3 Β· anything that eats GeoJSON |
// ββ MapLibre β protocol source βββββββββββββββββββββββββ
import { geopbfProtocol } from "geopbf/maplibre";
maplibregl.addProtocol("geopbf", geopbfProtocol);
map.addSource("rail", { type: "geojson", data: "geopbf://https://β¦/N02-25_RailroadSection" });
// ββ Leaflet β layer plugin βββββββββββββββββββββββββββββ
import { extendLeaflet } from "geopbf/leaflet";
extendLeaflet(L);
L.geoPBF("geopbf://https://β¦").on("load", e => console.log(e.meta)).addTo(map);
// ββ OpenLayers β source loader βββββββββββββββββββββββββ
import { makeGeopbfLoader } from "geopbf/openlayers";
const source = new VectorSource({ loader: makeGeopbfLoader(url, new GeoJSON()) });
// ββ deck.gl β loaders.gl βββββββββββββββββββββββββββββββ
import { GeoPBFLoader } from "geopbf/loaders";
new GeoJsonLayer({ data: url, loaders: [GeoPBFLoader] });
// ββ Everything else β plain loader βββββββββββββββββββββ
import { loadGeopbf } from "geopbf/load";
const r = await loadGeopbf(url); // { geojson, name, license, attribution, minZoom, maxZoom, β¦ }
viewer.dataSources.add(await Cesium.GeoJsonDataSource.load(r.geojson));
Attribution travels inside the file (Β§2), and the adapters honor it: Leaflet and OpenLayers wire the
header's attribution into the map credit automatically (an explicit option wins), and
loadGeopbf hands back all header metadata for manual wiring.
One contract to know: with MapLibre, write the inner URL absolute
(geopbf://https://β¦, pmtiles-style) β MapLibre normalizes source URLs through
new URL(), which corrupts relative forms.
GeoPBF is an open format: anyone can produce a file and hand you a URL. Display properties β
@tip, @pop, or any property your viewer renders as HTML β must therefore
be treated as untrusted input. A crafted file could otherwise run script in your
page (XSS) the moment a tooltip or popup opens. The rule is the classic one: sanitize at the
output boundary, right before the string reaches innerHTML β never assume a
file was cleaned upstream, because files reach viewers without passing through any editor.
// Sanitize at the output boundary β right before innerHTML
import { sanitizeHTML } from "geopbf/sanitize";
el.innerHTML = sanitizeHTML(feature.properties["@pop"]);
sanitizeHTML is a dependency-free allowlist filter: formatting tags, lists, tables,
images and links pass through; script, iframe, svg and the
like are dropped with their content; unknown tags are unwrapped, keeping their children; event
handlers and style never survive. href accepts
http(s)/mailto, img src accepts
http(s)/data:image, and every link gets
target="_blank" rel="noopener noreferrer". It touches the DOM only when called, so
importing it in a worker is safe. Note the division of labor with Β§10:
sanitizeProperties makes values JSON-safe (types), sanitizeHTML
makes them display-safe (markup). Use both.
A COG (Cloud Optimized GeoTIFF) is a static file read by HTTP Range requests β the same
no-server philosophy as GeoPBF, applied to rasters. v1.4's geopbf/cog is a
hand-written reader with zero new dependencies: the header is fetched in one 16 KB
range request, tile requests are sorted and adjacent ranges coalesced into a
single request, decode and reprojection run in a worker pool, and JPEG/WebP tiles go
straight to the browser's native decoder. cog.metrics() reports the numbers at any
time (range requests, coalesce ratio, decode time).
import { openCog } from "geopbf/cog";
const cog = await openCog("https://β¦/TCI.tif"); // header in one range request
const bm = await cog.renderXYZ(14, x, y); // ImageBitmap, warped to Web Mercator
The supported subset follows what public COG feeds actually serve: tiled and stripped layouts,
BigTIFF, compression none/deflate/LZW/JPEG/WebP, predictor 2, uint8 RGB(A)/palette/single-band
uint16/int16/float32 (auto percentile stretch, GDAL_NODATA β transparent), CRS
EPSG:4326/3857/UTM (hand-written KrΓΌger n-series, nm accuracy). Anything outside fails with an
explicit error pointing to gdal_translate -of COG. One line each for MapLibre
(cog:// protocol via geopbf/maplibre-cog) and Leaflet
(geopbf/leaflet-cog). The CLI prints structure and measured numbers:
npx geopbf cog info <url> --bench.
Through v1.4 the hub read formats. v1.5 opens the other direction: a GeoPBF leaves as a
PMTiles archive of Mapbox Vector Tiles or as a GeoParquet file, and a
GeoParquet comes back in. Still no new dependencies β in fact pako was removed.
Every codec now goes through the platform (CompressionStream in browsers and workers,
node:zlib in Node), leaving pbf as the only runtime dependency β and 1.6 dropped that one
too, by bringing the protobuf wire reader/writer in-house. The dependency list is now empty.
# CLI β plain Node, no native build
npx geopbf pmtiles countries.geopbf countries.pmtiles --maxzoom 10
npx geopbf parquet countries.geopbf countries.parquet
npx geopbf parquet2pbf countries.parquet back.geopbf # and back again
import { toPMTiles } from "geopbf/pmtiles";
import { toGeoParquet } from "geopbf/geoparquet";
const pbf = await geopbf(file, { gint: true });
const { buffer, stats } = await toPMTiles(pbf, { maxZoom: 12 }); // stats.engine β "gpu" | "cpu"
const pq = await toGeoParquet(pbf); // pq.buffer, pq.geo
Tiles are cut from Gint, not from the raw features. That is the same derived buffer
ortho-earth draws on the GPU: a shared border exists once, as a single arc, and every vertex carries its
VisvalingamβWhyatt rank. Per-zoom simplification is therefore a rank >= threshold filter,
which means two neighbouring polygons are necessarily reduced to the identical vertex list β no
slivers, no gaps, at any zoom. The thresholds are calibrated per arc against a DouglasβPeucker
decomposition, so vertex counts land within roughly 10 % of tippecanoe's while which vertices
survive is still decided by rank, keeping both sides of a border in step.
On Natural Earth 10m countries (258 polygons, 480k vertices, z0β10, 4-core Node) that is
about 6.5 s from GeoJSON for 573,896 tiles / 151.0 MB, against tippecanoe v2.82 at 58 s
for 573,885 tiles / 147 MB β the same tile set within a handful, with interior tiles byte-for-byte the
same size apart from the layer name and the feature id. GDAL's PMTiles driver (3.12) took
780 s for the same job. On US Census ZCTA5 (33,092 ZIP areas, 52M vertices, z0β12): 47 s for 238,322
tiles / 251 MB against tippecanoe's 251 s for 238,319 tiles / 246 MB, indistinguishable in MapLibre at
z3/z7/z11.
Parquet rows come out spatially ordered. Feature bbox centres are packed
Sort-Tile-Recursive style (order: "str", the default), so each row group's bbox
statistics cover a disjoint patch and any reader that pushes an area filter down β DuckDB, pyarrow
datasets, GeoPandas β fetches only the row groups that actually intersect it. On 1,000,000 points in 50
row groups the row-group overlap ratio runs 24.5 unsorted β 0.87 Morton β 0.28 Hilbert β 0.00 STR, and a
city-sized query touches 1β2 row groups instead of all 50. This follows Kanahiro Iguchi's
Spatial sort for well-packed GeoParquet (CNG Japan 2026).
Where the GPU is, honestly. Projection, the per-zoom rank filter, the integerβdouble conversion and the bbox reduction run on WebGPU when it is available, with a CPU path performing the same integer arithmetic when it is not β the output is byte-identical either way, verified kernel by kernel in headless Chromium. But those stages are only 3β6 % of the job on every dataset measured so far; ring assembly, clipping, MVT encoding and gzip dominate. The GPU keeps the browser's main thread free. It is not the reason the converter is fast β that comes from Gint and from the assembly stage.
And back again. fromGeoParquet() turns a GeoParquet into a GeoPBF using a
dependency-free Parquet reader β Thrift compact footer, DataPage v1/v2, PLAIN and dictionary encodings,
codecs none / snappy / gzip / zstd β plus a WKB parser. A file geopbf wrote comes back bit-identical, and
files written by GeoPandas, pyarrow and DuckDB read back to the same features. The CRS must be lon/lat
(CRS84 / EPSG:4326) unless ignoreCrs is passed. PMTiles has no such inverse: its tiles are
simplified and quantized, so only an approximate reassembly would be possible, and this package does not
pretend otherwise.