From 1af0d30d944aeaa2afccfd2555295a0f0d3f50c2 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Thu, 15 Oct 2020 17:22:08 +0200 Subject: [PATCH] Use new optional layers in maps module --- app/javascript/components/MapEditor/index.js | 28 +-- app/javascript/components/MapReader/index.js | 29 ++- .../components/MapStyles/base-style.js | 96 -------- app/javascript/components/MapStyles/index.js | 55 ----- .../components/shared/mapbox/Mapbox.js | 3 + .../mapbox}/SwitchMapStyle.js | 11 +- .../components/shared/mapbox/styles/base.js | 213 ++++++++++++++++++ .../mapbox/styles/cadastre-layers.js} | 0 .../mapbox/styles}/images/preview-ortho.png | Bin .../mapbox/styles}/images/preview-vector.png | Bin .../components/shared/mapbox/styles/index.js | 29 +++ .../mapbox/styles}/ortho-style.js | 0 .../mapbox/styles}/vector-style.js | 0 .../shared/{map.js => mapbox/utils.js} | 0 14 files changed, 280 insertions(+), 184 deletions(-) delete mode 100644 app/javascript/components/MapStyles/base-style.js delete mode 100644 app/javascript/components/MapStyles/index.js create mode 100644 app/javascript/components/shared/mapbox/Mapbox.js rename app/javascript/components/{MapStyles => shared/mapbox}/SwitchMapStyle.js (87%) create mode 100644 app/javascript/components/shared/mapbox/styles/base.js rename app/javascript/components/{MapStyles/cadastre.js => shared/mapbox/styles/cadastre-layers.js} (100%) rename app/javascript/components/{MapStyles => shared/mapbox/styles}/images/preview-ortho.png (100%) rename app/javascript/components/{MapStyles => shared/mapbox/styles}/images/preview-vector.png (100%) create mode 100644 app/javascript/components/shared/mapbox/styles/index.js rename app/javascript/components/{MapStyles => shared/mapbox/styles}/ortho-style.js (100%) rename app/javascript/components/{MapStyles => shared/mapbox/styles}/vector-style.js (100%) rename app/javascript/components/shared/{map.js => mapbox/utils.js} (100%) diff --git a/app/javascript/components/MapEditor/index.js b/app/javascript/components/MapEditor/index.js index 4c6246a2f..76803b00c 100644 --- a/app/javascript/components/MapEditor/index.js +++ b/app/javascript/components/MapEditor/index.js @@ -1,13 +1,15 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import PropTypes from 'prop-types'; import mapboxgl from 'mapbox-gl'; -import ReactMapboxGl, { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl'; +import { GeoJSONLayer, ZoomControl } from 'react-mapbox-gl'; import DrawControl from 'react-mapbox-gl-draw'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; import { getJSON, ajax, fire } from '@utils'; -import { getMapStyle, SwitchMapStyle } from '../MapStyles'; +import Mapbox from '../shared/mapbox/Mapbox'; +import { getMapStyle } from '../shared/mapbox/styles'; +import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle'; import ComboAdresseSearch from '../ComboAdresseSearch'; import { @@ -22,9 +24,7 @@ import { generateId, useEvent, findFeature -} from '../shared/map'; - -const Map = ReactMapboxGl({}); +} from '../shared/mapbox/utils'; function MapEditor({ featureCollection, url, preview, options }) { const drawControl = useRef(null); @@ -38,10 +38,11 @@ function MapEditor({ featureCollection, url, preview, options }) { const [cadastresFeatureCollection, setCadastresFeatureCollection] = useState( filterFeatureCollection(featureCollection, 'cadastre') ); - const mapStyle = useMemo( - () => getMapStyle(style, options.cadastres, options.mnhn), - [style, options] - ); + const mapStyle = useMemo(() => getMapStyle(style, options.layers), [ + style, + options + ]); + const hasCadastres = useMemo(() => options.layers.includes('cadastres')); const translations = [ ['.mapbox-gl-draw_line', 'Tracer une ligne'], @@ -288,7 +289,7 @@ function MapEditor({ featureCollection, url, preview, options }) { }} /> - onMapLoad(map)} fitBounds={bbox} fitBoundsOptions={{ padding: 100 }} @@ -299,7 +300,7 @@ function MapEditor({ featureCollection, url, preview, options }) { height: '500px' }} > - {options.cadastres ? ( + {hasCadastres ? ( - + ); } @@ -335,8 +336,7 @@ MapEditor.propTypes = { url: PropTypes.string, preview: PropTypes.bool, options: PropTypes.shape({ - cadastres: PropTypes.bool, - mnhn: PropTypes.bool, + layers: PropTypes.array, ign: PropTypes.bool }) }; diff --git a/app/javascript/components/MapReader/index.js b/app/javascript/components/MapReader/index.js index 48c86a7ef..9a14202cb 100644 --- a/app/javascript/components/MapReader/index.js +++ b/app/javascript/components/MapReader/index.js @@ -1,10 +1,11 @@ import React, { useState, useCallback, useMemo } from 'react'; -import ReactMapboxGl, { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl'; +import { ZoomControl, GeoJSONLayer } from 'react-mapbox-gl'; import mapboxgl, { Popup } from 'mapbox-gl'; import PropTypes from 'prop-types'; -import { getMapStyle, SwitchMapStyle } from '../MapStyles'; - +import Mapbox from '../shared/mapbox/Mapbox'; +import { getMapStyle } from '../shared/mapbox/styles'; +import SwitchMapStyle from '../shared/mapbox/SwitchMapStyle'; import { filterFeatureCollection, filterFeatureCollectionByGeometryType, @@ -12,9 +13,7 @@ import { findFeature, fitBounds, getCenter -} from '../shared/map'; - -const Map = ReactMapboxGl({}); +} from '../shared/mapbox/utils'; const MapReader = ({ featureCollection, options }) => { const [currentMap, setCurrentMap] = useState(null); @@ -51,11 +50,11 @@ const MapReader = ({ featureCollection, options }) => { ), [selectionsUtilisateurFeatureCollection] ); - const hasCadastres = !!cadastresFeatureCollection.length; - const mapStyle = useMemo( - () => getMapStyle(style, hasCadastres, options.mnhn), - [style, options, cadastresFeatureCollection] - ); + const hasCadastres = useMemo(() => options.layers.includes('cadastres')); + const mapStyle = useMemo(() => getMapStyle(style, options.layers), [ + style, + options + ]); const popup = useMemo( () => new Popup({ @@ -147,7 +146,7 @@ const MapReader = ({ featureCollection, options }) => { } return ( - onMapLoad(map)} fitBounds={boundData} fitBoundsOptions={{ padding: 100 }} @@ -186,7 +185,7 @@ const MapReader = ({ featureCollection, options }) => { - + ); }; @@ -197,8 +196,8 @@ MapReader.propTypes = { features: PropTypes.array }), options: PropTypes.shape({ - ign: PropTypes.bool, - mnhn: PropTypes.bool + layers: PropTypes.array, + ign: PropTypes.bool }) }; diff --git a/app/javascript/components/MapStyles/base-style.js b/app/javascript/components/MapStyles/base-style.js deleted file mode 100644 index 1443e4500..000000000 --- a/app/javascript/components/MapStyles/base-style.js +++ /dev/null @@ -1,96 +0,0 @@ -const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk'; - -function ignServiceURL(layer, format = 'image/png') { - const url = `https://wxs.ign.fr/${IGN_TOKEN}/geoportail/wmts`; - const query = - 'service=WMTS&request=GetTile&version=1.0.0&tilematrixset=PM&tilematrix={z}&tilecol={x}&tilerow={y}&style=normal'; - - return `${url}?${query}&layer=${layer}&format=${format}`; -} - -function rasterSource(tiles, attribution) { - return { - type: 'raster', - tiles, - tileSize: 256, - attribution, - minzoom: 0, - maxzoom: 18 - }; -} - -export default { - version: 8, - metadat: { - 'mapbox:autocomposite': false, - 'mapbox:groups': { - 1444849242106.713: { collapsed: false, name: 'Places' }, - 1444849334699.1902: { collapsed: true, name: 'Bridges' }, - 1444849345966.4436: { collapsed: false, name: 'Roads' }, - 1444849354174.1904: { collapsed: true, name: 'Tunnels' }, - 1444849364238.8171: { collapsed: false, name: 'Buildings' }, - 1444849382550.77: { collapsed: false, name: 'Water' }, - 1444849388993.3071: { collapsed: false, name: 'Land' } - }, - 'mapbox:type': 'template', - 'openmaptiles:mapbox:owner': 'openmaptiles', - 'openmaptiles:mapbox:source:url': 'mapbox://openmaptiles.4qljc88t', - 'openmaptiles:version': '3.x', - 'maputnik:renderer': 'mbgljs' - }, - center: [0, 0], - zoom: 1, - bearing: 0, - pitch: 0, - sources: { - 'decoupage-administratif': { - type: 'vector', - url: - 'https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json' - }, - openmaptiles: { - type: 'vector', - url: 'https://openmaptiles.geo.data.gouv.fr/data/france-vector.json' - }, - 'photographies-aeriennes': { - type: 'raster', - tiles: [ - 'https://tiles.geo.api.gouv.fr/photographies-aeriennes/tiles/{z}/{x}/{y}' - ], - tileSize: 256, - attribution: 'Images aériennes © IGN', - minzoom: 0, - maxzoom: 19 - }, - cadastre: { - type: 'vector', - url: 'https://openmaptiles.geo.data.gouv.fr/data/cadastre.json' - }, - 'plan-ign': rasterSource( - [ignServiceURL('GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2')], - 'IGN-F/Géoportail' - ), - 'protectedareas-gp': rasterSource( - [ignServiceURL('PROTECTEDAREAS.GP')], - 'IGN-F/Géoportail/MNHN' - ), - 'protectedareas-pn': rasterSource( - [ignServiceURL('PROTECTEDAREAS.PN')], - 'IGN-F/Géoportail/MNHN' - ), - 'protectedareas-pnr': rasterSource( - [ignServiceURL('PROTECTEDAREAS.PNR')], - 'IGN-F/Géoportail/MNHN' - ), - 'protectedareas-sic': rasterSource( - [ignServiceURL('PROTECTEDAREAS.SIC')], - 'IGN-F/Géoportail/MNHN' - ), - 'protectedareas-zps': rasterSource( - [ignServiceURL('PROTECTEDAREAS.ZPS')], - 'IGN-F/Géoportail/MNHN' - ) - }, - sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite', - glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf' -}; diff --git a/app/javascript/components/MapStyles/index.js b/app/javascript/components/MapStyles/index.js deleted file mode 100644 index 4122f0ef5..000000000 --- a/app/javascript/components/MapStyles/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import baseStyle from './base-style'; -import cadastre from './cadastre'; -import orthoStyle from './ortho-style'; -import vectorStyle from './vector-style'; - -function rasterStyle(source) { - return { - id: source, - source, - type: 'raster', - paint: { 'raster-resampling': 'linear' } - }; -} - -export function getMapStyle(style, hasCadastres, hasMNHN) { - const mapStyle = { ...baseStyle }; - - switch (style) { - case 'ortho': - mapStyle.layers = orthoStyle; - mapStyle.id = 'ortho'; - mapStyle.name = 'Photographies aériennes'; - break; - case 'vector': - mapStyle.layers = vectorStyle; - mapStyle.id = 'vector'; - mapStyle.name = 'Carte OSM'; - break; - case 'ign': - mapStyle.layers = [rasterStyle('plan-ign')]; - mapStyle.id = 'ign'; - mapStyle.name = 'Carte IGN'; - break; - } - - if (hasCadastres) { - mapStyle.layers = mapStyle.layers.concat(cadastre); - mapStyle.id += '-cadastre'; - } - - if (hasMNHN) { - mapStyle.layers = mapStyle.layers.concat([ - rasterStyle('protectedareas-gp'), - rasterStyle('protectedareas-pn'), - rasterStyle('protectedareas-pnr'), - rasterStyle('protectedareas-sic'), - rasterStyle('protectedareas-zps') - ]); - mapStyle.id += '-mnhn'; - } - - return mapStyle; -} - -export { SwitchMapStyle } from './SwitchMapStyle'; diff --git a/app/javascript/components/shared/mapbox/Mapbox.js b/app/javascript/components/shared/mapbox/Mapbox.js new file mode 100644 index 000000000..3acc17e94 --- /dev/null +++ b/app/javascript/components/shared/mapbox/Mapbox.js @@ -0,0 +1,3 @@ +import ReactMapboxGl from 'react-mapbox-gl'; + +export default ReactMapboxGl({}); diff --git a/app/javascript/components/MapStyles/SwitchMapStyle.js b/app/javascript/components/shared/mapbox/SwitchMapStyle.js similarity index 87% rename from app/javascript/components/MapStyles/SwitchMapStyle.js rename to app/javascript/components/shared/mapbox/SwitchMapStyle.js index 074b344f5..d971219f9 100644 --- a/app/javascript/components/MapStyles/SwitchMapStyle.js +++ b/app/javascript/components/shared/mapbox/SwitchMapStyle.js @@ -1,8 +1,9 @@ import React from 'react'; -import ortho from './images/preview-ortho.png'; -import vector from './images/preview-vector.png'; import PropTypes from 'prop-types'; +import ortho from './styles/images/preview-ortho.png'; +import vector from './styles/images/preview-vector.png'; + const STYLES = { ortho: { title: 'Satellite', @@ -34,7 +35,7 @@ function getNextStyle(style, ign) { return styles[index]; } -export const SwitchMapStyle = ({ style, setStyle, ign }) => { +function SwitchMapStyle({ style, setStyle, ign }) { const nextStyle = getNextStyle(style, ign); const { title, preview, color } = (ign ? IGN_STYLES : STYLES)[nextStyle]; @@ -69,10 +70,12 @@ export const SwitchMapStyle = ({ style, setStyle, ign }) => { ); -}; +} SwitchMapStyle.propTypes = { style: PropTypes.string, setStyle: PropTypes.func, ign: PropTypes.bool }; + +export default SwitchMapStyle; diff --git a/app/javascript/components/shared/mapbox/styles/base.js b/app/javascript/components/shared/mapbox/styles/base.js new file mode 100644 index 000000000..cce2d7f40 --- /dev/null +++ b/app/javascript/components/shared/mapbox/styles/base.js @@ -0,0 +1,213 @@ +import cadastreLayers from './cadastre-layers'; + +const IGN_TOKEN = 'rc1egnbeoss72hxvd143tbyk'; + +function ignServiceURL(layer, format = 'image/png') { + const url = `https://wxs.ign.fr/${IGN_TOKEN}/geoportail/wmts`; + const query = + 'service=WMTS&request=GetTile&version=1.0.0&tilematrixset=PM&tilematrix={z}&tilecol={x}&tilerow={y}&style=normal'; + + return `${url}?${query}&layer=${layer}&format=${format}`; +} + +const OPTIONAL_LAYERS = [ + { + label: 'UNESCO', + id: 'unesco', + layers: [ + ['Aires protégées Géoparcs', 'PROTECTEDAREAS.GP'], + ['Réserves de biosphère', 'PROTECTEDAREAS.BIOS'] + ] + }, + { + label: 'Arrêtés de protection', + id: 'arretes_protection', + layers: [ + ['Arrêtés de protection de biotope', 'PROTECTEDAREAS.APB'], + ['Arrêtés de protection de géotope', 'PROTECTEDAREAS.APG'] + ] + }, + { + label: 'Conservatoire du Littoral', + id: 'conservatoire_littoral', + layers: [ + [ + 'Conservatoire du littoral : parcelles protégées', + 'PROTECTEDAREAS.MNHN.CDL.PARCELS' + ], + [ + 'Conservatoire du littoral : périmètres d’intervention', + 'PROTECTEDAREAS.MNHN.CDL.PERIMETER' + ] + ] + }, + { + label: 'Réserves nationales de chasse et de faune sauvage', + id: 'reserves_chasse_faune_sauvage', + layers: [ + [ + 'Réserves nationales de chasse et de faune sauvage', + 'PROTECTEDAREAS.RNCF' + ] + ] + }, + { + label: 'Réserves biologiques', + id: 'reserves_biologiques', + layers: [['Réserves biologiques', 'PROTECTEDAREAS.RB']] + }, + { + label: 'Réserves naturelles', + id: 'reserves_naturelles', + layers: [ + ['Réserves naturelles nationales', 'PROTECTEDAREAS.RN'], + [ + 'Périmètres de protection de réserves naturelles', + 'PROTECTEDAREAS.MNHN.RN.PERIMETER' + ], + ['Réserves naturelles de Corse', 'PROTECTEDAREAS.RNC'], + [ + 'Réserves naturelles régionales', + 'PROTECTEDSITES.MNHN.RESERVES-REGIONALES' + ] + ] + }, + { + label: 'Natura 2000', + id: 'natura_2000', + layers: [ + ['Sites Natura 2000 (Directive Habitats)', 'PROTECTEDAREAS.SIC'], + ['Sites Natura 2000 (Directive Oiseaux)', 'PROTECTEDAREAS.ZPS'] + ] + }, + { + label: 'Zones humides d’importance internationale', + id: 'zones_humides', + layers: [ + ['Zones humides d’importance internationale', 'PROTECTEDAREAS.RAMSAR'] + ] + }, + { + label: 'ZNIEFF', + id: 'znieff', + layers: [ + [ + 'Zones naturelles d’intérêt écologique faunistique et floristique de type 1 (ZNIEFF 1 mer)', + 'PROTECTEDAREAS.ZNIEFF1.SEA' + ], + [ + 'Zones naturelles d’intérêt écologique faunistique et floristique de type 1 (ZNIEFF 1)', + 'PROTECTEDAREAS.ZNIEFF1' + ], + [ + 'Zones naturelles d’intérêt écologique faunistique et floristique de type 2 (ZNIEFF 2 mer)', + 'PROTECTEDAREAS.ZNIEFF2.SEA' + ], + [ + 'Zones naturelles d’intérêt écologique faunistique et floristique de type 2 (ZNIEFF 2)', + 'PROTECTEDAREAS.ZNIEFF2' + ] + ] + }, + { + label: 'Cadastre', + id: 'cadastres', + layers: [['Cadastre', 'CADASTRE']] + } +]; + +function buildSources() { + return Object.fromEntries( + OPTIONAL_LAYERS.flatMap(({ layers }) => layers).map(([, code]) => [ + code.toLowerCase().replace(/\./g, '-'), + rasterSource([ignServiceURL(code)], 'IGN-F/Géoportail/MNHN') + ]) + ); +} + +function rasterSource(tiles, attribution) { + return { + type: 'raster', + tiles, + tileSize: 256, + attribution, + minzoom: 0, + maxzoom: 18 + }; +} + +export function buildLayers(ids) { + return OPTIONAL_LAYERS.filter(({ id }) => ids.includes(id)) + .flatMap(({ layers }) => layers) + .map(([, code]) => + code === 'CADASTRE' + ? cadastreLayers + : rasterLayer(code.toLowerCase().replace(/\./g, '-')) + ); +} + +export function rasterLayer(source) { + return { + id: source, + source, + type: 'raster', + paint: { 'raster-resampling': 'linear' } + }; +} + +export default { + version: 8, + metadat: { + 'mapbox:autocomposite': false, + 'mapbox:groups': { + 1444849242106.713: { collapsed: false, name: 'Places' }, + 1444849334699.1902: { collapsed: true, name: 'Bridges' }, + 1444849345966.4436: { collapsed: false, name: 'Roads' }, + 1444849354174.1904: { collapsed: true, name: 'Tunnels' }, + 1444849364238.8171: { collapsed: false, name: 'Buildings' }, + 1444849382550.77: { collapsed: false, name: 'Water' }, + 1444849388993.3071: { collapsed: false, name: 'Land' } + }, + 'mapbox:type': 'template', + 'openmaptiles:mapbox:owner': 'openmaptiles', + 'openmaptiles:mapbox:source:url': 'mapbox://openmaptiles.4qljc88t', + 'openmaptiles:version': '3.x', + 'maputnik:renderer': 'mbgljs' + }, + center: [0, 0], + zoom: 1, + bearing: 0, + pitch: 0, + sources: { + 'decoupage-administratif': { + type: 'vector', + url: + 'https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json' + }, + openmaptiles: { + type: 'vector', + url: 'https://openmaptiles.geo.data.gouv.fr/data/france-vector.json' + }, + 'photographies-aeriennes': { + type: 'raster', + tiles: [ + 'https://tiles.geo.api.gouv.fr/photographies-aeriennes/tiles/{z}/{x}/{y}' + ], + tileSize: 256, + attribution: 'Images aériennes © IGN', + minzoom: 0, + maxzoom: 19 + }, + cadastre: { + type: 'vector', + url: 'https://openmaptiles.geo.data.gouv.fr/data/cadastre.json' + }, + 'plan-ign': rasterSource( + [ignServiceURL('GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2')], + 'IGN-F/Géoportail' + ), + ...buildSources() + }, + sprite: 'https://openmaptiles.github.io/osm-bright-gl-style/sprite', + glyphs: 'https://openmaptiles.geo.data.gouv.fr/fonts/{fontstack}/{range}.pbf' +}; diff --git a/app/javascript/components/MapStyles/cadastre.js b/app/javascript/components/shared/mapbox/styles/cadastre-layers.js similarity index 100% rename from app/javascript/components/MapStyles/cadastre.js rename to app/javascript/components/shared/mapbox/styles/cadastre-layers.js diff --git a/app/javascript/components/MapStyles/images/preview-ortho.png b/app/javascript/components/shared/mapbox/styles/images/preview-ortho.png similarity index 100% rename from app/javascript/components/MapStyles/images/preview-ortho.png rename to app/javascript/components/shared/mapbox/styles/images/preview-ortho.png diff --git a/app/javascript/components/MapStyles/images/preview-vector.png b/app/javascript/components/shared/mapbox/styles/images/preview-vector.png similarity index 100% rename from app/javascript/components/MapStyles/images/preview-vector.png rename to app/javascript/components/shared/mapbox/styles/images/preview-vector.png diff --git a/app/javascript/components/shared/mapbox/styles/index.js b/app/javascript/components/shared/mapbox/styles/index.js new file mode 100644 index 000000000..97257b33f --- /dev/null +++ b/app/javascript/components/shared/mapbox/styles/index.js @@ -0,0 +1,29 @@ +import baseStyle, { rasterLayer, buildLayers } from './base'; +import orthoStyle from './ortho-style'; +import vectorStyle from './vector-style'; + +export function getMapStyle(style, optionalLayers) { + const mapStyle = { ...baseStyle }; + + switch (style) { + case 'ortho': + mapStyle.layers = orthoStyle; + mapStyle.id = 'ortho'; + mapStyle.name = 'Photographies aériennes'; + break; + case 'vector': + mapStyle.layers = vectorStyle; + mapStyle.id = 'vector'; + mapStyle.name = 'Carte OSM'; + break; + case 'ign': + mapStyle.layers = [rasterLayer('plan-ign')]; + mapStyle.id = 'ign'; + mapStyle.name = 'Carte IGN'; + break; + } + + mapStyle.layers = mapStyle.layers.concat(buildLayers(optionalLayers)); + + return mapStyle; +} diff --git a/app/javascript/components/MapStyles/ortho-style.js b/app/javascript/components/shared/mapbox/styles/ortho-style.js similarity index 100% rename from app/javascript/components/MapStyles/ortho-style.js rename to app/javascript/components/shared/mapbox/styles/ortho-style.js diff --git a/app/javascript/components/MapStyles/vector-style.js b/app/javascript/components/shared/mapbox/styles/vector-style.js similarity index 100% rename from app/javascript/components/MapStyles/vector-style.js rename to app/javascript/components/shared/mapbox/styles/vector-style.js diff --git a/app/javascript/components/shared/map.js b/app/javascript/components/shared/mapbox/utils.js similarity index 100% rename from app/javascript/components/shared/map.js rename to app/javascript/components/shared/mapbox/utils.js