1import { tileTemplate } from "./data.js"; 2import { hostedNetworkKmlUrlFor, mapLibreSnippetFor } from "./take-map.js"; 3import { openingQuotes, openingQuoteGroups } from "./opening-quotes.js"; 4 5function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); } 6function pages(provenance) { if (!provenance?.printedPages) return ""; const [start, end] = provenance.printedPages; return start === end ? `book p. ${start}` : `book pp. ${start}â${end}`; } 7function sourceDetails(provenance, tile, relation, movementNote = null) { 8 const edge = relation?.edge; 9 return `<details class="data-details"><summary>source</summary>${provenance ? `<p>${escapeHtml(pages(provenance))} · ${escapeHtml(provenance.locator || "")}</p>` : ""}${edge ? `<p class="evidence">${escapeHtml(edge.relationType)} · ${escapeHtml(edge.assertion)} · ${escapeHtml(edge.evidence?.note || "")}</p>` : ""}${movementNote ? `<p class="evidence">${escapeHtml(movementNote)}</p>` : ""}${tile ? `<p><strong>Tiles:</strong> ${escapeHtml(tile.message)}<br><code>${escapeHtml(tile.url || tile.originalUrl || "No endpoint")}</code></p>` : ""}</details>`; 10} 11function pathButtons(links = []) { 12 return links.map((item) => `<button type="button" data-node="${escapeHtml(item.id)}">${escapeHtml(item.label)} <span aria-hidden="true">â</span></button>`).join(""); 13} 14function mapRecord(map) { 15 const record = map?.original?.sourceRecord || {}; 16 const fields = [ 17 ["Creator", record.creator], 18 ["Publisher", record.publisher], 19 ["Collection", record.collectionSource], 20 ["Scale", record.scale], 21 ["Projection", record.projection] 22 ].filter(([, value]) => value && value !== "N/ATEST"); 23 if (!fields.length) return ""; 24 return `<details class="map-record"><summary>map record</summary><dl>${fields.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl></details>`; 25} 26function strayCopy(lateral) { 27 if (!lateral) return ""; 28 const kind = String(lateral.kind || "book thread").replace(/-/g, " "); 29 const assertion = lateral.edge?.assertion === "book-explicit" ? "book-explicit" : lateral.edge?.assertion === "book-inferred" ? "book-inferred" : "editorial resonance"; 30 return `<button class="stray-path" type="button" data-node="${escapeHtml(lateral.id)}" aria-label="Stray through ${escapeHtml(lateral.label)}"><span class="stray-symbol" aria-hidden="true"><i></i><i></i><i></i></span><span class="stray-copy"><small>STRAY</small><strong>through ${escapeHtml(lateral.label)}</strong><em>${escapeHtml(kind)} · ${escapeHtml(assertion)}</em></span><span class="stray-arrow" aria-hidden="true">â</span></button>`; 31} 32 33function annotationCopy(annotations = [], visible, placing, simulated = false) { 34 const count = annotations.length; 35 const toggle = count 36 ? `<button class="annotation-summary" type="button" role="switch" data-annotations-toggle aria-checked="${visible ? "true" : "false"}" aria-label="Show map notes"><i aria-hidden="true"></i><span>NOTES <small>${count}</small></span><em>${visible ? "ON" : "OFF"}</em></button>` 37 : ""; 38 const open = count ? `<button class="annotation-open" type="button" data-annotations-open>READ NOTES â</button>` : ""; 39 const place = `<button class="annotation-switch${placing ? " is-active" : ""}" type="button" role="switch" data-annotation-place aria-checked="${placing ? "true" : "false"}"${simulated ? ' disabled title="Leave the simulation to add a real note"' : ""}><i class="note-glyph" aria-hidden="true"><b></b><b></b><b></b></i><span>${placing ? "PLACING A NOTE" : "ADD A NOTE"}</span><em aria-hidden="true">${simulated ? "SIMULATION" : placing ? "ON" : "OFF"}</em></button>`; 40 const instruction = placing ? `<p class="annotation-mode-prompt">Choose a point on this map to leave a note.</p>` : ""; 41 return { place, body: `${count ? `<div class="annotation-note-controls">${toggle}${open}</div>` : ""}${instruction}` }; 42} 43 44const BOOK_PAGE_COUNT = 212; 45// The source PDF begins with a few blank publication leaves. READ begins at 46// the printed table of contents, while the previous controls still expose the 47// complete scanned sequence. 48const BOOK_READ_START_PAGE = 5; 49const SVG_NS = "http://www.w3.org/2000/svg"; 50 51function randomBetween(min, max) { return min + Math.random() * (max - min); } 52function sourceQuoteText(text) { 53 // The extraction occasionally closes a word onto an opening or closing curly 54 // quote. Restore that missing typographic whitespace without changing words. 55 return String(text).replace(/([\p{L}\p{N}])â/gu, "$1 â").replace(/â([\p{L}\p{N}])/gu, "â $1"); 56} 57 58function meanderingPath(start, end) { 59 const dx = end.x - start.x; 60 const dy = end.y - start.y; 61 const length = Math.hypot(dx, dy) || 1; 62 const perpendicular = { x: -dy / length, y: dx / length }; 63 const points = [start]; 64 const firstTurn = Math.random() > 0.5 ? 1 : -1; 65 for (const [index, progress] of [0.16, 0.36, 0.58, 0.78, 0.9].entries()) { 66 // The map gives each route its direction; chance only perturbs the walk. 67 const turn = index % 2 === 0 ? firstTurn : -firstTurn; 68 const deviation = turn * randomBetween(0.38, 1) * Math.min(128, length * 0.24); 69 points.push({ 70 x: start.x + dx * progress + perpendicular.x * deviation, 71 y: start.y + dy * progress + perpendicular.y * deviation 72 }); 73 } 74 points.push(end); 75 return points.reduce((path, point, index) => { 76 if (index === 0) return `M ${point.x.toFixed(1)} ${point.y.toFixed(1)}`; 77 const previous = points[index - 1]; 78 if (index === points.length - 1) return `${path} Q ${previous.x.toFixed(1)} ${previous.y.toFixed(1)} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`; 79 const midpoint = { x: (previous.x + point.x) / 2, y: (previous.y + point.y) / 2 }; 80 return `${path} Q ${previous.x.toFixed(1)} ${previous.y.toFixed(1)} ${midpoint.x.toFixed(1)} ${midpoint.y.toFixed(1)}`; 81 }, ""); 82} 83 84export class Interface {
85 constructor(actions) { 86 this.actions = actions; 87 this.depth = document.querySelector("#depth-indicator"); 88 this.bookEntry = document.querySelector("#book-entry"); 89 this.bookEntryDismiss = document.querySelector("#book-entry-dismiss"); 90 this.bookEntryCopy = document.querySelector(".book-entry-copy"); 91 this.bookEntryQuote = document.querySelector("#book-entry-quote"); 92 this.bookEntryRead = document.querySelector("#book-entry-read"); 93 this.bookEntryNext = document.querySelector("#book-entry-next"); 94 this.bookEntryEnter = document.querySelector("#book-entry-enter"); 95 this.fieldPrompt = document.querySelector("#field-prompt"); 96 this.fieldEncounter = document.querySelector("#field-encounter"); 97 this.encounter = document.querySelector("#encounter"); 98 this.encounterContent = document.querySelector("#encounter-content"); 99 this.annotationFloat = document.querySelector("#annotation-float"); 100 this.takePrompt = document.querySelector("#take-prompt"); 101 this.takeMap = document.querySelector("#take-map-window"); 102 this.takeMapContent = document.querySelector("#take-map-window-content"); 103 this.takeMapReturnOnClose = false; 104 this.journey = document.querySelector("#journey"); 105 this.journeyContent = document.querySelector("#journey-content"); 106 this.journeyToggle = document.querySelector("#journey-toggle"); 107 this.touchNavigation = document.querySelector("#touch-navigation"); 108 this.touchState = null; 109 this.help = document.querySelector("#help-dialog"); 110 this.index = document.querySelector("#site-index"); 111 this.indexToggle = document.querySelector("#index-toggle"); 112 this.indexDrift = document.querySelector("#index-drift"); 113 this.surface = document.querySelector("#surface"); 114 this.groundToggle = document.querySelector("#ground-toggle"); 115 this.groundControl = document.querySelector("#ground-control"); 116 this.groundButtons = [...this.groundControl.querySelectorAll("[data-basemap]")]; 117 this.historicalOpacity = document.querySelector("#historical-opacity"); 118 this.historicalOpacityValue = document.querySelector("#historical-opacity-value"); 119 this.satelliteCredit = document.querySelector("#satellite-credit"); 120 this.googleCredit = document.querySelector("#google-credit"); 121 this.groundState = { mode: "dark", opacity: 1, hasHistorical: false }; 122 this.window = document.querySelector("#hyperbook-window"); 123 this.windowContent = document.querySelector("#hyperbook-window-content"); 124 this.bookReader = null; 125 this.bookEntryQuotes = openingQuotes; 126 this.currentBookEntryQuote = null; 127 this.renderBookEntryQuote(); 128 this.bookEntryDismiss.addEventListener("click", () => this.dismissBookEntry()); 129 this.bookEntryRead.addEventListener("click", () => this.openBookEntryQuote()); 130 this.bookEntryNext.addEventListener("click", () => this.renderBookEntryQuote(this.currentBookEntryQuote?.id)); 131 this.bookEntryEnter.addEventListener("click", () => this.dismissBookEntry()); 132 this.annotationFloat.addEventListener("click", (event) => { 133 const button = event.target.closest("[data-note-action]"); 134 if (!button) return; 135 const action = button.dataset.noteAction; 136 if (action === "close") this.actions.closeAnnotation(); 137 if (action === "change") this.actions.changeAnnotationPoint(); 138 if (action === "previous") this.actions.annotationStep(-1); 139 if (action === "next") this.actions.annotationStep(1); 140 }); 141 this.annotationFloat.addEventListener("submit", async (event) => { 142 if (!event.target.matches(".annotation-compose")) return; 143 event.preventDefault(); 144 const form = event.target; 145 const fields = new FormData(form); 146 const values = { 147 text: String(fields.get(form.querySelector("textarea").name) || ""), 148 name: String(fields.get(form.querySelector('input[required]').name) || ""), 149 tag: String(fields.get(form.querySelector('input[placeholder]').name) || "") 150 }; 151 const submit = form.querySelector('button[type="submit"]'); 152 submit.disabled = true; 153 submit.textContent = "SENDINGâ¦";
154 try { 155 // A simple cross-origin form POST can be sent from a static site. 156 // The opaque response cannot confirm publication; the Sheet poll does. 157 await fetch(form.action, { 158 method: "POST", mode: "no-cors", credentials: "omit", 159 body: new URLSearchParams(fields) 160 }); 161 this.actions.confirmAnnotation(values); 162 } catch (error) { 163 submit.disabled = false; 164 submit.textContent = "PUBLISH NOTE"; 165 let status = form.querySelector(".annotation-submit-error"); 166 if (!status) { 167 status = document.createElement("p"); 168 status.className = "annotation-compose-help annotation-submit-error"; 169 status.setAttribute("role", "alert"); 170 form.append(status); 171 } 172 status.textContent = "The note could not be sent. Please try again."; 173 console.warn("Annotation submission failed.", error); 174 } 175 }); 176 this.groundToggle.addEventListener("click", () => this.toggleGround()); 177 this.groundButtons.forEach((button) => button.addEventListener("click", () => this.actions.basemap(button.dataset.basemap))); 178 this.historicalOpacity.addEventListener("input", () => this.actions.rasterOpacity(Number(this.historicalOpacity.value) / 100)); 179 document.addEventListener("pointerdown", (event) => { 180 if (this.groundControl.hidden || this.groundControl.contains(event.target) || this.groundToggle.contains(event.target)) return; 181 this.closeGround(); 182 }); 183 document.querySelector("#help-toggle").addEventListener("click", () => this.help.showModal()); 184 document.querySelector("#help-close").addEventListener("click", () => this.help.close()); 185 this.indexToggle.addEventListener("click", () => { 186 // Some embedded browsers replay the opener's click when a fullscreen 187 // layer takes focus. Keep that replay from immediately closing Explore. 188 const now = performance.now(); 189 const previous = Number(this.indexToggle.dataset.lastToggleAt || 0); 190 if (now - previous < 300) return; 191 this.indexToggle.dataset.lastToggleAt = String(now); 192 this.toggleIndex(); 193 }); 194 document.querySelector("#index-close").addEventListener("click", (event) => { 195 event.preventDefault(); 196 event.stopPropagation(); 197 this.closeIndexAfterPointer(); 198 }); 199 this.index.querySelector("[data-index-field]").addEventListener("click", (event) => { 200 event.preventDefault(); 201 event.stopPropagation(); 202 this.closeIndexAfterPointer(() => this.actions.surface()); 203 }); 204 this.index.querySelector("[data-index-take]").addEventListener("click", (event) => { 205 event.preventDefault(); 206 event.stopPropagation(); 207 this.closeIndexAfterPointer(() => this.actions.take()); 208 }); 209 this.index.querySelector("[data-index-read]").addEventListener("click", (event) => { 210 event.preventDefault(); 211 event.stopPropagation(); 212 this.closeIndexAfterPointer(() => this.actions.read()); 213 }); 214 document.querySelector("#hyperbook-window-close").addEventListener("click", () => this.window.close()); 215 document.querySelector("#take-map-window-close").addEventListener("click", () => this.closeTakeMap(true)); 216 // Native dialogs already close on Escape. Treat a click on the backdrop as 217 // the same gentle return to the dérive, while preserving clicks inside the 218 // page itself for selection, scrolling, and inspection. 219 this.window.addEventListener("click", (event) => { 220 if (event.target !== this.window) return; 221 const bounds = this.window.getBoundingClientRect(); 222 const outside = event.clientX < bounds.left || event.clientX > bounds.right 223 || event.clientY < bounds.top || event.clientY > bounds.bottom; 224 if (outside) this.window.close(); 225 }); 226 this.window.addEventListener("close", () => { 227 this.window.classList.remove("is-book-page"); 228 this.window.classList.remove("is-book-spread"); 229 this.bookReader = null; 230 }); 231 this.takeMap.addEventListener("cancel", () => { this.takeMapReturnOnClose = true; }); 232 this.takeMap.addEventListener("click", (event) => { 233 if (event.target !== this.takeMap) return; 234 const bounds = this.takeMap.getBoundingClientRect(); 235 const outside = event.clientX < bounds.left || event.clientX > bounds.right 236 || event.clientY < bounds.top || event.clientY > bounds.bottom; 237 if (outside) this.closeTakeMap(true); 238 }); 239 this.takeMap.addEventListener("close", () => { 240 const resume = this.takeMapReturnOnClose; 241 this.takeMapReturnOnClose = false;
242 this.takeMapContent.replaceChildren(); 243 if (resume) this.actions.resumeTake(); 244 }); 245 document.addEventListener("keydown", (event) => { 246 if (!this.bookEntry.hidden) { 247 if (this.bookEntryCopy.contains(document.activeElement) && ["Enter", " "].includes(event.key)) return; 248 if (["Enter", " ", "Escape"].includes(event.key)) { 249 event.preventDefault(); 250 event.stopImmediatePropagation(); 251 this.dismissBookEntry(); 252 } 253 return; 254 } 255 if (!this.index.hidden) { 256 if (event.key === "Escape") { 257 event.preventDefault(); 258 event.stopImmediatePropagation(); 259 this.closeIndex(); 260 } 261 return; 262 } 263 if (!this.groundControl.hidden && event.key === "Escape") { 264 event.preventDefault(); 265 event.stopImmediatePropagation(); 266 this.closeGround(); 267 return; 268 } 269 if (!this.window.open || !this.bookReader) return; 270 if (event.key === "ArrowLeft") { 271 event.preventDefault(); 272 this.turnBookPage(-1); 273 } else if (event.key === "ArrowRight") { 274 event.preventDefault(); 275 this.turnBookPage(1); 276 } 277 }); 278 this.surface.addEventListener("click", () => this.actions.surface()); 279 this.journeyToggle.addEventListener("click", () => { 280 const open = this.journeyContent.hidden; 281 this.journeyContent.hidden = !open; 282 this.journeyToggle.setAttribute("aria-expanded", String(open)); 283 }); 284 this.touchNavigation.addEventListener("click", (event) => { 285 const button = event.target.closest("button[data-touch-move]"); 286 if (!button || button.disabled) return; 287 event.preventDefault(); 288 event.stopPropagation(); 289 const move = button.dataset.touchMove; 290 if (move === "newer") this.actions.time("newer"); 291 if (move === "older") this.actions.time("older"); 292 if (move === "stray" && this.touchState?.lateral) this.actions.stray(this.touchState.lateral.id); 293 if (move === "back") this.actions.back(); 294 }); 295 window.addEventListener("resize", () => { 296 if (this.window.open && this.bookReader) { 297 const shouldSpread = this.shouldUseBookSpread(); 298 if (shouldSpread !== this.bookReader.isSpread) this.renderBookPage(); 299 } 300 this.renderTouchNavigation(); 301 }); 302 } 303 304 bookEntryIsVisible() { 305 return !this.bookEntry.hidden; 306 } 307 308 toggleGround() { 309 if (this.groundState.hasHistorical) { 310 this.showGroundForMap(); 311 return; 312 } 313 if (this.groundControl.hidden) { 314 this.groundControl.hidden = false; 315 this.groundToggle.setAttribute("aria-expanded", "true"); 316 return; 317 } 318 this.closeGround(); 319 } 320 321 closeGround(force = false) { 322 if (this.groundState.hasHistorical && !force) return; 323 this.groundControl.hidden = true; 324 this.groundToggle.setAttribute("aria-expanded", "false"); 325 } 326 327 showGroundForMap() { 328 if (!this.groundState.hasHistorical) return; 329 this.groundControl.hidden = false; 330 this.groundToggle.setAttribute("aria-expanded", "true"); 331 } 332 333 setGroundState(next = {}) { 334 this.groundState = { ...this.groundState, ...next }; 335 const { mode, opacity, hasHistorical } = this.groundState; 336 this.groundButtons.forEach((button) => { 337 const selected = button.dataset.basemap === mode; 338 button.setAttribute("aria-pressed", String(selected)); 339 }); 340 this.groundControl.dataset.mode = mode; 341 this.historicalOpacity.value = String(Math.round(opacity * 100)); 342 this.historicalOpacity.disabled = !hasHistorical; 343 this.historicalOpacityValue.textContent = hasHistorical ? `${Math.round(opacity * 100)}%` : "â"; 344 this.satelliteCredit.hidden = mode !== "satellite"; 345 this.googleCredit.hidden = mode !== "google"; 346 } 347 348 setBookEntryQuotes(data) { 349 const quoteByNumber = new Map([...data.objects.values()] 350 .filter((object) => object.kind === "quotation") 351 .map((quote) => [Number(quote.id.slice(6, 9)), quote])); 352 const quotes = openingQuoteGroups 353 .flatMap((group) => group.numbers.map((number) => quoteByNumber.get(number))) 354 .filter((quote) => quote?.kind === "quotation" && quote.provenance?.printedPages?.[0]) 355 .map((quote) => ({ 356 id: quote.id, 357 page: quote.provenance.printedPages[0],
358 text: sourceQuoteText(quote.text), 359 section: quote.provenance.sectionId, 360 pageLocation: data.bookPageLocations?.[quote.id] || null 361 })); 362 if (!quotes.length) return; 363 this.bookEntryQuotes = quotes; 364 this.renderBookEntryQuote(this.currentBookEntryQuote?.id); 365 } 366 367 renderBookEntryQuote(avoidId = null) { 368 let recent = []; 369 try { recent = JSON.parse(sessionStorage.getItem("hypercities-book-entry-recent") || "[]"); } catch { /* file origins may deny storage */ } 370 if (!Array.isArray(recent)) recent = []; 371 const currentSection = this.currentBookEntryQuote?.section; 372 const fresh = this.bookEntryQuotes.filter((quote) => !recent.includes(quote.id) && quote.id !== avoidId); 373 const differentSection = fresh.filter((quote) => quote.section !== currentSection); 374 const choices = differentSection.length ? differentSection : fresh.length ? fresh : this.bookEntryQuotes.filter((quote) => quote.id !== avoidId); 375 // Give each part of the book an equal opening, even when one chapter has 376 // many more quotable passages than a Window or the Tohoku gallery. 377 const sections = [...new Set(choices.map((candidate) => candidate.section))]; 378 const section = sections[Math.floor(Math.random() * sections.length)]; 379 const inSection = choices.filter((candidate) => candidate.section === section); 380 const quote = inSection[Math.floor(Math.random() * inSection.length)] || this.bookEntryQuotes[0]; 381 try { sessionStorage.setItem("hypercities-book-entry-recent", JSON.stringify([...recent, quote.id].slice(-12))); } catch { /* the current quotation is still usable */ } 382 this.currentBookEntryQuote = quote; 383 this.bookEntryCopy.dataset.sourceQuote = quote.id; 384 this.bookEntryQuote.textContent = `â${sourceQuoteText(quote.text)}â`; 385 this.bookEntryRead.innerHTML = `${quote.page} <span aria-hidden="true">â</span>`; 386 this.bookEntryRead.setAttribute("aria-label", `Open page ${quote.page} in READ`); 387 } 388 389 openBookEntryQuote() { 390 const quote = this.currentBookEntryQuote; 391 if (!quote) return; 392 this.openRead(quote.page, { id: quote.id, page: quote.page, pageImage: true, pageLocation: quote.pageLocation }); 393 } 394 395 dismissBookEntry() { 396 if (this.bookEntry.hidden || this.bookEntry.classList.contains("is-dismissing")) return; 397 this.bookEntry.classList.add("is-dismissing"); 398 this.bookEntryDismiss.tabIndex = -1; 399 // Keep the quotation in the document until its fade completes, then make 400 // the map the only active surface again. A first click opens the field; 401 // it does not silently turn into a core at an accidental coordinate. 402 window.setTimeout(() => { 403 this.bookEntry.hidden = true; 404 this.bookEntry.setAttribute("aria-hidden", "true"); 405 }, 720); 406 } 407 408 toggleIndex() { 409 // Explore is an opener, not a toggle: its own close mark and Escape are 410 // the stable ways back. This also makes an opener-click replay harmless. 411 if (!this.index.hidden) return; 412 // The map-return action belongs to the TimeWell state underneath. Hide it 413 // while the Explore overlay is open so the two layers never compete. 414 if (!("restoreSurface" in this.index.dataset)) { 415 this.index.dataset.restoreSurface = String(!this.surface.hidden); 416 } 417 this.surface.hidden = true; 418 this.index.hidden = false; 419 document.body.classList.add("explore-open"); 420 this.indexToggle.setAttribute("aria-expanded", "true"); 421 this.drawIndexDrift(); 422 this.index.focus({ preventScroll: true }); 423 } 424 425 closeIndex() { 426 if (this.index.hidden) return; 427 this.index.hidden = true; 428 document.body.classList.remove("explore-open"); 429 this.indexToggle.setAttribute("aria-expanded", "false"); 430 this.indexDrift.replaceChildren(); 431 this.surface.hidden = this.index.dataset.restoreSurface !== "true"; 432 delete this.index.dataset.restoreSurface; 433 this.indexToggle.focus({ preventScroll: true }); 434 } 435 436 closeIndexAfterPointer(then = null) { 437 // Keep the full-screen layer present until the pointer sequence ends; 438 // otherwise a close click can land on the map beneath it. 439 setTimeout(() => { 440 this.closeIndex(); 441 then?.(); 442 }, 220); 443 } 444 445 drawIndexDrift() { 446 const field = this.index.querySelector("[data-index-field]"); 447 const destinations = [...this.index.querySelectorAll(".index-route--future")]; 448 if (!field || !destinations.length) return; 449 const from = field.getBoundingClientRect(); 450 const target = destinations[Math.floor(Math.random() * destinations.length)].getBoundingClientRect(); 451 const start = { x: from.right + 20, y: from.top + from.height * randomBetween(0.25, 0.75) }; 452 const end = { x: target.left - 12, y: target.top + target.height * randomBetween(0.26, 0.72) }; 453 this.indexDrift.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); 454 this.indexDrift.replaceChildren(); 455 const path = document.createElementNS(SVG_NS, "path"); 456 path.setAttribute("d", meanderingPath(start, end)); 457 const arrival = document.createElementNS(SVG_NS, "circle"); 458 arrival.setAttribute("cx", end.x.toFixed(1)); 459 arrival.setAttribute("cy", end.y.toFixed(1)); 460 arrival.setAttribute("r", "2.4"); 461 this.indexDrift.append(path, arrival); 462 } 463 464 showDepth() { this.depth.hidden = true; } 465 466 field() { 467 this.depth.hidden = true; 468 this.surface.hidden = true; 469 this.fieldPrompt.hidden = true; 470 this.encounter.hidden = true; 471 this.journey.hidden = true; 472 this.clearFieldEncounter(); 473 this.takePrompt.hidden = true; 474 this.touchState = null; 475 this.touchNavigation.hidden = true; 476 this.closeTakeMap(false); 477 } 478 479 lockedCore() { 480 this.depth.hidden = true; 481 this.surface.hidden = false;
482 this.fieldPrompt.hidden = true; 483 this.encounter.hidden = true; 484 this.journey.hidden = true; 485 this.clearFieldEncounter(); 486 this.takePrompt.hidden = true; 487 this.touchNavigation.hidden = true; 488 this.closeTakeMap(false); 489 } 490 491 beginTakeMap(map) { 492 this.closeTakeMap(false); 493 this.takePrompt.hidden = Boolean(map); 494 } 495 496 clearTakeMap() { 497 this.takePrompt.hidden = true; 498 this.closeTakeMap(false); 499 } 500 501 openTakeMap(map) { 502 const source = map.original?.sourceRecord || {}; 503 const years = map.endYear === map.year ? map.year : `${map.year}â${map.endYear}`; 504 const collection = source.collectionSource || "Collection not recorded"; 505 const creator = source.creator ? `${source.creator}. ` : ""; 506 const rights = source.copyright 507 ? `Recorded rights statement: ${source.copyright}` 508 : "No rights statement is recorded here. The original raster remains with its source."; 509 const template = tileTemplate(map); 510 const qgis = template 511 ? `<p class="take-platform-kicker">Desktop map / QGIS</p><h2>Open this raster in QGIS</h2><ol><li>Open the <strong>Browser</strong> panel. Right-click <strong>XYZ Tiles</strong> and choose <strong>New Connectionâ¦</strong>.</li><li>Name it after this map and paste the template below as the URL.</li><li>Double-click the new connection to add it to the project.</li></ol><code>${escapeHtml(template)}</code><p class="take-platform-note">If the layer does not draw, the external source has not accepted the connection. The original source's access terms still apply.</p>` 512 : `<p class="take-platform-kicker">Desktop map / QGIS</p><h2>No tile connection recorded</h2><p>This map can still be located through its citation, but this record has no URL that QGIS can open as a raster layer.</p>`; 513 const previewUrl = `./take-map-preview.html?map=${encodeURIComponent(map.id)}`; 514 const snippet = mapLibreSnippetFor(map); 515 const networkKmlUrl = hostedNetworkKmlUrlFor(map); 516 const kmlPanel = `<section class="take-tab-panel take-platform" id="take-panel-kml" role="tabpanel" aria-labelledby="take-tab-kml" data-take-tab-panel="kml" hidden> 517 <p class="take-platform-kicker">KML / Google Earth</p> 518 <h2>Keep a live link to this map in Google Earth</h2> 519 <ol class="take-network-steps"> 520 <li>Download the KML and open it with <strong>File â Openâ¦</strong>
520 in Google Earth Pro.</li> 521 <li>Or choose <strong>Add â Network Linkâ¦</strong>, give it a name, paste this link, and select <strong>OK</strong>.</li> 522 </ol> 523 <div class="take-network-url"><code>${escapeHtml(networkKmlUrl)}</code><button class="take-code-copy" type="button" data-copy-network-link>Copy link</button></div> 524 <button class="take-download" type="button" data-map-export="kml"><span>DOWNLOAD NETWORK-LINK KML</span><span>.kml â</span></button> 525 <p class="take-platform-note">The download draws the recorded historical raster as a geographic overview and keeps a live link to the hosted HyperCities record, refreshed hourly. Use the MapLibre or QGIS routes for full-resolution interactive tiles.</p> 526 </section>`; 527 this.takePrompt.hidden = true; 528 this.encounter.hidden = true; 529 this.takeMapContent.innerHTML = `<div class="take-map-sticky"><header class="take-map-header"><p class="window-kind">Take a map · map ${escapeHtml(map.sourceId)}</p><h1>${escapeHtml(map.title)}</h1><p>${escapeHtml(map.city)} · ${escapeHtml(years)}</p><p class="take-map-source">${escapeHtml(creator)}${escapeHtml(collection)}</p></header><nav class="take-map-tabs" role="tablist" aria-label="Ways to take this map"><button type="button" role="tab" id="take-tab-web" aria-selected="true" aria-controls="take-panel-web" data-take-tab="web">MapLibre</button><button type="button" role="tab" id="take-tab-kml" aria-selected="false" aria-controls="take-panel-kml" data-take-tab="kml">KML / Google Earth</button><button type="button" role="tab" id="take-tab-qgis" aria-selected="false" aria-controls="take-panel-qgis" data-take-tab="qgis">QGIS</button></nav></div><section class="take-tab-panel take-web-map" id="take-panel-web" role="tabpanel" aria-labelledby="take-tab-web" data-take-tab-panel="web"><p class="take-platform-kicker">Live web map / MapLibre</p><h2>Carry the original tile connection into a small web map</h2><iframe title="Live MapLibre map: ${escapeHtml(map.title)}" src="${escapeHtml(previewUrl)}"></iframe><p class="take-platform-note">This is a live MapLibre map: pan and zoom inside it. It uses the same recorded tile connection as HyperCities; an external source may still decline to load in a browser.</p><details class="take-code" open><summary>Complete MapLibre page</summary><div class="take-code-intro"><p>Copy this complete HTML page. It includes the document, head, body, MapLibre setup, bounds, zoom range, citation, and this map's source URL.</p><button class="take-code-copy" type="button" data-copy-map-code>Copy code</button></div><pre><code>${escapeHtml(snippet)}</code></pre></details></section>${kmlPanel}<section class="take-tab-panel take-platform" id="take-panel-qgis" role="tabpanel" aria-labelledby="take-tab-qgis" data-take-tab-panel="qgis" hidden>${qgis}</section><section class="take-source-note"><p class="take-platform-kicker">Source condition</p><p>${escapeHtml(rights)}</p></section>`; 530 this.takeMapContent.querySelectorAll("[data-map-export]").forEach((button) => button.addEventListener("click", () => this.actions.exportMap(button.dataset.mapExport))); 531 this.takeMapContent.querySelectorAll("[data-take-tab]").forEach((button) => button.addEventListener("click", () => this.activateTakeMapTab(button.dataset.takeTab))); 532 this.takeMapContent.querySelector("[data-copy-map-code]")?.addEventListener("click", (event) => this.copyTakeMapCode(snippet, event.currentTarget)); 533 this.takeMapContent.querySelector("[data-copy-network-link]")?.addEventListener("click", (event) => this.copyTakeMapCode(networkKmlUrl, event.currentTarget)); 534 this.takeMapReturnOnClose = true; 535 this.takeMap.showModal(); 536 this.takeMap.scrollTop = 0; 537 } 538 539 activateTakeMapTab(tab) { 540 this.takeMapContent.querySelectorAll("[data-take-tab]").forEach((button) => { 541 button.setAttribute("aria-selected", String(button.dataset.takeTab === tab)); 542 }); 543 this.takeMapContent.querySelectorAll("[data-take-tab-panel]").forEach((panel) => { 544 panel.hidden = panel.dataset.takeTabPanel !== tab; 545 }); 546 } 547 548 async copyTakeMapCode(code, button) { 549 const originalLabel = button.textContent; 550 try { 551 await navigator.clipboard.writeText(code); 552 } catch { 553 const fallback = document.createElement("textarea"); 554 fallback.value = code; 555 fallback.setAttribute("readonly", ""); 556 fallback.style.position = "fixed"; 557 fallback.style.opacity = "0"; 558 document.body.append(fallback); 559 fallback.select(); 560 const copied = document.execCommand("copy"); 561 fallback.remove(); 562 if (!copied) return; 563 } 564 button.textContent = "Copied"; 565 setTimeout(() => { 566 if (document.body.contains(button)) button.textContent = originalLabel; 567 }, 1500); 568 } 569 570 closeTakeMap(resume = false) { 571 if (!this.takeMap.open) return; 572 this.takeMapReturnOnClose = resume; 573 this.takeMap.close(); 574 } 575 576 showFieldEncounter(fragment, point) { 577 if (!fragment) { this.clearFieldEncounter(); return; } 578 const left = Math.max(30, Math.min(window.innerWidth - 350, point.x + 34)); 579 const top = Math.max(76, Math.min(window.innerHeight - 160, point.y - 56)); 580 this.fieldEncounter.hidden = false;
581 this.fieldEncounter.style.left = `${left}px`; 582 this.fieldEncounter.style.top = `${top}px`; 583 // Before a core is locked this is a passing voice in the field, never a 584 // control that can steal the click/tap needed to excavate that place. 585 const content = fragment.kind === "quotation" 586 ? `<p class="field-fragment-quote">â${escapeHtml(fragment.text)}â</p>` 587 : `<p class="field-fragment-name">${escapeHtml(fragment.text)}</p>`; 588 this.fieldEncounter.innerHTML = `${content}<p>${escapeHtml(fragment.arrival?.label || fragment.source)}</p>`; 589 } 590 591 clearFieldEncounter() { 592 this.fieldEncounter.hidden = true; 593 this.fieldEncounter.replaceChildren(); 594 } 595 596 positionAnnotationFloat(point) { 597 if (!point || this.annotationFloat.hidden) return; 598 if (window.matchMedia("(max-width: 780px)").matches) { 599 Object.assign(this.annotationFloat.style, { left: "16px", top: "auto", bottom: "74px" }); 600 return; 601 } 602 const width = Math.min(340, window.innerWidth - 36); 603 const left = Math.max(18, Math.min(point.x + 24, window.innerWidth - width - 18)); 604 const besidePoint = point.y > window.innerHeight * .42 ? point.y - 210 : point.y + 22; 605 const composing = this.annotationFloat.classList.contains("is-composing"); 606 const availableHeight = Math.min(composing ? 560 : 430, window.innerHeight - 100); 607 const top = Math.max(70, Math.min(besidePoint, window.innerHeight - availableHeight - 20)); 608 Object.assign(this.annotationFloat.style, { left: `${left}px`, top: `${top}px`, bottom: "auto" }); 609 } 610 611 showAnnotationNote({ annotation, index, count, point, map }) { 612 this.annotationFloat.classList.remove("is-composing"); 613 const tags = (annotation.tags || (annotation.tag ? annotation.tag.split(",") : [])).map((tag) => tag.trim().replace(/^#+/, "")).filter(Boolean); 614 const tagCopy = tags.map((tag) => `<span class="annotation-float-tag">#${escapeHtml(tag)}</span>`).join(" "); 615 const byline = annotation.name ? `<span class="annotation-float-byline">${escapeHtml(annotation.name)}</span>` : ""; 616 const status = annotation.unconfirmed ? "NOT YET PUBLIC · " : annotation.pending ? "PUBLISHING · " : annotation.simulated ? "SIMULATION · " : ""; 617 const pendingHint = annotation.unconfirmed ? `<p class="annotation-compose-help">This note has not appeared in the public feed. Please check again later before resubmitting.</p>` : ""; 618 this.annotationFloat.innerHTML = `<div class="annotation-float-head"><span class="note-glyph" aria-hidden="true"><b></b><b></b><b></b></span><span>${status}NOTE ${index + 1} OF ${count}</span><button type="button" data-note-action="close" aria-label="Close annotation">Ã</button></div><p class="annotation-float-map">${escapeHtml(map.city)} · ${map.year}</p><blockquote>${escapeHtml(annotation.text)}</blockquote>${byline}${pendingHint}<div class="annotation-float-foot"><span>${tagCopy}</span><nav aria-label="Move through annotations"><button type="button" data-note-action="previous" aria-label="Previous annotation"${count < 2 ? " disabled" : ""}>â</button><button type="button" data-note-action="next" aria-label="Next annotation"${count < 2 ? " disabled" : ""}>â</button></nav></div>`; 619 this.annotationFloat.hidden = false; 620 this.positionAnnotationFloat(point); 621 } 622 623 showAnnotationDraft({ point, screenPoint, map, submission }) { 624 this.annotationFloat.classList.add("is-composing"); 625 const { entries } = submission; 626 this.annotationFloat.innerHTML = `<div class="annotation-float-head"><span class="note-glyph" aria-hidden="true"><b></b><b></b><b></b></span><span>LEAVE A NOTE</span><button type="button" data-note-action="close" aria-label="Cancel annotation">Ã</button></div><p class="annotation-float-map">${escapeHtml(map.city)} · ${map.year}</p><p class="annotation-float-title">${escapeHtml(map.title)}</p><p class="annotation-float-coordinate">${Math.abs(point.lat).toFixed(4)}° ${point.lat < 0 ? "S" : "N"} · ${Math.abs(point.lng).toFixed(4)}° ${point.lng < 0 ? "W" : "E"}</p><form class="annotation-compose" action="${escapeHtml(submission.action)}" method="POST"><input type="hidden" name="${escapeHtml(entries.context)}" value="${escapeHtml(submission.context)}"><label>Your note<textarea name="${escapeHtml(entries.annotation)}" maxlength="1200" required></textarea></label><label>Name or alias<input name="${escapeHtml(entries.name)}" maxlength="80" required></label><p class="annotation-compose-help">Displayed publicly with your note; not a verified identity.</p><label>Tags <span>(optional)</span><input name="${escapeHtml(entries.tag)}" maxlength="80" placeholder="memory, railway, neighborhood"></label><p class="annotation-compose-help">Separate multiple tags with commas.</p><div class="annotation-float-foot"><button type="button" data-note-action="change">CHOOSE AGAIN</button><button type="submit">PUBLISH NOTE</button></div></form>`; 627 this.annotationFloat.hidden = false;
628 this.positionAnnotationFloat(screenPoint); 629 } 630 631 hideAnnotationFloat() { 632 this.annotationFloat.classList.remove("is-composing"); 633 this.annotationFloat.hidden = true; 634 this.annotationFloat.replaceChildren(); 635 } 636 637 annotationPlacementHint(message) { 638 const prompt = this.encounterContent.querySelector(".annotation-mode-prompt"); 639 if (prompt) prompt.textContent = message; 640 } 641 642 renderCore({ map, coreMaps, encounter, tile, lateral, drift, annotations = [], annotationsVisible = true, annotationMode = false, annotationCompact = false, annotationOpen = false, annotationSimulation = false }) { 643 this.depth.hidden = true; 644 this.fieldPrompt.hidden = true; 645 this.touchState = { map, coreMaps, lateral }; 646 this.renderTouchNavigation(); 647 this.encounter.hidden = !encounter; 648 if (!encounter) return; 649 const fragment = encounter.fragment || encounter.quote || null; 650 const title = fragment && fragment.kind !== "quotation" 651 ? `<button class="fragment-name" type="button" data-aperture="${escapeHtml(fragment.apertureId)}" aria-label="Open the cited book page for ${escapeHtml(fragment.text)}"><span>${escapeHtml(fragment.text)}</span><span class="quote-aperture" aria-hidden="true">read â</span></button>` 652 : encounter.title && !fragment ? `<button class="fragment-name" type="button" data-aperture="${escapeHtml(encounter.id)}" aria-label="Open the cited book page for ${escapeHtml(encounter.title)}"><span>${escapeHtml(encounter.title)}</span><span class="quote-aperture" aria-hidden="true">read â</span></button>` : ""; 653 const quote = fragment?.kind === "quotation" ? `<button class="fragment-quote" type="button" data-aperture="${escapeHtml(fragment.apertureId)}" aria-label="Open the cited book page for this quotation"><span class="quote-text">â${escapeHtml(fragment.text)}â</span><span class="quote-aperture" aria-hidden="true">read â</span></button>` : ""; 654 // The offer shown here is exactly the one accepted by keyboard/touch 655 // STRAY. No movement should depend on an invisible alternative link. 656 const offer = !drift ? strayCopy(lateral) : ""; 657 const driftOffer = drift ? `<button class="stray-path drift-path" type="button" data-drift="${escapeHtml(drift.map.id)}"><span class="stray-symbol" aria-hidden="true"><i></i><i></i><i></i></span><span class="stray-copy"><small>DRIFT</small><strong>elsewhere, ${escapeHtml(drift.map.year)}</strong><em>temporal juxtaposition · editorial</em></span><span class="stray-arrow" aria-hidden="true">â</span></button>` : ""; 658 // This is an archival action on the selected object, not another direction 659 // in the dérive. It stays with the map identity rather than competing with 660 // the book movement and the visitor's trace. 661 const takeOffer = `<button class="take-map-quiet" type="button" data-take-map><i aria-hidden="true"></i><span>take map</span></button>`; 662 const notes = annotationCopy(annotations, annotationsVisible, annotationMode, annotationSimulation); 663 this.surface.hidden = false; 664 this.encounter.classList.toggle("is-note-mode", annotationCompact || (annotationsVisible && annotations.length > 0)); 665 this.encounter.classList.toggle("is-note-open", annotationOpen); 666 const arrival = fragment?.arrival?.label || "a thread in the book"; 667 this.encounterContent.innerHTML = `<div class="encounter-flow"><section class="encounter-stage map-stage"><p class="stage-kicker">selected map</p><p class="map-marker">${escapeHtml(map.city)} · ${map.year}</p><div class="map-title-row"><h2>${escapeHtml(map.title)}</h2><span class="map-quiet-actions">${takeOffer}</span></div><div class="map-annotations">${notes.place}${notes.body}</div>${mapRecord(map)}</section><section class="encounter-stage book-stage"><p class="stage-kicker">the book enters <span>${escapeHtml(arrival)}</span></p>${title || quote}</section>${driftOffer || offer ? `<section class="encounter-stage stray-stage">${driftOffer || offer}</section>` : ""}</div>`; 668 this.bindTraversal(this.encounterContent); 669 } 670 671 renderTouchNavigation() { 672 const { map, coreMaps, lateral } = this.touchState || {}; 673 const isTouchLayout = window.matchMedia("(max-width: 780px)").matches; 674 if (!isTouchLayout || !map || !coreMaps?.length) { 675 this.touchNavigation.hidden = true; 676 return; 677 } 678 const index = coreMaps.findIndex((candidate) => candidate.id === map.id); 679 const newer = coreMaps[index + 1] || null; 680 const older = coreMaps[index - 1] || null; 681 const laterButton = this.touchNavigation.querySelector('[data-touch-move="newer"]'); 682 const earlierButton = this.touchNavigation.querySelector('[data-touch-move="older"]'); 683 const strayButton = this.touchNavigation.querySelector('[data-touch-move="stray"]');
684 laterButton.disabled = !newer; 685 laterButton.setAttribute("aria-label", newer ? `Move to later map, ${newer.year}` : "No later map in this TimeWell"); 686 earlierButton.disabled = !older; 687 earlierButton.setAttribute("aria-label", older ? `Move to earlier map, ${older.year}` : "No earlier map in this TimeWell"); 688 strayButton.hidden = !lateral; 689 if (lateral) strayButton.setAttribute("aria-label", `Stray toward ${lateral.label}`); 690 this.touchNavigation.hidden = false; 691 } 692 693 bindTraversal(element) { 694 element.querySelectorAll("[data-node]").forEach((button) => button.addEventListener("click", () => this.actions.node(button.dataset.node))); 695 element.querySelectorAll("[data-aperture]").forEach((button) => button.addEventListener("click", () => this.actions.aperture(button.dataset.aperture))); 696 element.querySelectorAll("[data-drift]").forEach((button) => button.addEventListener("click", () => this.actions.drift(button.dataset.drift))); 697 element.querySelectorAll("[data-take-map]").forEach((button) => button.addEventListener("click", () => this.actions.takeSelected())); 698 element.querySelectorAll("[data-annotations-toggle]").forEach((button) => button.addEventListener("click", () => this.actions.annotations())); 699 element.querySelectorAll("[data-annotations-open]").forEach((button) => button.addEventListener("click", () => this.actions.openAnnotations())); 700 element.querySelectorAll("[data-annotation-place]").forEach((button) => button.addEventListener("click", () => this.actions.annotate())); 701 element.querySelectorAll("[data-annotation-step]").forEach((button) => button.addEventListener("click", () => this.actions.annotationStep(Number(button.dataset.annotationStep)))); 702 } 703 704 openAperture(aperture) { 705 if (!aperture) return; 706 const figure = aperture.figure ? `<p class="window-figure"><span>Figure</span>${escapeHtml(aperture.figure.title)}${aperture.figure.caption ? `<small>${escapeHtml(aperture.figure.caption)}</small>` : ""}</p>` : ""; 707 const bookPage = Boolean(aperture.pageImage); 708 if (bookPage) { 709 // An encounter is a citation into READ, never a separate modal mode. 710 // Quote locators are only shown on the exact source page. 711 this.openRead(aperture.page, aperture); 712 } else { 713 this.window.classList.remove("is-book-page"); 714 this.window.classList.remove("is-book-spread"); 715 this.bookReader = null; 716 this.windowContent.innerHTML = `<p class="window-kind">${escapeHtml(aperture.kind)}</p><h1>${escapeHtml(aperture.title)}</h1>${aperture.quote ? `<blockquote>â${escapeHtml(aperture.quote)}â</blockquote>` : ""}<p class="window-source">${escapeHtml(aperture.source || "HyperCities")}</p>${figure}${sourceDetails(aperture.provenance, null, null)}`; 717 if (!this.window.open) this.window.showModal(); 718 } 719 } 720 721 openRead(page = BOOK_READ_START_PAGE, aperture = null) { 722 const startingPage = Math.max(1, Math.min(BOOK_PAGE_COUNT, Number(page) || BOOK_READ_START_PAGE)); 723 this.window.classList.add("is-book-page"); 724 this.bookReader = { aperture, page: startingPage }; 725 this.renderBookPage(); 726 if (!this.window.open) this.window.showModal(); 727 } 728 729 shouldUseBookSpread() { 730 return window.matchMedia("(min-width: 920px)").matches; 731 } 732 733 bookSpreadPages(page, isSpread) { 734 if (!isSpread || page === 1) return [page]; 735 const first = page % 2 === 0 ? page : page - 1; 736 return [first, first + 1].filter((item) => item <= BOOK_PAGE_COUNT); 737 } 738 739 bookPageFrame(page, aperture) { 740 // A quotation only receives an overlay on its own source page. Its facing 741 // page remains an uninterrupted facsimile. 742 const rect = aperture && page === aperture.page ? aperture.pageLocation?.rect : null; 743 const highlight = rect 744 ? `<span class="book-page-highlight" aria-label="The selected quotation on this page" style="--highlight-left:${rect.left * 100}%;--highlight-top:${rect.top * 100}%;--highlight-width:${rect.width * 100}%;--highlight-height:${rect.height * 100}%"></span>` 745 : ""; 746 const image = `./assets/book-pages/page-${String(page).padStart(3, "0")}.webp`; 747 return `<div class="book-page-frame"><img src="${image}" alt="Scanned book page ${escapeHtml(page)} of ${BOOK_PAGE_COUNT} from HyperCities: Thick Mapping in the Digital Humanities">${highlight}</div>`; 748 } 749 750 renderBookPage() { 751 const reader = this.bookReader; 752 if (!reader) return; 753 const { aperture, page } = reader; 754 const isSpread = this.shouldUseBookSpread(); 755 const spreadPages = this.bookSpreadPages(page, isSpread); 756 const [firstPage, lastPage] = [spreadPages[0], spreadPages.at(-1)]; 757 const pageLabel = firstPage === lastPage ? String(firstPage) : `${firstPage}â${lastPage}`; 758 const previousDisabled = firstPage === 1 ? " disabled" : ""; 759 const nextDisabled = lastPage === BOOK_PAGE_COUNT ? " disabled" : ""; 760 reader.isSpread = isSpread; 761 this.window.classList.toggle("is-book-spread", isSpread); 762 this.windowContent.innerHTML = `<section class="book-page-viewer${isSpread ? " is-spread" : ""}" aria-label="HyperCities book ${isSpread ? "pages" : "page"} ${escapeHtml(pageLabel)} of ${BOOK_PAGE_COUNT}"><div class="book-page-spread">${spreadPages.map((item) => this.bookPageFrame(item, aperture)).join("")}</div>
762<nav class="book-page-controls" aria-label="Turn book ${isSpread ? "spreads" : "pages"}"><button class="book-page-turn" type="button" data-book-page="-1" aria-label="Previous ${isSpread ? "spread" : "page"}"${previousDisabled}><span aria-hidden="true">â</span><small>PREV</small></button><p aria-live="polite">${escapeHtml(pageLabel)} <span>/</span> ${BOOK_PAGE_COUNT}</p><button class="book-page-turn book-page-turn--next" type="button" data-book-page="1" aria-label="Next ${isSpread ? "spread" : "page"}"${nextDisabled}><small>NEXT</small><span aria-hidden="true">â</span></button></nav></section>`; 763 this.windowContent.querySelectorAll("[data-book-page]").forEach((button) => { 764 button.addEventListener("click", () => this.turnBookPage(Number(button.dataset.bookPage))); 765 }); 766 } 767 768 turnBookPage(direction) { 769 const reader = this.bookReader; 770 if (!reader) return; 771 const nextPage = Math.max(1, Math.min(BOOK_PAGE_COUNT, reader.page + direction * (reader.isSpread ? 2 : 1))); 772 if (nextPage === reader.page) return; 773 reader.page = nextPage; 774 this.renderBookPage(); 775 } 776 777 renderJourney(items) { 778 this.journey.hidden = !items.length; 779 // The trace is a residue to open when wanted, not a competing caption over 780 // the map. New movements quietly update its count without reopening it. 781 this.journeyContent.hidden = true; 782 this.journeyToggle.setAttribute("aria-expanded", "false"); 783 this.journeyToggle.textContent = `TRACE · ${items.length}`; 784 this.journeyToggle.setAttribute("aria-label", `Trace of ${items.length} encounters; newest first`); 785 // Newest first: this is a residue of attention, not a numbered itinerary. 786 // Earlier encounters recede without making the trace hard to read. 787 const newestFirst = [...items].reverse(); 788 this.journeyContent.innerHTML = `<div class="journey-wander" aria-label="A dérive trace, newest encounter first">${newestFirst.map((item, index) => { 789 const age = Math.min(index, 5); 790 return `<span class="journey-item journey-item--age-${age}"><i aria-hidden="true"></i><span>${escapeHtml(item.label)}</span></span>`; 791 }).join("")}</div>`; 792 } 793}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.