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
32 kB 929 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 vertices: string[]; 31 edges: IslandEdge[]; 32 vertexMeta: Record<string, VertexMeta>; 33 summary: string | null; 34 strata: StrataData | null; 35} 36 37interface StrataData { 38 prose: string; 39 themes: string[]; 40 relationships: string[]; 41 tensions: string[]; 42 open_questions: string[]; 43 synthesis: string; 44} 45 46// ── State ────────────────────────────────────────────────────────────────── 47 48let session: { did: string; handle: string } | null = null; 49let currentPage: "explore" | "island" | "strata" | "connect" = "explore"; 50let islands: Island[] = []; 51 52// ── XRPC helpers ─────────────────────────────────────────────────────────── 53 54async function apiGet<T>(path: string): Promise<T> { 55 const res = await fetch(path); 56 if (!res.ok) { 57 const err = await res.text(); 58 throw new Error(`API ${path} failed: ${res.status} ${err}`); 59 } 60 return res.json(); 61} 62 63// ── Force-directed graph renderer ────────────────────────────────────────── 64 65interface GraphNode { 66 id: string; 67 label: string; 68 x: number; 69 y: number; 70 vx: number; 71 vy: number; 72 type: string; 73} 74 75interface GraphEdge { 76 source: string; 77 target: string; 78 label: string; 79 color: string; 80} 81 82const TYPE_COLORS: Record<string, string> = { 83 url: "#58a6ff", 84 citation: "#bc8cff", 85 note: "#3fb950", 86 card: "#3fb950", 87 collection: "#d29922", 88 unknown: "#8b949e", 89}; 90 91const EDGE_COLORS: Record<string, string> = { 92 "related": "#8b949e", 93 "relates": "#8b949e", 94 "leads to": "#58a6ff", 95 "supplement": "#bc8cff", 96 "supplements": "#bc8cff", 97 "opposes": "#f85149", 98 "supports": "#3fb950", 99 "addresses": "#d29922", 100 "explainer": "#79c0ff", 101 "helpful": "#56d364", 102 "contains": "#58a6ff", 103 "cites": "#bc8cff", 104 "inspired": "#d29922", 105 "contradicts": "#f85149", 106 "extends": "#3fb950", 107 "forks": "#79c0ff", 108 "annotates": "#8b949e", 109}; 110 111class ForceGraph { 112 nodes: GraphNode[] = []; 113 edges: GraphEdge[] = []; 114 nodeMap = new Map<string, GraphNode>(); 115 width: number; 116 height: number; 117 alpha = 1; 118 private rafId = 0; 119 private canvas: HTMLCanvasElement; 120 private ctx: CanvasRenderingContext2D; 121 private hoverNode: string | null = null; 122 private dragNode: string | null = null; 123 private dragOffset = { x: 0, y: 0 }; 124 125 constructor(canvas: HTMLCanvasElement, island: Island) { 126 this.canvas = canvas; 127 this.ctx = canvas.getContext("2d")!; 128 const rect = canvas.getBoundingClientRect(); 129 this.width = rect.width; 130 this.height = rect.height; 131 canvas.width = rect.width * devicePixelRatio; 132 canvas.height = rect.height * devicePixelRatio; 133 this.ctx.scale(devicePixelRatio, devicePixelRatio); 134 135 // Build nodes 136 const cx = this.width / 2; 137 const cy = this.height / 2; 138 const r = Math.min(this.width, this.height) * 0.35; 139 140 for (const uri of island.vertices) { 141 const meta = island.vertexMeta[uri]; 142 const angle = Math.random() * Math.PI * 2; 143 const dist = Math.random() * r; 144 const node: GraphNode = { 145 id: uri, 146 label: meta?.title || truncateUri(uri), 147 x: cx + Math.cos(angle) * dist, 148 y: cy + Math.sin(angle) * dist, 149 vx: 0, 150 vy: 0, 151 type: meta?.type || "unknown", 152 }; 153 this.nodes.push(node); 154 this.nodeMap.set(uri, node); 155 } 156 157 // Build edges 158 for (const e of island.edges) { 159 const sNode = this.nodeMap.get(e.source); 160 const tNode = this.nodeMap.get(e.target); 161 if (!sNode || !tNode) continue; 162 const label = connectionTypeLabel(e.connectionType); 163 this.edges.push({ 164 source: e.source, 165 target: e.target, 166 label, 167 color: EDGE_COLORS[label] || EDGE_COLORS[connectionTypeLabel(e.connectionType)] || "#8b949e", 168 }); 169 } 170 171 // Interactions 172 canvas.addEventListener("mousemove", this.onMouseMove); 173 canvas.addEventListener("mousedown", this.onMouseDown); 174 canvas.addEventListener("mouseup", this.onMouseUp); 175 canvas.addEventListener("mouseleave", this.onMouseLeave); 176 canvas.addEventListener("dblclick", this.onDblClick); 177 178 // Warm up the simulation 179 for (let i = 0; i < 120; i++) this.tick(); 180 this.draw(); 181 } 182 183 destroy() { 184 cancelAnimationFrame(this.rafId); 185 this.canvas.removeEventListener("mousemove", this.onMouseMove); 186 this.canvas.removeEventListener("mousedown", this.onMouseDown); 187 this.canvas.removeEventListener("mouseup", this.onMouseUp); 188 this.canvas.removeEventListener("mouseleave", this.onMouseLeave); 189 this.canvas.removeEventListener("dblclick", this.onDblClick); 190 } 191 192 tick() { 193 if (this.alpha < 0.001) return; 194 this.alpha *= 0.99; 195 196 const cx = this.width / 2; 197 const cy = this.height / 2; 198 199 // Center gravity 200 for (const n of this.nodes) { 201 n.vx += (cx - n.x) * 0.01 * this.alpha; 202 n.vy += (cy - n.y) * 0.01 * this.alpha; 203 } 204 205 // Repulsion (all pairs) 206 for (let i = 0; i < this.nodes.length; i++) { 207 for (let j = i + 1; j < this.nodes.length; j++) { 208 const a = this.nodes[i]; 209 const b = this.nodes[j]; 210 let dx = b.x - a.x; 211 let dy = b.y - a.y; 212 let dist = Math.sqrt(dx * dx + dy * dy) || 1; 213 const force = -300 * this.alpha / (dist * dist); 214 const fx = (dx / dist) * force; 215 const fy = (dy / dist) * force; 216 a.vx += fx; 217 a.vy += fy; 218 b.vx -= fx; 219 b.vy -= fy; 220 } 221 } 222 223 // Attraction (edges) 224 for (const e of this.edges) { 225 const a = this.nodeMap.get(e.source)!; 226 const b = this.nodeMap.get(e.target)!; 227 let dx = b.x - a.x; 228 let dy = b.y - a.y; 229 let dist = Math.sqrt(dx * dx + dy * dy) || 1; 230 const force = (dist - 80) * 0.05 * this.alpha; 231 const fx = (dx / dist) * force; 232 const fy = (dy / dist) * force; 233 a.vx += fx; 234 a.vy += fy; 235 b.vx -= fx; 236 b.vy -= fy; 237 } 238 239 // Apply velocity with damping 240 for (const n of this.nodes) { 241 if (n.id === this.dragNode) continue; 242 n.vx *= 0.6; 243 n.vy *= 0.6; 244 n.x += n.vx; 245 n.y += n.vy; 246 // Keep in bounds 247 const pad = 20; 248 n.x = Math.max(pad, Math.min(this.width - pad, n.x)); 249 n.y = Math.max(pad, Math.min(this.height - pad, n.y)); 250 } 251 } 252 253 draw() { 254 const ctx = this.ctx; 255 ctx.clearRect(0, 0, this.width, this.height); 256 257 // Edges 258 for (const e of this.edges) { 259 const a = this.nodeMap.get(e.source)!; 260 const b = this.nodeMap.get(e.target)!; 261 const isHovered = this.hoverNode === e.source || this.hoverNode === e.target; 262 ctx.beginPath(); 263 ctx.moveTo(a.x, a.y); 264 ctx.lineTo(b.x, b.y); 265 ctx.strokeStyle = isHovered ? e.color : `${e.color}44`; 266 ctx.lineWidth = isHovered ? 2 : 1; 267 ctx.stroke(); 268 269 // Arrow 270 const dx = b.x - a.x; 271 const dy = b.y - a.y; 272 const dist = Math.sqrt(dx * dx + dy * dy) || 1; 273 const arrowLen = 8; 274 const arrowX = b.x - (dx / dist) * 16; 275 const arrowY = b.y - (dy / dist) * 16; 276 const angle = Math.atan2(dy, dx); 277 ctx.beginPath(); 278 ctx.moveTo(arrowX, arrowY); 279 ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4)); 280 ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4)); 281 ctx.closePath(); 282 ctx.fillStyle = isHovered ? e.color : `${e.color}44`; 283 ctx.fill(); 284 } 285 286 // Nodes 287 for (const n of this.nodes) { 288 const isHovered = this.hoverNode === n.id; 289 const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; 290 const radius = isHovered ? 7 : 5; 291 292 ctx.beginPath(); 293 ctx.arc(n.x, n.y, radius, 0, Math.PI * 2); 294 ctx.fillStyle = isHovered ? color : `${color}cc`; 295 ctx.fill(); 296 ctx.strokeStyle = isHovered ? "#fff" : color; 297 ctx.lineWidth = isHovered ? 2 : 1; 298 ctx.stroke(); 299 300 // Label 301 if (isHovered || this.nodes.length <= 12) { 302 ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`; 303 ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; 304 ctx.textAlign = "center"; 305 ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4); 306 } 307 } 308 } 309 310 private getMousePos = (e: MouseEvent) => { 311 const rect = this.canvas.getBoundingClientRect(); 312 return { x: e.clientX - rect.left, y: e.clientY - rect.top }; 313 }; 314 315 private findNode(x: number, y: number): string | null { 316 for (const n of this.nodes) { 317 const dx = n.x - x; 318 const dy = n.y - y; 319 if (dx * dx + dy * dy < 100) return n.id; 320 } 321 return null; 322 } 323 324 private onMouseMove = (e: MouseEvent) => { 325 const pos = this.getMousePos(e); 326 if (this.dragNode) { 327 const n = this.nodeMap.get(this.dragNode)!; 328 n.x = pos.x; 329 n.y = pos.y; 330 n.vx = 0; 331 n.vy = 0; 332 this.alpha = 0.3; 333 this.tick(); 334 this.draw(); 335 return; 336 } 337 const prev = this.hoverNode; 338 this.hoverNode = this.findNode(pos.x, pos.y); 339 if (this.hoverNode !== prev) this.draw(); 340 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; 341 }; 342 343 private onMouseDown = (e: MouseEvent) => { 344 const pos = this.getMousePos(e); 345 const id = this.findNode(pos.x, pos.y); 346 if (id) { 347 this.dragNode = id; 348 this.canvas.style.cursor = "grabbing"; 349 } 350 }; 351 352 private onMouseUp = () => { 353 this.dragNode = null; 354 this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; 355 }; 356 357 private onMouseLeave = () => { 358 this.hoverNode = null; 359 this.dragNode = null; 360 this.canvas.style.cursor = "default"; 361 this.draw(); 362 }; 363 364 private onDblClick = (e: MouseEvent) => { 365 const pos = this.getMousePos(e); 366 const id = this.findNode(pos.x, pos.y); 367 if (!id) return; 368 if (id.startsWith("http")) { 369 window.open(id, "_blank"); 370 } 371 }; 372} 373 374// ── Rendering helpers ────────────────────────────────────────────────────── 375 376function escHtml(s: string): string { 377 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 378} 379 380function truncateUri(uri: string): string { 381 if (uri.startsWith("http")) { 382 try { 383 const u = new URL(uri); 384 const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40); 385 return u.hostname + path; 386 } catch { 387 return uri.slice(0, 60); 388 } 389 } 390 if (uri.startsWith("at://")) { 391 const parts = uri.split("/"); 392 return parts.slice(3).join("/") || uri.slice(0, 60); 393 } 394 return uri.slice(0, 60); 395} 396 397function connectionTypeLabel(t: string): string { 398 if (!t) return "relates"; 399 // Handle both "network.cosmik.connection#RELATES" and raw "RELATES" 400 const raw = t.includes("#") ? t.split("#").pop()! : t; 401 const labels: Record<string, string> = { 402 RELATED: "related", 403 LEADS_TO: "leads to", 404 SUPPLEMENT: "supplement", 405 SUPPLEMENTS: "supplements", 406 OPPOSES: "opposes", 407 SUPPORTS: "supports", 408 ADDRESSES: "addresses", 409 EXPLAINER: "explainer", 410 HELPFUL: "helpful", 411 CONTAINS: "contains", 412 CITES: "cites", 413 INSPIRED: "inspired", 414 CONTRADICTS: "contradicts", 415 EXTENDS: "extends", 416 FORKS: "forks", 417 ANNOTATES: "annotates", 418 RELATES: "relates", 419 }; 420 return labels[raw] || raw.toLowerCase().replace(/_/g, " "); 421} 422 423function connectionTypeIcon(t: string): string { 424 const label = connectionTypeLabel(t); 425 const icons: Record<string, string> = { 426 "leads to": "\u2192", 427 "cites": "\u2190", 428 "related": "\u2194", 429 "relates": "\u2194", 430 "inspired": "\u2728", 431 "opposes": "\u26D4", 432 "contradicts": "\u26D4", 433 "extends": "\u21D2", 434 "forks": "\u21C4", 435 "annotates": "\u270E", 436 "supplement": "\u2295", 437 "supplements": "\u2295", 438 "supports": "\u2713", 439 "addresses": "\u25C6", 440 "explainer": "\u2139", 441 "helpful": "\u2606", 442 "contains": "\u25B6", 443 }; 444 return icons[label] || "\u2194"; 445} 446 447// ── Explore page: island cards ───────────────────────────────────────────── 448 449function renderExplore(): void { 450 const content = document.getElementById("page-content"); 451 if (!content) return; 452 453 if (islands.length === 0) { 454 content.innerHTML = '<div class="empty">No islands found in the connection graph. Save some connections to see clusters!</div>'; 455 return; 456 } 457 458 content.innerHTML = ` 459 <div class="islands-feed"> 460 ${islands.map(island => renderIslandCard(island)).join("")} 461 </div> 462 `; 463 464 // Initialize graphs 465 document.querySelectorAll("canvas.island-graph").forEach(canvas => { 466 const islandId = (canvas as HTMLElement).dataset.islandId; 467 const island = islands.find(i => i.id === islandId); 468 if (island) { 469 new ForceGraph(canvas as HTMLCanvasElement, island); 470 } 471 }); 472 473 // Wire up Strata buttons on island cards — go to strata page 474 document.querySelectorAll(".island-strata-btn").forEach(btn => { 475 btn.addEventListener("click", (e) => { 476 const target = e.currentTarget as HTMLElement; 477 const islandId = target.dataset.islandId; 478 if (!islandId) return; 479 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 480 }); 481 }); 482} 483 484function summarizeIsland(island: Island): string { 485 if (island.summary) return island.summary; 486 487 // Fallback: build from metadata 488 const vertices = island.vertices; 489 const meta = island.vertexMeta; 490 491 const domains = new Map<string, number>(); 492 for (const v of vertices) { 493 if (v.startsWith("http")) { 494 try { 495 const host = new URL(v).hostname.replace(/^www\./, ""); 496 domains.set(host, (domains.get(host) || 0) + 1); 497 } catch {} 498 } 499 } 500 const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]); 501 502 const titles = vertices 503 .map(v => meta[v]?.title || "") 504 .filter(t => t.length > 10 && t.length < 120) 505 .slice(0, 2); 506 507 const parts: string[] = []; 508 if (topDomains.length > 0) parts.push(topDomains.join(", ")); 509 if (titles.length > 0) parts.push(titles.join("; ")); 510 return parts.join(" -- ") || `${vertices.length} connected nodes`; 511} 512 513function renderIslandCard(island: Island): string { 514 const summary = summarizeIsland(island); 515 const hasStrata = !!island.strata; 516 const strataBtnLabel = hasStrata ? "View Strata" : "Strata"; 517 const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn"; 518 519 return ` 520 <div class="island-card" data-island-id="${escHtml(island.id)}"> 521 <canvas class="island-graph" data-island-id="${escHtml(island.id)}" width="400" height="260"></canvas> 522 <div class="island-card-footer"> 523 <span class="island-size">${island.vertices.length} nodes &middot; ${island.edges.length} edges</span> 524 <p class="island-summary">${escHtml(summary)}</p> 525 <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button> 526 </div> 527 </div> 528 `; 529} 530 531// ── Island detail page ───────────────────────────────────────────────────── 532 533async function showIsland(islandId: string): Promise<void> { 534 currentPage = "island"; 535 const content = document.getElementById("page-content"); 536 if (!content) return; 537 538 const island = islands.find(i => i.id === islandId); 539 if (!island) { 540 content.innerHTML = '<div class="empty">Island not found.</div>'; 541 return; 542 } 543 544 const edgeList = island.edges.map(e => { 545 const icon = connectionTypeIcon(e.connectionType); 546 const type = connectionTypeLabel(e.connectionType); 547 const sourceMeta = island.vertexMeta[e.source]; 548 const targetMeta = island.vertexMeta[e.target]; 549 const sourceTitle = sourceMeta?.title || truncateUri(e.source); 550 const targetTitle = targetMeta?.title || truncateUri(e.target); 551 return ` 552 <div class="island-edge-detail"> 553 <span class="edge-source">${escHtml(sourceTitle)}</span> 554 <span class="edge-type-badge">${icon} ${escHtml(type)}</span> 555 <span class="edge-target">${escHtml(targetTitle)}</span> 556 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""} 557 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""} 558 </div> 559 `; 560 }).join(""); 561 562 content.innerHTML = ` 563 <div class="island-detail"> 564 <a href="/" class="back-link">&larr; Explore</a> 565 <h2>Island</h2> 566 <p class="island-meta">${island.vertices.length} vertices &middot; ${island.edges.length} edges</p> 567 568 <canvas class="island-graph-large" id="island-detail-graph" width="900" height="500"></canvas> 569 570 <div class="island-section"> 571 <h3>Connections</h3> 572 ${edgeList} 573 </div> 574 575 <div class="island-actions"> 576 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button> 577 </div> 578 </div> 579 `; 580 581 // Initialize the detail graph 582 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement; 583 if (canvas) { 584 new ForceGraph(canvas, island); 585 } 586 587 document.getElementById("island-strata-btn")?.addEventListener("click", async () => { 588 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 589 }); 590} 591 592// ── Strata page — prose with arrows ─────────────────────────────────────── 593 594async function showStrata(islandId: string): Promise<void> { 595 currentPage = "strata"; 596 const content = document.getElementById("page-content"); 597 if (!content) return; 598 599 let island: Island | null = null; 600 601 // If the ID is an AT URI, fetch from the strata record endpoint 602 if (islandId.startsWith("at://")) { 603 try { 604 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`); 605 island = data.island; 606 } catch { 607 content.innerHTML = '<div class="empty">Strata record not found.</div>'; 608 return; 609 } 610 } else { 611 // Legacy: sha256 ID — find from loaded islands 612 island = islands.find(i => i.id === islandId) || null; 613 614 // If not in memory, fetch from API 615 if (!island) { 616 try { 617 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); 618 island = data.islands.find(i => i.id === islandId) || null; 619 if (island) islands = data.islands; 620 } catch {} 621 } 622 } 623 624 if (!island) { 625 content.innerHTML = '<div class="empty">Island not found.</div>'; 626 return; 627 } 628 629 if (!island.strata) { 630 content.innerHTML = ` 631 <div class="strata-page"> 632 <a href="/" class="back-link">&larr; Explore</a> 633 <h2>Strata</h2> 634 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div> 635 </div> 636 `; 637 return; 638 } 639 640 renderStrata(island); 641} 642 643function renderStrata(island: Island): void { 644 const content = document.getElementById("page-content"); 645 if (!content) return; 646 const s = island.strata!; 647 648 const summary = island.summary || summarizeIsland(island); 649 650 // Themes 651 const themesHtml = s.themes.length > 0 652 ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>` 653 : ""; 654 655 // Relationships — link entity names to their Semble pages 656 const titleToUri = new Map<string, string>(); 657 for (const uri of island.vertices) { 658 const m = island.vertexMeta[uri]; 659 const title = m?.title; 660 if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) { 661 titleToUri.set(title, uri); 662 } 663 } 664 665 const relsHtml = s.relationships.length > 0 666 ? `<div class="strata-relationships">${s.relationships.map(r => { 667 // Link entity names to their Semble pages 668 let styled = escHtml(r); 669 // Sort titles longest-first so longer matches take priority 670 const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length); 671 for (const [title, uri] of sortedTitles) { 672 const escaped = escHtml(title); 673 // Exact match 674 if (styled.includes(escaped)) { 675 const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 676 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 677 } 678 } 679 // Also try linking short names that appear as substrings of titles 680 // e.g. "C2PA" appears inside "C2PA content authenticity standard" 681 for (const [title, uri] of sortedTitles) { 682 const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 683 // Extract short name segments from the title (first N words, abbreviations, etc.) 684 const shortNames = [ 685 title.split(/[:—–\-]/)[0].trim(), // text before delimiter 686 title.split("(")[0].trim(), // text before parenthetical 687 ].filter(n => n.length > 3 && n.length < title.length); 688 for (const shortName of shortNames) { 689 const escaped = escHtml(shortName); 690 if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) { 691 // Not already linked — link it 692 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 693 } 694 } 695 } 696 styled = styled 697 .replace(/→/g, '<span class="arrow arrow-forward">→</span>') 698 .replace(/←/g, '<span class="arrow arrow-back">←</span>') 699 .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>') 700 .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>'); 701 return `<div class="strata-rel-line">${styled}</div>`; 702 }).join("")}</div>` 703 : ""; 704 705 // Tensions 706 const tensionsHtml = s.tensions.length > 0 707 ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>` 708 : ""; 709 710 // Open questions 711 const questionsHtml = s.open_questions.length > 0 712 ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>` 713 : ""; 714 715 // Synthesis 716 const synthesisHtml = s.synthesis 717 ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>` 718 : ""; 719 720 content.innerHTML = ` 721 <div class="strata-page"> 722 <a href="/" class="back-link">&larr; Explore</a> 723 <p class="island-summary-large">${escHtml(summary)}</p> 724 <p class="island-meta">${island.vertices.length} vertices &middot; ${island.edges.length} edges</p> 725 726 <canvas class="island-graph-large" id="strata-graph" width="900" height="500"></canvas> 727 728 <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div> 729 730 ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""} 731 732 ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""} 733 734 ${tensionsHtml} 735 ${questionsHtml} 736 737 <div class="strata-section"> 738 <h3>Edges</h3> 739 <div class="strata-edges">${island.edges.map(e => { 740 const sourceMeta = island.vertexMeta[e.source]; 741 const targetMeta = island.vertexMeta[e.target]; 742 const sourceTitle = sourceMeta?.title || truncateUri(e.source); 743 const targetTitle = targetMeta?.title || truncateUri(e.target); 744 const type = connectionTypeLabel(e.connectionType); 745 const icon = connectionTypeIcon(e.connectionType); 746 const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : ""; 747 return `<a href="${escHtml(sembleUrl)}" class="strata-edge" target="_blank" rel="noopener"${!sembleUrl ? ' onclick="event.preventDefault()"' : ""}> 748 <span class="strata-edge-source">${escHtml(sourceTitle)}</span> 749 <span class="strata-edge-type">${icon} ${escHtml(type)}</span> 750 <span class="strata-edge-target">${escHtml(targetTitle)}</span> 751 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""} 752 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""} 753 </a>`; 754 }).join("")}</div> 755 </div> 756 </div> 757 `; 758 759 // Initialize the force graph 760 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement; 761 if (canvas) { 762 new ForceGraph(canvas, island); 763 } 764} 765 766// ── Connect page ─────────────────────────────────────────────────────────── 767 768function renderConnectForm(sourceUri?: string): void { 769 const content = document.getElementById("page-content"); 770 if (!content) return; 771 772 content.innerHTML = ` 773 <div class="connect-page"> 774 <a href="/" class="back-link">&larr; Explore</a> 775 <h2>Create Connection</h2> 776 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p> 777 778 <form id="connect-form" class="connect-form"> 779 <div class="form-field"> 780 <label for="source">Source</label> 781 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required> 782 </div> 783 <div class="form-field"> 784 <label for="target">Target</label> 785 <input type="text" id="target" name="target" placeholder="URL or AT URI" required> 786 </div> 787 <div class="form-field"> 788 <label for="connectionType">Type</label> 789 <select id="connectionType" name="connectionType"> 790 <option value="network.cosmik.connection#relates">relates</option> 791 <option value="network.cosmik.connection#contains">contains</option> 792 <option value="network.cosmik.connection#cites">cites</option> 793 <option value="network.cosmik.connection#inspired">inspired</option> 794 <option value="network.cosmik.connection#contradicts">contradicts</option> 795 <option value="network.cosmik.connection#extends">extends</option> 796 <option value="network.cosmik.connection#forks">forks</option> 797 <option value="network.cosmik.connection#annotates">annotates</option> 798 </select> 799 </div> 800 <div class="form-field"> 801 <label for="note">Note</label> 802 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea> 803 </div> 804 <div class="form-actions"> 805 <button type="submit" class="action-btn primary">Connect</button> 806 </div> 807 <div id="connect-result" class="connect-result"></div> 808 </form> 809 </div> 810 `; 811 812 document.getElementById("connect-form")?.addEventListener("submit", async (e) => { 813 e.preventDefault(); 814 const result = document.getElementById("connect-result"); 815 if (!result) return; 816 817 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>'; 818 819 const source = (document.getElementById("source") as HTMLInputElement).value; 820 const target = (document.getElementById("target") as HTMLInputElement).value; 821 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value; 822 const note = (document.getElementById("note") as HTMLTextAreaElement).value; 823 824 const record = { 825 $type: "network.cosmik.connection", 826 source, 827 target, 828 connectionType, 829 note: note || undefined, 830 createdAt: new Date().toISOString(), 831 }; 832 833 result.innerHTML = `<div class="preview"> 834 <p>Record to create on your PDS:</p> 835 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre> 836 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p> 837 </div>`; 838 }); 839} 840 841// ── Pages ────────────────────────────────────────────────────────────────── 842 843async function showExplore(): Promise<void> { 844 currentPage = "explore"; 845 846 // Try API first, fall back to server-rendered data 847 try { 848 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); 849 islands = data.islands || []; 850 } catch { 851 const serverData = (window as any).__ISLANDS__; 852 if (serverData) islands = serverData; 853 } 854 855 renderExplore(); 856} 857 858// ── Routing ─────────────────────────────────────────────────────────────── 859 860function getRoute(): { page: string; params: Record<string, string> } { 861 const url = new URL(window.location.href); 862 const path = url.pathname; 863 864 if (path === "/island") { 865 return { page: "island", params: { id: url.searchParams.get("id") || "" } }; 866 } 867 if (path === "/strata") { 868 return { page: "strata", params: { id: url.searchParams.get("id") || "" } }; 869 } 870 if (path === "/connect") { 871 return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; 872 } 873 return { page: "explore", params: {} }; 874} 875 876async function route(): Promise<void> { 877 const { page, params } = getRoute(); 878 879 if (page === "island" && params.id) { 880 await showIsland(params.id); 881 } else if (page === "strata" && params.id) { 882 await showStrata(params.id); 883 } else if (page === "connect") { 884 renderConnectForm(params.source); 885 } else { 886 await showExplore(); 887 } 888} 889 890// ── Auth ─────────────────────────────────────────────────────────────────── 891 892async function initAuth(): Promise<void> { 893 try { 894 const res = await fetch("/xrpc/org.latha.strata.getProfile", { 895 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") }, 896 }); 897 if (res.ok) { 898 const data: any = await res.json(); 899 session = { did: data.did, handle: data.handle }; 900 updateAuthUI(); 901 return; 902 } 903 } catch {} 904 905 updateAuthUI(); 906} 907 908function updateAuthUI(): void { 909 const status = document.getElementById("auth-status"); 910 const loginBtn = document.getElementById("login-btn"); 911 if (!status || !loginBtn) return; 912 913 if (session) { 914 status.textContent = session.handle; 915 status.classList.add("visible"); 916 loginBtn.classList.add("hidden"); 917 } else { 918 status.classList.remove("visible"); 919 loginBtn.classList.remove("hidden"); 920 } 921} 922 923// ── Init ─────────────────────────────────────────────────────────────────── 924 925document.addEventListener("DOMContentLoaded", () => { 926 route(); 927}); 928 929window.addEventListener("popstate", route);