/** * folk-data-cloud — Graph visualization of all data objects (documents) * across the user's spaces. Nodes represent individual documents, * grouped radially by module around a central space node. * * Click any node → opens that module in a new tab. * Demo mode shows dummy document nodes when unauthenticated. */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { rspaceNavUrl } from '../../../shared/url-helpers'; // ── Types ── interface DocNode { docId: string; title: string; modId: string; modName: string; modIcon: string; space: string; spaceName: string; visibility: string; } interface GraphNode { id: string; label: string; icon: string; type: "space" | "module" | "doc"; modId?: string; space?: string; color: string; x: number; y: number; r: number; } interface GraphEdge { from: string; to: string; color: string; } // ── Colors ── const VIS_COLORS: Record = { private: "#ef4444", permissioned: "#eab308", public: "#22c55e", }; const MOD_COLORS: Record = { notes: "#f97316", docs: "#f97316", vote: "#a855f7", tasks: "#3b82f6", cal: "#06b6d4", wallet: "#eab308", flows: "#14b8a6", pubs: "#ec4899", files: "#64748b", forum: "#8b5cf6", inbox: "#f43f5e", network: "#22d3ee", trips: "#10b981", tube: "#f59e0b", choices: "#6366f1", cart: "#84cc16", }; function modColor(modId: string): string { return MOD_COLORS[modId] || "#94a3b8"; } // ── Demo data ── const DEMO_DOCS: DocNode[] = [ { docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:notes:notebooks:nb3", title: "Research Log", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:vote:proposals:p1", title: "Dark mode proposal", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:cal:calendars:c2", title: "Personal", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", modId: "wallet", modName: "rWallet", modIcon: "💰", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:docs:notebooks:d1", title: "Onboarding Guide", modId: "docs", modName: "rDocs", modIcon: "📓", space: "demo", spaceName: "Demo Space", visibility: "public" }, { docId: "demo:pubs:pages:pub1", title: "Launch Announcement", modId: "pubs", modName: "rPubs", modIcon: "📰", space: "demo", spaceName: "Demo Space", visibility: "public" }, ]; // ── Component ── class FolkDataCloud extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private docs: DocNode[] = []; private nodes: GraphNode[] = []; private edges: GraphEdge[] = []; private loading = true; private isDemo = false; private hoveredId: string | null = null; private width = 700; private height = 700; private _stopPresence: (() => void) | null = null; private _resizeObserver: ResizeObserver | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this._resizeObserver = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width || 700; this.width = Math.min(w, 900); this.height = Math.max(this.width * 0.85, 500); if (!this.loading) { this.layout(); this.render(); } }); this._resizeObserver.observe(this); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); this.loadData(); } disconnectedCallback() { this._stopPresence?.(); this._resizeObserver?.disconnect(); } // ── Data loading ── private async loadData() { this.loading = true; this.render(); const token = localStorage.getItem("rspace_auth"); if (!token) { this.isDemo = true; this.docs = DEMO_DOCS; this.finalize(); return; } try { const spacesResp = await fetch("/api/spaces", { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000), }); if (!spacesResp.ok) throw new Error("spaces fetch failed"); const { spaces } = await spacesResp.json() as { spaces: Array<{ slug: string; name: string; visibility: string }> }; const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, ""); const allDocs: DocNode[] = []; await Promise.all(spaces.map(async (sp) => { try { const resp = await fetch( `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, { signal: AbortSignal.timeout(8000) } ); if (!resp.ok) return; const tree = await resp.json(); for (const mod of (tree.modules || [])) { for (const col of (mod.collections || [])) { for (const item of (col.items || [])) { allDocs.push({ docId: item.docId, title: item.title || col.collection, modId: mod.id, modName: mod.name, modIcon: mod.icon, space: sp.slug, spaceName: sp.name, visibility: sp.visibility || "private", }); } } } } catch { /* skip space */ } })); this.docs = allDocs; this.isDemo = false; } catch { this.isDemo = true; this.docs = DEMO_DOCS; } this.finalize(); } private finalize() { this.loading = false; this.layout(); this.render(); } // ── Graph layout ── // Central node per space, module nodes around it, doc nodes orbiting modules. private layout() { this.nodes = []; this.edges = []; const cx = this.width / 2; const cy = this.height / 2; const mobile = this.width < 500; // Group docs by space, then by module const spaceMap = new Map }>(); for (const doc of this.docs) { if (!spaceMap.has(doc.space)) { spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() }); } const sp = spaceMap.get(doc.space)!; if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []); sp.mods.get(doc.modId)!.push(doc); } const spaceKeys = [...spaceMap.keys()]; const spaceCount = spaceKeys.length; if (spaceCount === 0) return; // Single space → center layout. Multiple → distribute around center. const spaceR = mobile ? 18 : 24; const modR = mobile ? 12 : 16; const docR = mobile ? 6 : 8; const orbitMod = mobile ? 70 : 100; // module distance from space center const orbitDoc = mobile ? 28 : 38; // doc distance from module center for (let si = 0; si < spaceCount; si++) { const spaceSlug = spaceKeys[si]; const sp = spaceMap.get(spaceSlug)!; const visColor = VIS_COLORS[sp.vis] || VIS_COLORS.private; // Space position let sx: number, sy: number; if (spaceCount === 1) { sx = cx; sy = cy; } else { const spaceOrbit = Math.min(this.width, this.height) * 0.3; const spAngle = (2 * Math.PI * si / spaceCount) - Math.PI / 2; sx = cx + spaceOrbit * Math.cos(spAngle); sy = cy + spaceOrbit * Math.sin(spAngle); } const spaceNodeId = `space:${spaceSlug}`; this.nodes.push({ id: spaceNodeId, label: sp.name, icon: "", type: "space", space: spaceSlug, color: visColor, x: sx, y: sy, r: spaceR, }); // Modules around space const modKeys = [...sp.mods.keys()]; const modCount = modKeys.length; const actualModOrbit = Math.min(orbitMod, (spaceCount === 1 ? orbitMod * 1.5 : orbitMod)); for (let mi = 0; mi < modCount; mi++) { const mId = modKeys[mi]; const docs = sp.mods.get(mId)!; const firstDoc = docs[0]; const mAngle = (2 * Math.PI * mi / modCount) - Math.PI / 2; const mx = sx + actualModOrbit * Math.cos(mAngle); const my = sy + actualModOrbit * Math.sin(mAngle); const modNodeId = `mod:${spaceSlug}:${mId}`; this.nodes.push({ id: modNodeId, label: firstDoc.modName, icon: firstDoc.modIcon, type: "module", modId: mId, space: spaceSlug, color: modColor(mId), x: mx, y: my, r: modR, }); this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor }); // Doc nodes around module for (let di = 0; di < docs.length; di++) { const doc = docs[di]; const dAngle = (2 * Math.PI * di / docs.length) - Math.PI / 2; // Offset by module angle to spread outward const dx = mx + orbitDoc * Math.cos(dAngle); const dy = my + orbitDoc * Math.sin(dAngle); const docNodeId = `doc:${doc.docId}`; this.nodes.push({ id: docNodeId, label: doc.title, icon: "", type: "doc", modId: mId, space: spaceSlug, color: modColor(mId), x: dx, y: dy, r: docR, }); this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) }); } } } } // ── Rendering ── private render() { this.shadow.innerHTML = `
${this.isDemo ? `
Sign in to see your data cloud
` : ""} ${this.loading ? this.renderLoading() : this.renderGraph()} ${!this.loading ? this.renderLegend() : ""}
`; this.attachEvents(); } private renderLoading(): string { return ` Loading your data cloud… `; } private renderGraph(): string { if (this.nodes.length === 0) { return `
No data objects found
`; } const mobile = this.width < 500; let svg = ``; // Edges first (behind nodes) for (const edge of this.edges) { const from = this.nodes.find(n => n.id === edge.from); const to = this.nodes.find(n => n.id === edge.to); if (!from || !to) continue; svg += ``; } // Nodes for (const node of this.nodes) { const isHovered = this.hoveredId === node.id; const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2); const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15); const hoverOpacity = isHovered ? 0.8 : fillOpacity; svg += ``; // Circle svg += ``; // Labels if (node.type === "space") { const label = mobile ? node.label.slice(0, 8) : (node.label.length > 14 ? node.label.slice(0, 13) + "…" : node.label); svg += `${this.esc(label)}`; } else if (node.type === "module") { svg += `${node.icon}`; if (!mobile) { svg += `${this.esc(node.label)}`; } } else { // Doc — show label on hover via title } // Tooltip const tooltipText = node.type === "space" ? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)` : node.type === "module" ? `${node.label} — click to open in new tab` : `${node.label} — click to open in new tab`; svg += `${this.esc(tooltipText)}`; svg += ``; } svg += ``; return svg; } private renderLegend(): string { // Collect unique modules present const mods = new Map(); for (const doc of this.docs) { if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon }); } // Collect unique visibility levels const visLevels = new Set(); for (const doc of this.docs) visLevels.add(doc.visibility); return `
${[...visLevels].map(v => ` ${this.esc(v)} `).join("")}
${[...mods.entries()].map(([, m]) => ` ${m.icon} ${this.esc(m.name)} `).join("")}
Click any node to open in new tab
`; } // ── Events ── private attachEvents() { for (const g of this.shadow.querySelectorAll(".dc-node")) { const nodeId = g.dataset.id!; const space = g.dataset.space || ""; const modId = g.dataset.mod || ""; g.addEventListener("click", () => { if (!space) return; const modPath = modId ? (modId.startsWith("r") ? modId : `r${modId}`) : "rspace"; window.open(rspaceNavUrl(space, modPath), "_blank"); }); g.addEventListener("mouseenter", () => { this.hoveredId = nodeId; const circle = g.querySelector("circle") as SVGCircleElement; if (circle) { circle.setAttribute("stroke-width", "2.5"); circle.setAttribute("fill-opacity", "0.8"); } // Highlight connected edges const connectedEdges = this.edges.filter(e => e.from === nodeId || e.to === nodeId); for (const edge of connectedEdges) { const lines = this.shadow.querySelectorAll("line"); for (const line of lines) { const fromNode = this.nodes.find(n => n.id === edge.from); const toNode = this.nodes.find(n => n.id === edge.to); if (!fromNode || !toNode) continue; if (Math.abs(parseFloat(line.getAttribute("x1")!) - fromNode.x) < 1 && Math.abs(parseFloat(line.getAttribute("y1")!) - fromNode.y) < 1) { line.setAttribute("opacity", "0.6"); line.setAttribute("stroke-width", "2"); } } } }); g.addEventListener("mouseleave", () => { this.hoveredId = null; const circle = g.querySelector("circle") as SVGCircleElement; if (circle) { const node = this.nodes.find(n => n.id === nodeId); if (node) { circle.setAttribute("stroke-width", node.type === "space" ? "2" : "1.2"); const fo = node.type === "doc" ? "0.5" : (node.type === "module" ? "0.3" : "0.15"); circle.setAttribute("fill-opacity", fo); } } // Reset edges for (const line of this.shadow.querySelectorAll("line")) { line.setAttribute("opacity", "0.2"); line.setAttribute("stroke-width", "1"); } }); } } // ── Helpers ── private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } private escAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(/