1import { Controller } from "@hotwired/stimulus" 2import mapboxgl from "mapbox-gl" 3import supportsInteractionObserver from "utils/supports_interaction_observer" 4 5// Connects to data-controller="numiko--mapbox-globe" 6export default class extends Controller { 7 static targets = ['globeWrapper'] 8 static values = { token: String, style: String } 9 10 initialize() { 11 this.map = null; 12 } 13 14 connect () { 15 if (supportsInteractionObserver()) { 16 this.#setupObserver(); 17 } else { 18 this.#loadMapbox(); 19 } 20 } 21 22 disconnect () { 23 if (this.intersectionObserver) { 24 this.intersectionObserver.disconnect(); 25 } 26 this.map?.remove(); 27 } 28 29 #setupObserver () { 30 this.intersectionObserver = new window.IntersectionObserver(([entry]) => { 31 if (entry.isIntersecting) { 32 this.#loadMapbox(); 33 return; 34 } 35 }, { root: null, threshold: 0.1 }) 36 37 this.intersectionObserver.observe(this.element); 38 } 39 40 #loadMapbox () { 41 const globeContainerElem = this.globeWrapperTarget; 42 let allowSpin = true; 43 /* Should make file .js.erb and interpolate via ENV key */ 44 mapboxgl.accessToken = this.tokenValue; 45 this.map = new mapboxgl.Map({ 46 container: 'mapbox-globe', 47 style: this.styleValue, 48 center: [0, 10], 49 projection: { name: 'globe' }, 50 zoom: 1.2, 51 minZoom: 1.2, 52 scrollZoom: false, 53 dragRotate: false, 54 renderWorldCopies: false 55 }); 56 let nav = new mapboxgl.NavigationControl(); 57 this.map.addControl(nav, 'top-right'); 58 this.map.on('load', () => { 59 this.map.setFog({}); 60 this.spinGlobe(this.map, allowSpin); 61 this.map.addSource('pointsOnGlobe', { 62 type: 'geojson', 63 data: { 64 "type":"FeatureCollection", 65 "features": JSON.parse(globeContainerElem.dataset.mapboxGlobePoints).map((p
65oint) => JSON.parse(point)) 66 }, 67 cluster: true, 68 clusterMaxZoom: 14, 69 clusterRadius: 50 70 }); 71 this.map.on('moveend', () => { 72 this.spinGlobe(this.map, allowSpin); 73 }); 74 this.map.on('click', (e) => { 75 allowSpin = false; 76 this.stopSpin(this.map); 77 }) 78 this.map.addLayer({ 79 id: 'clusters', 80 type: 'circle', 81 source: 'pointsOnGlobe', 82 filter: ['has', 'point_count'], 83 paint: { 84 'circle-color': ['step', ['get', 'point_count'], '#B33D26', 100, '#B33D26', 750, '#B33D26'], 85 'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 20, 40, 30, 60] 86 } 87 }); 88 this.map.addLayer({ 89 id: 'cluster-count', 90 type: 'symbol', 91 source: 'pointsOnGlobe', 92 filter: ['has', 'point_count'], 93 layout: { 94 'text-field': '{point_count_abbreviated}', 95 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 96 'text-size': 14 97 }, 98 paint: { 99 'text-color': '#ffffff' 100 } 101 }); 102 this.map.addLayer({ 103 id: 'unclustered-point', 104 type: 'circle', 105 source: 'pointsOnGlobe', 106 filter: ['!', ['has', 'point_count']], 107 paint: { 108 'circle-color': '#B33D26', 109 'circle-radius': 8, 110 'circle-stroke-width': 1, 111 'circle-stroke-color': '#fff' 112 } 113 }); 114 this.map.on('click', 'clusters', (e) => { 115 var clusterId, features; 116 features = this.map.queryRenderedFeatures(e.point, { 117 layers: ['clusters'] 118 }); 119 clusterId = features[0].properties.cluster_id; 120 this.map.getSource('pointsOnGlobe').getClusterExpansionZoom(clusterId, function(err, zoom) { 121 if (err) { 122 return; 123 } 124 this.map.easeTo({ 125 center: features[0].geometry.coordinates, 126 zoom: zoom + 1 127 }); 128 }); 129 }); 130 this.map.on('mouseenter', 'clusters', () => { 131 this.map.getCanvas().style.cursor = 'pointer'; 132 }); 133 this.map.on('mouseleave', 'clusters', () => { 134 this.map.getCanvas().style.cursor = ''; 135 }); 136 this.map.on('click', 'unclustered-point', (e) => { 137 var coordinates, linktext, popupHtml, properties; 138 coordinates = e.features[0].geometry.coordinates.slice(); 139 properties = e.features[0].properties; 140 popupHtml = `<h3>${properties.title}</h3>`; 141 if (properties.subhead) { popupHtml += `<h5>${properties.subhead}</h5>`; } 142 if (properties.imageSrc) { 143 popupHtml += `<img class="mapbox--globe-popup" src="${properties.imageSrc}" />`; 144 } else if (properties.vimeoId) { 145 popupHtml += `<div class="mapbox--globe-popup"><iframe class="mapbox--globe-popup" src="https://player.vimeo.com/video/${properties.vimeoId}?color=B33D26" allowfullscreen></iframe></div>`; 146 } 147 if (properties.description) { 148 popupHtml += `<p>${properties.description}</p>`; 149 } 150 if (properties.link) { 151 linktext = 'Find out more'; 152 if (properties.linktext) { 153 linktext = properties.linktext; 154 } 155 popupHtml += `<a class="btn btn-primary" href="${properties.link}">${linktext}</a>`; 156 } 157 while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { 158 coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; 159 } 160 (new mapboxgl.Popup).setLngLat(coordinates).setHTML(popupHtml).addTo(this.map); 161 }); 162 this.map.on('mouseenter', 'unclustered-point', () => { 163 this.map.getCanvas().style.cursor = 'pointer'; 164 }); 165 this.map.on('mouseleave', 'unclustered-point', () => { 166 this.map.getCanvas().style.cursor = ''; 167 }); 168 }); 169 } 170 171 spinGlobe(map, allowSpin) { 172 if (allowSpin) { 173 // Rotate at intermediate speeds between zoom levels 3 and 5. 174 const slowSpinZoom = 3; 175 // At low zooms, complete a revolution every two minutes. 176 const secondsPerRevolution = 120; 177 // Above zoom level 5, do not rotate. 178 const maxSpinZoom = 5; 179 const zoom = this.map.getZoom(); 180 if (zoom < maxSpinZoom) { 181 let distancePerSecond = 360 / secondsPerRevolution; 182 if (zoom > slowSpinZoom) { 183 // Slow spinning at higher zooms 184 const zoomDif = 185 (maxSpinZoom - zoom) / (maxSpinZoom - slowSpinZoom); 186 distancePerSecond *= zoomDif; 187 } 188 const center = this.map.getCenter(); 189 center.lng -= distancePerSecond; 190 // Smoothly animate the map over one second. 191 // When this animation is complete, it calls a 'moveend' event. 192 this.map.easeTo({ center, duration: 1000, easing: (n) => n }); 193 } 194 } 195 } 196 197 stopSpin(map) { 198 map.stop(); 199 } 200}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.