import { GeoJSONSourceSpecification, LayerSpecification, LightSpecification, ProjectionSpecification, SkySpecification, SourceSpecification, SpriteSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification, StateSpecification } from './types.g'; import {deepEqual} from './util/deep_equal'; /** * Operations that can be performed by the diff. * Below are the operations and their arguments, the arguments should be aligned with the style methods in maplibre-gl-js. */ export type DiffOperationsMap = { setStyle: [StyleSpecification]; addLayer: [LayerSpecification, string | null]; removeLayer: [string]; setPaintProperty: [string, string, unknown, string | null]; setLayoutProperty: [string, string, unknown, string | null]; setFilter: [string, unknown]; addSource: [string, SourceSpecification]; removeSource: [string]; setGeoJSONSourceData: [string, unknown]; setLayerZoomRange: [string, number, number]; setLayerProperty: [string, string, unknown]; setCenter: [number[]]; setCenterAltitude: [number]; setZoom: [number]; setBearing: [number]; setPitch: [number]; setRoll: [number]; setSprite: [SpriteSpecification]; setGlyphs: [string]; setTransition: [TransitionSpecification]; setLight: [LightSpecification]; setTerrain: [TerrainSpecification]; setSky: [SkySpecification]; setProjection: [ProjectionSpecification]; setGlobalState: [StateSpecification]; }; export type DiffOperations = keyof DiffOperationsMap; export type DiffCommand = { command: T; args: DiffOperationsMap[T]; }; /** * The main reason for this method is to allow type check when adding a command to the array. * @param commands - The commands array to add to * @param command - The command to add */ function addCommand( commands: DiffCommand[], command: DiffCommand ) { commands.push(command); } function addSource( sourceId: string, after: {[key: string]: SourceSpecification}, commands: DiffCommand[] ) { addCommand(commands, {command: 'addSource', args: [sourceId, after[sourceId]]}); } function removeSource( sourceId: string, commands: DiffCommand[], sourcesRemoved: {[key: string]: boolean} ) { addCommand(commands, {command: 'removeSource', args: [sourceId]}); sourcesRemoved[sourceId] = true; } function updateSource( sourceId: string, after: {[key: string]: SourceSpecification}, commands: DiffCommand[], sourcesRemoved: {[key: string]: boolean} ) { removeSource(sourceId, commands, sourcesRemoved); addSource(sourceId, after, commands); } function canUpdateGeoJSON( before: {[key: string]: SourceSpecification}, after: {[key: string]: SourceSpecification}, sourceId: string ) { let prop; for (prop in before[sourceId]) { if (!Object.prototype.hasOwnProperty.call(before[sourceId], prop)) continue; if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) { return false; } } for (prop in after[sourceId]) { if (!Object.prototype.hasOwnProperty.call(after[sourceId], prop)) continue; if (prop !== 'data' && !deepEqual(before[sourceId][prop], after[sourceId][prop])) { return false; } } return true; } function diffSources( before: {[key: string]: SourceSpecification}, after: {[key: string]: SourceSpecification}, commands: DiffCommand[], sourcesRemoved: {[key: string]: boolean} ) { before = before || ({} as {[key: string]: SourceSpecification}); after = after || ({} as {[key: string]: SourceSpecification}); let sourceId: string; // look for sources to remove for (sourceId in before) { if (!Object.prototype.hasOwnProperty.call(before, sourceId)) continue; if (!Object.prototype.hasOwnProperty.call(after, sourceId)) { removeSource(sourceId, commands, sourcesRemoved); } } // look for sources to add/update for (sourceId in after) { if (!Object.prototype.hasOwnProperty.call(after, sourceId)) continue; if (!Object.prototype.hasOwnProperty.call(before, sourceId)) { addSource(sourceId, after, commands); } else if (!deepEqual(before[sourceId], after[sourceId])) { if ( before[sourceId].type === 'geojson' && after[sourceId].type === 'geojson' && canUpdateGeoJSON(before, after, sourceId) ) { addCommand(commands, { command: 'setGeoJSONSourceData', args: [sourceId, (after[sourceId] as GeoJSONSourceSpecification).data] }); } else { // no update command, must remove then add updateSource(sourceId, after, commands, sourcesRemoved); } } } } function diffLayerPropertyChanges( before: LayerSpecification['layout'] | LayerSpecification['paint'], after: LayerSpecification['layout'] | LayerSpecification['paint'], commands: DiffCommand[], layerId: string, klass: string | null, command: 'setPaintProperty' | 'setLayoutProperty' ) { before = before || ({} as LayerSpecification['layout'] | LayerSpecification['paint']); after = after || ({} as LayerSpecification['layout'] | LayerSpecification['paint']); for (const prop in before) { if (!Object.prototype.hasOwnProperty.call(before, prop)) continue; if (!deepEqual(before[prop], after[prop])) { commands.push({command, args: [layerId, prop, after[prop], klass]}); } } for (const prop in after) { if ( !Object.prototype.hasOwnProperty.call(after, prop) || Object.prototype.hasOwnProperty.call(before, prop) ) continue; if (!deepEqual(before[prop], after[prop])) { commands.push({command, args: [layerId, prop, after[prop], klass]}); } } } function pluckId(layer: LayerSpecification) { return layer.id; } function indexById(group: {[key: string]: LayerSpecification}, layer: LayerSpecification) { group[layer.id] = layer; return group; } function diffLayers( before: LayerSpecification[], after: LayerSpecification[], commands: DiffCommand[] ) { 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 layerId: string; let beforeLayer: LayerSpecification & {source?: string; filter?: unknown}; let afterLayer: LayerSpecification & {source?: string; filter?: unknown}; let insertBeforeLayerId: string; let prop: string; // remove layers for (let i = 0, d = 0; i < beforeOrder.length; i++) { layerId = beforeOrder[i]; if (!Object.prototype.hasOwnProperty.call(afterIndex, layerId)) { addCommand(commands, {command: '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 (let 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 (Object.prototype.hasOwnProperty.call(beforeIndex, layerId)) { // remove the layer before we insert at the correct position addCommand(commands, {command: '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]; addCommand(commands, { command: 'addLayer', args: [afterIndex[layerId], insertBeforeLayerId] }); tracker.splice(tracker.length - i, 0, layerId); clean[layerId] = true; } // update layers for (let 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] || deepEqual(beforeLayer, afterLayer)) continue; // If source, source-layer, or type have changes, then remove the layer // and add it back 'from scratch'. if ( !deepEqual(beforeLayer.source, afterLayer.source) || !deepEqual(beforeLayer['source-layer'], afterLayer['source-layer']) || !deepEqual(beforeLayer.type, afterLayer.type) ) { addCommand(commands, {command: '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]; addCommand(commands, {command: 'addLayer', args: [afterLayer, insertBeforeLayerId]}); continue; } // layout, paint, filter, minzoom, maxzoom diffLayerPropertyChanges( beforeLayer.layout, afterLayer.layout, commands, layerId, null, 'setLayoutProperty' ); diffLayerPropertyChanges( beforeLayer.paint, afterLayer.paint, commands, layerId, null, 'setPaintProperty' ); if (!deepEqual(beforeLayer.filter, afterLayer.filter)) { addCommand(commands, {command: 'setFilter', args: [layerId, afterLayer.filter]}); } if ( !deepEqual(beforeLayer.minzoom, afterLayer.minzoom) || !deepEqual(beforeLayer.maxzoom, afterLayer.maxzoom) ) { addCommand(commands, { command: 'setLayerZoomRange', args: [layerId, afterLayer.minzoom, afterLayer.maxzoom] }); } // handle all other layer props, including paint.* for (prop in beforeLayer) { if (!Object.prototype.hasOwnProperty.call(beforeLayer, prop)) continue; if ( prop === 'layout' || prop === 'paint' || prop === 'filter' || prop === 'metadata' || prop === 'minzoom' || prop === 'maxzoom' ) continue; if (prop.indexOf('paint.') === 0) { diffLayerPropertyChanges( beforeLayer[prop], afterLayer[prop], commands, layerId, prop.slice(6), 'setPaintProperty' ); } else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) { addCommand(commands, { command: 'setLayerProperty', args: [layerId, prop, afterLayer[prop]] }); } } for (prop in afterLayer) { if ( !Object.prototype.hasOwnProperty.call(afterLayer, prop) || Object.prototype.hasOwnProperty.call(beforeLayer, prop) ) continue; if ( prop === 'layout' || prop === 'paint' || prop === 'filter' || prop === 'metadata' || prop === 'minzoom' || prop === 'maxzoom' ) continue; if (prop.indexOf('paint.') === 0) { diffLayerPropertyChanges( beforeLayer[prop], afterLayer[prop], commands, layerId, prop.slice(6), 'setPaintProperty' ); } else if (!deepEqual(beforeLayer[prop], afterLayer[prop])) { addCommand(commands, { command: 'setLayerProperty', args: [layerId, prop, afterLayer[prop]] }); } } } } /** * Diff two stylesheet * * Creates semanticly aware diffs that can easily be applied at runtime. * Operations produced by the diff closely resemble the maplibre-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 function diff( before: StyleSpecification, after: StyleSpecification ): DiffCommand[] { if (!before) return [{command: 'setStyle', args: [after]}]; let commands: DiffCommand[] = []; try { // Handle changes to top-level properties if (!deepEqual(before.version, after.version)) { return [{command: 'setStyle', args: [after]}]; } if (!deepEqual(before.center, after.center)) { commands.push({command: 'setCenter', args: [after.center]}); } if (!deepEqual(before.state, after.state)) { commands.push({command: 'setGlobalState', args: [after.state]}); } if (!deepEqual(before.centerAltitude, after.centerAltitude)) { commands.push({command: 'setCenterAltitude', args: [after.centerAltitude]}); } if (!deepEqual(before.zoom, after.zoom)) { commands.push({command: 'setZoom', args: [after.zoom]}); } if (!deepEqual(before.bearing, after.bearing)) { commands.push({command: 'setBearing', args: [after.bearing]}); } if (!deepEqual(before.pitch, after.pitch)) { commands.push({command: 'setPitch', args: [after.pitch]}); } if (!deepEqual(before.roll, after.roll)) { commands.push({command: 'setRoll', args: [after.roll]}); } if (!deepEqual(before.sprite, after.sprite)) { commands.push({command: 'setSprite', args: [after.sprite]}); } if (!deepEqual(before.glyphs, after.glyphs)) { commands.push({command: 'setGlyphs', args: [after.glyphs]}); } if (!deepEqual(before.transition, after.transition)) { commands.push({command: 'setTransition', args: [after.transition]}); } if (!deepEqual(before.light, after.light)) { commands.push({command: 'setLight', args: [after.light]}); } if (!deepEqual(before.terrain, after.terrain)) { commands.push({command: 'setTerrain', args: [after.terrain]}); } if (!deepEqual(before.sky, after.sky)) { commands.push({command: 'setSky', args: [after.sky]}); } if (!deepEqual(before.projection, after.projection)) { commands.push({command: 'setProjection', args: [after.projection]}); } // 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 = {}; // 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 ('source' in layer && sourcesRemoved[layer.source]) { commands.push({command: 'removeLayer', args: [layer.id]}); } else { beforeLayers.push(layer); } }); } commands = commands.concat(removeOrAddSourceCommands); // 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: 'setStyle', args: [after]}]; } return commands; }