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