This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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