Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
119e381
add a proper memory bench
mourner May 14, 2026
98dcfb7
canonic winding, first-class TS, lazy tile materialize
mourner May 14, 2026
fecad72
cleaner public types
mourner May 14, 2026
8e9f5ea
more elaborate, reliable benchmark
mourner May 15, 2026
c242729
simplify type / size handling
mourner May 15, 2026
351b3cd
fix package lock
mourner May 15, 2026
09eae17
switch to flat line/poly geometry
mourner May 15, 2026
74c8303
presize geometry arrays
mourner May 15, 2026
12ad28a
presize wrap world copies
mourner May 15, 2026
d3aa61c
delete test-clip.js (not useful)
mourner May 15, 2026
495b5c2
lazy-init clipped
mourner May 15, 2026
9a0685c
harden benchmark
mourner May 17, 2026
df3c88a
optimize single points with specialized shape
mourner May 17, 2026
a9ba6c4
optimize committed shape (typed)
mourner May 17, 2026
4b92c59
simplify typed line optimization
mourner May 17, 2026
7cd2af0
2-pass clip + typed arrays everywhere
mourner May 17, 2026
4068732
calculate bbox only for outer ring
mourner May 17, 2026
748a14d
use Int32 for coords end to end when it fits
mourner May 17, 2026
2de04be
small correctness fixes
mourner May 18, 2026
993f6d0
export class as default, drop internal tile fields
mourner May 18, 2026
8909ef2
small cleanup
mourner May 18, 2026
32ff2cd
add more tests
mourner May 18, 2026
f5702d6
cover with strict TypeScript types
mourner May 18, 2026
6b30326
stricter typing
mourner May 18, 2026
fb32943
small perf fix
mourner May 18, 2026
2050b03
cosmestic changes
mourner May 18, 2026
ee00756
Merge remote-tracking branch 'origin/main' into v5-new
mourner Sep 2, 2026
236b58a
various fixes
mourner Sep 2, 2026
3284a2d
add type checking tests, cleanup
mourner Sep 2, 2026
bc16b9f
add a test for empty multipolygon rings
mourner Sep 2, 2026
20cc5f9
add missing types check to tsconfig
mourner Sep 2, 2026
690964f
upgrade dev deps
mourner Sep 2, 2026
e251b88
add getTileRaw for zero-copy return
mourner Sep 2, 2026
7e7fb01
various simplifications
mourner Sep 2, 2026
b7b48be
fix minor edge cases
mourner Sep 2, 2026
5e11cca
add CI permissions
mourner Sep 3, 2026
86d879e
update actions
mourner Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
name: Node
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2

- name: Setup Node
uses: actions/setup-node@v1
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20

- name: Install dependencies
run: npm ci

- name: Build the bundle
run: npm run build

- name: Run tests
run: npm test
node-version: 24
- run: npm ci
- run: npm run build
- run: npm test
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ debug/data/hrr.json
debug/data/hsa.json
debug/data/idaho.json
debug/data/zips.json
debug/data/gz_*
debug/data/county.json
debug/data/earthquakes.json
debug/data/cities.json
debug/data/ne*.json
geojson-vt-dev.js
geojson-vt.js
node_modules
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ISC License

Copyright (c) 2024, Mapbox
Copyright (c) 2026, Mapbox

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
Expand Down
40 changes: 35 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,37 @@ Just drag any GeoJSON on the page, watching the console.

```js
// build an initial index of tiles
var tileIndex = geojsonvt(geoJSON);
const tileIndex = new GeoJSONVT(geoJSON);

// request a particular tile
var features = tileIndex.getTile(z, x, y).features;
const features = tileIndex.getTile(z, x, y).features;

// show an array of tile coordinates created so far
console.log(tileIndex.tileCoords); // [{z: 0, x: 0, y: 0}, ...]
```

`getTile` returns `null` outside the data, and builds a fresh `[x, y]`-pair structure on every call.
For a zero-copy alternative, `getTileRaw` hands back the tile as stored — flat
`[x, y, x, y, ...]` typed arrays, one per ring, with no per-coordinate objects:

```js
const {features} = tileIndex.getTileRaw(z, x, y);

for (const {type, geometry} of features) {
// type 4 is a lone point, with `x`/`y` inline instead of a `geometry`;
// 1 is a multi-point (one flat array), 2 and 3 are lines and polygons (an array of rings)
}
```

The arrays belong to the index, so treat them as read-only and don't hold them past its lifetime.

### Options

You can fine-tune the results with an options object,
although the defaults are sensible and work well for most use cases.

```js
var tileIndex = geojsonvt(data, {
const tileIndex = new GeoJSONVT(data, {
maxZoom: 14, // max zoom to preserve detail on; can't be higher than 24
tolerance: 3, // simplification tolerance (higher means simpler)
extent: 4096, // tile extent (both width and height)
Expand All @@ -76,14 +91,29 @@ Install using NPM (`npm install geojson-vt`), then:

```js
// import as a ES module
import geojsonvt from 'geojson-vt';
import GeoJSONVT from 'geojson-vt';

// import from a CDN in the browser:
import geojsonvt from 'https://esm.run/geojson-vt';
import GeoJSONVT from 'https://esm.run/geojson-vt';
```

Or use a browser build directly:

```html
<script src="https://unpkg.com/geojson-vt/geojson-vt.js"></script>
<script>
const tileIndex = new GeoJSONVT(geoJSON); // exposed as a `GeoJSONVT` global
</script>
```

### TypeScript

Type declarations ship with the library; remove `@types/geojson-vt` if you have it.

```ts
import GeoJSONVT, {type Options, type LegacyFeature} from 'geojson-vt';

const options: Options = {maxZoom: 12};
const tile = new GeoJSONVT(geoJSON, options).getTile(0, 0, 0); // null outside the data
const features: LegacyFeature[] = tile ? tile.features : [];
```
182 changes: 182 additions & 0 deletions bench/bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Memory + speed benchmark for geojson-vt.
//
// Run: npm run bench
// or: node --expose-gc bench/bench.js [dataset1 dataset2 ...]
//
// Two phases per dataset:
// init — new GeoJSONVT(data, {})
// deep — new GeoJSONVT(data, {indexMaxZoom: 10, indexMaxPoints: 1000})
//
// `deep` is a realistic "pre-tile more aggressively" config: deeper indexing into dense areas,
// but `indexMaxPoints` still bounds it so sparse regions stop early. Use `indexMaxPoints: 0`
// for full every-tile pre-tiling (stress test, not a real workload).
//
// Columns per phase:
// ms median build time
// alloc held + Σ(bytes freed by GC during build) — total allocation
// peak max usedHeapSize before any in-build GC (proxy for high-water mark)
// held heap + external after multi-pass forced GC
//
// `alloc` and `peak` come from `v8.GCProfiler` rather than snapshots — naïve heap_used/RSS
// sampling misses GCs and underreports both. Some GCs near measurement boundaries are still
// missed, so we take 3 iterations and report max for alloc/peak (missed GCs only undercount).
//
// Each dataset runs in its own `node --expose-gc` child for a clean baseline. A warmup build
// before measurement stabilizes the heap after JSON.parse fragmentation. Numbers aren't
// comparable across machines, but are stable across geojson-vt versions on the same machine.

/* eslint no-void: 0, no-await-in-loop: 0 */

import {readFileSync, existsSync} from 'fs';
import {performance} from 'perf_hooks';
import {spawn} from 'child_process';
import {fileURLToPath} from 'url';
import v8 from 'v8';
import GeoJSONVT from '../src/index.js';

const __filename = fileURLToPath(import.meta.url);

const DEEP_OPTS = {indexMaxZoom: 10, indexMaxPoints: 1000};

const DATASETS = [
{name: 'earthquakes', file: 'debug/data/earthquakes.json'},
{name: 'places', file: 'debug/data/ne_10m_populated_places_simple.json'},
{name: 'route', file: 'debug/data/route.json'},
{name: 'hrr', file: 'debug/data/hrr.json'},
{name: 'us-2010', file: 'debug/data/gz_2010_us_050_00_500k.json'},
{name: 'county', file: 'debug/data/county.json'}
];

const ITER = 3;

if (process.argv.includes('--single')) {
if (!global.gc) { console.error('child needs --expose-gc'); process.exit(2); }
const name = process.argv[process.argv.indexOf('--single') + 1];
const ds = DATASETS.find(d => d.name === name);
if (!ds) { console.error(`unknown dataset: ${name}`); process.exit(2); }
try {
process.stdout.write(`${JSON.stringify(runOne(ds))}\n`);
} catch (e) {
process.stdout.write(`${JSON.stringify({name: ds.name, error: e.message})}\n`);
process.exit(1);
}
} else {
const want = new Set(process.argv.slice(2).filter(a => !a.startsWith('-')));
const datasets = DATASETS.filter((d) => {
if (want.size && !want.has(d.name)) return false;
const ok = existsSync(new URL(`../${d.file}`, import.meta.url));
if (!ok) console.error(`skipping ${d.name}: ${d.file} not found`);
return ok;
});
const results = [];
for (const ds of datasets) {
process.stderr.write(`${ds.name}… `);
const r = await spawnChild(ds.name);
results.push(r);
process.stderr.write(r.error ? `error: ${r.error}\n` : 'done\n');
}
printTable(results);
}

// ──────────────────────────── child ───────────────────────────────

function settle() { for (let i = 0; i < 8; i++) global.gc(); }
function snap() { const m = process.memoryUsage(); return m.heapUsed + m.external; }

function measureBuild(data, opts, baseline) {
const samples = [];
for (let i = 0; i < ITER; i++) {
const prof = new v8.GCProfiler();
prof.start();
const t0 = performance.now();
const idx = new GeoJSONVT(data, opts);
const ms = performance.now() - t0;
const stats = prof.stop().statistics;
const postHeap = process.memoryUsage().heapUsed;
const postExt = process.memoryUsage().external;
settle();
const held = snap() - baseline;

let freed = 0;
let peakHeap = postHeap;
for (const e of stats) {
const before = e.beforeGC.heapStatistics.usedHeapSize;
const after = e.afterGC.heapStatistics.usedHeapSize;
if (before > after) freed += before - after;
if (before > peakHeap) peakHeap = before;
}

samples.push({ms, alloc: held + freed, peak: peakHeap + postExt - baseline, held});
void idx;
settle();
}
const max = key => Math.max(...samples.map(s => s[key]));
const median = (key) => {
const sorted = samples.map(s => s[key]).sort((a, b) => a - b);
return sorted[sorted.length >> 1];
};
return {ms: median('ms'), alloc: max('alloc'), peak: max('peak'), held: median('held')};
}

function runOne(ds) {
const data = JSON.parse(readFileSync(new URL(`../${ds.file}`, import.meta.url), 'utf8'));

void new GeoJSONVT(data, {}); // warmup
settle();
const init = measureBuild(data, {}, snap());

void new GeoJSONVT(data, DEEP_OPTS);
settle();
const deep = measureBuild(data, DEEP_OPTS, snap());
return {name: ds.name, init, deep};
}

// ──────────────────────────── parent ──────────────────────────────

function spawnChild(name) {
return new Promise((resolve) => {
const args = ['--expose-gc', '--max-old-space-size=8192', __filename, '--single', name];
const child = spawn(process.execPath, args, {stdio: ['ignore', 'pipe', 'inherit']});
let out = '';
child.stdout.on('data', (d) => { out += d; });
child.on('close', (code) => {
const line = out.trim().split('\n').pop();
try { resolve(JSON.parse(line)); } catch (e) { resolve({name, error: `bad child output (code=${code}): ${e.message}`}); }
});
});
}

function fmtBytes(b) {
if (b == null) return '—';
const mb = b / (1024 * 1024);
if (mb < 1) return `${(b / 1024).toFixed(0)} KB`;
if (mb < 100) return `${mb.toFixed(1)} MB`;
return `${mb.toFixed(0)} MB`;
}
function fmtMs(t) { return t == null ? '—' : t.toFixed(1); }

function printTable(results) {
const cols = [
{h: 'dataset', w: 12, get: r => r.name},
{h: 'init ms', w: 9, get: r => fmtMs(r.init?.ms)},
{h: 'init alloc', w: 11, get: r => fmtBytes(r.init?.alloc)},
{h: 'init peak', w: 11, get: r => fmtBytes(r.init?.peak)},
{h: 'init held', w: 11, get: r => fmtBytes(r.init?.held)},
{h: 'deep ms', w: 9, get: r => fmtMs(r.deep?.ms)},
{h: 'deep alloc', w: 11, get: r => fmtBytes(r.deep?.alloc)},
{h: 'deep peak', w: 11, get: r => fmtBytes(r.deep?.peak)},
{h: 'deep held', w: 11, get: r => fmtBytes(r.deep?.held)}
];
const pad = (s, n) => String(s).padStart(n);
const row = arr => arr.map((s, i) => pad(s, cols[i].w)).join(' ');

console.log();
console.log(row(cols.map(c => c.h)));
console.log('-'.repeat(cols.reduce((s, c) => s + c.w + 1, -1)));
for (const r of results) console.log(row(cols.map(c => c.get(r) ?? '—')));
console.log();
console.log(`deep = new GeoJSONVT(data, {indexMaxZoom: ${DEEP_OPTS.indexMaxZoom}, indexMaxPoints: ${DEEP_OPTS.indexMaxPoints}})`);
console.log('alloc = held + Σ(bytes freed by GC during build)');
console.log('peak = max usedHeapSize before any in-build GC');
console.log('held = heap + external after multi-pass forced GC');
}
17 changes: 3 additions & 14 deletions debug/debug.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,14 @@

import geojsonvt from '../src/index.js';
import {getHeapStatistics} from 'v8';
import GeoJSONVT from '../src/index.js';
import {readFileSync} from 'fs';

console.time('load data');
const data = JSON.parse(readFileSync(new URL('data/hrr.json', import.meta.url)));
console.timeEnd('load data');

global.gc();
const size = getHeapStatistics().used_heap_size;

const tileIndex = geojsonvt(data, {
debug: 1
const tileIndex = new GeoJSONVT(data, {
debug: 1
});

global.gc();
console.log(`memory used: ${ Math.round((getHeapStatistics().used_heap_size - size) / 1024) } KB`);

console.time('drill down');
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 10; j++) {
Expand All @@ -37,9 +29,6 @@ console.timeEnd('drill down');

console.log('tiles generated:', tileIndex.total, JSON.stringify(tileIndex.stats));

global.gc();
console.log(`memory used: ${ Math.round((getHeapStatistics().used_heap_size - size) / 1024) } KB`);

// tileIndex.maxZoom = 14;
// tileIndex.getTile(14, 4100, 6200);
// tileIndex.getTile(14, 4100, 6000);
2 changes: 1 addition & 1 deletion debug/viz.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ canvas.ondrop = function (e) {
data = topojson.feature(data, data.objects[firstKey]);
}

tileIndex = geojsonvt(data, options); //eslint-disable-line
tileIndex = new GeoJSONVT(data, options); //eslint-disable-line

drawTile();
};
Expand Down
Loading