├── .gitignore ├── src ├── index.js ├── grid.d.ts ├── grid.js └── calc.js ├── CHANGELOG.md ├── docs ├── screenshot.jpg ├── screenshot@2x.jpg └── index.html ├── babel.config.json ├── tsconfig.json ├── rollup.config.js ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './grid'; 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 4 | 5 | - initial release 6 | -------------------------------------------------------------------------------- /docs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maptiler/maplibre-grid/HEAD/docs/screenshot.jpg -------------------------------------------------------------------------------- /docs/screenshot@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maptiler/maplibre-grid/HEAD/docs/screenshot@2x.jpg -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-runtime" 7 | ] 8 | } -------------------------------------------------------------------------------- /src/grid.d.ts: -------------------------------------------------------------------------------- 1 | import { Units } from '@turf/helpers'; 2 | import maplibregl from 'maplibre-gl'; 3 | 4 | export interface GridConfig { 5 | gridWidth: number; 6 | gridHeight: number; 7 | units: Units; 8 | minZoom?: number; 9 | maxZoom?: number; 10 | paint?: maplibregl.LinePaint; 11 | } 12 | 13 | export interface GridClickEvent { 14 | bbox: GeoJSON.BBox; 15 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "allowJs": true, 11 | "checkJs": true, 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true 16 | }, 17 | "include": [ 18 | "./src/**/*" 19 | ] 20 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from './package.json'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import babel from '@rollup/plugin-babel'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import visualizer from 'rollup-plugin-visualizer'; 7 | 8 | function bundle(filename, options = {}) { 9 | return { 10 | input: 'src/index.js', 11 | output: { 12 | file: filename, 13 | format: 'umd', 14 | name: 'MaplibreGrid', 15 | sourcemap: true 16 | }, 17 | external: [ 18 | // ...Object.keys(pkg.peerDependencies), 19 | 'fs', 20 | 'path', 21 | ], 22 | plugins: [ 23 | resolve(), 24 | commonjs(), 25 | babel({ babelHelpers: 'runtime' }), 26 | options.minimize ? terser() : false, 27 | options.stats ? visualizer({ 28 | filename: filename + '.stats.html', 29 | }) : false, 30 | ], 31 | }; 32 | } 33 | 34 | export default [ 35 | bundle(pkg.browser.replace('.min', ''), { stats: true }), 36 | bundle(pkg.browser, { minimize: true }), 37 | ]; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maplibre-grid", 3 | "version": "1.0.0", 4 | "description": "Grid / graticule plugin for MapLibre GL JS / Mapbox GL JS", 5 | "keywords": [ 6 | "maplibre", 7 | "grid" 8 | ], 9 | "author": "MapTiler Team ", 10 | "license": "BSD", 11 | "repository": "github:maptiler/maplibre-grid", 12 | "main": "src/index.js", 13 | "browser": "dist/maplibre-grid.min.js", 14 | "scripts": { 15 | "build": "rimraf dist && rollup -c", 16 | "dev": "rollup -c -w", 17 | "test": "echo \"Error: no test specified\" && exit 1", 18 | "prepublishOnly": "npm run build" 19 | }, 20 | "dependencies": { 21 | "@turf/destination": "^6.3.0", 22 | "@turf/distance": "^6.3.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.13.8", 26 | "@babel/plugin-transform-runtime": "^7.13.9", 27 | "@babel/preset-env": "^7.13.9", 28 | "@babel/runtime": "^7.13.9", 29 | "@rollup/plugin-babel": "^5.3.0", 30 | "@rollup/plugin-commonjs": "^17.1.0", 31 | "@rollup/plugin-node-resolve": "^11.2.0", 32 | "@types/maplibre-gl": "^1.13.0", 33 | "rimraf": "^3.0.2", 34 | "rollup": "^2.40.0", 35 | "rollup-plugin-terser": "^7.0.2", 36 | "rollup-plugin-visualizer": "^4.2.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, MapTiler 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # maplibre-grid 2 | 3 | Grid / graticule plugin for [MapLibre GL JS](https://docs.maptiler.com/maplibre-gl-js/get-started/) / Mapbox GL JS 4 | 5 | [Demo](https://labs.maptiler.com/maplibre-grid/) 6 | 7 | Screenshot 8 | 9 | ## Install 10 | 11 | ``` 12 | npm install maplibre-gl maplibre-grid 13 | ``` 14 | 15 | or 16 | 17 | ``` 18 | 19 | 20 | 21 | ``` 22 | 23 | ## Usage 24 | 25 | ``` 26 | import Maplibre from 'maplibre-gl'; 27 | import * as MaplibreGrid from 'maplibre-grid'; 28 | ``` 29 | 30 | ### API 31 | 32 | ``` 33 | export interface GridConfig { 34 | gridWidth: number; 35 | gridHeight: number; 36 | units: Units; 37 | minZoom?: number; 38 | maxZoom?: number; 39 | paint?: maplibregl.LinePaint; 40 | } 41 | 42 | const grid = new MaplibreGrid.Grid(config: GridConfig); 43 | ``` 44 | 45 | - `gridWidth` - number, **required** 46 | - `gridHeight` - number, **required** 47 | - `units` - 'degrees' | 'radians' | 'miles' | 'kilometers', grid width/height units, **required** 48 | - `minZoom` - number, min zoom to display the grid 49 | - `maxZoom` - number, max zoom to display the grid 50 | - `paint` - maplibregl.LinePaint, layer line paint properties 51 | 52 | Multiple grids can be added to display major and minor grid together, or different grids depending on zoom level. 53 | 54 | ### Basic 55 | 56 | ``` 57 | const grid = new MaplibreGrid.Grid({ 58 | gridWidth: 10, 59 | gridHeight: 10, 60 | units: 'degrees', 61 | paint: { 62 | 'line-opacity': 0.2 63 | } 64 | }); 65 | map.addControl(grid); 66 | ``` 67 | 68 | ### Multiple grids 69 | 70 | ``` 71 | const grid1 = new MaplibreGrid.Grid({ 72 | gridWidth: 10, 73 | gridHeight: 10, 74 | units: 'degrees', 75 | paint: { 76 | 'line-opacity': 0.2 77 | } 78 | }); 79 | map.addControl(grid1); 80 | 81 | const grid2 = new MaplibreGrid.Grid({ 82 | gridWidth: 5, 83 | gridHeight: 5, 84 | units: 'degrees', 85 | paint: { 86 | 'line-opacity': 0.2 87 | } 88 | }); 89 | map.addControl(grid2); 90 | ``` 91 | ### Click event 92 | 93 | ``` 94 | map.on(MaplibreGrid.GRID_CLICK_EVENT, event => { 95 | console.log(event.bbox); 96 | }); 97 | ``` 98 | 99 | Click event can be used to implement grid cell selection. Create a polygon feature from `event.bbox`, and add it to your custom layer. See [demo](https://labs.maptiler.com/maplibre-grid/) for details. 100 | 101 | ### Destroy 102 | 103 | ``` 104 | map.removeControl(grid); 105 | ``` 106 | -------------------------------------------------------------------------------- /src/grid.js: -------------------------------------------------------------------------------- 1 | import { getGrid, getGridCell } from './calc'; 2 | 3 | /** @typedef {import('maplibre-gl').Map} Map */ 4 | /** @typedef {import('maplibre-gl').GeoJSONSource} GeoJSONSource */ 5 | /** @typedef {import('maplibre-gl').LngLatBounds} LngLatBounds */ 6 | /** @typedef {import('maplibre-gl').MapMouseEvent} MapMouseEvent */ 7 | /** @typedef {import('@turf/helpers').Units} Units */ 8 | /** @typedef {import('./grid').GridConfig} GridConfig */ 9 | /** @typedef {import('./grid').GridClickEvent} GridClickEvent */ 10 | 11 | export const GRID_CLICK_EVENT = 'grid.click'; 12 | 13 | export function randomString() { 14 | return Math.floor(Math.random() * 10e12).toString(36); 15 | } 16 | export class Grid { 17 | /** 18 | * @param {GridConfig} config 19 | */ 20 | constructor(config) { 21 | this.id = `grid-${randomString()}`; 22 | this.config = config; 23 | 24 | this.updateBound = this.update.bind(this); 25 | this.onMapClickBound = this.onMapClick.bind(this); 26 | } 27 | 28 | /** 29 | * @param {Map} map 30 | * @returns {HTMLElement} 31 | */ 32 | onAdd(map) { 33 | this.map = map; 34 | 35 | this.map.on('load', this.updateBound); 36 | this.map.on('move', this.updateBound); 37 | this.map.on('click', this.onMapClickBound); 38 | 39 | if (this.map.loaded()) { 40 | this.update(); 41 | } 42 | 43 | return document.createElement('div'); 44 | } 45 | 46 | /** 47 | * @returns {void} 48 | */ 49 | onRemove() { 50 | if (!this.map) { 51 | return; 52 | } 53 | 54 | const source = this.map.getSource(this.id); 55 | if (source) { 56 | this.map.removeLayer(this.id); 57 | this.map.removeSource(this.id); 58 | } 59 | 60 | this.map.off('load', this.updateBound); 61 | this.map.off('move', this.updateBound); 62 | this.map.off('click', this.onMapClickBound); 63 | 64 | this.map = undefined; 65 | } 66 | 67 | /** 68 | * @returns {void} 69 | */ 70 | update() { 71 | if (!this.map) { 72 | return; 73 | } 74 | 75 | /** @type {GeoJSON.Feature[]} */ 76 | let grid = []; 77 | if (this.active) { 78 | grid = getGrid(this.bbox, this.config.gridWidth, this.config.gridHeight, this.config.units); 79 | } 80 | // console.log(grid); 81 | 82 | const source = /** @type {GeoJSONSource} */ (this.map.getSource(this.id)); 83 | if (!source) { 84 | this.map.addSource(this.id, { 85 | type: 'geojson', 86 | data: { type: 'FeatureCollection', features: grid } 87 | }); 88 | this.map.addLayer({ 89 | id: this.id, 90 | source: this.id, 91 | type: 'line', 92 | paint: this.config.paint ?? {} 93 | }); 94 | } else { 95 | source.setData({ type: 'FeatureCollection', features: grid }); 96 | } 97 | } 98 | 99 | /** 100 | * @returns {boolean} 101 | */ 102 | get active() { 103 | if (!this.map) { 104 | return false; 105 | } 106 | 107 | const minZoom = this.config.minZoom ?? 0; 108 | const maxZoom = this.config.maxZoom ?? 22; 109 | const zoom = this.map.getZoom(); 110 | // console.log(zoom); 111 | 112 | return minZoom <= zoom && zoom < maxZoom; 113 | } 114 | 115 | /** 116 | * @returns {GeoJSON.BBox} 117 | */ 118 | get bbox() { 119 | if (!this.map) { 120 | throw new Error('Invalid state'); 121 | } 122 | 123 | const bounds = this.map.getBounds(); 124 | if (bounds.getEast() - bounds.getWest() >= 360) { 125 | bounds.setNorthEast([bounds.getWest() + 360, bounds.getNorth()]); 126 | } 127 | 128 | const bbox = /** @type {GeoJSON.BBox} */ (bounds.toArray().flat()); 129 | return bbox; 130 | } 131 | 132 | /** 133 | * @param {MapMouseEvent} event 134 | * @returns {void} 135 | */ 136 | onMapClick(event) { 137 | if (!this.map || !this.active) { 138 | return; 139 | } 140 | 141 | const point = event.lngLat.toArray(); 142 | const bbox = getGridCell(point, this.config.gridWidth, this.config.gridWidth, this.config.units); 143 | 144 | /** @type {GridClickEvent} */ 145 | const event2 = { bbox }; 146 | this.map.fire(GRID_CLICK_EVENT, event2); 147 | } 148 | } -------------------------------------------------------------------------------- /src/calc.js: -------------------------------------------------------------------------------- 1 | import distance from '@turf/distance'; 2 | import destination from '@turf/destination'; 3 | 4 | /** @typedef {import('@turf/helpers').Units} Units */ 5 | 6 | /** 7 | * @param {GeoJSON.BBox} bbox 8 | * @param {number} gridWidth 9 | * @param {number} gridHeight 10 | * @param {Units} units 11 | * @returns {GeoJSON.Feature[]} 12 | */ 13 | export function getGrid(bbox, gridWidth, gridHeight, units) { 14 | // return rectangleGrid(bbox, gridWidth, gridHeight, { units }); 15 | 16 | const earthCircumference = Math.ceil(distance([0, 0], [180, 0], { units }) * 2); 17 | const maxColumns = Math.floor(earthCircumference / gridWidth); 18 | /** @type {(from: GeoJSON.Position, to: GeoJSON.Position, options: { units: Units }) => number} */ 19 | const fullDistance = (from, to, options) => { 20 | const dist = distance(from, to, options); 21 | if (Math.abs(to[0] - from[0]) >= 180) { 22 | return earthCircumference - dist; 23 | } 24 | return dist; 25 | }; 26 | 27 | /** @type {GeoJSON.Feature[]} */ 28 | const features = []; 29 | const west = bbox[0]; 30 | const south = bbox[1]; 31 | const east = bbox[2]; 32 | const north = bbox[3]; 33 | 34 | // calculate grid start point 35 | const deltaX = (west < 0 ? -1 : 1) * fullDistance([0, 0], [west, 0], { units }); 36 | const deltaY = (south < 0 ? -1 : 1) * fullDistance([0, 0], [0, south], { units }); 37 | const startDeltaX = Math.ceil(deltaX / gridWidth) * gridWidth; 38 | const startDeltaY = Math.ceil(deltaY / gridHeight) * gridHeight; 39 | /** @type {GeoJSON.Position} */ 40 | const startPoint = [ 41 | destination([0, 0], startDeltaX, 90, { units }).geometry.coordinates[0], 42 | destination([0, 0], startDeltaY, 0, { units }).geometry.coordinates[1] 43 | ]; 44 | 45 | // calculate grid columns and rows count 46 | const width = fullDistance([west, 0], [east, 0], { units }); 47 | const height = fullDistance([0, south], [0, north], { units }); 48 | const columns = Math.min(Math.ceil(width / gridWidth), maxColumns); 49 | const rows = Math.ceil(height / gridHeight); 50 | // console.log(startPoint, columns, rows); 51 | 52 | /** @type {GeoJSON.Position} */ 53 | let currentPoint; 54 | 55 | // meridians 56 | currentPoint = startPoint; 57 | for (let i = 0; i < columns; i++) { 58 | /** @type {GeoJSON.Position[]} */ 59 | const coordinates = [ 60 | [currentPoint[0], south], 61 | [currentPoint[0], north] 62 | ]; 63 | /** @type {GeoJSON.Feature} */ 64 | const feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: {}}; 65 | features.push(feature); 66 | 67 | currentPoint = [ 68 | destination([currentPoint[0], 0], gridWidth, 90, { units }).geometry.coordinates[0], 69 | currentPoint[1] 70 | ]; 71 | } 72 | 73 | // parallels 74 | currentPoint = startPoint; 75 | for (let i = 0; i < rows; i++) { 76 | /** @type {GeoJSON.Position[]} */ 77 | const coordinates = [ 78 | [west, currentPoint[1]], 79 | [east, currentPoint[1]] 80 | ]; 81 | /** @type {GeoJSON.Feature} */ 82 | const feature = { type: 'Feature', geometry: { type: 'LineString', coordinates }, properties: {}}; 83 | features.push(feature); 84 | 85 | currentPoint = [ 86 | currentPoint[0], 87 | destination([0, currentPoint[1]], gridHeight, 0, { units }).geometry.coordinates[1] 88 | ]; 89 | } 90 | 91 | return features; 92 | } 93 | 94 | /** 95 | * @param {GeoJSON.Position} point 96 | * @param {number} gridWidth 97 | * @param {number} gridHeight 98 | * @param {Units} units 99 | * @returns {GeoJSON.BBox} 100 | */ 101 | export function getGridCell(point, gridWidth, gridHeight, units) { 102 | const earthCircumference = Math.ceil(distance([0, 0], [180, 0], { units }) * 2); 103 | /** @type {(from: GeoJSON.Position, to: GeoJSON.Position, options: { units: Units }) => number} */ 104 | const fullDistance = (from, to, options) => { 105 | const dist = distance(from, to, options); 106 | if (Math.abs(to[0] - from[0]) >= 180) { 107 | return earthCircumference - dist; 108 | } 109 | return dist; 110 | }; 111 | 112 | const deltaX = (point[0] < 0 ? -1 : 1) * fullDistance([0, 0], [point[0], 0], { units }); 113 | const deltaY = (point[1] < 0 ? -1 : 1) * fullDistance([0, 0], [0, point[1]], { units }); 114 | const minDeltaX = Math.floor(deltaX / gridWidth) * gridWidth; 115 | const minDeltaY = Math.floor(deltaY / gridHeight) * gridHeight; 116 | const maxDeltaX = Math.ceil(deltaX / gridWidth) * gridWidth; 117 | const maxDeltaY = Math.ceil(deltaY / gridHeight) * gridHeight; 118 | const bbox = /** @type {GeoJSON.BBox} */ ([ 119 | destination([0, 0], minDeltaX, 90, { units }).geometry.coordinates[0], 120 | destination([0, 0], minDeltaY, 0, { units }).geometry.coordinates[1], 121 | destination([0, 0], maxDeltaX, 90, { units }).geometry.coordinates[0], 122 | destination([0, 0], maxDeltaY, 0, { units }).geometry.coordinates[1] 123 | ]); 124 | 125 | return bbox; 126 | } -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | maplibre-grid 6 | 7 | 8 | 9 | 10 | 11 | 42 | 43 | 44 |
45 |
46 | 47 | 194 | 195 | --------------------------------------------------------------------------------