Opening a feature from the results panel or the legend flies the camera to it, which is the point of the row -- but it also spends the view the reader was looking at, and the only way back was to find their place again by hand. A list is for glancing at things; glancing should not cost a place. The camera each jump leaves is held for as long as the card is open, and closing the card hands it back. Each jump holds afresh over the last, so closing undoes that card's own move and nothing older: reading three rows in turn steps back through them one view at a time. A hand on the map -- a drag, the wheel, the zoom buttons, the overview, descending into a cell -- lets the hold go, because after that the view the reader is in is the view they chose and returning from it would be the map arguing with them. A jump into another world or another lens is not this camera's to give back either. A location clicked on the map holds nothing: it was already in front of the reader, and no camera moved to show it.
- commit
- 016b45e8898349d9109d6f74a9b537dc47ef25ab
- parent
- c6eb7d60297312761334d566936ce7a9fa8d933e
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.8 of 3, Jev's estimate
| M | frontend/ | 5 | ++- |
| M | frontend/ | 10 | ++++++- |
| M | frontend/ | 8 | ++++- |
| M | frontend/ | 6 | +++- |
| M | frontend/ | 49 | ++++++++++++++++++++++++++++++ |
| M | frontend/ | 5 | ++- |
| M | frontend/ | 4 | ++ |
@@ -10,7 +10,7 @@ import { elements } from "./dom.js";
1010 import { state } from "./state.js";1111 import { showFeature } from "./detail.js";1212 import { collectionID, syncLegendCheckboxes, syncSectionSwitches } from "./legend.js";13-import { viewMaxZoom } from "./navigation.js";13+import { holdCameraReturn, viewMaxZoom } from "./navigation.js";1414 import { onActiveShard, refreshPinRendering, updateZonePinFocus } from "./features.js";1515 import { labelPolicy } from "./semconv.js";1616 import { colorFor } from "./theme.js";@@ -227,6 +227,9 @@ export function jumpToZone(zoneID) {
227227 state.focusedZoneID = zoneID;228228 updateZoneIndexState();229229 showFeature(record.zone);230+ // Reached for from a list, the same way a location is: the view it costs is231+ // given back when the card closes.232+ holdCameraReturn();230233 state.engine.getView().fit(record.extent, {231234 size: state.engine.getSize(),232235 padding: [54, 54, 54, 54],@@ -4,7 +4,7 @@ import { geohashCellAt } from "./cellsystems/geohash.js";
44 import { collectionFor, geometryContainsCoordinate } from "./collections.js";55 import { elements } from "./dom.js";66 import { syncLegendCheckboxes, syncSectionSwitches } from "./legend.js";7-import { viewMaxZoom } from "./navigation.js";7+import { holdCameraReturn, returnCamera, viewMaxZoom } from "./navigation.js";88 import { applyPinFilters, onActiveShard, refreshPrioritySource } from "./features.js";99 import { renderSearchResults, revealDock } from "./search.js";1010 import { featureAttributeRows, geoMapping } from "./semconv.js";@@ -79,7 +79,11 @@ export function showPin(pin, focus = false) {
7979 elements.detail.hidden = false;8080 revealDock();8181 renderSearchResults();82+ // A pin clicked on the map is already in front of the reader; only a pin83+ // reached for from a panel is worth moving the camera for, and only that84+ // move is worth being able to undo.8285 if (focus) {86+ holdCameraReturn();8387 const view = state.engine.getView();8488 view.animate({8589 center: pin.coordinate,@@ -346,4 +350,8 @@ export function closeDetail() {
346350 }347351 elements.detail.hidden = true;348352 if (hadSelection) renderSearchResults();353+ // The card is done with the camera it borrowed. If the reader has steered354+ // since, or never left where they were, there is nothing held and this is355+ // no move at all.356+ returnCamera();349357 }@@ -22,7 +22,7 @@ import {
2222 zoneTitleDetailStyle,2323 zoneTitleStyle,2424 } from "./styles.js";25-import { settleView } from "./navigation.js";25+import { releaseCameraReturn, settleView } from "./navigation.js";2626 import { updateOverviewViewport } from "./overview.js";2727 import { selectGridCell } from "./grid.js";2828 import { setHoveredPin } from "./features.js";@@ -161,10 +161,16 @@ export function initializeMap() {
161161 // The locator follows the view as it moves rather than only once it settles.162162 // Repeated calls that would draw the same rectangle cost nothing.163163 state.engine.on("postrender", updateOverviewViewport);164+ // A hand on the map is the reader choosing where they are, which retires165+ // whatever view a panel's jump was holding for them: dragging, the wheel,166+ // and the double-click that zooms all say the same thing.167+ state.engine.on("pointerdrag", releaseCameraReturn);168+ state.engine.on("dblclick", releaseCameraReturn);164169 // OpenLayers can condition wheel handling on focus when its target is165170 // keyboard-focusable. Refocus during the wheel's capture phase so returning166171 // from any sidebar control never costs a discarded scroll gesture.167172 elements.viewport.addEventListener("wheel", () => {173+ releaseCameraReturn();168174 if (document.activeElement !== elements.viewport) {169175 elements.viewport.focus({ preventScroll: true });170176 }@@ -18,7 +18,7 @@ import {
1818 import { gridTheme, palette } from "./constants.js";1919 import { closeDetail } from "./detail.js";2020 import { elements } from "./dom.js";21-import { viewMaxZoom } from "./navigation.js";21+import { releaseCameraReturn, viewMaxZoom } from "./navigation.js";2222 import { refreshPinRendering } from "./features.js";2323 import { state } from "./state.js";2424 @@ -102,6 +102,10 @@ export function selectGridCell(raw) {
102102 renderGrid();103103 refreshPinRendering();104104 if (!changed) return;105+ // Descending is the reader saying where they want to be, so the view a106+ // panel's jump was holding is spent -- and the card closing must not hand107+ // it back over the top of the cell being flown to.108+ releaseCameraReturn();105109 closeDetail();106110 state.engine.getView().fit(currentGridExtent(), {107111 size: state.engine.getSize(),@@ -111,6 +111,8 @@ export async function selectWorld(slug) {
111111 state.search = "";112112 elements.search.value = "";113113 elements.dock.hidden = false;114+ // A way back into the map being left behind is no way back at all.115+ releaseCameraReturn();114116 closeDetail();115117 renderLegend();116118 renderShapes();@@ -324,6 +326,7 @@ export function activeExtent() {
324326 325327 export function fitMap() {326328 if (!state.engine || !state.lens) return;329+ releaseCameraReturn();327330 const view = state.engine.getView();328331 view.fit(activeExtent(), {329332 size: state.engine.getSize(),@@ -336,6 +339,7 @@ export function fitMap() {
336339 }337340 338341 export function changeZoom(delta) {342+ releaseCameraReturn();339343 const view = state.engine.getView();340344 const current = view.getZoom() || 0;341345 view.animate({@@ -379,3 +383,48 @@ export function updateVisibleCount() {
379383 elements.visibleCount.textContent = `${formatNumber(inView)} of ` +380384 `${formatNumber(shown)} ${shown === 1 ? "feature" : "features"} in view`;381385 }386+387+// A jump made from a panel is a detour. The reader asked to look at one388+// feature, not to give up the view they were reading it against -- and until389+// now the only way back from a row in the list was to find their place again390+// by hand. The camera the jump left is held for as long as the card is open,391+// and closing the card hands it back.392+//393+// Each jump holds afresh, overwriting whatever the last one left: closing a394+// card undoes that card's own move and nothing older. Reading three rows in395+// turn steps back through them one view at a time, and none of the three can396+// hand back a place from before the reader went looking. Anything that steers397+// the map -- a drag, the wheel, the zoom buttons, the overview, a cell -- lets398+// the hold go entirely, because after that the view they are in is the view399+// they chose, and returning from it would be the map arguing with them.400+export function holdCameraReturn() {401+ if (!state.engine) return;402+ const view = state.engine.getView();403+ const center = view.getCenter();404+ const zoom = view.getZoom();405+ if (!center || !Number.isFinite(zoom)) return;406+ state.cameraReturn = {407+ center: center.slice(),408+ zoom,409+ world: state.world,410+ lens: state.lens,411+ };412+}413+414+export function releaseCameraReturn() {415+ state.cameraReturn = null;416+}417+418+export function returnCamera() {419+ const held = state.cameraReturn;420+ state.cameraReturn = null;421+ if (!held || !state.engine) return;422+ // A view belonging to another map, or to another layer of this one, is not423+ // this camera's to give back.424+ if (held.world !== state.world || held.lens !== state.lens) return;425+ state.engine.getView().animate({426+ center: held.center,427+ zoom: held.zoom,428+ duration: 220,429+ });430+}@@ -1,7 +1,7 @@
11 import { overviewTargetSize } from "./constants.js";22 import { elements } from "./dom.js";33 import { aimGlobe, globeCamera } from "./globe.js";4-import { activeExtent, settleView, tileExists } from "./navigation.js";4+import { activeExtent, releaseCameraReturn, settleView, tileExists } from "./navigation.js";55 import { equirectMapping } from "./semconv.js";66 import { saveSession } from "./session.js";77 import { state } from "./state.js";@@ -140,6 +140,9 @@ export function setOverviewDocked(docked, remember = true) {
140140 // move rather than a cut. Everything after it lands at once: a view that eased141141 // its way to each new position would trail behind the hand steering it.142142 export function overviewNavigate(event, ease) {143+ // Steering from the corner is steering: whatever view a panel's jump was144+ // holding is the reader's to give up here as much as on the map itself.145+ releaseCameraReturn();143146 const rect = elements.overviewCanvas.getBoundingClientRect();144147 const extent = activeExtent();145148 const fraction = (value, low, high) => clamp((value - low) / (high - low), 0, 1);@@ -62,6 +62,10 @@ export const state = {
6262 worldRun: 0,6363 textByWorld: new Map(),6464 restore: null,65+ // Where the camera was standing before a panel sent it somewhere: the view66+ // a jump from the dock or the legend borrowed, given back when the card it67+ // was opened for is closed. Null whenever the reader is steering.68+ cameraReturn: null,6569 settling: false,6670 styleCache: new Map(),6771 markerIcons: new Map(),