This repository has no description
34 kB
958 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 < 200; 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 = -500 * 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)}"></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 let 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 // If vertexMeta is missing (summary mode), fetch full data
559 if (!island.vertexMeta || Object.keys(island.vertexMeta).length === 0) {
560 try {
561 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?limit=50");
562 const full = data.islands?.find(i => i.id === islandId);
563 if (full) {
564 island = full;
565 // Update cached island too
566 const idx = islands.findIndex(i => i.id === islandId);
567 if (idx >= 0) islands[idx] = full;
568 }
569 } catch {}
570 }
571
572 const edgeList = island.edges!.map(e => {
573 const icon = connectionTypeIcon(e.connectionType);
574 const type = connectionTypeLabel(e.connectionType);
575 const sourceMeta = island.vertexMeta![e.source];
576 const targetMeta = island.vertexMeta![e.target];
577 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
578 const targetTitle = targetMeta?.title || truncateUri(e.target);
579 return `
580 <div class="island-edge-detail">
581 <span class="edge-source">${escHtml(sourceTitle)}</span>
582 <span class="edge-type-badge">${icon} ${escHtml(type)}</span>
583 <span class="edge-target">${escHtml(targetTitle)}</span>
584 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""}
585 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""}
586 </div>
587 `;
588 }).join("");
589
590 content.innerHTML = `
591 <div class="island-detail">
592 <a href="/" class="back-link">← Explore</a>
593 <h2>Island</h2>
594 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges</p>
595
596 <canvas class="island-graph-large" id="island-detail-graph"></canvas>
597
598 <div class="island-section">
599 <h3>Connections</h3>
600 ${edgeList}
601 </div>
602
603 <div class="island-actions">
604 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button>
605 </div>
606 </div>
607 `;
608
609 // Initialize the detail graph
610 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement;
611 if (canvas) {
612 new ForceGraph(canvas, island);
613 }
614
615 document.getElementById("island-strata-btn")?.addEventListener("click", async () => {
616 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`;
617 });
618}
619
620// ── Strata page — prose with arrows ───────────────────────────────────────
621
622async function showStrata(islandId: string): Promise<void> {
623 currentPage = "strata";
624 const content = document.getElementById("page-content");
625 if (!content) return;
626
627 let island: Island | null = null;
628
629 // If the ID is an AT URI, fetch from the strata record endpoint
630 if (islandId.startsWith("at://")) {
631 try {
632 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`);
633 island = data.island;
634 } catch {
635 content.innerHTML = '<div class="empty">Strata record not found.</div>';
636 return;
637 }
638 } else {
639 // Legacy: sha256 ID — find from loaded islands
640 island = islands.find(i => i.id === islandId) || null;
641
642 // If not in memory, fetch from API
643 if (!island) {
644 try {
645 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands");
646 island = data.islands.find(i => i.id === islandId) || null;
647 if (island) islands = data.islands;
648 } catch {}
649 }
650 }
651
652 if (!island) {
653 content.innerHTML = '<div class="empty">Island not found.</div>';
654 return;
655 }
656
657 if (!island.strata) {
658 content.innerHTML = `
659 <div class="strata-page">
660 <a href="/" class="back-link">← Explore</a>
661 <h2>Strata</h2>
662 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div>
663 </div>
664 `;
665 return;
666 }
667
668 renderStrata(island);
669}
670
671function renderStrata(island: Island): void {
672 const content = document.getElementById("page-content");
673 if (!content) return;
674 const s = island.strata!;
675
676 const summary = island.summary || summarizeIsland(island);
677
678 // Themes
679 const themesHtml = s.themes.length > 0
680 ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>`
681 : "";
682
683 // Relationships — link entity names to their Semble pages
684 const titleToUri = new Map<string, string>();
685 for (const uri of island.vertices!) {
686 const m = island.vertexMeta![uri];
687 const title = m?.title;
688 if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) {
689 titleToUri.set(title, uri);
690 }
691 }
692
693 const relsHtml = s.relationships.length > 0
694 ? `<div class="strata-relationships">${s.relationships.map(r => {
695 // Link entity names to their Semble pages
696 let styled = escHtml(r);
697 // Sort titles longest-first so longer matches take priority
698 const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length);
699 for (const [title, uri] of sortedTitles) {
700 const escaped = escHtml(title);
701 // Exact match
702 if (styled.includes(escaped)) {
703 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
704 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
705 }
706 }
707 // Also try linking short names that appear as substrings of titles
708 // e.g. "C2PA" appears inside "C2PA content authenticity standard"
709 for (const [title, uri] of sortedTitles) {
710 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
711 // Extract short name segments from the title (first N words, abbreviations, etc.)
712 const shortNames = [
713 title.split(/[:—–\-]/)[0].trim(), // text before delimiter
714 title.split("(")[0].trim(), // text before parenthetical
715 ].filter(n => n.length > 3 && n.length < title.length);
716 for (const shortName of shortNames) {
717 const escaped = escHtml(shortName);
718 if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) {
719 // Not already linked — link it
720 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
721 }
722 }
723 }
724 styled = styled
725 .replace(/→/g, '<span class="arrow arrow-forward">→</span>')
726 .replace(/←/g, '<span class="arrow arrow-back">←</span>')
727 .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>')
728 .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>');
729 return `<div class="strata-rel-line">${styled}</div>`;
730 }).join("")}</div>`
731 : "";
732
733 // Tensions
734 const tensionsHtml = s.tensions.length > 0
735 ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>`
736 : "";
737
738 // Open questions
739 const questionsHtml = s.open_questions.length > 0
740 ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>`
741 : "";
742
743 // Synthesis
744 const synthesisHtml = s.synthesis
745 ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>`
746 : "";
747
748 content.innerHTML = `
749 <div class="strata-page">
750 <a href="/" class="back-link">← Explore</a>
751 <p class="island-summary-large">${escHtml(summary)}</p>
752 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges</p>
753
754 <canvas class="island-graph-large" id="strata-graph"></canvas>
755
756 <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>
757
758 ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""}
759
760 ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""}
761
762 ${tensionsHtml}
763 ${questionsHtml}
764
765 <div class="strata-section">
766 <h3>Edges</h3>
767 <div class="strata-edges">${island.edges!.map(e => {
768 const sourceMeta = island.vertexMeta![e.source];
769 const targetMeta = island.vertexMeta![e.target];
770 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
771 const targetTitle = targetMeta?.title || truncateUri(e.target);
772 const type = connectionTypeLabel(e.connectionType);
773 const icon = connectionTypeIcon(e.connectionType);
774 const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
775 return `<a href="${escHtml(sembleUrl)}" class="strata-edge" target="_blank" rel="noopener"${!sembleUrl ? ' onclick="event.preventDefault()"' : ""}>
776 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
777 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
778 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
779 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
780 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
781 </a>`;
782 }).join("")}</div>
783 </div>
784 </div>
785 `;
786
787 // Initialize the force graph
788 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement;
789 if (canvas) {
790 new ForceGraph(canvas, island);
791 }
792}
793
794// ── Connect page ───────────────────────────────────────────────────────────
795
796function renderConnectForm(sourceUri?: string): void {
797 const content = document.getElementById("page-content");
798 if (!content) return;
799
800 content.innerHTML = `
801 <div class="connect-page">
802 <a href="/" class="back-link">← Explore</a>
803 <h2>Create Connection</h2>
804 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p>
805
806 <form id="connect-form" class="connect-form">
807 <div class="form-field">
808 <label for="source">Source</label>
809 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required>
810 </div>
811 <div class="form-field">
812 <label for="target">Target</label>
813 <input type="text" id="target" name="target" placeholder="URL or AT URI" required>
814 </div>
815 <div class="form-field">
816 <label for="connectionType">Type</label>
817 <select id="connectionType" name="connectionType">
818 <option value="network.cosmik.connection#relates">relates</option>
819 <option value="network.cosmik.connection#contains">contains</option>
820 <option value="network.cosmik.connection#cites">cites</option>
821 <option value="network.cosmik.connection#inspired">inspired</option>
822 <option value="network.cosmik.connection#contradicts">contradicts</option>
823 <option value="network.cosmik.connection#extends">extends</option>
824 <option value="network.cosmik.connection#forks">forks</option>
825 <option value="network.cosmik.connection#annotates">annotates</option>
826 </select>
827 </div>
828 <div class="form-field">
829 <label for="note">Note</label>
830 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea>
831 </div>
832 <div class="form-actions">
833 <button type="submit" class="action-btn primary">Connect</button>
834 </div>
835 <div id="connect-result" class="connect-result"></div>
836 </form>
837 </div>
838 `;
839
840 document.getElementById("connect-form")?.addEventListener("submit", async (e) => {
841 e.preventDefault();
842 const result = document.getElementById("connect-result");
843 if (!result) return;
844
845 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>';
846
847 const source = (document.getElementById("source") as HTMLInputElement).value;
848 const target = (document.getElementById("target") as HTMLInputElement).value;
849 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value;
850 const note = (document.getElementById("note") as HTMLTextAreaElement).value;
851
852 const record = {
853 $type: "network.cosmik.connection",
854 source,
855 target,
856 connectionType,
857 note: note || undefined,
858 createdAt: new Date().toISOString(),
859 };
860
861 result.innerHTML = `<div class="preview">
862 <p>Record to create on your PDS:</p>
863 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre>
864 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p>
865 </div>`;
866 });
867}
868
869// ── Pages ──────────────────────────────────────────────────────────────────
870
871async function showExplore(): Promise<void> {
872 currentPage = "explore";
873
874 // Try API first, fall back to server-rendered data
875 // Use summary mode — skip vertexMeta (saves ~250KB)
876 try {
877 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?summary=true");
878 islands = data.islands || [];
879 } catch {
880 const serverData = (window as any).__ISLANDS__;
881 if (serverData) islands = serverData;
882 }
883
884 renderExplore();
885}
886
887// ── Routing ───────────────────────────────────────────────────────────────
888
889function getRoute(): { page: string; params: Record<string, string> } {
890 const url = new URL(window.location.href);
891 const path = url.pathname;
892
893 if (path === "/island") {
894 return { page: "island", params: { id: url.searchParams.get("id") || "" } };
895 }
896 if (path === "/strata") {
897 return { page: "strata", params: { id: url.searchParams.get("id") || "" } };
898 }
899 if (path === "/connect") {
900 return { page: "connect", params: { source: url.searchParams.get("source") || "" } };
901 }
902 return { page: "explore", params: {} };
903}
904
905async function route(): Promise<void> {
906 const { page, params } = getRoute();
907
908 if (page === "island" && params.id) {
909 await showIsland(params.id);
910 } else if (page === "strata" && params.id) {
911 await showStrata(params.id);
912 } else if (page === "connect") {
913 renderConnectForm(params.source);
914 } else {
915 await showExplore();
916 }
917}
918
919// ── Auth ───────────────────────────────────────────────────────────────────
920
921async function initAuth(): Promise<void> {
922 try {
923 const res = await fetch("/xrpc/org.latha.strata.getProfile", {
924 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") },
925 });
926 if (res.ok) {
927 const data: any = await res.json();
928 session = { did: data.did, handle: data.handle };
929 updateAuthUI();
930 return;
931 }
932 } catch {}
933
934 updateAuthUI();
935}
936
937function updateAuthUI(): void {
938 const status = document.getElementById("auth-status");
939 const loginBtn = document.getElementById("login-btn");
940 if (!status || !loginBtn) return;
941
942 if (session) {
943 status.textContent = session.handle;
944 status.classList.add("visible");
945 loginBtn.classList.add("hidden");
946 } else {
947 status.classList.remove("visible");
948 loginBtn.classList.remove("hidden");
949 }
950}
951
952// ── Init ───────────────────────────────────────────────────────────────────
953
954document.addEventListener("DOMContentLoaded", () => {
955 route();
956});
957
958window.addEventListener("popstate", route);