maplibre-graticule
Version:
Graticuel / Grid plugin for MapLibre GL JS / Mapbox GL JS
750 lines (705 loc) • 26.1 kB
JavaScript
(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