PageSourceSearch

https://hypercities.com/js/annotations-page.js?v=annotation-note-filters-2

js hypercities.com collected 2026-10-03 21:38:49 UTC 10,736 bytes, 164 lines download raw bytes

1import { AnnotationStore, annotationDeepLink } from "./annotations.js?v=annotation-note-filters-2";
2import { annotationSimulationRequest, simulatedAnnotations } from "./annotations-simulation.js";
3import { loadData } from "./data.js?v=live-map-csv-1";
4
5const list = document.querySelector("#annotations-list");
6const refresh = document.querySelector("#annotations-refresh");
7const search = document.querySelector("#annotations-search");
8const expanded = new Set();
9const shownNotes = new Map();
10const groupFilters = new Map();
11let shownMaps = 18;
12let currentStore = null;
13let currentMaps = null;
14
15function escapeHtml(value) {
16  return String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[character]));
17}
18
19function noteDate(value) {
20  const date = new Date(value);
21  if (Number.isNaN(date.getTime())) return "Submitted";
22  return new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(date);
23}
24
25function pointLabel(point) {
26  const latitude = `${point.lat < 0 ? "S" : "N"} ${Math.abs(point.lat).toFixed(4)}`;
27  const longitude = `${point.lng < 0 ? "W" : "E"} ${Math.abs(point.lng).toFixed(4)}`;
28  return `${latitude} · ${longitude}`;
29}
30
31function render(store, maps) {
32  currentStore = store;
33  currentMaps = maps;
34  if (!store.live) {
35    list.innerHTML = `<p class="annotations-state">The public annotation stream is not open yet.</p>`;
36    refresh.hidden = true;
37    return;
38  }
39  const records = store.annotations;
40  if (!records.length) {
41    list.innerHTML = `<p class="annotations-state">No notes have been left yet. Open a historical map and choose <em>annotate</em> to begin.</p>`;
42    return;
43  }
44  const groups = new Map();
45  for (const annotation of records) {
46    const id = annotation.context.mapId;
47    const simulation = Boolean(annotation.simulated);
48    const key = simulation ? `${id}::class-simulation` : id;
49    if (!groups.has(key)) groups.set(key, { id, simulation, notes: [] });
50    groups.get(key).notes.push(annotation);
51  }
52  const term = search.value.trim().toLocaleLowerCase();
53  const matches = [...groups].map(([key, group]) => {
54    const { id, simulation, notes } = group;
55    const map = maps.get(id);
56    const title = map?.title || notes[0].context.title || "Historical map";
57    const city = map?.city || notes[0].context.city || "Unplaced";
58    const year = map?.year || notes[0].context.year || "";
59    const mapMatch = `${title} ${city} ${year}`.toLocaleLowerCase().includes(term);
60    const filtered = !term || mapMatch ? notes : notes.filter((note) => `${note.text} ${note.tag} ${note.name || ""}`.toLocaleLowerCase().includes(term));
61    return { key, id, simulation, title, city, year, notes: filtered, total: notes.length, latest: notes[0].timestampValue };
62  }).filter((group) => group.notes.length).sort((a, b) => b.latest - a.latest || a.title.localeCompare(b.title));
63  if (!matches.length) {
64    list.innerHTML = `<p class="annotations-state">No maps or notes match this search.</p>`;
65    return;
66  }
67  if (!expanded.size && !term) {
68    const firstPublicGroup = matches.find((group) => !group.simulation);
69    if (firstPublicGroup) expanded.add(firstPublicGroup.key);
70  }
71  const visible = matches.slice(0, shownMaps);
72  list.innerHTML = visible.map((group) => {
73    const limit = shownNotes.get(group.key) || 8;
74    const filter = groupFilters.get(group.key) || { alias: "", search: "" };
75    const filteredNotes = group.notes.filter((annotation) => {
76      const matchesAlias = !filter.alias || annotation.name === filter.alias;
77      const searchable = `${annotation.text} ${annotation.tag} ${(annotation.tags || []).join(" ")} ${annotation.name || ""}`.toLocaleLowerCase();
78      return matchesAlias && (!filter.search || searchable.includes(filter.search.toLocaleLowerCase()));
79    });
80    const aliases = [...new Set(group.notes.map((annotation) => annotation.name).filter(Boolean))].sort((a, b) => a.localeCompare(b));
81    const noteFilters = `<div class="annotation-list-filters"><label><span>ALIAS</span><select aria-label="Filter notes by alias" data-note-alias="${escapeHtml(group.key)}"><option value="">ALL ALIASES · ${aliases.length}</option>${aliases.map((alias) => `<option value="${escapeHtml(alias)}"${filter.alias === alias ? " selected" : ""}>${escapeHtml(alias)}</option>`).join("")}</select></label><label class="annotation-notes-search"><span>SEARCH NOTES</span><input type="search" aria-label="Search notes in this map" placeholder="A word, phrase, or tag" value="${escapeHtml(filter.search)}" data-note-search="${escapeHtml(group.key)}" /></label><small>${filteredNotes.length} OF ${group.notes.length}</small></div>`;
82    const noteRows = filteredNotes.slice(0, limit).map((annotation) => {
83      const tags = (annotation.tags || (annotation.tag ? annotation.tag.split(",") : [])).map((tag) =>
83 tag.trim().replace(/^#+/, "")).filter(Boolean);
84      const tagCopy = tags.map((tag) => `<span class="annotations-tag">#${escapeHtml(tag)}</span>`).join("");
85      const byline = annotation.name ? `<span>${escapeHtml(annotation.name)}</span>` : "";
86      return `<article class="annotation-card"><blockquote>${escapeHtml(annotation.text)}</blockquote><footer>${byline}<span>${escapeHtml(noteDate(annotation.timestamp))}</span><span>${escapeHtml(pointLabel(annotation.context.point))}</span>${tagCopy}<a href="${escapeHtml(annotationDeepLink(annotation))}">OPEN MAP <span aria-hidden="true">↗</span></a></footer></article>`;
87    }).join("");
88    const more = filteredNotes.length > limit ? `<button class="annotations-more" type="button" data-more-notes="${escapeHtml(group.key)}">SHOW ${Math.min(8, filteredNotes.length - limit)} MORE NOTES ↓</button>` : "";
89    const noFilteredNotes = filteredNotes.length ? "" : `<p class="annotations-state annotation-filter-empty">No notes match these filters.</p>`;
90    const eyebrow = group.simulation ? "SIMULATION · 20 STUDENT ALIASES" : `${escapeHtml(group.city)}${group.year ? ` · ${escapeHtml(group.year)}` : ""}`;
91    const count = group.simulation ? `${group.total} SIMULATED NOTES` : `${group.total} NOTE${group.total === 1 ? "" : "S"}`;
92    if (group.simulation) {
93      const mapHref = `./?map=${encodeURIComponent(group.id)}&simulateNotes=${group.total}`;
94      return `<section class="annotation-group annotation-group--simulation"><div class="simulation-entry-head"><span class="annotation-group-identity"><small>${eyebrow}</small><a class="simulation-map-link" href="${escapeHtml(mapHref)}"><strong>${escapeHtml(group.title)}</strong><span aria-hidden="true">↗</span></a></span><a class="simulation-open-map" href="${escapeHtml(mapHref)}">OPEN MAP</a></div><details data-map-group="${escapeHtml(group.key)}"${expanded.has(group.key) ? " open" : ""}><summary><span>EXPLORE THE NOTES</span><span class="annotation-group-count">${count} <i aria-hidden="true">↓</i></span></summary><div class="annotation-group-notes">${noteFilters}${noteRows}${noFilteredNotes}${more}</div></details></section>`;
95    }
96    return `<section class="annotation-group"><details data-map-group="${escapeHtml(group.key)}"${expanded.has(group.key) ? " open" : ""}><summary><span class="annotation-group-identity"><small>${eyebrow}</small><strong>${escapeHtml(group.title)}</strong></span><span class="annotation-group-count">${count} <i aria-hidden="true">↓</i></span></summary><div class="annotation-group-notes">${noteFilters}${noteRows}${noFilteredNotes}${more}</div></details></section>`;
97  }).join("") + (matches.length > shownMaps ? `<button class="annotations-more-maps" type="button">SHOW MORE MAPS ↓ <span>${shownMaps} OF ${matches.length}</span></button>` : "");
98}
99
100async function start({ force = false } = {}) {
101  refresh.disabled = true;
102  try {
103    const [store, data] = await Promise.all([AnnotationStore.load(), loadData()]);
104    const maps = new Map(data.maps.map((map) => [map.id, map]));
105    await store.refresh({ force });
106    const requestedSimulation = annotationSimulationRequest(window.location.search);
107    const simulationMap = (requestedSimulation && maps.get(requestedSimulation.mapId)) || maps.get("map:396");
108    if (simulationMap) {
109      const count = requestedSimulation?.count || 200;
110      const classNotes = simulatedAnnotations(simulationMap, count).reverse();
111      store.annotations = [...store.annotations.filter((note) => !note.simulated), ...classNotes];
112    }
113    render(store, maps);
114  } catch (error) {
115    list.innerHTML = `<p class="annotations-state">The annotation stream could not be opened. The maps remain available.</p>`;
116    console.warn("Could not open annotations page.", error);
117  } finally {
118    refresh.disabled = false;
119  }
120}
121
122refresh.addEventListener("click", () => start({ force: true }));
123search.addEventListener("input", () => { shownMaps = 18; if (currentStore && currentMaps) render(currentStore, currentMaps); });
124list.addEventListener("toggle", (event) => {
125  const details = event.target.closest("[data-map-group]");
126  if (!details) return;
127  if (details.open) expanded.add(details.dataset.mapGroup); else expanded.delete(details.dataset.mapGroup);
128}, true);
129list.addEventListener("click", (event) => {
130  const noteButton = event.target.closest("[data-more-notes]");
131  if (noteButton) {
132    shownNotes.set(noteButton.dataset.moreNotes, (shownNotes.get(noteButton.dataset.moreNotes) || 8) + 8);
133    render(currentStore, currentMaps);
134  }
135  if (event.target.closest(".annotations-more-maps")) {
136    shownMaps += 18;
137    render(currentStore, currentMaps);
138  }
139});
140list.addEventListener("change", (event) => {
141  const select = event.target.closest("[data-note-alias]");
142  if (!select || !currentStore || !currentMaps) return;
143  const key = select.dataset.noteAlias;
144  if (select.closest("details[data-map-group]")?.open) expanded.add(key);
145  const filter = groupFilters.get(key) || { alias: "", search: "" };
146  groupFilters.set(key, { ...filter, alias: select.value });
147  shownNotes.set(key, 8);
148  render(currentStore, currentMaps);
149});
150list.addEventListener("input", (event) => {
151  const input = event.target.closest("[data-note-search]");
152  if (!input || !currentStore || !currentMaps) return;
153  const key = input.dataset.noteSearch;
154  if (input.closest("details[data-map-group]")?.open) expanded.add(key);
155  const filter = groupFilters.get(key) || { alias: "", search: "" };
156  const caret = input.selectionStart;
157  groupFilters.set(key, { ...filter, search: input.value });
158  shownNotes.set(key, 8);
159  render(currentStore, currentMaps);
160  const replacement = [...list.querySelectorAll("[data-note-search]")].find((item) => item.dataset.noteSearch === key);
161  replacement?.focus();
162  replacement?.setSelectionRange(caret, caret);
163});
164start();

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.