PageSourceSearch

https://hypercities.com/js/ui.js?v=quiet-note-1

js hypercities.com collected 2026-09-25 18:52:56 UTC 49,534 bytes, 793 lines download raw bytes

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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.