From e440c851220776fd449ff4ebdf48eaaa06dc40d1 Mon Sep 17 00:00:00 2001 From: Marwin Hochfelsner <50826859+hlfan@users.noreply.github.com> Date: Fri, 10 Apr 2026 23:51:03 +0200 Subject: [PATCH 1/2] refactor hash methods to accept callbacks --- modules/behavior/hash.js | 112 +++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/modules/behavior/hash.js b/modules/behavior/hash.js index 644286a2649..b14feb9c020 100644 --- a/modules/behavior/hash.js +++ b/modules/behavior/hash.js @@ -5,31 +5,71 @@ import { select as d3_select } from 'd3-selection'; import { geoSphericalDistance } from '../geo'; import { modeBrowse } from '../modes/browse'; import { modeSelect, modeSelectNote } from '../modes'; -import { utilObjectOmit, utilQsString, utilStringQs } from '../util'; +import { utilQsString, utilStringQs } from '../util'; import { utilArrayIdentical } from '../util/array'; import { utilDisplayLabel } from '../util/utilDisplayLabel'; import { localizer, t } from '../core/localizer'; import { prefs } from '../core/preferences'; +function getNewHash(arg) { + const original = utilStringQs(window.location.hash); + const update = typeof arg === 'function' ? arg(original) : arg; + if (!update || typeof update !== 'object') return; + + const updated = { ...original, ...update }; + Object.keys(update) + .filter(key => update[key] === null || update[key] === undefined) + .forEach(key => delete updated[key]); + + return '#' + utilQsString(updated, true); +} + +/** + * Updates the URL hash by applying a partial patch. + * + * Keys with nullish values will be removed from the hash. + * + * @param {(?Object|function (Object): Object)} updater Either + * - a plain object of key/value pairs to merge into the hash, or + * - a function `(currentHash) => patchObject` that returns such an object. + * @returns {boolean} Whether the hash was updated. + */ +export function patchHash(updater) { + if (!updater || !['function', 'object'].includes(typeof updater)) return false; + + const latestHash = getNewHash(updater); + if (!latestHash || window.location.hash === latestHash) return false; + + // Update the URL hash without affecting the browser navigation stack, + // though unavoidably creating a browser history entry + window.history.replaceState(null, '', latestHash); + + // save last used map location for future + const { map } = utilStringQs(latestHash); + if (map) prefs('map-location', map); + return true; +} export function behaviorHash(context) { - // cached window.location.hash - var _cachedHash = null; // allowable latitude range var _latitudeLimit = 90 - 1e-8; - function computedHashParameters() { + function computeHashUpdate() { + if (context.inIntro()) return null; + var map = context.map(); var center = map.center(); var zoom = map.zoom(); var precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2)); - var oldParams = utilObjectOmit(utilStringQs(window.location.hash), - ['comment', 'source', 'hashtags', 'walkthrough'] - ); - var newParams = {}; + const newParams = { + comment: null, + source: null, + hashtags: null, + walkthrough: null, + id: null + }; - delete oldParams.id; var selected = context.selectedIDs().filter(function(id) { return context.hasEntity(id); }); @@ -43,11 +83,7 @@ export function behaviorHash(context) { '/' + center[1].toFixed(precision) + '/' + center[0].toFixed(precision); - return Object.assign(oldParams, newParams); - } - - function computedHash() { - return '#' + utilQsString(computedHashParameters(), true); + return newParams; } function computedTitle(includeChangeCount) { @@ -91,7 +127,7 @@ export function behaviorHash(context) { return baseTitle; } - function updateTitle(includeChangeCount) { + function updateTitle(includeChangeCount = true) { if (!context.setsDocumentTitle()) return; var newTitle = computedTitle(includeChangeCount); @@ -100,40 +136,14 @@ export function behaviorHash(context) { } } - function updateHashIfNeeded() { - if (context.inIntro()) return; - - var latestHash = computedHash(); - if (_cachedHash !== latestHash) { - _cachedHash = latestHash; - - // Update the URL hash without affecting the browser navigation stack, - // though unavoidably creating a browser history entry - window.history.replaceState(null, '', latestHash); - - // set the title we want displayed for the browser tab/window - updateTitle(true /* includeChangeCount */); - - // save last used map location for future - const q = utilStringQs(latestHash); - if (q.map) { - prefs('map-location', q.map); - } - } - } - - var _throttledUpdate = throttle(updateHashIfNeeded, 500); - var _throttledUpdateTitle = throttle(function() { - updateTitle(true /* includeChangeCount */); + var _throttledUpdate = throttle(() => { + patchHash(computeHashUpdate); + updateTitle(); }, 500); + var _throttledUpdateTitle = throttle(updateTitle, 500); function hashchange() { - // ignore spurious hashchange events - if (window.location.hash === _cachedHash) return; - - _cachedHash = window.location.hash; - - var q = utilStringQs(_cachedHash); + var q = utilStringQs(window.location.hash); if (q.theme) { context.theme(q.theme); @@ -147,11 +157,11 @@ export function behaviorHash(context) { var mapArgs = (q.map || '').split('/').map(Number); if (mapArgs.length < 3 || mapArgs.some(isNaN)) { // replace bogus hash - updateHashIfNeeded(); - + patchHash(computeHashUpdate); + updateTitle(); } else { // don't update if the new hash already reflects the state of iD - if (_cachedHash === computedHash()) return; + if (window.location.hash === getNewHash(computeHashUpdate)) return; var mode = context.mode(); @@ -224,14 +234,14 @@ export function behaviorHash(context) { const mapArgs = prefs('map-location').split('/').map(Number); context.map().centerZoom([mapArgs[2], Math.min(_latitudeLimit, Math.max(-_latitudeLimit, mapArgs[1]))], mapArgs[0]); - updateHashIfNeeded(); + patchHash(computeHashUpdate); behavior.hadLocation = true; } hashchange(); - updateTitle(false); + updateTitle(false /* includeChangeCount */); } behavior.off = function() { From 69de6478ba4f3ecbb73b088881b44e61f32fff0f Mon Sep 17 00:00:00 2001 From: Marwin Hochfelsner <50826859+hlfan@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:55:00 +0200 Subject: [PATCH 2/2] streamline hash updates --- modules/behavior/index.js | 2 +- modules/renderer/background.js | 29 ++++++++--------------------- modules/renderer/features.js | 15 +++++---------- modules/renderer/photos.js | 21 ++++----------------- modules/services/kartaview.js | 18 ++++-------------- modules/services/mapilio.js | 17 ++++------------- modules/services/mapillary.js | 19 ++++--------------- modules/services/panoramax.js | 21 ++++----------------- modules/services/streetside.js | 18 ++++-------------- modules/services/vegbilder.js | 17 ++++------------- modules/ui/intro/intro.js | 10 +++++++--- 11 files changed, 49 insertions(+), 138 deletions(-) diff --git a/modules/behavior/index.js b/modules/behavior/index.js index 19b0a8da06a..134024384e0 100644 --- a/modules/behavior/index.js +++ b/modules/behavior/index.js @@ -4,7 +4,7 @@ export { behaviorDrag } from './drag'; export { behaviorDrawWay } from './draw_way'; export { behaviorDraw } from './draw'; export { behaviorEdit } from './edit'; -export { behaviorHash } from './hash'; +export { behaviorHash, patchHash } from './hash'; export { behaviorHover } from './hover'; export { behaviorLasso } from './lasso'; export { behaviorOperation } from './operation'; diff --git a/modules/renderer/background.js b/modules/renderer/background.js index 5f9c2de479e..8463f3196f1 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -11,8 +11,9 @@ import { fileFetcher } from '../core/file_fetcher'; import { geoMetersToOffset, geoOffsetToMeters, geoExtent } from '../geo'; import { rendererBackgroundSource } from './background_source'; import { rendererTileLayer } from './tile_layer'; -import { utilQsString, utilStringQs } from '../util'; +import { utilStringQs } from '../util'; import { utilRebind } from '../util/rebind'; +import { patchHash } from '../behavior'; let _imageryIndex = null; @@ -200,32 +201,18 @@ export function rendererBackground(context) { const EPSILON = 0.01; const x = +meters[0].toFixed(2); const y = +meters[1].toFixed(2); - let hash = utilStringQs(window.location.hash); + const notableOffset = Math.abs(x) > EPSILON || Math.abs(y) > EPSILON; let id = currSource.id; if (id === 'custom') { id = `custom:${currSource.template()}`; } - if (id) { - hash.background = id; - } else { - delete hash.background; - } - - if (o) { - hash.overlays = o; - } else { - delete hash.overlays; - } - - if (Math.abs(x) > EPSILON || Math.abs(y) > EPSILON) { - hash.offset = `${x},${y}`; - } else { - delete hash.offset; - } - - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); + patchHash({ + background: id || null, + overlays: o || null, + offset: notableOffset ? `${x},${y}` : null + }); let imageryUsed = []; let photoOverlaysUsed = []; diff --git a/modules/renderer/features.js b/modules/renderer/features.js index 8a22285d94c..b53e437d46e 100644 --- a/modules/renderer/features.js +++ b/modules/renderer/features.js @@ -4,8 +4,9 @@ import { prefs } from '../core/preferences'; import { osmEntity } from '../osm'; import { osmLanduseTags, osmLifecyclePrefixes } from '../osm/tags.js'; import { utilRebind } from '../util/rebind'; -import { utilArrayGroupBy, utilArrayUnion, utilQsString, utilStringQs } from '../util'; +import { utilArrayGroupBy, utilArrayUnion, utilStringQs } from '../util'; import { isAddressPoint } from '../svg/labels'; +import { patchHash } from '../behavior'; export function rendererFeatures(context) { @@ -56,15 +57,9 @@ export function rendererFeatures(context) { function update() { - const hash = utilStringQs(window.location.hash); - const disabled = features.disabled(); - if (disabled.length) { - hash.disable_features = disabled.join(','); - } else { - delete hash.disable_features; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - prefs('disabled-features', disabled.join(',')); + const disabled = features.disabled().join(','); + patchHash({ disable_features: disabled || null }); + prefs('disabled-features', disabled); _hidden = features.hidden(); dispatch.call('change'); dispatch.call('redraw'); diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index 254ce2fa71e..93ed739f47e 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -2,7 +2,8 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { services } from '../services'; import { utilRebind } from '../util/rebind'; -import { utilQsString, utilStringQs } from '../util'; +import { utilStringQs } from '../util'; +import { patchHash } from '../behavior'; export function rendererPhotos(context) { @@ -18,18 +19,12 @@ export function rendererPhotos(context) { function photos() {} function updateStorage() { - var hash = utilStringQs(window.location.hash); var enabled = context.layers().all().filter(function(d) { return _layerIDs.indexOf(d.id) !== -1 && d.layer && d.layer.supported() && d.layer.enabled(); }).map(function(d) { return d.id; }); - if (enabled.length) { - hash.photo_overlay = enabled.join(','); - } else { - delete hash.photo_overlay; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); + patchHash({ photo_overlay: enabled.join(',') || null }); } /** @@ -148,15 +143,7 @@ export function rendererPhotos(context) { * @param {string} property Name of the value */ function setUrlFilterValue(property, val) { - const hash = utilStringQs(window.location.hash); - if (val) { - if (hash[property] === val) return; - hash[property] = val; - } else { - if (!(property in hash)) return; - delete hash[property]; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); + patchHash({ [property]: val || null }); } function showsLayer(id) { diff --git a/modules/services/kartaview.js b/modules/services/kartaview.js index e3a22abf758..f5930d58990 100644 --- a/modules/services/kartaview.js +++ b/modules/services/kartaview.js @@ -5,10 +5,11 @@ import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; import RBush from 'rbush'; import { geoExtent, geoScaleToZoom } from '../geo'; -import { utilQsString, utilRebind, utilSetTransform, utilStringQs, utilTiler } from '../util'; +import { utilQsString, utilRebind, utilSetTransform, utilTiler } from '../util'; import { services } from './'; import { searchLimited } from '../util/partition'; import { localeDateString } from '../util/date'; +import { patchHash } from '../behavior'; var apibase = 'https://kartaview.org'; @@ -363,7 +364,7 @@ export default { hideViewer: function(context) { _oscSelectedImage = null; - this.updateUrlImage(null); + patchHash({ photo: null }); var viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); @@ -386,7 +387,7 @@ export default { _oscSelectedImage = d; - this.updateUrlImage(imageKey); + patchHash({ photo: 'kartaview/' + imageKey }); var viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(d); @@ -517,17 +518,6 @@ export default { }, - updateUrlImage: function(imageKey) { - const hash = utilStringQs(window.location.hash); - if (imageKey) { - hash.photo = 'kartaview/' + imageKey; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - - cache: function() { return _oscCache; } diff --git a/modules/services/mapilio.js b/modules/services/mapilio.js index 06f1a817f37..db7e261e316 100644 --- a/modules/services/mapilio.js +++ b/modules/services/mapilio.js @@ -7,11 +7,12 @@ import Protobuf from 'pbf'; import RBush from 'rbush'; import { VectorTile } from '@mapbox/vector-tile'; -import { utilRebind, utilTiler, utilQsString, utilStringQs, utilSetTransform } from '../util'; +import { utilRebind, utilTiler, utilSetTransform } from '../util'; import { geoExtent } from '../geo'; import { services } from './'; import { searchLimited } from '../util/partition'; import { localeDateString } from '../util/date'; +import { patchHash } from '../behavior'; const apiUrl = 'https://end.mapilio.com'; const imageBaseUrl = 'https://cdn.mapilio.com/im'; @@ -295,16 +296,6 @@ export default { return this; }, - updateUrlImage: function(imageKey) { - const hash = utilStringQs(window.location.hash); - if (imageKey) { - hash.photo = 'mapilio/' + imageKey; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - initViewer: function () { if (!window.pannellum) return; if (_pannellumViewer) return; @@ -328,7 +319,7 @@ export default { this.setActiveImage(d); - this.updateUrlImage(d.id); + patchHash({ photo: 'mapilio/' + d.id }); let viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(d); @@ -590,7 +581,7 @@ export default { let viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); - this.updateUrlImage(null); + patchHash({ photo: null }); viewer .classed('hide', true) diff --git a/modules/services/mapillary.js b/modules/services/mapillary.js index 8cdbaf7fb23..9923dc88324 100644 --- a/modules/services/mapillary.js +++ b/modules/services/mapillary.js @@ -6,9 +6,10 @@ import Protobuf from 'pbf'; import RBush from 'rbush'; import { VectorTile } from '@mapbox/vector-tile'; import { geoExtent } from '../geo'; -import { utilQsString, utilRebind, utilTiler, utilStringQs } from '../util'; +import { utilRebind, utilTiler } from '../util'; import { services } from './'; import { searchLimited } from '../util/partition'; +import { patchHash } from '../behavior'; const accessToken = 'MLY|4100327730013843|5bb78b81720791946a9a7b956c57b7cf'; const apiUrl = 'https://graph.mapillary.com/'; @@ -477,7 +478,7 @@ export default { .selectAll('.photo-wrapper') .classed('hide', true); - this.updateUrlImage(null); + patchHash({ photo: null }); dispatch.call('imageChanged'); dispatch.call('loadedMapFeatures'); @@ -495,18 +496,6 @@ export default { }, - // Update the URL with current image id - updateUrlImage: function(imageId) { - const hash = utilStringQs(window.location.hash); - if (imageId) { - hash.photo = 'mapillary/' + imageId; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - - // Highlight the detection in the viewer that is related to the clicked map feature highlightDetection: function(detection) { if (detection) { @@ -568,7 +557,7 @@ export default { this.setStyles(context, null); const loc = [image.originalLngLat.lng, image.originalLngLat.lat]; context.map().centerEase(loc); - this.updateUrlImage(image.id); + patchHash({ photo: 'mapillary/' + image.id }); if (_mlyShowFeatureDetections || _mlyShowSignDetections) { this.updateDetections(image.id, `${apiUrl}/${image.id}/detections?access_token=${accessToken}&fields=id,image,geometry,value`); diff --git a/modules/services/panoramax.js b/modules/services/panoramax.js index 6838af9038a..4d89593b060 100644 --- a/modules/services/panoramax.js +++ b/modules/services/panoramax.js @@ -3,7 +3,7 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import Protobuf from 'pbf'; import RBush from 'rbush'; import { VectorTile } from '@mapbox/vector-tile'; -import { utilRebind, utilTiler, utilQsString, utilStringQs, utilUniqueDomId } from '../util'; +import { utilRebind, utilTiler, utilUniqueDomId } from '../util'; import { geoExtent } from '../geo'; import { t } from '../core/localizer'; import { pannellumPhotoFrame } from './pannellum_photo'; @@ -11,6 +11,7 @@ import { planePhotoFrame } from './plane_photo'; import { services } from './'; import { partitionViewport } from '../util/partition'; import { localeDateString } from '../util/date'; +import { patchHash } from '../behavior'; const apiUrl = 'https://api.panoramax.xyz/'; @@ -411,20 +412,6 @@ export default { return _isViewerOpen; }, - /** - * Updates the URL to save the current shown image - * @param {*} imageKey - */ - updateUrlImage: function(imageKey) { - const hash = utilStringQs(window.location.hash); - if (imageKey) { - hash.photo = 'panoramax/' + imageKey; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - /** * Loads the selected image in the frame * @param {*} context Current HTML context @@ -436,7 +423,7 @@ export default { let d = that.cachedImage(id); that.setActiveImage(d); - that.updateUrlImage(d.id); + patchHash({ photo: 'panoramax/' + d.id }); const viewerLink = `${viewerUrl}#pic=${d.id}&focus=pic`; @@ -726,7 +713,7 @@ export default { hideViewer: function (context) { let viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); - this.updateUrlImage(null); + patchHash({ photo: null }); viewer .classed('hide', true) .selectAll('.photo-wrapper') diff --git a/modules/services/streetside.js b/modules/services/streetside.js index e0ec0c2cad5..3d9d490e7da 100644 --- a/modules/services/streetside.js +++ b/modules/services/streetside.js @@ -13,11 +13,12 @@ import { geoRotate, geoVecLength } from '../geo'; -import { utilAesDecrypt, utilArrayUnion, utilQsString, utilRebind, utilStringQs, utilTiler, utilUniqueDomId } from '../util'; +import { utilAesDecrypt, utilArrayUnion, utilRebind, utilTiler, utilUniqueDomId } from '../util'; import { services } from './'; import { searchLimited } from '../util/partition'; import { localeTimestamp } from '../util/date'; +import { patchHash } from '../behavior'; const streetsideApi = 'https://dev.virtualearth.net/REST/v1/Imagery/MetaData/Streetside?mapArea={bbox}&key={key}&count={count}&uriScheme=https'; @@ -644,7 +645,7 @@ export default { context.container().selectAll('.viewfield-group, .sequence, .icon-sign') .classed('currentView', false); - this.updateUrlImage(null); + patchHash({ photo: null }); return this.setStyles(context, null, true); }, @@ -671,7 +672,7 @@ export default { if (!d) return this; - this.updateUrlImage(key); + patchHash({ photo: 'streetside/' + key }); _sceneOptions.northOffset = d.ca; @@ -869,17 +870,6 @@ export default { }, - updateUrlImage: function(imageKey) { - const hash = utilStringQs(window.location.hash); - if (imageKey) { - hash.photo = 'streetside/' + imageKey; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - - /** * cache(). */ diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index fd4b083cd50..89184817690 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -4,13 +4,14 @@ import { pairs as d3_pairs } from 'd3-array'; import RBush from 'rbush'; import { iso1A2Codes } from '@rapideditor/country-coder'; import { t } from '../core/localizer'; -import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs } from '../util'; +import { utilQsString, utilTiler, utilRebind, utilArrayUnion } from '../util'; import { searchLimited } from '../util/partition'; import { localeTimestamp } from '../util/date'; import { geoExtent, geoVecAngle, geoVecEqual } from '../geo'; import { pannellumPhotoFrame } from './pannellum_photo'; import { planePhotoFrame } from './plane_photo'; import { services } from './'; +import { patchHash } from '../behavior'; const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; @@ -426,7 +427,7 @@ export default { selectImage: function(context, key, keepOrientation) { const d = this.cachedImage(key); - this.updateUrlImage(key); + patchHash({ photo: 'vegbilder/' + key }); const viewer = context.container().select('.photoviewer'); if (!viewer.empty()) { viewer.datum(d); } @@ -486,7 +487,7 @@ export default { }, hideViewer: function(context) { - this.updateUrlImage(null); + patchHash({ photo: null }); const viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); @@ -558,16 +559,6 @@ export default { return this; }, - updateUrlImage: function (key) { - const hash = utilStringQs(window.location.hash); - if (key) { - hash.photo = 'vegbilder/' + key; - } else { - delete hash.photo; - } - window.history.replaceState(null, '', '#' + utilQsString(hash, true)); - }, - validHere: function(extent) { const bbox = Object.values(extent.bbox()); return iso1A2Codes(bbox).includes('NO'); diff --git a/modules/ui/intro/intro.js b/modules/ui/intro/intro.js index 4624fe90745..47cf6d63114 100644 --- a/modules/ui/intro/intro.js +++ b/modules/ui/intro/intro.js @@ -8,7 +8,7 @@ import { modeBrowse } from '../../modes/browse'; import { osmEntity } from '../../osm/entity'; import { svgIcon } from '../../svg/icon'; import { uiCurtain } from '../curtain'; -import { utilArrayDifference, utilArrayUniq } from '../../util'; +import { utilArrayDifference, utilArrayUniq, utilStringQs } from '../../util'; import { uiIntroWelcome } from './welcome'; import { uiIntroNavigation } from './navigation'; @@ -17,6 +17,7 @@ import { uiIntroArea } from './area'; import { uiIntroLine } from './line'; import { uiIntroBuilding } from './building'; import { uiIntroStartEditing } from './start_editing'; +import { patchHash } from '../../behavior'; const chapterUi = { @@ -67,7 +68,7 @@ export function uiIntro(context) { // Save current map state let osm = context.connection(); let history = context.history().toJSON(); - let hash = window.location.hash; + let hash = utilStringQs(window.location.hash); let center = context.map().center(); let zoom = context.map().zoom(); let background = context.background().baseLayerSource(); @@ -163,7 +164,10 @@ export function uiIntro(context) { overlays.forEach(d => context.background().toggleOverlayLayer(d)); if (history) { context.history().fromJSON(history, false); } context.map().centerZoom(center, zoom); - window.history.replaceState(null, '', hash); + patchHash(oldHash => ({ + ...Object.fromEntries(Object.keys(oldHash).map(k => [k, null])), + ...hash + })); context.inIntro(false); });