UNPKG

cheap-ruler

Version:

A collection of fast approximations to common geographic measurements.

489 lines (429 loc) 16.5 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.CheapRuler = factory()); })(this, (function () { 'use strict'; const factors = { kilometers: 1, miles: 1000 / 1609.344, nauticalmiles: 1000 / 1852, meters: 1000, metres: 1000, yards: 1000 / 0.9144, feet: 1000 / 0.3048, inches: 1000 / 0.0254 }; // Values that define WGS84 ellipsoid model of the Earth const RE = 6378.137; // equatorial radius const FE = 1 / 298.257223563; // flattening const E2 = FE * (2 - FE); const RAD = Math.PI / 180; /** * A collection of very fast approximations to common geodesic measurements. Useful for performance-sensitive code that measures things on a city scale. */ class CheapRuler { /** * Creates a ruler object from tile coordinates (y and z). * * @param {number} y * @param {number} z * @param {keyof typeof factors} [units='kilometers'] * @returns {CheapRuler} * @example * const ruler = cheapRuler.fromTile(1567, 12); * //=ruler */ static fromTile(y, z, units) { const n = Math.PI * (1 - 2 * (y + 0.5) / Math.pow(2, z)); const lat = Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))) / RAD; return new CheapRuler(lat, units); } /** * Multipliers for converting between units. * * @example * // convert 50 meters to yards * 50 * CheapRuler.units.yards / CheapRuler.units.meters; */ static get units() { return factors; } /** * Creates a ruler instance for very fast approximations to common geodesic measurements around a certain latitude. * * @param {number} lat latitude * @param {keyof typeof factors} [units='kilometers'] * @example * const ruler = cheapRuler(35.05, 'miles'); * //=ruler */ constructor(lat, units) { if (lat === undefined) throw new Error('No latitude given.'); if (units && !factors[units]) throw new Error(`Unknown unit ${ units }. Use one of: ${ Object.keys(factors).join(', ')}`); // Curvature formulas from https://en.wikipedia.org/wiki/Earth_radius#Meridional const m = RAD * RE * (units ? factors[units] : 1); const coslat = Math.cos(lat * RAD); const w2 = 1 / (1 - E2 * (1 - coslat * coslat)); const w = Math.sqrt(w2); // multipliers for converting longitude and latitude degrees into distance this.kx = m * w * coslat; // based on normal radius of curvature this.ky = m * w * w2 * (1 - E2); // based on meridonal radius of curvature } /** * Given two points of the form [longitude, latitude], returns the distance. * * @param {[number, number]} a point [longitude, latitude] * @param {[number, number]} b point [longitude, latitude] * @returns {number} distance * @example * const distance = ruler.distance([30.5, 50.5], [30.51, 50.49]); * //=distance */ distance(a, b) { const dx = wrap(a[0] - b[0]) * this.kx; const dy = (a[1] - b[1]) * this.ky; return Math.sqrt(dx * dx + dy * dy); } /** * Returns the bearing between two points in angles. * * @param {[number, number]} a point [longitude, latitude] * @param {[number, number]} b point [longitude, latitude] * @returns {number} bearing * @example * const bearing = ruler.bearing([30.5, 50.5], [30.51, 50.49]); * //=bearing */ bearing(a, b) { const dx = wrap(b[0] - a[0]) * this.kx; const dy = (b[1] - a[1]) * this.ky; return Math.atan2(dx, dy) / RAD; } /** * Returns a new point given distance and bearing from the starting point. * * @param {[number, number]} p point [longitude, latitude] * @param {number} dist distance * @param {number} bearing * @returns {[number, number]} point [longitude, latitude] * @example * const point = ruler.destination([30.5, 50.5], 0.1, 90); * //=point */ destination(p, dist, bearing) { const a = bearing * RAD; return this.offset(p, Math.sin(a) * dist, Math.cos(a) * dist); } /** * Returns a new point given easting and northing offsets (in ruler units) from the starting point. * * @param {[number, number]} p point [longitude, latitude] * @param {number} dx easting * @param {number} dy northing * @returns {[number, number]} point [longitude, latitude] * @example * const point = ruler.offset([30.5, 50.5], 10, 10); * //=point */ offset(p, dx, dy) { return [ p[0] + dx / this.kx, p[1] + dy / this.ky ]; } /** * Given a line (an array of points), returns the total line distance. * * @param {[number, number][]} points [longitude, latitude] * @returns {number} total line distance * @example * const length = ruler.lineDistance([ * [-67.031, 50.458], [-67.031, 50.534], * [-66.929, 50.534], [-66.929, 50.458] * ]); * //=length */ lineDistance(points) { let total = 0; for (let i = 0; i < points.length - 1; i++) { total += this.distance(points[i], points[i + 1]); } return total; } /** * Given a polygon (an array of rings, where each ring is an array of points), returns the area. * * @param {[number, number][][]} polygon * @returns {number} area value in the specified units (square kilometers by default) * @example * const area = ruler.area([[ * [-67.031, 50.458], [-67.031, 50.534], [-66.929, 50.534], * [-66.929, 50.458], [-67.031, 50.458] * ]]); * //=area */ area(polygon) { let sum = 0; for (let i = 0; i < polygon.length; i++) { const ring = polygon[i]; for (let j = 0, len = ring.length, k = len - 1; j < len; k = j++) { sum += wrap(ring[j][0] - ring[k][0]) * (ring[j][1] + ring[k][1]) * (i ? -1 : 1); } } return (Math.abs(sum) / 2) * this.kx * this.ky; } /** * Returns the point at a specified distance along the line. * * @param {[number, number][]} line * @param {number} dist distance * @returns {[number, number]} point [longitude, latitude] * @example * const point = ruler.along(line, 2.5); * //=point */ along(line, dist) { let sum = 0; if (dist <= 0) return line[0]; for (let i = 0; i < line.length - 1; i++) { const p0 = line[i]; const p1 = line[i + 1]; const d = this.distance(p0, p1); sum += d; if (sum > dist) return interpolate(p0, p1, (dist - (sum - d)) / d); } return line[line.length - 1]; } /** * Returns the distance from a point `p` to a line segment `a` to `b`. * * @pointToSegmentDistance * @param {[number, number]} p point [longitude, latitude] * @param {[number, number]} a segment point 1 [longitude, latitude] * @param {[number, number]} b segment point 2 [longitude, latitude] * @returns {number} distance * @example * const distance = ruler.pointToSegmentDistance([-67.04, 50.5], [-67.05, 50.57], [-67.03, 50.54]); * //=distance */ pointToSegmentDistance(p, a, b) { let [x, y] = a; let dx = wrap(b[0] - x) * this.kx; let dy = (b[1] - y) * this.ky; if (dx !== 0 || dy !== 0) { const t = (wrap(p[0] - x) * this.kx * dx + (p[1] - y) * this.ky * dy) / (dx * dx + dy * dy); if (t > 1) { x = b[0]; y = b[1]; } else if (t > 0) { x += (dx / this.kx) * t; y += (dy / this.ky) * t; } } dx = wrap(p[0] - x) * this.kx; dy = (p[1] - y) * this.ky; return Math.sqrt(dx * dx + dy * dy); } /** * Returns an object of the form {point, index, t}, where point is closest point on the line * from the given point, index is the start index of the segment with the closest point, * and t is a parameter from 0 to 1 that indicates where the closest point is on that segment. * * @param {[number, number][]} line * @param {[number, number]} p point [longitude, latitude] * @returns {{point: [number, number], index: number, t: number}} {point, index, t} * @example * const point = ruler.pointOnLine(line, [-67.04, 50.5]).point; * //=point */ pointOnLine(line, p) { let minDist = Infinity; let minX = line[0][0]; let minY = line[0][1]; let minI = 0; let minT = 0; for (let i = 0; i < line.length - 1; i++) { let x = line[i][0]; let y = line[i][1]; let dx = wrap(line[i + 1][0] - x) * this.kx; let dy = (line[i + 1][1] - y) * this.ky; let t = 0; if (dx !== 0 || dy !== 0) { t = (wrap(p[0] - x) * this.kx * dx + (p[1] - y) * this.ky * dy) / (dx * dx + dy * dy); if (t > 1) { x = line[i + 1][0]; y = line[i + 1][1]; } else if (t > 0) { x += (dx / this.kx) * t; y += (dy / this.ky) * t; } } dx = wrap(p[0] - x) * this.kx; dy = (p[1] - y) * this.ky; const sqDist = dx * dx + dy * dy; if (sqDist < minDist) { minDist = sqDist; minX = x; minY = y; minI = i; minT = t; } } return { point: [minX, minY], index: minI, t: Math.max(0, Math.min(1, minT)) }; } /** * Returns a part of the given line between the start and the stop points (or their closest points on the line). * * @param {[number, number]} start point [longitude, latitude] * @param {[number, number]} stop point [longitude, latitude] * @param {[number, number][]} line * @returns {[number, number][]} line part of a line * @example * const line2 = ruler.lineSlice([-67.04, 50.5], [-67.05, 50.56], line1); * //=line2 */ lineSlice(start, stop, line) { let p1 = this.pointOnLine(line, start); let p2 = this.pointOnLine(line, stop); if (p1.index > p2.index || (p1.index === p2.index && p1.t > p2.t)) { const tmp = p1; p1 = p2; p2 = tmp; } const slice = [p1.point]; const l = p1.index + 1; const r = p2.index; if (!equals(line[l], slice[0]) && l <= r) slice.push(line[l]); for (let i = l + 1; i <= r; i++) { slice.push(line[i]); } if (!equals(line[r], p2.point)) slice.push(p2.point); return slice; } /** * Returns a part of the given line between the start and the stop points indicated by distance along the line. * * @param {number} start start distance * @param {number} stop stop distance * @param {[number, number][]} line * @returns {[number, number][]} part of a line * @example * const line2 = ruler.lineSliceAlong(10, 20, line1); * //=line2 */ lineSliceAlong(start, stop, line) { let sum = 0; const slice = []; for (let i = 0; i < line.length - 1; i++) { const p0 = line[i]; const p1 = line[i + 1]; const d = this.distance(p0, p1); sum += d; if (sum > start && slice.length === 0) { slice.push(interpolate(p0, p1, (start - (sum - d)) / d)); } if (sum >= stop) { slice.push(interpolate(p0, p1, (stop - (sum - d)) / d)); return slice; } if (sum > start) slice.push(p1); } return slice; } /** * Given a point, returns a bounding box object ([w, s, e, n]) created from the given point buffered by a given distance. * * @param {[number, number]} p point [longitude, latitude] * @param {number} buffer * @returns {[number, number, number, number]} bbox ([w, s, e, n]) * @example * const bbox = ruler.bufferPoint([30.5, 50.5], 0.01); * //=bbox */ bufferPoint(p, buffer) { const v = buffer / this.ky; const h = buffer / this.kx; return [ p[0] - h, p[1] - v, p[0] + h, p[1] + v ]; } /** * Given a bounding box, returns the box buffered by a given distance. * * @param {[number, number, number, number]} bbox ([w, s, e, n]) * @param {number} buffer * @returns {[number, number, number, number]} bbox ([w, s, e, n]) * @example * const bbox = ruler.bufferBBox([30.5, 50.5, 31, 51], 0.2); * //=bbox */ bufferBBox(bbox, buffer) { const v = buffer / this.ky; const h = buffer / this.kx; return [ bbox[0] - h, bbox[1] - v, bbox[2] + h, bbox[3] + v ]; } /** * Returns true if the given point is inside in the given bounding box, otherwise false. * * @param {[number, number]} p point [longitude, latitude] * @param {[number, number, number, number]} bbox ([w, s, e, n]) * @returns {boolean} * @example * const inside = ruler.insideBBox([30.5, 50.5], [30, 50, 31, 51]); * //=inside */ insideBBox(p, bbox) { // eslint-disable-line return wrap(p[0] - bbox[0]) >= 0 && wrap(p[0] - bbox[2]) <= 0 && p[1] >= bbox[1] && p[1] <= bbox[3]; } } /** * @param {[number, number]} a * @param {[number, number]} b */ function equals(a, b) { return a[0] === b[0] && a[1] === b[1]; } /** * @param {[number, number]} a * @param {[number, number]} b * @param {number} t * @returns {[number, number]} */ function interpolate(a, b, t) { const dx = wrap(b[0] - a[0]); const dy = b[1] - a[1]; return [ a[0] + dx * t, a[1] + dy * t ]; } /** * normalize a degree value into [-180..180] range * @param {number} deg */ function wrap(deg) { while (deg < -180) deg += 360; while (deg > 180) deg -= 360; return deg; } return CheapRuler; }));