This repository has no description
42 kB
1140 lines
1/**
2 * Stigmergic frontend — island-centric SPA.
3 *
4 * The knowledge graph is a set of islands (connected components).
5 * Each island is a cluster of linked URLs discovered via
6 * network.cosmik.connection records. Strata analysis runs on an
7 * island and produces an org.latha.island record.
8 */
9
10// ── Types ──────────────────────────────────────────────────────────────────
11
12interface VertexMeta {
13 title: string;
14 description: string;
15 type: string;
16}
17
18interface IslandEdge {
19 uri: string;
20 did: string;
21 source: string;
22 target: string;
23 connectionType: string;
24 note: string;
25 handle?: string | null;
26}
27
28interface Island {
29 id: string;
30 islandHash?: string;
31 centroid?: string;
32 vertices?: string[];
33 edges?: IslandEdge[];
34 vertexMeta?: Record<string, VertexMeta>;
35 summary: string | null;
36 title?: string | null;
37 strata: StrataData | null;
38 recordUri?: string | null;
39 handle?: string | null;
40}
41
42interface StrataData {
43 prose: string;
44 themes: string[];
45 highlights: string[];
46 tensions: string[];
47 open_questions: string[];
48 synthesis: string;
49}
50
51// ── State ──────────────────────────────────────────────────────────────────
52
53let session: { did: string; handle: string } | null = null;
54let currentPage: "explore" | "island" | "strata" | "connect" = "explore";
55let islands: Island[] = [];
56
57// ── XRPC helpers ───────────────────────────────────────────────────────────
58
59async function apiGet<T>(path: string): Promise<T> {
60 const res = await fetch(path);
61 if (!res.ok) {
62 const err = await res.text();
63 throw new Error(`API ${path} failed: ${res.status} ${err}`);
64 }
65 return res.json();
66}
67
68// ── Force-directed graph renderer ──────────────────────────────────────────
69
70interface GraphNode {
71 id: string;
72 label: string;
73 x: number;
74 y: number;
75 vx: number;
76 vy: number;
77 type: string;
78}
79
80interface GraphEdge {
81 source: string;
82 target: string;
83 label: string;
84 color: string;
85}
86
87const TYPE_COLORS: Record<string, string> = {
88 url: "#58a6ff",
89 citation: "#bc8cff",
90 note: "#3fb950",
91 card: "#3fb950",
92 collection: "#d29922",
93 unknown: "#8b949e",
94};
95
96const EDGE_COLORS: Record<string, string> = {
97 "related": "#8b949e",
98 "relates": "#8b949e",
99 "leads to": "#58a6ff",
100 "supplement": "#bc8cff",
101 "supplements": "#bc8cff",
102 "opposes": "#f85149",
103 "supports": "#3fb950",
104 "addresses": "#d29922",
105 "explainer": "#79c0ff",
106 "helpful": "#56d364",
107 "contains": "#58a6ff",
108 "cites": "#bc8cff",
109 "inspired": "#d29922",
110 "contradicts": "#f85149",
111 "extends": "#3fb950",
112 "forks": "#79c0ff",
113 "annotates": "#8b949e",
114};
115
116class ForceGraph {
117 nodes: GraphNode[] = [];
118 edges: GraphEdge[] = [];
119 nodeMap = new Map<string, GraphNode>();
120 width: number;
121 height: number;
122 alpha = 1;
123 private rafId = 0;
124 private canvas: HTMLCanvasElement;
125 private ctx: CanvasRenderingContext2D;
126 private hoverNode: string | null = null;
127 private dragNode: string | null = null;
128 private dragOffset = { x: 0, y: 0 };
129
130 constructor(canvas: HTMLCanvasElement, island: Island) {
131 this.canvas = canvas;
132 this.ctx = canvas.getContext("2d")!;
133 const rect = canvas.getBoundingClientRect();
134 this.width = rect.width;
135 this.height = rect.height;
136 canvas.width = rect.width * devicePixelRatio;
137 canvas.height = rect.height * devicePixelRatio;
138 this.ctx.scale(devicePixelRatio, devicePixelRatio);
139
140 // Build nodes
141 const cx = this.width / 2;
142 const cy = this.height / 2;
143 const r = Math.min(this.width, this.height) * 0.35;
144
145 for (const uri of island.vertices!) {
146 const meta = island.vertexMeta![uri];
147 const angle = Math.random() * Math.PI * 2;
148 const dist = Math.random() * r;
149 const node: GraphNode = {
150 id: uri,
151 label: meta?.title || truncateUri(uri),
152 x: cx + Math.cos(angle) * dist,
153 y: cy + Math.sin(angle) * dist,
154 vx: 0,
155 vy: 0,
156 type: meta?.type || "unknown",
157 };
158 this.nodes.push(node);
159 this.nodeMap.set(uri, node);
160 }
161
162 // Build edges
163 for (const e of island.edges!) {
164 const sNode = this.nodeMap.get(e.source);
165 const tNode = this.nodeMap.get(e.target);
166 if (!sNode || !tNode) continue;
167 const label = connectionTypeLabel(e.connectionType);
168 this.edges.push({
169 source: e.source,
170 target: e.target,
171 label,
172 color: EDGE_COLORS[label] || EDGE_COLORS[connectionTypeLabel(e.connectionType)] || "#8b949e",
173 });
174 }
175
176 // Interactions
177 canvas.addEventListener("mousemove", this.onMouseMove);
178 canvas.addEventListener("mousedown", this.onMouseDown);
179 canvas.addEventListener("mouseup", this.onMouseUp);
180 canvas.addEventListener("mouseleave", this.onMouseLeave);
181 canvas.addEventListener("dblclick", this.onDblClick);
182
183 // Warm up the simulation
184 for (let i = 0; i < 200; i++) this.tick();
185 this.draw();
186 }
187
188 destroy() {
189 cancelAnimationFrame(this.rafId);
190 this.canvas.removeEventListener("mousemove", this.onMouseMove);
191 this.canvas.removeEventListener("mousedown", this.onMouseDown);
192 this.canvas.removeEventListener("mouseup", this.onMouseUp);
193 this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
194 this.canvas.removeEventListener("dblclick", this.onDblClick);
195 }
196
197 tick() {
198 if (this.alpha < 0.001) return;
199 this.alpha *= 0.99;
200
201 const cx = this.width / 2;
202 const cy = this.height / 2;
203
204 // Center gravity
205 for (const n of this.nodes) {
206 n.vx += (cx - n.x) * 0.01 * this.alpha;
207 n.vy += (cy - n.y) * 0.01 * this.alpha;
208 }
209
210 // Repulsion (all pairs)
211 for (let i = 0; i < this.nodes.length; i++) {
212 for (let j = i + 1; j < this.nodes.length; j++) {
213 const a = this.nodes[i];
214 const b = this.nodes[j];
215 let dx = b.x - a.x;
216 let dy = b.y - a.y;
217 let dist = Math.sqrt(dx * dx + dy * dy) || 1;
218 const force = -500 * this.alpha / (dist * dist);
219 const fx = (dx / dist) * force;
220 const fy = (dy / dist) * force;
221 a.vx += fx;
222 a.vy += fy;
223 b.vx -= fx;
224 b.vy -= fy;
225 }
226 }
227
228 // Attraction (edges)
229 for (const e of this.edges) {
230 const a = this.nodeMap.get(e.source)!;
231 const b = this.nodeMap.get(e.target)!;
232 let dx = b.x - a.x;
233 let dy = b.y - a.y;
234 let dist = Math.sqrt(dx * dx + dy * dy) || 1;
235 const force = (dist - 80) * 0.05 * this.alpha;
236 const fx = (dx / dist) * force;
237 const fy = (dy / dist) * force;
238 a.vx += fx;
239 a.vy += fy;
240 b.vx -= fx;
241 b.vy -= fy;
242 }
243
244 // Apply velocity with damping
245 for (const n of this.nodes) {
246 if (n.id === this.dragNode) continue;
247 n.vx *= 0.6;
248 n.vy *= 0.6;
249 n.x += n.vx;
250 n.y += n.vy;
251 // Keep in bounds
252 const pad = 20;
253 n.x = Math.max(pad, Math.min(this.width - pad, n.x));
254 n.y = Math.max(pad, Math.min(this.height - pad, n.y));
255 }
256 }
257
258 draw() {
259 const ctx = this.ctx;
260 ctx.clearRect(0, 0, this.width, this.height);
261
262 // Edges
263 for (const e of this.edges) {
264 const a = this.nodeMap.get(e.source)!;
265 const b = this.nodeMap.get(e.target)!;
266 const isHovered = this.hoverNode === e.source || this.hoverNode === e.target;
267 ctx.beginPath();
268 ctx.moveTo(a.x, a.y);
269 ctx.lineTo(b.x, b.y);
270 ctx.strokeStyle = isHovered ? e.color : `${e.color}44`;
271 ctx.lineWidth = isHovered ? 2 : 1;
272 ctx.stroke();
273
274 // Arrow
275 const dx = b.x - a.x;
276 const dy = b.y - a.y;
277 const dist = Math.sqrt(dx * dx + dy * dy) || 1;
278 const arrowLen = 8;
279 const arrowX = b.x - (dx / dist) * 16;
280 const arrowY = b.y - (dy / dist) * 16;
281 const angle = Math.atan2(dy, dx);
282 ctx.beginPath();
283 ctx.moveTo(arrowX, arrowY);
284 ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4));
285 ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4));
286 ctx.closePath();
287 ctx.fillStyle = isHovered ? e.color : `${e.color}44`;
288 ctx.fill();
289 }
290
291 // Nodes
292 for (const n of this.nodes) {
293 const isHovered = this.hoverNode === n.id;
294 const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
295 const radius = isHovered ? 7 : 5;
296
297 ctx.beginPath();
298 ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
299 ctx.fillStyle = isHovered ? color : `${color}cc`;
300 ctx.fill();
301 ctx.strokeStyle = isHovered ? "#fff" : color;
302 ctx.lineWidth = isHovered ? 2 : 1;
303 ctx.stroke();
304
305 // Label
306 if (isHovered || this.nodes.length <= 12) {
307 ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`;
308 ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e";
309 ctx.textAlign = "center";
310 ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4);
311 }
312 }
313 }
314
315 private getMousePos = (e: MouseEvent) => {
316 const rect = this.canvas.getBoundingClientRect();
317 return { x: e.clientX - rect.left, y: e.clientY - rect.top };
318 };
319
320 private findNode(x: number, y: number): string | null {
321 for (const n of this.nodes) {
322 const dx = n.x - x;
323 const dy = n.y - y;
324 if (dx * dx + dy * dy < 100) return n.id;
325 }
326 return null;
327 }
328
329 private onMouseMove = (e: MouseEvent) => {
330 const pos = this.getMousePos(e);
331 if (this.dragNode) {
332 const n = this.nodeMap.get(this.dragNode)!;
333 n.x = pos.x;
334 n.y = pos.y;
335 n.vx = 0;
336 n.vy = 0;
337 this.alpha = 0.3;
338 this.tick();
339 this.draw();
340 return;
341 }
342 const prev = this.hoverNode;
343 this.hoverNode = this.findNode(pos.x, pos.y);
344 if (this.hoverNode !== prev) this.draw();
345 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default";
346 };
347
348 private onMouseDown = (e: MouseEvent) => {
349 const pos = this.getMousePos(e);
350 const id = this.findNode(pos.x, pos.y);
351 if (id) {
352 this.dragNode = id;
353 this.canvas.style.cursor = "grabbing";
354 }
355 };
356
357 private onMouseUp = () => {
358 this.dragNode = null;
359 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default";
360 };
361
362 private onMouseLeave = () => {
363 this.hoverNode = null;
364 this.dragNode = null;
365 this.canvas.style.cursor = "default";
366 this.draw();
367 };
368
369 private onDblClick = (e: MouseEvent) => {
370 const pos = this.getMousePos(e);
371 const id = this.findNode(pos.x, pos.y);
372 if (!id) return;
373 if (id.startsWith("http")) {
374 window.open(id, "_blank");
375 }
376 };
377}
378
379// ── Rendering helpers ──────────────────────────────────────────────────────
380
381// Compute island hash from centroid: first 12 hex chars of SHA-256
382async function computeIslandHash(centroid: string): Promise<string> {
383 const data = new TextEncoder().encode(centroid);
384 const hashBuffer = await crypto.subtle.digest("SHA-256", data);
385 const hashArray = Array.from(new Uint8Array(hashBuffer));
386 return hashArray.map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 12);
387}
388
389// Populate islandHash on all islands that have centroid but no hash
390async function enrichIslandHashes(list: Island[]): Promise<void> {
391 for (const i of list) {
392 if (!i.islandHash && i.centroid) {
393 i.islandHash = await computeIslandHash(i.centroid);
394 }
395 }
396}
397
398function islandPath(island: Island): string {
399 if (island.recordUri) {
400 const parts = island.recordUri.split("/");
401 const rkey = parts[parts.length - 1];
402 const repo = island.handle || parts[2];
403 return `/island/${repo}/${rkey}`;
404 }
405 // Fallback to hash
406 return `/island/${encodeURIComponent(island.islandHash || island.id)}`;
407}
408
409function escHtml(s: string): string {
410 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
411}
412
413function truncateUri(uri: string): string {
414 if (uri.startsWith("http")) {
415 try {
416 const u = new URL(uri);
417 const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40);
418 return u.hostname + path;
419 } catch {
420 return uri.slice(0, 60);
421 }
422 }
423 if (uri.startsWith("at://")) {
424 const parts = uri.split("/");
425 return parts.slice(3).join("/") || uri.slice(0, 60);
426 }
427 return uri.slice(0, 60);
428}
429
430function connectionTypeLabel(t: string): string {
431 if (!t) return "relates";
432 // Handle both "network.cosmik.connection#RELATES" and raw "RELATES"
433 const raw = t.includes("#") ? t.split("#").pop()! : t;
434 const labels: Record<string, string> = {
435 RELATED: "related",
436 LEADS_TO: "leads to",
437 SUPPLEMENT: "supplement",
438 SUPPLEMENTS: "supplements",
439 OPPOSES: "opposes",
440 SUPPORTS: "supports",
441 ADDRESSES: "addresses",
442 EXPLAINER: "explainer",
443 HELPFUL: "helpful",
444 CONTAINS: "contains",
445 CITES: "cites",
446 INSPIRED: "inspired",
447 CONTRADICTS: "contradicts",
448 EXTENDS: "extends",
449 FORKS: "forks",
450 ANNOTATES: "annotates",
451 RELATES: "relates",
452 };
453 return labels[raw] || raw.toLowerCase().replace(/_/g, " ");
454}
455
456function connectionTypeIcon(t: string): string {
457 const label = connectionTypeLabel(t);
458 const icons: Record<string, string> = {
459 "leads to": "→",
460 "cites": "←",
461 "related": "↔",
462 "relates": "↔",
463 "inspired": "✨",
464 "opposes": "✕",
465 "contradicts": "✕",
466 "extends": "⇒",
467 "forks": "⇄",
468 "annotates": "✎",
469 "supplement": "⊕",
470 "supplements": "⊕",
471 "supports": "✓",
472 "addresses": "◆",
473 "explainer": "ℹ",
474 "helpful": "☆",
475 "contains": "▶",
476 };
477 return icons[label] || "\u2194";
478}
479
480// ── Explore page: island cards ─────────────────────────────────────────────
481
482function renderExplore(): void {
483 const content = document.getElementById("page-content");
484 if (!content) return;
485
486 if (islands.length === 0) {
487 content.innerHTML = '<div class="empty">No islands found in the connection graph. Save some connections to see clusters!</div>';
488 return;
489 }
490
491 content.innerHTML = `
492 <div class="islands-feed">
493 ${islands.map(island => renderIslandCard(island)).join("")}
494 </div>
495 `;
496
497 // Initialize graphs (only for islands with full data)
498 document.querySelectorAll("canvas.island-graph").forEach(canvas => {
499 const islandId = (canvas as HTMLElement).dataset.islandId;
500 const island = islands.find(i => i.id === islandId);
501 if (island && island.vertices && island.edges) {
502 new ForceGraph(canvas as HTMLCanvasElement, island as any);
503 }
504 });
505
506 // Wire up Strata buttons on island cards — go to strata page
507 document.querySelectorAll(".island-strata-btn").forEach(btn => {
508 btn.addEventListener("click", (e) => {
509 const target = e.currentTarget as HTMLElement;
510 const islandId = target.dataset.islandId;
511 if (!islandId) return;
512 const island = islands.find(i => i.id === islandId);
513 window.location.href = islandPath(island);
514 });
515 });
516}
517
518function summarizeIsland(island: Island): string {
519 if (island.title) return island.title;
520 if (island.summary) return island.summary;
521
522 // Fallback: build from metadata (only if we have full data)
523 const vertices = island.vertices;
524 if (!vertices || vertices.length === 0) return "Island";
525
526 const meta = island.vertexMeta || {};
527
528 const domains = new Map<string, number>();
529 for (const v of vertices) {
530 if (v.startsWith("http")) {
531 try {
532 const host = new URL(v).hostname.replace(/^www\./, "");
533 domains.set(host, (domains.get(host) || 0) + 1);
534 } catch {}
535 }
536 }
537 const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]);
538
539 const titles = vertices
540 .map(v => meta[v]?.title || "")
541 .filter(t => t.length > 10 && t.length < 120)
542 .slice(0, 2);
543
544 const parts: string[] = [];
545 if (topDomains.length > 0) parts.push(topDomains.join(", "));
546 if (titles.length > 0) parts.push(titles.join("; "));
547 return parts.join(" -- ") || `${vertices.length} connected nodes`;
548}
549
550function renderIslandCard(island: Island): string {
551 const summary = summarizeIsland(island);
552 const hasStrata = !!island.strata;
553 const strataBtnLabel = hasStrata ? "View Strata" : "Strata";
554 const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn";
555 const hasFullData = !!(island.vertices && island.edges);
556 const sizeLabel = hasFullData
557 ? `${island.vertices!.length} nodes · ${island.edges!.length} edges`
558 : "";
559
560 const graphOrTitle = hasFullData
561 ? `<canvas class="island-graph" data-island-id="${escHtml(island.id)}"></canvas>`
562 : `<div class="island-title-preview">${escHtml(summary)}</div>`;
563
564 return `
565 <div class="island-card" data-island-id="${escHtml(island.id)}">
566 ${graphOrTitle}
567 <div class="island-card-footer">
568 ${sizeLabel ? `<span class="island-size">${sizeLabel}</span>` : ""}
569 ${hasFullData ? `<p class="island-summary">${escHtml(summary)}</p>` : ""}
570 <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button>
571 </div>
572 </div>
573 `;
574}
575
576// ── Island detail page ─────────────────────────────────────────────────────
577
578async function showIsland(islandId: string): Promise<void> {
579 currentPage = "island";
580 const content = document.getElementById("page-content");
581 if (!content) return;
582
583 let island = islands.find(i => i.id === islandId);
584 if (!island) {
585 content.innerHTML = '<div class="empty">Island not found.</div>';
586 return;
587 }
588
589 // If vertexMeta is missing (summary mode), fetch full data
590 if (!island.vertexMeta || Object.keys(island.vertexMeta).length === 0) {
591 try {
592 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands?limit=50");
593 const full = data.islands?.find(i => i.id === islandId);
594 if (full) {
595 island = full;
596 // Update cached island too
597 const idx = islands.findIndex(i => i.id === islandId);
598 if (idx >= 0) islands[idx] = full;
599 }
600 } catch {}
601 }
602
603 const edgeList = island.edges!.map(e => {
604 const icon = connectionTypeIcon(e.connectionType);
605 const type = connectionTypeLabel(e.connectionType);
606 const sourceMeta = island.vertexMeta![e.source];
607 const targetMeta = island.vertexMeta![e.target];
608 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
609 const targetTitle = targetMeta?.title || truncateUri(e.target);
610 return `
611 <div class="island-edge-detail">
612 <span class="edge-source">${escHtml(sourceTitle)}</span>
613 <span class="edge-type-badge">${icon} ${escHtml(type)}</span>
614 <span class="edge-target">${escHtml(targetTitle)}</span>
615 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""}
616 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""}
617 </div>
618 `;
619 }).join("");
620
621 content.innerHTML = `
622 <div class="island-detail">
623 <a href="/" class="back-link">← Explore</a>
624 <h2>Island</h2>
625 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · <a href="https://pdsls.dev/${island.recordUri}#record" target="_blank" rel="noopener" class="pds-link">View on PDS ↗</a>` : ""}</p>
626
627 <canvas class="island-graph-large" id="island-detail-graph"></canvas>
628
629 <div class="island-section">
630 <h3>Connections</h3>
631 ${edgeList}
632 </div>
633
634 <div class="island-actions">
635 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button>
636 </div>
637 </div>
638 `;
639
640 // Initialize the detail graph
641 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement;
642 if (canvas) {
643 new ForceGraph(canvas, island);
644 }
645
646 document.getElementById("island-strata-btn")?.addEventListener("click", async () => {
647 window.location.href = islandPath(island);
648 });
649}
650
651// ── Strata page — prose with arrows ───────────────────────────────────────
652
653async function showStrataByRecord(repo: string, rkey: string): Promise<void> {
654 const atUri = `at://${repo}/org.latha.island/${rkey}`;
655 currentPage = "strata";
656 const content = document.getElementById("page-content");
657 if (!content) return;
658
659 try {
660 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.island.getIsland?id=${encodeURIComponent(atUri)}`);
661 await renderStrataPage(data.island, data.recordUri);
662 } catch {
663 content.innerHTML = '<div class="empty">Island record not found.</div>';
664 }
665}
666
667async function showStrataByLegacyId(legacyId: string): Promise<void> {
668 // Resolve legacy 12-char hash to a record, then redirect to canonical path
669 currentPage = "strata";
670 const content = document.getElementById("page-content");
671 if (!content) return;
672
673 try {
674 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.island.getIsland?id=${encodeURIComponent(legacyId)}`);
675 if (data.recordUri) {
676 const parts = data.recordUri.split("/");
677 const recordRkey = parts[parts.length - 1];
678 const repo = data.island.handle || parts[2];
679 window.history.replaceState(null, "", `/island/${repo}/${recordRkey}`);
680 }
681 await renderStrataPage(data.island, data.recordUri);
682 } catch {
683 content.innerHTML = '<div class="empty">Island not found.</div>';
684 }
685}
686
687async function renderStrataPage(island: Island, recordUri?: string): Promise<void> {
688 if (recordUri) island.recordUri = recordUri;
689
690 if (!island.strata) {
691 const content = document.getElementById("page-content");
692 if (!content) return;
693 content.innerHTML = `
694 <div class="strata-page">
695 <a href="/" class="back-link">← Explore</a>
696 <h2>Strata</h2>
697 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div>
698 </div>
699 `;
700 return;
701 }
702
703 renderStrata(island);
704}
705
706async function showStrata(islandId: string): Promise<void> {
707 currentPage = "strata";
708 const content = document.getElementById("page-content");
709 if (!content) return;
710
711 let island: Island | null = null;
712 let recordUri: string | undefined;
713
714 // If the ID is an AT URI, fetch from the island endpoint
715 if (islandId.startsWith("at://")) {
716 try {
717 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.island.getIsland?id=${encodeURIComponent(islandId)}`);
718 island = data.island;
719 recordUri = data.recordUri;
720 } catch {
721 content.innerHTML = '<div class="empty">Island record not found.</div>';
722 return;
723 }
724 } else {
725 // Island hash or legacy ID — find from loaded islands
726 await enrichIslandHashes(islands);
727 const matchIsland = (list: Island[]): Island | undefined =>
728 list.find(i => i.islandHash === islandId || i.id === islandId);
729
730 island = matchIsland(islands) || null;
731
732 // If not in memory, fetch from API
733 if (!island) {
734 try {
735 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands");
736 await enrichIslandHashes(data.islands);
737 island = matchIsland(data.islands) || null;
738 if (island) islands = data.islands;
739 } catch {}
740 }
741 }
742
743 if (!island) {
744 content.innerHTML = '<div class="empty">Island not found.</div>';
745 return;
746 }
747
748 await renderStrataPage(island, recordUri);
749}
750
751function renderStrata(island: Island): void {
752 const content = document.getElementById("page-content");
753 if (!content) return;
754 const s = island.strata!;
755
756 const summary = island.summary || summarizeIsland(island);
757
758 // Build editable strata page
759 content.innerHTML = `
760 <div class="strata-page">
761 <a href="/" class="back-link">← Explore</a>
762 <p class="island-summary-large" data-field="title" contenteditable="true">${escHtml(summary)}</p>
763 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · <a href="https://pdsls.dev/${island.recordUri}#record" target="_blank" rel="noopener" class="pds-link">View on PDS ↗</a>` : ""}</p>
764
765 <canvas class="island-graph-large" id="strata-graph"></canvas>
766
767 <div class="strata-section">
768 <h3>Synthesis <span class="edit-hint" title="Click to edit">✎</span></h3>
769 <div class="synthesis-text editable" data-field="synthesis" contenteditable="true">${escHtml(s.synthesis)}</div>
770 </div>
771
772 <div class="strata-section">
773 <h3>Themes <span class="edit-hint" title="Click to edit">✎</span></h3>
774 <div class="theme-tags editable" data-field="themes">
775 ${s.themes.map(t => `<span class="theme-tag" data-value="${escHtml(t)}">${escHtml(t)}<span class="theme-remove">×</span></span>`).join("")}
776 <span class="theme-add">+</span>
777 </div>
778 </div>
779
780 <div class="strata-section">
781 <h3>Tensions <span class="edit-hint" title="Click to edit">✎</span></h3>
782 <div class="editable-list" data-field="tensions">
783 ${s.tensions.map(t => `<div class="analysis-tension editable" contenteditable="true">${escHtml(t)}</div>`).join("")}
784 <div class="list-add" data-field="tensions">+ Add tension</div>
785 </div>
786 </div>
787
788 <div class="strata-section">
789 <h3>Open Questions <span class="edit-hint" title="Click to edit">✎</span></h3>
790 <div class="editable-list" data-field="openQuestions">
791 ${s.open_questions.map(q => `<div class="analysis-question editable" contenteditable="true">${escHtml(q)}</div>`).join("")}
792 <div class="list-add" data-field="openQuestions">+ Add question</div>
793 </div>
794 </div>
795
796 <div class="strata-actions">
797 <button class="strata-btn save-btn hidden" id="save-btn">Save to PDS</button>
798 <span class="save-status" id="save-status"></span>
799 </div>
800
801 ${s.highlights.length > 0 ? `<div class="strata-section">
802 <h3>Highlights <span class="highlights-count">${s.highlights.length} hot edges</span></h3>
803 <div class="strata-edges">${island.edges!.filter(e => s.highlights.includes(e.uri)).map(e => {
804 const sourceMeta = island.vertexMeta![e.source];
805 const targetMeta = island.vertexMeta![e.target];
806 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
807 const targetTitle = targetMeta?.title || truncateUri(e.target);
808 const type = connectionTypeLabel(e.connectionType);
809 const icon = connectionTypeIcon(e.connectionType);
810 const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
811 return `<a href="${escHtml(seemsUrl)}" class="strata-edge hot-edge" target="_blank" rel="noopener"${!seemsUrl ? ' onclick="event.preventDefault()"' : ""}>
812 <span class="hot-badge">★</span>
813 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
814 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
815 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
816 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
817 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
818 </a>`;
819 }).join("")}</div>
820 </div>` : ""}
821
822 <div class="strata-section">
823 <h3>Edges</h3>
824 <div class="strata-edges">${island.edges!.map(e => {
825 const sourceMeta = island.vertexMeta![e.source];
826 const targetMeta = island.vertexMeta![e.target];
827 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
828 const targetTitle = targetMeta?.title || truncateUri(e.target);
829 const type = connectionTypeLabel(e.connectionType);
830 const icon = connectionTypeIcon(e.connectionType);
831 const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
832 return `<a href="${escHtml(seemsUrl)}" class="strata-edge" target="_blank" rel="noopener"${!seemsUrl ? ' onclick="event.preventDefault()"' : ""}>
833 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
834 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
835 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
836 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
837 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
838 </a>`;
839 }).join("")}</div>
840 </div>
841 </div>
842 `;
843
844 // Initialize the force graph
845 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement;
846 if (canvas) {
847 new ForceGraph(canvas, island);
848 }
849
850 // ── Edit tracking ──────────────────────────────────────────────
851 let dirty = false;
852 const saveBtn = document.getElementById("save-btn");
853 const saveStatus = document.getElementById("save-status");
854
855 function markDirty() {
856 dirty = true;
857 saveBtn?.classList.remove("hidden");
858 saveStatus!.textContent = "";
859 }
860
861 // Track contenteditable changes
862 const editContainer = content!;
863 editContainer.querySelectorAll("[contenteditable]").forEach(el => {
864 el.addEventListener("input", markDirty);
865 });
866
867 // Theme tag removal
868 editContainer.querySelectorAll(".theme-remove").forEach(btn => {
869 btn.addEventListener("click", (e) => {
870 (e.currentTarget as HTMLElement).parentElement!.remove();
871 markDirty();
872 });
873 });
874
875 // Theme tag add
876 editContainer.querySelectorAll(".theme-add").forEach(btn => {
877 btn.addEventListener("click", () => {
878 const tag = document.createElement("span");
879 tag.className = "theme-tag";
880 tag.dataset.value = "new theme";
881 tag.innerHTML = 'new theme<span class="theme-remove">×</span>';
882 tag.setAttribute("contenteditable", "true");
883 tag.addEventListener("input", () => { tag.dataset.value = tag.textContent?.replace("×", "").trim() || ""; markDirty(); });
884 tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); });
885 (btn as Element as ChildNode).before(tag);
886 tag.focus();
887 markDirty();
888 });
889 });
890
891 // List item add
892 editContainer.querySelectorAll(".list-add").forEach(btn => {
893 btn.addEventListener("click", () => {
894 const field = (btn as HTMLElement).dataset.field!;
895 const div = document.createElement("div");
896 div.className = field === "tensions" ? "analysis-tension editable" : "analysis-question editable";
897 div.setAttribute("contenteditable", "true");
898 div.textContent = "New item...";
899 div.addEventListener("input", markDirty);
900 (btn as Element as ChildNode).before(div);
901 div.focus();
902 markDirty();
903 });
904 });
905
906 // ── Collect current analysis state ─────────────────────────────
907 function collectAnalysis(): Record<string, any> {
908 const titleEl = editContainer.querySelector('[data-field="title"]');
909 const synthesisEl = editContainer.querySelector('[data-field="synthesis"]');
910 const themeTags = editContainer.querySelectorAll('.theme-tag');
911 const tensionEls = editContainer.querySelectorAll('[data-field="tensions"] .editable');
912 const questionEls = editContainer.querySelectorAll('[data-field="openQuestions"] .editable');
913
914 return {
915 title: titleEl?.textContent?.trim() || "",
916 synthesis: synthesisEl?.textContent?.trim() || "",
917 themes: Array.from(themeTags).map(t => t.getAttribute("data-value") || t.textContent?.replace("×", "").trim() || ""),
918 tensions: Array.from(tensionEls).map(t => t.textContent?.trim() || "").filter(Boolean),
919 openQuestions: Array.from(questionEls).map(q => q.textContent?.trim() || "").filter(Boolean),
920 highlights: island.strata?.highlights || [],
921 };
922 }
923
924 // ── Save to PDS ────────────────────────────────────────────────
925 saveBtn?.addEventListener("click", async () => {
926 if (!island.recordUri) return;
927 saveBtn.textContent = "Saving...";
928 saveBtn.setAttribute("disabled", "true");
929
930 try {
931 const analysis = collectAnalysis();
932 const res = await fetch("/xrpc/org.latha.island.updateRecord", {
933 method: "PUT",
934 headers: { "Content-Type": "application/json" },
935 body: JSON.stringify({ uri: island.recordUri, analysis }),
936 });
937 if (!res.ok) {
938 const err = (await res.json()) as { message?: string };
939 throw new Error(err.message || "Save failed");
940 }
941 dirty = false;
942 saveBtn.classList.add("hidden");
943 saveStatus!.textContent = "Saved ✓";
944 setTimeout(() => { saveStatus!.textContent = ""; }, 3000);
945 } catch (e: any) {
946 saveStatus!.textContent = `Error: ${e.message}`;
947 } finally {
948 saveBtn.textContent = "Save to PDS";
949 saveBtn.removeAttribute("disabled");
950 }
951 });
952
953}
954
955// ── Connect page ───────────────────────────────────────────────────────────
956
957function renderConnectForm(sourceUri?: string): void {
958 const content = document.getElementById("page-content");
959 if (!content) return;
960
961 content.innerHTML = `
962 <div class="connect-page">
963 <a href="/" class="back-link">← Explore</a>
964 <h2>Create Connection</h2>
965 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p>
966
967 <form id="connect-form" class="connect-form">
968 <div class="form-field">
969 <label for="source">Source</label>
970 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required>
971 </div>
972 <div class="form-field">
973 <label for="target">Target</label>
974 <input type="text" id="target" name="target" placeholder="URL or AT URI" required>
975 </div>
976 <div class="form-field">
977 <label for="connectionType">Type</label>
978 <select id="connectionType" name="connectionType">
979 <option value="network.cosmik.connection#relates">relates</option>
980 <option value="network.cosmik.connection#contains">contains</option>
981 <option value="network.cosmik.connection#cites">cites</option>
982 <option value="network.cosmik.connection#inspired">inspired</option>
983 <option value="network.cosmik.connection#contradicts">contradicts</option>
984 <option value="network.cosmik.connection#extends">extends</option>
985 <option value="network.cosmik.connection#forks">forks</option>
986 <option value="network.cosmik.connection#annotates">annotates</option>
987 </select>
988 </div>
989 <div class="form-field">
990 <label for="note">Note</label>
991 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea>
992 </div>
993 <div class="form-actions">
994 <button type="submit" class="action-btn primary">Connect</button>
995 </div>
996 <div id="connect-result" class="connect-result"></div>
997 </form>
998 </div>
999 `;
1000
1001 document.getElementById("connect-form")?.addEventListener("submit", async (e) => {
1002 e.preventDefault();
1003 const result = document.getElementById("connect-result");
1004 if (!result) return;
1005
1006 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>';
1007
1008 const source = (document.getElementById("source") as HTMLInputElement).value;
1009 const target = (document.getElementById("target") as HTMLInputElement).value;
1010 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value;
1011 const note = (document.getElementById("note") as HTMLTextAreaElement).value;
1012
1013 const record = {
1014 $type: "network.cosmik.connection",
1015 source,
1016 target,
1017 connectionType,
1018 note: note || undefined,
1019 createdAt: new Date().toISOString(),
1020 };
1021
1022 result.innerHTML = `<div class="preview">
1023 <p>Record to create on your PDS:</p>
1024 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre>
1025 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p>
1026 </div>`;
1027 });
1028}
1029
1030// ── Pages ──────────────────────────────────────────────────────────────────
1031
1032async function showExplore(): Promise<void> {
1033 currentPage = "explore";
1034
1035 // Try API first, fall back to server-rendered data
1036 // Use summary mode — skip vertexMeta (saves ~250KB)
1037 try {
1038 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands?summary=true");
1039 islands = data.islands || [];
1040 } catch {
1041 const serverData = (window as any).__ISLANDS__;
1042 if (serverData) islands = serverData;
1043 }
1044 await enrichIslandHashes(islands);
1045
1046 renderExplore();
1047}
1048
1049// ── Routing ───────────────────────────────────────────────────────────────
1050
1051function getRoute(): { page: string; params: Record<string, string> } {
1052 const url = new URL(window.location.href);
1053 const path = url.pathname;
1054
1055 if (path === "/island") {
1056 return { page: "island", params: { id: url.searchParams.get("id") || "" } };
1057 }
1058 // /island/<repo>/<rkey> — AT Protocol record path
1059 const repoRkeyMatch = path.match(/^\/island\/([^/]+)\/([^/]+)$/);
1060 if (repoRkeyMatch) {
1061 return { page: "strata", params: { repo: repoRkeyMatch[1], rkey: repoRkeyMatch[2] } };
1062 }
1063 // Legacy: /island/<12-char-hash> — redirect to record path
1064 const islandMatch = path.match(/^\/island\/([0-9a-f]{12})$/);
1065 if (islandMatch) {
1066 return { page: "strata", params: { legacyId: islandMatch[1] } };
1067 }
1068 if (path === "/strata") {
1069 // Legacy redirect
1070 const legacyId = url.searchParams.get("id") || "";
1071 if (legacyId) {
1072 window.location.replace(`/island/${encodeURIComponent(legacyId)}`);
1073 return { page: "strata", params: { id: legacyId } };
1074 }
1075 return { page: "strata", params: { id: "" } };
1076 }
1077 if (path === "/connect") {
1078 return { page: "connect", params: { source: url.searchParams.get("source") || "" } };
1079 }
1080 return { page: "explore", params: {} };
1081}
1082
1083async function route(): Promise<void> {
1084 const { page, params } = getRoute();
1085
1086 if (page === "island" && params.id) {
1087 await showIsland(params.id);
1088 } else if (page === "strata" && params.repo && params.rkey) {
1089 await showStrataByRecord(params.repo, params.rkey);
1090 } else if (page === "strata" && params.legacyId) {
1091 await showStrataByLegacyId(params.legacyId);
1092 } else if (page === "strata" && params.id) {
1093 await showStrata(params.id);
1094 } else if (page === "connect") {
1095 renderConnectForm(params.source);
1096 } else {
1097 await showExplore();
1098 }
1099}
1100
1101// ── Auth ───────────────────────────────────────────────────────────────────
1102
1103async function initAuth(): Promise<void> {
1104 try {
1105 const res = await fetch("/xrpc/org.latha.island.getProfile", {
1106 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") },
1107 });
1108 if (res.ok) {
1109 const data: any = await res.json();
1110 session = { did: data.did, handle: data.handle };
1111 updateAuthUI();
1112 return;
1113 }
1114 } catch {}
1115
1116 updateAuthUI();
1117}
1118
1119function updateAuthUI(): void {
1120 const status = document.getElementById("auth-status");
1121 const loginBtn = document.getElementById("login-btn");
1122 if (!status || !loginBtn) return;
1123
1124 if (session) {
1125 status.textContent = session.handle;
1126 status.classList.add("visible");
1127 loginBtn.classList.add("hidden");
1128 } else {
1129 status.classList.remove("visible");
1130 loginBtn.classList.remove("hidden");
1131 }
1132}
1133
1134// ── Init ───────────────────────────────────────────────────────────────────
1135
1136document.addEventListener("DOMContentLoaded", () => {
1137 route();
1138});
1139
1140window.addEventListener("popstate", route);