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