search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: rename constellation to atlas and add search

Rename all files, routes, docs, and references from "constellation"
to "atlas" (constellation conflicts with an existing ATProto service).

Add search box to atlas page — type a query, it calls the semantic
search API, matches result URIs to points on the canvas, computes a
weighted centroid, and animates the view to center on the results
with matched points highlighted. Cmd+K to focus, Escape to clear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+634 -392
+1 -1
.gitignore
··· 6 6 .zig-cache/ 7 7 zig-out/ 8 8 .loq_cache 9 - site/constellation.json 9 + site/atlas.json
+3 -3
README.md
··· 61 61 62 62 the backend indexes multiple ATProto platforms - currently `pub.leaflet.*` and `site.standard.*` collections. platform is stored per-document and returned in search results. 63 63 64 - ## constellation 64 + ## atlas 65 65 66 - a 2D semantic map of the entire document index: [pub-search.waow.tech/constellation](https://pub-search.waow.tech/constellation) 66 + a 2D semantic map of the entire document index: [pub-search.waow.tech/atlas](https://pub-search.waow.tech/atlas) 67 67 68 68 documents are projected from 1024-dim voyage embeddings to 2D via PCA → UMAP, then clustered with HDBSCAN at two granularities. each point is colored by platform. zoom in to see finer cluster labels and individual document titles. 69 69 70 - built with `scripts/build-constellation` (batch job, ~20s) → `site/constellation.json` → canvas renderer. see [docs/constellation.md](docs/constellation.md) for details. 70 + built with `scripts/build-atlas` (batch job, ~20s) → `site/atlas.json` → canvas renderer. see [docs/atlas.md](docs/atlas.md) for details. 71 71 72 72 ## [stack](https://bsky.app/profile/zzstoatzz.io/post/3mbij5ip4ws2a) 73 73
+1 -1
docs/bridgy-fed.md
··· 8 8 9 9 **attempt 1 (early 2026):** bridgy fed content flooded the index — tens of thousands of short fediverse posts mixed in with long-form articles. search results became polluted with content that wasn't meaningfully "published" in the way leaflet/whitewind/etc. content is. we added `is_bridgyfed` column to turso and marked all bridgy fed documents, then excluded them from search results. 10 10 11 - **attempt 2 (later):** even with search exclusion, the vectors remained in turbopuffer and polluted semantic search and the constellation visualization. had to run `scripts/purge-bridgyfed-vectors` to clean up ~26k orphan vectors. 11 + **attempt 2 (later):** even with search exclusion, the vectors remained in turbopuffer and polluted semantic search and the atlas visualization. had to run `scripts/purge-bridgyfed-vectors` to clean up ~26k orphan vectors. 12 12 13 13 **current state:** bridgy fed content is now **dropped at ingest** in the backend. the tap still receives it (can't filter at the firehose level), but the backend's ingest pipeline checks the PDS endpoint and silently drops any DID hosted on `brid.gy`. this is the cleanest solution — no storage, no cleanup needed. 14 14
+9 -9
docs/constellation.md docs/atlas.md
··· 1 - # constellation 1 + # atlas 2 2 3 3 2D semantic map of the document index. each document is a point on a canvas, positioned by semantic similarity and colored by platform. 4 4 5 - **live:** [pub-search.waow.tech/constellation](https://pub-search.waow.tech/constellation) 5 + **live:** [pub-search.waow.tech/atlas](https://pub-search.waow.tech/atlas) 6 6 7 7 ## data pipeline 8 8 9 - `scripts/build-constellation` is a batch python script (uv inline dependencies) that: 9 + `scripts/build-atlas` is a batch python script (uv inline dependencies) that: 10 10 11 11 1. **exports vectors** from turbopuffer — paginated query with `rank_by: ["id", "asc"]`, fetches all ~12k vectors + metadata 12 12 2. **PCA 1024 → 50** — denoising pass, typically captures ~60% variance ··· 16 16 - fine: `min_cluster_size=20` (~160 clusters, zoomed-in labels) 17 17 - outliers assigned to nearest cluster centroid 18 18 5. **c-TF-IDF** on document titles per cluster → 3-term labels 19 - 6. **outputs** `site/constellation.json` (~3MB, gitignored) 19 + 6. **outputs** `site/atlas.json` (~3MB, gitignored) 20 20 21 21 run time: ~20s. dependencies: `umap-learn`, `hdbscan`, `scikit-learn`, `httpx`, `numpy`, `pydantic-settings`. 22 22 23 23 ```bash 24 - ./scripts/build-constellation # writes site/constellation.json 25 - ./scripts/build-constellation -o out.json # custom output path 24 + ./scripts/build-atlas # writes site/atlas.json 25 + ./scripts/build-atlas -o out.json # custom output path 26 26 ``` 27 27 28 28 ## frontend 29 29 30 - `site/constellation.html` + `site/constellation.js` + `site/constellation.css` 30 + `site/atlas.html` + `site/atlas.js` + `site/atlas.css` 31 31 32 32 - **canvas 2D** renderer — no libraries, sprite-based (pre-rendered offscreen canvas per platform) 33 33 - **pan/zoom** via wheel, drag, touch/pinch (max 15×) ··· 38 38 39 39 ## recomputing 40 40 41 - the constellation is a point-in-time snapshot. rerun the build script when the index changes meaningfully: 41 + the atlas is a point-in-time snapshot. rerun the build script when the index changes meaningfully: 42 42 43 43 ```bash 44 - ./scripts/build-constellation 44 + ./scripts/build-atlas 45 45 cd site && wrangler pages deploy . --project-name leaflet-search 46 46 ``` 47 47
+356
scripts/build-atlas
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["httpx", "numpy", "scikit-learn", "umap-learn", "hdbscan", "pydantic-settings"] 5 + # /// 6 + """ 7 + Build atlas.json — 2D semantic map of the document index. 8 + 9 + Exports vectors from turbopuffer, projects to 2D via PCA+UMAP, 10 + clusters with HDBSCAN at two granularities, labels via c-TF-IDF. 11 + 12 + Usage: 13 + ./scripts/build-atlas # writes site/atlas.json 14 + ./scripts/build-atlas --output out.json 15 + """ 16 + 17 + import argparse 18 + import json 19 + import os 20 + import re 21 + import sys 22 + import time 23 + from collections import Counter 24 + from pathlib import Path 25 + 26 + import httpx 27 + import numpy as np 28 + from pydantic_settings import BaseSettings, SettingsConfigDict 29 + from sklearn.decomposition import PCA 30 + from sklearn.feature_extraction.text import TfidfVectorizer 31 + 32 + 33 + def log(msg: str) -> None: 34 + print(msg, flush=True) 35 + 36 + 37 + class Settings(BaseSettings): 38 + model_config = SettingsConfigDict( 39 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 40 + ) 41 + 42 + turbopuffer_api_key: str 43 + turbopuffer_namespace: str = "leaflet-search" 44 + 45 + @classmethod 46 + def settings_customise_sources(cls, settings_cls, **kwargs): 47 + """Dotenv file wins over environment variables.""" 48 + return ( 49 + kwargs["init_settings"], 50 + kwargs["dotenv_settings"], 51 + kwargs["env_settings"], 52 + kwargs["file_secret_settings"], 53 + ) 54 + 55 + 56 + def tpuf_export(client: httpx.Client, settings: Settings) -> list[dict]: 57 + """Export all vectors from turbopuffer via paginated query.""" 58 + url = f"https://api.turbopuffer.com/v2/namespaces/{settings.turbopuffer_namespace}/query" 59 + headers = { 60 + "Authorization": f"Bearer {settings.turbopuffer_api_key}", 61 + "Content-Type": "application/json", 62 + } 63 + attrs = ["uri", "title", "platform", "base_path", "did", "vector"] 64 + all_rows = [] 65 + last_id = None 66 + 67 + while True: 68 + body: dict = { 69 + "rank_by": ["id", "asc"], 70 + "limit": 10000, 71 + "include_attributes": attrs, 72 + } 73 + if last_id is not None: 74 + body["filters"] = ["id", "Gt", last_id] 75 + 76 + resp = client.post(url, headers=headers, json=body, timeout=120) 77 + resp.raise_for_status() 78 + rows = resp.json().get("rows", []) 79 + if not rows: 80 + break 81 + 82 + all_rows.extend(rows) 83 + last_id = rows[-1]["id"] 84 + log(f" fetched {len(all_rows)} vectors so far...") 85 + 86 + if len(rows) < 10000: 87 + break 88 + 89 + return all_rows 90 + 91 + 92 + def extract_terms(title: str) -> list[str]: 93 + """Extract lowercase alphanumeric tokens from a title.""" 94 + if not title: 95 + return [] 96 + return [t for t in re.findall(r"[a-z0-9]+", title.lower()) if len(t) > 1] 97 + 98 + 99 + def cluster_labels(titles_per_cluster: dict[int, list[str]], n_terms: int = 3) -> dict[int, str]: 100 + """Compute c-TF-IDF labels for clusters from document titles.""" 101 + cluster_ids = sorted(titles_per_cluster.keys()) 102 + if not cluster_ids: 103 + return {} 104 + 105 + # build one "document" per cluster: concatenated titles 106 + docs = [] 107 + for cid in cluster_ids: 108 + docs.append(" ".join(titles_per_cluster[cid])) 109 + 110 + # TF-IDF across cluster pseudo-documents 111 + vectorizer = TfidfVectorizer( 112 + max_features=5000, 113 + stop_words="english", 114 + token_pattern=r"[a-z0-9]{2,}", 115 + lowercase=True, 116 + ) 117 + try: 118 + tfidf = vectorizer.fit_transform(docs) 119 + except ValueError: 120 + return {cid: f"cluster {cid}" for cid in cluster_ids} 121 + 122 + feature_names = vectorizer.get_feature_names_out() 123 + labels = {} 124 + for i, cid in enumerate(cluster_ids): 125 + row = tfidf[i].toarray().flatten() 126 + top_idx = row.argsort()[-n_terms:][::-1] 127 + top_terms = [feature_names[j] for j in top_idx if row[j] > 0] 128 + labels[cid] = " ".join(top_terms) if top_terms else f"cluster {cid}" 129 + 130 + return labels 131 + 132 + 133 + def assign_outliers(coords_2d: np.ndarray, labels: np.ndarray, centroids: dict[int, np.ndarray]) -> np.ndarray: 134 + """Assign outlier points (label == -1) to nearest cluster centroid.""" 135 + result = labels.copy() 136 + outlier_mask = result == -1 137 + if not outlier_mask.any() or not centroids: 138 + return result 139 + 140 + centroid_ids = sorted(centroids.keys()) 141 + centroid_arr = np.array([centroids[c] for c in centroid_ids]) 142 + outlier_coords = coords_2d[outlier_mask] 143 + 144 + # nearest centroid for each outlier 145 + dists = np.linalg.norm(outlier_coords[:, None] - centroid_arr[None, :], axis=2) 146 + nearest = dists.argmin(axis=1) 147 + result[outlier_mask] = np.array(centroid_ids)[nearest] 148 + 149 + return result 150 + 151 + 152 + def main(): 153 + parser = argparse.ArgumentParser(description="Build atlas.json") 154 + parser.add_argument( 155 + "--output", "-o", 156 + default=str(Path(__file__).resolve().parent.parent / "site" / "atlas.json"), 157 + help="Output path (default: site/atlas.json)", 158 + ) 159 + args = parser.parse_args() 160 + 161 + try: 162 + settings = Settings() # type: ignore 163 + except Exception as e: 164 + print(f"error loading settings: {e}", file=sys.stderr) 165 + print("required env vars: TURBOPUFFER_API_KEY", file=sys.stderr) 166 + sys.exit(1) 167 + 168 + t_start = time.monotonic() 169 + 170 + # --- step 1: export vectors from turbopuffer --- 171 + log("exporting vectors from turbopuffer...") 172 + client = httpx.Client() 173 + rows = tpuf_export(client, settings) 174 + client.close() 175 + log(f" got {len(rows)} documents") 176 + 177 + if len(rows) < 10: 178 + print("too few documents to build atlas", file=sys.stderr) 179 + sys.exit(1) 180 + 181 + # parse into arrays 182 + vectors = [] 183 + metadata = [] 184 + for row in rows: 185 + vec = row.get("vector") 186 + if not vec: 187 + continue 188 + vectors.append(vec) 189 + metadata.append({ 190 + "uri": row.get("uri", row.get("id", "")), 191 + "title": row.get("title", ""), 192 + "platform": row.get("platform", "other"), 193 + "basePath": row.get("base_path", ""), 194 + "did": row.get("did", ""), 195 + }) 196 + 197 + X = np.array(vectors, dtype=np.float32) 198 + log(f" {X.shape[0]} vectors, {X.shape[1]} dims") 199 + 200 + # --- step 2: dimensionality reduction --- 201 + log("PCA 1024 → 50...") 202 + n_components_pca = min(50, X.shape[0] - 1, X.shape[1]) 203 + pca = PCA(n_components=n_components_pca, random_state=42) 204 + X_pca = pca.fit_transform(X) 205 + log(f" variance explained: {pca.explained_variance_ratio_.sum():.2%}") 206 + 207 + log("UMAP 50 → 2...") 208 + import umap 209 + reducer = umap.UMAP( 210 + n_components=2, 211 + metric="cosine", 212 + n_neighbors=15, 213 + min_dist=0.1, 214 + random_state=42, 215 + ) 216 + X_2d = reducer.fit_transform(X_pca) 217 + 218 + # normalize to [-1, 1] range 219 + for dim in range(2): 220 + lo, hi = X_2d[:, dim].min(), X_2d[:, dim].max() 221 + if hi > lo: 222 + X_2d[:, dim] = 2 * (X_2d[:, dim] - lo) / (hi - lo) - 1 223 + 224 + log(f" 2D range: x=[{X_2d[:,0].min():.2f}, {X_2d[:,0].max():.2f}] y=[{X_2d[:,1].min():.2f}, {X_2d[:,1].max():.2f}]") 225 + 226 + # --- step 3: clustering --- 227 + import hdbscan 228 + 229 + log("HDBSCAN coarse (min_cluster_size=100)...") 230 + clusterer_coarse = hdbscan.HDBSCAN(min_cluster_size=100, min_samples=5) 231 + labels_coarse_raw = clusterer_coarse.fit_predict(X_2d) 232 + n_coarse = len(set(labels_coarse_raw)) - (1 if -1 in labels_coarse_raw else 0) 233 + n_outliers_coarse = (labels_coarse_raw == -1).sum() 234 + log(f" {n_coarse} clusters, {n_outliers_coarse} outliers") 235 + 236 + # compute centroids for coarse 237 + coarse_centroids = {} 238 + for cid in set(labels_coarse_raw): 239 + if cid == -1: 240 + continue 241 + mask = labels_coarse_raw == cid 242 + coarse_centroids[cid] = X_2d[mask].mean(axis=0) 243 + 244 + labels_coarse = assign_outliers(X_2d, labels_coarse_raw, coarse_centroids) 245 + # recompute centroids after outlier assignment 246 + for cid in set(labels_coarse): 247 + mask = labels_coarse == cid 248 + coarse_centroids[cid] = X_2d[mask].mean(axis=0) 249 + 250 + log("HDBSCAN fine (min_cluster_size=20)...") 251 + clusterer_fine = hdbscan.HDBSCAN(min_cluster_size=20, min_samples=3) 252 + labels_fine_raw = clusterer_fine.fit_predict(X_2d) 253 + n_fine = len(set(labels_fine_raw)) - (1 if -1 in labels_fine_raw else 0) 254 + n_outliers_fine = (labels_fine_raw == -1).sum() 255 + log(f" {n_fine} clusters, {n_outliers_fine} outliers") 256 + 257 + fine_centroids = {} 258 + for cid in set(labels_fine_raw): 259 + if cid == -1: 260 + continue 261 + mask = labels_fine_raw == cid 262 + fine_centroids[cid] = X_2d[mask].mean(axis=0) 263 + 264 + labels_fine = assign_outliers(X_2d, labels_fine_raw, fine_centroids) 265 + for cid in set(labels_fine): 266 + mask = labels_fine == cid 267 + fine_centroids[cid] = X_2d[mask].mean(axis=0) 268 + 269 + # --- step 4: labels via c-TF-IDF --- 270 + log("computing cluster labels (c-TF-IDF on titles)...") 271 + 272 + coarse_titles: dict[int, list[str]] = {} 273 + fine_titles: dict[int, list[str]] = {} 274 + for i, meta in enumerate(metadata): 275 + title = meta.get("title", "") 276 + if title: 277 + coarse_titles.setdefault(int(labels_coarse[i]), []).append(title) 278 + fine_titles.setdefault(int(labels_fine[i]), []).append(title) 279 + 280 + coarse_labels = cluster_labels(coarse_titles) 281 + fine_labels = cluster_labels(fine_titles) 282 + 283 + # map fine clusters to their parent coarse cluster (majority vote) 284 + fine_to_coarse = {} 285 + for fine_id in set(labels_fine): 286 + fine_mask = labels_fine == fine_id 287 + coarse_for_fine = labels_coarse[fine_mask] 288 + counts = Counter(coarse_for_fine) 289 + fine_to_coarse[int(fine_id)] = int(counts.most_common(1)[0][0]) 290 + 291 + log(f" coarse: {len(coarse_labels)} labels") 292 + log(f" fine: {len(fine_labels)} labels") 293 + 294 + # --- step 5: build output --- 295 + log("building output...") 296 + 297 + points = [] 298 + for i, meta in enumerate(metadata): 299 + points.append({ 300 + "x": round(float(X_2d[i, 0]), 4), 301 + "y": round(float(X_2d[i, 1]), 4), 302 + "uri": meta["uri"], 303 + "title": meta["title"], 304 + "platform": meta["platform"], 305 + "basePath": meta["basePath"], 306 + "clusterCoarse": int(labels_coarse[i]), 307 + "clusterFine": int(labels_fine[i]), 308 + }) 309 + 310 + coarse_clusters = [] 311 + for cid in sorted(coarse_centroids.keys()): 312 + count = int((labels_coarse == cid).sum()) 313 + coarse_clusters.append({ 314 + "id": int(cid), 315 + "label": coarse_labels.get(int(cid), f"cluster {cid}"), 316 + "cx": round(float(coarse_centroids[cid][0]), 4), 317 + "cy": round(float(coarse_centroids[cid][1]), 4), 318 + "count": count, 319 + }) 320 + 321 + fine_clusters = [] 322 + for cid in sorted(fine_centroids.keys()): 323 + count = int((labels_fine == cid).sum()) 324 + fine_clusters.append({ 325 + "id": int(cid), 326 + "label": fine_labels.get(int(cid), f"cluster {cid}"), 327 + "cx": round(float(fine_centroids[cid][0]), 4), 328 + "cy": round(float(fine_centroids[cid][1]), 4), 329 + "count": count, 330 + "parent": fine_to_coarse.get(int(cid), 0), 331 + }) 332 + 333 + output = { 334 + "points": points, 335 + "clusters": { 336 + "coarse": coarse_clusters, 337 + "fine": fine_clusters, 338 + }, 339 + "meta": { 340 + "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), 341 + "nDocuments": len(points), 342 + }, 343 + } 344 + 345 + out_path = Path(args.output) 346 + out_path.parent.mkdir(parents=True, exist_ok=True) 347 + out_path.write_text(json.dumps(output, separators=(",", ":"))) 348 + 349 + size_kb = out_path.stat().st_size / 1024 350 + elapsed = time.monotonic() - t_start 351 + log(f"\nwrote {out_path} ({size_kb:.0f} KB, {len(points)} points)") 352 + log(f"done in {elapsed:.1f}s") 353 + 354 + 355 + if __name__ == "__main__": 356 + main()
-356
scripts/build-constellation
··· 1 - #!/usr/bin/env -S uv run --script --quiet 2 - # /// script 3 - # requires-python = ">=3.12" 4 - # dependencies = ["httpx", "numpy", "scikit-learn", "umap-learn", "hdbscan", "pydantic-settings"] 5 - # /// 6 - """ 7 - Build constellation.json — 2D semantic map of the document index. 8 - 9 - Exports vectors from turbopuffer, projects to 2D via PCA+UMAP, 10 - clusters with HDBSCAN at two granularities, labels via c-TF-IDF. 11 - 12 - Usage: 13 - ./scripts/build-constellation # writes site/constellation.json 14 - ./scripts/build-constellation --output out.json 15 - """ 16 - 17 - import argparse 18 - import json 19 - import os 20 - import re 21 - import sys 22 - import time 23 - from collections import Counter 24 - from pathlib import Path 25 - 26 - import httpx 27 - import numpy as np 28 - from pydantic_settings import BaseSettings, SettingsConfigDict 29 - from sklearn.decomposition import PCA 30 - from sklearn.feature_extraction.text import TfidfVectorizer 31 - 32 - 33 - def log(msg: str) -> None: 34 - print(msg, flush=True) 35 - 36 - 37 - class Settings(BaseSettings): 38 - model_config = SettingsConfigDict( 39 - env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 40 - ) 41 - 42 - turbopuffer_api_key: str 43 - turbopuffer_namespace: str = "leaflet-search" 44 - 45 - @classmethod 46 - def settings_customise_sources(cls, settings_cls, **kwargs): 47 - """Dotenv file wins over environment variables.""" 48 - return ( 49 - kwargs["init_settings"], 50 - kwargs["dotenv_settings"], 51 - kwargs["env_settings"], 52 - kwargs["file_secret_settings"], 53 - ) 54 - 55 - 56 - def tpuf_export(client: httpx.Client, settings: Settings) -> list[dict]: 57 - """Export all vectors from turbopuffer via paginated query.""" 58 - url = f"https://api.turbopuffer.com/v2/namespaces/{settings.turbopuffer_namespace}/query" 59 - headers = { 60 - "Authorization": f"Bearer {settings.turbopuffer_api_key}", 61 - "Content-Type": "application/json", 62 - } 63 - attrs = ["uri", "title", "platform", "base_path", "did", "vector"] 64 - all_rows = [] 65 - last_id = None 66 - 67 - while True: 68 - body: dict = { 69 - "rank_by": ["id", "asc"], 70 - "limit": 10000, 71 - "include_attributes": attrs, 72 - } 73 - if last_id is not None: 74 - body["filters"] = ["id", "Gt", last_id] 75 - 76 - resp = client.post(url, headers=headers, json=body, timeout=120) 77 - resp.raise_for_status() 78 - rows = resp.json().get("rows", []) 79 - if not rows: 80 - break 81 - 82 - all_rows.extend(rows) 83 - last_id = rows[-1]["id"] 84 - log(f" fetched {len(all_rows)} vectors so far...") 85 - 86 - if len(rows) < 10000: 87 - break 88 - 89 - return all_rows 90 - 91 - 92 - def extract_terms(title: str) -> list[str]: 93 - """Extract lowercase alphanumeric tokens from a title.""" 94 - if not title: 95 - return [] 96 - return [t for t in re.findall(r"[a-z0-9]+", title.lower()) if len(t) > 1] 97 - 98 - 99 - def cluster_labels(titles_per_cluster: dict[int, list[str]], n_terms: int = 3) -> dict[int, str]: 100 - """Compute c-TF-IDF labels for clusters from document titles.""" 101 - cluster_ids = sorted(titles_per_cluster.keys()) 102 - if not cluster_ids: 103 - return {} 104 - 105 - # build one "document" per cluster: concatenated titles 106 - docs = [] 107 - for cid in cluster_ids: 108 - docs.append(" ".join(titles_per_cluster[cid])) 109 - 110 - # TF-IDF across cluster pseudo-documents 111 - vectorizer = TfidfVectorizer( 112 - max_features=5000, 113 - stop_words="english", 114 - token_pattern=r"[a-z0-9]{2,}", 115 - lowercase=True, 116 - ) 117 - try: 118 - tfidf = vectorizer.fit_transform(docs) 119 - except ValueError: 120 - return {cid: f"cluster {cid}" for cid in cluster_ids} 121 - 122 - feature_names = vectorizer.get_feature_names_out() 123 - labels = {} 124 - for i, cid in enumerate(cluster_ids): 125 - row = tfidf[i].toarray().flatten() 126 - top_idx = row.argsort()[-n_terms:][::-1] 127 - top_terms = [feature_names[j] for j in top_idx if row[j] > 0] 128 - labels[cid] = " ".join(top_terms) if top_terms else f"cluster {cid}" 129 - 130 - return labels 131 - 132 - 133 - def assign_outliers(coords_2d: np.ndarray, labels: np.ndarray, centroids: dict[int, np.ndarray]) -> np.ndarray: 134 - """Assign outlier points (label == -1) to nearest cluster centroid.""" 135 - result = labels.copy() 136 - outlier_mask = result == -1 137 - if not outlier_mask.any() or not centroids: 138 - return result 139 - 140 - centroid_ids = sorted(centroids.keys()) 141 - centroid_arr = np.array([centroids[c] for c in centroid_ids]) 142 - outlier_coords = coords_2d[outlier_mask] 143 - 144 - # nearest centroid for each outlier 145 - dists = np.linalg.norm(outlier_coords[:, None] - centroid_arr[None, :], axis=2) 146 - nearest = dists.argmin(axis=1) 147 - result[outlier_mask] = np.array(centroid_ids)[nearest] 148 - 149 - return result 150 - 151 - 152 - def main(): 153 - parser = argparse.ArgumentParser(description="Build constellation.json") 154 - parser.add_argument( 155 - "--output", "-o", 156 - default=str(Path(__file__).resolve().parent.parent / "site" / "constellation.json"), 157 - help="Output path (default: site/constellation.json)", 158 - ) 159 - args = parser.parse_args() 160 - 161 - try: 162 - settings = Settings() # type: ignore 163 - except Exception as e: 164 - print(f"error loading settings: {e}", file=sys.stderr) 165 - print("required env vars: TURBOPUFFER_API_KEY", file=sys.stderr) 166 - sys.exit(1) 167 - 168 - t_start = time.monotonic() 169 - 170 - # --- step 1: export vectors from turbopuffer --- 171 - log("exporting vectors from turbopuffer...") 172 - client = httpx.Client() 173 - rows = tpuf_export(client, settings) 174 - client.close() 175 - log(f" got {len(rows)} documents") 176 - 177 - if len(rows) < 10: 178 - print("too few documents to build constellation", file=sys.stderr) 179 - sys.exit(1) 180 - 181 - # parse into arrays 182 - vectors = [] 183 - metadata = [] 184 - for row in rows: 185 - vec = row.get("vector") 186 - if not vec: 187 - continue 188 - vectors.append(vec) 189 - metadata.append({ 190 - "uri": row.get("uri", row.get("id", "")), 191 - "title": row.get("title", ""), 192 - "platform": row.get("platform", "other"), 193 - "basePath": row.get("base_path", ""), 194 - "did": row.get("did", ""), 195 - }) 196 - 197 - X = np.array(vectors, dtype=np.float32) 198 - log(f" {X.shape[0]} vectors, {X.shape[1]} dims") 199 - 200 - # --- step 2: dimensionality reduction --- 201 - log("PCA 1024 → 50...") 202 - n_components_pca = min(50, X.shape[0] - 1, X.shape[1]) 203 - pca = PCA(n_components=n_components_pca, random_state=42) 204 - X_pca = pca.fit_transform(X) 205 - log(f" variance explained: {pca.explained_variance_ratio_.sum():.2%}") 206 - 207 - log("UMAP 50 → 2...") 208 - import umap 209 - reducer = umap.UMAP( 210 - n_components=2, 211 - metric="cosine", 212 - n_neighbors=15, 213 - min_dist=0.1, 214 - random_state=42, 215 - ) 216 - X_2d = reducer.fit_transform(X_pca) 217 - 218 - # normalize to [-1, 1] range 219 - for dim in range(2): 220 - lo, hi = X_2d[:, dim].min(), X_2d[:, dim].max() 221 - if hi > lo: 222 - X_2d[:, dim] = 2 * (X_2d[:, dim] - lo) / (hi - lo) - 1 223 - 224 - log(f" 2D range: x=[{X_2d[:,0].min():.2f}, {X_2d[:,0].max():.2f}] y=[{X_2d[:,1].min():.2f}, {X_2d[:,1].max():.2f}]") 225 - 226 - # --- step 3: clustering --- 227 - import hdbscan 228 - 229 - log("HDBSCAN coarse (min_cluster_size=100)...") 230 - clusterer_coarse = hdbscan.HDBSCAN(min_cluster_size=100, min_samples=5) 231 - labels_coarse_raw = clusterer_coarse.fit_predict(X_2d) 232 - n_coarse = len(set(labels_coarse_raw)) - (1 if -1 in labels_coarse_raw else 0) 233 - n_outliers_coarse = (labels_coarse_raw == -1).sum() 234 - log(f" {n_coarse} clusters, {n_outliers_coarse} outliers") 235 - 236 - # compute centroids for coarse 237 - coarse_centroids = {} 238 - for cid in set(labels_coarse_raw): 239 - if cid == -1: 240 - continue 241 - mask = labels_coarse_raw == cid 242 - coarse_centroids[cid] = X_2d[mask].mean(axis=0) 243 - 244 - labels_coarse = assign_outliers(X_2d, labels_coarse_raw, coarse_centroids) 245 - # recompute centroids after outlier assignment 246 - for cid in set(labels_coarse): 247 - mask = labels_coarse == cid 248 - coarse_centroids[cid] = X_2d[mask].mean(axis=0) 249 - 250 - log("HDBSCAN fine (min_cluster_size=20)...") 251 - clusterer_fine = hdbscan.HDBSCAN(min_cluster_size=20, min_samples=3) 252 - labels_fine_raw = clusterer_fine.fit_predict(X_2d) 253 - n_fine = len(set(labels_fine_raw)) - (1 if -1 in labels_fine_raw else 0) 254 - n_outliers_fine = (labels_fine_raw == -1).sum() 255 - log(f" {n_fine} clusters, {n_outliers_fine} outliers") 256 - 257 - fine_centroids = {} 258 - for cid in set(labels_fine_raw): 259 - if cid == -1: 260 - continue 261 - mask = labels_fine_raw == cid 262 - fine_centroids[cid] = X_2d[mask].mean(axis=0) 263 - 264 - labels_fine = assign_outliers(X_2d, labels_fine_raw, fine_centroids) 265 - for cid in set(labels_fine): 266 - mask = labels_fine == cid 267 - fine_centroids[cid] = X_2d[mask].mean(axis=0) 268 - 269 - # --- step 4: labels via c-TF-IDF --- 270 - log("computing cluster labels (c-TF-IDF on titles)...") 271 - 272 - coarse_titles: dict[int, list[str]] = {} 273 - fine_titles: dict[int, list[str]] = {} 274 - for i, meta in enumerate(metadata): 275 - title = meta.get("title", "") 276 - if title: 277 - coarse_titles.setdefault(int(labels_coarse[i]), []).append(title) 278 - fine_titles.setdefault(int(labels_fine[i]), []).append(title) 279 - 280 - coarse_labels = cluster_labels(coarse_titles) 281 - fine_labels = cluster_labels(fine_titles) 282 - 283 - # map fine clusters to their parent coarse cluster (majority vote) 284 - fine_to_coarse = {} 285 - for fine_id in set(labels_fine): 286 - fine_mask = labels_fine == fine_id 287 - coarse_for_fine = labels_coarse[fine_mask] 288 - counts = Counter(coarse_for_fine) 289 - fine_to_coarse[int(fine_id)] = int(counts.most_common(1)[0][0]) 290 - 291 - log(f" coarse: {len(coarse_labels)} labels") 292 - log(f" fine: {len(fine_labels)} labels") 293 - 294 - # --- step 5: build output --- 295 - log("building output...") 296 - 297 - points = [] 298 - for i, meta in enumerate(metadata): 299 - points.append({ 300 - "x": round(float(X_2d[i, 0]), 4), 301 - "y": round(float(X_2d[i, 1]), 4), 302 - "uri": meta["uri"], 303 - "title": meta["title"], 304 - "platform": meta["platform"], 305 - "basePath": meta["basePath"], 306 - "clusterCoarse": int(labels_coarse[i]), 307 - "clusterFine": int(labels_fine[i]), 308 - }) 309 - 310 - coarse_clusters = [] 311 - for cid in sorted(coarse_centroids.keys()): 312 - count = int((labels_coarse == cid).sum()) 313 - coarse_clusters.append({ 314 - "id": int(cid), 315 - "label": coarse_labels.get(int(cid), f"cluster {cid}"), 316 - "cx": round(float(coarse_centroids[cid][0]), 4), 317 - "cy": round(float(coarse_centroids[cid][1]), 4), 318 - "count": count, 319 - }) 320 - 321 - fine_clusters = [] 322 - for cid in sorted(fine_centroids.keys()): 323 - count = int((labels_fine == cid).sum()) 324 - fine_clusters.append({ 325 - "id": int(cid), 326 - "label": fine_labels.get(int(cid), f"cluster {cid}"), 327 - "cx": round(float(fine_centroids[cid][0]), 4), 328 - "cy": round(float(fine_centroids[cid][1]), 4), 329 - "count": count, 330 - "parent": fine_to_coarse.get(int(cid), 0), 331 - }) 332 - 333 - output = { 334 - "points": points, 335 - "clusters": { 336 - "coarse": coarse_clusters, 337 - "fine": fine_clusters, 338 - }, 339 - "meta": { 340 - "generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), 341 - "nDocuments": len(points), 342 - }, 343 - } 344 - 345 - out_path = Path(args.output) 346 - out_path.parent.mkdir(parents=True, exist_ok=True) 347 - out_path.write_text(json.dumps(output, separators=(",", ":"))) 348 - 349 - size_kb = out_path.stat().st_size / 1024 350 - elapsed = time.monotonic() - t_start 351 - log(f"\nwrote {out_path} ({size_kb:.0f} KB, {len(points)} points)") 352 - log(f"done in {elapsed:.1f}s") 353 - 354 - 355 - if __name__ == "__main__": 356 - main()
+34
site/constellation.css site/atlas.css
··· 194 194 border-radius: 50%; 195 195 } 196 196 197 + /* search */ 198 + .search-form { 199 + display: flex; 200 + align-items: center; 201 + } 202 + 203 + #search-input { 204 + background: var(--bg-subtle); 205 + border: 1px solid var(--tooltip-border); 206 + color: var(--text); 207 + font-family: monospace; 208 + font-size: 11px; 209 + padding: 3px 8px; 210 + width: 140px; 211 + outline: none; 212 + transition: width 0.2s, border-color 0.2s; 213 + } 214 + 215 + #search-input:focus { 216 + width: 200px; 217 + border-color: var(--text-dim); 218 + } 219 + 220 + #search-input::placeholder { 221 + color: var(--text-muted); 222 + } 223 + 224 + .search-status { 225 + font-size: 10px; 226 + color: var(--text-dim); 227 + margin-left: 6px; 228 + white-space: nowrap; 229 + } 230 + 197 231 /* stats */ 198 232 .stats { 199 233 position: fixed;
+13 -10
site/constellation.html site/atlas.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 6 - <title>pub search / constellation</title> 6 + <title>pub search / atlas</title> 7 7 <meta name="description" content="2d semantic map of atproto publishing platforms"> 8 - <meta property="og:title" content="pub search / constellation"> 8 + <meta property="og:title" content="pub search / atlas"> 9 9 <meta property="og:description" content="2d semantic map of atproto publishing platforms"> 10 10 <meta property="og:type" content="website"> 11 - <meta property="og:image" content="https://pub-search.waow.tech/og-image?page=constellation"> 11 + <meta property="og:image" content="https://pub-search.waow.tech/og-image?page=atlas"> 12 12 <meta property="og:image:width" content="1200"> 13 13 <meta property="og:image:height" content="630"> 14 14 <meta name="twitter:card" content="summary_large_image"> 15 - <meta name="twitter:title" content="pub search / constellation"> 15 + <meta name="twitter:title" content="pub search / atlas"> 16 16 <meta name="twitter:description" content="2d semantic map of atproto publishing platforms"> 17 - <meta name="twitter:image" content="https://pub-search.waow.tech/og-image?page=constellation"> 17 + <meta name="twitter:image" content="https://pub-search.waow.tech/og-image?page=atlas"> 18 18 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='2' fill='%231B7340'/><circle cx='8' cy='10' r='1.5' fill='%231B7340' opacity='.6'/><circle cx='24' cy='8' r='1' fill='%231B7340' opacity='.4'/><circle cx='22' cy='22' r='1.5' fill='%231B7340' opacity='.5'/><circle cx='6' cy='24' r='1' fill='%231B7340' opacity='.3'/></svg>"> 19 19 <script> 20 20 (function() { ··· 23 23 document.documentElement.setAttribute('data-theme', t); 24 24 })(); 25 25 </script> 26 - <link rel="stylesheet" href="constellation.css"> 26 + <link rel="stylesheet" href="atlas.css"> 27 27 </head> 28 28 <body> 29 29 <div class="loading-overlay" id="loading"> 30 - <div class="spinner">loading constellation...</div> 30 + <div class="spinner">loading atlas...</div> 31 31 </div> 32 32 33 33 <canvas id="canvas"></canvas> 34 34 35 35 <div class="header"> 36 - <h1><a href="/">pub search</a> <span class="dim">/ constellation</span></h1> 36 + <h1><a href="/">pub search</a> <span class="dim">/ atlas</span></h1> 37 37 <div class="nav"> 38 + <form class="search-form" id="search-form"> 39 + <input type="text" id="search-input" placeholder="search..." autocomplete="off" spellcheck="false"> 40 + </form> 38 41 <a href="/dashboard.html">stats</a> 39 42 <span class="theme-toggle" id="theme-toggle"></span> 40 43 </div> ··· 58 61 if (theme === 'system') resolved = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; 59 62 document.documentElement.setAttribute('data-theme', resolved); 60 63 renderThemeToggle(); 61 - if (window.constellation) window.constellation.setDirty(); 64 + if (window.atlas) window.atlas.setDirty(); 62 65 } 63 66 var THEME_ICONS = { dark: '\u263E', light: '\u263C', system: '\u25D1' }; 64 67 var THEME_CYCLE = ['dark', 'light', 'system']; ··· 76 79 }); 77 80 renderThemeToggle(); 78 81 </script> 79 - <script src="constellation.js"></script> 82 + <script src="atlas.js"></script> 80 83 </body> 81 84 </html>
+208 -3
site/constellation.js site/atlas.js
··· 39 39 var pointsY = null; 40 40 var platformIdx = null; 41 41 var gridIndex = null; 42 + var uriToIndex = null; // Map<uri, index> for search matching 43 + 44 + // --- search state --- 45 + var searchMatches = null; // Set of point indices matching current search 46 + var searchCenter = null; // {x, y} weighted centroid of matches 47 + var searchQuery = ''; 48 + 49 + // --- animation state --- 50 + var animating = false; 51 + var animFrom = null; 52 + var animTo = null; 53 + var animStart = 0; 54 + var ANIM_DURATION = 600; // ms 42 55 43 56 // --- canvas --- 44 57 var canvas = document.getElementById('canvas'); ··· 316 329 } 317 330 } 318 331 332 + // --- search highlights --- 333 + if (searchMatches && searchMatches.size > 0) { 334 + // dim non-matching points by drawing a semi-transparent overlay 335 + ctx.globalAlpha = dark ? 0.6 : 0.5; 336 + ctx.fillStyle = dark ? '#050505' : '#f5f5f0'; 337 + ctx.fillRect(0, 0, W, H); 338 + 339 + // redraw matched points brighter 340 + ctx.globalAlpha = 1; 341 + searchMatches.forEach(function(i) { 342 + var px = pointsX[i], py = pointsY[i]; 343 + if (px < xMin || px > xMax || py < yMin || py > yMax) return; 344 + var sx = cx + px * scale, sy = cy + py * scale; 345 + var pi = platformIdx[i]; 346 + if (useGlow) { 347 + var spr = sprites[pi].hover; 348 + ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 349 + } else { 350 + var dot = dotSprites[pi]; 351 + ctx.drawImage(dot, sx - dot.width / (2 * dpr), sy - dot.height / (2 * dpr), dot.width / dpr, dot.height / dpr); 352 + } 353 + }); 354 + 355 + // draw search centroid marker 356 + if (searchCenter) { 357 + var mx = cx + searchCenter.x * scale, my = cy + searchCenter.y * scale; 358 + // crosshair 359 + ctx.strokeStyle = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'; 360 + ctx.lineWidth = 1; 361 + ctx.beginPath(); 362 + ctx.moveTo(mx - 12, my); ctx.lineTo(mx + 12, my); 363 + ctx.moveTo(mx, my - 12); ctx.lineTo(mx, my + 12); 364 + ctx.stroke(); 365 + // ring 366 + ctx.beginPath(); 367 + ctx.arc(mx, my, 6, 0, Math.PI * 2); 368 + ctx.strokeStyle = dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.5)'; 369 + ctx.lineWidth = 1.5; 370 + ctx.stroke(); 371 + // label 372 + ctx.font = '10px monospace'; 373 + ctx.fillStyle = dark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.5)'; 374 + ctx.textAlign = 'left'; 375 + ctx.textBaseline = 'top'; 376 + ctx.fillText('"' + searchQuery + '"', mx + 12, my - 6); 377 + } 378 + } 379 + 319 380 // --- labels (no shadowBlur — uses strokeText outline instead) --- 320 381 ctx.globalAlpha = 1; 321 382 ctx.fillStyle = dark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'; ··· 365 426 } 366 427 367 428 // --- animation loop --- 429 + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } 430 + 431 + function tickAnimation() { 432 + if (!animating) return; 433 + var t = Math.min(1, (Date.now() - animStart) / ANIM_DURATION); 434 + var e = easeOutCubic(t); 435 + view.zoom = animFrom.zoom + (animTo.zoom - animFrom.zoom) * e; 436 + view.panX = animFrom.panX + (animTo.panX - animFrom.panX) * e; 437 + view.panY = animFrom.panY + (animTo.panY - animFrom.panY) * e; 438 + view.dirty = true; 439 + if (t >= 1) animating = false; 440 + } 441 + 442 + function animateTo(targetX, targetY, targetZoom) { 443 + animFrom = { zoom: view.zoom, panX: view.panX, panY: view.panY }; 444 + animTo = { zoom: targetZoom, panX: -targetX, panY: -targetY }; 445 + animStart = Date.now(); 446 + animating = true; 447 + } 448 + 368 449 function loop() { 450 + tickAnimation(); 369 451 render(); 370 452 requestAnimationFrame(loop); 371 453 } ··· 528 610 } 529 611 530 612 function loadData() { 531 - fetch('constellation.json') 613 + fetch('atlas.json') 532 614 .then(function(r) { 533 - if (!r.ok) throw new Error('failed to load constellation.json: ' + r.status); 615 + if (!r.ok) throw new Error('failed to load atlas.json: ' + r.status); 534 616 return r.json(); 535 617 }) 536 618 .then(function(d) { ··· 547 629 pointsY[i] = d.points[i].y; 548 630 platformIdx[i] = platMap[d.points[i].platform] !== undefined ? platMap[d.points[i].platform] : otherIdx; 549 631 } 632 + // build URI → index map for search matching 633 + uriToIndex = new Map(); 634 + for (var i = 0; i < n; i++) { 635 + uriToIndex.set(d.points[i].uri, i); 636 + } 550 637 buildSpatialIndex(); 551 638 renderLegend(); 552 639 document.getElementById('stats').textContent = ··· 567 654 loadData(); 568 655 loop(); 569 656 570 - window.constellation = { 657 + // --- search --- 658 + var API_URL = 'https://leaflet-search-backend.fly.dev'; 659 + var searchInput = document.getElementById('search-input'); 660 + var searchForm = document.getElementById('search-form'); 661 + var searchStatusEl = null; 662 + 663 + function setSearchStatus(msg) { 664 + if (!searchStatusEl) { 665 + searchStatusEl = document.createElement('span'); 666 + searchStatusEl.className = 'search-status'; 667 + searchForm.appendChild(searchStatusEl); 668 + } 669 + searchStatusEl.textContent = msg; 670 + } 671 + 672 + function clearSearch() { 673 + searchMatches = null; 674 + searchCenter = null; 675 + searchQuery = ''; 676 + setSearchStatus(''); 677 + view.dirty = true; 678 + } 679 + 680 + function doSearch(query) { 681 + if (!query || !data || !uriToIndex) return; 682 + searchQuery = query; 683 + setSearchStatus('searching...'); 684 + 685 + fetch(API_URL + '/search?mode=semantic&limit=20&format=v2&q=' + encodeURIComponent(query)) 686 + .then(function(r) { 687 + if (!r.ok) throw new Error('search failed: ' + r.status); 688 + return r.json(); 689 + }) 690 + .then(function(resp) { 691 + var results = resp.results || []; 692 + if (results.length === 0) { 693 + setSearchStatus('no results'); 694 + searchMatches = null; 695 + searchCenter = null; 696 + view.dirty = true; 697 + return; 698 + } 699 + 700 + // match result URIs to atlas points 701 + var matches = new Set(); 702 + var weightedX = 0, weightedY = 0, totalWeight = 0; 703 + for (var i = 0; i < results.length; i++) { 704 + var uri = results[i].uri; 705 + if (uriToIndex.has(uri)) { 706 + var idx = uriToIndex.get(uri); 707 + matches.add(idx); 708 + // weight by rank (higher rank = more weight) 709 + var w = results.length - i; 710 + weightedX += pointsX[idx] * w; 711 + weightedY += pointsY[idx] * w; 712 + totalWeight += w; 713 + } 714 + } 715 + 716 + if (matches.size === 0) { 717 + setSearchStatus(results.length + ' results, 0 on map'); 718 + searchMatches = null; 719 + searchCenter = null; 720 + view.dirty = true; 721 + return; 722 + } 723 + 724 + searchMatches = matches; 725 + searchCenter = { x: weightedX / totalWeight, y: weightedY / totalWeight }; 726 + setSearchStatus(matches.size + ' of ' + results.length + ' on map'); 727 + 728 + // compute spread to determine zoom level 729 + var maxDist = 0; 730 + matches.forEach(function(idx) { 731 + var dx = pointsX[idx] - searchCenter.x; 732 + var dy = pointsY[idx] - searchCenter.y; 733 + var d = Math.sqrt(dx * dx + dy * dy); 734 + if (d > maxDist) maxDist = d; 735 + }); 736 + 737 + // zoom to fit the spread with some padding 738 + // at zoom=1, visible radius in data coords is ~1.0 (since range is [-1,1]) 739 + // we want maxDist to fit in ~40% of the viewport 740 + var targetZoom = maxDist > 0 ? Math.min(view.maxZoom, 0.4 / maxDist) : 4; 741 + targetZoom = Math.max(2, Math.min(8, targetZoom)); // clamp to reasonable range 742 + 743 + animateTo(searchCenter.x, searchCenter.y, targetZoom); 744 + }) 745 + .catch(function(err) { 746 + setSearchStatus('error'); 747 + console.error(err); 748 + }); 749 + } 750 + 751 + searchForm.addEventListener('submit', function(e) { 752 + e.preventDefault(); 753 + var q = searchInput.value.trim(); 754 + if (q) doSearch(q); 755 + else clearSearch(); 756 + }); 757 + 758 + searchInput.addEventListener('keydown', function(e) { 759 + if (e.key === 'Escape') { 760 + searchInput.value = ''; 761 + searchInput.blur(); 762 + clearSearch(); 763 + } 764 + }); 765 + 766 + // cmd+k / ctrl+k to focus search 767 + window.addEventListener('keydown', function(e) { 768 + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { 769 + e.preventDefault(); 770 + searchInput.focus(); 771 + searchInput.select(); 772 + } 773 + }); 774 + 775 + window.atlas = { 571 776 setDirty: function() { 572 777 sprites = null; 573 778 dotSprites = null;
+1 -1
site/dashboard.html
··· 94 94 </section> 95 95 96 96 <footer> 97 - <a href="/">back</a> · <a href="/constellation.html">constellation</a> · source on <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search">tangled</a> · <span class="theme-toggle" id="theme-toggle"></span> 97 + <a href="/">back</a> · <a href="/atlas.html">atlas</a> · source on <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search">tangled</a> · <span class="theme-toggle" id="theme-toggle"></span> 98 98 </footer> 99 99 </div> 100 100
+7 -7
site/functions/og-image.js
··· 101 101 } 102 102 } 103 103 104 - // platform colors for constellation OG image (core colors from the canvas) 104 + // platform colors for atlas OG image (core colors from the canvas) 105 105 const PLATFORM_DOTS = [ 106 106 { color: "#4ade80", label: "leaflet" }, 107 107 { color: "#60a5fa", label: "whitewind" }, ··· 111 111 { color: "#9ca3af", label: "other" }, 112 112 ]; 113 113 114 - // deterministic "random" positions for constellation dots 115 - function constellationDots() { 114 + // deterministic "random" positions for atlas dots 115 + function atlasDots() { 116 116 const dots = []; 117 117 const positions = [ 118 118 [180, 200], [340, 150], [520, 280], [700, 180], [850, 250], ··· 149 149 const children = []; 150 150 151 151 // scattered dots as background decoration 152 - children.push(...constellationDots()); 152 + children.push(...atlasDots()); 153 153 154 154 // header 155 155 children.push({ ··· 175 175 fontFamily: '"JetBrains Mono", monospace', 176 176 marginTop: "16px", 177 177 }, 178 - children: "constellation", 178 + children: "atlas", 179 179 }, 180 180 }); 181 181 ··· 284 284 const since = url.searchParams.get("since"); 285 285 const mode = url.searchParams.get("mode"); 286 286 287 - // constellation page 288 - if (page === "constellation") { 287 + // atlas page 288 + if (page === "atlas") { 289 289 const stats = await fetchStats(); 290 290 const html = buildConstellationImage(stats ? stats.documents : null); 291 291 return new ImageResponse(html, {
+1 -1
site/index.html
··· 1594 1594 fetch(`${API_URL}/stats`) 1595 1595 .then(r => r.json()) 1596 1596 .then(data => { 1597 - statsDiv.innerHTML = `${data.documents} documents, ${data.publications} publications · <a href="/dashboard.html">stats</a> · <a href="/constellation.html">constellation</a>`; 1597 + statsDiv.innerHTML = `${data.documents} documents, ${data.publications} publications · <a href="/dashboard.html">stats</a> · <a href="/atlas.html">atlas</a>`; 1598 1598 const headerStats = document.getElementById('header-stats'); 1599 1599 headerStats.href = '/dashboard.html'; 1600 1600 headerStats.textContent = `[${data.documents.toLocaleString()} docs]`;