├── .gitignore ├── README.md ├── dist ├── mapboxgl-marker-compass.css ├── mapboxgl-marker-compass.mjs └── mapboxgl-marker-compass.umd.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src ├── MarkerCompass.css ├── MarkerCompass.js └── main.js └── vite.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .vite 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapbox GL Marker Compass 2 | 3 | Plugin for Mapbox GL JS to create small marker compasses at the edge of the screen to indicate markers outside the viewport. 4 | 5 | ![mapboxgl-marker-compass-preview](https://github.com/marco-land/mapboxgl-marker-compass/assets/24410335/ed9cfcca-1684-41ef-8d9c-451fbb5e7d80) 6 | 7 | # Live Demos 8 | 9 | [Basic usage](https://mapbox-gl-marker-compass.netlify.app) 10 | 11 | [Example project usage](https://boote-bojen-pokale.de/) 12 | 13 | # Instructions 14 | 15 | 1. Add the package 16 | 17 | ```bash 18 | pnpm add mapboxgl-marker-compass # or npm, yarn 19 | ``` 20 | 21 | 2. Add CSS, import module, create a map and markers 22 | 23 | ```html 24 | 28 | ``` 29 | 30 | ```javascript 31 | import { MarkerCompass } from "mapboxgl-marker-compass/dist/mapboxgl-marker-compass.mjs"; 32 | 33 | const map = new mapboxgl.Map({ 34 | container: "map", 35 | center: [13.404954, 52.520008], 36 | zoom: 9, 37 | projection: "equirectangular", // Works best with equirectangular maps 38 | }); 39 | const markers = [ 40 | new mapboxgl.Marker().setLngLat([13.404954, 52.520008]).addTo(map), 41 | ]; 42 | ``` 43 | 44 | 3. Pass the map and markers to the `MarkerCompass` constructor 45 | 46 | ```javascript 47 | new MarkerCompass(map, markers, { 48 | // Options, see below 49 | }); 50 | ``` 51 | 52 | # Options 53 | 54 | | Option | Default | Description | 55 | | ----------------- | --------- | -------------------------------------------------------- | 56 | | `offset` | `10` | Offset of the compass element to the viewport edge in px | 57 | | `width` | `20` | Width of the compass element in px | 58 | | `height` | `20` | Height of the compass element in px | 59 | | `backgroundColor` | `#3FB1CE` | Background color of the compass element and arrow | 60 | | `arrowSize` | `4` | Size of the arrow in px | 61 | | `arrowOffset` | `14` | Offset of the arrow to the compass element in px | 62 | | `flyToZoom` | `12` | Zoom level when clicking on the compass element in px | 63 | 64 | # ⚠️ Note 65 | 66 | Works best with `equirectangular` map projection 67 | 68 | # Copyright 69 | 70 | © 2024 Marco Land 71 | 72 | # License 73 | 74 | AGPL-3.0 75 | -------------------------------------------------------------------------------- /dist/mapboxgl-marker-compass.css: -------------------------------------------------------------------------------- 1 | .mapboxgl-compass { 2 | -webkit-transition: opacity 400ms ease; 3 | -o-transition: opacity 400ms ease; 4 | transition: opacity 400ms ease; 5 | border-radius: 100%; 6 | cursor: pointer; 7 | background-color: var(--marker-compass-background-color, #3fb1ce); 8 | } 9 | .mapboxgl-compass .mapboxgl-compass__arrow { 10 | height: 100%; 11 | left: 50%; 12 | position: absolute; 13 | top: 50%; 14 | -webkit-transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 15 | -ms-transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 16 | transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 17 | width: 100%; 18 | } 19 | .mapboxgl-compass .mapboxgl-compass__arrow:after { 20 | content: ""; 21 | border-bottom: var(--marker-compass-arrow-size, 4px) solid transparent; 22 | border-right: var(--marker-compass-arrow-size, 4px) solid 23 | var(--marker-compass-background-color, #3fb1ce); 24 | border-top: var(--marker-compass-arrow-size, 4px) solid transparent; 25 | height: 0; 26 | left: 50%; 27 | position: absolute; 28 | top: 50%; 29 | -webkit-transform: translate( 30 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 31 | -50% 32 | ); 33 | -ms-transform: translate( 34 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 35 | -50% 36 | ); 37 | transform: translate( 38 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 39 | -50% 40 | ); 41 | width: 0; 42 | } 43 | -------------------------------------------------------------------------------- /dist/mapboxgl-marker-compass.mjs: -------------------------------------------------------------------------------- 1 | class MarkerCompass { 2 | constructor(map, markers, options) { 3 | this.options = { 4 | // defaults 5 | offset: 10, 6 | width: 20, 7 | height: 20, 8 | backgroundColor: "#3FB1CE", 9 | arrowSize: 4, 10 | arrowOffset: 14, 11 | flyToZoom: 12, 12 | ...options 13 | }; 14 | this.container = null; 15 | this.markers = markers; 16 | this.compasses = []; 17 | this.map = map; 18 | this.init(); 19 | } 20 | init() { 21 | if (!this.map) { 22 | console.warn("No map container provided."); 23 | return; 24 | } 25 | if (!this.markers || !this.markers.length) { 26 | console.warn("No markers found."); 27 | return; 28 | } 29 | this.map.on("load", this.createCompasses.bind(this)); 30 | this.map.on("move", this.updateCompasses.bind(this)); 31 | } 32 | destroy() { 33 | this.map.off("move", this.updateCompasses.bind(this)); 34 | this.compasses.forEach((compass) => compass.remove()); 35 | } 36 | createCompasses() { 37 | const container = this.map.getContainer(); 38 | container.style.setProperty( 39 | "--marker-compass-arrow-size", 40 | this.options.arrowSize + "px" 41 | ); 42 | container.style.setProperty( 43 | "--marker-compass-arrow-offset", 44 | this.options.arrowOffset + "px" 45 | ); 46 | container.style.setProperty( 47 | "--marker-compass-background-color", 48 | this.options.backgroundColor 49 | ); 50 | if (!container) { 51 | console.warn("No map container found."); 52 | return; 53 | } 54 | this.markers.forEach((marker, index) => { 55 | const compass = document.createElement("div"); 56 | compass.classList.add("mapboxgl-compass"); 57 | compass.dataset.compass = "true"; 58 | compass.style.width = this.options.width + "px"; 59 | compass.style.height = this.options.height + "px"; 60 | compass.style.position = "absolute"; 61 | compass.style.opacity = 0; 62 | compass.style.zIndex = 2; 63 | const arrow = document.createElement("div"); 64 | arrow.classList.add("mapboxgl-compass__arrow"); 65 | compass.appendChild(arrow); 66 | container.appendChild(compass); 67 | compass.addEventListener("click", () => { 68 | var _a, _b; 69 | if (!((_b = (_a = this.markers) == null ? void 0 : _a[index]) == null ? void 0 : _b._lngLat)) { 70 | return; 71 | } 72 | this.map.flyTo({ 73 | center: this.markers[index]._lngLat, 74 | zoom: this.options.flyToZoom 75 | }); 76 | }); 77 | this.compasses.push(compass); 78 | }); 79 | this.updateCompasses(); 80 | } 81 | updateCompasses() { 82 | const bounds = this.map.getBounds(); 83 | if (!this.markers.length) { 84 | return; 85 | } 86 | this.markers.forEach((marker, index) => { 87 | const lngLat = marker.getLngLat(); 88 | const compass = this.compasses[index]; 89 | if (!compass) { 90 | return; 91 | } 92 | const compassWidth = compass.clientWidth; 93 | const compassHeight = compass.clientHeight; 94 | const offset = this.options.offset; 95 | const container = this.map.getContainer(); 96 | const maxWidth = container.clientWidth - offset; 97 | const maxHeight = container.clientHeight - offset; 98 | const x = this.map.project(lngLat).x; 99 | const y = this.map.project(lngLat).y; 100 | let translate = { 101 | x: offset, 102 | y: offset 103 | }; 104 | if (y > compassHeight) { 105 | translate.y = y - compassHeight / 2; 106 | } 107 | if (y >= maxHeight - compassHeight / 2) { 108 | translate.y = maxHeight - compassHeight; 109 | } 110 | if (x > compassWidth) { 111 | translate.x = x - compassWidth / 2; 112 | } 113 | if (x >= maxWidth - compassWidth / 2) { 114 | translate.x = maxWidth - compassWidth; 115 | } 116 | compass.style.transform = `translate(${translate.x}px, ${translate.y}px)`; 117 | if (!bounds.contains(lngLat)) { 118 | const angleDeg = Math.atan2(translate.y - y, translate.x - x) * 180 / Math.PI; 119 | compass.style.setProperty("--marker-compass-angle", `${angleDeg}deg`); 120 | compass.style.opacity = 1; 121 | compass.style.pointerEvents = "all"; 122 | } else { 123 | compass.style.pointerEvents = "none"; 124 | compass.style.opacity = 0; 125 | } 126 | }); 127 | } 128 | } 129 | export { 130 | MarkerCompass 131 | }; 132 | -------------------------------------------------------------------------------- /dist/mapboxgl-marker-compass.umd.js: -------------------------------------------------------------------------------- 1 | (function(global, factory) { 2 | typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global["mapboxgl-marker-compass"] = {})); 3 | })(this, function(exports2) { 4 | "use strict"; 5 | class MarkerCompass { 6 | constructor(map, markers, options) { 7 | this.options = { 8 | // defaults 9 | offset: 10, 10 | width: 20, 11 | height: 20, 12 | backgroundColor: "#3FB1CE", 13 | arrowSize: 4, 14 | arrowOffset: 14, 15 | flyToZoom: 12, 16 | ...options 17 | }; 18 | this.container = null; 19 | this.markers = markers; 20 | this.compasses = []; 21 | this.map = map; 22 | this.init(); 23 | } 24 | init() { 25 | if (!this.map) { 26 | console.warn("No map container provided."); 27 | return; 28 | } 29 | if (!this.markers || !this.markers.length) { 30 | console.warn("No markers found."); 31 | return; 32 | } 33 | this.map.on("load", this.createCompasses.bind(this)); 34 | this.map.on("move", this.updateCompasses.bind(this)); 35 | } 36 | destroy() { 37 | this.map.off("move", this.updateCompasses.bind(this)); 38 | this.compasses.forEach((compass) => compass.remove()); 39 | } 40 | createCompasses() { 41 | const container = this.map.getContainer(); 42 | container.style.setProperty( 43 | "--marker-compass-arrow-size", 44 | this.options.arrowSize + "px" 45 | ); 46 | container.style.setProperty( 47 | "--marker-compass-arrow-offset", 48 | this.options.arrowOffset + "px" 49 | ); 50 | container.style.setProperty( 51 | "--marker-compass-background-color", 52 | this.options.backgroundColor 53 | ); 54 | if (!container) { 55 | console.warn("No map container found."); 56 | return; 57 | } 58 | this.markers.forEach((marker, index) => { 59 | const compass = document.createElement("div"); 60 | compass.classList.add("mapboxgl-compass"); 61 | compass.dataset.compass = "true"; 62 | compass.style.width = this.options.width + "px"; 63 | compass.style.height = this.options.height + "px"; 64 | compass.style.position = "absolute"; 65 | compass.style.opacity = 0; 66 | compass.style.zIndex = 2; 67 | const arrow = document.createElement("div"); 68 | arrow.classList.add("mapboxgl-compass__arrow"); 69 | compass.appendChild(arrow); 70 | container.appendChild(compass); 71 | compass.addEventListener("click", () => { 72 | var _a, _b; 73 | if (!((_b = (_a = this.markers) == null ? void 0 : _a[index]) == null ? void 0 : _b._lngLat)) { 74 | return; 75 | } 76 | this.map.flyTo({ 77 | center: this.markers[index]._lngLat, 78 | zoom: this.options.flyToZoom 79 | }); 80 | }); 81 | this.compasses.push(compass); 82 | }); 83 | this.updateCompasses(); 84 | } 85 | updateCompasses() { 86 | const bounds = this.map.getBounds(); 87 | if (!this.markers.length) { 88 | return; 89 | } 90 | this.markers.forEach((marker, index) => { 91 | const lngLat = marker.getLngLat(); 92 | const compass = this.compasses[index]; 93 | if (!compass) { 94 | return; 95 | } 96 | const compassWidth = compass.clientWidth; 97 | const compassHeight = compass.clientHeight; 98 | const offset = this.options.offset; 99 | const container = this.map.getContainer(); 100 | const maxWidth = container.clientWidth - offset; 101 | const maxHeight = container.clientHeight - offset; 102 | const x = this.map.project(lngLat).x; 103 | const y = this.map.project(lngLat).y; 104 | let translate = { 105 | x: offset, 106 | y: offset 107 | }; 108 | if (y > compassHeight) { 109 | translate.y = y - compassHeight / 2; 110 | } 111 | if (y >= maxHeight - compassHeight / 2) { 112 | translate.y = maxHeight - compassHeight; 113 | } 114 | if (x > compassWidth) { 115 | translate.x = x - compassWidth / 2; 116 | } 117 | if (x >= maxWidth - compassWidth / 2) { 118 | translate.x = maxWidth - compassWidth; 119 | } 120 | compass.style.transform = `translate(${translate.x}px, ${translate.y}px)`; 121 | if (!bounds.contains(lngLat)) { 122 | const angleDeg = Math.atan2(translate.y - y, translate.x - x) * 180 / Math.PI; 123 | compass.style.setProperty("--marker-compass-angle", `${angleDeg}deg`); 124 | compass.style.opacity = 1; 125 | compass.style.pointerEvents = "all"; 126 | } else { 127 | compass.style.pointerEvents = "none"; 128 | compass.style.opacity = 0; 129 | } 130 | }); 131 | } 132 | } 133 | exports2.MarkerCompass = MarkerCompass; 134 | Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" }); 135 | }); 136 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Mapbox GL Marker Compass Example 6 | 10 | 14 | 18 | 19 | 20 | 32 | 33 | 34 |
35 | 36 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mapboxgl-marker-compass", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "build:watch": "vite build --watch", 8 | "preview": "vite preview" 9 | }, 10 | "devDependencies": { 11 | "vite": "^5.1.0" 12 | }, 13 | "dependencies": { 14 | "prettier": "^3.2.5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | prettier: 9 | specifier: ^3.2.5 10 | version: 3.2.5 11 | 12 | devDependencies: 13 | vite: 14 | specifier: ^5.1.0 15 | version: 5.1.1 16 | 17 | packages: 18 | 19 | /@esbuild/aix-ppc64@0.19.12: 20 | resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} 21 | engines: {node: '>=12'} 22 | cpu: [ppc64] 23 | os: [aix] 24 | requiresBuild: true 25 | dev: true 26 | optional: true 27 | 28 | /@esbuild/android-arm64@0.19.12: 29 | resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} 30 | engines: {node: '>=12'} 31 | cpu: [arm64] 32 | os: [android] 33 | requiresBuild: true 34 | dev: true 35 | optional: true 36 | 37 | /@esbuild/android-arm@0.19.12: 38 | resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} 39 | engines: {node: '>=12'} 40 | cpu: [arm] 41 | os: [android] 42 | requiresBuild: true 43 | dev: true 44 | optional: true 45 | 46 | /@esbuild/android-x64@0.19.12: 47 | resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} 48 | engines: {node: '>=12'} 49 | cpu: [x64] 50 | os: [android] 51 | requiresBuild: true 52 | dev: true 53 | optional: true 54 | 55 | /@esbuild/darwin-arm64@0.19.12: 56 | resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} 57 | engines: {node: '>=12'} 58 | cpu: [arm64] 59 | os: [darwin] 60 | requiresBuild: true 61 | dev: true 62 | optional: true 63 | 64 | /@esbuild/darwin-x64@0.19.12: 65 | resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} 66 | engines: {node: '>=12'} 67 | cpu: [x64] 68 | os: [darwin] 69 | requiresBuild: true 70 | dev: true 71 | optional: true 72 | 73 | /@esbuild/freebsd-arm64@0.19.12: 74 | resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} 75 | engines: {node: '>=12'} 76 | cpu: [arm64] 77 | os: [freebsd] 78 | requiresBuild: true 79 | dev: true 80 | optional: true 81 | 82 | /@esbuild/freebsd-x64@0.19.12: 83 | resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} 84 | engines: {node: '>=12'} 85 | cpu: [x64] 86 | os: [freebsd] 87 | requiresBuild: true 88 | dev: true 89 | optional: true 90 | 91 | /@esbuild/linux-arm64@0.19.12: 92 | resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} 93 | engines: {node: '>=12'} 94 | cpu: [arm64] 95 | os: [linux] 96 | requiresBuild: true 97 | dev: true 98 | optional: true 99 | 100 | /@esbuild/linux-arm@0.19.12: 101 | resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} 102 | engines: {node: '>=12'} 103 | cpu: [arm] 104 | os: [linux] 105 | requiresBuild: true 106 | dev: true 107 | optional: true 108 | 109 | /@esbuild/linux-ia32@0.19.12: 110 | resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} 111 | engines: {node: '>=12'} 112 | cpu: [ia32] 113 | os: [linux] 114 | requiresBuild: true 115 | dev: true 116 | optional: true 117 | 118 | /@esbuild/linux-loong64@0.19.12: 119 | resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} 120 | engines: {node: '>=12'} 121 | cpu: [loong64] 122 | os: [linux] 123 | requiresBuild: true 124 | dev: true 125 | optional: true 126 | 127 | /@esbuild/linux-mips64el@0.19.12: 128 | resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} 129 | engines: {node: '>=12'} 130 | cpu: [mips64el] 131 | os: [linux] 132 | requiresBuild: true 133 | dev: true 134 | optional: true 135 | 136 | /@esbuild/linux-ppc64@0.19.12: 137 | resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} 138 | engines: {node: '>=12'} 139 | cpu: [ppc64] 140 | os: [linux] 141 | requiresBuild: true 142 | dev: true 143 | optional: true 144 | 145 | /@esbuild/linux-riscv64@0.19.12: 146 | resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} 147 | engines: {node: '>=12'} 148 | cpu: [riscv64] 149 | os: [linux] 150 | requiresBuild: true 151 | dev: true 152 | optional: true 153 | 154 | /@esbuild/linux-s390x@0.19.12: 155 | resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} 156 | engines: {node: '>=12'} 157 | cpu: [s390x] 158 | os: [linux] 159 | requiresBuild: true 160 | dev: true 161 | optional: true 162 | 163 | /@esbuild/linux-x64@0.19.12: 164 | resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} 165 | engines: {node: '>=12'} 166 | cpu: [x64] 167 | os: [linux] 168 | requiresBuild: true 169 | dev: true 170 | optional: true 171 | 172 | /@esbuild/netbsd-x64@0.19.12: 173 | resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} 174 | engines: {node: '>=12'} 175 | cpu: [x64] 176 | os: [netbsd] 177 | requiresBuild: true 178 | dev: true 179 | optional: true 180 | 181 | /@esbuild/openbsd-x64@0.19.12: 182 | resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} 183 | engines: {node: '>=12'} 184 | cpu: [x64] 185 | os: [openbsd] 186 | requiresBuild: true 187 | dev: true 188 | optional: true 189 | 190 | /@esbuild/sunos-x64@0.19.12: 191 | resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} 192 | engines: {node: '>=12'} 193 | cpu: [x64] 194 | os: [sunos] 195 | requiresBuild: true 196 | dev: true 197 | optional: true 198 | 199 | /@esbuild/win32-arm64@0.19.12: 200 | resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} 201 | engines: {node: '>=12'} 202 | cpu: [arm64] 203 | os: [win32] 204 | requiresBuild: true 205 | dev: true 206 | optional: true 207 | 208 | /@esbuild/win32-ia32@0.19.12: 209 | resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} 210 | engines: {node: '>=12'} 211 | cpu: [ia32] 212 | os: [win32] 213 | requiresBuild: true 214 | dev: true 215 | optional: true 216 | 217 | /@esbuild/win32-x64@0.19.12: 218 | resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} 219 | engines: {node: '>=12'} 220 | cpu: [x64] 221 | os: [win32] 222 | requiresBuild: true 223 | dev: true 224 | optional: true 225 | 226 | /@rollup/rollup-android-arm-eabi@4.9.6: 227 | resolution: {integrity: sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==} 228 | cpu: [arm] 229 | os: [android] 230 | requiresBuild: true 231 | dev: true 232 | optional: true 233 | 234 | /@rollup/rollup-android-arm64@4.9.6: 235 | resolution: {integrity: sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==} 236 | cpu: [arm64] 237 | os: [android] 238 | requiresBuild: true 239 | dev: true 240 | optional: true 241 | 242 | /@rollup/rollup-darwin-arm64@4.9.6: 243 | resolution: {integrity: sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==} 244 | cpu: [arm64] 245 | os: [darwin] 246 | requiresBuild: true 247 | dev: true 248 | optional: true 249 | 250 | /@rollup/rollup-darwin-x64@4.9.6: 251 | resolution: {integrity: sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==} 252 | cpu: [x64] 253 | os: [darwin] 254 | requiresBuild: true 255 | dev: true 256 | optional: true 257 | 258 | /@rollup/rollup-linux-arm-gnueabihf@4.9.6: 259 | resolution: {integrity: sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==} 260 | cpu: [arm] 261 | os: [linux] 262 | requiresBuild: true 263 | dev: true 264 | optional: true 265 | 266 | /@rollup/rollup-linux-arm64-gnu@4.9.6: 267 | resolution: {integrity: sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==} 268 | cpu: [arm64] 269 | os: [linux] 270 | requiresBuild: true 271 | dev: true 272 | optional: true 273 | 274 | /@rollup/rollup-linux-arm64-musl@4.9.6: 275 | resolution: {integrity: sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==} 276 | cpu: [arm64] 277 | os: [linux] 278 | requiresBuild: true 279 | dev: true 280 | optional: true 281 | 282 | /@rollup/rollup-linux-riscv64-gnu@4.9.6: 283 | resolution: {integrity: sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==} 284 | cpu: [riscv64] 285 | os: [linux] 286 | requiresBuild: true 287 | dev: true 288 | optional: true 289 | 290 | /@rollup/rollup-linux-x64-gnu@4.9.6: 291 | resolution: {integrity: sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==} 292 | cpu: [x64] 293 | os: [linux] 294 | requiresBuild: true 295 | dev: true 296 | optional: true 297 | 298 | /@rollup/rollup-linux-x64-musl@4.9.6: 299 | resolution: {integrity: sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==} 300 | cpu: [x64] 301 | os: [linux] 302 | requiresBuild: true 303 | dev: true 304 | optional: true 305 | 306 | /@rollup/rollup-win32-arm64-msvc@4.9.6: 307 | resolution: {integrity: sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==} 308 | cpu: [arm64] 309 | os: [win32] 310 | requiresBuild: true 311 | dev: true 312 | optional: true 313 | 314 | /@rollup/rollup-win32-ia32-msvc@4.9.6: 315 | resolution: {integrity: sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==} 316 | cpu: [ia32] 317 | os: [win32] 318 | requiresBuild: true 319 | dev: true 320 | optional: true 321 | 322 | /@rollup/rollup-win32-x64-msvc@4.9.6: 323 | resolution: {integrity: sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==} 324 | cpu: [x64] 325 | os: [win32] 326 | requiresBuild: true 327 | dev: true 328 | optional: true 329 | 330 | /@types/estree@1.0.5: 331 | resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} 332 | dev: true 333 | 334 | /esbuild@0.19.12: 335 | resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} 336 | engines: {node: '>=12'} 337 | hasBin: true 338 | requiresBuild: true 339 | optionalDependencies: 340 | '@esbuild/aix-ppc64': 0.19.12 341 | '@esbuild/android-arm': 0.19.12 342 | '@esbuild/android-arm64': 0.19.12 343 | '@esbuild/android-x64': 0.19.12 344 | '@esbuild/darwin-arm64': 0.19.12 345 | '@esbuild/darwin-x64': 0.19.12 346 | '@esbuild/freebsd-arm64': 0.19.12 347 | '@esbuild/freebsd-x64': 0.19.12 348 | '@esbuild/linux-arm': 0.19.12 349 | '@esbuild/linux-arm64': 0.19.12 350 | '@esbuild/linux-ia32': 0.19.12 351 | '@esbuild/linux-loong64': 0.19.12 352 | '@esbuild/linux-mips64el': 0.19.12 353 | '@esbuild/linux-ppc64': 0.19.12 354 | '@esbuild/linux-riscv64': 0.19.12 355 | '@esbuild/linux-s390x': 0.19.12 356 | '@esbuild/linux-x64': 0.19.12 357 | '@esbuild/netbsd-x64': 0.19.12 358 | '@esbuild/openbsd-x64': 0.19.12 359 | '@esbuild/sunos-x64': 0.19.12 360 | '@esbuild/win32-arm64': 0.19.12 361 | '@esbuild/win32-ia32': 0.19.12 362 | '@esbuild/win32-x64': 0.19.12 363 | dev: true 364 | 365 | /fsevents@2.3.3: 366 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 367 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 368 | os: [darwin] 369 | requiresBuild: true 370 | dev: true 371 | optional: true 372 | 373 | /nanoid@3.3.7: 374 | resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 375 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 376 | hasBin: true 377 | dev: true 378 | 379 | /picocolors@1.0.0: 380 | resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} 381 | dev: true 382 | 383 | /postcss@8.4.35: 384 | resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} 385 | engines: {node: ^10 || ^12 || >=14} 386 | dependencies: 387 | nanoid: 3.3.7 388 | picocolors: 1.0.0 389 | source-map-js: 1.0.2 390 | dev: true 391 | 392 | /prettier@3.2.5: 393 | resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} 394 | engines: {node: '>=14'} 395 | hasBin: true 396 | dev: false 397 | 398 | /rollup@4.9.6: 399 | resolution: {integrity: sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==} 400 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} 401 | hasBin: true 402 | dependencies: 403 | '@types/estree': 1.0.5 404 | optionalDependencies: 405 | '@rollup/rollup-android-arm-eabi': 4.9.6 406 | '@rollup/rollup-android-arm64': 4.9.6 407 | '@rollup/rollup-darwin-arm64': 4.9.6 408 | '@rollup/rollup-darwin-x64': 4.9.6 409 | '@rollup/rollup-linux-arm-gnueabihf': 4.9.6 410 | '@rollup/rollup-linux-arm64-gnu': 4.9.6 411 | '@rollup/rollup-linux-arm64-musl': 4.9.6 412 | '@rollup/rollup-linux-riscv64-gnu': 4.9.6 413 | '@rollup/rollup-linux-x64-gnu': 4.9.6 414 | '@rollup/rollup-linux-x64-musl': 4.9.6 415 | '@rollup/rollup-win32-arm64-msvc': 4.9.6 416 | '@rollup/rollup-win32-ia32-msvc': 4.9.6 417 | '@rollup/rollup-win32-x64-msvc': 4.9.6 418 | fsevents: 2.3.3 419 | dev: true 420 | 421 | /source-map-js@1.0.2: 422 | resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} 423 | engines: {node: '>=0.10.0'} 424 | dev: true 425 | 426 | /vite@5.1.1: 427 | resolution: {integrity: sha512-wclpAgY3F1tR7t9LL5CcHC41YPkQIpKUGeIuT8MdNwNZr6OqOTLs7JX5vIHAtzqLWXts0T+GDrh9pN2arneKqg==} 428 | engines: {node: ^18.0.0 || >=20.0.0} 429 | hasBin: true 430 | peerDependencies: 431 | '@types/node': ^18.0.0 || >=20.0.0 432 | less: '*' 433 | lightningcss: ^1.21.0 434 | sass: '*' 435 | stylus: '*' 436 | sugarss: '*' 437 | terser: ^5.4.0 438 | peerDependenciesMeta: 439 | '@types/node': 440 | optional: true 441 | less: 442 | optional: true 443 | lightningcss: 444 | optional: true 445 | sass: 446 | optional: true 447 | stylus: 448 | optional: true 449 | sugarss: 450 | optional: true 451 | terser: 452 | optional: true 453 | dependencies: 454 | esbuild: 0.19.12 455 | postcss: 8.4.35 456 | rollup: 4.9.6 457 | optionalDependencies: 458 | fsevents: 2.3.3 459 | dev: true 460 | -------------------------------------------------------------------------------- /src/MarkerCompass.css: -------------------------------------------------------------------------------- 1 | .mapboxgl-compass { 2 | -webkit-transition: opacity 400ms ease; 3 | -o-transition: opacity 400ms ease; 4 | transition: opacity 400ms ease; 5 | border-radius: 100%; 6 | cursor: pointer; 7 | background-color: var(--marker-compass-background-color, #3fb1ce); 8 | } 9 | .mapboxgl-compass .mapboxgl-compass__arrow { 10 | height: 100%; 11 | left: 50%; 12 | position: absolute; 13 | top: 50%; 14 | -webkit-transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 15 | -ms-transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 16 | transform: translate(-50%, -50%) rotate(var(--marker-compass-angle)); 17 | width: 100%; 18 | } 19 | .mapboxgl-compass .mapboxgl-compass__arrow:after { 20 | content: ""; 21 | border-bottom: var(--marker-compass-arrow-size, 4px) solid transparent; 22 | border-right: var(--marker-compass-arrow-size, 4px) solid 23 | var(--marker-compass-background-color, #3fb1ce); 24 | border-top: var(--marker-compass-arrow-size, 4px) solid transparent; 25 | height: 0; 26 | left: 50%; 27 | position: absolute; 28 | top: 50%; 29 | -webkit-transform: translate( 30 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 31 | -50% 32 | ); 33 | -ms-transform: translate( 34 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 35 | -50% 36 | ); 37 | transform: translate( 38 | calc(-50% - var(--marker-compass-arrow-offset, 14px)), 39 | -50% 40 | ); 41 | width: 0; 42 | } 43 | -------------------------------------------------------------------------------- /src/MarkerCompass.js: -------------------------------------------------------------------------------- 1 | export class MarkerCompass { 2 | constructor(map, markers, options) { 3 | this.options = { 4 | // defaults 5 | offset: 10, 6 | width: 20, 7 | height: 20, 8 | backgroundColor: "#3FB1CE", 9 | arrowSize: 4, 10 | arrowOffset: 14, 11 | flyToZoom: 12, 12 | ...options, 13 | }; 14 | this.container = null; 15 | this.markers = markers; 16 | this.compasses = []; 17 | this.map = map; 18 | this.init(); 19 | } 20 | 21 | init() { 22 | if (!this.map) { 23 | console.warn("No map container provided."); 24 | return; 25 | } 26 | if (!this.markers || !this.markers.length) { 27 | console.warn("No markers found."); 28 | return; 29 | } 30 | 31 | this.map.on("load", this.createCompasses.bind(this)); 32 | this.map.on("move", this.updateCompasses.bind(this)); 33 | } 34 | destroy() { 35 | this.map.off("move", this.updateCompasses.bind(this)); 36 | this.compasses.forEach((compass) => compass.remove()); 37 | } 38 | createCompasses() { 39 | const container = this.map.getContainer(); 40 | container.style.setProperty( 41 | "--marker-compass-arrow-size", 42 | this.options.arrowSize + "px" 43 | ); 44 | container.style.setProperty( 45 | "--marker-compass-arrow-offset", 46 | this.options.arrowOffset + "px" 47 | ); 48 | container.style.setProperty( 49 | "--marker-compass-background-color", 50 | this.options.backgroundColor 51 | ); 52 | 53 | if (!container) { 54 | console.warn("No map container found."); 55 | return; 56 | } 57 | this.markers.forEach((marker, index) => { 58 | const compass = document.createElement("div"); 59 | compass.classList.add("mapboxgl-compass"); 60 | compass.dataset.compass = "true"; 61 | compass.style.width = this.options.width + "px"; 62 | compass.style.height = this.options.height + "px"; 63 | compass.style.position = "absolute"; 64 | compass.style.opacity = 0; 65 | compass.style.zIndex = 2; 66 | const arrow = document.createElement("div"); 67 | arrow.classList.add("mapboxgl-compass__arrow"); 68 | compass.appendChild(arrow); 69 | container.appendChild(compass); 70 | compass.addEventListener("click", () => { 71 | if (!this.markers?.[index]?._lngLat) { 72 | return; 73 | } 74 | this.map.flyTo({ 75 | center: this.markers[index]._lngLat, 76 | zoom: this.options.flyToZoom, 77 | }); 78 | }); 79 | this.compasses.push(compass); 80 | }); 81 | this.updateCompasses(); 82 | } 83 | updateCompasses() { 84 | const bounds = this.map.getBounds(); 85 | if (!this.markers.length) { 86 | return; 87 | } 88 | this.markers.forEach((marker, index) => { 89 | const lngLat = marker.getLngLat(); 90 | const compass = this.compasses[index]; 91 | if (!compass) { 92 | return; 93 | } 94 | const compassWidth = compass.clientWidth; 95 | const compassHeight = compass.clientHeight; 96 | const offset = this.options.offset; 97 | const container = this.map.getContainer(); 98 | const maxWidth = container.clientWidth - offset; 99 | const maxHeight = container.clientHeight - offset; 100 | const x = this.map.project(lngLat).x; 101 | const y = this.map.project(lngLat).y; 102 | let translate = { 103 | x: offset, 104 | y: offset, 105 | }; 106 | if (y > compassHeight) { 107 | translate.y = y - compassHeight / 2; 108 | } 109 | if (y >= maxHeight - compassHeight / 2) { 110 | translate.y = maxHeight - compassHeight; 111 | } 112 | if (x > compassWidth) { 113 | translate.x = x - compassWidth / 2; 114 | } 115 | if (x >= maxWidth - compassWidth / 2) { 116 | translate.x = maxWidth - compassWidth; 117 | } 118 | compass.style.transform = `translate(${translate.x}px, ${translate.y}px)`; 119 | if (!bounds.contains(lngLat)) { 120 | const angleDeg = 121 | (Math.atan2(translate.y - y, translate.x - x) * 180) / Math.PI; 122 | compass.style.setProperty("--marker-compass-angle", `${angleDeg}deg`); 123 | compass.style.opacity = 1; 124 | compass.style.pointerEvents = "all"; 125 | } else { 126 | compass.style.pointerEvents = "none"; 127 | compass.style.opacity = 0; 128 | } 129 | }); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import "./MarkerCompass.css"; 2 | import { MarkerCompass } from "./MarkerCompass.js"; 3 | export { MarkerCompass }; 4 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | import path from "path"; 4 | 5 | export default defineConfig({ 6 | server: { 7 | port: 3000, 8 | }, 9 | build: { 10 | minify: true, 11 | lib: { 12 | entry: path.resolve(__dirname, "src/main.js"), 13 | name: "mapboxgl-marker-compass", 14 | fileName: "mapboxgl-marker-compass", 15 | }, 16 | rollupOptions: { 17 | output: { 18 | assetFileNames: (assetInfo) => { 19 | if (assetInfo.name === "style.css") 20 | return "mapboxgl-marker-compass.css"; 21 | return assetInfo.name; 22 | }, 23 | }, 24 | }, 25 | }, 26 | }); 27 | --------------------------------------------------------------------------------