UNPKG

geojson-vt

Version:

Slice GeoJSON data into vector tiles efficiently

890 lines (724 loc) 28 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.geojsonvt = factory()); })(this, (function () { 'use strict'; // calculate simplification data using optimized Douglas-Peucker algorithm function simplify(coords, first, last, sqTolerance) { let maxSqDist = sqTolerance; const mid = first + ((last - first) >> 1); let minPosToMid = last - first; let index; const ax = coords[first]; const ay = coords[first + 1]; const bx = coords[last]; const by = coords[last + 1]; for (let i = first + 3; i < last; i += 3) { const d = getSqSegDist(coords[i], coords[i + 1], ax, ay, bx, by); if (d > maxSqDist) { index = i; maxSqDist = d; } else if (d === maxSqDist) { // a workaround to ensure we choose a pivot close to the middle of the list, // reducing recursion depth, for certain degenerate inputs // https://github.com/mapbox/geojson-vt/issues/104 const posToMid = Math.abs(i - mid); if (posToMid < minPosToMid) { index = i; minPosToMid = posToMid; } } } if (maxSqDist > sqTolerance) { if (index - first > 3) simplify(coords, first, index, sqTolerance); coords[index + 2] = maxSqDist; if (last - index > 3) simplify(coords, index, last, sqTolerance); } } // square distance from a point to a segment function getSqSegDist(px, py, x, y, bx, by) { let dx = bx - x; let dy = by - y; if (dx !== 0 || dy !== 0) { const t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); if (t > 1) { x = bx; y = by; } else if (t > 0) { x += dx * t; y += dy * t; } } dx = px - x; dy = py - y; return dx * dx + dy * dy; } function createFeature(id, type, geom, tags) { const feature = { id: id == null ? null : id, type, geometry: geom, tags, minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; if (type === 'Point' || type === 'MultiPoint' || type === 'LineString') { calcLineBBox(feature, geom); } else if (type === 'Polygon') { // the outer ring (ie [0]) contains all inner rings calcLineBBox(feature, geom[0]); } else if (type === 'MultiLineString') { for (const line of geom) { calcLineBBox(feature, line); } } else if (type === 'MultiPolygon') { for (const polygon of geom) { // the outer ring (ie [0]) contains all inner rings calcLineBBox(feature, polygon[0]); } } return feature; } function calcLineBBox(feature, geom) { for (let i = 0; i < geom.length; i += 3) { feature.minX = Math.min(feature.minX, geom[i]); feature.minY = Math.min(feature.minY, geom[i + 1]); feature.maxX = Math.max(feature.maxX, geom[i]); feature.maxY = Math.max(feature.maxY, geom[i + 1]); } } // converts GeoJSON feature into an intermediate projected JSON vector format with simplification data function convert(data, options) { const features = []; if (data.type === 'FeatureCollection') { for (let i = 0; i < data.features.length; i++) { convertFeature(features, data.features[i], options, i); } } else if (data.type === 'Feature') { convertFeature(features, data, options); } else { // single geometry or a geometry collection convertFeature(features, {geometry: data}, options); } return features; } function convertFeature(features, geojson, options, index) { if (!geojson.geometry) return; const coords = geojson.geometry.coordinates; if (coords && coords.length === 0) return; const type = geojson.geometry.type; const tolerance = Math.pow(options.tolerance / ((1 << options.maxZoom) * options.extent), 2); let geometry = []; let id = geojson.id; if (options.promoteId) { id = geojson.properties[options.promoteId]; } else if (options.generateId) { id = index || 0; } if (type === 'Point') { convertPoint(coords, geometry); } else if (type === 'MultiPoint') { for (const p of coords) { convertPoint(p, geometry); } } else if (type === 'LineString') { convertLine(coords, geometry, tolerance, false); } else if (type === 'MultiLineString') { if (options.lineMetrics) { // explode into linestrings to be able to track metrics for (const line of coords) { geometry = []; convertLine(line, geometry, tolerance, false); features.push(createFeature(id, 'LineString', geometry, geojson.properties)); } return; } else { convertLines(coords, geometry, tolerance, false); } } else if (type === 'Polygon') { convertLines(coords, geometry, tolerance, true); } else if (type === 'MultiPolygon') { for (const polygon of coords) { const newPolygon = []; convertLines(polygon, newPolygon, tolerance, true); geometry.push(newPolygon); } } else if (type === 'GeometryCollection') { for (const singleGeometry of geojson.geometry.geometries) { convertFeature(features, { id, geometry: singleGeometry, properties: geojson.properties }, options, index); } return; } else { throw new Error('Input data is not a valid GeoJSON object.'); } features.push(createFeature(id, type, geometry, geojson.properties)); } function convertPoint(coords, out) { out.push(projectX(coords[0]), projectY(coords[1]), 0); } function convertLine(ring, out, tolerance, isPolygon) { let x0, y0; let size = 0; for (let j = 0; j < ring.length; j++) { const x = projectX(ring[j][0]); const y = projectY(ring[j][1]); out.push(x, y, 0); if (j > 0) { if (isPolygon) { size += (x0 * y - x * y0) / 2; // area } else { size += Math.sqrt(Math.pow(x - x0, 2) + Math.pow(y - y0, 2)); // length } } x0 = x; y0 = y; } const last = out.length - 3; out[2] = 1; simplify(out, 0, last, tolerance); out[last + 2] = 1; out.size = Math.abs(size); out.start = 0; out.end = out.size; } function convertLines(rings, out, tolerance, isPolygon) { for (let i = 0; i < rings.length; i++) { const geom = []; convertLine(rings[i], geom, tolerance, isPolygon); out.push(geom); } } 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; } /* clip features between two vertical or horizontal axis-parallel lines: * | | * ___|___ | / * / | \____|____/ * | | * * k1 and k2 are the line coordinates * axis: 0 for x, 1 for y * minAll and maxAll: minimum and maximum coordinate value for all features */ function clip(features, scale, k1, k2, axis, minAll, maxAll, options) { k1 /= scale; k2 /= scale; if (minAll >= k1 && maxAll < k2) return features; // trivial accept else if (maxAll < k1 || minAll >= k2) return null; // trivial reject const clipped = []; for (const feature of features) { const geometry = feature.geometry; let type = feature.type; const min = axis === 0 ? feature.minX : feature.minY; const max = axis === 0 ? feature.maxX : feature.maxY; if (min >= k1 && max < k2) { // trivial accept clipped.push(feature); continue; } else if (max < k1 || min >= k2) { // trivial reject continue; } let newGeometry = []; if (type === 'Point' || type === 'MultiPoint') { clipPoints(geometry, newGeometry, k1, k2, axis); } else if (type === 'LineString') { clipLine(geometry, newGeometry, k1, k2, axis, false, options.lineMetrics); } else if (type === 'MultiLineString') { clipLines(geometry, newGeometry, k1, k2, axis, false); } else if (type === 'Polygon') { clipLines(geometry, newGeometry, k1, k2, axis, true); } else if (type === 'MultiPolygon') { for (const polygon of geometry) { const newPolygon = []; clipLines(polygon, newPolygon, k1, k2, axis, true); if (newPolygon.length) { newGeometry.push(newPolygon); } } } if (newGeometry.length) { if (options.lineMetrics && type === 'LineString') { for (const line of newGeometry) { clipped.push(createFeature(feature.id, type, line, feature.tags)); } continue; } if (type === 'LineString' || type === 'MultiLineString') { if (newGeometry.length === 1) { type = 'LineString'; newGeometry = newGeometry[0]; } else { type = 'MultiLineString'; } } if (type === 'Point' || type === 'MultiPoint') { type = newGeometry.length === 3 ? 'Point' : 'MultiPoint'; } clipped.push(createFeature(feature.id, type, newGeometry, feature.tags)); } } return clipped.length ? clipped : null; } function clipPoints(geom, newGeom, k1, k2, axis) { for (let i = 0; i < geom.length; i += 3) { const a = geom[i + axis]; if (a >= k1 && a <= k2) { addPoint(newGeom, geom[i], geom[i + 1], geom[i + 2]); } } } function clipLine(geom, newGeom, k1, k2, axis, isPolygon, trackMetrics) { let slice = newSlice(geom); const intersect = axis === 0 ? intersectX : intersectY; let len = geom.start; let segLen, t; for (let i = 0; i < geom.length - 3; i += 3) { const ax = geom[i]; const ay = geom[i + 1]; const az = geom[i + 2]; const bx = geom[i + 3]; const by = geom[i + 4]; const a = axis === 0 ? ax : ay; const b = axis === 0 ? bx : by; let exited = false; if (trackMetrics) segLen = Math.sqrt(Math.pow(ax - bx, 2) + Math.pow(ay - by, 2)); if (a < k1) { // ---|--> | (line enters the clip region from the left) if (b > k1) { t = intersect(slice, ax, ay, bx, by, k1); if (trackMetrics) slice.start = len + segLen * t; } } else if (a > k2) { // | <--|--- (line enters the clip region from the right) if (b < k2) { t = intersect(slice, ax, ay, bx, by, k2); if (trackMetrics) slice.start = len + segLen * t; } } else { addPoint(slice, ax, ay, az); } if (b < k1 && a >= k1) { // <--|--- | or <--|-----|--- (line exits the clip region on the left) t = intersect(slice, ax, ay, bx, by, k1); exited = true; } if (b > k2 && a <= k2) { // | ---|--> or ---|-----|--> (line exits the clip region on the right) t = intersect(slice, ax, ay, bx, by, k2); exited = true; } if (!isPolygon && exited) { if (trackMetrics) slice.end = len + segLen * t; newGeom.push(slice); slice = newSlice(geom); } if (trackMetrics) len += segLen; } // add the last point let last = geom.length - 3; const ax = geom[last]; const ay = geom[last + 1]; const az = geom[last + 2]; const a = axis === 0 ? ax : ay; if (a >= k1 && a <= k2) addPoint(slice, ax, ay, az); // close the polygon if its endpoints are not the same after clipping last = slice.length - 3; if (isPolygon && last >= 3 && (slice[last] !== slice[0] || slice[last + 1] !== slice[1])) { addPoint(slice, slice[0], slice[1], slice[2]); } // add the final slice if (slice.length) { newGeom.push(slice); } } function newSlice(line) { const slice = []; slice.size = line.size; slice.start = line.start; slice.end = line.end; return slice; } function clipLines(geom, newGeom, k1, k2, axis, isPolygon) { for (const line of geom) { clipLine(line, newGeom, k1, k2, axis, isPolygon, false); } } function addPoint(out, x, y, z) { out.push(x, y, z); } function intersectX(out, ax, ay, bx, by, x) { const t = (x - ax) / (bx - ax); addPoint(out, x, ay + (by - ay) * t, 1); return t; } function intersectY(out, ax, ay, bx, by, y) { const t = (y - ay) / (by - ay); addPoint(out, ax + (bx - ax) * t, y, 1); return t; } function wrap(features, options) { const buffer = options.buffer / options.extent; let merged = features; const left = clip(features, 1, -1 - buffer, buffer, 0, -1, 2, options); // left world copy const right = clip(features, 1, 1 - buffer, 2 + buffer, 0, -1, 2, options); // right world copy if (left || right) { merged = clip(features, 1, -buffer, 1 + buffer, 0, -1, 2, options) || []; // center world copy if (left) merged = shiftFeatureCoords(left, 1).concat(merged); // merge left into center if (right) merged = merged.concat(shiftFeatureCoords(right, -1)); // merge right into center } return merged; } function shiftFeatureCoords(features, offset) { const newFeatures = []; for (let i = 0; i < features.length; i++) { const feature = features[i]; const type = feature.type; let newGeometry; if (type === 'Point' || type === 'MultiPoint' || type === 'LineString') { newGeometry = shiftCoords(feature.geometry, offset); } else if (type === 'MultiLineString' || type === 'Polygon') { newGeometry = []; for (const line of feature.geometry) { newGeometry.push(shiftCoords(line, offset)); } } else if (type === 'MultiPolygon') { newGeometry = []; for (const polygon of feature.geometry) { const newPolygon = []; for (const line of polygon) { newPolygon.push(shiftCoords(line, offset)); } newGeometry.push(newPolygon); } } newFeatures.push(createFeature(feature.id, type, newGeometry, feature.tags)); } return newFeatures; } function shiftCoords(points, offset) { const newPoints = []; newPoints.size = points.size; if (points.start !== undefined) { newPoints.start = points.start; newPoints.end = points.end; } for (let i = 0; i < points.length; i += 3) { newPoints.push(points[i] + offset, points[i + 1], points[i + 2]); } return newPoints; } // Transforms the coordinates of each feature in the given tile from // mercator-projected space into (extent x extent) tile space. function transformTile(tile, extent) { if (tile.transformed) return tile; const z2 = 1 << tile.z; const tx = tile.x; const ty = tile.y; for (const feature of tile.features) { const geom = feature.geometry; const type = feature.type; feature.geometry = []; if (type === 1) { for (let j = 0; j < geom.length; j += 2) { feature.geometry.push(transformPoint(geom[j], geom[j + 1], extent, z2, tx, ty)); } } else { for (let j = 0; j < geom.length; j++) { const ring = []; for (let k = 0; k < geom[j].length; k += 2) { ring.push(transformPoint(geom[j][k], geom[j][k + 1], extent, z2, tx, ty)); } feature.geometry.push(ring); } } } tile.transformed = true; return tile; } function transformPoint(x, y, extent, z2, tx, ty) { return [ Math.round(extent * (x * z2 - tx)), Math.round(extent * (y * z2 - ty))]; } function createTile(features, z, tx, ty, options) { const tolerance = z === options.maxZoom ? 0 : options.tolerance / ((1 << z) * options.extent); const tile = { features: [], numPoints: 0, numSimplified: 0, numFeatures: features.length, source: null, x: tx, y: ty, z, transformed: false, minX: 2, minY: 1, maxX: -1, maxY: 0 }; for (const feature of features) { addFeature(tile, feature, tolerance, options); } return tile; } function addFeature(tile, feature, tolerance, options) { const geom = feature.geometry; const type = feature.type; const simplified = []; tile.minX = Math.min(tile.minX, feature.minX); tile.minY = Math.min(tile.minY, feature.minY); tile.maxX = Math.max(tile.maxX, feature.maxX); tile.maxY = Math.max(tile.maxY, feature.maxY); if (type === 'Point' || type === 'MultiPoint') { for (let i = 0; i < geom.length; i += 3) { simplified.push(geom[i], geom[i + 1]); tile.numPoints++; tile.numSimplified++; } } else if (type === 'LineString') { addLine(simplified, geom, tile, tolerance, false, false); } else if (type === 'MultiLineString' || type === 'Polygon') { for (let i = 0; i < geom.length; i++) { addLine(simplified, geom[i], tile, tolerance, type === 'Polygon', i === 0); } } else if (type === 'MultiPolygon') { for (let k = 0; k < geom.length; k++) { const polygon = geom[k]; for (let i = 0; i < polygon.length; i++) { addLine(simplified, polygon[i], tile, tolerance, true, i === 0); } } } if (simplified.length) { let tags = feature.tags || null; if (type === 'LineString' && options.lineMetrics) { tags = {}; for (const key in feature.tags) tags[key] = feature.tags[key]; tags['mapbox_clip_start'] = geom.start / geom.size; tags['mapbox_clip_end'] = geom.end / geom.size; } const tileFeature = { geometry: simplified, type: type === 'Polygon' || type === 'MultiPolygon' ? 3 : (type === 'LineString' || type === 'MultiLineString' ? 2 : 1), tags }; if (feature.id !== null) { tileFeature.id = feature.id; } tile.features.push(tileFeature); } } function addLine(result, geom, tile, tolerance, isPolygon, isOuter) { const sqTolerance = tolerance * tolerance; if (tolerance > 0 && (geom.size < (isPolygon ? sqTolerance : tolerance))) { tile.numPoints += geom.length / 3; return; } const ring = []; for (let i = 0; i < geom.length; i += 3) { if (tolerance === 0 || geom[i + 2] > sqTolerance) { tile.numSimplified++; ring.push(geom[i], geom[i + 1]); } tile.numPoints++; } if (isPolygon) rewind(ring, isOuter); result.push(ring); } function rewind(ring, clockwise) { let area = 0; for (let i = 0, len = ring.length, j = len - 2; i < len; j = i, i += 2) { area += (ring[i] - ring[j]) * (ring[i + 1] + ring[j + 1]); } if (area > 0 === clockwise) { for (let i = 0, len = ring.length; i < len / 2; i += 2) { const x = ring[i]; const y = ring[i + 1]; ring[i] = ring[len - 2 - i]; ring[i + 1] = ring[len - 1 - i]; ring[len - 2 - i] = x; ring[len - 1 - i] = y; } } } const defaultOptions = { maxZoom: 14, // max zoom to preserve detail on indexMaxZoom: 5, // max zoom in the tile index indexMaxPoints: 100000, // max number of points per tile in the tile index tolerance: 3, // simplification tolerance (higher means simpler) extent: 4096, // tile extent buffer: 64, // tile buffer on each side 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 debug: 0 // logging level (0, 1 or 2) }; class GeoJSONVT { constructor(data, options) { options = this.options = extend(Object.create(defaultOptions), options); const debug = options.debug; if (debug) console.time('preprocess data'); if (options.maxZoom < 0 || options.maxZoom > 24) throw new Error('maxZoom should be in the 0-24 range'); if (options.promoteId && options.generateId) throw new Error('promoteId and generateId cannot be used together.'); // projects and adds simplification info let features = convert(data, options); // tiles and tileCoords are part of the public API this.tiles = {}; this.tileCoords = []; if (debug) { console.timeEnd('preprocess data'); console.log('index: maxZoom: %d, maxPoints: %d', options.indexMaxZoom, options.indexMaxPoints); console.time('generate tiles'); this.stats = {}; this.total = 0; } // wraps features (ie extreme west and extreme east) features = wrap(features, options); // start slicing from the top tile down if (features.length) this.splitTile(features, 0, 0, 0); if (debug) { if (features.length) console.log('features: %d, points: %d', this.tiles[0].numFeatures, this.tiles[0].numPoints); console.timeEnd('generate tiles'); console.log('tiles generated:', this.total, JSON.stringify(this.stats)); } } // splits features from a parent tile to sub-tiles. // z, x, and y are the coordinates of the parent tile // cz, cx, and cy are the coordinates of the target tile // // If no target tile is specified, splitting stops when we reach the maximum // zoom or the number of points is low as specified in the options. splitTile(features, z, x, y, cz, cx, cy) { const stack = [features, z, x, y]; const options = this.options; const debug = options.debug; // avoid recursion by using a processing queue while (stack.length) { y = stack.pop(); x = stack.pop(); z = stack.pop(); features = stack.pop(); const z2 = 1 << z; const id = toID(z, x, y); let tile = this.tiles[id]; if (!tile) { if (debug > 1) console.time('creation'); tile = this.tiles[id] = createTile(features, z, x, y, options); this.tileCoords.push({z, x, y}); if (debug) { if (debug > 1) { console.log('tile z%d-%d-%d (features: %d, points: %d, simplified: %d)', z, x, y, tile.numFeatures, tile.numPoints, tile.numSimplified); console.timeEnd('creation'); } const key = `z${ z}`; this.stats[key] = (this.stats[key] || 0) + 1; this.total++; } } // save reference to original geometry in tile so that we can drill down later if we stop now tile.source = features; // if it's the first-pass tiling if (cz == null) { // stop tiling if we reached max zoom, or if the tile is too simple if (z === options.indexMaxZoom || tile.numPoints <= options.indexMaxPoints) continue; // if a drilldown to a specific tile } else if (z === options.maxZoom || z === cz) { // stop tiling if we reached base zoom or our target tile zoom continue; } else if (cz != null) { // stop tiling if it's not an ancestor of the target tile const zoomSteps = cz - z; if (x !== cx >> zoomSteps || y !== cy >> zoomSteps) continue; } // if we slice further down, no need to keep source geometry tile.source = null; if (features.length === 0) continue; if (debug > 1) console.time('clipping'); // values we'll use for clipping const k1 = 0.5 * options.buffer / options.extent; const k2 = 0.5 - k1; const k3 = 0.5 + k1; const k4 = 1 + k1; let tl = null; let bl = null; let tr = null; let br = null; let left = clip(features, z2, x - k1, x + k3, 0, tile.minX, tile.maxX, options); let right = clip(features, z2, x + k2, x + k4, 0, tile.minX, tile.maxX, options); features = null; if (left) { tl = clip(left, z2, y - k1, y + k3, 1, tile.minY, tile.maxY, options); bl = clip(left, z2, y + k2, y + k4, 1, tile.minY, tile.maxY, options); left = null; } if (right) { tr = clip(right, z2, y - k1, y + k3, 1, tile.minY, tile.maxY, options); br = clip(right, z2, y + k2, y + k4, 1, tile.minY, tile.maxY, options); right = null; } if (debug > 1) console.timeEnd('clipping'); stack.push(tl || [], z + 1, x * 2, y * 2); stack.push(bl || [], z + 1, x * 2, y * 2 + 1); stack.push(tr || [], z + 1, x * 2 + 1, y * 2); stack.push(br || [], z + 1, x * 2 + 1, y * 2 + 1); } } getTile(z, x, y) { z = +z; x = +x; y = +y; const options = this.options; const {extent, debug} = options; if (z < 0 || z > 24) return null; const z2 = 1 << z; x = (x + z2) & (z2 - 1); // wrap tile x coordinate const id = toID(z, x, y); if (this.tiles[id]) return transformTile(this.tiles[id], extent); if (debug > 1) console.log('drilling down to z%d-%d-%d', z, x, y); let z0 = z; let x0 = x; let y0 = y; let parent; while (!parent && z0 > 0) { z0--; x0 = x0 >> 1; y0 = y0 >> 1; parent = this.tiles[toID(z0, x0, y0)]; } if (!parent || !parent.source) return null; // if we found a parent tile containing the original geometry, we can drill down from it if (debug > 1) { console.log('found parent tile z%d-%d-%d', z0, x0, y0); console.time('drilling down'); } this.splitTile(parent.source, z0, x0, y0, z, x, y); if (debug > 1) console.timeEnd('drilling down'); return this.tiles[id] ? transformTile(this.tiles[id], extent) : null; } } function toID(z, x, y) { return (((1 << z) * y + x) * 32) + z; } function extend(dest, src) { for (const i in src) dest[i] = src[i]; return dest; } function geojsonvt(data, options) { return new GeoJSONVT(data, options); } return geojsonvt; }));