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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[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.