1import { objectKind, titleFor } from "./data.js?v=live-map-csv-1"; 2 3const LINK_KINDS = new Set(["concept", "person", "project", "window", "event", "place", "technology", "date", "time-range"]); 4const STRENGTH = { strong: 1, moderate: 0.66, light: 0.34 }; 5const CONFIDENCE = { high: 1, medium: 0.68, low: 0.36 }; 6const DIRECT_PLACE_SCALES = new Set(["city", "district", "site", "region"]); 7// This is only an editorial activation region. It is not an assertion that a 8// map anywhere in this box is a Fukushima map. The selected passages retain 9// their own explicit Tohoku/Fukushima provenance. 10const JAPAN_ACTIVATION_BOUNDS = { west: 122, east: 154.5, south: 24, north: 46.5 }; 11 12function value(value, scale) { return typeof value === "number" ? value : scale[value] || 0.5; } 13function score(edge) { return value(edge?.strength, STRENGTH) * value(edge?.confidence, CONFIDENCE) + (edge?.assertion === "book-explicit" || edge?.assertion === "explicit" ? 0.08 : 0); } 14function relatedEdges(data, id) { return data.edges.filter((edge) => edge.from === id || edge.to === id).map((edge) => ({ edge, other: edge.from === id ? edge.to : edge.from })); } 15function unique(items, key = (item) => item.id) { 16 const seen = new Set(); 17 return items.filter((item) => { const id = key(item); if (seen.has(id)) return false; seen.add(id); return true; }); 18} 19function slug(value) { 20 return String(value || "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "") 21 .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); 22} 23function pageLabel(provenance) { 24 const [start, end] = provenance?.printedPages || []; 25 if (!start) return ""; 26 return start === end ? `p. ${start}` : `pp. ${start}â${end}`; 27} 28function readableTag(tag) { 29 const names = { 30 compare: "comparison", layer: "layers", wander: "wandering", drift: "drift", 31 "historical-change": "historical change", "memory-time": "memory", "event-time": "event time", 32 "open-future": "the open future", "time-layering": "time-layers", "scale-and-zoom": "scale", 33 "raster-basemap-alignment": "alignment", "conceptual-continuity": "an idea already in motion" 34 }; 35 return names[tag] || String(tag || "the map").replace(/-/g, " "); 36} 37function takeWeighted(candidates, count = 1) { 38 const remaining = [...candidates]; 39 const selected = []; 40 while (remaining.length && selected.length < count) { 41 const max = Math.max(...remaining.map((item) => item.weight)); 42 const weights = remaining.map((item) => Math.exp((item.weight - max) * 1.15)); 43 const total = weights.reduce((sum, weight) => sum + weight, 0); 44 let cursor = Math.random() * total; 45 let index = 0; 46 for (; index < weights.length - 1; index += 1) { cursor -= weights[index]; if (cursor <= 0) break; } 47 selected.push(remaining.splice(index, 1)[0]); 48 } 49 return selected; 50} 51function recent(options, key) { return new Set((options?.[key] || []).slice(-(key.includes("Passage") ? 6 : 16))); } 52function sourceLabel(data, encounter) { 53 const passageId = encounter.sourcePassageId; 54 const voice = passageId && relatedEdges(data, passageId).find(({ edge }) => edge.relationType === "narrated-by"); 55 return voice ? titleFor(data, voice.other) : "HyperCities"; 56} 57function link(data, id, edge, passageId = null, bonus = 0) { 58 return { id, label: titleFor(data, id), kind: objectKind(data, id), edge, passageId, weight: score(edge) + bonus }; 59} 60function passageIdsForNode(data, id) { 61 if (objectKind(data, id) === "passage") return [id]; 62 return unique(relatedEdges(data, id) 63 .filter(({ other }) => objectKind(data, other) === "passage") 64 .map(({ other }) => other)); 65} 66function containersForPassage(data, passageId) { 67 return relatedEdges(data, passageId) 68 .filter(({ edge, other }) => edge.relationType === "contains" && ["chapter", "windows", "gallery"].includes(objectKind(data, other))) 69 .map(({ edge, other }) => ({ id: other, edge, kind: objectKind(data, other), label: titleFor(data, other) })); 70} 71function directPassageLinks(data, passageId, bonus = 0) { 72 return unique(relatedEdges(data, passageId) 73 .filter(({ other }) => LINK_KINDS.has(objectKind(data, other))) 74 .map(({ edge, other }) => link(data, other, edge, passageId, bonus))); 75} 76function coreIsInJapan(core) { 77 return Boolean(core && core.lng >= JAPAN_ACTIVATION_BOUNDS.west && core.lng <= JAPAN_ACTIVATION_BOUNDS.east 78 && core.lat >= JAPAN_ACTIVATION_BOUNDS.south && core.lat <= JAPAN_ACTIVATION_BOUNDS.north); 79} 80function japanThreadPassageIds(data) { 81 const yohPassages = relatedEdges(data, "voice:yoh-kawano") 82 .filter(({ edge, other }) => edge.relationType === "narrated-by" && objectKind(data, other) === "passage") 83 .map(({ other }) => other); 84 return new Set(yohPassages.filter((id) => (data.objects.get(id)?.provenance?.printedPages?.[0] || 0) >= 172)); 85} 86function fukushimaThreadPassageIds(data) { 87 return new Set(["place:fukushima", "place:tohoku"].flatMap((id) => passageIdsForNode(data, id))); 88} 89function placeIdsForMap(data, map) { 90 const city = slug(map.city); 91 return new Set([...data.entities.values()] 92 .filter((entity) => entity.type === "place" && DIRECT_PLACE_SCALES.has(entity.placeScale)) 93 .filter((entity) => [entity.preferredLabel, ...(entity.aliases || [])].some((label) => slug(label) === city)) 94 .map((entity) => entity.id)); 95} 96function spatialAffordancesForMap(data, map) { 97 const placeIds = placeIdsForMap(data, map); 98 const placeTags = new Set([...placeIds].map((id) => slug(titleFor(data, id)))); 99 return data.affordances.filter((affordance) => affordance.dimension === "spatial" && placeTags.has(slug(affordance.tag))); 100} 101function encounterFor(data, id) { return data.encounters.get(id) || null; } 102function encounterPassage(encounter) { return encounter?.sourcePassageId || null; } 103function encounterLinks(data, encounter, options = {}) { 104 const passageId = encounterPassage(encounter); 105 if (!passageId) return []; 106 const seen = recent(options, "seenNodeIds"); 107 const links = directPassageLinks(data, passageId) 108 .filter((item) => item.id !== encounter.sourceObjectId && !seen.has(item.id)) 109 .map((item) => ({ ...item, weight: item.weight + (item.kind === "concept" ? 0.28 : 0) + Math.random() * 0.22 })); 110 return takeWeighted(unique(links), 2); 111} 112function encounterConceptIds(data, encounter) { 113 const passageId = encounterPassage(encounter); 114 if (!passageId) return []; 115 return directPassageLinks(data, passageId).filter((item) => item.kind === "concept").map((item) => item.id); 116} 117function stateAffordances(data, encounter, state, directAffordanceIds) { 118 const cues = new Set(state.cues || []); 119 const concepts = new Set(state.conceptIds || []); 120 const affordances = data.affordancesByEncounter.get(encounter.id) || []; 121 return affordances.map((affordance) => { 122 const cueMatch = (affordance.activation?.stateCues || []).filter((cue) => cues.has(cue)); 123 const direct = directAffordanceIds.has(affordance.id); 124 const conceptualContinuity = affordance.dimension === "conceptual" && concepts.has(`concept:${slug(affordance.tag)}`); 125 const timed = affordance.dimension === "temporal" && /^\d{3,4}$/.test(affordance.tag || "") && Math.abs(Number(affordance.tag) - state.map.year) <= 14; 126 return { affordance, cueMatch, direct, conceptualContinuity, timed }; 127 }); 128} 129function arrivalFor(candidate, state, mode) { 130 const match = candidate.matches.find((item) => item.direct) || candidate.matches.find((item) =>
130 item.cueMatch.length) || candidate.matches.find((item) => item.conceptualContinuity) || candidate.matches[0]; 131 const affordance = match?.affordance; 132 const tag = affordance?.tag || "the map"; 133 const label = readableTag(tag); 134 const detail = affordance 135 ? `Editorial ${affordance.dimension} resonance: ${tag}. Source basis: ${affordance.sourceBasis?.assertion || "book-explicit"}.` 136 : "Editorial encounter selection with source provenance retained."; 137 if (mode === "japan") { 138 return { 139 mode, 140 label: "a Japan thread · Yoh Kawano", 141 detail: "Editorial regional activation: this core lies in Japan, so the encounter is drawn from Yoh Kawanoâs book-explicit Mapping Events passages on Tohoku/Fukushima. It does not claim that the selected historical map depicts those sites." 142 }; 143 } 144 if (mode === "spatial") return { mode, label: `here, in ${state.map.city}`, detail }; 145 if (mode === "sideways") return { mode, label: `a side street through ${label}`, detail }; 146 if ((state.cues || []).includes("move-earlier")) return { mode, label: `descending through ${label}`, detail }; 147 if ((state.cues || []).includes("move-later")) return { mode, label: `surfacing through ${label}`, detail }; 148 if ((state.cues || []).includes("zoom-change")) return { mode, label: `at a changed scale: ${label}`, detail }; 149 if ((state.cues || []).includes("return-to-place")) return { mode, label: `on returning: ${label}`, detail }; 150 return { mode, label: `through ${label}`, detail }; 151} 152function makeFragment(data, encounter, arrival) { 153 return { 154 id: encounter.id, 155 kind: encounter.kind, 156 text: encounter.displayText, 157 passageId: encounter.sourcePassageId, 158 sourceObjectId: encounter.sourceObjectId, 159 source: `${sourceLabel(data, encounter)} · ${pageLabel(encounter.provenance)}`, 160 provenance: encounter.provenance, 161 apertureId: encounter.id, 162 arrival, 163 conceptIds: encounterConceptIds(data, encounter) 164 }; 165} 166function candidatesForMap(data, map, state, allowed = null) { 167 const directAffordances = spatialAffordancesForMap(data, map); 168 const directAffordanceIds = new Set(directAffordances.map((item) => item.id)); 169 const seenEncounterIds = recent(state, "seenEncounterIds"); 170 const seenPassageIds = recent(state, "seenPassageIds"); 171 const japanThread = coreIsInJapan(state.core); 172 const japanPassages = japanThread ? japanThreadPassageIds(data) : new Set(); 173 const fukushimaPassages = japanThread ? fukushimaThreadPassageIds(data) : new Set(); 174 const raw = (allowed || data.encounterList).filter((encounter) => { 175 if (encounter.kind === "quotation" || encounter.kind === "passage-title") return true; 176 return encounter.editorialSuitability === "strong"; 177 }).map((encounter) => { 178 const matches = stateAffordances(data, encounter, state, directAffordanceIds); 179 const direct = matches.some((item) => item.direct); 180 const cueMatches = matches.flatMap((item) => item.cueMatch); 181 const continuity = matches.some((item) => item.conceptualContinuity); 182 const timed = matches.some((item) => item.timed); 183 const isJapanThread = japanPassages.has(encounter.sourcePassageId); 184 const isFukushimaThread = fukushimaPassages.has(encounter.sourcePassageId); 185 const kindBonus = encounter.kind === "quotation" ? 0.46 : encounter.kind === "passage-title" ? 0.18 : -0.05; 186 const suitability = encounter.editorialSuitability === "strong" ? 0.26 : 0; 187 const freshness = seenEncounterIds.has(encounter.id) ? -12 : seenPassageIds.has(encounter.sourcePassageId) ? -4.2 : 0; 188 const weight = (direct ? 3.3 : 0) + cueMatches.length * 0.72 + (continuity ? 0.58 : 0) + (timed ? 0.4 : 0) 189 + (isJapanThread ? 3.8 : 0) + (isFukushimaThread ? 1.1 : 0) + kindBonus + suitability + freshness + Math.random() * 0.34; 190 return { encounter, matches, direct, cueMatches, continuity, timed, japanThread: isJapanThread, fukushimaThread: isFukushimaThread, weight }; 191 }); 192 const fresh = raw.filter((candidate) => !seenEncounterIds.has(candidate.encounter.id) && !seenPassageIds.has(candidate.encounter.sourcePassageId)); 193 // A passage cooldown is a real safeguard, not a preference that vanishes 194 // as soon as the current interaction has fewer than ten candidates. 195 return fresh.length >= 3 ? fresh : raw.filter((candidate) => !seenEncounterIds.has(candidate.encounter.id)); 196} 197function chooseCandidate(candidates, state) { 198 const direct = candidates.filter((candidate) => candidate.direct); 199 const resonant = candidates.filter((candidate) => !candidate.direct && (candidate.cueMatches.length || candidate.continuity || candidate.timed)); 200 const sideStreet = resonant.filter((candidate) => candidate.matches.some((item) => ["cartographic", "interactional", "conceptual"].includes(item.affordance.dimension))); 201 let mode = "resonance"; 202 let pool = resonant.length ? resonant : candidates; 203 const japan = candidates.filter((candidate) => candidate.japanThread); 204 if (japan.length) { 205 // A coordinate inside Japan opens a deliberately labelled Yoh/Fukushima 206 // thread. This is editorial activation, never a broad geographic claim. 207 mode = "japan"; 208 pool = japan; 209 } else if (sideStreet.length >= 8 && (state.seenEncounterIds || []).length > 1 && Math.random() < 0.24) { 210 mode = "sideways"; 211 // A sideways encounter stays source-grounded, but it makes room for a 212 // surprising voice rather than always taking the most literal relation. 213 pool = sideStreet.filter((candidate) => candidate.weight > 0.5); 214 } else if (direct.length && Math.random() < 0.46) { 215 mode = "spatial"; 216 pool = direct; 217 } 218 // The headings and named fragments are useful doors, but the book should 219 // ordinarily speak in its own sentences rather than becoming an index. 220 const quotations = pool.filter((candidate) => candidate.encounter.kind === "quotation"); 221 if ((mode === "japan" && quotations.length) || (quotations.length >= 4 && Math.random() < 0.76)) pool = quotations; 222 const band = [...pool].sort((a, b) => b.weight - a.weight).slice(0, Math.max(10, Math.min(72, pool.length))); 223 const chosen = takeWeighted(band, 1)[0] || null; 224 return chosen && { ...chosen, mode }; 225} 226 227export function mapContext(data, map) { 228 const placeIds = placeIdsForMap(data, map); 229 const directAffordances = spatialAffordancesForMap(data, map); 230 const directEncounterIds = new Set(directAffordances.map((item) => item.encounterId)); 231 const passages = unique([...directEncounterIds].map((id) =>
231 encounterFor(data, id)?.sourcePassageId).filter(Boolean)); 232 const relations = unique(passages.flatMap((passageId) => directPassageLinks(data, passageId))); 233 return { placeIds, directAffordances, directEncounterIds, passages, relations, title: map.city }; 234} 235 236export function mapEncounter(data, map, options = {}) { 237 if (Array.isArray(options)) options = { seenEncounterIds: options }; 238 const state = { map, cues: ["select-map", "compare-representations"], ...options.state, seenEncounterIds: options.seenEncounterIds || [], seenPassageIds: options.seenPassageIds || [] }; 239 const context = options.context || mapContext(data, map); 240 const chosen = chooseCandidate(candidatesForMap(data, map, state), state); 241 if (!chosen) return { context, fragment: null, links: [] }; 242 const fragment = makeFragment(data, chosen.encounter, arrivalFor(chosen, state, chosen.mode)); 243 return { context, fragment, links: encounterLinks(data, chosen.encounter, options), selection: chosen }; 244} 245 246export function fieldFragment(data, maps, options = {}) { 247 const map = [...maps].sort((a, b) => a.year - b.year)[0]; 248 if (!map) return null; 249 const encounter = mapEncounter(data, map, { 250 ...options, 251 state: { map, cues: ["core-depth", "select-map"], ...(options.state || {}) } 252 }); 253 return encounter.fragment ? { ...encounter.fragment, links: encounter.links, city: map.city } : null; 254} 255 256export function nodeEncounter(data, nodeId, options = {}) { 257 const entity = data.entities.get(nodeId); 258 const object = data.objects.get(nodeId); 259 const source = entity || object; 260 if (!source) return null; 261 const passages = passageIdsForNode(data, nodeId); 262 const direct = data.encounterList.filter((encounter) => encounter.sourceObjectId === nodeId); 263 const passageEncounters = data.encounterList.filter((encounter) => passages.includes(encounter.sourcePassageId)); 264 const allowed = unique([...direct, ...passageEncounters]); 265 const state = { 266 map: options.map || { city: "the field", year: 0 }, 267 cues: ["conceptual-continuity", "lateral-traverse", ...(options.state?.cues || [])], 268 conceptIds: [nodeId, ...(options.state?.conceptIds || [])], 269 seenEncounterIds: options.seenEncounterIds || [], 270 seenPassageIds: options.seenPassageIds || [] 271 }; 272 const chosen = chooseCandidate(candidatesForMap(data, state.map, state, allowed), state); 273 if (!chosen) return null; 274 const fragment = makeFragment(data, chosen.encounter, arrivalFor(chosen, state, "resonance")); 275 return { 276 id: nodeId, 277 title: entity?.preferredLabel || object?.title || nodeId, 278 kind: entity?.type || object?.kind || "object", 279 provenance: fragment.provenance, 280 fragment, 281 links: encounterLinks(data, chosen.encounter, options), 282 source 283 }; 284} 285 286export function apertureFor(data, nodeId) { 287 const encounter = encounterFor(data, nodeId); 288 const sourceId = encounter?.sourceObjectId || nodeId; 289 const object = data.objects.get(sourceId); 290 const entity = data.entities.get(sourceId) || data.entities.get(nodeId); 291 const source = object || entity; 292 if (!source && !encounter) return null; 293 const passageId = encounter?.sourcePassageId || (object?.kind === "passage" ? sourceId : passageIdsForNode(data, sourceId)[0] || null); 294 const passage = passageId ? data.objects.get(passageId) : null; 295 const container = passageId ? containersForPassage(data, passageId)[0] : null; 296 const quote = object?.kind === "quotation" ? object.text : null; 297 const page = encounter?.provenance?.printedPages?.[0] || source?.provenance?.printedPages?.[0] || null; 298 const pageLocation = quote ? data.bookPageLocations?.[sourceId] || null : null; 299 return { 300 id: encounter?.id || container?.id || nodeId, 301 kind: encounter?.kind || object?.kind || entity?.type || "object", 302 title: passage?.title || encounter?.displayText || object?.title || entity?.preferredLabel || "HyperCities", 303 quote, 304 page, 305 pageImage: page ? `./assets/book-pages/page-${String(page).padStart(3, "0")}.webp` : null, 306 pageLocation, 307 source: `${encounter ? sourceLabel(data, encounter) : "HyperCities"}${encounter?.provenance ? ` · ${pageLabel(encounter.provenance)}` : ""}`, 308 provenance: encounter?.provenance || source?.provenance || null, 309 figure: passageId ? relatedEdges(data, passageId).map(({ other }) => data.objects.get(other)).find((item) => item?.kind === "figure-reference") || null : null, 310 container 311 }; 312} 313 314export function isPlaceNode(data, nodeId) { return objectKind(data, nodeId) === "place"; } 315 316export function strayOffer(data, context, trailIds = []) { 317 const recentTrail = new Set(trailIds.slice(-5)); 318 const candidates = (context?.relations || []) 319 .filter((relation) => !recentTrail.has(relation.id)) 320 .map((relation) => ({ ...relation, weight: relation.weight + Math.random() * 0.7 })); 321 const chosen = takeWeighted(candidates.sort((a, b) => b.weight - a.weight).slice(0, 14), 1)[0]; 322 if (!chosen) return null; 323 return { 324 ...chosen, 325 explanation: `A lateral book path via ${chosen.edge.assertion}; ${chosen.edge.relationType};
325 ${chosen.edge.evidence?.note || "source-grounded relation"}.` 326 }; 327} 328 329export function driftOffer(data, map, { force = false } = {}) { 330 const seed = Number(String(map.sourceId).replace(/\D/g, "")) || 0; 331 if (!force && seed % 4 !== 0) return null; 332 let nearby = data.maps.filter((candidate) => candidate.city !== map.city && Math.abs(candidate.year - map.year) <= 8); 333 if (!nearby.length && force) nearby = data.maps.filter((candidate) => candidate.city !== map.city && placeIdsForMap(data, candidate).size && Math.abs(candidate.year - map.year) <= 50); 334 if (!nearby.length) return null; 335 const bookLinked = nearby.filter((candidate) => placeIdsForMap(data, candidate).size); 336 const sorted = (bookLinked.length ? bookLinked : nearby).sort((a, b) => Math.abs(a.year - map.year) - Math.abs(b.year - map.year)); 337 const chosen = sorted[Math.floor(Math.random() * Math.min(8, sorted.length))]; 338 return { map: chosen, explanation: `Temporal juxtaposition only: ${chosen.city} ${chosen.year} is within ${Math.abs(chosen.year - map.year)} years of ${map.city} ${map.year}; no scholarly relation is claimed.` }; 339}
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.