From 8c31733837e7c011939217b136c5d95c247e0904 Mon Sep 17 00:00:00 2001 From: George Owen Date: Tue, 30 May 2023 14:03:43 -0700 Subject: [PATCH] Implement v2 styles endpoint and update layer inheritance (#182) * add support for v2 styles #174 * suggestions from code review * Apply suggestions from code review Co-authored-by: Gavin Rehkemper * add unit tests and style enum validation * add support for custom itemids * revert package-lock.json * update leaflet, esri leaflet, maplibre versions * fix basemap layer pane options #170 * change VectorBasemapLayer to inherit from VectorTileLayer #150 * clean up inheritance change #150 * fix attribution sources for v2 #174 * revert version update (goes in separate pr) * apply fixes from review meeting * remove debug statement * update unit tests * Apply suggestions from code review Co-authored-by: Gavin Rehkemper Co-authored-by: Patrick Arlt * add suggested unit test #174 --------- Co-authored-by: Gavin Rehkemper Co-authored-by: Patrick Arlt --- examples/customize-basemap-style.html | 73 +++++++++++++ examples/customize-vtl-style.html | 79 ++++++++++++++ examples/languages.html | 104 +++++++++++++++++++ spec/VectorBasemapLayerSpec.js | 144 +++++++++++++++++++++++++- src/Util.js | 36 ++++++- src/VectorBasemapLayer.js | 131 ++++++++++------------- src/VectorTileLayer.js | 74 +++++++------ 7 files changed, 526 insertions(+), 115 deletions(-) create mode 100644 examples/customize-basemap-style.html create mode 100644 examples/customize-vtl-style.html create mode 100644 examples/languages.html diff --git a/examples/customize-basemap-style.html b/examples/customize-basemap-style.html new file mode 100644 index 0000000..c2660f4 --- /dev/null +++ b/examples/customize-basemap-style.html @@ -0,0 +1,73 @@ + + + + + Esri Leaflet Vector: Customize the basemap style + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/examples/customize-vtl-style.html b/examples/customize-vtl-style.html new file mode 100644 index 0000000..7601a7d --- /dev/null +++ b/examples/customize-vtl-style.html @@ -0,0 +1,79 @@ + + + + + + Esri Leaflet: Customize a vector tile layer style + + + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/examples/languages.html b/examples/languages.html new file mode 100644 index 0000000..72d256c --- /dev/null +++ b/examples/languages.html @@ -0,0 +1,104 @@ + + + + + Esri Leaflet Tutorials: Change the basemap style + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/spec/VectorBasemapLayerSpec.js b/spec/VectorBasemapLayerSpec.js index 8e4a88b..14d1e80 100644 --- a/spec/VectorBasemapLayerSpec.js +++ b/spec/VectorBasemapLayerSpec.js @@ -2,6 +2,9 @@ const itemId = '287c07ef752246d08bb4712fd4b74438'; const apikey = '1234'; const basemapKey = 'ArcGIS:Streets'; +const basemapKeyV2 = 'arcgis/streets'; +const customBasemap = 'f04f33b9626240f084cb52f0b08758ef' +const language = 'zh_s'; describe('VectorBasemapLayer', function () { it('should have a L.esri.vectorBasemapLayer alias', function () { @@ -43,13 +46,152 @@ describe('VectorBasemapLayer', function () { }); it('should save the token as apikey from the constructor', function () { - const layer = new L.esri.Vector.VectorBasemapLayer(basemapKey, { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKey, { token: apikey }); expect(layer.options.apikey).to.equal(apikey); }); + it('should create basemap styles in the \'tilePane\' by default', function () { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKey, { + apikey: apikey + }); + const layerV2 = new L.esri.Vector.vectorBasemapLayer(basemapKeyV2, { + apikey: apikey, + version:2 + }); + + expect(layer.options.pane).to.equal('tilePane'); + expect(layerV2.options.pane).to.equal('tilePane'); + }) + + it('should add \'Labels\' styles to the \'esri-labels\' pane by default', function () { + const layer = new L.esri.Vector.vectorBasemapLayer('ArcGIS:Imagery:Labels', { + apikey: apikey + }); + + const layerV2 = new L.esri.Vector.vectorBasemapLayer('arcgis/imagery/labels', { + apikey: apikey, + version:2 + }); + + // These label styles use a different endpoint (/label instead of /labels, for some reason) + const humanGeoLayer = new L.esri.Vector.vectorBasemapLayer('ArcGIS:HumanGeography:Label',{ + apikey: apikey + }); + const humanGeoLayerV2 = new L.esri.Vector.vectorBasemapLayer('arcgis/human-geography/label',{ + apikey: apikey, + version:2 + }); + + expect(layer.options.pane).to.equal('esri-labels'); + expect(layerV2.options.pane).to.equal('esri-labels'); + expect(humanGeoLayer.options.pane).to.equal('esri-labels'); + expect(humanGeoLayerV2.options.pane).to.equal('esri-labels'); + }) + + it('should add \'Detail\' styles to the \'esri-detail\' pane by default', function () { + const layer = new L.esri.Vector.vectorBasemapLayer('ArcGIS:Terrain:Detail', { + apikey: apikey + }) + const layerV2 = new L.esri.Vector.vectorBasemapLayer('arcgis/terrain/detail', { + apikey: apikey, + version:2 + }) + + expect(layer.options.pane).to.equal('esri-detail'); + expect(layerV2.options.pane).to.equal('esri-detail'); + }) + + it('should save the service version from the constructor', function () { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKeyV2, { + apikey: apikey, + version:2 + }); + + expect(layer.options.version).to.equal(2); + }); + + it('should load a v1 basemap from a v1 style key without needing to specify a version', function () { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKey, { + apikey: apikey + }) + + expect(layer.options.version).to.equal(1); + }); + + it('should load a v2 basemap from a v2 style key without needing to specify a version', function () { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKeyV2, { + apikey: apikey + }) + + expect(layer.options.version).to.equal(2); + }); + + it('should save the language from the constructor', function () { + const layer = new L.esri.Vector.vectorBasemapLayer(basemapKeyV2, { + apikey: apikey, + version:2, + language:language + }) + + expect(layer.options.language).to.equal(language); + }); + + it('should error if a language is provided when accessing the v1 service', function () { + expect(function () { + L.esri.Vector.vectorBasemapLayer(basemapKey, { + apikey:apikey, + language:language + }); + }).to.throw('The language parameter is only supported by the basemap styles service v2. Provide a v2 style enumeration to use this option.'); + }); + + it('should not accept a v2 style enumeration when accessing the v1 service', function () { + expect(function () { + L.esri.Vector.vectorBasemapLayer(basemapKeyV2, { + apikey:apikey, + version:1 + }); + }).to.throw(basemapKeyV2 + ' is a v2 style enumeration. Set version:2 to request this style') + }) + + it('should not accept a v1 style enumeration when accessing the v2 service', function () { + expect(function () { + L.esri.Vector.vectorBasemapLayer(basemapKey, { + apikey:apikey, + version:2 + }); + }).to.throw(basemapKey + ' is a v1 style enumeration. Set version:1 to request this style') + }) + + it('should load a custom basemap style from an item ID when using the v1 service', function () { + const customLayer = L.esri.Vector.vectorBasemapLayer(customBasemap, { + apikey:apikey, + version:1 + }) + expect(customLayer._maplibreGL.options.style).to.equal(`https://basemaps-api.arcgis.com/arcgis/rest/services/styles/${customBasemap}?type=style&token=${apikey}`) + }) + + it('should load a custom basemap style from an item ID when using the v2 service', function () { + const customLayer = L.esri.Vector.vectorBasemapLayer(customBasemap, { + apikey:apikey, + version:2 + }) + expect(customLayer._maplibreGL.options.style).to.equal(`https://basemapstyles-api.arcgis.com/arcgis/rest/services/styles/v2/styles/items/${customBasemap}?token=${apikey}`) + }) + + it('should error if a language is provided when loading a custom basemap style', function () { + expect(function () { + L.esri.Vector.vectorBasemapLayer(customBasemap, { + apikey,apikey, + version:2, + language:language + }) + }).to.throw('The \'language\' parameter is not supported for custom basemap styles') + }) + describe('_getAttributionUrls', function () { it('should handle OSM keys', function () { const key = 'OSM:Standard'; diff --git a/src/Util.js b/src/Util.js index 6a85611..73818d8 100644 --- a/src/Util.js +++ b/src/Util.js @@ -5,17 +5,47 @@ import { request, Support, Util } from 'esri-leaflet'; utility to establish a URL for the basemap styles API used primarily by VectorBasemapLayer.js */ -export function getBasemapStyleUrl (key, apikey) { +export function getBasemapStyleUrl (style, apikey) { + if (style.includes('/')) { + throw new Error(style + ' is a v2 style enumeration. Set version:2 to request this style'); + } + let url = 'https://basemaps-api.arcgis.com/arcgis/rest/services/styles/' + - key + + style + '?type=style'; if (apikey) { - url = url + '&apiKey=' + apikey; + url = url + '&token=' + apikey; } return url; } +export function getBasemapStyleV2Url (style, apikey, language) { + if (style.includes(':')) { + throw new Error(style + ' is a v1 style enumeration. Set version:1 to request this style'); + } + + let url = 'https://basemapstyles-api.arcgis.com/arcgis/rest/services/styles/v2/styles/'; + if (!(style.startsWith('osm/') || style.startsWith('arcgis/')) && style.length === 32) { + // style is an itemID + url = url + 'items/' + style; + + if (language) { + throw new Error('The \'language\' parameter is not supported for custom basemap styles'); + } + } else { + url = url + style; + } + + if (apikey) { + url = url + '?token=' + apikey; + + if (language) { + url = url + '&language=' + language; + } + } + return url; +} /* utilities to communicate with custom user styles via an ITEM ID or SERVICE URL used primarily by VectorTileLayer.js diff --git a/src/VectorBasemapLayer.js b/src/VectorBasemapLayer.js index 6bdd47c..fb3e9ae 100644 --- a/src/VectorBasemapLayer.js +++ b/src/VectorBasemapLayer.js @@ -1,13 +1,8 @@ -import { Layer, setOptions } from 'leaflet'; import { Util } from 'esri-leaflet'; -import { getBasemapStyleUrl, getAttributionData } from './Util'; -import { maplibreGLJSLayer } from './MaplibreGLLayer'; - -export var VectorBasemapLayer = Layer.extend({ - options: { - key: 'ArcGIS:Streets' // default style key enum if none provided - }, +import { getBasemapStyleUrl, getAttributionData, getBasemapStyleV2Url } from './Util'; +import { VectorTileLayer } from './VectorTileLayer'; +export var VectorBasemapLayer = VectorTileLayer.extend({ /** * Populates "this.options" to be used in the rest of the module. * @@ -15,58 +10,57 @@ export var VectorBasemapLayer = Layer.extend({ * @param {object} options optional */ initialize: function (key, options) { - if (options) { - setOptions(this, options); + // Default to the v1 service endpoint + if (!options.version) { + if (key.includes('/')) options.version = 2; + else options.version = 1; } - - // support outdated casing apiKey of apikey - if (this.options.apiKey) { - this.options.apikey = this.options.apiKey; + if (!key) { + // Default style enum if none provided + key = options.version === 1 ? 'ArcGIS:Streets' : 'arcgis/streets'; } - - // if token is passed in, use it as an apiKey - if (this.options.token) { - this.options.apikey = this.options.token; + // If no API Key or token is provided (support outdated casing apiKey of apikey) + if (!(options.apikey || options.apiKey || options.token)) { + throw new Error('An API Key or token is required for vectorBasemapLayer.'); } - - // If no API Key or token is required: - if (!(this.options.apikey || this.options.token)) { - throw new Error('API Key or token is required for vectorBasemapLayer.'); + // Validate language param + if (options.language) { + if (options.version !== 2) { + throw new Error('The language parameter is only supported by the basemap styles service v2. Provide a v2 style enumeration to use this option.'); + } } - - // set key onto "this.options" for use elsewhere in the module. - if (key) { - this.options.key = key; + // Determine layer order + if (!options.pane) { + if (key.includes(':Label') || key.includes('/label')) { + options.pane = 'esri-labels'; + } else if (key.includes(':Detail') || key.includes('/detail')) { + options.pane = 'esri-detail'; + } else { + // Create layer in the tilePane by default + options.pane = 'tilePane'; + } } - // this.options has been set, continue on to create the layer: - this._createLayer(); + // Options has been configured, continue on to create the layer: + VectorTileLayer.prototype.initialize.call(this, key, options); }, /** - * Creates the maplibreGLJSLayer given using "this.options" + * Creates the maplibreGLJSLayer using "this.options" */ _createLayer: function () { - const styleUrl = getBasemapStyleUrl(this.options.key, this.options.apikey); - - this._maplibreGL = maplibreGLJSLayer({ - style: styleUrl, - pane: this.options.pane, - opacity: this.options.opacity - }); - - this._ready = true; - this.fire('ready', {}, true); - - this._maplibreGL.on('styleLoaded', function (res) { - this._setupAttribution(); - }.bind(this)); + let styleUrl; + if (this.options.version && this.options.version === 2) { + styleUrl = getBasemapStyleV2Url(this.options.key, this.options.apikey, this.options.language); + } else { + styleUrl = getBasemapStyleUrl(this.options.key, this.options.apikey); + } + this._createMaplibreLayer(styleUrl); }, _setupAttribution: function () { - const map = this._map; // Set attribution - Util.setEsriAttribution(map); + Util.setEsriAttribution(this._map); if (this.options.key.length === 32) { // this is an itemId @@ -79,7 +73,7 @@ export var VectorBasemapLayer = Layer.extend({ } }); - map.attributionControl.addAttribution('' + allAttributions.join(', ') + ''); + this._map.attributionControl.addAttribution('' + allAttributions.join(', ') + ''); } else { // this is an enum if (!this.options.attributionUrls) { @@ -94,10 +88,10 @@ export var VectorBasemapLayer = Layer.extend({ index++ ) { const attributionUrl = this.options.attributionUrls[index]; - getAttributionData(attributionUrl, map); + getAttributionData(attributionUrl, this._map); } - map.attributionControl.addAttribution( + this._map.attributionControl.addAttribution( '' ); } @@ -111,9 +105,9 @@ export var VectorBasemapLayer = Layer.extend({ * @param {string} key */ _getAttributionUrls: function (key) { - if (key.indexOf('OSM:') === 0) { + if (key.indexOf('OSM:') === 0 || (key.indexOf('osm/') === 0)) { return ['https://static.arcgis.com/attribution/Vector/OpenStreetMap_v2']; - } else if (key.indexOf('ArcGIS:Imagery') === 0) { + } else if (key.indexOf('ArcGIS:Imagery') === 0 || key.indexOf('arcgis/imagery') === 0) { return [ 'https://static.arcgis.com/attribution/World_Imagery', 'https://static.arcgis.com/attribution/Vector/World_Basemap_v2' @@ -124,38 +118,18 @@ export var VectorBasemapLayer = Layer.extend({ return ['https://static.arcgis.com/attribution/Vector/World_Basemap_v2']; }, - onAdd: function (map) { - this._map = map; - - this._initPane(); - - if (this._ready) { - this._asyncAdd(); - } else { - this.once( - 'ready', - function () { - this._asyncAdd(); - }, - this - ); - } - }, - _initPane: function () { - // if the layer is a "label" layer, should use the "esri-label" pane. - if (!this.options.pane) { - if (this.options.key.indexOf(':Labels') > -1) { - this.options.pane = 'esri-labels'; - } else { - this.options.pane = 'tilePane'; - } - } - if (!this._map.getPane(this.options.pane)) { const pane = this._map.createPane(this.options.pane); pane.style.pointerEvents = 'none'; - pane.style.zIndex = this.options.pane === 'esri-labels' ? 550 : 500; + + let zIndex = 500; + if (this.options.pane === 'esri-detail') { + zIndex = 250; + } else if (this.options.pane === 'esri-labels') { + zIndex = 300; + } + pane.style.zIndex = zIndex; } }, @@ -176,6 +150,7 @@ export var VectorBasemapLayer = Layer.extend({ _asyncAdd: function () { const map = this._map; + this._initPane(); map.on('moveend', Util._updateMapAttribution); this._maplibreGL.addTo(map, this); } diff --git a/src/VectorTileLayer.js b/src/VectorTileLayer.js index d5c2a4f..fe98b0b 100644 --- a/src/VectorTileLayer.js +++ b/src/VectorTileLayer.js @@ -4,10 +4,6 @@ import { maplibreGLJSLayer } from './MaplibreGLLayer'; export var VectorTileLayer = Layer.extend({ options: { - // if pane is not provided, default to LeafletJS's overlayPane - // https://leafletjs.com/reference.html#map-pane - pane: 'overlayPane', - // if portalUrl is not provided, default to ArcGIS Online portalUrl: 'https://www.arcgis.com' }, @@ -28,10 +24,12 @@ export var VectorTileLayer = Layer.extend({ this.options.apikey = this.options.apiKey; } - // if apiKey is passed in, use it as a token - // (opposite from VectorBasemapLayer.js) + // if apiKey is passed in, propagate to token + // if token is passed in, propagate to apiKey if (this.options.apikey) { this.options.token = this.options.apikey; + } else if (this.options.token) { + this.options.apikey = this.options.token; } // if no key passed in @@ -76,37 +74,47 @@ export var VectorTileLayer = Layer.extend({ // once style object is loaded it must be transformed to be compliant with maplibreGLJSLayer style = formatStyle(style, styleUrl, service, this.options.token); - // if a custom attribution was not provided in the options, - // then attempt to rely on the attribution of the last source in the style object - // and add it to the map's attribution control - // (otherwise it would have already been added by leaflet to the attribution control) - if (!this.getAttribution()) { - const sourcesKeys = Object.keys(style.sources); - this.options.attribution = - style.sources[sourcesKeys[sourcesKeys.length - 1]].attribution; - if (this._map && this._map.attributionControl) { - // NOTE: if attribution is an empty string (or otherwise falsy) at this point it would not appear in the attribution control - this._map.attributionControl.addAttribution(this.getAttribution()); - } - } - - // additionally modify the style object with the user's optional style override function - if (this.options.style && typeof this.options.style === 'function') { - style = this.options.style(style); - } - - this._maplibreGL = maplibreGLJSLayer({ - style: style, - pane: this.options.pane, - opacity: this.options.opacity - }); - - this._ready = true; - this.fire('ready', {}, true); + this._createMaplibreLayer(style); }.bind(this) ); }, + _setupAttribution: function () { + // if a custom attribution was not provided in the options, + // then attempt to rely on the attribution of the last source in the style object + // and add it to the map's attribution control + // (otherwise it would have already been added by leaflet to the attribution control) + if (!this.getAttribution()) { + const sources = this._maplibreGL.getMaplibreMap().style.stylesheet.sources; + const sourcesKeys = Object.keys(sources); + this.options.attribution = + sources[sourcesKeys[sourcesKeys.length - 1]].attribution; + if (this._map && this._map.attributionControl) { + // NOTE: if attribution is an empty string (or otherwise falsy) at this point it would not appear in the attribution control + this._map.attributionControl.addAttribution(this.getAttribution()); + } + } + }, + + _createMaplibreLayer: function (style) { + this._maplibreGL = maplibreGLJSLayer({ + style: style, + pane: this.options.pane, + opacity: this.options.opacity + }); + + this._ready = true; + this.fire('ready', {}, true); + + this._maplibreGL.on('styleLoaded', function () { + this._setupAttribution(); + // additionally modify the style object with the user's optional style override function + if (this.options.style && typeof this.options.style === 'function') { + this._maplibreGL._glMap.setStyle(this.options.style(this._maplibreGL._glMap.getStyle())); + } + }.bind(this)); + }, + onAdd: function (map) { this._map = map;