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