Structures.vue 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. <template>
  2. <LayoutContainer>
  3. <v-responsive :aspect-ratio="1" width="100%">
  4. <!-- /!\ We do not use the nuxt-leaflet library here,
  5. because in its current version it's way too slow to deal with hundreds of markers -->
  6. <div id="map" />
  7. </v-responsive>
  8. <div class="advice">
  9. {{ $t("click_on_land_to_go_there") }}
  10. </div>
  11. <v-row class="map-shortcuts">
  12. <v-col
  13. v-for="shortcut in shortcuts"
  14. :key="shortcut.src"
  15. cols="4"
  16. lg="2"
  17. class="px-1"
  18. >
  19. <div
  20. class="map-shortcut"
  21. @click="setMapBounds(shortcut.bounds)"
  22. >
  23. <v-img
  24. :src="shortcut.src"
  25. :alt="shortcut.alt"
  26. :title="shortcut.alt"
  27. width="85"
  28. height="85"
  29. />
  30. <div class="map-shortcut-overlay">
  31. <font-awesome-icon class="icon" icon="search" />
  32. </div>
  33. </div>
  34. </v-col>
  35. </v-row>
  36. </LayoutContainer>
  37. </template>
  38. <script lang="ts">
  39. import 'leaflet/dist/leaflet.css'
  40. import Vue from 'vue'
  41. let L: any
  42. if (process.client) { // only require leaflet on client-side
  43. L = require('leaflet')
  44. }
  45. declare module 'vue/types/vue' {
  46. interface Vue {
  47. structures: Array<Structure>,
  48. zoomRequired: boolean,
  49. map: any,
  50. defaultBounds: L.LatLngBounds,
  51. shortcuts: Array<{src: string, alt: string, bounds: number[][] }>
  52. }
  53. }
  54. const METROPOLE_BOUNDS = new L.LatLngBounds([51.03, -5.78], [41.2, 9.70])
  55. export default Vue.extend({
  56. props: {
  57. structures: {
  58. type: Array as () => Array<Structure>,
  59. required: false,
  60. default: () => []
  61. }
  62. },
  63. data () {
  64. return {
  65. map: null,
  66. defaultBounds: METROPOLE_BOUNDS,
  67. shortcuts: [
  68. { src: '/images/metropole.png', alt: 'Metropole', bounds: [[51.03, -5.78], [41.2, 9.70]] },
  69. { src: '/images/guadeloupe.png', alt: 'Guadeloupe', bounds: [[16.62, -62.03], [15.74, -60.97]] },
  70. { src: '/images/martinique.png', alt: 'Martinique', bounds: [[14.95, -61.43], [14.28, -60.60]] },
  71. { src: '/images/mayotte.png', alt: 'Mayotte', bounds: [[-12.51, 44.86], [-13.19, 45.45]] },
  72. { src: '/images/la_reunion.png', alt: 'La Réunion', bounds: [[-20.65, 54.92], [-21.65, 56.15]] },
  73. { src: '/images/guyane.png', alt: 'Guyane', bounds: [[6.24, -54.62], [1.87, -50.59]] }
  74. ],
  75. zoomRequired: false,
  76. nextZoomIsDefault: false,
  77. firstPopulate: true
  78. }
  79. },
  80. watch: {
  81. structures (oldVal: Array<Structure>, newVal: Array<Structure>): void {
  82. if (oldVal !== newVal) {
  83. this.populateMarkers()
  84. }
  85. if (this.zoomRequired) {
  86. this.fitBoundsToMarkers()
  87. }
  88. }
  89. },
  90. async mounted () {
  91. const defaultCenter: L.LatLngTuple = [46.71, 1.94]
  92. const defaultZoom: number = 5.5
  93. const layerSource: string = 'https://{s}.tile.osm.org/{z}/{x}/{y}.png'
  94. const attribution: string = '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
  95. const options: L.MapOptions = { scrollWheelZoom: false, zoomSnap: 0.25 }
  96. this.map = L.map('map', options)
  97. this.map.setView(defaultCenter, defaultZoom)
  98. L.tileLayer(layerSource, { attribution }).addTo(this.map)
  99. await this.populateMarkers()
  100. this.map.on('zoomend moveend', () => {
  101. this.$emit('boundsUpdated', this.map.getBounds())
  102. })
  103. if (this.zoomRequired) {
  104. this.fitBoundsToMarkers()
  105. }
  106. },
  107. beforeDestroy (): void {
  108. if (this.map) {
  109. this.map.remove()
  110. }
  111. },
  112. methods: {
  113. populateMarkers () {
  114. // remove any existant marker layers
  115. this.map.eachLayer((layer: L.Layer) => {
  116. if (layer instanceof L.MarkerClusterGroup) {
  117. this.map.removeLayer(layer)
  118. }
  119. })
  120. // populate new layer
  121. const clusters = L.markerClusterGroup()
  122. for (const s of this.structures) {
  123. if (!s.mapAddress || !s.mapAddress.latitude || !s.mapAddress.longitude) { continue }
  124. const marker = L.marker([s.mapAddress.latitude, s.mapAddress.longitude])
  125. marker.bindPopup(`<b>${s.name}</b><br/>${s.mapAddress.postalCode} ${s.mapAddress.addressCity}<br/><a href="${s.website}" target="_blank">${s.website}</a>`)
  126. clusters.addLayer(marker)
  127. }
  128. this.map.addLayer(clusters)
  129. if (this.firstPopulate) {
  130. if (this.structures.length > 0) { // map is considered as mounted only when the first results are diplayed on it
  131. this.$emit('mounted')
  132. this.firstPopulate = false
  133. }
  134. } else {
  135. this.$emit('populated')
  136. }
  137. },
  138. setMapBounds (bounds: L.LatLngBoundsExpression, padding: [number, number] = [0, 0]): void {
  139. this.map.fitBounds(bounds, { padding }) // << without this, the new bounds may not be properly set when the current view overlaps the new bounds.
  140. },
  141. resetBounds (): void {
  142. this.setMapBounds(
  143. this.defaultBounds,
  144. (this.defaultBounds === METROPOLE_BOUNDS ? [0, 0] : [80, 80]) as [number, number]
  145. )
  146. },
  147. fitNextResults (setAsDefault = false) {
  148. this.zoomRequired = true
  149. this.nextZoomIsDefault = setAsDefault
  150. },
  151. fitBoundsToMarkers (): boolean {
  152. const coords: L.LatLngBoundsLiteral = this.structures.filter(
  153. (s: Structure) => {
  154. return (
  155. s.mapAddress !== null &&
  156. s.mapAddress.latitude && typeof s.mapAddress.latitude !== 'undefined' &&
  157. s.mapAddress.longitude && typeof s.mapAddress.latitude !== 'undefined'
  158. )
  159. }
  160. ).map(
  161. (s: Structure) => { return [(s.mapAddress as Address).latitude as number, (s.mapAddress as Address).longitude as number] as L.LatLngTuple }
  162. )
  163. if (!(coords.length > 0)) {
  164. return false
  165. }
  166. this.setMapBounds(coords, [80, 80])
  167. if (this.nextZoomIsDefault) {
  168. this.defaultBounds = coords
  169. }
  170. return true
  171. }
  172. }
  173. })
  174. </script>
  175. <style scoped lang="scss">
  176. #map {
  177. height: 100%;
  178. width: 100%;
  179. }
  180. .advice {
  181. margin: 1rem 0;
  182. color: #262626;
  183. font-weight: 750;
  184. text-align: center;
  185. font-size: 0.9rem;
  186. width: 100%;
  187. }
  188. .map-shortcuts {
  189. padding: 12px 6px;
  190. }
  191. .map-shortcut {
  192. position: relative;
  193. border: solid 1px #000;
  194. height: 87px;
  195. width: 87px;
  196. }
  197. .map-shortcut-overlay {
  198. position: absolute;
  199. top: 0;
  200. bottom: 0;
  201. left: 0;
  202. right: 0;
  203. height: 100%;
  204. width: 100%;
  205. opacity: 0;
  206. cursor: pointer;
  207. }
  208. .map-shortcut:hover .map-shortcut-overlay {
  209. opacity: 0.5;
  210. }
  211. .map-shortcut-overlay>.icon {
  212. color: #595959;
  213. font-size: 38px;
  214. position: absolute;
  215. top: 50%;
  216. left: 50%;
  217. -webkit-transform: translate(-50%, -50%);
  218. -ms-transform: translate(-50%, -50%);
  219. transform: translate(-50%, -50%);
  220. text-align: center;
  221. }
  222. </style>