/** * folk-data-cloud — Concentric-ring SVG visualization of data objects * across user spaces, grouped by visibility level (private/permissioned/public). * * Two-level interaction: click space bubble → detail panel with modules, * click module row → navigate to that module page. */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; interface SpaceInfo { slug: string; name: string; visibility: string; role?: string; relationship?: string; } interface ModuleSummary { id: string; name: string; icon: string; docCount: number; } interface SpaceBubble extends SpaceInfo { docCount: number; modules: ModuleSummary[]; } type Ring = "private" | "permissioned" | "public"; const RING_CONFIG: Record = { private: { color: "#ef4444", label: "Private", radius: 0.28 }, permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 }, public: { color: "#22c55e", label: "Public", radius: 0.80 }, }; const RINGS: Ring[] = ["private", "permissioned", "public"]; const DEMO_SPACES: SpaceBubble[] = [ { slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [ { id: "notes", name: "rNotes", icon: "📝", docCount: 5 }, { id: "tasks", name: "rTasks", icon: "📋", docCount: 4 }, { id: "cal", name: "rCal", icon: "📅", docCount: 3 }, { id: "wallet", name: "rWallet", icon: "💰", docCount: 2 }, ]}, { slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [ { id: "docs", name: "rDocs", icon: "📓", docCount: 3 }, { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, ]}, { slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [ { id: "docs", name: "rDocs", icon: "📓", docCount: 6 }, { id: "vote", name: "rVote", icon: "🗳", docCount: 4 }, { id: "flows", name: "rFlows", icon: "🌊", docCount: 3 }, { id: "tasks", name: "rTasks", icon: "📋", docCount: 5 }, { id: "cal", name: "rCal", icon: "📅", docCount: 4 }, ]}, { slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [ { id: "vote", name: "rVote", icon: "🗳", docCount: 7 }, { id: "flows", name: "rFlows", icon: "🌊", docCount: 4 }, ]}, { slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [ { id: "notes", name: "rNotes", icon: "📝", docCount: 3 }, { id: "vote", name: "rVote", icon: "🗳", docCount: 2 }, { id: "tasks", name: "rTasks", icon: "📋", docCount: 4 }, { id: "cal", name: "rCal", icon: "📅", docCount: 3 }, { id: "wallet", name: "rWallet", icon: "💰", docCount: 1 }, { id: "flows", name: "rFlows", icon: "🌊", docCount: 5 }, ]}, { slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [ { id: "docs", name: "rDocs", icon: "📓", docCount: 4 }, { id: "pubs", name: "rPubs", icon: "📰", docCount: 5 }, ]}, ]; class FolkDataCloud extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private spaces: SpaceBubble[] = []; private loading = true; private isDemo = false; private selected: string | null = null; private hoveredSlug: string | null = null; private width = 600; private height = 600; 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 || 600; this.width = Math.min(w, 800); this.height = this.width; if (!this.loading) this.render(); }); this._resizeObserver.observe(this); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); this.loadData(); } disconnectedCallback() { this._stopPresence?.(); this._resizeObserver?.disconnect(); } private async loadData() { this.loading = true; this.render(); const token = localStorage.getItem("rspace_auth"); if (!token) { this.isDemo = true; this.spaces = DEMO_SPACES; this.loading = false; this.render(); 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 spacesData: { spaces: SpaceInfo[] } = await spacesResp.json(); // Fetch content-tree for each space in parallel const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, ""); const bubbles: SpaceBubble[] = await Promise.all( spacesData.spaces.map(async (sp) => { try { const treeResp = await fetch( `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, { signal: AbortSignal.timeout(8000) } ); if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] }; const tree = await treeResp.json(); const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({ id: m.id, name: m.name, icon: m.icon, docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0), })); const docCount = modules.reduce((s, m) => s + m.docCount, 0); return { ...sp, docCount, modules }; } catch { return { ...sp, docCount: 0, modules: [] }; } }) ); this.spaces = bubbles; this.isDemo = false; } catch { this.isDemo = true; this.spaces = DEMO_SPACES; } this.loading = false; this.render(); } private groupByRing(): Record { const groups: Record = { private: [], permissioned: [], public: [] }; for (const sp of this.spaces) { const ring = (sp.visibility as Ring) || "private"; (groups[ring] || groups.private).push(sp); } return groups; } private isMobile(): boolean { return this.width < 500; } private render() { const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null; this.shadow.innerHTML = `
${this.isDemo ? `
Sign in to see your data cloud
` : ""} ${this.loading ? this.renderLoading() : this.renderSVG()} ${selected ? this.renderDetailPanel(selected) : ""}
`; this.attachEvents(); } private renderLoading(): string { const cx = this.width / 2; const cy = this.height / 2; return ` ${RINGS.map(ring => { const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9; return ``; }).join("")} Loading your data cloud… `; } private renderSVG(): string { const groups = this.groupByRing(); const cx = this.width / 2; const cy = this.height / 2; const scale = (this.width / 2) * 0.9; const mobile = this.isMobile(); const bubbleR = mobile ? 20 : 28; const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount)); let svg = ``; // Render rings (outer to inner so inner draws on top) for (const ring of [...RINGS].reverse()) { const cfg = RING_CONFIG[ring]; const r = cfg.radius * scale; svg += ``; // Ring label at top const labelY = cy - r - 8; svg += `${cfg.label}`; } // Render bubbles per ring for (const ring of RINGS) { const cfg = RING_CONFIG[ring]; const ringR = cfg.radius * scale; const ringSpaces = groups[ring]; if (ringSpaces.length === 0) continue; const angleStep = (2 * Math.PI) / ringSpaces.length; const startAngle = -Math.PI / 2; // Start from top for (let i = 0; i < ringSpaces.length; i++) { const sp = ringSpaces[i]; const angle = startAngle + i * angleStep; const bx = cx + ringR * Math.cos(angle); const by = cy + ringR * Math.sin(angle); // Scale bubble size by doc count (min 60%, max 100%) const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount); const r = bubbleR * sizeScale; const isSelected = this.selected === sp.slug; const isHovered = this.hoveredSlug === sp.slug; const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5); const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1); // Bubble circle svg += ``; if (isSelected) { svg += ` `; } svg += ``; // Label const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name); svg += `${this.esc(label)}`; // Doc count badge svg += `${sp.docCount}`; // Tooltip (title element) svg += `${this.esc(sp.name)} — ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})`; svg += ``; } } // Center label const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0); svg += `${totalDocs}`; svg += `total documents`; svg += ``; return svg; } private renderDetailPanel(sp: SpaceBubble): string { const ring = (sp.visibility as Ring) || "private"; const cfg = RING_CONFIG[ring]; const visBadgeColor = cfg.color; return `
${this.esc(sp.name)} ${sp.visibility} ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}
${sp.modules.length === 0 ? `
No documents in this space
` : `
${sp.modules.map(m => `
${m.icon} ${this.esc(m.name)} ${m.docCount}
`).join("")}
` }
`; } private attachEvents() { // Bubble click — toggle selection for (const g of this.shadow.querySelectorAll(".dc-bubble")) { const slug = g.dataset.slug!; g.addEventListener("click", () => { this.selected = this.selected === slug ? null : slug; this.render(); }); g.addEventListener("mouseenter", () => { this.hoveredSlug = slug; // Update stroke without full re-render for perf const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; if (circle) circle.setAttribute("stroke-width", "2.5"); }); g.addEventListener("mouseleave", () => { this.hoveredSlug = null; const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement; if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5"); }); } // Module row click — navigate for (const row of this.shadow.querySelectorAll(".dc-panel__mod")) { row.addEventListener("click", () => { const spaceSlug = row.dataset.navSpace!; const modId = row.dataset.navMod!; const modPath = modId.startsWith("r") ? modId : `r${modId}`; window.location.href = `/${spaceSlug}/${modPath}`; }); } } private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } private escAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(/