maplibre-gl-vector
v0.10.12
Published
A MapLibre GL JS plugin for visualizing vector data (GeoJSON, GeoPackage, Shapefile, GeoParquet, FlatGeobuf, CSV) powered by DuckDB-WASM with dynamic vector tiles
Maintainers
Readme
maplibre-gl-vector
A MapLibre GL JS plugin for visualizing vector data in many formats - GeoJSON, GeoPackage, Shapefile, GeoParquet, FlatGeobuf, CSV - powered by DuckDB-WASM and its spatial extension.
Features
- Many vector formats - GeoJSON, GeoPackage, Shapefile (zipped, or a loose
.shpselected together with its.shx/.dbf/.prj/... sidecars), GeoParquet, FlatGeobuf, CSV (WKT or lon/lat columns), KMZ, and every other format the spatial extension's GDAL build reads (KML, GML, MapInfo TAB, DXF, ...) - Small data → GeoJSON - small datasets are converted to GeoJSON and rendered with a
geojsonsource - Large data → dynamic tiles - large datasets are rendered as MVT tiles generated client-side by DuckDB per
z/x/y, served through aduckdb://protocol handler (reference approach) - Auto mode with override - render mode is picked automatically from configurable feature-count/byte-size thresholds, with per-layer and UI overrides
- Lazy DuckDB loading - DuckDB-WASM (~15-25 MB gzipped) is loaded from the jsDelivr CDN only when a non-GeoJSON format or tile rendering is first requested; GeoJSON-only usage never downloads it
- Collapsible panel UI - 29x29 toggle button matching MapLibre controls, with drag-and-drop file upload, URL loading, a layer list (visibility / zoom / remove), a per-layer style editor (colors, render mode, popup toggle, layer placement), dark mode support, and viewport-aware scrolling on small screens
- Attribute picker - clicking a feature opens a popup with its attributes (
enablePicker, on by default) - Programmatic API -
addData(),removeLayer(),setLayerStyle(),setRenderMode(), events, and more - React support -
VectorControlReactwrapper anduseVectorStatehook - GeoLibre plugin bundle - builds a zip loadable by GeoLibre Desktop
Installation
npm install maplibre-gl-vectorQuick Start
Vanilla JavaScript/TypeScript
import maplibregl from "maplibre-gl";
import { VectorControl } from "maplibre-gl-vector";
import "maplibre-gl-vector/style.css";
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.openfreemap.org/styles/positron",
center: [0, 0],
zoom: 2,
});
map.on("load", async () => {
const control = new VectorControl({ collapsed: false });
map.addControl(control, "top-right");
// GeoJSON renders without DuckDB
await control.addData("https://example.com/data.geojson");
// Other formats load DuckDB-WASM from the CDN on first use
await control.addData("https://example.com/buildings.parquet");
// Force dynamic tiles for a large dataset
await control.addData("https://example.com/roads.gpkg", {
renderMode: "tiles",
});
});React
import { useEffect, useRef, useState } from "react";
import maplibregl, { Map } from "maplibre-gl";
import { VectorControlReact, useVectorState } from "maplibre-gl-vector/react";
import type { VectorControl } from "maplibre-gl-vector";
import "maplibre-gl-vector/style.css";
function App() {
const mapContainer = useRef<HTMLDivElement>(null);
const controlRef = useRef<VectorControl | null>(null);
const [map, setMap] = useState<Map | null>(null);
const { state, toggle } = useVectorState();
useEffect(() => {
if (!mapContainer.current) return;
const mapInstance = new maplibregl.Map({
container: mapContainer.current,
style: "https://tiles.openfreemap.org/styles/positron",
center: [0, 0],
zoom: 2,
});
mapInstance.on("load", () => setMap(mapInstance));
return () => mapInstance.remove();
}, []);
return (
<div style={{ width: "100%", height: "100vh" }}>
<div ref={mapContainer} style={{ width: "100%", height: "100%" }} />
{map && (
<VectorControlReact
map={map}
controlRef={controlRef}
collapsed={state.collapsed}
onLayerAdded={(layer) => console.log("added", layer.name)}
onError={(error) => console.error(error)}
/>
)}
<button onClick={() => controlRef.current?.addData("data.parquet")}>
Load data
</button>
</div>
);
}How it works
addData(url | File | GeoJSON)
├─ .geojson / GeoJSON object ──→ parsed in JS ──→ geojson source (no DuckDB)
└─ .gpkg / .zip / .parquet / .fgb / .csv
└─ DuckDB-WASM + spatial (lazy-loaded from jsDelivr)
├─ small (≤ thresholds) ──→ ST_AsGeoJSON ──→ geojson source
└─ large (> thresholds) ──→ ST_AsMVT per z/x/y tile
└─ vector source: duckdb://{id}/{z}/{x}/{y}For tile rendering, the data is transformed once to EPSG:3857 (ST_Transform), indexed with an R-Tree, and each tile request runs:
SELECT ST_AsMVT({geometry: ST_AsMVTGeom(geom_3857, ST_Extent(ST_TileEnvelope(z, x, y))), ...}, 'layer')
FROM data
WHERE ST_Intersects(geom_3857, ST_TileEnvelope(z, x, y));On DuckDB builds without ST_AsMVT, the plugin falls back to encoding tiles in JavaScript with geojson-vt + vt-pbf (also lazy-loaded).
Streaming large GeoParquet
By default a dataset is materialized into an in-memory DuckDB table (plus an EPSG:3857 column and R-Tree index in tiles mode) - fastest per-tile queries, but peak memory roughly equals the dataset size. For large GeoParquet you can instead stream it in place:
await control.addData("https://example.com/buildings.parquet", {
ingestMode: "stream",
renderMode: "tiles",
});(or check "Stream GeoParquet (no copy)" in the panel before loading)
If a producer omitted or misreported the source coordinate system, enter a Source CRS override in the panel before loading, or pass it explicitly:
await control.addData(file, { sourceCrs: "EPSG:28992" });The geometry is reprojected to WGS84 during either table or streaming ingest. Streaming sources with an override use geometry filtering because a covering bbox column remains expressed in the original CRS and cannot safely prune WGS84 tile queries.
In streaming mode the file is wrapped in a view and queried directly - nothing is copied into the database. Remote files are read with HTTP range requests, and when the file has a GeoParquet 1.1 bbox covering column (named bbox or anything ending in _bbox, e.g. geometry_bbox), the per-tile filter is pushed into parquet row-group statistics so only the row groups intersecting each tile are downloaded. The layer summary (count/extent) also comes from the bbox stats instead of a geometry scan.
Trade-offs: each tile re-reads and reprojects matching rows (slower than the indexed table), and files without a bbox covering column or spatial ordering fall back to scanning per tile. Streaming applies to GeoParquet only; other formats ignore the option and materialize.
Remote file size limit: DuckDB-WASM cannot open remote files of 2 GiB or larger (its HTTP filesystem handles sizes as 32-bit values). The control probes the size with a HEAD request and reports a clear error; split larger datasets into partitions under 2 GiB.
Spatial ordering matters. Row-group pruning only helps when nearby features share row groups. In testing with a 205 MB / 6.5M-feature global grid, the row-major (unsorted) file took 30-90 s per tile, while the same data Hilbert-sorted with small row groups served tiles in 60-400 ms. Sort with DuckDB:
INSTALL spatial; LOAD spatial;
COPY (
SELECT * FROM 'input.parquet'
ORDER BY ST_Hilbert(geometry, ST_Extent(ST_MakeEnvelope(-180, -90, 180, 90)))
) TO 'sorted.parquet' (FORMAT PARQUET, COMPRESSION 'zstd', ROW_GROUP_SIZE 30000);The example hardcodes global WGS84 bounds because that dataset is global; for regional data, derive the Hilbert bounds from the data itself:
COPY (
WITH b AS (SELECT ST_Extent(ST_Extent_Agg(geometry)) AS box FROM 'input.parquet')
SELECT * FROM 'input.parquet'
ORDER BY ST_Hilbert(geometry, (SELECT box FROM b))
) TO 'sorted.parquet' (FORMAT PARQUET, COMPRESSION 'zstd', ROW_GROUP_SIZE 30000);Keep row groups small (20k-50k rows) so pruning stays fine-grained, and prefer COMPRESSION 'zstd' - it decompresses fast and shrinks the ranges each tile downloads. The bbox covering column is carried through unchanged. (GeoPandas alternative: sort by gdf.hilbert_distance() and write with to_parquet(write_covering_bbox=True, compression='zstd', row_group_size=30000).)
Size thresholds
In the default 'auto' render mode, a dataset is rendered as dynamic tiles when it exceeds 50,000 features or 25 MB (whichever trips first); otherwise it is converted to GeoJSON. Both limits are configurable, and a per-layer renderMode always wins:
const control = new VectorControl({
// Switch to tiles above 10k features or 5 MB
autoThreshold: { featureCount: 10_000, byteSize: 5 * 1024 * 1024 },
});
// Or bypass the thresholds for one layer
await control.addData(url, { renderMode: "tiles" });Supported formats
| Format | Extensions | Reader | Local files | URLs |
| --- | --- | --- | --- | --- |
| GeoJSON | .geojson, .json | Pure JS (or ST_Read in tiles mode) | ✅ | ✅ |
| GeoParquet | .parquet, .geoparquet, .pq | read_parquet (HTTP range reads) | ✅ | ✅ |
| GeoPackage | .gpkg | ST_Read (GDAL) | ✅ | ✅ |
| Shapefile | .zip (zipped), or .shp with its sidecars | ST_Read (GDAL, /vsizip/) | ✅ | ✅ |
| FlatGeobuf | .fgb | ST_Read (GDAL) | ✅ | ✅ |
| CSV | .csv, .tsv | read_csv + WKT or lon/lat columns | ✅ | ✅ |
| KMZ | .kmz | Unzipped, then ST_Read (GDAL) on the KML inside | ✅ | ✅ |
| Anything GDAL reads | .kml, .gml, .tab, .dxf, ... | ST_Read (GDAL) | ✅ | ✅ |
Extensions without a dedicated reader are passed straight to ST_Read, so any vector format the spatial extension's GDAL build supports will load. Remote URLs must be served with CORS enabled.
Multi-layer datasets
Containers that hold several layers (a GeoPackage with multiple tables, KML folders, GML/DXF layers, ...) are expanded into one vector layer per source layer, each with its own panel entry, visibility toggle, and style. The control enumerates the layers with ST_Read_Meta (with sql.js for GeoPackages), the map zooms once to the combined extent, and the underlying file is registered with DuckDB only once.
Because a container can hold far more layers than the user wants on the map, loading one opens a layer picker: a modal over the map listing every layer with a checkbox, all preselected. Confirming loads the checked layers; dismissing it loads nothing and rejects the addData promise with a VectorLayerSelectionCancelledError (nothing failed, so no 'error' event is emitted -- use isVectorLayerSelectionCancelled(error) to tell the two apart).
// `maplibre-gl-vector/errors` is a standalone entry point, so a host that
// lazy-loads the control does not pull the engine bundle in just to import it.
import { isVectorLayerSelectionCancelled } from "maplibre-gl-vector/errors";
try {
await control.addData("city.gpkg");
} catch (error) {
if (!isVectorLayerSelectionCancelled(error)) throw error;
}To load just one layer from a container, pass sourceLayer; to load a known subset without prompting, pass sourceLayers. Both skip the picker, so a host restoring a saved project never prompts:
await control.addData("city.gpkg", { sourceLayer: "roads" });
await control.addData("city.gpkg", { sourceLayers: ["roads", "parks"] });A host can replace the picker with its own dialog, or turn the prompt off, through the control-level selectLayers option:
// Host dialog: return the chosen names, [] to cancel, or null to load all.
new VectorControl({
selectLayers: (layers, { sourceName }) => myLayerDialog(sourceName, layers),
});
// No prompt: load every layer, as releases before 0.10 did.
new VectorControl({ selectLayers: false });Single-layer formats (GeoJSON, GeoParquet, CSV) skip the enumeration entirely. CSV files need either a WKT column (geometry, wkt, geom, the_geom, wkb_geometry) or lon/lat columns (longitude/latitude, lon/lat, lng/lat, x/y).
Restorable local files
A layer loaded from a URL can be recreated from its VectorLayerInfo.source when a host restores a saved project, but a File/Blob cannot: the browser exposes no path to re-read it. Two hooks let a host make local files restorable:
Desktop hosts can provide a
fileOpenerthat opens a native dialog and returns each file with the absolutesourcePathit was read from. That path is echoed onsource.path, so the host can persist it and re-read the file (addData(file, { sourcePath })) on reopen.new VectorControl({ fileOpener: async () => { const paths = await openNativeDialog(); // host-specific, returns string[] return Promise.all( paths.map(async (path) => ({ file: await readFileAsFile(path), sourcePath: path })), ); }, });Web hosts (no filesystem path) can instead embed the data: call
getLayerGeoJSON(id)at save time to materialize the features, store them in the project, and replay them withaddData(featureCollection, { renderMode })on reopen.
Self-hosting DuckDB-WASM
By default the plugin loads DuckDB-WASM from cdn.jsdelivr.net on first use. If you serve the app with a Content-Security-Policy, that requires allowing https://cdn.jsdelivr.net in script-src (the engine is loaded via a dynamic import()); if the CDN is blocked or unreachable, loading fails with Failed to fetch dynamically imported module.
To avoid the CDN entirely, mirror the pinned duckdb-wasm assets onto your own origin and point the control at them with duckdbWasmBaseUrl:
new VectorControl({
// Serves /+esm and /dist/* just like jsDelivr, from your origin
duckdbWasmBaseUrl: "/vendor/duckdb-wasm-1.31.0",
});The base must mirror jsDelivr's layout for the pinned version (currently 1.31.0): an /+esm ES-module bundle plus the /dist/*.wasm and worker files. The simplest way is to copy the files from https://cdn.jsdelivr.net/npm/@duckdb/[email protected]/ (including the +esm bundle) into a directory your server hosts. The version is pinned deliberately because its DuckDB core ships ST_AsMVT; do not substitute a different version.
API
VectorControl
Constructor Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| collapsed | boolean | true | Whether the panel starts collapsed (showing only the 29x29 toggle button) |
| position | string | 'top-right' | Control position on the map |
| title | string | 'Vector Data' | Title displayed in the header |
| panelWidth | number | 320 | Width of the dropdown panel in pixels |
| className | string | '' | Custom CSS class name |
| autoThreshold | { featureCount?, byteSize? } | { featureCount: 50000, byteSize: 25 MB } | Thresholds that switch 'auto' mode to tiles |
| defaultRenderMode | 'auto' \| 'geojson' \| 'tiles' | 'auto' | Default render mode for new layers |
| maxTileZoom | number | 16 | Maximum zoom for dynamic tile generation |
| attribution | string | - | Attribution attached to created sources |
| beforeId | string | - | Existing map layer id new layers are inserted before (e.g. a label layer) |
| enablePicker | boolean | true | Click a feature to open a popup with its attributes |
| defaultIngestMode | 'table' \| 'stream' | 'table' | Materialize into DuckDB or stream GeoParquet in place |
| urlPlaceholder | string | 'https://example.com/data.parquet' | Placeholder text for the panel's URL input |
| defaultUrl | string | - | Initial value of the panel's URL input (a ready-to-load sample dataset) |
| autoLoad | boolean | false | Load defaultUrl automatically when the control is added to the map |
| closeOnOutsideClick | boolean | true | Collapse the panel when clicking outside it; set false to close only via the header button |
| resizable | boolean | false | Show bottom-left and bottom-right drag handles so the panel can be resized |
| sampleData | VectorSampleDataset[] | - | Sample datasets shown as a "Load sample data" dropdown below the URL input; picking one fills the input and waits for the user to click Load (hidden when empty) |
| sampleDataLabel | string | 'Load sample data...' | Placeholder shown in the sample-data dropdown |
| duckdbWasmBaseUrl | string | jsDelivr | Base URL to load DuckDB-WASM from instead of the CDN (see Self-hosting DuckDB-WASM) |
| fileOpener | () => VectorFileSelection[] \| null \| Promise<...> | - | Replace the panel's built-in file browse with a host picker. Each returned selection's sourcePath is echoed on the layer's source so a desktop host can persist and re-read local files (see Restorable local files) |
| selectLayers | VectorLayerSelector \| false | built-in picker | How the layers of a multi-layer container are chosen: a host callback, or false to load every layer without prompting (see Multi-layer datasets) |
Data Methods
addData(source, options?)- Load a URL,File/Blob, or GeoJSON object; resolves with the layer'sVectorLayerInforemoveLayer(id)/removeAll()- Remove layersgetLayers()/getLayer(id)- Layer metadatagetLayerGeoJSON(id)- Materialize a layer's features as a GeoJSONFeatureCollection(from the cached collection, the DuckDB table, or the map source), so a host can persist a local-file layer's data; resolvesnullwhen the data is not held locally (e.g. a streamed GeoParquet)setLayerVisibility(id, visible)- Show/hide a layerzoomToLayer(id)- Fit the map to a layer's extentsetLayerStyle(id, style)- Update colors, opacity, line width, circle radiussetLayerOpacity(id, opacity)- Master opacity (0-1) multiplied into every style opacitysetRenderMode(id, mode)- Switch between'geojson'and'tiles'reloadLayer(id)- Re-fetch a URL-backed layer and re-render it in place (keeps the same id, style, and position; no-op for File/GeoJSON-object sources)setLayerPicker(id, enabled)- Toggle the attribute popup (also a "Popup" checkbox in the panel)setLayerBeforeId(id, beforeId?)- Move the layer before another map layer, or to the top (also a "Before" select in the panel)
Layer Options (addData)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| id | string | auto | Unique layer id |
| name | string | file name | Display name |
| renderMode | 'auto' \| 'geojson' \| 'tiles' | 'auto' | Per-layer render mode |
| visible | boolean | true | Initial visibility |
| opacity | number | 1 | Initial master opacity (0-1) multiplied into every style opacity |
| fitBounds | boolean | true | Zoom to the layer after loading |
| style | Partial<VectorLayerStyle> | defaults | Initial style overrides |
| sourceLayer | string | - | Load only this layer from a multi-layer container (skips the picker) |
| sourceLayers | string[] | - | Load this subset of a multi-layer container's layers, one vector layer each (skips the picker) |
| sourceCrs | string | embedded CRS | Override the source coordinate system, for example EPSG:28992, and reproject to WGS84 during ingest |
| format | VectorFormat | detected | Explicit format override |
| beforeId | string | control option | Map layer id this layer is inserted before |
| picker | boolean | control option | Attribute popup on feature click |
| ingestMode | 'table' \| 'stream' | control option | Stream GeoParquet in place instead of copying it into DuckDB |
| sourcePath | string | - | Host-meaningful path a File/Blob was read from (e.g. an absolute path on a desktop host). Echoed unchanged on VectorLayerInfo.source.path so a host can persist it and re-read the file; ignored for URL and GeoJSON-object sources |
Panel Methods
toggle()/expand()/collapse()- Panel visibilitygetState()/setState(state)- Control state (includeslayers)on(event, handler)/off(event, handler)- Event handlers
Events
layeradded,layerremoved,layerupdated- Layer lifecycle (payload includeslayer)loading- Progress messages (payload includesmessage)error- Loading failures (payload includeserror)collapse,expand,statechange- Panel state
VectorControlReact
All VectorControl options plus:
| Prop | Type | Description |
| --- | --- | --- |
| map | Map | MapLibre GL map instance (required) |
| controlRef | Ref<VectorControl> | Receives the control instance for programmatic use |
| onStateChange | function | Fired when the state changes |
| onLayerAdded / onLayerRemoved | function | Layer lifecycle callbacks |
| onError | function | Loading failure callback |
useVectorState
const {
state, // { collapsed, panelWidth, layers, data }
setState, // Update entire state
setCollapsed, // Set collapsed state
setPanelWidth, // Set panel width
setData, // Set custom data
reset, // Reset to initial state
toggle, // Toggle collapsed state
} = useVectorState(initialState);DuckDB-WASM notes
- DuckDB-WASM is pinned to a version whose core is DuckDB ≥ 1.4 (the first with
ST_AsMVT). The plugin probes the capability at startup and falls back to JS tile encoding when missing. - The WASM bundles (~15-25 MB gzipped) and the spatial extension are fetched from
cdn.jsdelivr.netandextensions.duckdb.orgon first use. The panel shows loading progress. - Remote GeoParquet is read with HTTP range requests - only the needed row groups are downloaded.
- All queries (ingestion and tile generation) run on a single connection through a serialized queue; aborted tile requests (fast panning) are skipped before execution.
Build a GeoLibre plugin zip
GeoLibre Desktop loads external plugins from an app data plugins/ directory. The zip must contain plugin.json at the root, plus a bundled ESM entry and optional CSS file.
npm install
npm run package:geolibreThis creates geolibre-plugin/maplibre-gl-vector-0.1.0.zip. Copy it into GeoLibre Desktop's app data plugins/ directory and restart GeoLibre. On Linux with the default app identifier:
~/.local/share/org.geolibre.desktop/plugins/For the GeoLibre web app, serve the unpacked plugin with CORS enabled:
npm run package:geolibre
npm run serve:geolibre -- 8000Then add http://localhost:8000/plugin.json in GeoLibre Settings > Plugins.
Development
Setup
git clone https://github.com/opengeos/maplibre-gl-vector.git
cd maplibre-gl-vector
npm install
npm run devScripts
| Script | Description |
| --- | --- |
| npm run dev | Start development server |
| npm run build | Build the library and GeoLibre bundle |
| npm run build:lib | Build the standalone MapLibre library |
| npm run build:geolibre | Build the GeoLibre ESM and CSS bundle |
| npm run package:geolibre | Build and zip the GeoLibre plugin bundle |
| npm run build:examples | Build examples for deployment |
| npm run test | Run tests |
| npm run test:ui | Run tests with UI |
| npm run test:coverage | Run tests with coverage |
| npm run lint | Lint the code |
| npm run format | Format the code |
Project Structure
maplibre-gl-vector/
├── geolibre-plugin/
│ └── plugin.json # GeoLibre external plugin manifest
├── scripts/ # Packaging/serving helpers
├── src/
│ ├── index.ts # Main entry point
│ ├── geolibre.ts # GeoLibre plugin wrapper entry point
│ ├── react.ts # React entry point
│ ├── index.css # Root styles
│ └── lib/
│ ├── core/ # VectorControl, LayerManager, types
│ ├── engine/ # DuckDB-WASM loader, engine, SQL builders
│ ├── formats/ # Format detection
│ ├── tiles/ # duckdb:// protocol, MVT fallback
│ ├── render/ # Render mode, sources/layers, styling
│ ├── ui/ # Panel UI (vanilla DOM)
│ ├── hooks/ # React hooks
│ ├── utils/ # Utility functions
│ └── styles/ # Component styles
├── tests/ # Vitest unit tests
├── examples/ # Example applications
│ ├── basic/ # Vanilla TS example
│ ├── react/ # React example
│ └── data/ # Small sample data
└── .github/workflows/ # CI/CD workflowsDocker
The examples can be run using Docker. The image is automatically built and published to GitHub Container Registry.
docker pull ghcr.io/opengeos/maplibre-gl-vector:latest
docker run -p 8080:80 ghcr.io/opengeos/maplibre-gl-vector:latestThen open http://localhost:8080/maplibre-gl-vector/ in your browser to view the examples.
Build Locally
docker build -t maplibre-gl-vector .
docker run -p 8080:80 maplibre-gl-vectorAcknowledgements
- Dynamic tile approach based on Max Gabrielsson's DuckDB vector tile server gist
- Client-side MVT generation pattern validated by rot1024/duckdb-wasm-mvt
- Sample datasets from opengeos/datasets
License
MIT License - see LICENSE for details.
