PageSourceSearch

https://hypercities.com/js/graph.js

js hypercities.com collected 2026-10-03 21:38:53 UTC 20,055 bytes, 339 lines download raw bytes

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.