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; +}