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
34 kB 958 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 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 483 }); 484 }); 485} 486 487function summarizeIsland(island: Island): string { 488 if (island.title) return island.title; 489 if (island.summary) return island.summary; 490 491 // Fallback: build from metadata (only if we have full data) 492 const vertices = island.vertices; 493 if (!vertices || vertices.length === 0) return "Island"; 494 495 const meta = island.vertexMeta || {}; 496 497 const domains = new Map<string, number>(); 498 for (const v of vertices) { 499 if (v.startsWith("http")) { 500 try { 501 const host = new URL(v).hostname.replace(/^www\./, ""); 502 domains.set(host, (domains.get(host) || 0) + 1); 503 } catch {} 504 } 505 } 506 const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]); 507 508 const titles = vertices 509 .map(v => meta[v]?.title || "") 510 .filter(t => t.length > 10 && t.length < 120) 511 .slice(0, 2); 512 513 const parts: string[] = []; 514 if (topDomains.length > 0) parts.push(topDomains.join(", ")); 515 if (titles.length > 0) parts.push(titles.join("; ")); 516 return parts.join(" -- ") || `${vertices.length} connected nodes`; 517} 518 519function renderIslandCard(island: Island): string { 520 const summary = summarizeIsland(island); 521 const hasStrata = !!island.strata; 522 const strataBtnLabel = hasStrata ? "View Strata" : "Strata"; 523 const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn"; 524 const hasFullData = !!(island.vertices && island.edges); 525 const sizeLabel = hasFullData 526 ? `${island.vertices!.length} nodes &middot; ${island.edges!.length} edges` 527 : ""; 528 529 const graphOrTitle = hasFullData 530 ? `<canvas class="island-graph" data-island-id="${escHtml(island.id)}"></canvas>` 531 : `<div class="island-title-preview">${escHtml(summary)}</div>`; 532 533 return ` 534 <div class="island-card" data-island-id="${escHtml(island.id)}"> 535 ${graphOrTitle} 536 <div class="island-card-footer"> 537 ${sizeLabel ? `<span class="island-size">${sizeLabel}</span>` : ""} 538 ${hasFullData ? `<p class="island-summary">${escHtml(summary)}</p>` : ""} 539 <button class="${strataBtnClass}" data-island-id="${escHtml(island.id)}">${strataBtnLabel}</button> 540 </div> 541 </div> 542 `; 543} 544 545// ── Island detail page ───────────────────────────────────────────────────── 546 547async function showIsland(islandId: string): Promise<void> { 548 currentPage = "island"; 549 const content = document.getElementById("page-content"); 550 if (!content) return; 551 552 let island = islands.find(i => i.id === islandId); 553 if (!island) { 554 content.innerHTML = '<div class="empty">Island not found.</div>'; 555 return; 556 } 557 558 // If vertexMeta is missing (summary mode), fetch full data 559 if (!island.vertexMeta || Object.keys(island.vertexMeta).length === 0) { 560 try { 561 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?limit=50"); 562 const full = data.islands?.find(i => i.id === islandId); 563 if (full) { 564 island = full; 565 // Update cached island too 566 const idx = islands.findIndex(i => i.id === islandId); 567 if (idx >= 0) islands[idx] = full; 568 } 569 } catch {} 570 } 571 572 const edgeList = island.edges!.map(e => { 573 const icon = connectionTypeIcon(e.connectionType); 574 const type = connectionTypeLabel(e.connectionType); 575 const sourceMeta = island.vertexMeta![e.source]; 576 const targetMeta = island.vertexMeta![e.target]; 577 const sourceTitle = sourceMeta?.title || truncateUri(e.source); 578 const targetTitle = targetMeta?.title || truncateUri(e.target); 579 return ` 580 <div class="island-edge-detail"> 581 <span class="edge-source">${escHtml(sourceTitle)}</span> 582 <span class="edge-type-badge">${icon} ${escHtml(type)}</span> 583 <span class="edge-target">${escHtml(targetTitle)}</span> 584 ${e.note ? `<span class="edge-note">${escHtml(e.note)}</span>` : ""} 585 ${e.handle ? `<span class="edge-handle">${escHtml(e.handle)}</span>` : ""} 586 </div> 587 `; 588 }).join(""); 589 590 content.innerHTML = ` 591 <div class="island-detail"> 592 <a href="/" class="back-link">&larr; Explore</a> 593 <h2>Island</h2> 594 <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> 595 596 <canvas class="island-graph-large" id="island-detail-graph"></canvas> 597 598 <div class="island-section"> 599 <h3>Connections</h3> 600 ${edgeList} 601 </div> 602 603 <div class="island-actions"> 604 <button class="strata-btn" id="island-strata-btn">Run Strata Analysis</button> 605 </div> 606 </div> 607 `; 608 609 // Initialize the detail graph 610 const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement; 611 if (canvas) { 612 new ForceGraph(canvas, island); 613 } 614 615 document.getElementById("island-strata-btn")?.addEventListener("click", async () => { 616 window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; 617 }); 618} 619 620// ── Strata page — prose with arrows ─────────────────────────────────────── 621 622async function showStrata(islandId: string): Promise<void> { 623 currentPage = "strata"; 624 const content = document.getElementById("page-content"); 625 if (!content) return; 626 627 let island: Island | null = null; 628 629 // If the ID is an AT URI, fetch from the strata record endpoint 630 if (islandId.startsWith("at://")) { 631 try { 632 const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`); 633 island = data.island; 634 } catch { 635 content.innerHTML = '<div class="empty">Strata record not found.</div>'; 636 return; 637 } 638 } else { 639 // Legacy: sha256 ID — find from loaded islands 640 island = islands.find(i => i.id === islandId) || null; 641 642 // If not in memory, fetch from API 643 if (!island) { 644 try { 645 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); 646 island = data.islands.find(i => i.id === islandId) || null; 647 if (island) islands = data.islands; 648 } catch {} 649 } 650 } 651 652 if (!island) { 653 content.innerHTML = '<div class="empty">Island not found.</div>'; 654 return; 655 } 656 657 if (!island.strata) { 658 content.innerHTML = ` 659 <div class="strata-page"> 660 <a href="/" class="back-link">&larr; Explore</a> 661 <h2>Strata</h2> 662 <div class="empty">No strata analysis yet for this island. Run <code>scripts/strata-analyze.py</code> to generate.</div> 663 </div> 664 `; 665 return; 666 } 667 668 renderStrata(island); 669} 670 671function renderStrata(island: Island): void { 672 const content = document.getElementById("page-content"); 673 if (!content) return; 674 const s = island.strata!; 675 676 const summary = island.summary || summarizeIsland(island); 677 678 // Themes 679 const themesHtml = s.themes.length > 0 680 ? `<div class="theme-tags">${s.themes.map(t => `<span class="theme-tag">${escHtml(t)}</span>`).join("")}</div>` 681 : ""; 682 683 // Relationships — link entity names to their Semble pages 684 const titleToUri = new Map<string, string>(); 685 for (const uri of island.vertices!) { 686 const m = island.vertexMeta![uri]; 687 const title = m?.title; 688 if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) { 689 titleToUri.set(title, uri); 690 } 691 } 692 693 const relsHtml = s.relationships.length > 0 694 ? `<div class="strata-relationships">${s.relationships.map(r => { 695 // Link entity names to their Semble pages 696 let styled = escHtml(r); 697 // Sort titles longest-first so longer matches take priority 698 const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length); 699 for (const [title, uri] of sortedTitles) { 700 const escaped = escHtml(title); 701 // Exact match 702 if (styled.includes(escaped)) { 703 const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 704 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 705 } 706 } 707 // Also try linking short names that appear as substrings of titles 708 // e.g. "C2PA" appears inside "C2PA content authenticity standard" 709 for (const [title, uri] of sortedTitles) { 710 const href = `https://semble.so/url/${encodeURIComponent(uri)}`; 711 // Extract short name segments from the title (first N words, abbreviations, etc.) 712 const shortNames = [ 713 title.split(/[:—–\-]/)[0].trim(), // text before delimiter 714 title.split("(")[0].trim(), // text before parenthetical 715 ].filter(n => n.length > 3 && n.length < title.length); 716 for (const shortName of shortNames) { 717 const escaped = escHtml(shortName); 718 if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) { 719 // Not already linked — link it 720 styled = styled.replace(escaped, `<a href="${href}" target="_blank" rel="noopener" class="entity-link">${escaped}</a>`); 721 } 722 } 723 } 724 styled = styled 725 .replace(/→/g, '<span class="arrow arrow-forward">→</span>') 726 .replace(/←/g, '<span class="arrow arrow-back">←</span>') 727 .replace(/↔/g, '<span class="arrow arrow-bidi">↔</span>') 728 .replace(/⇏/g, '<span class="arrow arrow-blocked">⇏</span>'); 729 return `<div class="strata-rel-line">${styled}</div>`; 730 }).join("")}</div>` 731 : ""; 732 733 // Tensions 734 const tensionsHtml = s.tensions.length > 0 735 ? `<div class="strata-section"><h3>Tensions</h3>${s.tensions.map(t => `<div class="analysis-tension">${escHtml(t)}</div>`).join("")}</div>` 736 : ""; 737 738 // Open questions 739 const questionsHtml = s.open_questions.length > 0 740 ? `<div class="strata-section"><h3>Open Questions</h3>${s.open_questions.map(q => `<div class="analysis-question">${escHtml(q)}</div>`).join("")}</div>` 741 : ""; 742 743 // Synthesis 744 const synthesisHtml = s.synthesis 745 ? `<div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div>` 746 : ""; 747 748 content.innerHTML = ` 749 <div class="strata-page"> 750 <a href="/" class="back-link">&larr; Explore</a> 751 <p class="island-summary-large">${escHtml(summary)}</p> 752 <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> 753 754 <canvas class="island-graph-large" id="strata-graph"></canvas> 755 756 <div class="strata-section"><h3>Synthesis</h3><div class="synthesis-text">${escHtml(s.synthesis)}</div></div> 757 758 ${themesHtml ? `<div class="strata-section"><h3>Themes</h3>${themesHtml}</div>` : ""} 759 760 ${relsHtml ? `<div class="strata-section"><h3>Relationships</h3>${relsHtml}</div>` : ""} 761 762 ${tensionsHtml} 763 ${questionsHtml} 764 765 <div class="strata-section"> 766 <h3>Edges</h3> 767 <div class="strata-edges">${island.edges!.map(e => { 768 const sourceMeta = island.vertexMeta![e.source]; 769 const targetMeta = island.vertexMeta![e.target]; 770 const sourceTitle = sourceMeta?.title || truncateUri(e.source); 771 const targetTitle = targetMeta?.title || truncateUri(e.target); 772 const type = connectionTypeLabel(e.connectionType); 773 const icon = connectionTypeIcon(e.connectionType); 774 const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : ""; 775 return `<a href="${escHtml(sembleUrl)}" class="strata-edge" target="_blank" rel="noopener"${!sembleUrl ? ' onclick="event.preventDefault()"' : ""}> 776 <span class="strata-edge-source">${escHtml(sourceTitle)}</span> 777 <span class="strata-edge-type">${icon} ${escHtml(type)}</span> 778 <span class="strata-edge-target">${escHtml(targetTitle)}</span> 779 ${e.note ? `<span class="strata-edge-note">${escHtml(e.note)}</span>` : ""} 780 ${e.handle ? `<span class="strata-edge-handle">@${escHtml(e.handle)}</span>` : ""} 781 </a>`; 782 }).join("")}</div> 783 </div> 784 </div> 785 `; 786 787 // Initialize the force graph 788 const canvas = document.getElementById("strata-graph") as HTMLCanvasElement; 789 if (canvas) { 790 new ForceGraph(canvas, island); 791 } 792} 793 794// ── Connect page ─────────────────────────────────────────────────────────── 795 796function renderConnectForm(sourceUri?: string): void { 797 const content = document.getElementById("page-content"); 798 if (!content) return; 799 800 content.innerHTML = ` 801 <div class="connect-page"> 802 <a href="/" class="back-link">&larr; Explore</a> 803 <h2>Create Connection</h2> 804 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p> 805 806 <form id="connect-form" class="connect-form"> 807 <div class="form-field"> 808 <label for="source">Source</label> 809 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required> 810 </div> 811 <div class="form-field"> 812 <label for="target">Target</label> 813 <input type="text" id="target" name="target" placeholder="URL or AT URI" required> 814 </div> 815 <div class="form-field"> 816 <label for="connectionType">Type</label> 817 <select id="connectionType" name="connectionType"> 818 <option value="network.cosmik.connection#relates">relates</option> 819 <option value="network.cosmik.connection#contains">contains</option> 820 <option value="network.cosmik.connection#cites">cites</option> 821 <option value="network.cosmik.connection#inspired">inspired</option> 822 <option value="network.cosmik.connection#contradicts">contradicts</option> 823 <option value="network.cosmik.connection#extends">extends</option> 824 <option value="network.cosmik.connection#forks">forks</option> 825 <option value="network.cosmik.connection#annotates">annotates</option> 826 </select> 827 </div> 828 <div class="form-field"> 829 <label for="note">Note</label> 830 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea> 831 </div> 832 <div class="form-actions"> 833 <button type="submit" class="action-btn primary">Connect</button> 834 </div> 835 <div id="connect-result" class="connect-result"></div> 836 </form> 837 </div> 838 `; 839 840 document.getElementById("connect-form")?.addEventListener("submit", async (e) => { 841 e.preventDefault(); 842 const result = document.getElementById("connect-result"); 843 if (!result) return; 844 845 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>'; 846 847 const source = (document.getElementById("source") as HTMLInputElement).value; 848 const target = (document.getElementById("target") as HTMLInputElement).value; 849 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value; 850 const note = (document.getElementById("note") as HTMLTextAreaElement).value; 851 852 const record = { 853 $type: "network.cosmik.connection", 854 source, 855 target, 856 connectionType, 857 note: note || undefined, 858 createdAt: new Date().toISOString(), 859 }; 860 861 result.innerHTML = `<div class="preview"> 862 <p>Record to create on your PDS:</p> 863 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre> 864 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p> 865 </div>`; 866 }); 867} 868 869// ── Pages ────────────────────────────────────────────────────────────────── 870 871async function showExplore(): Promise<void> { 872 currentPage = "explore"; 873 874 // Try API first, fall back to server-rendered data 875 // Use summary mode — skip vertexMeta (saves ~250KB) 876 try { 877 const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands?summary=true"); 878 islands = data.islands || []; 879 } catch { 880 const serverData = (window as any).__ISLANDS__; 881 if (serverData) islands = serverData; 882 } 883 884 renderExplore(); 885} 886 887// ── Routing ─────────────────────────────────────────────────────────────── 888 889function getRoute(): { page: string; params: Record<string, string> } { 890 const url = new URL(window.location.href); 891 const path = url.pathname; 892 893 if (path === "/island") { 894 return { page: "island", params: { id: url.searchParams.get("id") || "" } }; 895 } 896 if (path === "/strata") { 897 return { page: "strata", params: { id: url.searchParams.get("id") || "" } }; 898 } 899 if (path === "/connect") { 900 return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; 901 } 902 return { page: "explore", params: {} }; 903} 904 905async function route(): Promise<void> { 906 const { page, params } = getRoute(); 907 908 if (page === "island" && params.id) { 909 await showIsland(params.id); 910 } else if (page === "strata" && params.id) { 911 await showStrata(params.id); 912 } else if (page === "connect") { 913 renderConnectForm(params.source); 914 } else { 915 await showExplore(); 916 } 917} 918 919// ── Auth ─────────────────────────────────────────────────────────────────── 920 921async function initAuth(): Promise<void> { 922 try { 923 const res = await fetch("/xrpc/org.latha.strata.getProfile", { 924 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") }, 925 }); 926 if (res.ok) { 927 const data: any = await res.json(); 928 session = { did: data.did, handle: data.handle }; 929 updateAuthUI(); 930 return; 931 } 932 } catch {} 933 934 updateAuthUI(); 935} 936 937function updateAuthUI(): void { 938 const status = document.getElementById("auth-status"); 939 const loginBtn = document.getElementById("login-btn"); 940 if (!status || !loginBtn) return; 941 942 if (session) { 943 status.textContent = session.handle; 944 status.classList.add("visible"); 945 loginBtn.classList.add("hidden"); 946 } else { 947 status.classList.remove("visible"); 948 loginBtn.classList.remove("hidden"); 949 } 950} 951 952// ── Init ─────────────────────────────────────────────────────────────────── 953 954document.addEventListener("DOMContentLoaded", () => { 955 route(); 956}); 957 958window.addEventListener("popstate", route);