This repository has no description
32 kB
929 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.strata 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 vertices: string[];
31 edges: IslandEdge[];
32 vertexMeta: Record<string, VertexMeta>;
33 summary: string | null;
34 strata: StrataData | null;
35}
36
37interface StrataData {
38 prose: string;
39 themes: string[];
40 relationships: string[];
41 tensions: string[];
42 open_questions: string[];
43 synthesis: string;
44}
45
46// ── State ──────────────────────────────────────────────────────────────────
47
48let session: { did: string; handle: string } | null = null;
49let currentPage: "explore" | "island" | "strata" | "connect" = "explore";
50let islands: Island[] = [];
51
52// ── XRPC helpers ───────────────────────────────────────────────────────────
53
54async function apiGet<T>(path: string): Promise<T> {
55 const res = await fetch(path);
56 if (!res.ok) {
57 const err = await res.text();
58 throw new Error(`API ${path} failed: ${res.status} ${err}`);
59 }
60 return res.json();
61}
62
63// ── Force-directed graph renderer ──────────────────────────────────────────
64
65interface GraphNode {
66 id: string;
67 label: string;
68 x: number;
69 y: number;
70 vx: number;
71 vy: number;
72 type: string;
73}
74
75interface GraphEdge {
76 source: string;
77 target: string;
78 label: string;
79 color: string;
80}
81
82const TYPE_COLORS: Record<string, string> = {
83 url: "#58a6ff",
84 citation: "#bc8cff",
85 note: "#3fb950",
86 card: "#3fb950",
87 collection: "#d29922",
88 unknown: "#8b949e",
89};
90
91const EDGE_COLORS: Record<string, string> = {
92 "related": "#8b949e",
93 "relates": "#8b949e",
94 "leads to": "#58a6ff",
95 "supplement": "#bc8cff",
96 "supplements": "#bc8cff",
97 "opposes": "#f85149",
98 "supports": "#3fb950",
99 "addresses": "#d29922",
100 "explainer": "#79c0ff",
101 "helpful": "#56d364",
102 "contains": "#58a6ff",
103 "cites": "#bc8cff",
104 "inspired": "#d29922",
105 "contradicts": "#f85149",
106 "extends": "#3fb950",
107 "forks": "#79c0ff",
108 "annotates": "#8b949e",
109};
110
111class ForceGraph {
112 nodes: GraphNode[] = [];
113 edges: GraphEdge[] = [];
114 nodeMap = new Map<string, GraphNode>();
115 width: number;
116 height: number;
117 alpha = 1;
118 private rafId = 0;
119 private canvas: HTMLCanvasElement;
120 private ctx: CanvasRenderingContext2D;
121 private hoverNode: string | null = null;
122 private dragNode: string | null = null;
123 private dragOffset = { x: 0, y: 0 };
124
125 constructor(canvas: HTMLCanvasElement, island: Island) {
126 this.canvas = canvas;
127 this.ctx = canvas.getContext("2d")!;
128 const rect = canvas.getBoundingClientRect();
129 this.width = rect.width;
130 this.height = rect.height;
131 canvas.width = rect.width * devicePixelRatio;
132 canvas.height = rect.height * devicePixelRatio;
133 this.ctx.scale(devicePixelRatio, devicePixelRatio);
134
135 // Build nodes
136 const cx = this.width / 2;
137 const cy = this.height / 2;
138 const r = Math.min(this.width, this.height) * 0.35;
139
140 for (const uri of island.vertices) {
141 const meta = island.vertexMeta[uri];
142 const angle = Math.random() * Math.PI * 2;
143 const dist = Math.random() * r;
144 const node: GraphNode = {
145 id: uri,
146 label: meta?.title || truncateUri(uri),
147 x: cx + Math.cos(angle) * dist,
148 y: cy + Math.sin(angle) * dist,
149 vx: 0,
150 vy: 0,
151 type: meta?.type || "unknown",
152 };
153 this.nodes.push(node);
154 this.nodeMap.set(uri, node);
155 }
156
157 // Build edges
158 for (const e of island.edges) {
159 const sNode = this.nodeMap.get(e.source);
160 const tNode = this.nodeMap.get(e.target);
161 if (!sNode || !tNode) continue;
162 const label = connectionTypeLabel(e.connectionType);
163 this.edges.push({
164 source: e.source,
165 target: e.target,
166 label,
167 color: EDGE_COLORS[label] || EDGE_COLORS[connectionTypeLabel(e.connectionType)] || "#8b949e",
168 });
169 }
170
171 // Interactions
172 canvas.addEventListener("mousemove", this.onMouseMove);
173 canvas.addEventListener("mousedown", this.onMouseDown);
174 canvas.addEventListener("mouseup", this.onMouseUp);
175 canvas.addEventListener("mouseleave", this.onMouseLeave);
176 canvas.addEventListener("dblclick", this.onDblClick);
177
178 // Warm up the simulation
179 for (let i = 0; i < 120; i++) this.tick();
180 this.draw();
181 }
182
183 destroy() {
184 cancelAnimationFrame(this.rafId);
185 this.canvas.removeEventListener("mousemove", this.onMouseMove);
186 this.canvas.removeEventListener("mousedown", this.onMouseDown);
187 this.canvas.removeEventListener("mouseup", this.onMouseUp);
188 this.canvas.removeEventListener("mouseleave", this.onMouseLeave);
189 this.canvas.removeEventListener("dblclick", this.onDblClick);
190 }
191
192 tick() {
193 if (this.alpha < 0.001) return;
194 this.alpha *= 0.99;
195
196 const cx = this.width / 2;
197 const cy = this.height / 2;
198
199 // Center gravity
200 for (const n of this.nodes) {
201 n.vx += (cx - n.x) * 0.01 * this.alpha;
202 n.vy += (cy - n.y) * 0.01 * this.alpha;
203 }
204
205 // Repulsion (all pairs)
206 for (let i = 0; i < this.nodes.length; i++) {
207 for (let j = i + 1; j < this.nodes.length; j++) {
208 const a = this.nodes[i];
209 const b = this.nodes[j];
210 let dx = b.x - a.x;
211 let dy = b.y - a.y;
212 let dist = Math.sqrt(dx * dx + dy * dy) || 1;
213 const force = -300 * this.alpha / (dist * dist);
214 const fx = (dx / dist) * force;
215 const fy = (dy / dist) * force;
216 a.vx += fx;
217 a.vy += fy;
218 b.vx -= fx;
219 b.vy -= fy;
220 }
221 }
222
223 // Attraction (edges)
224 for (const e of this.edges) {
225 const a = this.nodeMap.get(e.source)!;
226 const b = this.nodeMap.get(e.target)!;
227 let dx = b.x - a.x;
228 let dy = b.y - a.y;
229 let dist = Math.sqrt(dx * dx + dy * dy) || 1;
230 const force = (dist - 80) * 0.05 * this.alpha;
231 const fx = (dx / dist) * force;
232 const fy = (dy / dist) * force;
233 a.vx += fx;
234 a.vy += fy;
235 b.vx -= fx;
236 b.vy -= fy;
237 }
238
239 // Apply velocity with damping
240 for (const n of this.nodes) {
241 if (n.id === this.dragNode) continue;
242 n.vx *= 0.6;
243 n.vy *= 0.6;
244 n.x += n.vx;
245 n.y += n.vy;
246 // Keep in bounds
247 const pad = 20;
248 n.x = Math.max(pad, Math.min(this.width - pad, n.x));
249 n.y = Math.max(pad, Math.min(this.height - pad, n.y));
250 }
251 }
252
253 draw() {
254 const ctx = this.ctx;
255 ctx.clearRect(0, 0, this.width, this.height);
256
257 // Edges
258 for (const e of this.edges) {
259 const a = this.nodeMap.get(e.source)!;
260 const b = this.nodeMap.get(e.target)!;
261 const isHovered = this.hoverNode === e.source || this.hoverNode === e.target;
262 ctx.beginPath();
263 ctx.moveTo(a.x, a.y);
264 ctx.lineTo(b.x, b.y);
265 ctx.strokeStyle = isHovered ? e.color : `${e.color}44`;
266 ctx.lineWidth = isHovered ? 2 : 1;
267 ctx.stroke();
268
269 // Arrow
270 const dx = b.x - a.x;
271 const dy = b.y - a.y;
272 const dist = Math.sqrt(dx * dx + dy * dy) || 1;
273 const arrowLen = 8;
274 const arrowX = b.x - (dx / dist) * 16;
275 const arrowY = b.y - (dy / dist) * 16;
276 const angle = Math.atan2(dy, dx);
277 ctx.beginPath();
278 ctx.moveTo(arrowX, arrowY);
279 ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4));
280 ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4));
281 ctx.closePath();
282 ctx.fillStyle = isHovered ? e.color : `${e.color}44`;
283 ctx.fill();
284 }
285
286 // Nodes
287 for (const n of this.nodes) {
288 const isHovered = this.hoverNode === n.id;
289 const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
290 const radius = isHovered ? 7 : 5;
291
292 ctx.beginPath();
293 ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
294 ctx.fillStyle = isHovered ? color : `${color}cc`;
295 ctx.fill();
296 ctx.strokeStyle = isHovered ? "#fff" : color;
297 ctx.lineWidth = isHovered ? 2 : 1;
298 ctx.stroke();
299
300 // Label
301 if (isHovered || this.nodes.length <= 12) {
302 ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`;
303 ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e";
304 ctx.textAlign = "center";
305 ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4);
306 }
307 }
308 }
309
310 private getMousePos = (e: MouseEvent) => {
311 const rect = this.canvas.getBoundingClientRect();
312 return { x: e.clientX - rect.left, y: e.clientY - rect.top };
313 };
314
315 private findNode(x: number, y: number): string | null {
316 for (const n of this.nodes) {
317 const dx = n.x - x;
318 const dy = n.y - y;
319 if (dx * dx + dy * dy < 100) return n.id;
320 }
321 return null;
322 }
323
324 private onMouseMove = (e: MouseEvent) => {
325 const pos = this.getMousePos(e);
326 if (this.dragNode) {
327 const n = this.nodeMap.get(this.dragNode)!;
328 n.x = pos.x;
329 n.y = pos.y;
330 n.vx = 0;
331 n.vy = 0;
332 this.alpha = 0.3;
333 this.tick();
334 this.draw();
335 return;
336 }
337 const prev = this.hoverNode;
338 this.hoverNode = this.findNode(pos.x, pos.y);
339 if (this.hoverNode !== prev) this.draw();
340 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default";
341 };
342
343 private onMouseDown = (e: MouseEvent) => {
344 const pos = this.getMousePos(e);
345 const id = this.findNode(pos.x, pos.y);
346 if (id) {
347 this.dragNode = id;
348 this.canvas.style.cursor = "grabbing";
349 }
350 };
351
352 private onMouseUp = () => {
353 this.dragNode = null;
354 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default";
355 };
356
357 private onMouseLeave = () => {
358 this.hoverNode = null;
359 this.dragNode = null;
360 this.canvas.style.cursor = "default";
361 this.draw();
362 };
363
364 private onDblClick = (e: MouseEvent) => {
365 const pos = this.getMousePos(e);
366 const id = this.findNode(pos.x, pos.y);
367 if (!id) return;
368 if (id.startsWith("http")) {
369 window.open(id, "_blank");
370 }
371 };
372}
373
374// ── Rendering helpers ──────────────────────────────────────────────────────
375
376function escHtml(s: string): string {
377 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
378}
379
380function truncateUri(uri: string): string {
381 if (uri.startsWith("http")) {
382 try {
383 const u = new URL(uri);
384 const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40);
385 return u.hostname + path;
386 } catch {
387 return uri.slice(0, 60);
388 }
389 }
390 if (uri.startsWith("at://")) {
391 const parts = uri.split("/");
392 return parts.slice(3).join("/") || uri.slice(0, 60);
393 }
394 return uri.slice(0, 60);
395}
396
397function connectionTypeLabel(t: string): string {
398 if (!t) return "relates";
399 // Handle both "network.cosmik.connection#RELATES" and raw "RELATES"
400 const raw = t.includes("#") ? t.split("#").pop()! : t;
401 const labels: Record<string, string> = {
402 RELATED: "related",
403 LEADS_TO: "leads to",
404 SUPPLEMENT: "supplement",
405 SUPPLEMENTS: "supplements",
406 OPPOSES: "opposes",
407 SUPPORTS: "supports",
408 ADDRESSES: "addresses",
409 EXPLAINER: "explainer",
410 HELPFUL: "helpful",
411 CONTAINS: "contains",
412 CITES: "cites",
413 INSPIRED: "inspired",
414 CONTRADICTS: "contradicts",
415 EXTENDS: "extends",
416 FORKS: "forks",
417 ANNOTATES: "annotates",
418 RELATES: "relates",
419 };
420 return labels[raw] || raw.toLowerCase().replace(/_/g, " ");
421}
422
423function connectionTypeIcon(t: string): string {
424 const label = connectionTypeLabel(t);
425 const icons: Record<string, string> = {
426 "leads to": "\u2192",
427 "cites": "\u2190",
428 "related": "\u2194",
429 "relates": "\u2194",
430 "inspired": "\u2728",
431 "opposes": "\u26D4",
432 "contradicts": "\u26D4",
433 "extends": "\u21D2",
434 "forks": "\u21C4",
435 "annotates": "\u270E",
436 "supplement": "\u2295",
437 "supplements": "\u2295",
438 "supports": "\u2713",
439 "addresses": "\u25C6",
440 "explainer": "\u2139",
441 "helpful": "\u2606",
442 "contains": "\u25B6",
443 };
444 return icons[label] || "\u2194";
445}
446
447// ── Explore page: island cards ─────────────────────────────────────────────
448
449function renderExplore(): void {
450 const content = document.getElementById("page-content");
451 if (!content) return;
452
453 if (islands.length === 0) {
454 content.innerHTML = '<div class="empty">No islands found in the connection graph. Save some connections to see clusters!</div>';
455 return;
456 }
457
458 content.innerHTML = `
459 <div class="islands-feed">
460 ${islands.map(island => renderIslandCard(island)).join("")}
461 </div>
462 `;
463
464 // Initialize graphs
465 document.querySelectorAll("canvas.island-graph").forEach(canvas => {
466 const islandId = (canvas as HTMLElement).dataset.islandId;
467 const island = islands.find(i => i.id === islandId);
468 if (island) {
469 new ForceGraph(canvas as HTMLCanvasElement, island);
470 }
471 });
472
473 // Wire up Strata buttons on island cards — go to strata page
474 document.querySelectorAll(".island-strata-btn").forEach(btn => {
475 btn.addEventListener("click", (e) => {
476 const target = e.currentTarget as HTMLElement;
477 const islandId = target.dataset.islandId;
478 if (!islandId) return;
479 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`;
480 });
481 });
482}
483
484function summarizeIsland(island: Island): string {
485 if (island.summary) return island.summary;
486
487 // Fallback: build from metadata
488 const vertices = island.vertices;
489 const meta = island.vertexMeta;
490
491 const domains = new Map<string, number>();
492 for (const v of vertices) {
493 if (v.startsWith("http")) {
494 try {
495 const host = new URL(v).hostname.replace(/^www\./, "");
496 domains.set(host, (domains.get(host) || 0) + 1);
497 } catch {}
498 }
499 }
500 const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]);
501
502 const titles = vertices
503 .map(v => meta[v]?.title || "")
504 .filter(t => t.length > 10 && t.length < 120)
505 .slice(0, 2);
506
507 const parts: string[] = [];
508 if (topDomains.length > 0) parts.push(topDomains.join(", "));
509 if (titles.length > 0) parts.push(titles.join("; "));
510 return parts.join(" -- ") || `${vertices.length} connected nodes`;
511}
512
513function renderIslandCard(island: Island): string {
514 const summary = summarizeIsland(island);
515 const hasStrata = !!island.strata;
516 const strataBtnLabel = hasStrata ? "View Strata" : "Strata";
517 const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn";
518
519 return `
520 <div class="island-card" data-island-id="${escHtml(island.id)}">
521 <canvas class="island-graph" data-island-id="${escHtml(island.id)}" width="400" height="260"></canvas>
522 <div class="island-card-footer">
523 <span class="island-size">${island.vertices.length} nodes · ${island.edges.length} edges</span>
524 <p class="island-summary">${escHtml(summary)}</p>
525 <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button>
526 </div>
527 </div>
528 `;
529}
530
531// ── Island detail page ─────────────────────────────────────────────────────
532
533async function showIsland(islandId: string): Promise<void> {
534 currentPage = "island";
535 const content = document.getElementById("page-content");
536 if (!content) return;
537
538 const island = islands.find(i => i.id === islandId);
539 if (!island) {
540 content.innerHTML = '<div class="empty">Island not found.</div>';
541 return;
542 }
543
544 const edgeList = island.edges.map(e => {
545 const icon = connectionTypeIcon(e.connectionType);
546 const type = connectionTypeLabel(e.connectionType);
547 const sourceMeta = island.vertexMeta[e.source];
548 const targetMeta = island.vertexMeta[e.target];
549 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
550 const targetTitle = targetMeta?.title || truncateUri(e.target);
551 return `
552 <div class="island-edge-detail">
553 <span class="edge-source">${escHtml(sourceTitle)}</span>
554 <span class="edge-type-badge">${icon} ${escHtml(type)}</span>
555 <span class="edge-target">${escHtml(targetTitle)}</span>
556 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""}
557 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""}
558 </div>
559 `;
560 }).join("");
561
562 content.innerHTML = `
563 <div class="island-detail">
564 <a href="/" class="back-link">← Explore</a>
565 <h2>Island</h2>
566 <p class="island-meta">${island.vertices.length} vertices · ${island.edges.length} edges</p>
567
568 <canvas class="island-graph-large" id="island-detail-graph" width="900" height="500"></canvas>
569
570 <div class="island-section">
571 <h3>Connections</h3>
572 ${edgeList}
573 </div>
574
575 <div class="island-actions">
576 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button>
577 </div>
578 </div>
579 `;
580
581 // Initialize the detail graph
582 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement;
583 if (canvas) {
584 new ForceGraph(canvas, island);
585 }
586
587 document.getElementById("island-strata-btn")?.addEventListener("click", async () => {
588 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`;
589 });
590}
591
592// ── Strata page — prose with arrows ───────────────────────────────────────
593
594async function showStrata(islandId: string): Promise<void> {
595 currentPage = "strata";
596 const content = document.getElementById("page-content");
597 if (!content) return;
598
599 let island: Island | null = null;
600
601 // If the ID is an AT URI, fetch from the strata record endpoint
602 if (islandId.startsWith("at://")) {
603 try {
604 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`);
605 island = data.island;
606 } catch {
607 content.innerHTML = '<div class="empty">Strata record not found.</div>';
608 return;
609 }
610 } else {
611 // Legacy: sha256 ID — find from loaded islands
612 island = islands.find(i => i.id === islandId) || null;
613
614 // If not in memory, fetch from API
615 if (!island) {
616 try {
617 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands");
618 island = data.islands.find(i => i.id === islandId) || null;
619 if (island) islands = data.islands;
620 } catch {}
621 }
622 }
623
624 if (!island) {
625 content.innerHTML = '<div class="empty">Island not found.</div>';
626 return;
627 }
628
629 if (!island.strata) {
630 content.innerHTML = `
631 <div class="strata-page">
632 <a href="/" class="back-link">← Explore</a>
633 <h2>Strata</h2>
634 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div>
635 </div>
636 `;
637 return;
638 }
639
640 renderStrata(island);
641}
642
643function renderStrata(island: Island): void {
644 const content = document.getElementById("page-content");
645 if (!content) return;
646 const s = island.strata!;
647
648 const summary = island.summary || summarizeIsland(island);
649
650 // Themes
651 const themesHtml = s.themes.length > 0
652 ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>`
653 : "";
654
655 // Relationships — link entity names to their Semble pages
656 const titleToUri = new Map<string, string>();
657 for (const uri of island.vertices) {
658 const m = island.vertexMeta[uri];
659 const title = m?.title;
660 if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) {
661 titleToUri.set(title, uri);
662 }
663 }
664
665 const relsHtml = s.relationships.length > 0
666 ? `<div class="strata-relationships">${s.relationships.map(r => {
667 // Link entity names to their Semble pages
668 let styled = escHtml(r);
669 // Sort titles longest-first so longer matches take priority
670 const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length);
671 for (const [title, uri] of sortedTitles) {
672 const escaped = escHtml(title);
673 // Exact match
674 if (styled.includes(escaped)) {
675 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
676 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
677 }
678 }
679 // Also try linking short names that appear as substrings of titles
680 // e.g. "C2PA" appears inside "C2PA content authenticity standard"
681 for (const [title, uri] of sortedTitles) {
682 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
683 // Extract short name segments from the title (first N words, abbreviations, etc.)
684 const shortNames = [
685 title.split(/[:—–\-]/)[0].trim(), // text before delimiter
686 title.split("(")[0].trim(), // text before parenthetical
687 ].filter(n => n.length > 3 && n.length < title.length);
688 for (const shortName of shortNames) {
689 const escaped = escHtml(shortName);
690 if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) {
691 // Not already linked — link it
692 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
693 }
694 }
695 }
696 styled = styled
697 .replace(/→/g, '<span class="arrow arrow-forward">→</span>')
698 .replace(/←/g, '<span class="arrow arrow-back">←</span>')
699 .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>')
700 .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>');
701 return `<div class="strata-rel-line">${styled}</div>`;
702 }).join("")}</div>`
703 : "";
704
705 // Tensions
706 const tensionsHtml = s.tensions.length > 0
707 ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>`
708 : "";
709
710 // Open questions
711 const questionsHtml = s.open_questions.length > 0
712 ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>`
713 : "";
714
715 // Synthesis
716 const synthesisHtml = s.synthesis
717 ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>`
718 : "";
719
720 content.innerHTML = `
721 <div class="strata-page">
722 <a href="/" class="back-link">← Explore</a>
723 <p class="island-summary-large">${escHtml(summary)}</p>
724 <p class="island-meta">${island.vertices.length} vertices · ${island.edges.length} edges</p>
725
726 <canvas class="island-graph-large" id="strata-graph" width="900" height="500"></canvas>
727
728 <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>
729
730 ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""}
731
732 ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""}
733
734 ${tensionsHtml}
735 ${questionsHtml}
736
737 <div class="strata-section">
738 <h3>Edges</h3>
739 <div class="strata-edges">${island.edges.map(e => {
740 const sourceMeta = island.vertexMeta[e.source];
741 const targetMeta = island.vertexMeta[e.target];
742 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
743 const targetTitle = targetMeta?.title || truncateUri(e.target);
744 const type = connectionTypeLabel(e.connectionType);
745 const icon = connectionTypeIcon(e.connectionType);
746 const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
747 return `<a href="${escHtml(sembleUrl)}" class="strata-edge" target="_blank" rel="noopener"${!sembleUrl ? ' onclick="event.preventDefault()"' : ""}>
748 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
749 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
750 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
751 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
752 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
753 </a>`;
754 }).join("")}</div>
755 </div>
756 </div>
757 `;
758
759 // Initialize the force graph
760 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement;
761 if (canvas) {
762 new ForceGraph(canvas, island);
763 }
764}
765
766// ── Connect page ───────────────────────────────────────────────────────────
767
768function renderConnectForm(sourceUri?: string): void {
769 const content = document.getElementById("page-content");
770 if (!content) return;
771
772 content.innerHTML = `
773 <div class="connect-page">
774 <a href="/" class="back-link">← Explore</a>
775 <h2>Create Connection</h2>
776 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p>
777
778 <form id="connect-form" class="connect-form">
779 <div class="form-field">
780 <label for="source">Source</label>
781 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required>
782 </div>
783 <div class="form-field">
784 <label for="target">Target</label>
785 <input type="text" id="target" name="target" placeholder="URL or AT URI" required>
786 </div>
787 <div class="form-field">
788 <label for="connectionType">Type</label>
789 <select id="connectionType" name="connectionType">
790 <option value="network.cosmik.connection#relates">relates</option>
791 <option value="network.cosmik.connection#contains">contains</option>
792 <option value="network.cosmik.connection#cites">cites</option>
793 <option value="network.cosmik.connection#inspired">inspired</option>
794 <option value="network.cosmik.connection#contradicts">contradicts</option>
795 <option value="network.cosmik.connection#extends">extends</option>
796 <option value="network.cosmik.connection#forks">forks</option>
797 <option value="network.cosmik.connection#annotates">annotates</option>
798 </select>
799 </div>
800 <div class="form-field">
801 <label for="note">Note</label>
802 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea>
803 </div>
804 <div class="form-actions">
805 <button type="submit" class="action-btn primary">Connect</button>
806 </div>
807 <div id="connect-result" class="connect-result"></div>
808 </form>
809 </div>
810 `;
811
812 document.getElementById("connect-form")?.addEventListener("submit", async (e) => {
813 e.preventDefault();
814 const result = document.getElementById("connect-result");
815 if (!result) return;
816
817 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>';
818
819 const source = (document.getElementById("source") as HTMLInputElement).value;
820 const target = (document.getElementById("target") as HTMLInputElement).value;
821 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value;
822 const note = (document.getElementById("note") as HTMLTextAreaElement).value;
823
824 const record = {
825 $type: "network.cosmik.connection",
826 source,
827 target,
828 connectionType,
829 note: note || undefined,
830 createdAt: new Date().toISOString(),
831 };
832
833 result.innerHTML = `<div class="preview">
834 <p>Record to create on your PDS:</p>
835 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre>
836 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p>
837 </div>`;
838 });
839}
840
841// ── Pages ──────────────────────────────────────────────────────────────────
842
843async function showExplore(): Promise<void> {
844 currentPage = "explore";
845
846 // Try API first, fall back to server-rendered data
847 try {
848 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands");
849 islands = data.islands || [];
850 } catch {
851 const serverData = (window as any).__ISLANDS__;
852 if (serverData) islands = serverData;
853 }
854
855 renderExplore();
856}
857
858// ── Routing ───────────────────────────────────────────────────────────────
859
860function getRoute(): { page: string; params: Record<string, string> } {
861 const url = new URL(window.location.href);
862 const path = url.pathname;
863
864 if (path === "/island") {
865 return { page: "island", params: { id: url.searchParams.get("id") || "" } };
866 }
867 if (path === "/strata") {
868 return { page: "strata", params: { id: url.searchParams.get("id") || "" } };
869 }
870 if (path === "/connect") {
871 return { page: "connect", params: { source: url.searchParams.get("source") || "" } };
872 }
873 return { page: "explore", params: {} };
874}
875
876async function route(): Promise<void> {
877 const { page, params } = getRoute();
878
879 if (page === "island" && params.id) {
880 await showIsland(params.id);
881 } else if (page === "strata" && params.id) {
882 await showStrata(params.id);
883 } else if (page === "connect") {
884 renderConnectForm(params.source);
885 } else {
886 await showExplore();
887 }
888}
889
890// ── Auth ───────────────────────────────────────────────────────────────────
891
892async function initAuth(): Promise<void> {
893 try {
894 const res = await fetch("/xrpc/org.latha.strata.getProfile", {
895 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") },
896 });
897 if (res.ok) {
898 const data: any = await res.json();
899 session = { did: data.did, handle: data.handle };
900 updateAuthUI();
901 return;
902 }
903 } catch {}
904
905 updateAuthUI();
906}
907
908function updateAuthUI(): void {
909 const status = document.getElementById("auth-status");
910 const loginBtn = document.getElementById("login-btn");
911 if (!status || !loginBtn) return;
912
913 if (session) {
914 status.textContent = session.handle;
915 status.classList.add("visible");
916 loginBtn.classList.add("hidden");
917 } else {
918 status.classList.remove("visible");
919 loginBtn.classList.remove("hidden");
920 }
921}
922
923// ── Init ───────────────────────────────────────────────────────────────────
924
925document.addEventListener("DOMContentLoaded", () => {
926 route();
927});
928
929window.addEventListener("popstate", route);