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