PageSourceSearch

https://hypercities.com/js/main.js?v=metadata-read-controls-1

js hypercities.com collected 2026-10-03 21:38:48 UTC 29,303 bytes, 616 lines download raw bytes

1import { containsCoordinate, loadData, tileDiagnostic } from "./data.js?v=live-map-csv-1";
2import { CoreView } from "./core-view.js?v=well-controls-3";
3import { apertureFor, driftOffer, fieldFragment, isPlaceNode, mapContext, mapEncounter, nodeEncounter, strayOffer } from "./graph.js";
4import { MapView } from "./map-view.js?v=map-focus-layout-3";
5import { AnnotationStore, annotationContext } from "./annotations.js?v=note-autoshow-1";
6import { annotationSimulationRequest, simulatedAnnotations } from "./annotations-simulation.js";
7import { downloadText, kmlFilenameFor, kmlFor } from "./take-map.js";
8import { Interface } from "./ui.js?v=read-gestures-1";
9
10const app = { data: null, field: null, core: null, ui: null, annotations: null, simulation: null, simulatedNotes: [], pendingAnnotations: [], allMapAnnotations: [], mapAnnotations: [], annotationFilter: { search: "", alias: "" }, annotationsVisible: true, timewellExpanded: false, activeAnnotationId: null, annotationMode: false, annotationDraft: null, annotationFloatOpen: false, annotationArrivalFocused: false, coreMaps: [], corePoint: null, selected: null, context: null, bookEncounter: null, activeEncounter: null, tile: null, stray: null, drift: null, takeMode: false, trail: [], history: [], seenEncounterIds: [], seenPassageIds: [], seenNodeIds: [], recentConceptIds: [] };
11
12function annotationsForMap(mapId) {
13  if (app.simulation?.mapId === mapId) return app.simulatedNotes;
14  const published = app.annotations?.forMap(mapId) || [];
15  const pending = app.pendingAnnotations.filter((note) => note.context.mapId === mapId && !published.some((live) => live.id === note.id));
16  return [...pending, ...published];
17}
18
19function filteredAnnotations(annotations = []) {
20  const search = app.annotationFilter.search.trim().toLocaleLowerCase();
21  const alias = app.annotationFilter.alias;
22  return annotations.filter((annotation) => {
23    if (alias && annotation.name !== alias) return false;
24    if (!search) return true;
25    return `${annotation.name || ""} ${annotation.text || ""} ${(annotation.tags || []).join(" ")} ${annotation.tag || ""}`.toLocaleLowerCase().includes(search);
26  });
27}
28
29function setMapAnnotationSet(mapId) {
30  app.allMapAnnotations = annotationsForMap(mapId);
31  app.mapAnnotations = filteredAnnotations(app.allMapAnnotations);
32  if (!app.mapAnnotations.some((annotation) => annotation.id === app.activeAnnotationId)) {
33    app.activeAnnotationId = app.mapAnnotations[0]?.id || null;
34    app.annotationFloatOpen = false;
35  }
36}
37
38function filterAnnotations(filter) {
39  app.annotationFilter = { search: filter.search || "", alias: filter.alias || "" };
40  if (!app.selected) return;
41  const openId = app.annotationFloatOpen ? app.activeAnnotationId : null;
42  setMapAnnotationSet(app.selected.id);
43  if (openId && !app.mapAnnotations.some((note) => note.id === openId)) {
44    app.annotationFloatOpen = false;
45    app.ui.hideAnnotationFloat();
46  }
47  app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: app.annotationFloatOpen ? app.activeAnnotationId : null });
48  render();
49  showActiveAnnotation();
50}
51
52function pushTrail(label, id, why = null) {
53  const last = app.trail.at(-1);
54  if (last?.id === id) return;
55  app.trail.push({ label, id, why });
56  if (app.trail.length > 7) app.trail.shift();
57  app.ui.renderJourney(app.trail);
58}
59
60function fragmentTraceLabel(fragment) {
61  if (fragment.kind !== "quotation") return fragment.text;
62  const words = fragment.text.replace(/[“”]/g, "").split(/\s+/).filter(Boolean);
63  return `${words.slice(0, 5).join(" ")}…`;
64}
65
66function recordEncounter(fragment) {
67  if (!fragment || app.seenEncounterIds.at(-1) === fragment.id) return;
68  app.seenEncounterIds.push(fragment.id);
69  app.seenPassageIds.push(fragment.passageId);
70  app.recentConceptIds.push(...(fragment.conceptIds || []));
71  if (app.seenEncounterIds.length > 24) app.seenEncounterIds.shift();
72  if (app.seenPassageIds.length > 12) app.seenPassageIds.shift();
73  if (app.recentConceptIds.length > 20) app.recentConceptIds.splice(0, app.recentConceptIds.length - 20);
74  pushTrail(fragmentTraceLabel(fragment), fragment.id, fragment.arrival?.detail || "A sourced Hyperbook encounter.");
75}
76
77function encounterState(map, interaction = "select-map") {
78  const cues = ["select-map", "compare-representations", "raster-basemap-alignment"];
79  if (app.coreMaps.length > 3) cues.push("core-depth-many"); else cues.push("core-depth");
80  if (interaction === "move-earlier") cues.push("move-earlier", "move-earlier-or-later", "select-temporal-layer", "move-within-place");
81  if (interaction === "move-later") cues.push("move-later", "move-earlier-or-later", "select-temporal-layer", "move-within-place");
82  if (interaction === "zoom-change") cues.push("zoom-change");
83  if (interaction === "return") cues.push("return-to-place", "revisit-trace");
84  if (interaction === "lateral") cues.push("lateral-traverse", "conceptual-continuity");
85  if (interaction === "drift") cues.push("lateral-drift", "same-time-juxtaposition");
86  return { map, core: app.corePoint, cues, conceptIds: app.recentConceptIds };
87}
88
89function selectionOptions(map, interaction) {
90  return {
91    context: app.context,
92    state: encounterState(map, interaction),
93    seenEncounterIds: app.seenEncounterIds,
94    seenPassageIds: app.seenPassageIds,
95    seenNodeIds: app.seenNodeIds
96  };
97}
98
99function render() {
100  if (!app.selected) return;
101  syncTimewellSize();
102  app.ui.renderCore({ map: app.selected, coreMaps: app.coreMaps, encounter: app.activeEncounter || app.bookEncounter, tile: app.tile, lateral: app.stray, drift: app.drift, annotations: app.allMapAnnotations, filteredAnnotations: app.mapAnnotations, annotationFilter: app.annotationFilter, annotationsVisible: app.annotationsVisible, annotationMode: app.annotationMode, annotationCompact: app.annotationMode || Boolean(app.annotationDraft), annotationOpen: app.annotationFloatOpen, annotationSimulation: app.simulation?.mapId === app.selected.id });
103}
104
105function syncTimewellSize() {
106  const button = document.querySelector("#timewell-size");
107  const addingNote = app.annotationMode || Boolean(app.annotationDraft);
108  const canShrink = Boolean(app.selected);
109  const preferMini = window.matchMedia("(max-width: 780px)").matches || (app.annotationsVisible && app.mapAnnotations.length > 0);
110  const compact = canShrink && (addingNote || !(app.timewellExpanded ?? !preferMini));
111  app.core?.setCompact(compact);
112  button.hidden = !canShrink;
113  button.classList.toggle("is-compact", compact);
114  button.innerHTML = compact
115    ? `<span>EXPAND ↗</span>`
116    : "MINIMIZE ↘";
117  button.setAttribute("aria-label", compact ? "Expand TimeWell" : "Shrink TimeWell");
118}
119
120function showActiveAnnotation() {
121  if (!app.annotationFloatOpen || !app.selected || !app.field) return;
122  const index = app.mapAnnotations.findIndex((annotation) => annotation.id === app.activeAnnotationId);
123  if (index < 0) { app.ui.hideAnnotationFloat(); return; }
124  const annotation = app.mapAnnotations[index];
125  app.ui.showAnnotationNote({ annotation, index, count: app.mapAnnotations.length, point: app.field.projectPoint(annotation.context.point), map: a
125pp.selected });
126}
127
128function moveAnnotationFloatWithMap() {
129  const point = app.annotationDraft?.point || (app.annotationFloatOpen && app.mapAnnotations.find((annotation) => annotation.id === app.activeAnnotationId)?.context.point);
130  if (point) app.ui.positionAnnotationFloat(app.field.projectPoint(point));
131}
132
133async function refreshAnnotations({ force = false } = {}) {
134  if (!app.annotations || !app.selected) return;
135  const selectedId = app.selected.id;
136  try {
137    await app.annotations.refresh({ force });
138    app.pendingAnnotations = app.pendingAnnotations.filter((note) => !app.annotations.annotations.some((live) => live.id === note.id));
139  } catch (error) {
140    // A public note feed must never interrupt the map. The selected layer and
141    // its source record remain fully usable if Google Sheets is momentarily
142    // unavailable.
143    console.warn("Could not refresh annotations.", error);
144  }
145  if (!app.selected || app.selected.id !== selectedId) return;
146  setMapAnnotationSet(selectedId);
147  if (!app.mapAnnotations.some((annotation) => annotation.id === app.activeAnnotationId)) {
148    const requested = new URLSearchParams(window.location.search).get("annotation");
149    app.activeAnnotationId = app.mapAnnotations.some((annotation) => annotation.id === requested)
150      ? requested
151      : app.mapAnnotations[0]?.id || null;
152  }
153  const requested = new URLSearchParams(window.location.search).get("annotation");
154  if (requested && !app.annotationArrivalFocused) {
155    const arrival = app.mapAnnotations.find((annotation) => annotation.id === requested);
156    if (arrival) {
157      app.annotationArrivalFocused = true;
158      app.field.focusMap(app.selected, { point: arrival.context.point });
159      app.annotationFloatOpen = true;
160    }
161  }
162  app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: app.annotationFloatOpen ? app.activeAnnotationId : null });
163  render();
164  showActiveAnnotation();
165}
166
167function selectMap(map, { keepEncounter = false, trailWhy = null, focus = true, interaction = "select-map" } = {}) {
168  if (!map) return;
169  if (app.takeMode) app.ui.closeTakeMap(false);
170  app.selected = map;
171  app.context = mapContext(app.data, map);
172  app.bookEncounter = mapEncounter(app.data, map, selectionOptions(map, interaction));
173  app.activeEncounter = keepEncounter ? app.activeEncounter : null;
174  app.tile = tileDiagnostic(map);
175  const visibleEncounter = app.activeEncounter || app.bookEncounter;
176  app.stray = strayOffer(app.data, { relations: visibleEncounter?.links || app.context.relations }, app.trail.map((item) => item.id));
177  // Temporal drift is an occasional consequence of a conceptual dérive, not
178  // a persistent competing prompt on every historical map layer.
179  app.drift = null;
180  app.annotationMode = false;
181  app.annotationDraft = null;
182  app.annotationFloatOpen = false;
183  app.annotationFilter = { search: "", alias: "" };
184  app.ui.hideAnnotationFloat();
185  app.field.setPendingAnnotation(null);
186  app.annotationsVisible = true;
187  app.field.setAnnotationMode(false);
188  app.core.setPassThrough(false);
189  setMapAnnotationSet(map.id);
190  app.core.select(map, { focus });
191  app.field.showRaster(map, { focus: false });
192  app.ui.showGroundForMap();
193  app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: null });
194  pushTrail(`${map.city} ${map.year}`, map.id, trailWhy || "Selected from the temporal stack; X/Y remains the map’s geographic footprint and Z is its historical position.");
195  recordEncounter(visibleEncounter?.fragment);
196  render();
197  if (focus) app.field.focusMap(map);
198  refreshAnnotations();
199}
200
201function previewCore(maps, lngLat) {
202  app.core.preview([...maps].sort((a, b) => a.year - b.year || a.title.localeCompare(b.title)), lngLat);
203}
204
205function discoverField(maps, point, lngLat) {
206  const fragment = fieldFragment(app.data, maps, {
207    state: { core: lngLat },
208    seenEncounterIds: app.seenEncounterIds,
209    seenPassageIds: app.seenPassageIds
210  });
211  app.ui.showFieldEncounter(fragment, point);
212}
213
214function enterCore(maps, lngLat) {
215  if (!maps.length) { app.ui.field("No sampled historical layer holds this point. Keep moving; the map remains open."); return; }
216  app.timewellExpanded = null;
217  app.history.push({ type: "field" });
218  app.corePoint = lngLat;
219  app.coreMaps = [...maps].sort((a, b) => a.year - b.year || a.title.localeCompare(b.title));
220  app.field.enterCore(lngLat, app.coreMaps);
221  app.core.enter(app.coreMaps, lngLat);
222  app.ui.lockedCore(app.coreMaps);
223  // A core begins at its uppermost surviving layer. The visitor can descend
224  // immediately, without needing a second click in the timewell.
225  selectMap(app.coreMaps.at(-1), {
226    trailWhy: "The newest map opens the core; descend to move into its earlier layers.",
227    interaction: "core"
228  });
229}
230
231function moveTime(direction) {
232  const index = app.coreMaps.findIndex((map) => map.id === app.selected.id);
233  const next = direction === "older" ? app.coreMaps[index - 1] : app.coreMaps[index + 1];
234  if (next) selectMap(next, { interaction: direction === "older" ? "move-earlier" : "move-later" });
235}
236
237function followNode(nodeId, why = null) {
238  if (isPlaceNode(app.data, nodeId)) { moveToPlace(nodeId, why); return; }
239  const encounter = nodeEncounter(app.data, nodeId, {
240    map: app.selected,
241    state: encounterState(app.selected, "lateral"),
242    seenEncounterIds: app.seenEncounterIds,
243    seenPassageIds: app.seenPassageIds,
244    seenNodeIds: app.seenNodeIds
245  });
246  if (!encounter) return;
247  app.history.push({ type: "map", map: app.selected.id });
248  app.activeEncounter = encounter;
249  app.stray = strayOffer(app.data, { relations: encounter.links }, app.trail.map((item) => item.id));
250  // This is the one deliberately non-scholarly movement: the book's inferred
251  // dérive can open a contemporaneous map elsewhere, with the distinction
252  // preserved in its provenance trail.
253  app.drift = nodeId === "concept:derive" ? driftOffer(app.data, app.selected, { force: true }) : null;
254  pushTrail(encounter.title, nodeId, why || `Followed a Hyperbook relation with source evidence in ${encounter.provenance ? `book pp. ${encounter.provenance.printedPages.join("–")}` : "the Hyperbook"}.`);
255  app.seenNodeIds.push(nodeId);
256  if (app.seenNodeIds.length > 12) app.seenNodeIds.shift();
257  recordEncounter(encounter.fragment);
258  render();
259}
260
261function openAperture(nodeId) {
262  const aperture = apertureFor(app.data, nodeId);
263  if (!aperture) return;
264  pushTrail(aperture.title, aperture.id, `Opened a Hyperbook ${aperture.kind}; closing it returns to this same dérive.`);
265  app.ui.openAperture(aperture);
266}
267
268function moveToPlace(nodeId, why = null) {
269  const label = app.data.entities.get(nodeId)?.preferredLabel;
270  const maps = app.data.maps.filter((map) => map.city === label).sort((a, b) => a.year - b.year || a.title.localeCompare(b.title));
271  if (!maps.length) {
272    const encounter = nodeEncounter(app.data, nodeId, {
273      map: app.selected,
274      state: encounterState(app.selected, "lateral"),
275      seenEncounterIds: app.seenEncounterIds,
276      seenPassageIds: app.seenPassageIds,
277      seenNodeIds: app.seenNodeIds
278    });
279    if (!encounter) return;
280    app.activeEncounter = encounter;
281    pushTrail(encounter.title, nodeId, why || `Reached ${label} through the book; no historical map layer is available here.`);
282    recordEncounter(encounter.fragment);
283    render();
284    return;
285  }
286  const seed = maps.at(-1);
287  const core = { lng: (seed.bbox[0] + seed.bbox[2]) / 2, lat: (seed.bbox[1] + seed.bbox[3]) / 2 };
288  const layers = maps.filter((map) => map.bbox[0] <= core.lng && map.bbox[2] >= core.lng && map.bbox[1] <= core.lat && map.bbox[3] >= core.lat);
289  app.core.leave();
290  app.field.leaveCore();
291  app.coreMaps = layers.length ? layers : [seed];
292  app.timewellExpanded = null;
293  app.corePoint = core;
294  app.field.enterCore(core, app.coreMaps);
295  app.core.enter(app.coreMaps, core);
296  app.ui.lockedCore(app.coreMaps);
297  selectMap(app.coreMaps.at(-1), { trailWhy: why || `Followed a book path into ${label}.`, interaction: "lateral" });
298}
299
300function acceptStray(nodeId) { const offer = app.stray; followNode(nodeId, offer?.explanation || "A lateral Hyperbook movement."); }
301
302function acceptDrift(mapId) {
303  const offer = app.drift;
304  const map = app.data.maps.find((candidate) => candidate.id === mapId);
305  if (!map) return;
306  app.history.push({ type: "map", map: app.selected.id });
307  const core = { lng: (map.bbox[0] + map.bbox[2]) / 2, lat: (map.bbox[1] + map.bbox[3]) / 2 };
308  const layers = app.data.maps.filter((candidate) => candidate.bbox[0] <= core.lng && candidate.bbox[2] >= core.lng && candidate.bbox[1] <= core.lat && candidate.bbox[3] >= core.lat)
309    .sort((a, b) => a.year - b.year || a.title.localeCompare(b.title));
310  app.coreMaps = layers.length ? layers : [map];
311  app.timewellExpanded = null;
312  app.corePoint = core;
313  app.field.enterCore(core, app.coreMaps);
314  app.core.enter(app.coreMaps, core);
315  selectMap(map, { trailWhy: offer?.explanation || "Temporal juxtaposition; not a claimed scholarly relation.", interaction: "drift" });
316}
317
318function leaveCore() {
319  closeAnnotation();
320  app.ui.closeGround(true);
321  app.timewellExpanded = null;
322  app.core.leave(); app.field.leaveCore(); app.coreMaps = []; app.corePoint = null; app.selected = null; app.context = null; app.bookEncounter = null; app.activeEncounter = null; app.stray = null; app.drift = null; app.takeMode = false; app.annotationMode = false; app.allMapAnnotations = []; app.mapAnnotations = []; app.activeAnnotationId = null; app.history = []; app.ui.clearTakeMap(); app.ui.field();
323  syncTimewellSize();
324}
325
326function beginTakeMap() {
327  app.takeMode = true;
328  app.ui.beginTakeMap(app.selected);
329  if (app.selected) render();
330}
331
332function openTakeMap() {
333  if (!app.selected) return;
334  app.ui.openTakeMap(app.selected);
335}
336
337function resumeTakeMap() { render(); }
338
339function exportMap(kind) {
340  if (!app.selected) return;
341  if (kind === "kml") downloadText(kmlFilenameFor(app.selected), kmlFor(app.selected), "application/vnd.google-earth.kml+xml");
342}
343
344function setBasemap(mode) { app.field.setBasemap(mode); }
345
346function setRasterOpacity(opacity) { app.field.setRasterOpacity(opacity); }
347
348function toggleAnnotations() {
349  if (!app.selected || !app.mapAnnotations.length) return;
350  app.annotationsVisible = !app.annotationsVisible;
351  if (app.annotationsVisible) app.timewellExpanded = false;
352  if (!app.annotationsVisible) { app.annotationFloatOpen = false; app.ui.hideAnnotationFloat(); }
353  app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: app.annotationFloatOpen ? app.activeAnnotationId : null });
354  render();
355}
356
357function openAnnotations() {
358  if (!app.selected || !app.mapAnnotations.length) return;
359  const wasHidden = !app.annotationsVisible;
360  app.activeAnnotationId = app.mapAnnotations.some((annotation) => annotation.id === app.activeAnnotationId)
361    ? app.activeAnnotationId : app.mapAnnotations[0].id;
362  app.annotationFloatOpen = true;
363  app.annotationsVisible = true;
364  if (wasHidden) app.timewellExpanded = false;
365  app.field.setAnnotations(app.mapAnnotations, { visible: true, activeId: app.activeAnnotationId });
366  app.field.focusAnnotation(app.mapAnnotations.find((annotation) => annotation.id === app.activeAnnotationId)?.context.point);
367  render();
368  showActiveAnnotation();
369}
370
371function toggleAnnotationMode() {
372  if (!app.selected || !app.annotations?.live) return;
373  app.annotationMode = !app.annotationMode;
374  if (app.annotationMode) app.timewellExpanded = false;
375  app.annotationDraft = null;
376  app.annotationFloatOpen = false;
377  app.ui.hideAnnotationFloat();
378  app.field.setPendingAnnotation(null);
379  app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: null });
380  app.field.setAnnotationMode(app.annotationMode);
381  app.core.setPassThrough(app.annotationMode);
382  render();
383}
384
385function stepAnnotation(direction) {
386  if (!app.mapAnnotations.length) return;
387  const current = app.mapAnnotations.findIndex((annotation) => annotation.id === app.activeAnnotationId);
388  const next = (Math.max(0, current) + direction + app.mapAnnotations.length) % app.mapAnnotations.length;
389  app.activeAnnotationId = app.mapAnnotations[next].id;
390  app.annotationsVisible = true;
391  app.annotationFloatOpen = true;
392  app.field.setAnnotations(app.mapAnnotations, { visible: true, activeId: app.activeAnnotationId });
393  app.field.focusAnnotation(app.mapAnnotations[next].context.point);
394  render();
395  showActiveAnnotation();
396}
397
398function selectAnnotation(annotation) {
399  if (!annotation || annotation.context.mapId !== app.selected?.id) return;
400  closeAnnotation();
401  app.annotationMode = false;
402  app.core.setPassThrough(false);
403  app.annotationsVisible = true;
404  app.activeAnnotationId = annotation.id;
405  app.annotationFloatOpen = true;
406  app.field.setAnnotationMode(false);
407  app.field.setAnnotations(app.mapAnnotations, { visible: true, activeId: annotation.id });
408  app.field.focusAnnotation(annotation.context.point);
409  render();
410  showActiveAnnotation();
411}
412
413function placeAnnotation(point) {
414  if (!app.selected || !app.annotations?.live) return;
415  if (!containsCoordinate(app.selected, point)) {
416    app.ui.annotationPlacementHint("Choose a point inside the selected historical map.");
417    return;
418  }
419  const submission = app.annotations.buildFormSubmission(annotationContext({
420    map: app.selected,
421    point,
422    core: app.corePoint,
423    camera: app.field.getAnnotationCamera(),
424    basemap: app.ui.groundState.mode,
425    opacity: app.ui.groundState.opacity
426  }));
427  if (!submission) return;
428  app.annotationMode = false;
429  app.core.setPassThrough(false);
430  app.annotationDraft = { point, submission };
431  app.field.setAnnotationMode(false);
432  app.field.setPendingAnnotation(point);
433  render();
434  app.ui.showAnnotationDraft({ point, screenPoint: app.field.projectPoint(point), map: app.selected, submission });
435}
436
437function closeAnnotation() {
438  app.annotationFloatOpen = false;
439  app.annotationDraft = null;
440  app.field?.setPendingAnnotation(null);
441  app.ui.hideAnnotationFloat();
442  if (app.selected) {
443    app.field.setAnnotations(app.mapAnnotations, { visible: app.annotationsVisible, activeId: null });
444    render();
445  }
446}
447
448function changeAnnotationPoint() {
449  closeAnnotation();
450  app.annotationMode = true;
451  app.core.setPassThrough(true);
452  app.field.setAnnotationMode(true);
453  render();
454}
455
456function confirmAnnotation({ text, name, tag }) {
457  const draft = app.annotationDraft;
458  if (!draft || !app.selected) return;
459  const id = new URLSearchParams(draft.submission.context).get("id");
460  if (!id) return;
461  const note = {
462    id, text: text.trim(), name: name.trim(), tag: tag.trim(),
463    tags: tag.split(",").map((part) => part.trim().replace(/^#+/, "")).filter(Boolean).slice(0, 8),
464    timestamp: new Date().toISOString(), timestampValue: Date.now(),
465    context: { mapId: app.selected.id, point: draft.point }, pending: true
466  };
467  app.pendingAnnotations.unshift(note);
468  app.annotationDraft = null;
469  app.annotationFloatOpen = true;
470  app.annotationsVisible = true;
471  app.activeAnnotationId = id;
472  app.field.setPendingAnnotation(null);
473  app.annotationFilter = { search: "", alias: "" };
474  setMapAnnotationSet(app.selected.id);
475  app.field.setAnnotations(app.mapAnnotations, { visible: true, activeId: id });
476  render();
477  showActiveAnnotation();
478  pollSubmittedNote(id);
479}
480
481function pollSubmittedNote(id, attempts = 0) {
482  window.setTimeout(async () => {
483    await refreshAnnotations({ force: true });
484    if (app.annotations?.annotations.some((note) => note.id === id)) return;
485    const pending = app.pendingAnnotations.find((note) => note.id === id);
486    if (!pending) return;
487    if (attempts < 17) { pollSubmittedNote(id, attempts + 1); return; }
488    pending.unconfirmed = true;
489    if (app.selected?.id === pending.context.mapId) {
490      setMapAnnotationSet(app.selected.id);
491      render();
492      showActiveAnnotation();
493    }
494  }, attempts === 0 ? 2000 : 5000);
495}
496
497function stepBack() {
498  const previous = app.history.pop();
499  if (!previous || previous.type === "field") { leaveCore(); return; }
500  const map = app.data.maps.find((candidate) => candidate.id === previous.map);
501  if (map) { app.activeEncounter = null; selectMap(map, { interaction: "return" }); }
502}
503
504function respondToMapGesture(cue) {
505  if (!app.selected) return;
506  app.activeEncounter = null;
507  app.context = mapContext(app.data, app.selected);
508  app.bookEncounter = mapEncounter(app.data, app.selected, selectionOptions(app.selected, cue));
509  app.stray = strayOffer(app.data, { relations: app.bookEncounter.links }, app.trail.map((item) => item.id));
510  recordEncounter(app.bookEncounter.fragment);
511  render();
512}
513
514function keyboard(event) {
515  if (document.querySelector("#origins-window").open || document.querySelector("#stories-window").open) return;
516  if (app.ui.bookEntryIsVisible()) return;
517  if (document.querySelector("#help-dialog").open || !document.querySelector("#site-index").hidden || document.querySelector("#hyperbook-window").open || document.querySelector("#take-map-window").open) return;
518  if ((app.annotationDraft || app.annotationFloatOpen) && event.key === "Escape") {
519    event.preventDefault();
520    closeAnnotation();
521    return;
522  }
523  if (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable]")) return;
524  if (app.annotationMode && event.key === "Escape") {
525    event.preventDefault();
526    app.annotationMode = false;
527    app.core.setPassThrough(false);
528    app.field.setAnnotationMode(false);
529    render();
530    return;
531  }
532  if (app.annotationFloatOpen && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
533    event.preventDefault();
534    stepAnnotation(event.key === "ArrowRight" ? 1 : -1);
535    return;
536  }
537  if (event.key === "Escape" || event.key === "ArrowLeft") { event.preventDefault(); stepBack(); return; }
538  if (!app.selected) return;
539  if (event.key === "ArrowDown") { event.preventDefault(); moveTime("older"); }
540  if (event.key === "ArrowUp") { event.preventDefault(); moveTime("newer"); }
541  if ((event.key === "ArrowRight" || event.key === " ") && app.stray) { event.preventDefault(); acceptStray(app.stray.id); }
542}
543
544async function start() {
545  app.timewellExpanded = null;
546  window.matchMedia("(max-width: 780px)").addEventListener("change", () => {
547    app.timewellExpanded = null;
548    syncTimewellSize();
549  });
550  try {
551    app.ui = new Interface({ node: followNode, time: moveTime, stray: acceptStray, drift: acceptDrift, aperture: openAperture, read: () => app.ui.openRead(), surface: leaveCore, back: stepBack, take: beginTakeMap, takeSelected: openTakeMap, resumeTake: resumeTakeMap, exportMap, basemap: setBasemap, rasterOpacity: setRasterOpacity, annotations: toggleAnnotations, openAnnotations, annotate: toggleAnnotationMode, annotationStep: stepAnnotation, filterAnnotations, closeAnnotation, changeAnnotationPoint, confirmAnnotation });
552    document.querySelector("#timewell-size").addEventListener("click", () => {
553      app.timewellExpanded = app.core.compact;
554      syncTimewellSize();
555    });
556    const pageParams = new URLSearchParams(window.location.search);
557    if (pageParams.get("enter") === "map") app.ui.dismissBookEntry();
558    [app.data, app.annotations] = await Promise.all([loadData(), AnnotationStore.load()]);
559    app.simulation = annotationSimulationRequest(window.location.search);
560    if (app.simulation) {
561      const simulationMap = app.data.maps.find((map) => map.id === app.simulation.mapId);
562      if (simulationMap) app.simulatedNotes = simulatedAnnotations(simulationMap, app.simulation.count);
563    }
564    app.ui.setBookEntryQuotes(app.data);
565    app.core = new CoreView({
566      onSelect: (map) => selectMap(map),
567      onHover: (map) => app.field.highlightTimewellMap(map),
568      onMapGesture: (event) => app.field.forwardTimewellGesture(event)
569    });
570    app.field = new MapView({
571      maps: app.data.maps,
572      onCore: enterCore,
573      onDepth: (maps) => app.ui.showDepth(maps),
574      onPreview: previewCore,
575      onFieldEncounter: discoverField,
576      onInteraction: respondToMapGesture,
577      onTileStatus: (status) => { app.tile = status; render(); },
578      onBasemapChange: (state) => app.ui.setGroundState(state),
579      onRasterOpacityChange: (state) => app.ui.setGroundState(state),
580      onAnnotationPlace: placeAnnotation,
581      onAnnotationSelect: selectAnnotation,
582      onAnnotationViewMove: moveAnnotationFloatWithMap
583    });
584    await app.field.init();
585    app.ui.field();
586    const requestedMapId = pageParams.get("map");
587    const requestedMap = app.data.maps.find((map) => map.id === requestedMapId);
588    if (requestedMap) {
589      const core = { lng: (requestedMap.bbox[0] + requestedMap.bbox[2]) / 2, lat: (requestedMap.bbox[1] + requestedMap.bbox[3]) / 2 };
590      const layers = app.data.maps.filter((map) => map.bbox[0] <= core.lng && map.bbox[2] >= core.lng && map.bbox[1] <= core.lat && map.bbox[3] >= core.lat)
591        .sort((a, b) => a.year - b.year || a.title.localeCompare(b.title));
592      app.ui.dismissBookEntry();
593      app.corePoint = core;
594      app.coreMaps = layers.length ? layers : [requestedMap];
595      app.field.enterCore(core, app.coreMaps);
596      app.core.enter(app.coreMaps, core);
597      app.ui.lockedCore(app.coreMaps);
598      selectMap(requestedMap, { focus: true, interaction: "annotation-link", trailWhy: "Arrived through a map annotation." });
599    }
600    document.addEventListener("keydown", keyboard);
601  } catch (error) {
602    const directFile = window.location.protocol === "file:";
603    document.querySelector("#field-prompt").innerHTML = directFile
604      ? "<p>This map needs a static web preview. Open it through GitHub Pages or a local HTTP server so the book graph and map records can be read.</p>"
605      : `<p>Unable to open the map: ${error.message}</p>`;
606    document.querySelector("#field-prompt").hidden = false;
607    console.error(error);
608  }
609}
610
611// A module should evaluate once, but this guard keeps embedded-browser reload
612// quirks from registering a second set of map and interface listeners.
613if (!window.__hypercitiesStarted) {
614  window.__hypercitiesStarted = true;
615  start();
616}

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.