@maplibre/maplibre-gl-style-spec
Version:
a specification for maplibre styles
486 lines (446 loc) • 17.4 kB
text/typescript
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<T extends DiffOperations> = {
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<T extends DiffOperations>(
commands: DiffCommand<DiffOperations>[],
command: DiffCommand<T>
) {
commands.push(command);
}
function addSource(
sourceId: string,
after: {[key: string]: SourceSpecification},
commands: DiffCommand<DiffOperations>[]
) {
addCommand(commands, {command: 'addSource', args: [sourceId, after[sourceId]]});
}
function removeSource(
sourceId: string,
commands: DiffCommand<DiffOperations>[],
sourcesRemoved: {[key: string]: boolean}
) {
addCommand(commands, {command: 'removeSource', args: [sourceId]});
sourcesRemoved[sourceId] = true;
}
function updateSource(
sourceId: string,
after: {[key: string]: SourceSpecification},
commands: DiffCommand<DiffOperations>[],
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<DiffOperations>[],
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<DiffOperations>[],
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<DiffOperations>[]
) {
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<DiffOperations>[] {
if (!before) return [{command: 'setStyle', args: [after]}];
let commands: DiffCommand<DiffOperations>[] = [];
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;
}