This repository has no description
43 kB
1157 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 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.strata.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/at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.strata/${island.id}#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.strata.getRecord?uri=${encodeURIComponent(islandId)}`);
634 island = data.island;
635 } catch {
636 content.innerHTML = '<div class="empty">Strata record not found.</div>';
637 return;
638 }
639 } else {
640 // Legacy: sha256 ID — find from loaded islands
641 island = islands.find(i => i.id === islandId) || null;
642
643 // If not in memory, fetch from API
644 if (!island) {
645 try {
646 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands");
647 island = data.islands.find(i => i.id === islandId) || null;
648 if (island) islands = data.islands;
649 } catch {}
650 }
651 }
652
653 if (!island) {
654 content.innerHTML = '<div class="empty">Island not found.</div>';
655 return;
656 }
657
658 if (!island.strata) {
659 content.innerHTML = `
660 <div class="strata-page">
661 <a href="/" class="back-link">← Explore</a>
662 <h2>Strata</h2>
663 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div>
664 </div>
665 `;
666 return;
667 }
668
669 renderStrata(island);
670}
671
672function renderStrata(island: Island): void {
673 const content = document.getElementById("page-content");
674 if (!content) return;
675 const s = island.strata!;
676
677 const summary = island.summary || summarizeIsland(island);
678
679 // Build editable strata page
680 content.innerHTML = `
681 <div class="strata-page">
682 <a href="/" class="back-link">← Explore</a>
683 <p class="island-summary-large" data-field="title" contenteditable="true">${escHtml(summary)}</p>
684 <p class="island-meta">${island.vertices!.length} vertices · ${island.edges!.length} edges${island.recordUri ? ` · <a href="https://pdsls.dev/at://did:plc:ngokl2gnmpbvuvrfckja3g7p/org.latha.strata/${island.id}#record" target="_blank" rel="noopener" class="pds-link">View on PDS ↗</a>` : ""}</p>
685
686 <canvas class="island-graph-large" id="strata-graph"></canvas>
687
688 <div class="strata-section">
689 <h3>Synthesis <span class="edit-hint" title="Click to edit">✎</span></h3>
690 <div class="synthesis-text editable" data-field="synthesis" contenteditable="true">${escHtml(s.synthesis)}</div>
691 </div>
692
693 <div class="strata-section">
694 <h3>Themes <span class="edit-hint" title="Click to edit">✎</span></h3>
695 <div class="theme-tags editable" data-field="themes">
696 ${s.themes.map(t => `<span class="theme-tag" data-value="${escHtml(t)}">${escHtml(t)}<span class="theme-remove">×</span></span>`).join("")}
697 <span class="theme-add">+</span>
698 </div>
699 </div>
700
701 ${s.relationships.length > 0 ? `<div class="strata-section">
702 <h3>Relationships <span class="edit-hint" title="Click to edit">✎</span></h3>
703 <div class="editable-list" data-field="relationships">
704 ${s.relationships.map(r => {
705 // Link entity names to their Semble pages
706 let styled = escHtml(r);
707 const titleToUri = new Map<string, string>();
708 for (const uri of island.vertices!) {
709 const m = island.vertexMeta![uri];
710 const title = m?.title;
711 if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) {
712 titleToUri.set(title, uri);
713 }
714 }
715 const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length);
716 for (const [title, uri] of sortedTitles) {
717 const escaped = escHtml(title);
718 if (styled.includes(escaped)) {
719 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
720 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
721 }
722 }
723 for (const [title, uri] of sortedTitles) {
724 const href = `https://semble.so/url/${encodeURIComponent(uri)}`;
725 const shortNames = [
726 title.split(/[:—–\-]/)[0].trim(),
727 title.split("(")[0].trim(),
728 ].filter(n => n.length > 3 && n.length < title.length);
729 for (const shortName of shortNames) {
730 const escaped = escHtml(shortName);
731 if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) {
732 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`);
733 }
734 }
735 }
736 styled = styled
737 .replace(/→/g, '<span class="arrow arrow-forward">→</span>')
738 .replace(/←/g, '<span class="arrow arrow-back">←</span>')
739 .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>')
740 .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>');
741 return `<div class="strata-rel-line editable" contenteditable="true">${styled}</div>`;
742 }).join("")}
743 <div class="list-add" data-field="relationships">+ Add relationship</div>
744 </div>
745 </div>` : ""}
746
747 <div class="strata-section">
748 <h3>Tensions <span class="edit-hint" title="Click to edit">✎</span></h3>
749 <div class="editable-list" data-field="tensions">
750 ${s.tensions.map(t => `<div class="analysis-tension editable" contenteditable="true">${escHtml(t)}</div>`).join("")}
751 <div class="list-add" data-field="tensions">+ Add tension</div>
752 </div>
753 </div>
754
755 <div class="strata-section">
756 <h3>Open Questions <span class="edit-hint" title="Click to edit">✎</span></h3>
757 <div class="editable-list" data-field="openQuestions">
758 ${s.open_questions.map(q => `<div class="analysis-question editable" contenteditable="true">${escHtml(q)}</div>`).join("")}
759 <div class="list-add" data-field="openQuestions">+ Add question</div>
760 </div>
761 </div>
762
763 <div class="strata-actions">
764 <button class="strata-btn regenerate-btn" id="regenerate-btn">Regenerate</button>
765 <button class="strata-btn save-btn hidden" id="save-btn">Save to PDS</button>
766 <span class="save-status" id="save-status"></span>
767 </div>
768
769 <div class="strata-section">
770 <h3>Edges</h3>
771 <div class="strata-edges">${island.edges!.map(e => {
772 const sourceMeta = island.vertexMeta![e.source];
773 const targetMeta = island.vertexMeta![e.target];
774 const sourceTitle = sourceMeta?.title || truncateUri(e.source);
775 const targetTitle = targetMeta?.title || truncateUri(e.target);
776 const type = connectionTypeLabel(e.connectionType);
777 const icon = connectionTypeIcon(e.connectionType);
778 const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : "";
779 return `<a href="${escHtml(sembleUrl)}" class="strata-edge" target="_blank" rel="noopener"${!sembleUrl ? ' onclick="event.preventDefault()"' : ""}>
780 <span class="strata-edge-source">${escHtml(sourceTitle)}</span>
781 <span class="strata-edge-type">${icon} ${escHtml(type)}</span>
782 <span class="strata-edge-target">${escHtml(targetTitle)}</span>
783 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""}
784 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""}
785 </a>`;
786 }).join("")}</div>
787 </div>
788 </div>
789 `;
790
791 // Initialize the force graph
792 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement;
793 if (canvas) {
794 new ForceGraph(canvas, island);
795 }
796
797 // ── Edit tracking ──────────────────────────────────────────────
798 let dirty = false;
799 const saveBtn = document.getElementById("save-btn");
800 const saveStatus = document.getElementById("save-status");
801
802 function markDirty() {
803 dirty = true;
804 saveBtn?.classList.remove("hidden");
805 saveStatus!.textContent = "";
806 }
807
808 // Track contenteditable changes
809 const editContainer = content!;
810 editContainer.querySelectorAll("[contenteditable]").forEach(el => {
811 el.addEventListener("input", markDirty);
812 });
813
814 // Theme tag removal
815 editContainer.querySelectorAll(".theme-remove").forEach(btn => {
816 btn.addEventListener("click", (e) => {
817 (e.currentTarget as HTMLElement).parentElement!.remove();
818 markDirty();
819 });
820 });
821
822 // Theme tag add
823 editContainer.querySelectorAll(".theme-add").forEach(btn => {
824 btn.addEventListener("click", () => {
825 const tag = document.createElement("span");
826 tag.className = "theme-tag";
827 tag.dataset.value = "new theme";
828 tag.innerHTML = 'new theme<span class="theme-remove">×</span>';
829 tag.setAttribute("contenteditable", "true");
830 tag.addEventListener("input", () => { tag.dataset.value = tag.textContent?.replace("×", "").trim() || ""; markDirty(); });
831 tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); });
832 (btn as Element as ChildNode).before(tag);
833 tag.focus();
834 markDirty();
835 });
836 });
837
838 // List item add
839 editContainer.querySelectorAll(".list-add").forEach(btn => {
840 btn.addEventListener("click", () => {
841 const field = (btn as HTMLElement).dataset.field!;
842 const div = document.createElement("div");
843 div.className = field === "tensions" ? "analysis-tension editable" : field === "relationships" ? "strata-rel-line editable" : "analysis-question editable";
844 div.setAttribute("contenteditable", "true");
845 div.textContent = "New item...";
846 div.addEventListener("input", markDirty);
847 (btn as Element as ChildNode).before(div);
848 div.focus();
849 markDirty();
850 });
851 });
852
853 // ── Collect current analysis state ─────────────────────────────
854 function collectAnalysis(): Record<string, any> {
855 const titleEl = editContainer.querySelector('[data-field="title"]');
856 const synthesisEl = editContainer.querySelector('[data-field="synthesis"]');
857 const themeTags = editContainer.querySelectorAll('.theme-tag');
858 const tensionEls = editContainer.querySelectorAll('[data-field="tensions"] .editable');
859 const questionEls = editContainer.querySelectorAll('[data-field="openQuestions"] .editable');
860 const relEls = editContainer.querySelectorAll('[data-field="relationships"] .editable');
861
862 return {
863 title: titleEl?.textContent?.trim() || "",
864 synthesis: synthesisEl?.textContent?.trim() || "",
865 themes: Array.from(themeTags).map(t => t.getAttribute("data-value") || t.textContent?.replace("×", "").trim() || ""),
866 tensions: Array.from(tensionEls).map(t => t.textContent?.trim() || "").filter(Boolean),
867 openQuestions: Array.from(questionEls).map(q => q.textContent?.trim() || "").filter(Boolean),
868 relationships: Array.from(relEls).map(r => r.textContent?.trim() || "").filter(Boolean),
869 };
870 }
871
872 // ── Save to PDS ────────────────────────────────────────────────
873 saveBtn?.addEventListener("click", async () => {
874 if (!island.recordUri) return;
875 saveBtn.textContent = "Saving...";
876 saveBtn.setAttribute("disabled", "true");
877
878 try {
879 const analysis = collectAnalysis();
880 const res = await fetch("/xrpc/org.latha.strata.updateRecord", {
881 method: "PUT",
882 headers: { "Content-Type": "application/json" },
883 body: JSON.stringify({ uri: island.recordUri, analysis }),
884 });
885 if (!res.ok) {
886 const err = (await res.json()) as { message?: string };
887 throw new Error(err.message || "Save failed");
888 }
889 dirty = false;
890 saveBtn.classList.add("hidden");
891 saveStatus!.textContent = "Saved ✓";
892 setTimeout(() => { saveStatus!.textContent = ""; }, 3000);
893 } catch (e: any) {
894 saveStatus!.textContent = `Error: ${e.message}`;
895 } finally {
896 saveBtn.textContent = "Save to PDS";
897 saveBtn.removeAttribute("disabled");
898 }
899 });
900
901 // ── Regenerate ─────────────────────────────────────────────────
902 const regenBtn = document.getElementById("regenerate-btn");
903 regenBtn?.addEventListener("click", async () => {
904 regenBtn.textContent = "Generating...";
905 regenBtn.setAttribute("disabled", "true");
906
907 try {
908 const res = await fetch("/xrpc/org.latha.strata.analyze", {
909 method: "POST",
910 headers: { "Content-Type": "application/json" },
911 body: JSON.stringify({ island: { vertices: island.vertices, edges: island.edges } }),
912 });
913 if (!res.ok) throw new Error("Analysis failed");
914 const result = await res.json();
915
916 // Diff-highlight: mark changed fields
917 const oldAnalysis = collectAnalysis();
918 const newAnalysis = result as Record<string, any>;
919
920 // Update fields with animation
921 const titleEl = editContainer.querySelector('[data-field="title"]') as HTMLElement;
922 const synthesisEl = editContainer.querySelector('[data-field="synthesis"]') as HTMLElement;
923
924 if (titleEl && newAnalysis.title !== oldAnalysis.title) {
925 titleEl.textContent = newAnalysis.title;
926 titleEl.classList.add("field-changed");
927 setTimeout(() => titleEl.classList.remove("field-changed"), 2000);
928 }
929 if (synthesisEl && newAnalysis.synthesis !== oldAnalysis.synthesis) {
930 synthesisEl.textContent = newAnalysis.synthesis;
931 synthesisEl.classList.add("field-changed");
932 setTimeout(() => synthesisEl.classList.remove("field-changed"), 2000);
933 }
934
935 // Update themes
936 const themesContainer = editContainer.querySelector('[data-field="themes"]');
937 if (themesContainer) {
938 themesContainer.querySelectorAll(".theme-tag").forEach(t => t.remove());
939 const addBtn = themesContainer.querySelector(".theme-add");
940 for (const theme of newAnalysis.themes || []) {
941 const tag = document.createElement("span");
942 tag.className = "theme-tag field-changed";
943 tag.dataset.value = theme;
944 tag.innerHTML = `${escHtml(theme)}<span class="theme-remove">×</span>`;
945 tag.querySelector(".theme-remove")!.addEventListener("click", () => { tag.remove(); markDirty(); });
946 (addBtn as ChildNode)?.before(tag);
947 setTimeout(() => tag.classList.remove("field-changed"), 2000);
948 }
949 }
950
951 // Update tensions
952 const tensionsContainer = editContainer.querySelector('[data-field="tensions"]');
953 if (tensionsContainer) {
954 tensionsContainer.querySelectorAll(".editable").forEach(t => t.remove());
955 const addBtn = tensionsContainer.querySelector(".list-add");
956 for (const t of newAnalysis.tensions || []) {
957 const div = document.createElement("div");
958 div.className = "analysis-tension editable field-changed";
959 div.setAttribute("contenteditable", "true");
960 div.textContent = t;
961 div.addEventListener("input", markDirty);
962 (addBtn as ChildNode)?.before(div);
963 setTimeout(() => div.classList.remove("field-changed"), 2000);
964 }
965 }
966
967 // Update questions
968 const questionsContainer = editContainer.querySelector('[data-field="openQuestions"]');
969 if (questionsContainer) {
970 questionsContainer.querySelectorAll(".editable").forEach(q => q.remove());
971 const addBtn = questionsContainer.querySelector(".list-add");
972 for (const q of newAnalysis.openQuestions || []) {
973 const div = document.createElement("div");
974 div.className = "analysis-question editable field-changed";
975 div.setAttribute("contenteditable", "true");
976 div.textContent = q;
977 div.addEventListener("input", markDirty);
978 (addBtn as ChildNode)?.before(div);
979 setTimeout(() => div.classList.remove("field-changed"), 2000);
980 }
981 }
982
983 markDirty(); // Regenerated = needs save
984 } catch (e: any) {
985 saveStatus!.textContent = `Regenerate error: ${e.message}`;
986 } finally {
987 regenBtn.textContent = "Regenerate";
988 regenBtn.removeAttribute("disabled");
989 }
990 });
991}
992
993// ── Connect page ───────────────────────────────────────────────────────────
994
995function renderConnectForm(sourceUri?: string): void {
996 const content = document.getElementById("page-content");
997 if (!content) return;
998
999 content.innerHTML = `
1000 <div class="connect-page">
1001 <a href="/" class="back-link">← Explore</a>
1002 <h2>Create Connection</h2>
1003 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p>
1004
1005 <form id="connect-form" class="connect-form">
1006 <div class="form-field">
1007 <label for="source">Source</label>
1008 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required>
1009 </div>
1010 <div class="form-field">
1011 <label for="target">Target</label>
1012 <input type="text" id="target" name="target" placeholder="URL or AT URI" required>
1013 </div>
1014 <div class="form-field">
1015 <label for="connectionType">Type</label>
1016 <select id="connectionType" name="connectionType">
1017 <option value="network.cosmik.connection#relates">relates</option>
1018 <option value="network.cosmik.connection#contains">contains</option>
1019 <option value="network.cosmik.connection#cites">cites</option>
1020 <option value="network.cosmik.connection#inspired">inspired</option>
1021 <option value="network.cosmik.connection#contradicts">contradicts</option>
1022 <option value="network.cosmik.connection#extends">extends</option>
1023 <option value="network.cosmik.connection#forks">forks</option>
1024 <option value="network.cosmik.connection#annotates">annotates</option>
1025 </select>
1026 </div>
1027 <div class="form-field">
1028 <label for="note">Note</label>
1029 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea>
1030 </div>
1031 <div class="form-actions">
1032 <button type="submit" class="action-btn primary">Connect</button>
1033 </div>
1034 <div id="connect-result" class="connect-result"></div>
1035 </form>
1036 </div>
1037 `;
1038
1039 document.getElementById("connect-form")?.addEventListener("submit", async (e) => {
1040 e.preventDefault();
1041 const result = document.getElementById("connect-result");
1042 if (!result) return;
1043
1044 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>';
1045
1046 const source = (document.getElementById("source") as HTMLInputElement).value;
1047 const target = (document.getElementById("target") as HTMLInputElement).value;
1048 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value;
1049 const note = (document.getElementById("note") as HTMLTextAreaElement).value;
1050
1051 const record = {
1052 $type: "network.cosmik.connection",
1053 source,
1054 target,
1055 connectionType,
1056 note: note || undefined,
1057 createdAt: new Date().toISOString(),
1058 };
1059
1060 result.innerHTML = `<div class="preview">
1061 <p>Record to create on your PDS:</p>
1062 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre>
1063 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p>
1064 </div>`;
1065 });
1066}
1067
1068// ── Pages ──────────────────────────────────────────────────────────────────
1069
1070async function showExplore(): Promise<void> {
1071 currentPage = "explore";
1072
1073 // Try API first, fall back to server-rendered data
1074 // Use summary mode — skip vertexMeta (saves ~250KB)
1075 try {
1076 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?summary=true");
1077 islands = data.islands || [];
1078 } catch {
1079 const serverData = (window as any).__ISLANDS__;
1080 if (serverData) islands = serverData;
1081 }
1082
1083 renderExplore();
1084}
1085
1086// ── Routing ───────────────────────────────────────────────────────────────
1087
1088function getRoute(): { page: string; params: Record<string, string> } {
1089 const url = new URL(window.location.href);
1090 const path = url.pathname;
1091
1092 if (path === "/island") {
1093 return { page: "island", params: { id: url.searchParams.get("id") || "" } };
1094 }
1095 if (path === "/strata") {
1096 return { page: "strata", params: { id: url.searchParams.get("id") || "" } };
1097 }
1098 if (path === "/connect") {
1099 return { page: "connect", params: { source: url.searchParams.get("source") || "" } };
1100 }
1101 return { page: "explore", params: {} };
1102}
1103
1104async function route(): Promise<void> {
1105 const { page, params } = getRoute();
1106
1107 if (page === "island" && params.id) {
1108 await showIsland(params.id);
1109 } else if (page === "strata" && params.id) {
1110 await showStrata(params.id);
1111 } else if (page === "connect") {
1112 renderConnectForm(params.source);
1113 } else {
1114 await showExplore();
1115 }
1116}
1117
1118// ── Auth ───────────────────────────────────────────────────────────────────
1119
1120async function initAuth(): Promise<void> {
1121 try {
1122 const res = await fetch("/xrpc/org.latha.strata.getProfile", {
1123 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") },
1124 });
1125 if (res.ok) {
1126 const data: any = await res.json();
1127 session = { did: data.did, handle: data.handle };
1128 updateAuthUI();
1129 return;
1130 }
1131 } catch {}
1132
1133 updateAuthUI();
1134}
1135
1136function updateAuthUI(): void {
1137 const status = document.getElementById("auth-status");
1138 const loginBtn = document.getElementById("login-btn");
1139 if (!status || !loginBtn) return;
1140
1141 if (session) {
1142 status.textContent = session.handle;
1143 status.classList.add("visible");
1144 loginBtn.classList.add("hidden");
1145 } else {
1146 status.classList.remove("visible");
1147 loginBtn.classList.remove("hidden");
1148 }
1149}
1150
1151// ── Init ───────────────────────────────────────────────────────────────────
1152
1153document.addEventListener("DOMContentLoaded", () => {
1154 route();
1155});
1156
1157window.addEventListener("popstate", route);