modules/core/history.js (519 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { easeLinear as d3_easeLinear } from 'd3-ease'; import { select as d3_select } from 'd3-selection'; import { coreDifference } from './difference'; import { coreGraph } from './graph'; import { coreTree } from './tree'; import { osmEntity } from '../osm/entity'; import { uiLoading } from '../ui/loading'; import { utilArrayDifference, utilArrayGroupBy, utilArrayUnion, utilObjectOmit, utilRebind, utilSessionMutex } from '../util'; export function coreHistory(context) { var dispatch = d3_dispatch('change', 'merge', 'restore', 'undone', 'redone'); var lock = utilSessionMutex('lock'); // is iD not open in another window and it detects that // there's a history stored in localStorage that's recoverable? var hasUnresolvedRestorableChanges = lock.lock() && context.storage(getKey('saved_history')); var duration = 150; var _imageryUsed = []; var _photoOverlaysUsed = []; var _checkpoints = {}; var _pausedGraph; var _stack; var _index; var _tree; // internal _act, accepts list of actions and eased time function _act(actions, t) { actions = Array.prototype.slice.call(actions); var annotation; if (typeof actions[actions.length - 1] !== 'function') { annotation = actions.pop(); } var graph = _stack[_index].graph; for (var i = 0; i < actions.length; i++) { graph = actions[i](graph, t); } return { graph: graph, annotation: annotation, imageryUsed: _imageryUsed, photoOverlaysUsed: _photoOverlaysUsed, transform: context.projection.transform(), selectedIDs: context.selectedIDs() }; } // internal _perform with eased time function _perform(args, t) { var previous = _stack[_index].graph; _stack = _stack.slice(0, _index + 1); var actionResult = _act(args, t); _stack.push(actionResult); _index++; return change(previous); } // internal _replace with eased time function _replace(args, t) { var previous = _stack[_index].graph; // assert(_index == _stack.length - 1) var actionResult = _act(args, t); _stack[_index] = actionResult; return change(previous); } // internal _overwrite with eased time function _overwrite(args, t) { var previous = _stack[_index].graph; if (_index > 0) { _index--; _stack.pop(); } _stack = _stack.slice(0, _index + 1); var actionResult = _act(args, t); _stack.push(actionResult); _index++; return change(previous); } // determine difference and dispatch a change event function change(previous) { var difference = coreDifference(previous, history.graph()); if (!_pausedGraph) { dispatch.call('change', this, difference); } return difference; } // iD uses namespaced keys so multiple installations do not conflict function getKey(n) { return 'iD_' + window.location.origin + '_' + n; } var history = { graph: function() { return _stack[_index].graph; }, tree: function() { return _tree; }, base: function() { return _stack[0].graph; }, merge: function(entities, extent) { var stack = _stack.map(function(state) { return state.graph; }); _stack[0].graph.rebase(entities, stack, false); _tree.rebase(entities, false); dispatch.call('merge', this, entities); }, perform: function() { // complete any transition already in progress d3_select(document).interrupt('history.perform'); var transitionable = false; var action0 = arguments[0]; if (arguments.length === 1 || (arguments.length === 2 && (typeof arguments[1] !== 'function'))) { transitionable = !!action0.transitionable; } if (transitionable) { var origArguments = arguments; d3_select(document) .transition('history.perform') .duration(duration) .ease(d3_easeLinear) .tween('history.tween', function() { return function(t) { if (t < 1) _overwrite([action0], t); }; }) .on('start', function() { _perform([action0], 0); }) .on('end interrupt', function() { _overwrite(origArguments, 1); }); } else { return _perform(arguments); } }, replace: function() { d3_select(document).interrupt('history.perform'); return _replace(arguments, 1); }, // Same as calling pop and then perform overwrite: function() { d3_select(document).interrupt('history.perform'); return _overwrite(arguments, 1); }, pop: function(n) { d3_select(document).interrupt('history.perform'); var previous = _stack[_index].graph; if (isNaN(+n) || +n < 0) { n = 1; } while (n-- > 0 && _index > 0) { _index--; _stack.pop(); } return change(previous); }, // Back to the previous annotated state or _index = 0. undo: function() { d3_select(document).interrupt('history.perform'); var previousStack = _stack[_index]; var previous = previousStack.graph; while (_index > 0) { _index--; if (_stack[_index].annotation) break; } dispatch.call('undone', this, _stack[_index], previousStack); return change(previous); }, // Forward to the next annotated state. redo: function() { d3_select(document).interrupt('history.perform'); var previousStack = _stack[_index]; var previous = previousStack.graph; var tryIndex = _index; while (tryIndex < _stack.length - 1) { tryIndex++; if (_stack[tryIndex].annotation) { _index = tryIndex; dispatch.call('redone', this, _stack[_index], previousStack); break; } } return change(previous); }, pauseChangeDispatch: function() { if (!_pausedGraph) { _pausedGraph = _stack[_index].graph; } }, resumeChangeDispatch: function() { if (_pausedGraph) { var previous = _pausedGraph; _pausedGraph = null; return change(previous); } }, undoAnnotation: function() { var i = _index; while (i >= 0) { if (_stack[i].annotation) return _stack[i].annotation; i--; } }, redoAnnotation: function() { var i = _index + 1; while (i <= _stack.length - 1) { if (_stack[i].annotation) return _stack[i].annotation; i++; } }, intersects: function(extent) { return _tree.intersects(extent, _stack[_index].graph); }, difference: function() { var base = _stack[0].graph; var head = _stack[_index].graph; return coreDifference(base, head); }, changes: function(action) { var base = _stack[0].graph; var head = _stack[_index].graph; if (action) { head = action(head); } var difference = coreDifference(base, head); return { modified: difference.modified(), created: difference.created(), deleted: difference.deleted() }; }, hasChanges: function() { return this.difference().length() > 0; }, imageryUsed: function(sources) { if (sources) { _imageryUsed = sources; return history; } else { var s = new Set(); _stack.slice(1, _index + 1).forEach(function(state) { state.imageryUsed.forEach(function(source) { if (source !== 'Custom') { s.add(source); } }); }); return Array.from(s); } }, photoOverlaysUsed: function(sources) { if (sources) { _photoOverlaysUsed = sources; return history; } else { var s = new Set(); _stack.slice(1, _index + 1).forEach(function(state) { if (state.photoOverlaysUsed && Array.isArray(state.photoOverlaysUsed)) { state.photoOverlaysUsed.forEach(function(photoOverlay) { s.add(photoOverlay); }); } }); return Array.from(s); } }, // save the current history state checkpoint: function(key) { _checkpoints[key] = { stack: _stack, index: _index }; return history; }, // restore history state to a given checkpoint or reset completely reset: function(key) { if (key !== undefined && _checkpoints.hasOwnProperty(key)) { _stack = _checkpoints[key].stack; _index = _checkpoints[key].index; } else { _stack = [{graph: coreGraph()}]; _index = 0; _tree = coreTree(_stack[0].graph); _checkpoints = {}; } dispatch.call('change'); return history; }, // `toIntroGraph()` is used to export the intro graph used by the walkthrough. // // To use it: // 1. Start the walkthrough. // 2. Get to a "free editing" tutorial step // 3. Make your edits to the walkthrough map // 4. In your browser dev console run: // `id.history().toIntroGraph()` // 5. This outputs stringified JSON to the browser console // 6. Copy it to `data/intro_graph.json` and prettify it in your code editor toIntroGraph: function() { var nextID = { n: 0, r: 0, w: 0 }; var permIDs = {}; var graph = this.graph(); var baseEntities = {}; // clone base entities.. Object.values(graph.base().entities).forEach(function(entity) { var copy = copyIntroEntity(entity); baseEntities[copy.id] = copy; }); // replace base entities with head entities.. Object.keys(graph.entities).forEach(function(id) { var entity = graph.entities[id]; if (entity) { var copy = copyIntroEntity(entity); baseEntities[copy.id] = copy; } else { delete baseEntities[id]; } }); // swap temporary for permanent ids.. Object.values(baseEntities).forEach(function(entity) { if (Array.isArray(entity.nodes)) { entity.nodes = entity.nodes.map(function(node) { return permIDs[node] || node; }); } if (Array.isArray(entity.members)) { entity.members = entity.members.map(function(member) { member.id = permIDs[member.id] || member.id; return member; }); } }); return JSON.stringify({ dataIntroGraph: baseEntities }); function copyIntroEntity(source) { var copy = utilObjectOmit(source, ['type', 'user', 'v', 'version', 'visible']); // Note: the copy is no longer an osmEntity, so it might not have `tags` if (copy.tags && !Object.keys(copy.tags)) { delete copy.tags; } if (Array.isArray(copy.loc)) { copy.loc[0] = +copy.loc[0].toFixed(6); copy.loc[1] = +copy.loc[1].toFixed(6); } var match = source.id.match(/([nrw])-\d*/); // temporary id if (match !== null) { var nrw = match[1]; var permID; do { permID = nrw + (++nextID[nrw]); } while (baseEntities.hasOwnProperty(permID)); copy.id = permIDs[source.id] = permID; } return copy; } }, toJSON: function() { if (!this.hasChanges()) return; var allEntities = {}; var baseEntities = {}; var base = _stack[0]; var s = _stack.map(function(i) { var modified = []; var deleted = []; Object.keys(i.graph.entities).forEach(function(id) { var entity = i.graph.entities[id]; if (entity) { var key = osmEntity.key(entity); allEntities[key] = entity; modified.push(key); } else { deleted.push(id); } // make sure that the originals of changed or deleted entities get merged // into the base of the _stack after restoring the data from JSON. if (id in base.graph.entities) { baseEntities[id] = base.graph.entities[id]; } if (entity && entity.nodes) { // get originals of pre-existing child nodes entity.nodes.forEach(function(nodeID) { if (nodeID in base.graph.entities) { baseEntities[nodeID] = base.graph.entities[nodeID]; } }); } // get originals of parent entities too var baseParents = base.graph._parentWays[id]; if (baseParents) { baseParents.forEach(function(parentID) { if (parentID in base.graph.entities) { baseEntities[parentID] = base.graph.entities[parentID]; } }); } }); var x = {}; if (modified.length) x.modified = modified; if (deleted.length) x.deleted = deleted; if (i.imageryUsed) x.imageryUsed = i.imageryUsed; if (i.photoOverlaysUsed) x.photoOverlaysUsed = i.photoOverlaysUsed; if (i.annotation) x.annotation = i.annotation; if (i.transform) x.transform = i.transform; if (i.selectedIDs) x.selectedIDs = i.selectedIDs; return x; }); return JSON.stringify({ version: 3, entities: Object.values(allEntities), baseEntities: Object.values(baseEntities), stack: s, nextIDs: osmEntity.id.next, index: _index, timestamp: (new Date()).getTime() }); }, fromJSON: function(json, loadChildNodes) { var h = JSON.parse(json); var loadComplete = true; osmEntity.id.next = h.nextIDs; _index = h.index; if (h.version === 2 || h.version === 3) { var allEntities = {}; h.entities.forEach(function(entity) { allEntities[osmEntity.key(entity)] = osmEntity(entity); }); if (h.version === 3) { // This merges originals for changed entities into the base of // the _stack even if the current _stack doesn't have them (for // example when iD has been restarted in a different region) var baseEntities = h.baseEntities.map(function(d) { return osmEntity(d); }); var stack = _stack.map(function(state) { return state.graph; }); _stack[0].graph.rebase(baseEntities, stack, true); _tree.rebase(baseEntities, true); // When we restore a modified way, we also need to fetch any missing // childnodes that would normally have been downloaded with it.. #2142 if (loadChildNodes) { var osm = context.connection(); var baseWays = baseEntities .filter(function(e) { return e.type === 'way'; }); var nodeIDs = baseWays .reduce(function(acc, way) { return utilArrayUnion(acc, way.nodes); }, []); var missing = nodeIDs .filter(function(n) { return !_stack[0].graph.hasEntity(n); }); if (missing.length && osm) { loadComplete = false; context.redrawEnable(false); var loading = uiLoading(context).blocking(true); context.container().call(loading); var childNodesLoaded = function(err, result) { if (!err) { var visibleGroups = utilArrayGroupBy(result.data, 'visible'); var visibles = visibleGroups.true || []; // alive nodes var invisibles = visibleGroups.false || []; // deleted nodes if (visibles.length) { var visibleIDs = visibles.map(function(entity) { return entity.id; }); var stack = _stack.map(function(state) { return state.graph; }); missing = utilArrayDifference(missing, visibleIDs); _stack[0].graph.rebase(visibles, stack, true); _tree.rebase(visibles, true); } // fetch older versions of nodes that were deleted.. invisibles.forEach(function(entity) { osm.loadEntityVersion(entity.id, +entity.version - 1, childNodesLoaded); }); } if (err || !missing.length) { loading.close(); context.redrawEnable(true); dispatch.call('change'); dispatch.call('restore', this); } }; osm.loadMultiple(missing, childNodesLoaded); } } } _stack = h.stack.map(function(d) { var entities = {}, entity; if (d.modified) { d.modified.forEach(function(key) { entity = allEntities[key]; entities[entity.id] = entity; }); } if (d.deleted) { d.deleted.forEach(function(id) { entities[id] = undefined; }); } return { graph: coreGraph(_stack[0].graph).load(entities), annotation: d.annotation, imageryUsed: d.imageryUsed, photoOverlaysUsed: d.photoOverlaysUsed, transform: d.transform, selectedIDs: d.selectedIDs }; }); } else { // original version _stack = h.stack.map(function(d) { var entities = {}; for (var i in d.entities) { var entity = d.entities[i]; entities[i] = entity === 'undefined' ? undefined : osmEntity(entity); } d.graph = coreGraph(_stack[0].graph).load(entities); return d; }); } var transform = _stack[_index].transform; if (transform) { context.map().transformEase(transform, 0); // 0 = immediate, no easing } if (loadComplete) { dispatch.call('change'); dispatch.call('restore', this); } return history; }, save: function() { if (lock.locked() && // don't overwrite existing, unresolved changes !hasUnresolvedRestorableChanges) { context.storage(getKey('saved_history'), history.toJSON() || null); } return history; }, clearSaved: function() { context.debouncedSave.cancel(); if (lock.locked()) { hasUnresolvedRestorableChanges = false; context.storage(getKey('saved_history'), null); // clear the changeset metadata associated with the saved history context.storage('comment', null); context.storage('hashtags', null); context.storage('source', null); } return history; }, lock: function() { return lock.lock(); }, unlock: function() { lock.unlock(); }, savedHistoryJSON: function() { return context.storage(getKey('saved_history')); }, hasRestorableChanges: function() { return hasUnresolvedRestorableChanges; }, // load history from a version stored in localStorage restore: function() { if (!lock.locked()) return; hasUnresolvedRestorableChanges = false; var json = this.savedHistoryJSON(); if (json) history.fromJSON(json, true); }, _getKey: getKey }; history.reset(); return utilRebind(history, dispatch, 'on'); }