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