UNPKG

maplibre-graticule

Version:

Graticuel / Grid plugin for MapLibre GL JS / Mapbox GL JS

750 lines (705 loc) 26.1 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.MaplibreGraticule = factory()); }(this, (function () { 'use strict'; /** * @module helpers */ /** * Earth Radius used with the Harvesine formula and approximates using a spherical (non-ellipsoid) Earth. * * @memberof helpers * @type {number} */ var earthRadius = 6371008.8; /** * Unit of measurement factors using a spherical (non-ellipsoid) earth radius. * * @memberof helpers * @type {Object} */ var factors = { centimeters: earthRadius * 100, centimetres: earthRadius * 100, degrees: earthRadius / 111325, feet: earthRadius * 3.28084, inches: earthRadius * 39.37, kilometers: earthRadius / 1000, kilometres: earthRadius / 1000, meters: earthRadius, metres: earthRadius, miles: earthRadius / 1609.344, millimeters: earthRadius * 1000, millimetres: earthRadius * 1000, nauticalmiles: earthRadius / 1852, radians: 1, yards: earthRadius / 1.0936 }; /** * Wraps a GeoJSON {@link Geometry} in a GeoJSON {@link Feature}. * * @name feature * @param {Geometry} geometry input geometry * @param {Object} [properties={}] an Object of key-value pairs to add as properties * @param {Object} [options={}] Optional Parameters * @param {Array<number>} [options.bbox] Bounding Box Array [west, south, east, north] associated with the Feature * @param {string|number} [options.id] Identifier associated with the Feature * @returns {Feature} a GeoJSON Feature * @example * var geometry = { * "type": "Point", * "coordinates": [110, 50] * }; * * var feature = turf.feature(geometry); * * //=feature */ function feature(geom, properties, options) { if (options === void 0) { options = {}; } var feat = { type: "Feature" }; if (options.id === 0 || options.id) { feat.id = options.id; } if (options.bbox) { feat.bbox = options.bbox; } feat.properties = properties || {}; feat.geometry = geom; return feat; } /** * Creates a {@link Point} {@link Feature} from a Position. * * @name point * @param {Array<number>} coordinates longitude, latitude position (each in decimal degrees) * @param {Object} [properties={}] an Object of key-value pairs to add as properties * @param {Object} [options={}] Optional Parameters * @param {Array<number>} [options.bbox] Bounding Box Array [west, south, east, north] associated with the Feature * @param {string|number} [options.id] Identifier associated with the Feature * @returns {Feature<Point>} a Point feature * @example * var point = turf.point([-75.343, 39.984]); * * //=point */ function point(coordinates, properties, options) { if (options === void 0) { options = {}; } if (!coordinates) { throw new Error("coordinates is required"); } if (!Array.isArray(coordinates)) { throw new Error("coordinates must be an Array"); } if (coordinates.length < 2) { throw new Error("coordinates must be at least 2 numbers long"); } if (!isNumber(coordinates[0]) || !isNumber(coordinates[1])) { throw new Error("coordinates must contain numbers"); } var geom = { type: "Point", coordinates: coordinates }; return feature(geom, properties, options); } /** * Convert a distance measurement (assuming a spherical Earth) from radians to a more friendly unit. * Valid units: miles, nauticalmiles, inches, yards, meters, metres, kilometers, centimeters, feet * * @name radiansToLength * @param {number} radians in radians across the sphere * @param {string} [units="kilometers"] can be degrees, radians, miles, inches, yards, metres, * meters, kilometres, kilometers. * @returns {number} distance */ function radiansToLength(radians, units) { if (units === void 0) { units = "kilometers"; } var factor = factors[units]; if (!factor) { throw new Error(units + " units is invalid"); } return radians * factor; } /** * Convert a distance measurement (assuming a spherical Earth) from a real-world unit into radians * Valid units: miles, nauticalmiles, inches, yards, meters, metres, kilometers, centimeters, feet * * @name lengthToRadians * @param {number} distance in real units * @param {string} [units="kilometers"] can be degrees, radians, miles, inches, yards, metres, * meters, kilometres, kilometers. * @returns {number} radians */ function lengthToRadians(distance, units) { if (units === void 0) { units = "kilometers"; } var factor = factors[units]; if (!factor) { throw new Error(units + " units is invalid"); } return distance / factor; } /** * Converts an angle in radians to degrees * * @name radiansToDegrees * @param {number} radians angle in radians * @returns {number} degrees between 0 and 360 degrees */ function radiansToDegrees(radians) { var degrees = radians % (2 * Math.PI); return degrees * 180 / Math.PI; } /** * Converts an angle in degrees to radians * * @name degreesToRadians * @param {number} degrees angle between 0 and 360 degrees * @returns {number} angle in radians */ function degreesToRadians(degrees) { var radians = degrees % 360; return radians * Math.PI / 180; } /** * isNumber * * @param {*} num Number to validate * @returns {boolean} true/false * @example * turf.isNumber(123) * //=true * turf.isNumber('foo') * //=false */ function isNumber(num) { return !isNaN(num) && num !== null && !Array.isArray(num); } /** * Unwrap a coordinate from a Point Feature, Geometry or a single coordinate. * * @name getCoord * @param {Array<number>|Geometry<Point>|Feature<Point>} coord GeoJSON Point or an Array of numbers * @returns {Array<number>} coordinates * @example * var pt = turf.point([10, 10]); * * var coord = turf.getCoord(pt); * //= [10, 10] */ function getCoord(coord) { if (!coord) { throw new Error("coord is required"); } if (!Array.isArray(coord)) { if (coord.type === "Feature" && coord.geometry !== null && coord.geometry.type === "Point") { return coord.geometry.coordinates; } if (coord.type === "Point") { return coord.coordinates; } } if (Array.isArray(coord) && coord.length >= 2 && !Array.isArray(coord[0]) && !Array.isArray(coord[1])) { return coord; } throw new Error("coord must be GeoJSON Point or an Array of numbers"); } //http://www.movable-type.co.uk/scripts/latlong.html /** * Calculates the distance between two {@link Point|points} in degrees, radians, miles, or kilometers. * This uses the [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula) to account for global curvature. * * @name distance * @param {Coord} from origin point * @param {Coord} to destination point * @param {Object} [options={}] Optional parameters * @param {string} [options.units='kilometers'] can be degrees, radians, miles, or kilometers * @returns {number} distance between the two points * @example * var from = turf.point([-75.343, 39.984]); * var to = turf.point([-75.534, 39.123]); * var options = {units: 'miles'}; * * var distance = turf.distance(from, to, options); * * //addToMap * var addToMap = [from, to]; * from.properties.distance = distance; * to.properties.distance = distance; */ function distance(from, to, options) { if (options === void 0) { options = {}; } var coordinates1 = getCoord(from); var coordinates2 = getCoord(to); var dLat = degreesToRadians(coordinates2[1] - coordinates1[1]); var dLon = degreesToRadians(coordinates2[0] - coordinates1[0]); var lat1 = degreesToRadians(coordinates1[1]); var lat2 = degreesToRadians(coordinates2[1]); var a = Math.pow(Math.sin(dLat / 2), 2) + Math.pow(Math.sin(dLon / 2), 2) * Math.cos(lat1) * Math.cos(lat2); return radiansToLength(2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)), options.units); } // http://en.wikipedia.org/wiki/Haversine_formula /** * Takes a {@link Point} and calculates the location of a destination point given a distance in * degrees, radians, miles, or kilometers; and bearing in degrees. * This uses the [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula) to account for global curvature. * * @name destination * @param {Coord} origin starting point * @param {number} distance distance from the origin point * @param {number} bearing ranging from -180 to 180 * @param {Object} [options={}] Optional parameters * @param {string} [options.units='kilometers'] miles, kilometers, degrees, or radians * @param {Object} [options.properties={}] Translate properties to Point * @returns {Feature<Point>} destination point * @example * var point = turf.point([-75.343, 39.984]); * var distance = 50; * var bearing = 90; * var options = {units: 'miles'}; * * var destination = turf.destination(point, distance, bearing, options); * * //addToMap * var addToMap = [point, destination] * destination.properties['marker-color'] = '#f00'; * point.properties['marker-color'] = '#0f0'; */ function destination(origin, distance, bearing, options) { if (options === void 0) { options = {}; } // Handle input var coordinates1 = getCoord(origin); var longitude1 = degreesToRadians(coordinates1[0]); var latitude1 = degreesToRadians(coordinates1[1]); var bearingRad = degreesToRadians(bearing); var radians = lengthToRadians(distance, options.units); // Main var latitude2 = Math.asin(Math.sin(latitude1) * Math.cos(radians) + Math.cos(latitude1) * Math.sin(radians) * Math.cos(bearingRad)); var longitude2 = longitude1 + Math.atan2(Math.sin(bearingRad) * Math.sin(radians) * Math.cos(latitude1), Math.cos(radians) - Math.sin(latitude1) * Math.sin(latitude2)); var lng = radiansToDegrees(longitude2); var lat = radiansToDegrees(latitude2); return point([lng, lat], options.properties); } /** * * @param {number} n * @param {number} decimals * @returns {number} */ function toFixed(n, decimals) { const factor = Math.pow(10, decimals); return Math.round(n * factor) / factor; } /** * * @param {number} a * @param {number} b * @returns {number} */ function modulo(a, b) { const r = a % b; return r * b < 0 ? r + b : r; } /** * * @param {number} number * @param {number} width * @param {number | undefined} precision * @returns {string} */ function padNumber(number, width, precision = undefined) { const numberString = precision !== undefined ? number.toFixed(precision) : "" + number; let decimal = numberString.indexOf("."); decimal = decimal === -1 ? numberString.length : decimal; return decimal > width ? numberString : new Array(1 + width - decimal).join("0") + numberString; } /** * * @param {string} hemispheres * @param {number} degrees * @param {number} fractionDigits * @returns {string} */ function degreesToStringHDMS(hemispheres, degrees, fractionDigits) { const normalizedDegrees = modulo(degrees + 180, 360) - 180; const x = Math.abs(3600 * normalizedDegrees); const decimals = fractionDigits || 0; let deg = Math.floor(x / 3600); let min = Math.floor((x - deg * 3600) / 60); let sec = toFixed(x - deg * 3600 - min * 60, decimals); if (sec >= 60) { sec = 0; min += 1; } if (min >= 60) { min = 0; deg += 1; } let hdms = deg + "\u00b0"; if (min !== 0 || sec !== 0) { hdms += " " + padNumber(min, 2) + "\u2032"; } if (sec !== 0) { hdms += " " + padNumber(sec, 2, decimals) + "\u2033"; } if (normalizedDegrees !== 0) { hdms += " " + hemispheres; } return hdms; } /** * @param {GeoJSON.BBox} bbox * @param {number} graticuleWidth * @param {number} graticuleHeight * @param {Units} units * @param {string} labelType * @param {string} longitudePosition * @param {string} latitudePosition * @returns {graticuleJson} */ function getGraticule(bbox, graticuleWidth, graticuleHeight, units, labelType, longitudePosition, latitudePosition) { const earthCircumference = Math.ceil(distance([0, 0], [180, 0], { units }) * 2); const maxColumns = Math.floor(earthCircumference / graticuleWidth); const fullDistance = (from, to, options) => { const dist = distance(from, to, options); if (Math.abs(to[0] - from[0]) >= 180) { return earthCircumference - dist; } return dist; }; const meridians = []; const parallels = []; const west = bbox[0]; const south = bbox[1]; const east = bbox[2]; const north = bbox[3]; // calculate graticule start point const deltaX = (west < 0 ? -1 : 1) * fullDistance([0, 0], [west, 0], { units }); const deltaY = (south < 0 ? -1 : 1) * fullDistance([0, 0], [0, south], { units }); const startDeltaX = Math.ceil(deltaX / graticuleWidth) * graticuleWidth; const startDeltaY = Math.ceil(deltaY / graticuleHeight) * graticuleHeight; const startPoint = [ destination([0, 0], startDeltaX, 90, { units }).geometry.coordinates[0], destination([0, 0], startDeltaY, 0, { units }).geometry.coordinates[1], ]; // calculate graticule columns and rows count const width = fullDistance([west, 0], [east, 0], { units }); const height = fullDistance([0, south], [0, north], { units }); const columns = Math.min(Math.ceil(width / graticuleWidth), maxColumns); const rows = Math.ceil(height / graticuleHeight); let currentPoint; // meridians currentPoint = startPoint; for (let i = 0; i < columns; i++) { let coordinates; if (longitudePosition === "bottom") { coordinates = [ [currentPoint[0], south], [currentPoint[0], north], ]; } else { coordinates = [ [currentPoint[0], north], [currentPoint[0], south], ]; } let hemisphere; if (currentPoint[0] > 0) { hemisphere = "E"; } else { hemisphere = "W"; } const hdms = degreesToStringHDMS(hemisphere, currentPoint[0], 2); let feature; if (labelType === "hdms") { feature = { type: "Feature", geometry: { type: "LineString", coordinates }, properties: { coord: hdms }, }; } else { feature = { type: "Feature", geometry: { type: "LineString", coordinates }, properties: { coord: currentPoint[0].toFixed(3) + "°" }, }; } meridians.push(feature); currentPoint = [ destination([currentPoint[0], 0], graticuleWidth, 90, { units }).geometry .coordinates[0], currentPoint[1], ]; } // parallels currentPoint = startPoint; for (let i = 0; i < rows; i++) { let coordinates; if (latitudePosition === "right") { coordinates = [ [east, currentPoint[1]], [west, currentPoint[1]], ]; } else { coordinates = [ [west, currentPoint[1]], [east, currentPoint[1]], ]; } let hemisphere = "S"; if (currentPoint[1] > 0) { hemisphere = "N"; } else { hemisphere = "S"; } const hdms = degreesToStringHDMS(hemisphere, currentPoint[1], 2); let feature; if (labelType === "hdms") { feature = { type: "Feature", geometry: { type: "LineString", coordinates }, properties: { coord: hdms }, }; } else { feature = { type: "Feature", geometry: { type: "LineString", coordinates }, properties: { coord: currentPoint[1].toFixed(3) + "°" }, }; } parallels.push(feature); currentPoint = [ currentPoint[0], destination([0, currentPoint[1]], graticuleHeight, 0, { units }).geometry .coordinates[1], ]; } return { meridians: meridians, parallels: parallels, }; } function randomString() { return Math.floor(Math.random() * 10e12).toString(36); } /** * * @param {Map} map * @returns */ function calculateResolution(map) { const zoom = map.getZoom(); const container = map.getContainer(); const containerWidth = container.offsetWidth; const containerHeight = container.offsetHeight; const tileSize = 256; const metersPerPixel = (Math.PI * 2 * 6378137) / (tileSize * 2 ** zoom); const resolutionX = metersPerPixel * (containerWidth / tileSize); const resolutionY = metersPerPixel * (containerHeight / tileSize); return { x: resolutionX, y: resolutionY, }; } class MaplibreGraticule { constructor(config) { this.xid = `graticule-meridains-${randomString()}`; this.yid = `graticule-parallels-${randomString()}`; this.config = config; this.updateBound = this.update.bind(this); this.labelSize = this.config.labelSize; } /** * @param {Map} map * @returns {HTMLElement} */ onAdd(map) { this.map = map; this.map.on("load", this.updateBound); this.map.on("move", this.updateBound); if (this.map.loaded()) { this.update(); } return document.createElement("div"); } /** * @returns {void} */ onRemove() { if (!this.map) { return; } // Remove symbol layers const xSymbolLayer = this.map.getLayer(`symbols${this.xid}`); if (xSymbolLayer) { this.map.removeLayer(`symbols${this.xid}`); } const ySymbolLayer = this.map.getLayer(`symbols${this.yid}`); if (ySymbolLayer) { this.map.removeLayer(`symbols${this.yid}`); } const xsource = this.map.getSource(this.xid); if (xsource) { this.map.removeLayer(this.xid); this.map.removeSource(this.xid); } const ysource = this.map.getSource(this.yid); if (ysource) { this.map.removeLayer(this.yid); this.map.removeSource(this.yid); } this.map.off("load", this.updateBound); this.map.off("move", this.updateBound); this.map = undefined; } /** * @returns {void} */ update() { if (!this.map) { return; } const longitudePosition = this.config.longitudePosition ?? "bottom"; const latitudePosition = this.config.latitudePosition ?? "right"; const labelType = this.config.labelType ?? "hdms"; const resolution = calculateResolution(this.map); const xWidth = (resolution.x / 100) * 2; const yWidth = (resolution.y / 100) * 2; /** @type {graticuleJson} */ let graticule = { meridians: [], parallels: [], }; if (this.active) { graticule = getGraticule(this.bbox, xWidth, yWidth, "kilometers", labelType, longitudePosition, latitudePosition); } const xsource = this.map.getSource(this.xid); if (!xsource) { this.map.addSource(this.xid, { type: "geojson", data: { type: "FeatureCollection", features: graticule.meridians }, }); this.map.addLayer({ id: this.xid, source: this.xid, type: "line", paint: this.config.paint ?? {}, }); if (this.config.showLabels) { this.map.addLayer({ id: `symbols${this.xid}`, type: "symbol", source: this.xid, layout: { "symbol-placement": "point", "text-field": "{coord}", "text-size": this.config.labelSize ?? 12, "text-anchor": longitudePosition === "top" ? "top" : "bottom", "text-offset": this.config.longitudeOffset ?? [0, 0], }, paint: { "text-color": this.config.labelColor ?? "#000000", "text-halo-blur": 1, "text-halo-color": "rgba(255,255,255,1)", "text-halo-width": 3, }, }); } } else { xsource.setData({ type: "FeatureCollection", features: graticule.meridians, }); } const ysource = this.map.getSource(this.yid); if (!ysource) { this.map.addSource(this.yid, { type: "geojson", data: { type: "FeatureCollection", features: graticule.parallels }, }); this.map.addLayer({ id: this.yid, source: this.yid, type: "line", paint: this.config.paint ?? {}, }); if (this.config.showLabels) { this.map.addLayer({ id: `symbols${this.yid}`, type: "symbol", source: this.yid, layout: { "symbol-placement": "point", "text-field": "{coord}", "text-size": this.config.labelSize ?? 12, "text-anchor": latitudePosition === "left" ? "left" : "right", "text-offset": this.config.latitudeOffset ?? [0, 0], }, paint: { "text-color": this.config.labelColor ?? "#000000", "text-halo-blur": 1, "text-halo-color": "rgba(255,255,255,1)", "text-halo-width": 3, }, }); } } else { ysource.setData({ type: "FeatureCollection", features: graticule.parallels, }); } } /** * @returns {boolean} */ get active() { if (!this.map) { return false; } const minZoom = this.config.minZoom ?? 0; const maxZoom = this.config.maxZoom ?? 22; const zoom = this.map.getZoom(); return minZoom <= zoom && zoom < maxZoom; } /** * @returns {GeoJSON.BBox} */ get bbox() { if (!this.map) { throw new Error("Invalid state"); } const bounds = this.map.getBounds(); if (bounds.getEast() - bounds.getWest() >= 360) { bounds.setNorthEast([bounds.getWest() + 360, bounds.getNorth()]); } const bbox = /** @type {GeoJSON.BBox} */ bounds.toArray().flat(); return bbox; } } return MaplibreGraticule; }))); //# sourceMappingURL=maplibre-graticule.js.map