UNPKG

geogrid-maplibre-gl

Version:

GeoGrid is a MapLibre GL JS plugin for adding a highly customizable geographic grid (graticule) to your map.

484 lines (475 loc) 19.4 kB
const MIN_LATTITUDE = -90; const MAX_LATTITUDE = 90; const MAX_LONGITUDE = 180; const MIN_LONGITUDE = -180; const PLUGIN_PREFIX = 'geo-grid'; const classnames = { container: 'geogrid', containerOverride: 'geogrid-overrides', label: 'geogrid__label' }; const createMultiLineString = (coordinates) => ({ type: 'MultiLineString', coordinates }); const createParallelsGeometry = (densityInDegrees, bounds) => { const geometry = []; let currentLattitude = Math.ceil(bounds.getSouth() / densityInDegrees) * densityInDegrees; for (; currentLattitude < bounds.getNorth(); currentLattitude += densityInDegrees) { geometry.push([[MIN_LONGITUDE, currentLattitude], [MAX_LONGITUDE, currentLattitude]]); } return geometry; }; const createMeridiansGeometry = (densityInDegrees, bounds) => { const geometry = []; let currentLongitude = Math.ceil(bounds.getWest() / densityInDegrees) * densityInDegrees; for (; currentLongitude < bounds.getEast(); currentLongitude += densityInDegrees) { geometry.push([[currentLongitude, MIN_LATTITUDE], [currentLongitude, MAX_LATTITUDE]]); } return geometry; }; const createLabelsContainerElement = () => { const el = document.createElement('div'); el.classList.add(classnames.container, classnames.containerOverride); el.style.position = 'relative'; el.style.height = '100%'; el.style.pointerEvents = 'none'; return el; }; const createLabelElement = (value, x, y, align, format, labelStyle) => { const alignTopOrBottom = align === 'top' || align === 'bottom'; const el = document.createElement('div'); el.classList.add(classnames.label, `${classnames.label}--${align}`); if (labelStyle.color) { el.style.color = labelStyle.color; } if (labelStyle.fontFamily) { el.style.fontFamily = labelStyle.fontFamily; } if (labelStyle.fontSize) { el.style.fontSize = labelStyle.fontSize; } if (labelStyle.textShadow) { el.style.textShadow = labelStyle.textShadow; } el.innerText = format(value); el.setAttribute(alignTopOrBottom ? 'longitude' : 'latitude', value.toFixed(20)); el.style.position = 'absolute'; el.style[alignTopOrBottom ? 'left' : align] = `${x.toString()}px`; el.style[alignTopOrBottom ? align : 'top'] = `${y.toString()}px`; return el; }; function getGridDensity(zoom) { switch (zoom) { case 0: return 30; case 1: return 15; case 2: return 10; case 3: return 7.5; case 4: return 5; case 5: return 3; case 6: return 2; case 7: return 1.5; case 8: return 0.75; case 9: return 0.5; case 10: return 0.25; case 11: return 0.125; case 12: return 0.075; case 13: return 0.05; case 14: return 0.025; default: return 30; } } const formatDegrees = (degressFloat) => { const degrees = Math.floor(degressFloat); const degreessFractionalPart = degressFloat - degrees; const minutesFloat = degreessFractionalPart * 60; const minutes = Math.floor(minutesFloat); const minutesFractionalPart = minutesFloat - minutes; const seconds = Math.round(minutesFractionalPart * 60); let output = `${degrees.toString()}°`; if (minutes !== 0) { output += ` ${minutes}′`; } if (seconds !== 0) { output += ` ${seconds}′′`; } return output; }; const calculateTopMostNotOcludedLatitude = (map, longitude) => { let result = undefined; const step = map.getZoom() > 12 ? 0.01 : 1; const centerLat = map.getCenter().lat; for (let latitude = centerLat; latitude < 85; latitude += step) { // @ts-expect-error const isOccluded = map.transform.isLocationOccluded?.({ lng: longitude, lat: latitude }); if (!isOccluded) { result = latitude; } } return result; }; const calculateLeftMostNotOcludedLongitude = (map, latitude) => { let result = undefined; const step = 0.5; const centerLng = map.getCenter().lng; for (let longitude = centerLng; longitude > centerLng - 90; longitude -= step) { // @ts-expect-error const isOccluded = map.transform.isLocationOccluded?.({ lng: longitude, lat: latitude }); if (!isOccluded) { result = longitude; } } return result; }; const calculateRightMostNotOccludedLongitude = (map, latitude) => { let result = undefined; const step = 0.5; const centerLng = map.getCenter().lng; for (let longitude = centerLng; longitude < centerLng + 90; longitude += step) { // @ts-expect-error const isOccluded = map.transform.isLocationOccluded({ lng: longitude, lat: latitude }); if (!isOccluded) { result = longitude; } } return result; }; const calculateBottomMostNotOcludedLatitude = (map, longitude) => { let result = undefined; const step = map.getZoom() > 12 ? 0.01 : 1; const centerLat = map.getCenter().lat; for (let latitude = centerLat; latitude > -85; latitude -= step) { // @ts-expect-error const isOccluded = map.transform.isLocationOccluded?.({ lng: longitude, lat: latitude }); if (!isOccluded) { result = latitude; } } return result; }; const calculateLeftEdgeLongitude = (map, latitude) => { let lng = map.getCenter().lng; let intersects = false; const maxIterations = 180; let it = 0; // We are limiting the loop because some meridians may never intersect with the screen edge // and will pass the break condition (x <= 0) while (it < maxIterations) { lng--; const x = map.project([lng, latitude]).x; if (x <= 0) { intersects = true; break; } it++; } return intersects ? lng : null; }; const calculateRightEdgeLongitude = (map, latitude) => { let lng = map.getCenter().lng; let intersects = false; const maxIterations = 180; let it = 0; const screenWidth = map.getContainer().offsetWidth; // Limiting the loop because some meridians may never intersect with the screen edge // and will pass the break condition (x <= 0) while (it < maxIterations) { lng++; const x = map.project([lng, latitude]).x; if (x >= screenWidth) { intersects = true; break; } it++; } return intersects ? lng : null; }; /** * Creates customizable geographic grid and adds it to the map. */ class GeoGrid { map; config = { beforeLayerId: undefined, zoomLevelRange: [0, 22], parallersLayerName: `${PLUGIN_PREFIX}_parallers`, parallersSourceName: `${PLUGIN_PREFIX}_parallers_source`, meridiansLayerName: `${PLUGIN_PREFIX}_meridians`, meridiansSourceName: `${PLUGIN_PREFIX}_meridians_source`, style: { color: '#000000', width: 1, dasharray: undefined }, labelStyle: {}, gridDensity: getGridDensity, formatLabels: formatDegrees }; elements = { labels: [], labelsContainer: createLabelsContainerElement() }; constructor(options) { if (!options.map) { throw new Error('GeoGrid: "map" option is required'); } this.map = options.map; this.config.beforeLayerId = options.beforeLayerId || this.config.beforeLayerId; this.config.zoomLevelRange = options.zoomLevelRange || this.config.zoomLevelRange; this.config.style.color = options.gridStyle?.color || options.style?.color || this.config.style.color; this.config.style.width = options.gridStyle?.width || options.style?.width || this.config.style.width; this.config.style.dasharray = options.gridStyle?.dasharray || options.style?.dasharray || this.config.style.dasharray; this.config.labelStyle.color = options.labelStyle?.color; this.config.labelStyle.fontSize = options.labelStyle?.fontSize; this.config.labelStyle.fontFamily = options.labelStyle?.fontFamily; this.config.labelStyle.textShadow = options.labelStyle?.textShadow; this.config.formatLabels = options.formatLabels || this.config.formatLabels; this.config.gridDensity = options.gridDensity || this.config.gridDensity; this.map.once('load', this.add); } /** * Adds grid back to the map. * You only need to call this function if remove() was called. */ add = () => { const labelsContainer = this.map.getContainer().querySelector(`.${classnames.container}`); if (labelsContainer) { return; } this.map.getContainer().appendChild(this.elements.labelsContainer); this.map.on('move', this.onMove); this.map.on('remove', this.removeEventListeners); this.map.on('projectiontransition', this.onProjectionTransition); const densityInDegrees = this.config.gridDensity( // Zoom can be negative in the globe projection, so we clamp it Math.max(Math.floor(this.map.getZoom()), 0)); this.addLayersAndSources(densityInDegrees); }; /** * Removes grid from the map. */ remove = () => { this.map.off('remove', this.removeEventListeners); this.removeEventListeners(); // Remove html elements this.removeLabels(); this.map.getContainer().removeChild(this.elements.labelsContainer); // Remove layers and sources this.map.removeLayer(this.config.parallersLayerName); this.map.removeLayer(this.config.meridiansLayerName); this.map.removeSource(this.config.parallersSourceName); this.map.removeSource(this.config.meridiansSourceName); }; removeEventListeners = () => { this.map.off('move', this.onMove); this.map.off('projectiontransition', this.onProjectionTransition); }; onMove = () => { this.updateLabelsVisibility(); this.removeLabels(); const densityInDegrees = this.config.gridDensity(Math.floor(this.map.getZoom())); this.drawLabels(densityInDegrees); this.updateGrid(densityInDegrees); }; onProjectionTransition = () => { this.onMove(); }; addLayersAndSources = (densityInDegrees) => { const bounds = this.map.getBounds(); const filter = [ 'all', ['>=', ['zoom'], this.config.zoomLevelRange[0]], ['<=', ['zoom'], this.config.zoomLevelRange[1]] ]; this.map.addSource(this.config.parallersSourceName, { type: 'geojson', data: { type: 'MultiLineString', coordinates: createParallelsGeometry(densityInDegrees, bounds) } }); this.map.addLayer({ id: this.config.parallersLayerName, filter, type: 'line', source: this.config.parallersSourceName, paint: { 'line-color': this.config.style.color, 'line-width': this.config.style.width, ...(this.config.style.dasharray && { 'line-dasharray': this.config.style.dasharray }) } }, this.config.beforeLayerId); this.map.addSource(this.config.meridiansSourceName, { type: 'geojson', data: { type: 'MultiLineString', coordinates: createMeridiansGeometry(densityInDegrees, bounds) } }); this.map.addLayer({ id: this.config.meridiansLayerName, filter, type: 'line', source: this.config.meridiansSourceName, paint: { 'line-color': this.config.style.color, 'line-width': this.config.style.width, ...(this.config.style.dasharray && { 'line-dasharray': this.config.style.dasharray }) } }, this.config.beforeLayerId); this.drawLabels(densityInDegrees); }; drawLabels = (densityInDegrees) => { const currentZoomLevel = Math.floor(this.map.getZoom()); const isInZoomLevelRange = currentZoomLevel >= this.config.zoomLevelRange[0] || currentZoomLevel <= this.config.zoomLevelRange[1]; if (!isInZoomLevelRange) { return; } const bounds = this.map.getBounds(); const isGlobeProjection = this.map.getStyle().projection?.type === 'globe'; let currentLattitude = Math.ceil(bounds.getSouth() / densityInDegrees) * densityInDegrees; for (; currentLattitude < bounds.getNorth(); currentLattitude += densityInDegrees) { if (isGlobeProjection) { const leftLabel = this.drawLeftLabel(currentLattitude); if (leftLabel) { this.elements.labels.push(leftLabel); this.elements.labelsContainer.appendChild(leftLabel); } const rightLabel = this.drawRightLabel(currentLattitude); if (rightLabel) { this.elements.labels.push(rightLabel); this.elements.labelsContainer.appendChild(rightLabel); } } else { const y = this.map.project([0, currentLattitude]).y; const elements = [ createLabelElement(currentLattitude, 0, y, 'left', this.config.formatLabels, this.config.labelStyle), createLabelElement(currentLattitude, 0, y, 'right', this.config.formatLabels, this.config.labelStyle), ]; elements.forEach(element => { this.elements.labels.push(element); this.elements.labelsContainer.appendChild(element); }); } } let currentLongitude = Math.ceil(bounds.getWest() / densityInDegrees) * densityInDegrees; for (; currentLongitude < bounds.getEast(); currentLongitude += densityInDegrees) { if (isGlobeProjection) { const topLabel = this.drawTopLabel(currentLongitude, bounds); const bottomLabel = this.drawBottomLabel(currentLongitude, bounds); if (topLabel) { this.elements.labels.push(topLabel); this.elements.labelsContainer.appendChild(topLabel); } if (bottomLabel) { this.elements.labels.push(bottomLabel); this.elements.labelsContainer.appendChild(bottomLabel); } } else { const x = this.map.project([currentLongitude, 0]).x; const topLabel = createLabelElement(currentLongitude, x, 0, 'top', this.config.formatLabels, this.config.labelStyle); const bottomLabel = createLabelElement(currentLongitude, x, 0, 'bottom', this.config.formatLabels, this.config.labelStyle); this.elements.labels.push(topLabel); this.elements.labels.push(bottomLabel); this.elements.labelsContainer.appendChild(topLabel); this.elements.labelsContainer.appendChild(bottomLabel); } } }; updateGrid = (densityInDegrees) => { const bounds = this.map.getBounds(); const parallersSource = this.map.getSource(this.config.parallersSourceName); parallersSource.setData(createMultiLineString(createParallelsGeometry(densityInDegrees, bounds))); const meridiansSource = this.map.getSource(this.config.meridiansSourceName); meridiansSource.setData(createMultiLineString(createMeridiansGeometry(densityInDegrees, bounds))); }; updateLabelsVisibility = () => { const isFacingNorth = Math.abs(this.map.getBearing()) === 0; this.elements.labelsContainer.style.display = isFacingNorth ? 'block' : 'none'; }; removeLabels = () => { this.elements.labels = []; this.elements.labelsContainer.innerHTML = ''; }; drawBottomLabel(currentLongitude, bounds) { const bottomMostNotOcludedLatitude = calculateBottomMostNotOcludedLatitude(this.map, currentLongitude); if (!bottomMostNotOcludedLatitude) { return; } const mostSouthNotOccludedLat = bottomMostNotOcludedLatitude % -90; // The case when the bottom of the screen is beyond (on the other side) the south pole in the globe projection. if (mostSouthNotOccludedLat > bounds.getSouth()) { return; } const x = this.map.project([currentLongitude, bounds.getSouth()]).x; // @ts-expect-error const isBottomYOccluded = this.map.transform.isLocationOccluded?.(this.map.unproject([x, this.map.getCanvas().offsetHeight])); if (isBottomYOccluded) { return; } return createLabelElement(currentLongitude, x, 0, 'bottom', this.config.formatLabels, this.config.labelStyle); } drawTopLabel(currentLongitude, bounds) { const topMostNotOcludedLatitute = calculateTopMostNotOcludedLatitude(this.map, currentLongitude); if (!topMostNotOcludedLatitute) { return; } const mostNorthNotOccludedLat = topMostNotOcludedLatitute % 90; // The case when top of the screen is beyond (on the other side) north pole in the globe projection. if (mostNorthNotOccludedLat < bounds.getNorth()) { return; } const x = this.map.project([currentLongitude, bounds.getNorth()]).x; // @ts-expect-error const isTopYOccluded = this.map.transform.isLocationOccluded?.(this.map.unproject([x, 0])); if (isTopYOccluded) { return; } return createLabelElement(currentLongitude, x, 0, 'top', this.config.formatLabels, this.config.labelStyle); } drawLeftLabel(currentLatitude) { const leftMostNotOcludedLongitude = calculateLeftMostNotOcludedLongitude(this.map, currentLatitude); if (leftMostNotOcludedLongitude === undefined) { return; } const edgeIntersectionLng = calculateLeftEdgeLongitude(this.map, currentLatitude); if (edgeIntersectionLng === null) { return; } const x = 0; const y = this.map.project([edgeIntersectionLng, currentLatitude]).y; return createLabelElement(currentLatitude, x, y, 'left', this.config.formatLabels, this.config.labelStyle); } drawRightLabel(currentLatitude) { const rightMostNotOccludedLongitude = calculateRightMostNotOccludedLongitude(this.map, currentLatitude); if (rightMostNotOccludedLongitude === undefined) { return; } const edgeIntersectionLng = calculateRightEdgeLongitude(this.map, currentLatitude); if (edgeIntersectionLng === null) { return; } const x = 0; const y = this.map.project([edgeIntersectionLng, currentLatitude]).y; return createLabelElement(currentLatitude, x, y, 'right', this.config.formatLabels, this.config.labelStyle); } } export { GeoGrid };