/** * Stigmergic frontend — island-centric SPA. * * The knowledge graph is a set of islands (connected components). * Each island is a cluster of linked URLs discovered via * network.cosmik.connection records. Strata analysis runs on an * island and produces an org.latha.strata record. */ // ── Types ────────────────────────────────────────────────────────────────── interface VertexMeta { title: string; description: string; type: string; } interface IslandEdge { uri: string; did: string; source: string; target: string; connectionType: string; note: string; handle?: string | null; } interface Island { id: string; lexmin?: string; vertices?: string[]; edges?: IslandEdge[]; vertexMeta?: Record; summary: string | null; title?: string | null; strata: StrataData | null; recordUri?: string | null; } interface StrataData { prose: string; themes: string[]; relationships: string[]; tensions: string[]; open_questions: string[]; synthesis: string; } // ── State ────────────────────────────────────────────────────────────────── let session: { did: string; handle: string } | null = null; let currentPage: "explore" | "island" | "strata" | "connect" = "explore"; let islands: Island[] = []; // ── XRPC helpers ─────────────────────────────────────────────────────────── async function apiGet(path: string): Promise { const res = await fetch(path); if (!res.ok) { const err = await res.text(); throw new Error(`API ${path} failed: ${res.status} ${err}`); } return res.json(); } // ── Force-directed graph renderer ────────────────────────────────────────── interface GraphNode { id: string; label: string; x: number; y: number; vx: number; vy: number; type: string; } interface GraphEdge { source: string; target: string; label: string; color: string; } const TYPE_COLORS: Record = { url: "#58a6ff", citation: "#bc8cff", note: "#3fb950", card: "#3fb950", collection: "#d29922", unknown: "#8b949e", }; const EDGE_COLORS: Record = { "related": "#8b949e", "relates": "#8b949e", "leads to": "#58a6ff", "supplement": "#bc8cff", "supplements": "#bc8cff", "opposes": "#f85149", "supports": "#3fb950", "addresses": "#d29922", "explainer": "#79c0ff", "helpful": "#56d364", "contains": "#58a6ff", "cites": "#bc8cff", "inspired": "#d29922", "contradicts": "#f85149", "extends": "#3fb950", "forks": "#79c0ff", "annotates": "#8b949e", }; class ForceGraph { nodes: GraphNode[] = []; edges: GraphEdge[] = []; nodeMap = new Map(); width: number; height: number; alpha = 1; private rafId = 0; private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private hoverNode: string | null = null; private dragNode: string | null = null; private dragOffset = { x: 0, y: 0 }; constructor(canvas: HTMLCanvasElement, island: Island) { this.canvas = canvas; this.ctx = canvas.getContext("2d")!; const rect = canvas.getBoundingClientRect(); this.width = rect.width; this.height = rect.height; canvas.width = rect.width * devicePixelRatio; canvas.height = rect.height * devicePixelRatio; this.ctx.scale(devicePixelRatio, devicePixelRatio); // Build nodes const cx = this.width / 2; const cy = this.height / 2; const r = Math.min(this.width, this.height) * 0.35; for (const uri of island.vertices!) { const meta = island.vertexMeta![uri]; const angle = Math.random() * Math.PI * 2; const dist = Math.random() * r; const node: GraphNode = { id: uri, label: meta?.title || truncateUri(uri), x: cx + Math.cos(angle) * dist, y: cy + Math.sin(angle) * dist, vx: 0, vy: 0, type: meta?.type || "unknown", }; this.nodes.push(node); this.nodeMap.set(uri, node); } // Build edges for (const e of island.edges!) { const sNode = this.nodeMap.get(e.source); const tNode = this.nodeMap.get(e.target); if (!sNode || !tNode) continue; const label = connectionTypeLabel(e.connectionType); this.edges.push({ source: e.source, target: e.target, label, color: EDGE_COLORS[label] || EDGE_COLORS[connectionTypeLabel(e.connectionType)] || "#8b949e", }); } // Interactions canvas.addEventListener("mousemove", this.onMouseMove); canvas.addEventListener("mousedown", this.onMouseDown); canvas.addEventListener("mouseup", this.onMouseUp); canvas.addEventListener("mouseleave", this.onMouseLeave); canvas.addEventListener("dblclick", this.onDblClick); // Warm up the simulation for (let i = 0; i < 120; i++) this.tick(); this.draw(); } destroy() { cancelAnimationFrame(this.rafId); this.canvas.removeEventListener("mousemove", this.onMouseMove); this.canvas.removeEventListener("mousedown", this.onMouseDown); this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("mouseleave", this.onMouseLeave); this.canvas.removeEventListener("dblclick", this.onDblClick); } tick() { if (this.alpha < 0.001) return; this.alpha *= 0.99; const cx = this.width / 2; const cy = this.height / 2; // Center gravity for (const n of this.nodes) { n.vx += (cx - n.x) * 0.01 * this.alpha; n.vy += (cy - n.y) * 0.01 * this.alpha; } // Repulsion (all pairs) for (let i = 0; i < this.nodes.length; i++) { for (let j = i + 1; j < this.nodes.length; j++) { const a = this.nodes[i]; const b = this.nodes[j]; let dx = b.x - a.x; let dy = b.y - a.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = -300 * this.alpha / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } // Attraction (edges) for (const e of this.edges) { const a = this.nodeMap.get(e.source)!; const b = this.nodeMap.get(e.target)!; let dx = b.x - a.x; let dy = b.y - a.y; let dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - 80) * 0.05 * this.alpha; const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Apply velocity with damping for (const n of this.nodes) { if (n.id === this.dragNode) continue; n.vx *= 0.6; n.vy *= 0.6; n.x += n.vx; n.y += n.vy; // Keep in bounds const pad = 20; n.x = Math.max(pad, Math.min(this.width - pad, n.x)); n.y = Math.max(pad, Math.min(this.height - pad, n.y)); } } draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); // Edges for (const e of this.edges) { const a = this.nodeMap.get(e.source)!; const b = this.nodeMap.get(e.target)!; const isHovered = this.hoverNode === e.source || this.hoverNode === e.target; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = isHovered ? e.color : `${e.color}44`; ctx.lineWidth = isHovered ? 2 : 1; ctx.stroke(); // Arrow const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const arrowLen = 8; const arrowX = b.x - (dx / dist) * 16; const arrowY = b.y - (dy / dist) * 16; const angle = Math.atan2(dy, dx); ctx.beginPath(); ctx.moveTo(arrowX, arrowY); ctx.lineTo(arrowX - arrowLen * Math.cos(angle - 0.4), arrowY - arrowLen * Math.sin(angle - 0.4)); ctx.lineTo(arrowX - arrowLen * Math.cos(angle + 0.4), arrowY - arrowLen * Math.sin(angle + 0.4)); ctx.closePath(); ctx.fillStyle = isHovered ? e.color : `${e.color}44`; ctx.fill(); } // Nodes for (const n of this.nodes) { const isHovered = this.hoverNode === n.id; const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown; const radius = isHovered ? 7 : 5; ctx.beginPath(); ctx.arc(n.x, n.y, radius, 0, Math.PI * 2); ctx.fillStyle = isHovered ? color : `${color}cc`; ctx.fill(); ctx.strokeStyle = isHovered ? "#fff" : color; ctx.lineWidth = isHovered ? 2 : 1; ctx.stroke(); // Label if (isHovered || this.nodes.length <= 12) { ctx.font = `${isHovered ? "11" : "10"}px -apple-system, sans-serif`; ctx.fillStyle = isHovered ? "#e6edf3" : "#8b949e"; ctx.textAlign = "center"; ctx.fillText(n.label.slice(0, 30), n.x, n.y - radius - 4); } } } private getMousePos = (e: MouseEvent) => { const rect = this.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; private findNode(x: number, y: number): string | null { for (const n of this.nodes) { const dx = n.x - x; const dy = n.y - y; if (dx * dx + dy * dy < 100) return n.id; } return null; } private onMouseMove = (e: MouseEvent) => { const pos = this.getMousePos(e); if (this.dragNode) { const n = this.nodeMap.get(this.dragNode)!; n.x = pos.x; n.y = pos.y; n.vx = 0; n.vy = 0; this.alpha = 0.3; this.tick(); this.draw(); return; } const prev = this.hoverNode; this.hoverNode = this.findNode(pos.x, pos.y); if (this.hoverNode !== prev) this.draw(); this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; }; private onMouseDown = (e: MouseEvent) => { const pos = this.getMousePos(e); const id = this.findNode(pos.x, pos.y); if (id) { this.dragNode = id; this.canvas.style.cursor = "grabbing"; } }; private onMouseUp = () => { this.dragNode = null; this.canvas.style.cursor = this.hoverNode ? "pointer" : "default"; }; private onMouseLeave = () => { this.hoverNode = null; this.dragNode = null; this.canvas.style.cursor = "default"; this.draw(); }; private onDblClick = (e: MouseEvent) => { const pos = this.getMousePos(e); const id = this.findNode(pos.x, pos.y); if (!id) return; if (id.startsWith("http")) { window.open(id, "_blank"); } }; } // ── Rendering helpers ────────────────────────────────────────────────────── function escHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function truncateUri(uri: string): string { if (uri.startsWith("http")) { try { const u = new URL(uri); const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40); return u.hostname + path; } catch { return uri.slice(0, 60); } } if (uri.startsWith("at://")) { const parts = uri.split("/"); return parts.slice(3).join("/") || uri.slice(0, 60); } return uri.slice(0, 60); } function connectionTypeLabel(t: string): string { if (!t) return "relates"; // Handle both "network.cosmik.connection#RELATES" and raw "RELATES" const raw = t.includes("#") ? t.split("#").pop()! : t; const labels: Record = { RELATED: "related", LEADS_TO: "leads to", SUPPLEMENT: "supplement", SUPPLEMENTS: "supplements", OPPOSES: "opposes", SUPPORTS: "supports", ADDRESSES: "addresses", EXPLAINER: "explainer", HELPFUL: "helpful", CONTAINS: "contains", CITES: "cites", INSPIRED: "inspired", CONTRADICTS: "contradicts", EXTENDS: "extends", FORKS: "forks", ANNOTATES: "annotates", RELATES: "relates", }; return labels[raw] || raw.toLowerCase().replace(/_/g, " "); } function connectionTypeIcon(t: string): string { const label = connectionTypeLabel(t); const icons: Record = { "leads to": "\u2192", "cites": "\u2190", "related": "\u2194", "relates": "\u2194", "inspired": "\u2728", "opposes": "\u26D4", "contradicts": "\u26D4", "extends": "\u21D2", "forks": "\u21C4", "annotates": "\u270E", "supplement": "\u2295", "supplements": "\u2295", "supports": "\u2713", "addresses": "\u25C6", "explainer": "\u2139", "helpful": "\u2606", "contains": "\u25B6", }; return icons[label] || "\u2194"; } // ── Explore page: island cards ───────────────────────────────────────────── function renderExplore(): void { const content = document.getElementById("page-content"); if (!content) return; if (islands.length === 0) { content.innerHTML = '
No islands found in the connection graph. Save some connections to see clusters!
'; return; } content.innerHTML = `
${islands.map(island => renderIslandCard(island)).join("")}
`; // Initialize graphs (only for islands with full data) document.querySelectorAll("canvas.island-graph").forEach(canvas => { const islandId = (canvas as HTMLElement).dataset.islandId; const island = islands.find(i => i.id === islandId); if (island && island.vertices && island.edges) { new ForceGraph(canvas as HTMLCanvasElement, island as any); } }); // Wire up Strata buttons on island cards — go to strata page document.querySelectorAll(".island-strata-btn").forEach(btn => { btn.addEventListener("click", (e) => { const target = e.currentTarget as HTMLElement; const islandId = target.dataset.islandId; if (!islandId) return; window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; }); }); } function summarizeIsland(island: Island): string { if (island.title) return island.title; if (island.summary) return island.summary; // Fallback: build from metadata (only if we have full data) const vertices = island.vertices; if (!vertices || vertices.length === 0) return "Island"; const meta = island.vertexMeta || {}; const domains = new Map(); for (const v of vertices) { if (v.startsWith("http")) { try { const host = new URL(v).hostname.replace(/^www\./, ""); domains.set(host, (domains.get(host) || 0) + 1); } catch {} } } const topDomains = [...domains.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(d => d[0]); const titles = vertices .map(v => meta[v]?.title || "") .filter(t => t.length > 10 && t.length < 120) .slice(0, 2); const parts: string[] = []; if (topDomains.length > 0) parts.push(topDomains.join(", ")); if (titles.length > 0) parts.push(titles.join("; ")); return parts.join(" -- ") || `${vertices.length} connected nodes`; } function renderIslandCard(island: Island): string { const summary = summarizeIsland(island); const hasStrata = !!island.strata; const strataBtnLabel = hasStrata ? "View Strata" : "Strata"; const strataBtnClass = hasStrata ? "strata-btn island-strata-btn has-strata" : "strata-btn island-strata-btn"; const hasFullData = !!(island.vertices && island.edges); const sizeLabel = hasFullData ? `${island.vertices!.length} nodes · ${island.edges!.length} edges` : ""; const graphOrTitle = hasFullData ? `` : `
${escHtml(summary)}
`; return `
${graphOrTitle}
`; } // ── Island detail page ───────────────────────────────────────────────────── async function showIsland(islandId: string): Promise { currentPage = "island"; const content = document.getElementById("page-content"); if (!content) return; const island = islands.find(i => i.id === islandId); if (!island) { content.innerHTML = '
Island not found.
'; return; } const edgeList = island.edges!.map(e => { const icon = connectionTypeIcon(e.connectionType); const type = connectionTypeLabel(e.connectionType); const sourceMeta = island.vertexMeta![e.source]; const targetMeta = island.vertexMeta![e.target]; const sourceTitle = sourceMeta?.title || truncateUri(e.source); const targetTitle = targetMeta?.title || truncateUri(e.target); return `
${escHtml(sourceTitle)} ${icon} ${escHtml(type)} ${escHtml(targetTitle)} ${e.note ? `${escHtml(e.note)}` : ""} ${e.handle ? `${escHtml(e.handle)}` : ""}
`; }).join(""); content.innerHTML = `
← Explore

Island

${island.vertices!.length} vertices · ${island.edges!.length} edges

Connections

${edgeList}
`; // Initialize the detail graph const canvas = document.getElementById("island-detail-graph") as HTMLCanvasElement; if (canvas) { new ForceGraph(canvas, island); } document.getElementById("island-strata-btn")?.addEventListener("click", async () => { window.location.href = `/strata?id=${encodeURIComponent(islandId)}`; }); } // ── Strata page — prose with arrows ─────────────────────────────────────── async function showStrata(islandId: string): Promise { currentPage = "strata"; const content = document.getElementById("page-content"); if (!content) return; let island: Island | null = null; // If the ID is an AT URI, fetch from the strata record endpoint if (islandId.startsWith("at://")) { try { const data = await apiGet<{ island: Island; recordUri: string }>(`/xrpc/org.latha.strata.getRecord?uri=${encodeURIComponent(islandId)}`); island = data.island; } catch { content.innerHTML = '
Strata record not found.
'; return; } } else { // Legacy: sha256 ID — find from loaded islands island = islands.find(i => i.id === islandId) || null; // If not in memory, fetch from API if (!island) { try { const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); island = data.islands.find(i => i.id === islandId) || null; if (island) islands = data.islands; } catch {} } } if (!island) { content.innerHTML = '
Island not found.
'; return; } if (!island.strata) { content.innerHTML = `
← Explore

Strata

No strata analysis yet for this island. Run scripts/strata-analyze.py to generate.
`; return; } renderStrata(island); } function renderStrata(island: Island): void { const content = document.getElementById("page-content"); if (!content) return; const s = island.strata!; const summary = island.summary || summarizeIsland(island); // Themes const themesHtml = s.themes.length > 0 ? `
${s.themes.map(t => `${escHtml(t)}`).join("")}
` : ""; // Relationships — link entity names to their Semble pages const titleToUri = new Map(); for (const uri of island.vertices!) { const m = island.vertexMeta![uri]; const title = m?.title; if (title && title.length > 3 && !title.startsWith("http") && !title.startsWith("at://")) { titleToUri.set(title, uri); } } const relsHtml = s.relationships.length > 0 ? `
${s.relationships.map(r => { // Link entity names to their Semble pages let styled = escHtml(r); // Sort titles longest-first so longer matches take priority const sortedTitles = [...titleToUri.entries()].sort((a, b) => b[0].length - a[0].length); for (const [title, uri] of sortedTitles) { const escaped = escHtml(title); // Exact match if (styled.includes(escaped)) { const href = `https://semble.so/url/${encodeURIComponent(uri)}`; styled = styled.replace(escaped, `${escaped}`); } } // Also try linking short names that appear as substrings of titles // e.g. "C2PA" appears inside "C2PA content authenticity standard" for (const [title, uri] of sortedTitles) { const href = `https://semble.so/url/${encodeURIComponent(uri)}`; // Extract short name segments from the title (first N words, abbreviations, etc.) const shortNames = [ title.split(/[:—–\-]/)[0].trim(), // text before delimiter title.split("(")[0].trim(), // text before parenthetical ].filter(n => n.length > 3 && n.length < title.length); for (const shortName of shortNames) { const escaped = escHtml(shortName); if (styled.includes(escaped) && !styled.includes(`>${escaped}<`)) { // Not already linked — link it styled = styled.replace(escaped, `${escaped}`); } } } styled = styled .replace(/→/g, '') .replace(/←/g, '') .replace(/↔/g, '') .replace(/⇏/g, ''); return `
${styled}
`; }).join("")}
` : ""; // Tensions const tensionsHtml = s.tensions.length > 0 ? `

Tensions

${s.tensions.map(t => `
${escHtml(t)}
`).join("")}
` : ""; // Open questions const questionsHtml = s.open_questions.length > 0 ? `

Open Questions

${s.open_questions.map(q => `
${escHtml(q)}
`).join("")}
` : ""; // Synthesis const synthesisHtml = s.synthesis ? `

Synthesis

${escHtml(s.synthesis)}
` : ""; content.innerHTML = `
← Explore

${escHtml(summary)}

${island.vertices!.length} vertices · ${island.edges!.length} edges

Synthesis

${escHtml(s.synthesis)}
${themesHtml ? `

Themes

${themesHtml}
` : ""} ${relsHtml ? `

Relationships

${relsHtml}
` : ""} ${tensionsHtml} ${questionsHtml}

Edges

${island.edges!.map(e => { const sourceMeta = island.vertexMeta![e.source]; const targetMeta = island.vertexMeta![e.target]; const sourceTitle = sourceMeta?.title || truncateUri(e.source); const targetTitle = targetMeta?.title || truncateUri(e.target); const type = connectionTypeLabel(e.connectionType); const icon = connectionTypeIcon(e.connectionType); const sembleUrl = e.source.startsWith("http") ? `https://semble.so/url/${encodeURIComponent(e.source)}` : ""; return ` ${escHtml(sourceTitle)} ${icon} ${escHtml(type)} ${escHtml(targetTitle)} ${e.note ? `${escHtml(e.note)}` : ""} ${e.handle ? `@${escHtml(e.handle)}` : ""} `; }).join("")}
`; // Initialize the force graph const canvas = document.getElementById("strata-graph") as HTMLCanvasElement; if (canvas) { new ForceGraph(canvas, island); } } // ── Connect page ─────────────────────────────────────────────────────────── function renderConnectForm(sourceUri?: string): void { const content = document.getElementById("page-content"); if (!content) return; content.innerHTML = `
← Explore

Create Connection

A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.

`; document.getElementById("connect-form")?.addEventListener("submit", async (e) => { e.preventDefault(); const result = document.getElementById("connect-result"); if (!result) return; result.innerHTML = '
Creating connection on your PDS...
'; const source = (document.getElementById("source") as HTMLInputElement).value; const target = (document.getElementById("target") as HTMLInputElement).value; const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value; const note = (document.getElementById("note") as HTMLTextAreaElement).value; const record = { $type: "network.cosmik.connection", source, target, connectionType, note: note || undefined, createdAt: new Date().toISOString(), }; result.innerHTML = `

Record to create on your PDS:

${escHtml(JSON.stringify(record, null, 2))}

OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.

`; }); } // ── Pages ────────────────────────────────────────────────────────────────── async function showExplore(): Promise { currentPage = "explore"; // Try API first, fall back to server-rendered data try { const data = await apiGet<{ islands: Island[] }>("/xrpc/org.latha.strata.getIslands"); islands = data.islands || []; } catch { const serverData = (window as any).__ISLANDS__; if (serverData) islands = serverData; } renderExplore(); } // ── Routing ─────────────────────────────────────────────────────────────── function getRoute(): { page: string; params: Record } { const url = new URL(window.location.href); const path = url.pathname; if (path === "/island") { return { page: "island", params: { id: url.searchParams.get("id") || "" } }; } if (path === "/strata") { return { page: "strata", params: { id: url.searchParams.get("id") || "" } }; } if (path === "/connect") { return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; } return { page: "explore", params: {} }; } async function route(): Promise { const { page, params } = getRoute(); if (page === "island" && params.id) { await showIsland(params.id); } else if (page === "strata" && params.id) { await showStrata(params.id); } else if (page === "connect") { renderConnectForm(params.source); } else { await showExplore(); } } // ── Auth ─────────────────────────────────────────────────────────────────── async function initAuth(): Promise { try { const res = await fetch("/xrpc/org.latha.strata.getProfile", { headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") }, }); if (res.ok) { const data: any = await res.json(); session = { did: data.did, handle: data.handle }; updateAuthUI(); return; } } catch {} updateAuthUI(); } function updateAuthUI(): void { const status = document.getElementById("auth-status"); const loginBtn = document.getElementById("login-btn"); if (!status || !loginBtn) return; if (session) { status.textContent = session.handle; status.classList.add("visible"); loginBtn.classList.add("hidden"); } else { status.classList.remove("visible"); loginBtn.classList.remove("hidden"); } } // ── Init ─────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", () => { route(); }); window.addEventListener("popstate", route);