UNPKG

geojson-map-fit-mercator

Version:

Finds the optimal bearing, zoom and center point for fitting a set of GeoJSON features in a Mapbox GL or LibreMap Mercator map.

157 lines (151 loc) 8.19 kB
/*! * geojson-map-fit-mercator v1.1.0 * (c) undefined * Released under the MIT License. */ 'use strict'; var sphericalmercator = require('@mapbox/sphericalmercator'); var bearing = require('@turf/bearing'); var convex = require('@turf/convex'); var invariant = require('@turf/invariant'); var transformRotate = require('@turf/transform-rotate'); var centroid = require('@turf/centroid'); var meta = require('@turf/meta'); var polygonToLine = require('@turf/polygon-to-line'); var envelope = require('@turf/envelope'); var length = require('@turf/length'); function findScreenZoom(paddedScreenDimensions, paddedScreenRatio, boundingRectangleOrientation, maxZoom, floatZoom, merc) { const { shortSide, longSide } = boundingRectangleOrientation; const longSideCoords = invariant.getCoords(longSide); const shortSideCoords = invariant.getCoords(shortSide); // We need to determine the ratio required for the zoom level. To do this we are going to approximate the length // of the longest and shortest sides of the polygon in pixels (This doesn't account for projection distortion but is // a good estimation) const longPx = [merc.px(longSideCoords[0], maxZoom), merc.px(longSideCoords[1], maxZoom)]; const shortPx = [merc.px(shortSideCoords[0], maxZoom), merc.px(shortSideCoords[1], maxZoom)]; // Because these points aren't aligned to the axis, we use the Pythagorean theorem to calculate the distance const longPxX = longPx[0][0] - longPx[1][0]; const longPxY = longPx[0][1] - longPx[1][1]; const shortPxX = shortPx[0][0] - shortPx[1][0]; const shortPxY = shortPx[0][1] - shortPx[1][1]; const longPxDistance = Math.sqrt(Math.pow(longPxX, 2) + Math.pow(longPxY, 2)); const shortPxDistance = Math.sqrt(Math.pow(shortPxX, 2) + Math.pow(shortPxY, 2)); let xPx = longPxDistance; let yPx = shortPxDistance; // If the screen is taller than it is wide, swap the x and y values if (paddedScreenRatio < 1) { xPx = shortPxDistance; yPx = longPxDistance; } const ratios = [Math.abs(xPx / paddedScreenDimensions[0]), Math.abs(yPx / paddedScreenDimensions[1])]; const zoom = Math.min(maxZoom - Math.log(ratios[0]) / Math.log(2), maxZoom - Math.log(ratios[1]) / Math.log(2)); return floatZoom ? zoom : Math.floor(zoom); } function findScreenBearing(boundingRectangleBearing, preferredBearing, screenRatio) { let bearing = boundingRectangleBearing; // Rotate the bearing by 90 degrees if the screen is wider than it is tall if (screenRatio > 1) { bearing = bearing + (90 % 360); } // Rotate the bearing 180 degrees if the preferred bearing is on the opposite side of the screen if (bearing < (preferredBearing - 90) % 360 || bearing > (preferredBearing + 90) % 360) { bearing = (bearing + 180) % 360; } return bearing; } function findScreenCenter(boundingRectangle, bearing, zoom, padding, merc) { const { left = 0, right = 0, top = 0, bottom = 0 } = padding; // Use the bounding rectangle's pixel location to calculate the centre of the // map. This allows us to account for mercator projection distortion. const coords = invariant.getCoords(boundingRectangle); const uniqCoords = coords[0].reduce((uniq, coord) => { if (!uniq.find((c) => c[0] === coord[0] && c[1] === coord[1])) { uniq.push(coord); } return uniq; }, []); const sumCoords = uniqCoords.reduce((acc, coord) => { const [x, y] = merc.px(coord, zoom); acc[0] = acc[0] + x; acc[1] = acc[1] + y; return acc; }, [0, 0]); const midX = sumCoords[0] / uniqCoords.length; const midY = sumCoords[1] / uniqCoords.length; const xPaddingOffset = right - left; const yPaddingOffset = bottom - top; const bearingRadians = bearing * (Math.PI / 180); const centerXOffset = xPaddingOffset * Math.cos(bearingRadians) - yPaddingOffset * Math.sin(bearingRadians); const centerYOffset = xPaddingOffset * Math.sin(bearingRadians) + yPaddingOffset * Math.cos(bearingRadians); return merc.ll([midX + centerXOffset, midY + centerYOffset], zoom); } function mapFitFeatures(features, screenDimensions, options = {}) { // Set default options const { tileSize = 512, preferredBearing = 0, padding = {}, maxZoom = 23, floatZoom = true, } = options; // Create a mercator projection. SphericalMercator caches its calculations so it's safe to create a new instance each run const merc = new sphericalmercator.SphericalMercator({ size: tileSize, antimeridian: true }); const [screenWidth, screenHeight] = screenDimensions; const { left = 0, right = 0, top = 0, bottom = 0 } = padding; const paddedScreenWidth = screenWidth - left - right; const paddedScreenHeight = screenHeight - top - bottom; const paddedScreenRatio = paddedScreenWidth / paddedScreenHeight; // Calculate the bounding rectangle of the features const { boundsOrientation: { orientation, bearing: baseBearing }, boundingRectangle, } = minimumBoundingRectangle(features); if (!boundingRectangle) { throw new Error('Unable to calculate bounding rectangle'); } // Determine how to fit the bounding rectangle to the screen const zoom = findScreenZoom([paddedScreenWidth, paddedScreenHeight], paddedScreenRatio, orientation, maxZoom, floatZoom, merc); const bearing = findScreenBearing(baseBearing, preferredBearing, paddedScreenRatio); const center = findScreenCenter(boundingRectangle, bearing, zoom, padding, merc); return { bearing, zoom, center }; } function minimumBoundingRectangle(geoJsonInput) { // Create a convex hull around the input geometry const convexHull = convex.convex(geoJsonInput); if (!convexHull) throw new Error("Can't determine minimumBoundingRectangle for given geometry"); // Break the hull into its constituent edges and find the smallest const hullLines = polygonToLine(convexHull); const smallestHullBoundsOrientation = meta.segmentReduce(hullLines, (smallestEnvelope, segment) => { return smallestHullEnvelopeReducer(smallestEnvelope, segment, convexHull); }, { bearing: undefined, orientation: { shortSide: undefined, longSide: undefined }, envelope: undefined }); const boundingRectangle = transformRotate(envelope(smallestHullBoundsOrientation.envelope), smallestHullBoundsOrientation.bearing, { pivot: centroid(convexHull), }); return { boundsOrientation: smallestHullBoundsOrientation, boundingRectangle, }; } function smallestHullEnvelopeReducer(smallestEnvelope, segment, hull) { const segmentCoords = invariant.getCoords(segment); const segmentBearing = bearing.bearing(segmentCoords[0], segmentCoords[1]); const rotatedHull = transformRotate(hull, -1 * segmentBearing, { pivot: centroid(hull), }); const envelopeOfHull = envelope(rotatedHull); const rectangleOrientation = findRectangleOrientation(envelopeOfHull); const shortSideLength = length.length(rectangleOrientation.shortSide); if (smallestEnvelope.orientation.shortSide == undefined || shortSideLength < length.length(smallestEnvelope.orientation.shortSide)) { return { bearing: segmentBearing, orientation: rectangleOrientation, envelope: envelopeOfHull }; } return smallestEnvelope; } function findRectangleOrientation(rectangle) { const rectangleSides = polygonToLine(rectangle); return meta.segmentReduce(rectangleSides, (sideOrientation, segment) => { const segmentLength = length.length(segment); if (sideOrientation.shortSide == undefined || length.length(sideOrientation.shortSide) > segmentLength) { sideOrientation.shortSide = segment; } if (sideOrientation.longSide == undefined || length.length(sideOrientation.longSide) < segmentLength) { sideOrientation.longSide = segment; } return sideOrientation; }, { shortSide: undefined, longSide: undefined }); } exports.mapFitFeatures = mapFitFeatures; exports.minimumBoundingRectangle = minimumBoundingRectangle; //# sourceMappingURL=index.cjs.map