@maplibre/maplibre-gl-style-spec
Version:
a specification for maplibre styles
357 lines (315 loc) • 15.9 kB
text/typescript
import {GeoJSONSourceSpecification, LayerSpecification, LightSpecification, ProjectionSpecification, SkySpecification, SourceSpecification, SpriteSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} 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];
}
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.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;
}