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