PageSourceSearch

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

js hypercities.com collected 2026-10-03 21:39:01 UTC 8,072 bytes, 215 lines download raw bytes

1const CONFIG_PATH = "./data/annotations-config.json";
2
3function finiteNumber(value) {
4  const number = Number(value);
5  return Number.isFinite(number) ? number : null;
6}
7
8function cleanText(value, limit = 1200) {
9  return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
10}
11
12function safeId(value) {
13  return String(value ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 80);
14}
15
16function safeMapId(value) {
17  return String(value ?? "").replace(/[^a-zA-Z0-9_:-]/g, "").slice(0, 120);
18}
19
20function decodeGviz(text) {
21  const match = String(text).match(/google\.visualization\.Query\.setResponse\((.*)\);?\s*$/s);
22  if (!match) throw new Error("The annotation feed did not return a Google Sheets table.");
23  const table = JSON.parse(match[1]);
24  const labels = (table.table?.cols || []).map((column) => String(column.label || "").trim());
25  return (table.table?.rows || []).map((row) => {
26    const record = {};
27    labels.forEach((label, index) => { record[label] = row.c?.[index]?.v ?? ""; });
28    return record;
29  });
30}
31
32function parseCsv(text) {
33  const rows = [];
34  let row = [];
35  let cell = "";
36  let quoted = false;
37  const source = String(text || "").replace(/^\uFEFF/, "");
38
39  for (let index = 0; index < source.length; index += 1) {
40    const character = source[index];
41    if (quoted) {
42      if (character === '"' && source[index + 1] === '"') {
43        cell += '"';
44        index += 1;
45      } else if (character === '"') {
46        quoted = false;
47      } else {
48        cell += character;
49      }
50      continue;
51    }
52    if (character === '"') {
53      quoted = true;
54    } else if (character === ",") {
55      row.push(cell);
56      cell = "";
57    } else if (character === "\n" || character === "\r") {
58      if (character === "\r" && source[index + 1] === "\n") index += 1;
59      row.push(cell);
60      if (row.some((value) => value !== "")) rows.push(row);
61      row = [];
62      cell = "";
63    } else {
64      cell += character;
65    }
66  }
67  row.push(cell);
68  if (row.some((value) => value !== "")) rows.push(row);
69
70  const [headers = [], ...body] = rows;
71  return body.map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""])));
72}
73
74function decodeFeed(text) {
75  return String(text).includes("google.visualization.Query.setResponse") ? decodeGviz(text) : parseCsv(text);
76}
77
78function parseContext(value) {
79  const params = new URLSearchParams(String(value || ""));
80  const point = String(params.get("point") || "").split(",").map(finiteNumber);
81  const core = String(params.get("core") || "").split(",").map(finiteNumber);
82  const camera = String(params.get("camera") || "").split(",").map(finiteNumber);
83  const mapId = safeMapId(params.get("map"));
84  if (!mapId || point.length !== 2 || point.some((item) => item === null)) return null;
85  return {
86    id: safeId(params.get("id")) || null,
87    mapId,
88    title: cleanText(params.get("title"), 240),
89    city: cleanText(params.get("city"), 100),
90    year: finiteNumber(params.get("year")),
91    point: { lng: point[0], lat: point[1] },
92    core: core.length === 2 && core.every((item) => item !== null) ? { lng: core[0], lat: core[1] } : null,
93    camera: camera.length >= 3 && camera.slice(0, 3).every((item) => item !== null)
94      ? { lng: camera[0], lat: camera[1], zoom: camera[2], bearing: camera[3] ?? 0, pitch: camera[4] ?? 0 }
95      : null,
96    basemap: cleanText(params.get("ground"), 20),
97    opacity: finiteNumber(params.get("veil"))
98  };
99}
100
101function toTimestamp(value) {
102  const parsed = Date.parse(String(value || ""));
103  return Number.isFinite(parsed) ? parsed : 0;
104}
105
106function normaliseRow(row, labels) {
107  const context = parseContext(row[labels.context]);
108  const text = cleanText(row[labels.annotation], 1200);
109  if (!context || !text) return null;
110  const timestamp = String(row[labels.timestamp] || "");
111  const tag = cleanText(row[labels.tag], 80).replace(/^#+/, "");
112  const tags = tag.split(",").map((item) => item.trim().replace(/^#+/, "")).filter(Boolean).slice(0, 8);
113  const name = cleanText(row[labels.name], 80);
114  const fallback = `${context.mapId}-${timestamp}-${text}`.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 120);
115  return {
116    id: context.id || fallback,
117    timestamp,
118    timestampValue: toTimestamp(timestamp),
119    text,
120    tag,
121    tags,
122    name,
123    context
124  };
125}
126
127function contextParam({ map, point, core, camera, basemap, opacity }) {
128  const params = new URLSearchParams();
129  const annotationId = `note-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
130  const rounded = (value, decimals = 5) => Number(value).toFixed(decimals);
131  params.set("id", annotationId);
132  params.set("map", map.id);
133  params.set("title", map.title);
134  params.set("city", map.city);
135  params.set("year", String(map.year));
136  params.set("point", `${rounded(point.lng)},${rounded(point.lat)}`);
137  if (core) params.set("core", `${rounded(core.lng)},${rounded(core.lat)}`);
138  if (camera) params.set("camera", [rounded(camera.lng), rounded(camera.lat), rounded(camera.zoom, 2), rounded(camera.bearing || 0, 1), rounded(camera.pitch || 0, 1)].join(","));
139  if (basemap) params.set("ground", basemap);
140  if (Number.isFinite(opacity)) params.set("veil", String(Math.round(opacity * 100)));
141  return params.toString();
142}
143
144export class AnnotationStore {
145  constructor(config) {
146    this.config = config || {};
147    this.annotations = [];
148    this.lastFetchedAt = 0;
149  }
150
151  get live() {
152    return this.config?.status === "live" && Boolean(this.config?.sheet?.feedUrl) && Boolean(this.config?.form?.viewUrl);
153  }
154
155  static async load() {
156    try {
157      const response = await fetch(CONFIG_PATH, { cache: "no-store" });
158      if (!response.ok) throw new Error(`Could not read annotations configuration (${response.status})`);
159      return new AnnotationStore(await response.json());
160    } catch (error) {
161      console.warn("Annotations are unavailable.", error);
162      return new AnnotationStore({ status: "unavailable" });
163    }
164  }
165
166  async refresh({ force = false } = {}) {
167    if (!this.live) return this.annotations;
168    if (!force && Date.now() - this.lastFetchedAt < 20_000) return this.annotations;
169    const feedUrl = new URL(this.config.sheet.feedUrl);
170    if (force) feedUrl.searchParams.set("_hypercities", String(Date.now()));
171    const response = await fetch(feedUrl, { cache: "no-store" });
172    if (!response.ok) throw new Error(`The annotations feed returned ${response.status}.`);
173    const rows = decodeFeed(await response.text());
174    const labels = this.config.labels || {};
175    this.annotations = rows.map((row) => normaliseRow(row, labels)).filter(Boolean)
176      .sort((a, b) => b.timestampValue - a.timestampValue || b.id.localeCompare(a.id));
177    this.lastFetchedAt = Date.now();
178    return this.annotations;
179  }
180
181  forMap(mapId) {
182    return this.annotations.filter((annotation) => annotation.context.mapId === mapId);
183  }
184
185  buildFormUrl(context) {
186    if (!this.live) return null;
187    const entries = this.config.form?.entries || {};
188    if (!entries.annotation || !entries.tag || !entries.context) return null;
189    const url = new URL(this.config.form.viewUrl);
190    url.searchParams.set("usp", "pp_url");
191    url.searchParams.set(entries.context, contextParam(context));
192    return url.toString();
193  }
194
195  buildFormSubmission(context) {
196    if (!this.live) return null;
197    const entries = this.config.form?.entries || {};
198    if (!this.config.form?.submitUrl || !entries.annotation || !entries.tag || !entries.name || !entries.context) return null;
199    return {
200      action: this.config.form.submitUrl,
201      entries,
202      context: contextParam(context)
203    };
204  }
205}
206
207export function annotationContext({ map, point, core, camera, basemap, opacity }) {
208  return { map, point, core, camera, basemap, opacity };
209}
210
211export function annotationDeepLink(annotation) {
212  const params = new URLSearchParams({ map: annotation.context.mapId, annotation: annotation.id });
213  if (annotation.simulated) params.set("simulateNotes", String(annotation.simulationCount || 60));
214  return `./?${params.toString()}`;
215}

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.