This repository has no description
42 kB
1143 lines
1/**
2 * Stigmergic frontend — island-centric SPA.
3 *
4 * The knowledge graph is a set of islands (connected components).
5 * Each island is a cluster of linked URLs discovered via
6 * network.cosmik.connection records. Strata analysis runs on an
7 * island and produces an org.latha.island record.
8 */
9
10// ── Types ──────────────────────────────────────────────────────────────────
11
12interface VertexMeta {
13 title: string;
14 description: string;
15 type: string;
16}
17
18interface IslandEdge {
19 uri: string;
20 did: string;
21 source: string;
22 target: string;
23 connectionType: string;
24 note: string;
25 handle?: string | null;
26}
27
28interface Island {
29 id: string;
30 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 highlights: 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": "→",
430 "cites": "←",
431 "related": "↔",
432 "relates": "↔",
433 "inspired": "✨",
434 "opposes": "✕",
435 "contradicts": "✕",
436 "extends": "⇒",
437 "forks": "⇄",
438 "annotates": "✎",
439 "supplement": "⊕",
440 "supplements": "⊕",
441 "supports": "✓",
442 "addresses": "◆",
443 "explainer": "ℹ",
444 "helpful": "☆",
445 "contains": "▶",
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 const island = islands.find(i => i.id === islandId);
483 window.location.href = `/strata?id=${encodeURIComponent(island?.recordUri || islandId)}`;
484 });
485 });
486}
487
488function summarizeIsland(island: Island): string {
489 if (island.title) return island.title;
490 if (island.summary) return island.summary;
491
492 // Fallback: build from metadata (only if we have full data)
493 const vertices = island.vertices;
494 if (!vertices || vertices.length === 0) return "Island";
495
496 const meta = island.vertexMeta || {};
497
498 const domains = new Map<string, number>();
499 for (const v of vertices) {
500 if (v.startsWith("http")) {
501 try {
502 const host = new URL(v).hostname.replace(/^www\./, "");
503 domains.set(host, (domains.get(host) || 0) + 1);
504 } catch {}
505 }
506 }
507 const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]);
508
509 const titles = vertices
510 .map(v => meta[v]?.title || "")
511 .filter(t => t.length > 10 && t.length < 120)
512 .slice(0, 2);
513
514 const parts: string[] = [];
515 if (topDomains.length > 0) parts.push(topDomains.join(", "));
516 if (titles.length > 0) parts.push(titles.join("; "));
517 return parts.join(" -- ") || `${vertices.length} connected nodes`;
518}
519
520function renderIslandCard(island: Island): string {
521 const summary = summarizeIsland(island);
522 const hasStrata = !!island.strata;
523 const strataBtnLabel = hasStrata ? "View Strata" : "Strata";
524 const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn";
525 const hasFullData = !!(island.vertices && island.edges);
526 const sizeLabel = hasFullData
527 ? `${island.vertices!.length} nodes · ${island.edges!.length} edges`
528 : "";
529
530 const graphOrTitle = hasFullData
531 ? `<canvas class="island-graph" data-island-id="${escHtml(island.id)}"></canvas>`
532 : `<div class="island-title-preview">${escHtml(summary)}</div>`;
533
534 return `
535 <div class="island-card" data-island-id="${escHtml(island.id)}">
536 ${graphOrTitle}
537 <div class="island-card-footer">
538 ${sizeLabel ? `<span class="island-size">${sizeLabel}</span>` : ""}
539 ${hasFullData ? `<p class="island-summary">${escHtml(summary)}</p>` : ""}
540 <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button>
541 </div>
542 </div>
543 `;
544}
545
546// ── Island detail page ─────────────────────────────────────────────────────
547
548async function showIsland(islandId: string): Promise<void> {
549 currentPage = "island";
550 const content = document.getElementById("page-content");
551 if (!content) return;
552
553 let island = islands.find(i => i.id === islandId);
554 if (!island) {
555 content.innerHTML = '<div class="empty">Island not found.</div>';
556 return;
557 }
558
559 // If vertexMeta is missing (summary mode), fetch full data
560 if (!island.vertexMeta || Object.keys(island.vertexMeta).length === 0) {
561 try {
562 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands?limit=50");
563 const full = data.islands?.find(i => i.id === islandId);
564 if (full) {
565 island = full;
566 // Update cached island too
567 const idx = islands.findIndex(i => i.id === islandId);
568 if (idx >= 0) islands[idx] = full;
569 }
570 } catch {}
571 }
572
573 const edgeList = island.edges!.map(e => {
574 const icon = connectionTypeIcon(e.connectionType);
575 const type = connectionTypeLabel(e.connectionType);
576 const sourceMeta = island.vertexMeta![e.source];
577 const targetMeta = island.vertexMeta![e.target];
578 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
579 const targetTitle = targetMeta?.title || truncateUri(e.target);
580 return `
581 <div class="island-edge-detail">
582 <span class="edge-source">${escHtml(sourceTitle)}</span>
583 <span class="edge-type-badge">${icon} ${escHtml(type)}</span>
584 <span class="edge-target">${escHtml(targetTitle)}</span>
585 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""}
586 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""}
587 </div>
588 `;
589 }).join("");
590
591 content.innerHTML = `
592 <div class="island-detail">
593 <a href="/" class="back-link">← Explore</a>
594 <h2>Island</h2>
595 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · <a href="https://pdsls.dev/${island.recordUri}#record" target="_blank" rel="noopener" class="pds-link">View on PDS ↗</a>` : ""}</p>
596
597 <canvas class="island-graph-large" id="island-detail-graph"></canvas>
598
599 <div class="island-section">
600 <h3>Connections</h3>
601 ${edgeList}
602 </div>
603
604 <div class="island-actions">
605 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button>
606 </div>
607 </div>
608 `;
609
610 // Initialize the detail graph
611 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement;
612 if (canvas) {
613 new ForceGraph(canvas, island);
614 }
615
616 document.getElementById("island-strata-btn")?.addEventListener("click", async () => {
617 window.location.href = `/strata?id=${encodeURIComponent(island.recordUri || islandId)}`;
618 });
619}
620
621// ── Strata page — prose with arrows ───────────────────────────────────────
622
623async function showStrata(islandId: string): Promise<void> {
624 currentPage = "strata";
625 const content = document.getElementById("page-content");
626 if (!content) return;
627
628 let island: Island | null = null;
629
630 // If the ID is an AT URI, fetch from the strata record endpoint
631 if (islandId.startsWith("at://")) {
632 try {
633 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.island.getRecord?uri=${encodeURIComponent(islandId)}`);
634 island = data.island;
635 island.recordUri = data.recordUri;
636 } catch {
637 content.innerHTML = '<div class="empty">Strata record not found.</div>';
638 return;
639 }
640 } else {
641 // Legacy: sha256 ID — find from loaded islands
642 island = islands.find(i => i.id === islandId) || null;
643
644 // If not in memory, fetch from API
645 if (!island) {
646 try {
647 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands");
648 island = data.islands.find(i => i.id === islandId) || null;
649 if (island) islands = data.islands;
650 } catch {}
651 }
652 }
653
654 if (!island) {
655 content.innerHTML = '<div class="empty">Island not found.</div>';
656 return;
657 }
658
659 if (!island.strata) {
660 content.innerHTML = `
661 <div class="strata-page">
662 <a href="/" class="back-link">← Explore</a>
663 <h2>Strata</h2>
664 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div>
665 </div>
666 `;
667 return;
668 }
669
670 renderStrata(island);
671}
672
673function renderStrata(island: Island): void {
674 const content = document.getElementById("page-content");
675 if (!content) return;
676 const s = island.strata!;
677
678 const summary = island.summary || summarizeIsland(island);
679
680 // Build editable strata page
681 content.innerHTML = `
682 <div class="strata-page">
683 <a href="/" class="back-link">← Explore</a>
684 <p class="island-summary-large" data-field="title" contenteditable="true">${escHtml(summary)}</p>
685 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · <a href="https://pdsls.dev/${island.recordUri}#record" target="_blank" rel="noopener" class="pds-link">View on PDS ↗</a>` : ""}</p>
686
687 <canvas class="island-graph-large" id="strata-graph"></canvas>
688
689 <div class="strata-section">
690 <h3>Synthesis <span class="edit-hint" title="Click to edit">✎</span></h3>
691 <div class="synthesis-text editable" data-field="synthesis" contenteditable="true">${escHtml(s.synthesis)}</div>
692 </div>
693
694 <div class="strata-section">
695 <h3>Themes <span class="edit-hint" title="Click to edit">✎</span></h3>
696 <div class="theme-tags editable" data-field="themes">
697 ${s.themes.map(t => `<span class="theme-tag" data-value="${escHtml(t)}">${escHtml(t)}<span class="theme-remove">×</span></span>`).join("")}
698 <span class="theme-add">+</span>
699 </div>
700 </div>
701
702 <div class="strata-section">
703 <h3>Tensions <span class="edit-hint" title="Click to edit">✎</span></h3>
704 <div class="editable-list" data-field="tensions">
705 ${s.tensions.map(t => `<div class="analysis-tension editable" contenteditable="true">${escHtml(t)}</div>`).join("")}
706 <div class="list-add" data-field="tensions">+ Add tension</div>
707 </div>
708 </div>
709
710 <div class="strata-section">
711 <h3>Open Questions <span class="edit-hint" title="Click to edit">✎</span></h3>
712 <div class="editable-list" data-field="openQuestions">
713 ${s.open_questions.map(q => `<div class="analysis-question editable" contenteditable="true">${escHtml(q)}</div>`).join("")}
714 <div class="list-add" data-field="openQuestions">+ Add question</div>
715 </div>
716 </div>
717
718 <div class="strata-actions">
719 <button class="strata-btn regenerate-btn" id="regenerate-btn">Regenerate</button>
720 <button class="strata-btn save-btn hidden" id="save-btn">Save to PDS</button>
721 <span class="save-status" id="save-status"></span>
722 </div>
723
724 ${s.highlights.length > 0 ? `<div class="strata-section">
725 <h3>Highlights <span class="highlights-count">${s.highlights.length} hot edges</span></h3>
726 <div class="strata-edges">${island.edges!.filter(e => s.highlights.includes(e.uri)).map(e => {
727 const sourceMeta = island.vertexMeta![e.source];
728 const targetMeta = island.vertexMeta![e.target];
729 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
730 const targetTitle = targetMeta?.title || truncateUri(e.target);
731 const type = connectionTypeLabel(e.connectionType);
732 const icon = connectionTypeIcon(e.connectionType);
733 const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
734 return `<a href="${escHtml(seemsUrl)}" class="strata-edge hot-edge" target="_blank" rel="noopener"${!seemsUrl ? ' onclick="event.preventDefault()"' : ""}>
735 <span class="hot-badge">★</span>
736 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
737 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
738 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
739 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
740 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
741 </a>`;
742 }).join("")}</div>
743 </div>` : ""}
744
745 <div class="strata-section">
746 <h3>Edges</h3>
747 <div class="strata-edges">${island.edges!.map(e => {
748 const sourceMeta = island.vertexMeta![e.source];
749 const targetMeta = island.vertexMeta![e.target];
750 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
751 const targetTitle = targetMeta?.title || truncateUri(e.target);
752 const type = connectionTypeLabel(e.connectionType);
753 const icon = connectionTypeIcon(e.connectionType);
754 const seemsUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
755 return `<a href="${escHtml(seemsUrl)}" class="strata-edge" target="_blank" rel="noopener"${!seemsUrl ? ' onclick="event.preventDefault()"' : ""}>
756 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
757 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
758 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
759 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
760 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
761 </a>`;
762 }).join("")}</div>
763 </div>
764 </div>
765 `;
766
767 // Initialize the force graph
768 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement;
769 if (canvas) {
770 new ForceGraph(canvas, island);
771 }
772
773 // ── Edit tracking ──────────────────────────────────────────────
774 let dirty = false;
775 const saveBtn = document.getElementById("save-btn");
776 const saveStatus = document.getElementById("save-status");
777
778 function markDirty() {
779 dirty = true;
780 saveBtn?.classList.remove("hidden");
781 saveStatus!.textContent = "";
782 }
783
784 // Track contenteditable changes
785 const editContainer = content!;
786 editContainer.querySelectorAll("[contenteditable]").forEach(el => {
787 el.addEventListener("input", markDirty);
788 });
789
790 // Theme tag removal
791 editContainer.querySelectorAll(".theme-remove").forEach(btn => {
792 btn.addEventListener("click", (e) => {
793 (e.currentTarget as HTMLElement).parentElement!.remove();
794 markDirty();
795 });
796 });
797
798 // Theme tag add
799 editContainer.querySelectorAll(".theme-add").forEach(btn => {
800 btn.addEventListener("click", () => {
801 const tag = document.createElement("span");
802 tag.className = "theme-tag";
803 tag.dataset.value = "new theme";
804 tag.innerHTML = 'new theme<span class="theme-remove">×</span>';
805 tag.setAttribute("contenteditable", "true");
806 tag.addEventListener("input", () => { tag.dataset.value = tag.textContent?.replace("×", "").trim() || ""; markDirty(); });
807 tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); });
808 (btn as Element as ChildNode).before(tag);
809 tag.focus();
810 markDirty();
811 });
812 });
813
814 // List item add
815 editContainer.querySelectorAll(".list-add").forEach(btn => {
816 btn.addEventListener("click", () => {
817 const field = (btn as HTMLElement).dataset.field!;
818 const div = document.createElement("div");
819 div.className = field === "tensions" ? "analysis-tension editable" : "analysis-question editable";
820 div.setAttribute("contenteditable", "true");
821 div.textContent = "New item...";
822 div.addEventListener("input", markDirty);
823 (btn as Element as ChildNode).before(div);
824 div.focus();
825 markDirty();
826 });
827 });
828
829 // ── Collect current analysis state ─────────────────────────────
830 function collectAnalysis(): Record<string, any> {
831 const titleEl = editContainer.querySelector('[data-field="title"]');
832 const synthesisEl = editContainer.querySelector('[data-field="synthesis"]');
833 const themeTags = editContainer.querySelectorAll('.theme-tag');
834 const tensionEls = editContainer.querySelectorAll('[data-field="tensions"] .editable');
835 const questionEls = editContainer.querySelectorAll('[data-field="openQuestions"] .editable');
836
837 return {
838 title: titleEl?.textContent?.trim() || "",
839 synthesis: synthesisEl?.textContent?.trim() || "",
840 themes: Array.from(themeTags).map(t => t.getAttribute("data-value") || t.textContent?.replace("×", "").trim() || ""),
841 tensions: Array.from(tensionEls).map(t => t.textContent?.trim() || "").filter(Boolean),
842 openQuestions: Array.from(questionEls).map(q => q.textContent?.trim() || "").filter(Boolean),
843 highlights: island.strata?.highlights || [],
844 };
845 }
846
847 // ── Save to PDS ────────────────────────────────────────────────
848 saveBtn?.addEventListener("click", async () => {
849 if (!island.recordUri) return;
850 saveBtn.textContent = "Saving...";
851 saveBtn.setAttribute("disabled", "true");
852
853 try {
854 const analysis = collectAnalysis();
855 const res = await fetch("/xrpc/org.latha.island.updateRecord", {
856 method: "PUT",
857 headers: { "Content-Type": "application/json" },
858 body: JSON.stringify({ uri: island.recordUri, analysis }),
859 });
860 if (!res.ok) {
861 const err = (await res.json()) as { message?: string };
862 throw new Error(err.message || "Save failed");
863 }
864 dirty = false;
865 saveBtn.classList.add("hidden");
866 saveStatus!.textContent = "Saved ✓";
867 setTimeout(() => { saveStatus!.textContent = ""; }, 3000);
868 } catch (e: any) {
869 saveStatus!.textContent = `Error: ${e.message}`;
870 } finally {
871 saveBtn.textContent = "Save to PDS";
872 saveBtn.removeAttribute("disabled");
873 }
874 });
875
876 // ── Regenerate ─────────────────────────────────────────────────
877 const regenBtn = document.getElementById("regenerate-btn");
878 regenBtn?.addEventListener("click", async () => {
879 regenBtn.textContent = "Generating...";
880 regenBtn.setAttribute("disabled", "true");
881
882 try {
883 const res = await fetch("/xrpc/org.latha.island.analyze", {
884 method: "POST",
885 headers: { "Content-Type": "application/json" },
886 body: JSON.stringify({
887 did: session?.did,
888 island: {
889 vertices: (island.vertices || []).map(uri => ({
890 uri,
891 title: island.vertexMeta?.[uri]?.title || "",
892 description: island.vertexMeta?.[uri]?.description || "",
893 type: island.vertexMeta?.[uri]?.type || "url",
894 })),
895 edges: island.edges || [],
896 },
897 }),
898 });
899 if (!res.ok) throw new Error("Analysis failed");
900 const result = await res.json();
901
902 // Diff-highlight: mark changed fields
903 const oldAnalysis = collectAnalysis();
904 const newAnalysis = result as Record<string, any>;
905
906 // Update fields with animation
907 const titleEl = editContainer.querySelector('[data-field="title"]') as HTMLElement;
908 const synthesisEl = editContainer.querySelector('[data-field="synthesis"]') as HTMLElement;
909
910 if (titleEl && newAnalysis.title !== oldAnalysis.title) {
911 titleEl.textContent = newAnalysis.title;
912 titleEl.classList.add("field-changed");
913 setTimeout(() => titleEl.classList.remove("field-changed"), 2000);
914 }
915 if (synthesisEl && newAnalysis.synthesis !== oldAnalysis.synthesis) {
916 synthesisEl.textContent = newAnalysis.synthesis;
917 synthesisEl.classList.add("field-changed");
918 setTimeout(() => synthesisEl.classList.remove("field-changed"), 2000);
919 }
920
921 // Update themes
922 const themesContainer = editContainer.querySelector('[data-field="themes"]');
923 if (themesContainer) {
924 themesContainer.querySelectorAll(".theme-tag").forEach(t => t.remove());
925 const addBtn = themesContainer.querySelector(".theme-add");
926 for (const theme of newAnalysis.themes || []) {
927 const tag = document.createElement("span");
928 tag.className = "theme-tag field-changed";
929 tag.dataset.value = theme;
930 tag.innerHTML = `${escHtml(theme)}<span class="theme-remove">×</span>`;
931 tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); });
932 (addBtn as ChildNode)?.before(tag);
933 setTimeout(() => tag.classList.remove("field-changed"), 2000);
934 }
935 }
936
937 // Update tensions
938 const tensionsContainer = editContainer.querySelector('[data-field="tensions"]');
939 if (tensionsContainer) {
940 tensionsContainer.querySelectorAll(".editable").forEach(t => t.remove());
941 const addBtn = tensionsContainer.querySelector(".list-add");
942 for (const t of newAnalysis.tensions || []) {
943 const div = document.createElement("div");
944 div.className = "analysis-tension editable field-changed";
945 div.setAttribute("contenteditable", "true");
946 div.textContent = t;
947 div.addEventListener("input", markDirty);
948 (addBtn as ChildNode)?.before(div);
949 setTimeout(() => div.classList.remove("field-changed"), 2000);
950 }
951 }
952
953 // Update questions
954 const questionsContainer = editContainer.querySelector('[data-field="openQuestions"]');
955 if (questionsContainer) {
956 questionsContainer.querySelectorAll(".editable").forEach(q => q.remove());
957 const addBtn = questionsContainer.querySelector(".list-add");
958 for (const q of newAnalysis.openQuestions || []) {
959 const div = document.createElement("div");
960 div.className = "analysis-question editable field-changed";
961 div.setAttribute("contenteditable", "true");
962 div.textContent = q;
963 div.addEventListener("input", markDirty);
964 (addBtn as ChildNode)?.before(div);
965 setTimeout(() => div.classList.remove("field-changed"), 2000);
966 }
967 }
968
969 markDirty(); // Regenerated = needs save
970 } catch (e: any) {
971 saveStatus!.textContent = `Regenerate error: ${e.message}`;
972 } finally {
973 regenBtn.textContent = "Regenerate";
974 regenBtn.removeAttribute("disabled");
975 }
976 });
977}
978
979// ── Connect page ───────────────────────────────────────────────────────────
980
981function renderConnectForm(sourceUri?: string): void {
982 const content = document.getElementById("page-content");
983 if (!content) return;
984
985 content.innerHTML = `
986 <div class="connect-page">
987 <a href="/" class="back-link">← Explore</a>
988 <h2>Create Connection</h2>
989 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p>
990
991 <form id="connect-form" class="connect-form">
992 <div class="form-field">
993 <label for="source">Source</label>
994 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required>
995 </div>
996 <div class="form-field">
997 <label for="target">Target</label>
998 <input type="text" id="target" name="target" placeholder="URL or AT URI" required>
999 </div>
1000 <div class="form-field">
1001 <label for="connectionType">Type</label>
1002 <select id="connectionType" name="connectionType">
1003 <option value="network.cosmik.connection#relates">relates</option>
1004 <option value="network.cosmik.connection#contains">contains</option>
1005 <option value="network.cosmik.connection#cites">cites</option>
1006 <option value="network.cosmik.connection#inspired">inspired</option>
1007 <option value="network.cosmik.connection#contradicts">contradicts</option>
1008 <option value="network.cosmik.connection#extends">extends</option>
1009 <option value="network.cosmik.connection#forks">forks</option>
1010 <option value="network.cosmik.connection#annotates">annotates</option>
1011 </select>
1012 </div>
1013 <div class="form-field">
1014 <label for="note">Note</label>
1015 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea>
1016 </div>
1017 <div class="form-actions">
1018 <button type="submit" class="action-btn primary">Connect</button>
1019 </div>
1020 <div id="connect-result" class="connect-result"></div>
1021 </form>
1022 </div>
1023 `;
1024
1025 document.getElementById("connect-form")?.addEventListener("submit", async (e) => {
1026 e.preventDefault();
1027 const result = document.getElementById("connect-result");
1028 if (!result) return;
1029
1030 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>';
1031
1032 const source = (document.getElementById("source") as HTMLInputElement).value;
1033 const target = (document.getElementById("target") as HTMLInputElement).value;
1034 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value;
1035 const note = (document.getElementById("note") as HTMLTextAreaElement).value;
1036
1037 const record = {
1038 $type: "network.cosmik.connection",
1039 source,
1040 target,
1041 connectionType,
1042 note: note || undefined,
1043 createdAt: new Date().toISOString(),
1044 };
1045
1046 result.innerHTML = `<div class="preview">
1047 <p>Record to create on your PDS:</p>
1048 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre>
1049 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p>
1050 </div>`;
1051 });
1052}
1053
1054// ── Pages ──────────────────────────────────────────────────────────────────
1055
1056async function showExplore(): Promise<void> {
1057 currentPage = "explore";
1058
1059 // Try API first, fall back to server-rendered data
1060 // Use summary mode — skip vertexMeta (saves ~250KB)
1061 try {
1062 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.island.getIslands?summary=true");
1063 islands = data.islands || [];
1064 } catch {
1065 const serverData = (window as any).__ISLANDS__;
1066 if (serverData) islands = serverData;
1067 }
1068
1069 renderExplore();
1070}
1071
1072// ── Routing ───────────────────────────────────────────────────────────────
1073
1074function getRoute(): { page: string; params: Record<string, string> } {
1075 const url = new URL(window.location.href);
1076 const path = url.pathname;
1077
1078 if (path === "/island") {
1079 return { page: "island", params: { id: url.searchParams.get("id") || "" } };
1080 }
1081 if (path === "/strata") {
1082 return { page: "strata", params: { id: url.searchParams.get("id") || "" } };
1083 }
1084 if (path === "/connect") {
1085 return { page: "connect", params: { source: url.searchParams.get("source") || "" } };
1086 }
1087 return { page: "explore", params: {} };
1088}
1089
1090async function route(): Promise<void> {
1091 const { page, params } = getRoute();
1092
1093 if (page === "island" && params.id) {
1094 await showIsland(params.id);
1095 } else if (page === "strata" && params.id) {
1096 await showStrata(params.id);
1097 } else if (page === "connect") {
1098 renderConnectForm(params.source);
1099 } else {
1100 await showExplore();
1101 }
1102}
1103
1104// ── Auth ───────────────────────────────────────────────────────────────────
1105
1106async function initAuth(): Promise<void> {
1107 try {
1108 const res = await fetch("/xrpc/org.latha.island.getProfile", {
1109 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") },
1110 });
1111 if (res.ok) {
1112 const data: any = await res.json();
1113 session = { did: data.did, handle: data.handle };
1114 updateAuthUI();
1115 return;
1116 }
1117 } catch {}
1118
1119 updateAuthUI();
1120}
1121
1122function updateAuthUI(): void {
1123 const status = document.getElementById("auth-status");
1124 const loginBtn = document.getElementById("login-btn");
1125 if (!status || !loginBtn) return;
1126
1127 if (session) {
1128 status.textContent = session.handle;
1129 status.classList.add("visible");
1130 loginBtn.classList.add("hidden");
1131 } else {
1132 status.classList.remove("visible");
1133 loginBtn.classList.remove("hidden");
1134 }
1135}
1136
1137// ── Init ───────────────────────────────────────────────────────────────────
1138
1139document.addEventListener("DOMContentLoaded", () => {
1140 route();
1141});
1142
1143window.addEventListener("popstate", route);