diff --git a/.gitignore b/.gitignore
index f5e1fe1..2d3bb10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ geojson-vt.js
node_modules
coverage
.nyc_output
+.idea
diff --git a/README.md b/README.md
index 2ae8fb9..bb396ab 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ var tileIndex = geojsonvt(data, {
lineMetrics: false, // whether to enable line metrics tracking for LineString/MultiLineString features
promoteId: null, // name of a feature property to promote to feature.id. Cannot be used with `generateId`
generateId: false, // whether to generate feature ids. Cannot be used with `promoteId`
+ updateable: false, // whether the tile index can be updated (with the caveat of a stored simplified copy)
indexMaxZoom: 5, // max zoom in the initial tile index
indexMaxPoints: 100000 // max number of points per tile in the index
});
@@ -70,6 +71,36 @@ The `promoteId` and `generateId` options ignore existing `id` values on the feat
GeoJSON-VT only operates on zoom levels up to 24.
+### Update
+
+For incremental updates to the tile index, you can use `updateData` to change features without having to recreate a fresh index. `updateData` takes a diff object as a parameter with the following properties:
+
+```js
+var diff = {
+ removeAll: false, // set to true to clear all features
+ remove: ['id1', 'id2'], // array of feature ids to remove
+ add: [feature1, feature2], // array of GeoJSON features to add
+ update: [ // array of feature update objects
+ {
+ id: 'feature1', // required - id of feature to update
+ newGeometry: {type: 'Point', ...}, // new geometry for the feature
+ removeAllProperties: false, // remove all properties
+ removeProperties: ['prop1', 'prop2'], // array of property keys to remove
+ addOrUpdateProperties: [ // array of properties to add/update
+ {key: 'name', value: 'New Name'},
+ {key: 'population', value: 5000}
+ ]
+ }
+ ]
+};
+
+tileIndex.updateData(diff);
+```
+
+All properties in the diff are optional, but at least one operation should be specified. Remove operations are applied before add/update operations.
+
+To use `updateData`, the index must be created with the `updateable: true` option.
+
### Install
Install using NPM (`npm install geojson-vt`), then:
diff --git a/bench/benchmark.js b/bench/benchmark.js
new file mode 100644
index 0000000..fc50e03
--- /dev/null
+++ b/bench/benchmark.js
@@ -0,0 +1,140 @@
+import Benchmark from 'benchmark';
+import geojsonvt from '../src/index.js';
+
+function generateRectangle(id, centerX, centerY, width, height) {
+ const corners = [
+ [centerX - width / 2, centerY + height / 2],
+ [centerX + width / 2, centerY + height / 2],
+ [centerX + width / 2, centerY - height / 2],
+ [centerX - width / 2, centerY - height / 2],
+ [centerX - width / 2, centerY + height / 2]
+ ];
+
+ return {
+ id,
+ type: 'Feature',
+ properties: {name: `Rectangle ${id}`},
+ geometry: {
+ type: 'Polygon',
+ coordinates: [corners]
+ }
+ };
+}
+
+function generateFeatures(count) {
+ const features = [];
+ for (let i = 0; i < count; i++) {
+ const id = `rect-${i}`;
+ const centerX = Math.random() * 360 - 180;
+ const centerY = Math.random() * 180 - 90;
+ const width = Math.random() * 0.5 + 0.1;
+ const height = Math.random() * 0.5 + 0.1;
+ features.push(generateRectangle(id, centerX, centerY, width, height));
+ }
+ return features;
+}
+
+const optionsConstructor = {
+ maxZoom: 20,
+ updateable: false
+};
+
+const optionsUpdate = {
+ maxZoom: 20,
+ updateable: true
+};
+
+const testConfigs = [
+ {initial: 100, changing: 1, z: 20},
+ {initial: 100, changing: 10, z: 20},
+ {initial: 100, changing: 100, z: 20},
+
+ {initial: 10000, changing: 1, z: 20},
+ {initial: 10000, changing: 100, z: 20},
+ {initial: 10000, changing: 1000, z: 20},
+
+ {initial: 100000, changing: 1, z: 20},
+ {initial: 100000, changing: 100, z: 20},
+ {initial: 100000, changing: 1000, z: 20}
+];
+
+console.log('Starting geojson-vt benchmark - constructor vs updateData:\n');
+
+testConfigs.forEach((config) => {
+ const suite = new Benchmark.Suite();
+
+ const initialFeatures = generateFeatures(config.initial);
+ const changedFeatures = generateFeatures(config.changing);
+
+ // Replace the first N features with changed ones
+ const updatedFeatures = [...initialFeatures];
+ for (let i = 0; i < config.changing; i++) {
+ updatedFeatures[i] = changedFeatures[i];
+ }
+
+ const initialData = {
+ type: 'FeatureCollection',
+ features: initialFeatures
+ };
+
+ const updatedData = {
+ type: 'FeatureCollection',
+ features: updatedFeatures
+ };
+
+ const removeIds = [];
+ for (let i = 0; i < config.changing; i++) {
+ removeIds.push(`rect-${i}`);
+ }
+
+ const tileX = Math.floor(Math.pow(2, config.z) / 2);
+ const tileY = Math.floor(Math.pow(2, config.z) / 2);
+
+ let reusableIndex = geojsonvt(initialData, optionsUpdate);
+
+ console.log(`\n${config.initial.toLocaleString()} Initial, ${config.changing.toLocaleString()} changing, getTile z=${config.z}:`);
+
+ const results = {};
+
+ suite
+ .add('constructor', () => {
+ const index = geojsonvt(updatedData, optionsConstructor);
+ index.getTile(config.z, tileX, tileY);
+ })
+ .add('updateData', () => {
+ reusableIndex.updateData({
+ remove: removeIds,
+ add: changedFeatures
+ });
+ reusableIndex.getTile(config.z, tileX, tileY);
+ }, {
+ onCycle: () => {
+ reusableIndex = geojsonvt(initialData, optionsUpdate);
+ }
+ })
+ .on('cycle', (event) => {
+ const benchmark = event.target;
+ results[benchmark.name] = {
+ hz: benchmark.hz,
+ stats: benchmark.stats
+ };
+ // console.log(` ${String(benchmark)}`);
+ })
+ .on('complete', (event) => {
+ const benches = event.currentTarget;
+ const fastest = benches.filter('fastest').map('name')[0];
+ const slowest = benches.filter('slowest').map('name')[0];
+
+ const fastestHz = results[fastest].hz;
+ const slowestHz = results[slowest].hz;
+
+ const percentFaster = (((fastestHz - slowestHz) / slowestHz) * 100).toFixed(0);
+
+ console.log(` - ${fastest}: ${percentFaster}% faster`);
+ })
+ .run({
+ async: false
+ });
+});
+
+console.log('Benchmark complete!');
diff --git a/debug/update.css b/debug/update.css
new file mode 100644
index 0000000..2cec5cb
--- /dev/null
+++ b/debug/update.css
@@ -0,0 +1,121 @@
+body {
+ padding: 0;
+ margin: 0;
+ font-family: Arial, sans-serif;
+}
+button {
+ font-family: Arial, sans-serif;
+ font-size: 16px;
+ background: white;
+ border: 1px solid #ccc;
+ padding: 5px 10px;
+ cursor: pointer;
+}
+button:hover {
+ border-color: black;
+}
+.canvas-container {
+ position: relative;
+ display: inline-block;
+ margin-top: 80px;
+ margin-left: 35px;
+}
+#canvas {
+ border: 1px dotted #aaa;
+}
+#canvas.hover {
+ background: #cfc;
+}
+.controls {
+ position: fixed;
+ top: 5px;
+ left: 5px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ z-index: 100;
+ background: rgba(255, 255, 255, 0.95);
+ padding: 5px;
+ border-radius: 3px;
+}
+#time, #coord {
+ font-size: 16px;
+ background: rgba(255, 255, 255, 0.9);
+ padding: 5px 10px;
+ border: 1px solid #ccc;
+ user-select: none;
+}
+#stats {
+ position: fixed;
+ right: 10px;
+ top: 10px;
+ background: rgba(255, 255, 255, 0.95);
+ border: 1px solid #ccc;
+ padding: 15px;
+ font-family: monospace;
+ font-size: 12px;
+ min-width: 150px;
+ box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
+}
+#stats h3 {
+ margin: 0 0 10px 0;
+ font-size: 14px;
+ border-bottom: 1px solid #ddd;
+ padding-bottom: 5px;
+}
+#stats .stat-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 3px 0;
+}
+#stats .stat-label {
+ color: #666;
+}
+#stats .stat-value {
+ font-weight: bold;
+ color: #333;
+}
+.nav-button {
+ position: absolute;
+ background: rgba(255, 255, 255, 0.7);
+ border: none;
+ cursor: pointer;
+ font-size: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+ z-index: 10;
+}
+.nav-button:hover {
+ background: rgba(255, 255, 255, 0.95);
+ border-color: black;
+}
+.nav-button:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+#navLeft, #navRight {
+ width: 30px;
+ writing-mode: vertical-rl;
+ text-orientation: upright;
+ top: 30px;
+ bottom: 30px;
+}
+#navLeft {
+ left: -35px;
+}
+#navRight {
+ right: -35px;
+}
+#navUp, #navDown {
+ height: 30px;
+ left: 0;
+ right: 0;
+}
+#navUp {
+ top: -35px;
+}
+#navDown {
+ bottom: -35px;
+}
diff --git a/debug/update.html b/debug/update.html
new file mode 100644
index 0000000..c8acd42
--- /dev/null
+++ b/debug/update.html
@@ -0,0 +1,41 @@
+
+
+
+ Update
+
+
+
+
+
+
+
+
tile: z0-0-0
+
+
+
+
+
+
+
+
+
Time: 0
+
+
+
+
+
+
+
+
+
+
+
+
Tile Index Stats
+
No data
+
+
+
+
+
+
+
diff --git a/debug/viz-update.js b/debug/viz-update.js
new file mode 100644
index 0000000..dc98d22
--- /dev/null
+++ b/debug/viz-update.js
@@ -0,0 +1,631 @@
+const options = {
+ debug: 2,
+ updateable: true,
+ // tolerance: 0,
+ // indexMaxZoom: 0,
+ maxZoom: 14
+};
+
+const padding = 8 / 512;
+const totalExtent = 4096 * (1 + padding * 2);
+
+let tileIndex;
+
+const canvas = document.getElementById('canvas');
+const ctx = canvas.getContext('2d');
+const height = canvas.height = canvas.width = window.innerHeight - 125;
+const ratio = height / totalExtent;
+const pad = 4096 * padding * ratio;
+
+const backButton = document.getElementById('back');
+const startButton = document.getElementById('start');
+const stopButton = document.getElementById('stop');
+const addButton = document.getElementById('add');
+const removeButton = document.getElementById('remove');
+const generateButton = document.getElementById('generate');
+const removeAllButton = document.getElementById('removeAll');
+const modeButton = document.getElementById('mode');
+const propsButton = document.getElementById('props');
+const navLeftButton = document.getElementById('navLeft');
+const navRightButton = document.getElementById('navRight');
+const navUpButton = document.getElementById('navUp');
+const navDownButton = document.getElementById('navDown');
+const coordDiv = document.getElementById('coord');
+const msSpan = document.getElementById('ms');
+
+let x = 0;
+let y = 0;
+let z = 0;
+
+if (devicePixelRatio > 1) {
+ canvas.style.width = `${canvas.width}px`;
+ canvas.style.height = `${canvas.height}px`;
+ canvas.width *= 2;
+ canvas.height *= 2;
+ ctx.scale(2, 2);
+}
+
+ctx.textAlign = 'center';
+ctx.font = '36px Helvetica, Arial';
+ctx.fillText('Drag a GeoJSON or TopoJSON here', height / 2, height / 2);
+
+let animationId = null;
+let isAnimating = false;
+let rotatingRectangles = [];
+
+let updateCount = 0;
+let lastStatsTime = 0;
+let lastUpdateTime = 0;
+let updateTimes = [];
+const UPDATE_TIMES_WINDOW = 30;
+
+let useUpdateData = true;
+let originalData = null;
+let generatedFeatures = [];
+
+let minX = -180;
+let maxX = 180;
+let minY = -90;
+let maxY = 90;
+let rectangleWidth = 30;
+let rectangleHeight = 20;
+
+function zoomIn(left, top) {
+ if (z === options.maxZoom) return;
+
+ z++;
+ x *= 2;
+ y *= 2;
+ if (!left) x++;
+ if (!top) y++;
+
+ drawTile();
+ drawSquare(left, top);
+
+ backButton.textContent = `← z${z}`;
+}
+
+function zoomOut() {
+ if (z === 0) return;
+
+ z--;
+ x = Math.floor(x / 2);
+ y = Math.floor(y / 2);
+
+ drawTile();
+
+ backButton.textContent = `${z > 0 ? '← ' : ''}z${z}`;
+}
+
+function rotatePoint(cx, cy, x, y, angle) {
+ const radians = (Math.PI / 180) * angle;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ const nx = (cos * (x - cx)) + (sin * (y - cy)) + cx;
+ const ny = (cos * (y - cy)) - (sin * (x - cx)) + cy;
+ return [nx, ny];
+}
+
+function updateRotatingRectangles() {
+ if (rotatingRectangles.length === 0) {
+ return;
+ }
+
+ const updateStartTime = performance.now();
+
+ if (isAnimating) {
+ for (let i = 0; i < rotatingRectangles.length; i++) {
+ const rect = rotatingRectangles[i];
+
+ rect.angle = (rect.angle + 2) % 360;
+ rect.x += rect.velocityX;
+ rect.y += rect.velocityY;
+
+ if (rect.x - rectangleWidth / 2 <= minX || rect.x + rectangleWidth / 2 >= maxX) {
+ rect.velocityX = -rect.velocityX;
+ rect.velocityX *= (0.9 + Math.random() * 0.2);
+ rect.x = Math.max(minX + rectangleWidth / 2, Math.min(maxX - rectangleWidth / 2, rect.x));
+ }
+ if (rect.y - rectangleHeight / 2 <= minY || rect.y + rectangleHeight / 2 >= maxY) {
+ rect.velocityY = -rect.velocityY;
+ rect.velocityY *= (0.9 + Math.random() * 0.2);
+ rect.y = Math.max(minY + rectangleHeight / 2, Math.min(maxY - rectangleHeight / 2, rect.y));
+ }
+ }
+ }
+
+ const rectangleFeatures = rotatingRectangles.map((rect, index) => {
+ const corners = [
+ [rect.x - rectangleWidth / 2, rect.y + rectangleHeight / 2],
+ [rect.x + rectangleWidth / 2, rect.y + rectangleHeight / 2],
+ [rect.x + rectangleWidth / 2, rect.y - rectangleHeight / 2],
+ [rect.x - rectangleWidth / 2, rect.y - rectangleHeight / 2]
+ ];
+
+ const rotatedCorners = corners.map(([x, y]) => rotatePoint(rect.x, rect.y, x, y, rect.angle));
+ rotatedCorners.push(rotatedCorners[0]);
+
+ return {
+ type: 'Feature',
+ id: `rotating-rectangle-${index}`,
+ properties: {
+ name: `Rotating Rectangle ${index}`,
+ color: rect.color
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [rotatedCorners]
+ }
+ };
+ });
+
+ if (useUpdateData) {
+ const removeIds = rotatingRectangles.map((_, index) => `rotating-rectangle-${index}`);
+ tileIndex.updateData({
+ remove: removeIds,
+ add: rectangleFeatures
+ });
+ } else {
+ // Use new geojsonvt constructor (full rebuild)
+ const allFeatures = {
+ type: 'FeatureCollection',
+ features: [...(originalData ? originalData.features : []), ...generatedFeatures, ...rectangleFeatures]
+ };
+ tileIndex = geojsonvt(allFeatures, options); //eslint-disable-line
+ }
+
+ drawTile();
+
+ if (isAnimating) {
+ lastUpdateTime = performance.now() - updateStartTime;
+
+ updateTimes.push(lastUpdateTime);
+ if (updateTimes.length > UPDATE_TIMES_WINDOW) {
+ updateTimes.shift();
+ }
+
+ const avgTime = updateTimes.reduce((sum, t) => sum + t, 0) / updateTimes.length;
+ msSpan.textContent = avgTime.toFixed(2) + ' ms';
+ }
+}
+
+function startAnimation() {
+ if (isAnimating) return;
+
+ if (!tileIndex) {
+ tileIndex = geojsonvt({type: 'FeatureCollection', features: []}, options); //eslint-disable-line
+ }
+
+ if (rotatingRectangles.length === 0) {
+ addRandomRectangle();
+ }
+
+ isAnimating = true;
+ lastStatsTime = performance.now();
+ updateCount = 0;
+
+ function animate() {
+ if (!isAnimating) return;
+
+ updateRotatingRectangles();
+ updateCount++;
+
+ animationId = requestAnimationFrame(animate);
+ }
+ animationId = requestAnimationFrame(animate);
+}
+
+function stopAnimation() {
+ isAnimating = false;
+ if (animationId) {
+ cancelAnimationFrame(animationId);
+ animationId = null;
+ }
+ updateCount = 0;
+ updateTimes = [];
+ msSpan.textContent = '0.00 ms';
+}
+
+function generateRandomRectangles() {
+ console.time('generate rectangles');
+
+ if (!tileIndex) {
+ tileIndex = geojsonvt({type: 'FeatureCollection', features: []}, options); //eslint-disable-line
+ }
+
+ const features = [];
+ const startIndex = generatedFeatures.length;
+
+ for (let i = 0; i < 50000; i++) {
+ features.push(getRectangle(`bulk-${startIndex + i}`));
+ }
+
+ generatedFeatures.push(...features);
+
+ tileIndex.updateData({
+ add: features
+ });
+
+ console.timeEnd('generate rectangles');
+ console.log(`Total generated features: ${generatedFeatures.length}`);
+
+ drawTile();
+}
+
+function getRectangle(id, sizeMultiplier = 0.3) {
+ const centerX = Math.random() * 360 - 180;
+ const centerY = Math.random() * 180 - 90;
+ const width = Math.random() * sizeMultiplier + 0.1;
+ const height = Math.random() * sizeMultiplier + 0.1;
+ const angle = Math.random() * 360;
+
+ const corners = [
+ [centerX - width / 2, centerY + height / 2],
+ [centerX + width / 2, centerY + height / 2],
+ [centerX + width / 2, centerY - height / 2],
+ [centerX - width / 2, centerY - height / 2]
+ ];
+
+ const rotatedCorners = corners.map(([x, y]) => rotatePoint(centerX, centerY, x, y, angle));
+ rotatedCorners.push(rotatedCorners[0]);
+
+ return {
+ type: 'Feature',
+ id: `rect-${id}`,
+ properties: {
+ color: '#ff0000'
+ },
+ geometry: {
+ type: 'Polygon',
+ coordinates: [rotatedCorners]
+ }
+ };
+}
+
+function addRandomRectangle() {
+ if (!tileIndex) {
+ tileIndex = geojsonvt({type: 'FeatureCollection', features: []}, options); //eslint-disable-line
+ }
+
+ const newRect = {
+ x: Math.random() * 360 - 180,
+ y: Math.random() * 180 - 90,
+ velocityX: (Math.random() - 0.5) * 2,
+ velocityY: (Math.random() - 0.5) * 2,
+ angle: Math.random() * 360,
+ color: '#ff0000'
+ };
+
+ rotatingRectangles.push(newRect);
+
+ updateRotatingRectangles();
+}
+
+function removeRandomRectangle() {
+ if (!tileIndex || rotatingRectangles.length === 0) {
+ return;
+ }
+
+ const lastIndex = rotatingRectangles.length - 1;
+ const removeId = `rotating-rectangle-${lastIndex}`;
+
+ rotatingRectangles.pop();
+
+ tileIndex.updateData({
+ remove: [removeId]
+ });
+
+ drawTile();
+
+ if (isAnimating && rotatingRectangles.length === 0) {
+ stopAnimation();
+ }
+}
+
+function getRandomColor() {
+ const colors = ['#0000ff', '#00ff00', '#ffa500', '#800080', '#ffff00', '#ff69b4', '#00ffff', '#ff00ff', '#ff1493', '#1e90ff', '#32cd32', '#ff4500', '#9370db', '#00ced1', '#ff6347', '#4169e1', '#ff8c00', '#ba55d3', '#20b2aa'];
+ return colors[Math.floor(Math.random() * colors.length)];
+}
+
+function randomizeRectangleProps() {
+ if (!tileIndex || rotatingRectangles.length === 0) {
+ return;
+ }
+
+ for (const rect of rotatingRectangles) {
+ rect.color = getRandomColor();
+ }
+
+ const updates = rotatingRectangles.map((rect, index) => ({
+ id: `rotating-rectangle-${index}`,
+ addOrUpdateProperties: [
+ {key: 'color', value: rect.color}
+ ]
+ }));
+
+ tileIndex.updateData({
+ update: updates
+ });
+
+ drawTile();
+}
+
+function humanFileSize(size) {
+ const i = Math.floor(Math.log(size) / Math.log(1024));
+ return `${Math.round(100 * (size / Math.pow(1024, i))) / 100} ${['B', 'kB', 'MB', 'GB'][i]}`;
+}
+
+canvas.ondragover = function () {
+ this.className = 'hover';
+ return false;
+};
+canvas.ondragend = function () {
+ this.className = '';
+ return false;
+};
+canvas.ondrop = function (e) {
+ this.className = 'loaded';
+
+ ctx.clearRect(0, 0, height, height);
+ ctx.fillText('Thanks! Loading...', height / 2, height / 2);
+
+ const reader = new FileReader();
+ reader.onload = function (event) {
+ console.log('data size', humanFileSize(event.target.result.length));
+ console.time('JSON.parse');
+
+ let data = JSON.parse(event.target.result);
+ console.timeEnd('JSON.parse');
+
+ if (data.type === 'Topology') {
+ const firstKey = Object.keys(data.objects)[0];
+ /* global topojson: false */
+ data = topojson.feature(data, data.objects[firstKey]);
+ }
+
+ originalData = data;
+ tileIndex = geojsonvt(data, options); //eslint-disable-line
+
+ drawTile();
+ };
+ reader.readAsText(e.dataTransfer.files[0]);
+
+ e.preventDefault();
+ return false;
+};
+
+ctx.lineWidth = 1;
+
+const halfHeight = height / 2;
+
+function drawGrid() {
+ ctx.strokeStyle = 'lightgreen';
+ ctx.strokeRect(pad, pad, height - 2 * pad, height - 2 * pad);
+ ctx.beginPath();
+ ctx.moveTo(pad, halfHeight);
+ ctx.lineTo(height - pad, halfHeight);
+ ctx.moveTo(halfHeight, pad);
+ ctx.lineTo(halfHeight, height - pad);
+ ctx.stroke();
+}
+
+function drawSquare(left, top) {
+ ctx.strokeStyle = 'blue';
+ ctx.strokeRect(left ? pad : halfHeight, top ? pad : halfHeight, halfHeight - pad, halfHeight - pad);
+}
+
+function drawTile() {
+ console.time(`getting tile z${z}-${x}-${y}`);
+ const tile = tileIndex.getTile(z, x, y);
+ console.timeEnd(`getting tile z${z}-${x}-${y}`);
+
+ coordDiv.textContent = `tile: z${z}-${x}-${y}`;
+ ctx.clearRect(0, 0, height, height);
+
+ if (!tile) {
+ canvas.className = '';
+ ctx.clearRect(0, 0, height, height);
+ ctx.fillStyle = 'black';
+ ctx.fillText(`No tile found at z${z}-${x}-${y}`, height / 2, height / 2);
+ } else {
+ const features = tile.features;
+
+ for (let i = 0; i < features.length; i++) {
+ const feature = features[i];
+ const type = feature.type;
+ const color = feature.tags?.color || '#ff0000';
+
+ ctx.strokeStyle = color;
+ ctx.fillStyle = `${color}22`;
+
+ ctx.beginPath();
+
+ for (let j = 0; j < feature.geometry.length; j++) {
+ const geom = feature.geometry[j];
+
+ if (type === 1) {
+ ctx.arc(geom[0] * ratio + pad, geom[1] * ratio + pad, 2, 0, 2 * Math.PI, false);
+ continue;
+ }
+
+ for (let k = 0; k < geom.length; k++) {
+ const p = geom[k];
+ if (k) ctx.lineTo(p[0] * ratio + pad, p[1] * ratio + pad);
+ else ctx.moveTo(p[0] * ratio + pad, p[1] * ratio + pad);
+ }
+ }
+
+ if (type === 3 || type === 1) ctx.fill('evenodd');
+ ctx.stroke();
+ }
+ }
+
+ drawGrid();
+ updateStatsDisplay();
+}
+
+canvas.onclick = function (e) {
+ if (!tileIndex) return;
+
+ const mouseX = e.layerX - 10;
+ const mouseY = e.layerY - 10;
+ const left = mouseX / height < 0.5;
+ const top = mouseY / height < 0.5;
+
+ zoomIn(left, top);
+};
+
+canvas.onmousemove = function (e) {
+ if (!tileIndex) return;
+
+ const mouseX = e.layerX - 10;
+ const mouseY = e.layerY - 10;
+ const left = mouseX / height < 0.5;
+ const top = mouseY / height < 0.5;
+
+ drawGrid();
+ drawSquare(left, top);
+};
+
+backButton.onclick = function () {
+ if (!tileIndex) return;
+ zoomOut();
+};
+
+startButton.onclick = function () {
+ startAnimation();
+};
+
+stopButton.onclick = function () {
+ stopAnimation();
+};
+
+generateButton.onclick = function () {
+ generateRandomRectangles();
+};
+
+addButton.onclick = function () {
+ addRandomRectangle();
+};
+
+removeButton.onclick = function () {
+ removeRandomRectangle();
+};
+
+modeButton.onclick = function () {
+ useUpdateData = !useUpdateData;
+ const modeText = useUpdateData ? 'Update' : 'Rebuild';
+ modeButton.textContent = `Mode: ${modeText}`;
+ console.log(`Switched to ${modeText} mode`);
+};
+
+propsButton.onclick = function () {
+ randomizeRectangleProps();
+};
+
+removeAllButton.onclick = function () {
+ if (!tileIndex) return;
+ stopAnimation();
+
+ tileIndex.updateData({
+ removeAll: true
+ });
+
+ generatedFeatures = [];
+ originalData = null;
+ rotatingRectangles = [];
+
+ // Reset to root tile
+ z = 0;
+ x = 0;
+ y = 0;
+
+ drawTile();
+ backButton.textContent = 'z0';
+ coordDiv.textContent = 'tile: z0-0-0';
+
+ updateStatsDisplay();
+};
+
+navLeftButton.onclick = function () {
+ if (!tileIndex || x <= 0) return;
+ x--;
+ drawTile();
+};
+
+navRightButton.onclick = function () {
+ if (!tileIndex) return;
+ const maxTile = Math.pow(2, z) - 1;
+ if (x >= maxTile) return;
+ x++;
+ drawTile();
+};
+
+navUpButton.onclick = function () {
+ if (!tileIndex || y <= 0) return;
+ y--;
+ drawTile();
+};
+
+navDownButton.onclick = function () {
+ if (!tileIndex) return;
+ const maxTile = Math.pow(2, z) - 1;
+ if (y >= maxTile) return;
+ y++;
+ drawTile();
+};
+
+function updateStatsDisplay() {
+ const statsContent = document.getElementById('stats_content');
+
+ if (!tileIndex || !tileIndex.stats) {
+ statsContent.innerHTML = 'No data yet';
+ return;
+ }
+
+ const stats = tileIndex.stats;
+ const statsEntries = Object.entries(stats).sort((a, b) => {
+ // Sort by zoom level (z0, z1, z2, etc.)
+ const aNum = parseInt(a[0].substring(1));
+ const bNum = parseInt(b[0].substring(1));
+ return aNum - bNum;
+ });
+
+ if (statsEntries.length === 0) {
+ statsContent.innerHTML = 'No tiles yet';
+ return;
+ }
+
+ let html = '';
+ for (const [key, count] of statsEntries) {
+ html += `
+ ${key}:
+ ${count}
+
`;
+ }
+
+ html += `
+ Total:
+ ${tileIndex.total || 0}
+
`;
+
+ statsContent.innerHTML = html;
+}
+
+/*eslint-disable no-unused-vars */
+function drillDown() {
+ let i, j;
+ console.time('drill down');
+ for (i = 0; i < 10; i++) {
+ for (j = 0; j < 10; j++) {
+ tileIndex.getTile(7, 30 + i, 45 + j);
+ }
+ }
+ for (i = 0; i < 10; i++) {
+ for (j = 0; j < 10; j++) {
+ tileIndex.getTile(8, 70 + i, 100 + j);
+ }
+ }
+ console.timeEnd('drill down');
+}
+/*eslint-enable no-unused-vars */
diff --git a/package-lock.json b/package-lock.json
index bfeb06d..7386d4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,35 +11,47 @@
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
"benchmark": "^2.1.4",
- "eslint": "^9.5.0",
- "eslint-config-mourner": "^4.0.0",
+ "eslint": "^9.38.0",
+ "eslint-config-mourner": "^4.1.0",
"rollup": "^4.18.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
+ "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "eslint-visitor-keys": "^3.3.0"
+ "eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
- "version": "3.3.0",
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
- "version": "4.10.1",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"dev": true,
"license": "MIT",
"engines": {
@@ -47,24 +59,50 @@
}
},
"node_modules/@eslint/config-array": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.16.0.tgz",
- "integrity": "sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
+ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/object-schema": "^2.1.4",
+ "@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
- "minimatch": "^3.0.5"
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
+ "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
+ "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
- "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+ "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -86,25 +124,66 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.5.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.5.0.tgz",
- "integrity": "sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w==",
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
+ "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/object-schema": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
- "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
+ "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
"dev": true,
"license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.16.0",
+ "levn": "^0.4.1"
+ },
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"dev": true,
@@ -118,9 +197,9 @@
}
},
"node_modules/@humanwhocodes/retry": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
- "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==",
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -188,38 +267,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"dev": true,
@@ -465,34 +512,25 @@
"win32"
]
},
- "node_modules/@stylistic/eslint-plugin-js": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin-js/-/eslint-plugin-js-2.2.2.tgz",
- "integrity": "sha512-Vj2Q1YHVvJw+ThtOvmk5Yx7wZanVrIBRUTT89horLDb4xdP9GA1um9XOYQC6j67VeUC2gjZQnz5/RVJMzaOhtw==",
+ "node_modules/@stylistic/eslint-plugin": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.5.0.tgz",
+ "integrity": "sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/eslint": "^8.56.10",
- "acorn": "^8.11.3",
- "eslint-visitor-keys": "^4.0.0",
- "espree": "^10.0.1"
+ "@eslint-community/eslint-utils": "^4.9.0",
+ "@typescript-eslint/types": "^8.46.1",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "estraverse": "^5.3.0",
+ "picomatch": "^4.0.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
- "eslint": ">=8.40.0"
- }
- },
- "node_modules/@types/eslint": {
- "version": "8.56.10",
- "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz",
- "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "*",
- "@types/json-schema": "*"
+ "eslint": ">=9.0.0"
}
},
"node_modules/@types/estree": {
@@ -509,10 +547,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.46.2",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
+ "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/acorn": {
- "version": "8.12.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.0.tgz",
- "integrity": "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==",
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -549,14 +601,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/ansi-styles": {
"version": "4.3.0",
"dev": true,
@@ -580,6 +624,8 @@
},
"node_modules/balanced-match": {
"version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
@@ -593,9 +639,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -662,7 +708,9 @@
"license": "MIT"
},
"node_modules/cross-spawn": {
- "version": "7.0.3",
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -675,13 +723,13 @@
}
},
"node_modules/debug": {
- "version": "4.3.5",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
- "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -709,28 +757,32 @@
}
},
"node_modules/eslint": {
- "version": "9.5.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.5.0.tgz",
- "integrity": "sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw==",
+ "version": "9.38.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
+ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.2.0",
- "@eslint-community/regexpp": "^4.6.1",
- "@eslint/config-array": "^0.16.0",
- "@eslint/eslintrc": "^3.1.0",
- "@eslint/js": "9.5.0",
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.1",
+ "@eslint/core": "^0.16.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.38.0",
+ "@eslint/plugin-kit": "^0.4.0",
+ "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
- "@humanwhocodes/retry": "^0.3.0",
- "@nodelib/fs.walk": "^1.2.8",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
- "cross-spawn": "^7.0.2",
+ "cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
- "eslint-scope": "^8.0.1",
- "eslint-visitor-keys": "^4.0.0",
- "espree": "^10.0.1",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@@ -740,15 +792,11 @@
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
- "is-path-inside": "^3.0.3",
"json-stable-stringify-without-jsonify": "^1.0.1",
- "levn": "^0.4.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
- "optionator": "^0.9.3",
- "strip-ansi": "^6.0.1",
- "text-table": "^0.2.0"
+ "optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
@@ -758,23 +806,45 @@
},
"funding": {
"url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
}
},
"node_modules/eslint-config-mourner": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/eslint-config-mourner/-/eslint-config-mourner-4.0.0.tgz",
- "integrity": "sha512-svOOXP1KFS9DZOR6hxVQVTug75a/aQQ/shc/Tlgvbszo9ypTbRta/elow54Qq6RJ7S8LwnXeuPEAcOdDWExqlg==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-mourner/-/eslint-config-mourner-4.1.0.tgz",
+ "integrity": "sha512-XEPuEuauqbnLPi/QZGjZr57w2+quw1fEXa2p6jz3tR8ggDk/ciMjiQo7/PR43t47osdS/RaduN/T1MGncwmOKQ==",
"dev": true,
"license": "ISC",
"dependencies": {
- "@eslint/js": "^9.5.0",
- "@stylistic/eslint-plugin-js": "^2.2.2"
+ "@eslint/js": "^9.31.0",
+ "@stylistic/eslint-plugin": "^5.1.0",
+ "globals": "^16.3.0"
+ }
+ },
+ "node_modules/eslint-config-mourner/node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-scope": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz",
- "integrity": "sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og==",
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@@ -789,9 +859,9 @@
}
},
"node_modules/eslint-visitor-keys": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
- "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@@ -801,16 +871,23 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/eslint/node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/espree": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
- "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
- "acorn": "^8.12.0",
+ "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.0.0"
+ "eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -880,14 +957,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fastq": {
- "version": "1.15.0",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "reusify": "^1.0.4"
- }
- },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -982,9 +1051,9 @@
}
},
"node_modules/ignore": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
- "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -992,9 +1061,9 @@
}
},
"node_modules/import-fresh": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
- "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1035,16 +1104,10 @@
"node": ">=0.10.0"
}
},
- "node_modules/is-path-inside": {
- "version": "3.0.3",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/isexe": {
"version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
@@ -1140,9 +1203,9 @@
}
},
"node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
@@ -1218,12 +1281,27 @@
},
"node_modules/path-key": {
"version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/platform": {
"version": "1.3.6",
"dev": true,
@@ -1247,25 +1325,6 @@
"node": ">=6"
}
},
- "node_modules/queue-microtask": {
- "version": "1.2.3",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT"
- },
"node_modules/randombytes": {
"version": "2.1.0",
"dev": true,
@@ -1284,15 +1343,6 @@
"node": ">=4"
}
},
- "node_modules/reusify": {
- "version": "1.0.4",
- "dev": true,
- "license": "MIT",
- "engines": {
- "iojs": ">=1.0.0",
- "node": ">=0.10.0"
- }
- },
"node_modules/rollup": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz",
@@ -1329,28 +1379,6 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/run-parallel": {
- "version": "1.2.0",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "queue-microtask": "^1.2.2"
- }
- },
"node_modules/safe-buffer": {
"version": "5.2.1",
"dev": true,
@@ -1380,6 +1408,8 @@
},
"node_modules/shebang-command": {
"version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1391,6 +1421,8 @@
},
"node_modules/shebang-regex": {
"version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1419,17 +1451,6 @@
"source-map": "^0.6.0"
}
},
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -1471,11 +1492,6 @@
"node": ">=10"
}
},
- "node_modules/text-table": {
- "version": "0.2.0",
- "dev": true,
- "license": "MIT"
- },
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -1499,6 +1515,8 @@
},
"node_modules/which": {
"version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
diff --git a/package.json b/package.json
index 00d8537..47d32e8 100644
--- a/package.json
+++ b/package.json
@@ -24,8 +24,8 @@
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
"benchmark": "^2.1.4",
- "eslint": "^9.5.0",
- "eslint-config-mourner": "^4.0.0",
+ "eslint": "^9.38.0",
+ "eslint-config-mourner": "^4.1.0",
"rollup": "^4.18.0"
},
"license": "ISC",
@@ -35,6 +35,7 @@
"cov": "node --experimental-test-coverage --test",
"build": "rollup -c",
"watch": "rollup -cw",
+ "bench": "node bench/benchmark.js",
"prepublishOnly": "npm run test && npm run build"
},
"files": [
diff --git a/src/convert.js b/src/convert.js
index 63cf7d7..6d6de5e 100644
--- a/src/convert.js
+++ b/src/convert.js
@@ -112,7 +112,7 @@ function convertLine(ring, out, tolerance, isPolygon) {
const last = out.length - 3;
out[2] = 1;
- simplify(out, 0, last, tolerance);
+ if (tolerance > 0) simplify(out, 0, last, tolerance);
out[last + 2] = 1;
out.size = Math.abs(size);
diff --git a/src/difference.js b/src/difference.js
new file mode 100644
index 0000000..b6d9de2
--- /dev/null
+++ b/src/difference.js
@@ -0,0 +1,180 @@
+import convert from './convert.js'; // GeoJSON conversion and preprocessing
+import wrap from './wrap.js'; // date line processing
+
+// This file provides a set of helper functions for managing "diffs" (changes)
+// to GeoJSON data structures. These diffs describe additions, removals,
+// and updates of features in a GeoJSON source in an efficient way.
+
+// GeoJSON Source Diff:
+// {
+// removeAll: true, // If true, clear all existing features
+// remove: [featureId, ...], // Array of feature IDs to remove
+// add: [feature, ...], // Array of GeoJSON features to add
+// update: [GeoJSON Feature Diff, ...] // Array of per-feature updates
+// }
+
+// GeoJSON Feature Diff:
+// {
+// id: featureId, // ID of the feature being updated
+// newGeometry: GeoJSON.Geometry, // Optional new geometry
+// removeAllProperties: true, // Remove all properties if true
+// removeProperties: [key, ...], // Specific properties to delete
+// addOrUpdateProperties: [ // Properties to add or update
+// { key: "name", value: "New name" }
+// ]
+// }
+
+/* eslint @stylistic/comma-spacing: 0, no-shadow: 0 */
+
+// applies a diff to the geojsonvt source simplified features array
+// returns an object with the affected features and new source array for invalidation
+export function applySourceDiff(source, dataDiff, options) {
+
+ // convert diff to sets/maps for o(1) lookups
+ const diff = diffToHashed(dataDiff);
+
+ // collection for features that will be affected by this update
+ let affected = [];
+
+ // full removal - clear everything before applying diff
+ if (diff.removeAll) {
+ affected = source;
+ source = [];
+ }
+
+ // remove/add features and collect affected ones
+ if (diff.remove.size || diff.add.size) {
+ const removeFeatures = [];
+
+ // collect source features to be removed
+ for (const feature of source) {
+ const {id} = feature;
+
+ // explicit feature removal
+ if (diff.remove.has(id)) {
+ removeFeatures.push(feature);
+ // feature with duplicate id being added
+ } else if (diff.add.has(id)) {
+ removeFeatures.push(feature);
+ }
+ }
+
+ // collect affected and remove from source
+ if (removeFeatures.length) {
+ affected.push(...removeFeatures);
+
+ const removeIds = new Set(removeFeatures.map(f => f.id));
+ source = source.filter(f => !removeIds.has(f.id));
+ }
+
+ // convert and add new features
+ if (diff.add.size) {
+ // projects and adds simplification info
+ let addFeatures = convert({type: 'FeatureCollection', features: Array.from(diff.add.values())}, options);
+
+ // wraps features (ie extreme west and extreme east)
+ addFeatures = wrap(addFeatures, options);
+
+ affected.push(...addFeatures);
+ source.push(...addFeatures);
+ }
+ }
+
+ if (diff.update.size) {
+ for (const [id, update] of diff.update) {
+ const featureIndex = source.findIndex(f => f.id === id);
+ if (featureIndex === -1) continue;
+
+ const feature = source[featureIndex];
+
+ // get updated geojsonvt simplified feature
+ const updatedFeature = getUpdatedFeature(feature, update, options);
+ if (!updatedFeature) continue;
+
+ // track both features for invalidation
+ affected.push(feature, updatedFeature);
+
+ // replace old feature with updated feature
+ source[featureIndex] = updatedFeature;
+ }
+ }
+
+ return {affected, source};
+}
+
+// return an updated geojsonvt simplified feature
+function getUpdatedFeature(vtFeature, update, options) {
+ const changeGeometry = !!update.newGeometry;
+
+ const changeProps =
+ update.removeAllProperties ||
+ update.removeProperties?.length > 0 ||
+ update.addOrUpdateProperties?.length > 0;
+
+ // nothing to do
+ if (!changeGeometry && !changeProps) return null;
+
+ // if geometry changed, need to create new geojson feature and convert to simplified format
+ if (changeGeometry) {
+ const geojsonFeature = {
+ type: 'Feature',
+ id: vtFeature.id,
+ geometry: update.newGeometry,
+ properties: changeProps ? applyPropertyUpdates(vtFeature.tags, update) : vtFeature.tags
+ };
+
+ // projects and adds simplification info
+ let features = convert({type: 'FeatureCollection', features: [geojsonFeature]}, options);
+
+ // wraps features (ie extreme west and extreme east)
+ features = wrap(features, options);
+
+ return features[0];
+ }
+
+ // only properties changed - update tags directly
+ if (changeProps) {
+ const feature = {...vtFeature};
+ feature.tags = applyPropertyUpdates(feature.tags, update);
+ return feature;
+ }
+
+ return null;
+}
+
+// helper to apply property updates from a diff update object to a properties object
+function applyPropertyUpdates(tags, update) {
+ if (update.removeAllProperties) {
+ return {};
+ }
+
+ const properties = {...tags || {}};
+
+ if (update.removeProperties) {
+ for (const key of update.removeProperties) {
+ delete properties[key];
+ }
+ }
+
+ if (update.addOrUpdateProperties) {
+ for (const {key, value} of update.addOrUpdateProperties) {
+ properties[key] = value;
+ }
+ }
+
+ return properties;
+}
+
+// Convert a GeoJSON Source Diff to an idempotent hashed representation using Sets and Maps
+export function diffToHashed(diff) {
+ if (!diff) return {};
+
+ const hashed = {};
+
+ hashed.removeAll = diff.removeAll;
+ hashed.remove = new Set(diff.remove || []);
+ hashed.add = new Map(diff.add?.map(feature => [feature.id, feature]));
+ hashed.update = new Map(diff.update?.map(update => [update.id, update]));
+
+ return hashed;
+}
diff --git a/src/index.js b/src/index.js
index c1f8870..6f0a9dc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,9 +1,10 @@
-import convert from './convert.js'; // GeoJSON conversion and preprocessing
-import clip from './clip.js'; // stripe clipping algorithm
-import wrap from './wrap.js'; // date line processing
-import transform from './transform.js'; // coordinate transformation
-import createTile from './tile.js'; // final simplified tile generation
+import convert from './convert.js'; // GeoJSON conversion and preprocessing
+import clip from './clip.js'; // stripe clipping algorithm
+import wrap from './wrap.js'; // date line processing
+import transform from './transform.js'; // coordinate transformation
+import createTile from './tile.js'; // final simplified tile generation
+import {applySourceDiff} from './difference.js'; // diff utilities
const defaultOptions = {
maxZoom: 14, // max zoom to preserve detail on
@@ -15,6 +16,7 @@ const defaultOptions = {
lineMetrics: false, // whether to calculate line metrics
promoteId: null, // name of a feature property to be promoted to feature.id
generateId: false, // whether to generate feature ids. Cannot be used with promoteId
+ updateable: false, // whether geojson can be updated (with caveat of a stored simplified copy)
debug: 0 // logging level (0, 1 or 2)
};
@@ -50,6 +52,11 @@ class GeoJSONVT {
// start slicing from the top tile down
if (features.length) this.splitTile(features, 0, 0, 0);
+ // for updateable indexes, store a copy of the original simplified features
+ if (options.updateable) {
+ this.source = features;
+ }
+
if (debug) {
if (features.length) console.log('features: %d, points: %d', this.tiles[0].numFeatures, this.tiles[0].numPoints);
console.timeEnd('generate tiles');
@@ -84,7 +91,7 @@ class GeoJSONVT {
if (debug > 1) console.time('creation');
tile = this.tiles[id] = createTile(features, z, x, y, options);
- this.tileCoords.push({z, x, y});
+ this.tileCoords.push({z, x, y, id});
if (debug) {
if (debug > 1) {
@@ -197,6 +204,120 @@ class GeoJSONVT {
return this.tiles[id] ? transform(this.tiles[id], extent) : null;
}
+
+ // invalidates (removes) tiles affected by the provided features
+ invalidateTiles(features) {
+ const options = this.options;
+ const {debug} = options;
+
+ // calculate bounding box of all features for trivial reject
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+
+ for (const feature of features) {
+ minX = Math.min(minX, feature.minX);
+ maxX = Math.max(maxX, feature.maxX);
+ minY = Math.min(minY, feature.minY);
+ maxY = Math.max(maxY, feature.maxY);
+ }
+
+ // tile buffer clipping value - not halved as in splitTile above because checking against tile's own extent
+ const k1 = options.buffer / options.extent;
+
+ // track removed tile ids for o(1) lookup
+ const removedLookup = new Set();
+
+ // iterate through existing tiles and remove ones that are affected by features
+ for (const id in this.tiles) {
+ const tile = this.tiles[id];
+
+ // calculate tile bounds including buffer
+ const z2 = 1 << tile.z;
+ const tileMinX = (tile.x - k1) / z2;
+ const tileMaxX = (tile.x + 1 + k1) / z2;
+ const tileMinY = (tile.y - k1) / z2;
+ const tileMaxY = (tile.y + 1 + k1) / z2;
+
+ // trivial reject if feature bounds don't intersect tile
+ if (maxX < tileMinX || minX >= tileMaxX ||
+ maxY < tileMinY || minY >= tileMaxY) {
+ continue;
+ }
+
+ // check if any feature intersects with the tile
+ let intersects = false;
+ for (const feature of features) {
+ if (feature.maxX >= tileMinX && feature.minX < tileMaxX &&
+ feature.maxY >= tileMinY && feature.minY < tileMaxY) {
+ intersects = true;
+ break;
+ }
+ }
+ if (!intersects) continue;
+
+ if (debug) {
+ if (debug > 1) {
+ console.log('invalidate tile z%d-%d-%d (features: %d, points: %d, simplified: %d)',
+ tile.z, tile.x, tile.y, tile.numFeatures, tile.numPoints, tile.numSimplified);
+ }
+ const key = `z${ tile.z}`;
+ this.stats[key] = (this.stats[key] || 0) - 1;
+ this.total--;
+ }
+
+ delete this.tiles[id];
+ removedLookup.add(id);
+ }
+
+ // remove tile coords that are no longer in the index
+ if (removedLookup.size) this.tileCoords = this.tileCoords.filter(c => !removedLookup.has(c.id));
+ }
+
+ // updates the tile index by adding and/or removing geojson features
+ // invalidates tiles that are affected by the update for regeneration on next getTile call
+ // diff is an object with properties specified in difference.js
+ updateData(diff) {
+ const options = this.options;
+ const debug = options.debug;
+
+ if (!options.updateable) throw new Error('to update tile geojson `updateable` option must be set to true');
+
+ // apply diff and collect affected features and updated source that will be used to invalidate tiles
+ const {affected, source} = applySourceDiff(this.source, diff, options);
+
+ // nothing has changed
+ if (!affected.length) return;
+
+ // update source with new simplified feature set
+ this.source = source;
+
+ if (debug > 1) {
+ console.log('invalidating tiles');
+ console.time('invalidating');
+ }
+
+ this.invalidateTiles(affected);
+
+ if (debug > 1) console.timeEnd('invalidating');
+
+ // re-generate root tile with updated feature set
+ const [z, x, y] = [0, 0, 0];
+ const rootTile = createTile(this.source, z, x, y, this.options);
+ rootTile.source = this.source;
+
+ // update tile index with new root tile - ready for getTile calls
+ const id = toID(z, x, y);
+ this.tiles[id] = rootTile;
+ this.tileCoords.push({z, x, y, id});
+
+ if (debug) {
+ const key = `z${ z}`;
+ this.stats[key] = (this.stats[key] || 0) + 1;
+ this.total++;
+ }
+ }
}
function toID(z, x, y) {
diff --git a/test/test-clip.js b/test/test-clip.js
index ff6b1b9..55256ee 100644
--- a/test/test-clip.js
+++ b/test/test-clip.js
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import clip from '../src/clip.js';
-/*eslint @stylistic/js/comma-spacing:0*/
+/* eslint @stylistic/comma-spacing: 0 */
const geom1 = [0,0,0,50,0,0,50,10,0,20,10,0,20,20,0,30,20,0,30,30,0,50,30,0,50,40,0,25,40,0,25,50,0,0,50,0,0,60,0,25,60,0];
const geom2 = [0,0,0,50,0,0,50,10,0,0,10,0];
diff --git a/test/test-difference.js b/test/test-difference.js
new file mode 100644
index 0000000..2bb6b1e
--- /dev/null
+++ b/test/test-difference.js
@@ -0,0 +1,237 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {applySourceDiff} from '../src/difference.js';
+
+const options = {
+ maxZoom: 14,
+ indexMaxZoom: 5,
+ indexMaxPoints: 100000,
+ tolerance: 3,
+ extent: 4096,
+ buffer: 64,
+ updateable: true
+};
+
+test('applySourceDiff: adds a feature using the feature id', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {},
+ };
+
+ const {source} = applySourceDiff([], {
+ add: [point]
+ }, options);
+ assert.equal(source.length, 1);
+ assert.equal(source[0].id, 'point');
+});
+
+test('applySourceDiff: adds a feature using the promoteId', () => {
+ const point2 = {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0],
+ },
+ properties: {
+ promoteId: 'point2'
+ },
+ };
+
+ const {source} = applySourceDiff([], {
+ add: [point2]
+ }, {promoteId: 'promoteId'});
+ assert.equal(source.length, 1);
+ assert.equal(source[0].id, 'point2');
+});
+
+test('applySourceDiff: removes a feature by its id', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {},
+ };
+
+ const point2 = {
+ type: 'Feature',
+ id: 'point2',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {},
+ };
+
+ const {source} = applySourceDiff([point, point2], {
+ remove: ['point2'],
+ }, options);
+ assert.equal(source.length, 1);
+ assert.equal(source[0].id, 'point');
+});
+
+test('applySourceDiff: updates a feature geometry', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {},
+ tags: {},
+ minX: 0,
+ minY: 0,
+ maxX: 0,
+ maxY: 0
+ };
+
+ const {source} = applySourceDiff([point], {
+ update: [{
+ id: 'point',
+ newGeometry: {
+ type: 'Point',
+ coordinates: [1, 0]
+ }
+ }]
+ }, options);
+
+ assert.equal(source.length, 1);
+ assert.equal(source[0].id, 'point');
+ assert.equal(source[0].geometry[0], projectX(1));
+ assert.equal(source[0].geometry[1], projectY(0));
+});
+
+test('applySourceDiff: adds properties', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {},
+ tags: {},
+ minX: 0,
+ minY: 0,
+ maxX: 0,
+ maxY: 0
+ };
+
+ const {source} = applySourceDiff([point], {
+ update: [{
+ id: 'point',
+ addOrUpdateProperties: [
+ {key: 'prop', value: 'value'},
+ {key: 'prop2', value: 'value2'}
+ ]
+ }]
+ }, options);
+ assert.equal(source.length, 1);
+ const tags = source[0].tags;
+ assert.equal(Object.keys(tags).length, 2);
+ assert.equal(tags.prop, 'value');
+ assert.equal(tags.prop2, 'value2');
+});
+
+test('applySourceDiff: updates properties', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {prop: 'value', prop2: 'value2'},
+ tags: {prop: 'value', prop2: 'value2'},
+ minX: 0,
+ minY: 0,
+ maxX: 0,
+ maxY: 0
+ };
+
+ const {source} = applySourceDiff([point], {
+ update: [{
+ id: 'point',
+ addOrUpdateProperties: [
+ {key: 'prop2', value: 'value3'}
+ ]
+ }]
+ }, options);
+ assert.equal(source.length, 1);
+ const tags2 = source[0].tags;
+ assert.equal(Object.keys(tags2).length, 2);
+ assert.equal(tags2.prop, 'value');
+ assert.equal(tags2.prop2, 'value3');
+});
+
+test('applySourceDiff: removes properties', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {prop: 'value', prop2: 'value2'},
+ tags: {prop: 'value', prop2: 'value2'},
+ minX: 0,
+ minY: 0,
+ maxX: 0,
+ maxY: 0
+ };
+
+ const {source} = applySourceDiff([point], {
+ update: [{
+ id: 'point',
+ removeProperties: ['prop2']
+ }]
+ }, options);
+ assert.equal(source.length, 1);
+ const tags3 = source[0].tags;
+ assert.equal(Object.keys(tags3).length, 1);
+ assert.equal(tags3.prop, 'value');
+});
+
+test('applySourceDiff: removes all properties', () => {
+ const point = {
+ type: 'Feature',
+ id: 'point',
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0]
+ },
+ properties: {prop: 'value', prop2: 'value2'},
+ tags: {prop: 'value', prop2: 'value2'},
+ minX: 0,
+ minY: 0,
+ maxX: 0,
+ maxY: 0
+ };
+
+ const {source} = applySourceDiff([point], {
+ update: [{
+ id: 'point',
+ removeAllProperties: true,
+ }]
+ }, options);
+ assert.equal(source.length, 1);
+ assert.equal(Object.keys(source[0].tags).length, 0);
+});
+
+function projectX(x) {
+ return x / 360 + 0.5;
+}
+
+function projectY(y) {
+ const sin = Math.sin(y * Math.PI / 180);
+ const y2 = 0.5 - 0.25 * Math.log((1 + sin) / (1 - sin)) / Math.PI;
+ return y2 < 0 ? 0 : y2 > 1 ? 1 : y2;
+}
diff --git a/test/test-simplify.js b/test/test-simplify.js
index 4a003ee..e3366fb 100644
--- a/test/test-simplify.js
+++ b/test/test-simplify.js
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import simplify from '../src/simplify.js';
-/*eslint @stylistic/js/comma-spacing:0, no-shadow: 0*/
+/* eslint @stylistic/comma-spacing: 0, no-shadow: 0 */
const points = [
[0.22455,0.25015],[0.22691,0.24419],[0.23331,0.24145],[0.23498,0.23606],
diff --git a/test/test-update.js b/test/test-update.js
new file mode 100644
index 0000000..15427f7
--- /dev/null
+++ b/test/test-update.js
@@ -0,0 +1,472 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import geojsonvt from '../src/index.js';
+
+test('updateData: requires updateable option', () => {
+ const index = geojsonvt({
+ type: 'FeatureCollection',
+ features: []
+ });
+
+ assert.throws(() => {
+ index.updateData({add: [], remove: []});
+ });
+});
+
+test('updateData: adds new features', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {type: 'Point', coordinates: [0, 0]},
+ properties: {name: 'Feature 1'}
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {updateable: true});
+
+ const newFeature = {
+ type: 'Feature',
+ id: 'feature2',
+ geometry: {type: 'Point', coordinates: [10, 10]},
+ properties: {name: 'Feature 2'}
+ };
+
+ index.updateData({add: [newFeature]});
+
+ const tile = index.getTile(0, 0, 0);
+ assert.equal(tile.features.length, 2);
+});
+
+test('updateData: removes features by id', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {type: 'Point', coordinates: [0, 0]},
+ properties: {name: 'Feature 1'}
+ },
+ {
+ type: 'Feature',
+ id: 'feature2',
+ geometry: {type: 'Point', coordinates: [10, 10]},
+ properties: {name: 'Feature 2'}
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {updateable: true});
+
+ index.updateData({remove: ['feature1']});
+
+ const tile = index.getTile(0, 0, 0);
+ assert.equal(tile.features.length, 1);
+});
+
+test('updateData: replaces features with duplicate ids', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {type: 'Point', coordinates: [0, 0]},
+ properties: {name: 'Original'}
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {updateable: true});
+
+ const updatedFeature = {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {type: 'Point', coordinates: [5, 5]},
+ properties: {name: 'Updated'}
+ };
+
+ index.updateData({add: [updatedFeature]});
+
+ const tile = index.getTile(0, 0, 0);
+ assert.equal(tile.features.length, 1);
+ assert.equal(tile.features[0].id, 'feature1');
+ assert.equal(tile.features[0].tags.name, 'Updated');
+});
+
+test('updateData: handles both add and remove in same call', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {type: 'Point', coordinates: [0, 0]},
+ properties: {name: 'Feature 1'}
+ },
+ {
+ type: 'Feature',
+ id: 'feature2',
+ geometry: {type: 'Point', coordinates: [10, 10]},
+ properties: {name: 'Feature 2'}
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {updateable: true});
+
+ const newFeature = {
+ type: 'Feature',
+ id: 'feature3',
+ geometry: {type: 'Point', coordinates: [20, 20]},
+ properties: {name: 'Feature 3'}
+ };
+
+ index.updateData({
+ remove: ['feature1'],
+ add: [newFeature]
+ });
+
+ const tile = index.getTile(0, 0, 0);
+ assert.equal(tile.features.length, 2);
+
+ const featureIds = tile.features.map(f => f.id).sort();
+ assert.deepEqual(featureIds, ['feature2', 'feature3']);
+});
+
+test('updateData: works with empty diff', () => {
+ const index = geojsonvt({
+ type: 'FeatureCollection',
+ features: []
+ }, {updateable: true});
+
+ assert.doesNotThrow(() => {
+ index.updateData({});
+ index.updateData({add: [], remove: []});
+ });
+});
+
+test('updateData: invalidates tiles at deeper zoom', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [0, 0], [5, 0], [5, 5], [0, 5], [0, 0]
+ ]]
+ },
+ properties: {name: 'Original'}
+ }]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 5,
+ indexMaxPoints: 0
+ });
+
+ const tileId = toID(5, 16, 16);
+
+ const tileBefore = index.tiles[tileId];
+ assert.ok(tileBefore);
+ assert.equal(tileBefore.numFeatures, 1);
+
+ const updatedFeature = {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [0, 0], [10, 0], [10, 10], [0, 10], [0, 0]
+ ]]
+ },
+ properties: {name: 'Updated'}
+ };
+
+ index.updateData({add: [updatedFeature]});
+
+ const tileAfter = index.tiles[tileId];
+ assert.equal(tileAfter, undefined);
+
+ const tileRegenerated = index.getTile(5, 16, 16);
+ assert.ok(tileRegenerated);
+ assert.equal(tileRegenerated.features[0].tags.name, 'Updated');
+});
+
+test('updateData: invalidates tiles with partial intersection', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'far-east',
+ geometry: {
+ type: 'Point',
+ coordinates: [179.99, 0] // far east
+ }
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 2,
+ indexMaxPoints: 0
+ });
+
+ const edgeFeature = {
+ type: 'Feature',
+ id: 'edge-line',
+ geometry: {
+ type: 'LineString',
+ coordinates: [[0, -1], [180, 1]]
+ }
+ };
+
+ index.updateData({add: [edgeFeature]});
+
+ const tile = index.getTile(2, 3, 2);
+ assert.ok(tile);
+ assert.equal(tile.features.length, 2);
+});
+
+test('updateData: invalidates empty tiles', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'nw-only',
+ geometry: {
+ type: 'Point',
+ coordinates: [-90, 45] // top left quadrant only
+ }
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 1,
+ indexMaxPoints: 0,
+ debug: 1
+ });
+ assert.equal(index.stats.z1, 4);
+
+ const globalFeature = {
+ type: 'Feature',
+ id: 'global',
+ geometry: {
+ type: 'LineString',
+ coordinates: [[-180, -85], [180, 85]] // spans whole world
+ }
+ };
+
+ index.updateData({add: [globalFeature]});
+ assert.equal(index.stats.z1, 0);
+});
+
+test('updateData: does not invalidate unaffected tiles', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'northwest',
+ geometry: {
+ type: 'Point',
+ coordinates: [-90, 45] // NW quadrant only
+ }
+ },
+ {
+ type: 'Feature',
+ id: 'southeast',
+ geometry: {
+ type: 'Point',
+ coordinates: [90, -45] // SE quadrant only
+ }
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 2,
+ indexMaxPoints: 0
+ });
+
+ const nwTileId = toID(1, 0, 0);
+ const seTileId = toID(1, 1, 1);
+
+ const nwTileBefore = index.tiles[nwTileId];
+ const seTileBefore = index.tiles[seTileId];
+
+ assert.ok(nwTileBefore);
+ assert.ok(seTileBefore);
+
+ const updatedFeature = {
+ type: 'Feature',
+ id: 'northwest',
+ geometry: {
+ type: 'Point',
+ coordinates: [-85, 40] // NW different coordinate
+ }
+ };
+
+ index.updateData({add: [updatedFeature]});
+
+ const nwTileAfter = index.tiles[nwTileId];
+ assert.equal(nwTileAfter, undefined);
+
+ const seTileAfter = index.tiles[seTileId];
+ assert.equal(seTileAfter, seTileBefore);
+});
+
+test('updateData: invalidates and regenerates tiles at multiple zoom levels', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [0, 0],
+ [5, 0],
+ [5, 5],
+ [0, 5],
+ [0, 0]
+ ]]
+ },
+ properties: {name: 'Original'}
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 7,
+ indexMaxPoints: 0
+ });
+
+ const updatedFeature = {
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {
+ type: 'Polygon',
+ coordinates: [[
+ [0, 0],
+ [10, 0],
+ [10, 10],
+ [0, 10],
+ [0, 0]
+ ]]
+ },
+ properties: {name: 'Updated'}
+ };
+
+ index.updateData({add: [updatedFeature]});
+
+ const newZ3Tile = index.getTile(3, 4, 4);
+ const newZ5Tile = index.getTile(5, 16, 16);
+ const newZ7Tile = index.getTile(7, 64, 64);
+
+ assert.ok(newZ3Tile);
+ assert.ok(newZ5Tile);
+ assert.ok(newZ7Tile);
+
+ assert.equal(newZ3Tile.features[0].id, 'feature1');
+ assert.equal(newZ3Tile.features[0].tags.name, 'Updated');
+
+ assert.equal(newZ5Tile.features[0].id, 'feature1');
+ assert.equal(newZ5Tile.features[0].tags.name, 'Updated');
+
+ assert.equal(newZ7Tile.features[0].id, 'feature1');
+ assert.equal(newZ7Tile.features[0].tags.name, 'Updated');
+});
+
+test('updateData: invalidates tiles when feature is within the buffer edge', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [{
+ type: 'Feature',
+ id: 'feature1',
+ geometry: {
+ type: 'Point',
+ coordinates: [-45, 45] // inside tile 1-0-0
+ }
+ }]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 1,
+ indexMaxPoints: 0
+ });
+
+ const tileId = toID(1, 0, 0);
+ index.getTile(1, 0, 0);
+ assert.ok(index.tiles[tileId]);
+
+ const featureWithinBuffer = {
+ type: 'Feature',
+ id: 'buffer-feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [2, 0] // feature within tile buffer edge
+ }
+ };
+
+ index.updateData({add: [featureWithinBuffer]});
+ assert.equal(index.tiles[tileId], undefined);
+});
+
+test('updateData: handles drill-down after update', () => {
+ const initialData = {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'line1',
+ geometry: {
+ type: 'LineString',
+ coordinates: [[0, 0], [5, 5]]
+ }
+ }
+ ]
+ };
+
+ const index = geojsonvt(initialData, {
+ updateable: true,
+ indexMaxZoom: 5
+ });
+
+ const newFeature = {
+ type: 'Feature',
+ id: 'line2',
+ geometry: {
+ type: 'LineString',
+ coordinates: [[0, 0], [6, 6]]
+ }
+ };
+
+ index.updateData({add: [newFeature]});
+
+ const highZoomTile = index.getTile(8, 128, 128);
+ assert.ok(highZoomTile);
+
+ const featureIds = highZoomTile.features.map(f => f.id).sort();
+ assert.deepEqual(featureIds, ['line1', 'line2']);
+});
+
+function toID(z, x, y) {
+ return (((1 << z) * y + x) * 32) + z;
+}