clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

archive/golden-reference
atlas / frontend / src / events.js
events.js 14.4K
1import { $, elements } from "./dom.js";2import { state } from "./state.js";3import { activeSystem } from "./cellsystems/index.js";4import {5 changeZoom,6 selectVolume,7 selectWorld,8 selectLens,9 settleView,10 toggleSidebar,11} from "./navigation.js";12import {13 collectionID,14 setAllCollections,15 setAllSectionsCollapsed,16 showOnly,17 syncSectionCollapse,18 syncSectionSwitches,19 toggleCollectionExpanded,20 toggleLabelPolicy,21 toggleSection,22} from "./legend.js";23import { applyPinFilters, setLabelsHeld } from "./features.js";24import { changeGlobeZoom, refreshGlobe, resizeGlobe, toggleGlobe } from "./globe.js";25import { jumpToZone, syncZoneLayers, toggleZoneHighlight } from "./areas.js";26import { foldDockByHand, revealDock } from "./search.js";27import { importBundles } from "./library.js";28import { closeDetail, revealPin } from "./detail.js";29import {30 ascendGrid,31 handleGridKey,32 selectGridCell,33 cycleGridSystem,34 setSubgridVisible,35} from "./grid.js";36import { readRoute } from "./session.js";37import {38 overviewNavigate,39 setOverviewDocked,40 updateOverviewViewport,41} from "./overview.js";42import { isEditableTarget } from "./util.js";4344export function bindUIEvents() {45 elements.volume.addEventListener("change", () => { void selectVolume(elements.volume.value); });46 elements.world.addEventListener("change", () => { void selectWorld(elements.world.value); });47 elements.addBundles.addEventListener("click", () => { void importBundles(); });48 elements.emptyOpen.addEventListener("click", () => { void importBundles(); });49 elements.lens.addEventListener("change", () => {50 selectLens(Number(elements.lens.value));51 refreshGlobe();52 });53 elements.globeToggle.addEventListener("click", () => toggleGlobe());5455 elements.legend.addEventListener("change", (event) => {56 const input = event.target;57 if (input.dataset.sectionToggle) {58 toggleSection(input.dataset.sectionToggle);59 return;60 }61 if (!input.dataset.collection) return;62 const id = collectionID(input.dataset.collection);63 if (input.checked) state.hiddenCollections.delete(id);64 else state.hiddenCollections.add(id);65 syncZoneLayers();66 applyPinFilters();67 syncSectionSwitches();68 });6970 // Which row the pointer is over is worked out from where the pointer is,71 // rather than read from :hover. Scrolling moves the list under a still72 // cursor, and the browser does not always revise :hover when it does -- so a73 // row kept offering an action to a pointer that had long since left it.74 // Recomputing on scroll settles it, because the answer comes from the75 // pointer's actual position either way.76 let pointerAt = null;77 const markHoveredRow = () => {78 for (const marked of elements.layers.querySelectorAll(".is-hovered")) {79 marked.classList.remove("is-hovered");80 }81 if (!pointerAt) return;82 const under = document.elementFromPoint(pointerAt.x, pointerAt.y);83 const row = under?.closest(".category-row, .layer-header");84 if (row && elements.layers.contains(row)) row.classList.add("is-hovered");85 };86 elements.layers.addEventListener("pointermove", (event) => {87 pointerAt = { x: event.clientX, y: event.clientY };88 markHoveredRow();89 }, { passive: true });90 elements.layers.addEventListener("pointerleave", () => {91 pointerAt = null;92 markHoveredRow();93 });94 elements.layers.addEventListener("scroll", markHoveredRow, { passive: true });9596 elements.layers.addEventListener("click", (event) => {97 const only = event.target.closest("[data-only-collection], [data-only-section]");98 if (only) {99 // The button sits inside the row's label, whose default action would100 // otherwise toggle the very checkbox this is meant to override.101 event.preventDefault();102 event.stopPropagation();103 showOnly(only.dataset.onlyCollection104 ? { collection: collectionID(only.dataset.onlyCollection) }105 : { section: only.dataset.onlySection });106 return;107 }108 // The unfolding chevron and the label toggle also live inside the row's109 // label, and mean themselves rather than the checkbox under it.110 const expand = event.target.closest("[data-expand-collection]");111 if (expand) {112 event.preventDefault();113 event.stopPropagation();114 toggleCollectionExpanded(collectionID(expand.dataset.expandCollection));115 return;116 }117 const labels = event.target.closest("[data-label-toggle]");118 if (labels) {119 event.preventDefault();120 event.stopPropagation();121 toggleLabelPolicy(collectionID(labels.dataset.labelToggle));122 return;123 }124 const zone = event.target.closest("[data-zone]");125 if (zone) {126 jumpToZone(Number(zone.dataset.zone));127 return;128 }129 const button = event.target.closest("[data-section]");130 if (!button) return;131 const key = button.dataset.section;132 if (state.collapsedSections.has(key)) state.collapsedSections.delete(key);133 else state.collapsedSections.add(key);134 syncSectionCollapse();135 });136 // The right button asks after a zone in particular: toggle its highlight137 // rather than jumping to it.138 elements.layers.addEventListener("contextmenu", (event) => {139 const zone = event.target.closest("[data-zone]");140 if (!zone) return;141 event.preventDefault();142 toggleZoneHighlight(Number(zone.dataset.zone));143 });144145 elements.soloChip.addEventListener("click", () => setAllCollections(true));146 $("#expand-all").addEventListener("click", () => setAllSectionsCollapsed(false));147 $("#collapse-all").addEventListener("click", () => setAllSectionsCollapsed(true));148 $("#show-all").addEventListener("click", () => setAllCollections(true));149 $("#hide-all").addEventListener("click", () => setAllCollections(false));150 elements.search.addEventListener("input", () => {151 state.search = elements.search.value.trim().toLocaleLowerCase();152 // applyPinFilters refreshes the dock list along with the canvas.153 applyPinFilters();154 // What a search finds is listed in the panel, so a search asked with the155 // panel away is a question with the answer out of sight.156 if (state.search) revealDock();157 });158 elements.dockFold.addEventListener("click", () => {159 foldDockByHand(!state.dockFolded);160 });161 elements.searchResults.addEventListener("click", (event) => {162 const result = event.target.closest("[data-location], [data-zone]");163 if (!result) return;164 if (result.dataset.zone) {165 jumpToZone(Number(result.dataset.zone));166 return;167 }168 const pin = state.featureByID.get(Number(result.dataset.location));169 if (pin) revealPin(pin);170 });171172 elements.detailLinks.addEventListener("click", (event) => {173 const button = event.target.closest("[data-location]");174 if (!button) return;175 const pin = state.featureByID.get(Number(button.dataset.location));176 if (pin) revealPin(pin);177 });178179 $("#close-detail").addEventListener("click", closeDetail);180 // One pair of buttons, whichever pane is showing: the chart steps its181 // zoom, the globe halves or doubles its distance.182 $("#zoom-in").addEventListener("click", () =>183 state.globeActive ? changeGlobeZoom(1) : changeZoom(1));184 $("#zoom-out").addEventListener("click", () =>185 state.globeActive ? changeGlobeZoom(-1) : changeZoom(-1));186 elements.gridBack.addEventListener("click", ascendGrid);187 elements.gridSystem.addEventListener("click", cycleGridSystem);188 elements.gridInput.addEventListener("input", () => {189 // The system says what the field keeps of a keystroke and when the190 // text has become a place: geohash goes somewhere on every character,191 // a token system waits until the address parses whole.192 const system = activeSystem();193 const value = system.normalizeInput(elements.gridInput.value);194 elements.gridInput.value = value;195 if (system.parseInput(value) !== null) selectGridCell(value);196 });197 elements.subgridToggle.addEventListener("click", () => {198 setSubgridVisible(!state.subgridVisible);199 elements.viewport.focus({ preventScroll: true });200 });201 // Escape leaves the field before it leaves the level: the first press hands202 // the keyboard back to the map, and the next one telescopes out as ever.203 // Space reaches the subgrid from inside the field too, since a hash has no204 // spaces in it and clearing the mesh is what is most wanted while typing one.205 elements.gridInput.addEventListener("keydown", (event) => {206 if (event.key === " ") {207 event.preventDefault();208 setSubgridVisible(!state.subgridVisible);209 return;210 }211 // The backquote flips panes from inside the field the way space works212 // the subgrid from it: no hash will ever spell either character.213 if (event.key === "`") {214 event.preventDefault();215 if (!elements.globeToggle.hidden) toggleGlobe();216 return;217 }218 if (event.key !== "Escape") return;219 event.preventDefault();220 event.stopPropagation();221 elements.viewport.focus({ preventScroll: true });222 });223224 elements.viewport.addEventListener("keydown", (event) => {225 if (isEditableTarget(event.target)) return;226 if (event.key === "+" || event.key === "=") changeZoom(1);227 else if (event.key === "-") changeZoom(-1);228 else if (event.key === "Escape") closeDetail();229 else return;230 event.preventDefault();231 });232 // The webview's own menu offers Reload and Inspect Element, which belong to a233 // browser rather than to a map. Text fields keep theirs, where cut and paste234 // are worth having.235 window.addEventListener("contextmenu", (event) => {236 if (isEditableTarget(event.target)) return;237 event.preventDefault();238 });239240 window.addEventListener("keydown", (event) => {241 // Reload keeps its usual shortcut now that the menu offering it is gone.242 // The session is restored on the way back, so the view returns as it was.243 if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "r") {244 event.preventDefault();245 location.reload();246 return;247 }248 // ⌘G cycles which cell system divides the map, carrying the chosen249 // place across at like precision. Above the editable-target guard: the250 // switch is most wanted mid-thought, right from the token field.251 if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "g") {252 event.preventDefault();253 cycleGridSystem();254 return;255 }256 if (isEditableTarget(event.target)) return;257 // The backquote flips between chart and globe and touches nothing else:258 // selection, filters, and each pane's own camera all stay where they259 // were. Only a map that declared itself a sphere answers it.260 if (event.key === "`" && !event.metaKey && !event.ctrlKey && !event.altKey) {261 if (!elements.globeToggle.hidden) {262 event.preventDefault();263 toggleGlobe();264 }265 return;266 }267 if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k") {268 event.preventDefault();269 elements.search.focus();270 elements.search.select();271 return;272 }273 // ⌘⌥B before ⌘B, and by physical key: Option rewrites event.key on a Mac274 // (⌥B types "∫"), and on Windows Ctrl+Alt+B leaves the key readable, so275 // the code is the one name the chord keeps everywhere. The right panel276 // answers the same shortcut editors give their secondary sidebar.277 if ((event.metaKey || event.ctrlKey) && event.altKey && event.code === "KeyB") {278 event.preventDefault();279 if (!elements.dock.hidden) foldDockByHand(!state.dockFolded);280 return;281 }282 // ⌘B on a Mac, Ctrl+B elsewhere: the usual shortcut for putting a sidebar283 // away, and the map is the reason the window is open.284 if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "b") {285 event.preventDefault();286 toggleSidebar();287 return;288 }289 if (handleGridKey(event)) return;290 // A key held down repeats, and every repeat after the first says what the291 // first one already said.292 if (event.key.toLocaleLowerCase() === "z") {293 event.preventDefault();294 if (!event.repeat) setLabelsHeld(true);295 }296 });297 window.addEventListener("keyup", (event) => {298 if (event.key.toLocaleLowerCase() === "z") setLabelsHeld(false);299 });300 // A window that loses focus with the key down never hears it come back up,301 // so ⌘-Tab away while reading the names and they would still be there on the302 // way back, with nothing holding them.303 window.addEventListener("blur", () => setLabelsHeld(false));304 window.addEventListener("hashchange", () => {305 const route = readRoute();306 if (route.volume === state.volume?.slug && route.world === state.world?.slug) return;307 // An address typed by hand names where to go, so it overrides what the308 // volume remembers.309 state.restore = null;310 void selectVolume(route.volume || state.catalog.volumes[0].slug, route.world);311 });312 window.addEventListener("resize", () => {313 state.engine?.updateSize();314 updateOverviewViewport();315 resizeGlobe();316 });317 // The globe reporting where it looks is the overview's cue to move its318 // reticle, the same way the chart's view changes are.319 document.addEventListener("atlas:globe-camera", () => updateOverviewViewport());320 // Holding the pointer down steers the map: a reader crossing the world drags321 // the locator there in one sweep instead of clicking their way across it.322 // Capturing the pointer keeps the sweep alive past the edge of the overview,323 // where the destination is simply the nearest point still on the map.324 elements.overview.addEventListener("pointerdown", (event) => {325 if (state.overviewDocked) return;326 if (event.pointerType === "mouse" && event.button !== 0) return;327 event.preventDefault();328 elements.overview.setPointerCapture(event.pointerId);329 state.overviewPointer = event.pointerId;330 overviewNavigate(event, true);331 });332 elements.overview.addEventListener("pointermove", (event) => {333 if (state.overviewPointer !== event.pointerId) return;334 event.preventDefault();335 overviewNavigate(event, false);336 });337 const releaseOverview = (event) => {338 if (state.overviewPointer !== event.pointerId) return;339 state.overviewPointer = null;340 settleView();341 };342 elements.overview.addEventListener("pointerup", releaseOverview);343 elements.overview.addEventListener("pointercancel", releaseOverview);344 elements.overviewDock.addEventListener("click", () => {345 setOverviewDocked(!state.overviewDocked);346 elements.viewport.focus({ preventScroll: true });347 });348 elements.mobileLegend.addEventListener("click", () => {349 const open = elements.sidebar.classList.toggle("is-open");350 elements.mobileLegend.setAttribute("aria-expanded", String(open));351 });352}