UNPKG

@mapbox/mapbox-gl-style-spec

Version:

a specification for mapbox gl styles

617 lines (524 loc) 22.2 kB
import isEqual from './util/deep_equal'; import type {StyleSpecification, ImportSpecification, SourceSpecification, LayerSpecification, IconsetsSpecification} from './types'; type Sources = { [key: string]: SourceSpecification; }; type Command = { command: string; args: unknown[]; }; export const operations = { /* * { command: 'setStyle', args: [stylesheet] } */ setStyle: 'setStyle', /* * { command: 'addLayer', args: [layer, 'beforeLayerId'] } */ addLayer: 'addLayer', /* * { command: 'removeLayer', args: ['layerId'] } */ removeLayer: 'removeLayer', /* * { command: 'setPaintProperty', args: ['layerId', 'prop', value] } */ setPaintProperty: 'setPaintProperty', /* * { command: 'setLayoutProperty', args: ['layerId', 'prop', value] } */ setLayoutProperty: 'setLayoutProperty', /* * { command: 'setSlot', args: ['layerId', slot] } */ setSlot: 'setSlot', /* * { command: 'setFilter', args: ['layerId', filter] } */ setFilter: 'setFilter', /* * { command: 'addSource', args: ['sourceId', source] } */ addSource: 'addSource', /* * { command: 'removeSource', args: ['sourceId'] } */ removeSource: 'removeSource', /* * { command: 'setGeoJSONSourceData', args: ['sourceId', data] } */ setGeoJSONSourceData: 'setGeoJSONSourceData', /* * { command: 'setLayerZoomRange', args: ['layerId', 0, 22] } */ setLayerZoomRange: 'setLayerZoomRange', /* * { command: 'setLayerProperty', args: ['layerId', 'prop', value] } */ setLayerProperty: 'setLayerProperty', /* * { command: 'setCenter', args: [[lon, lat]] } */ setCenter: 'setCenter', /* * { command: 'setZoom', args: [zoom] } */ setZoom: 'setZoom', /* * { command: 'setBearing', args: [bearing] } */ setBearing: 'setBearing', /* * { command: 'setPitch', args: [pitch] } */ setPitch: 'setPitch', /* * { command: 'setSprite', args: ['spriteUrl'] } */ setSprite: 'setSprite', /* * { command: 'setGlyphs', args: ['glyphsUrl'] } */ setGlyphs: 'setGlyphs', /* * { command: 'setTransition', args: [transition] } */ setTransition: 'setTransition', /* * { command: 'setLighting', args: [lightProperties] } */ setLight: 'setLight', /* * { command: 'setTerrain', args: [terrainProperties] } */ setTerrain: 'setTerrain', /* * { command: 'setFog', args: [fogProperties] } */ setFog: 'setFog', /* * { command: 'setSnow', args: [snowProperties] } */ setSnow: 'setSnow', /* * { command: 'setRain', args: [rainProperties] } */ setRain: 'setRain', /* * { command: 'setCamera', args: [cameraProperties] } */ setCamera: 'setCamera', /* * { command: 'setLights', args: [{light-3d},...] } */ setLights: 'setLights', /* * { command: 'setProjection', args: [projectionProperties] } */ setProjection: 'setProjection', /* * { command: 'addImport', args: [import] } */ addImport: 'addImport', /* * { command: 'removeImport', args: [importId] } */ removeImport: 'removeImport', /** * { command: 'updateImport', args: [importId, importSpecification | styleUrl] } */ updateImport: 'updateImport', /* * { command: 'addIconset', args: [iconsetId, IconsetSpecification] } */ addIconset: 'addIconset', /* * { command: 'removeIconset', args: [iconsetId] } */ removeIconset: 'removeIconset' } as const; function addSource(sourceId: string, after: Sources, commands: Array<Command>) { commands.push({command: operations.addSource, args: [sourceId, after[sourceId]]}); } function removeSource(sourceId: string, commands: Array<Command>, sourcesRemoved: { [key: string]: true; }) { commands.push({command: operations.removeSource, args: [sourceId]}); sourcesRemoved[sourceId] = true; } function updateSource(sourceId: string, after: Sources, commands: Array<Command>, sourcesRemoved: { [key: string]: true; }) { removeSource(sourceId, commands, sourcesRemoved); addSource(sourceId, after, commands); } function canUpdateGeoJSON(before: Sources, after: Sources, sourceId: string) { let prop; for (prop in before[sourceId]) { if (!before[sourceId].hasOwnProperty(prop)) continue; if (prop !== 'data' && !isEqual(before[sourceId][prop], after[sourceId][prop])) { return false; } } for (prop in after[sourceId]) { if (!after[sourceId].hasOwnProperty(prop)) continue; if (prop !== 'data' && !isEqual(before[sourceId][prop], after[sourceId][prop])) { return false; } } return true; } function diffSources(before: Sources, after: Sources, commands: Array<Command>, sourcesRemoved: {[key: string]: true}) { before = before || {}; after = after || {}; let sourceId; // look for sources to remove for (sourceId in before) { if (!before.hasOwnProperty(sourceId)) continue; if (!after.hasOwnProperty(sourceId)) { removeSource(sourceId, commands, sourcesRemoved); } } // look for sources to add/update for (sourceId in after) { if (!after.hasOwnProperty(sourceId)) continue; const source = after[sourceId]; if (!before.hasOwnProperty(sourceId)) { addSource(sourceId, after, commands); } else if (!isEqual(before[sourceId], source)) { if (before[sourceId].type === 'geojson' && source.type === 'geojson' && canUpdateGeoJSON(before, after, sourceId)) { commands.push({command: operations.setGeoJSONSourceData, args: [sourceId, source.data]}); } else { // no update command, must remove then add updateSource(sourceId, after, commands, sourcesRemoved); } } } } function diffLayerPropertyChanges(before: LayerSpecification['layout'], after: LayerSpecification['layout'], commands: Array<Command>, layerId: string, klass: string | null | undefined, command: string): void; function diffLayerPropertyChanges(before: LayerSpecification['paint'], after: LayerSpecification['paint'], commands: Array<Command>, layerId: string, klass: string | null | undefined, command: string): void; function diffLayerPropertyChanges( before: LayerSpecification['paint'] | LayerSpecification['layout'], after: LayerSpecification['paint'] | LayerSpecification['layout'], commands: Command[], layerId: string, klass: string | null | undefined, command: string ) { before = before || {}; after = after || {}; let prop; for (prop in before) { if (!before.hasOwnProperty(prop)) continue; if (!isEqual(before[prop], after[prop])) { commands.push({command, args: [layerId, prop, after[prop], klass]}); } } for (prop in after) { if (!after.hasOwnProperty(prop) || before.hasOwnProperty(prop)) continue; if (!isEqual(before[prop], after[prop])) { commands.push({command, args: [layerId, prop, after[prop], klass]}); } } } function pluckId<T extends {id: string}>(item: T): string { return item.id; } function indexById<T extends {id: string}>(group: {[key: string]: T}, item: T): {[id: string]: T} { group[item.id] = item; return group; } function diffLayers(before: Array<LayerSpecification>, after: Array<LayerSpecification>, commands: Array<Command>) { before = before || []; after = after || []; // order of layers by id const beforeOrder = before.map(pluckId); const afterOrder = after.map(pluckId); // index of layer by id const beforeIndex = before.reduce(indexById, {}); const afterIndex = after.reduce(indexById, {}); // track order of layers as if they have been mutated const tracker = beforeOrder.slice(); // layers that have been added do not need to be diffed const clean = Object.create(null); let i, d, layerId, beforeLayer: LayerSpecification, afterLayer: LayerSpecification, insertBeforeLayerId, prop; // remove layers for (i = 0, d = 0; i < beforeOrder.length; i++) { layerId = beforeOrder[i]; if (!afterIndex.hasOwnProperty(layerId)) { commands.push({command: operations.removeLayer, args: [layerId]}); tracker.splice(tracker.indexOf(layerId, d), 1); } else { // limit where in tracker we need to look for a match d++; } } // add/reorder layers for (i = 0, d = 0; i < afterOrder.length; i++) { // work backwards as insert is before an existing layer layerId = afterOrder[afterOrder.length - 1 - i]; if (tracker[tracker.length - 1 - i] === layerId) continue; if (beforeIndex.hasOwnProperty(layerId)) { // remove the layer before we insert at the correct position commands.push({command: operations.removeLayer, args: [layerId]}); tracker.splice(tracker.lastIndexOf(layerId, tracker.length - d), 1); } else { // limit where in tracker we need to look for a match d++; } // add layer at correct position insertBeforeLayerId = tracker[tracker.length - i]; commands.push({command: operations.addLayer, args: [afterIndex[layerId], insertBeforeLayerId]}); tracker.splice(tracker.length - i, 0, layerId); clean[layerId] = true; } // update layers for (i = 0; i < afterOrder.length; i++) { layerId = afterOrder[i]; beforeLayer = beforeIndex[layerId]; afterLayer = afterIndex[layerId]; // no need to update if previously added (new or moved) if (clean[layerId] || isEqual(beforeLayer, afterLayer)) continue; // If source, source-layer, or type have changes, then remove the layer // and add it back 'from scratch'. if (!isEqual(beforeLayer.source, afterLayer.source) || !isEqual(beforeLayer['source-layer'], afterLayer['source-layer']) || !isEqual(beforeLayer.type, afterLayer.type)) { commands.push({command: operations.removeLayer, args: [layerId]}); // we add the layer back at the same position it was already in, so // there's no need to update the `tracker` insertBeforeLayerId = tracker[tracker.lastIndexOf(layerId) + 1]; commands.push({command: operations.addLayer, args: [afterLayer, insertBeforeLayerId]}); continue; } // layout, paint, filter, minzoom, maxzoom diffLayerPropertyChanges(beforeLayer.layout, afterLayer.layout, commands, layerId, null, operations.setLayoutProperty); diffLayerPropertyChanges(beforeLayer.paint, afterLayer.paint, commands, layerId, null, operations.setPaintProperty); if (!isEqual(beforeLayer.slot, afterLayer.slot)) { commands.push({command: operations.setSlot, args: [layerId, afterLayer.slot]}); } if (!isEqual(beforeLayer.filter, afterLayer.filter)) { commands.push({command: operations.setFilter, args: [layerId, afterLayer.filter]}); } if (!isEqual(beforeLayer.minzoom, afterLayer.minzoom) || !isEqual(beforeLayer.maxzoom, afterLayer.maxzoom)) { commands.push({command: operations.setLayerZoomRange, args: [layerId, afterLayer.minzoom, afterLayer.maxzoom]}); } // handle all other layer props, including paint.* for (prop in beforeLayer) { if (!beforeLayer.hasOwnProperty(prop)) continue; if (prop === 'layout' || prop === 'paint' || prop === 'filter' || prop === 'metadata' || prop === 'minzoom' || prop === 'maxzoom' || prop === 'slot') continue; if (prop.indexOf('paint.') === 0) { diffLayerPropertyChanges(beforeLayer[prop], afterLayer[prop], commands, layerId, prop.slice(6), operations.setPaintProperty); } else if (!isEqual(beforeLayer[prop], afterLayer[prop])) { commands.push({command: operations.setLayerProperty, args: [layerId, prop, afterLayer[prop]]}); } } for (prop in afterLayer) { if (!afterLayer.hasOwnProperty(prop) || beforeLayer.hasOwnProperty(prop)) continue; if (prop === 'layout' || prop === 'paint' || prop === 'filter' || prop === 'metadata' || prop === 'minzoom' || prop === 'maxzoom' || prop === 'slot') continue; if (prop.indexOf('paint.') === 0) { diffLayerPropertyChanges(beforeLayer[prop], afterLayer[prop], commands, layerId, prop.slice(6), operations.setPaintProperty); } else if (!isEqual(beforeLayer[prop], afterLayer[prop])) { commands.push({command: operations.setLayerProperty, args: [layerId, prop, afterLayer[prop]]}); } } } } export function diffImports(before: Array<ImportSpecification> | null | undefined = [], after: Array<ImportSpecification> | null | undefined = [], commands: Array<Command>) { before = before || []; after = after || []; // order imports by id const beforeOrder = before.map(pluckId); const afterOrder = after.map(pluckId); // index imports by id const beforeIndex = before.reduce(indexById, {}); const afterIndex = after.reduce(indexById, {}); // track order of imports as if they have been mutated const tracker = beforeOrder.slice(); let i, d, importId, insertBefore; // remove imports for (i = 0, d = 0; i < beforeOrder.length; i++) { importId = beforeOrder[i]; if (!afterIndex.hasOwnProperty(importId)) { commands.push({command: operations.removeImport, args: [importId]}); tracker.splice(tracker.indexOf(importId, d), 1); } else { // limit where in tracker we need to look for a match d++; } } // add/reorder imports for (i = 0, d = 0; i < afterOrder.length; i++) { // work backwards as insert is before an existing import importId = afterOrder[afterOrder.length - 1 - i]; if (tracker[tracker.length - 1 - i] === importId) continue; if (beforeIndex.hasOwnProperty(importId)) { // remove the import before we insert at the correct position commands.push({command: operations.removeImport, args: [importId]}); tracker.splice(tracker.lastIndexOf(importId, tracker.length - d), 1); } else { // limit where in tracker we need to look for a match d++; } // add import at correct position insertBefore = tracker[tracker.length - i]; commands.push({command: operations.addImport, args: [afterIndex[importId], insertBefore]}); tracker.splice(tracker.length - i, 0, importId); } // update imports for (const afterImport of after) { const beforeImport = beforeIndex[afterImport.id]; if (!beforeImport) continue; delete beforeImport.data; if (isEqual(beforeImport, afterImport)) continue; commands.push({command: operations.updateImport, args: [afterImport.id, afterImport]}); } } function diffIconsets(before: IconsetsSpecification, after: IconsetsSpecification, commands: Array<Command>) { before = before || {}; after = after || {}; let iconsetId; // look for iconsets to remove for (iconsetId in before) { if (!before.hasOwnProperty(iconsetId)) continue; if (!after.hasOwnProperty(iconsetId)) { commands.push({command: operations.removeIconset, args: [iconsetId]}); } } // look for iconsets to add/update for (iconsetId in after) { if (!after.hasOwnProperty(iconsetId)) continue; const iconset = after[iconsetId]; if (!before.hasOwnProperty(iconsetId)) { commands.push({command: operations.addIconset, args: [iconsetId, iconset]}); } else if (!isEqual(before[iconsetId], iconset)) { commands.push({command: operations.removeIconset, args: [iconsetId]}); commands.push({command: operations.addIconset, args: [iconsetId, iconset]}); } } } /** * Diff two stylesheet * * Creates semanticly aware diffs that can easily be applied at runtime. * Operations produced by the diff closely resemble the mapbox-gl-js API. Any * error creating the diff will fall back to the 'setStyle' operation. * * Example diff: * [ * { command: 'setConstant', args: ['@water', '#0000FF'] }, * { command: 'setPaintProperty', args: ['background', 'background-color', 'black'] } * ] * * @private * @param {*} [before] stylesheet to compare from * @param {*} after stylesheet to compare to * @returns Array list of changes */ export default function diffStyles(before: StyleSpecification, after: StyleSpecification): Array<Command> { if (!before) return [{command: operations.setStyle, args: [after]}]; let commands: Array<Command> = []; try { // Handle changes to top-level properties if (!isEqual(before.version, after.version)) { return [{command: operations.setStyle, args: [after]}]; } if (!isEqual(before.center, after.center)) { commands.push({command: operations.setCenter, args: [after.center]}); } if (!isEqual(before.zoom, after.zoom)) { commands.push({command: operations.setZoom, args: [after.zoom]}); } if (!isEqual(before.bearing, after.bearing)) { commands.push({command: operations.setBearing, args: [after.bearing]}); } if (!isEqual(before.pitch, after.pitch)) { commands.push({command: operations.setPitch, args: [after.pitch]}); } if (!isEqual(before.sprite, after.sprite)) { commands.push({command: operations.setSprite, args: [after.sprite]}); } if (!isEqual(before.glyphs, after.glyphs)) { commands.push({command: operations.setGlyphs, args: [after.glyphs]}); } // Handle changes to `imports` before other mergable top-level properties if (!isEqual(before.imports, after.imports)) { diffImports(before.imports, after.imports, commands); } if (!isEqual(before.transition, after.transition)) { commands.push({command: operations.setTransition, args: [after.transition]}); } if (!isEqual(before.light, after.light)) { commands.push({command: operations.setLight, args: [after.light]}); } if (!isEqual(before.fog, after.fog)) { commands.push({command: operations.setFog, args: [after.fog]}); } if (!isEqual(before.snow, after.snow)) { commands.push({command: operations.setSnow, args: [after.snow]}); } if (!isEqual(before.rain, after.rain)) { commands.push({command: operations.setRain, args: [after.rain]}); } if (!isEqual(before.projection, after.projection)) { commands.push({command: operations.setProjection, args: [after.projection]}); } if (!isEqual(before.lights, after.lights)) { commands.push({command: operations.setLights, args: [after.lights]}); } if (!isEqual(before.camera, after.camera)) { commands.push({command: operations.setCamera, args: [after.camera]}); } if (!isEqual(before.iconsets, after.iconsets)) { diffIconsets(before.iconsets, after.iconsets, commands); } if (!isEqual(before["color-theme"], after["color-theme"])) { // Update this to setColorTheme after // https://mapbox.atlassian.net/browse/GLJS-842 is implemented return [{command: operations.setStyle, args: [after]}]; } // Handle changes to `sources` // If a source is to be removed, we also--before the removeSource // command--need to remove all the style layers that depend on it. const sourcesRemoved: Record<string, true> = {}; // First collect the {add,remove}Source commands const removeOrAddSourceCommands = []; diffSources(before.sources, after.sources, removeOrAddSourceCommands, sourcesRemoved); // Push a removeLayer command for each style layer that depends on a // source that's being removed. // Also, exclude any such layers them from the input to `diffLayers` // below, so that diffLayers produces the appropriate `addLayers` // command const beforeLayers = []; if (before.layers) { before.layers.forEach((layer) => { if (layer.source && sourcesRemoved[layer.source]) { commands.push({command: operations.removeLayer, args: [layer.id]}); } else { beforeLayers.push(layer); } }); } // Remove the terrain if the source for that terrain is being removed let beforeTerrain = before.terrain; if (beforeTerrain) { if (sourcesRemoved[beforeTerrain.source]) { commands.push({command: operations.setTerrain, args: [undefined]}); beforeTerrain = undefined; } } commands = commands.concat(removeOrAddSourceCommands); // Even though terrain is a top-level property // Its like a layer in the sense that it depends on a source being present. if (!isEqual(beforeTerrain, after.terrain)) { commands.push({command: operations.setTerrain, args: [after.terrain]}); } // Handle changes to `layers` diffLayers(beforeLayers, after.layers, commands); } catch (e) { // fall back to setStyle console.warn('Unable to compute style diff:', e); commands = [{command: operations.setStyle, args: [after]}]; } return commands; }