diff --git a/modules/rdata/components/folk-data-cloud.ts b/modules/rdata/components/folk-data-cloud.ts index fbd0cb55..617461a3 100644 --- a/modules/rdata/components/folk-data-cloud.ts +++ b/modules/rdata/components/folk-data-cloud.ts @@ -1,10 +1,10 @@ /** - * 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. + * folk-data-cloud — 3D force-directed graph of all data objects across spaces. * - * Click any node → opens that module in a new tab. - * Demo mode shows dummy document nodes when unauthenticated. + * Canvas 2D with perspective-projected 3D force simulation. + * Three-tier hierarchy: Space → Module → Document nodes. + * Click space to collapse/expand, click module/doc to open in new tab. + * Drag to orbit, scroll to zoom. */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; @@ -18,115 +18,182 @@ interface DocNode { modId: string; modName: string; modIcon: string; + tags: string[]; space: string; spaceName: string; visibility: string; } -interface GraphNode { +interface Node3D { id: string; label: string; icon: string; - type: "space" | "module" | "doc"; + type: 'space' | 'module' | 'doc'; modId?: string; - space?: string; + space: string; + parentId: string | null; color: string; - x: number; - y: number; - r: number; + baseRadius: number; + // 3D position + x: number; y: number; z: number; + // Velocity + vx: number; vy: number; vz: number; + // Projected 2D (computed each frame) + px: number; py: number; pr: number; depth: number; + // Cluster state + collapsed: boolean; + childCount: number; + hidden: boolean; } -interface GraphEdge { +interface Edge3D { from: string; to: string; color: string; + style: 'solid' | 'dotted' | 'faint'; } -// ── Colors ── +// ── Constants ── const VIS_COLORS: Record = { - private: "#ef4444", - permissioned: "#eab308", - public: "#22c55e", + 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", + 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', + data: '#94a3b8', maps: '#059669', }; -function modColor(modId: string): string { - return MOD_COLORS[modId] || "#94a3b8"; -} +function modColor(id: string): string { return MOD_COLORS[id] || '#94a3b8'; } + +const DAMPING = 0.92; +const REPULSION = 800; +const SPRING_K = 0.015; +const SPRING_REST = 120; +const CENTER_PULL = 0.002; +const DT = 0.8; +const FOV = 600; +const MIN_ZOOM = 200; +const MAX_ZOOM = 1200; // ── 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" }, + { docId: 'demo:notes:notebooks:nb1', title: 'Product Roadmap', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:notes:notebooks:nb2', title: 'Meeting Notes', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:notes:notebooks:nb3', title: 'Research Log', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['research'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:vote:proposals:p1', title: 'Dark mode proposal', modId: 'vote', modName: 'rVote', modIcon: '🗳', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:vote:proposals:p2', title: 'Budget Q2', modId: 'vote', modName: 'rVote', modIcon: '🗳', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:tasks:boards:b1', title: 'Dev Board', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:tasks:boards:b2', title: 'Design Sprint', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:tasks:boards:b3', title: 'Bug Tracker', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:cal:calendars:c1', title: 'Team Calendar', modId: 'cal', modName: 'rCal', modIcon: '📅', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:cal:calendars:c2', title: 'Personal', modId: 'cal', modName: 'rCal', modIcon: '📅', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:wallet:ledgers:l1', title: 'cUSDC Ledger', modId: 'wallet', modName: 'rWallet', modIcon: '💰', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:flows:streams:s1', title: 'Contributor Fund', modId: 'flows', modName: 'rFlows', modIcon: '🌊', tags: ['research'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:flows:streams:s2', title: 'Community Pool', modId: 'flows', modName: 'rFlows', modIcon: '🌊', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:docs:notebooks:d1', title: 'Onboarding Guide', modId: 'docs', modName: 'rDocs', modIcon: '📓', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, + { docId: 'demo:pubs:pages:pub1', title: 'Launch Announcement', modId: 'pubs', modName: 'rPubs', modIcon: '📰', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' }, ]; // ── Component ── class FolkDataCloud extends HTMLElement { private shadow: ShadowRoot; - private space = "demo"; + private canvas!: HTMLCanvasElement; + private ctx!: CanvasRenderingContext2D; + private space = 'demo'; private docs: DocNode[] = []; - private nodes: GraphNode[] = []; - private edges: GraphEdge[] = []; + private nodes: Node3D[] = []; + private edges: Edge3D[] = []; + private nodeMap = new Map(); private loading = true; private isDemo = false; - private hoveredId: string | null = null; - private width = 700; - private height = 700; + private width = 800; + private height = 600; + private dpr = 1; + + // Camera + private camDist = 500; + private rotX = 0.3; // pitch + private rotY = 0.0; // yaw + + // Interaction + private dragging = false; + private dragStartX = 0; + private dragStartY = 0; + private dragRotX = 0; + private dragRotY = 0; + private hoveredNode: Node3D | null = null; + private tooltipX = 0; + private tooltipY = 0; + + // Animation + private animFrame = 0; + private frameCount = 0; + private settled = false; + + // Edge particles + private particles: { edge: Edge3D; t: number; speed: number }[] = []; + private _stopPresence: (() => void) | null = null; private _resizeObserver: ResizeObserver | null = null; constructor() { super(); - this.shadow = this.attachShadow({ mode: "open" }); + this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { - this.space = this.getAttribute("space") || "demo"; + this.space = this.getAttribute('space') || 'demo'; + this.dpr = Math.min(window.devicePixelRatio || 1, 2); + + this.shadow.innerHTML = ` +
+ +
Loading your data cloud…
+ + +
+
`; + + this.canvas = this.shadow.querySelector('.dc-canvas')!; + this.ctx = this.canvas.getContext('2d')!; + 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(); } + const w = entries[0]?.contentRect.width || 800; + this.width = Math.max(w, 300); + this.height = Math.max(Math.min(this.width * 0.75, 700), 400); + this.canvas.width = this.width * this.dpr; + this.canvas.height = this.height * this.dpr; + this.canvas.style.width = `${this.width}px`; + this.canvas.style.height = `${this.height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); }); this._resizeObserver.observe(this); - this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' })); + + this.attachInteraction(); + this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Data Cloud' })); this.loadData(); } disconnectedCallback() { this._stopPresence?.(); this._resizeObserver?.disconnect(); + if (this.animFrame) cancelAnimationFrame(this.animFrame); + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); } // ── Data loading ── private async loadData() { - this.loading = true; - this.render(); - - const token = localStorage.getItem("rspace_auth"); + const token = localStorage.getItem('rspace_auth'); if (!token) { this.isDemo = true; this.docs = DEMO_DOCS; @@ -135,14 +202,14 @@ class FolkDataCloud extends HTMLElement { } try { - const spacesResp = await fetch("/api/spaces", { + const spacesResp = await fetch('/api/spaces', { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000), }); - if (!spacesResp.ok) throw new Error("spaces fetch failed"); + 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 base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, ''); const allDocs: DocNode[] = []; await Promise.all(spaces.map(async (sp) => { @@ -162,9 +229,10 @@ class FolkDataCloud extends HTMLElement { modId: mod.id, modName: mod.name, modIcon: mod.icon, + tags: item.tags || [], space: sp.slug, spaceName: sp.name, - visibility: sp.visibility || "private", + visibility: sp.visibility || 'private', }); } } @@ -184,22 +252,28 @@ class FolkDataCloud extends HTMLElement { private finalize() { this.loading = false; - this.layout(); - this.render(); + const loadingEl = this.shadow.querySelector('.dc-loading') as HTMLElement; + if (loadingEl) loadingEl.style.display = 'none'; + if (this.isDemo) { + const banner = this.shadow.querySelector('.dc-banner') as HTMLElement; + if (banner) banner.style.display = 'block'; + } + this.buildGraph(); + this.renderLegend(); + this.settled = false; + this.frameCount = 0; + this.tick(); } - // ── Graph layout ── - // Central node per space, module nodes around it, doc nodes orbiting modules. + // ── Graph construction ── - private layout() { + private buildGraph() { this.nodes = []; this.edges = []; + this.nodeMap.clear(); + this.particles = []; - const cx = this.width / 2; - const cy = this.height / 2; - const mobile = this.width < 500; - - // Group docs by space, then by module + // Group docs by space → module const spaceMap = new Map }>(); for (const doc of this.docs) { if (!spaceMap.has(doc.space)) { @@ -212,330 +286,708 @@ class FolkDataCloud extends HTMLElement { 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 + const spaceSpread = spaceCount > 1 ? 200 : 0; + // Create space nodes 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; + const slug = spaceKeys[si]; + const sp = spaceMap.get(slug)!; + const angle = (2 * Math.PI * si / spaceCount); + let docTotal = 0; + for (const docs of sp.mods.values()) docTotal += docs.length; - // 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, + const spaceNode: Node3D = { + id: `space:${slug}`, label: sp.name, - icon: "", - type: "space", - space: spaceSlug, - color: visColor, - x: sx, y: sy, r: spaceR, - }); + icon: '', + type: 'space', + space: slug, + parentId: null, + color: VIS_COLORS[sp.vis] || VIS_COLORS.private, + baseRadius: 18, + x: spaceSpread * Math.cos(angle) + rand(-20, 20), + y: spaceSpread * Math.sin(angle) + rand(-20, 20), + z: rand(-50, 50), + vx: 0, vy: 0, vz: 0, + px: 0, py: 0, pr: 0, depth: 0, + collapsed: false, + childCount: sp.mods.size + docTotal, + hidden: false, + }; + this.nodes.push(spaceNode); + this.nodeMap.set(spaceNode.id, spaceNode); - // Modules around space + // Module nodes 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++) { + for (let mi = 0; mi < modKeys.length; 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 mAngle = (2 * Math.PI * mi / modKeys.length); + const mDist = 100 + rand(-15, 15); - const modNodeId = `mod:${spaceSlug}:${mId}`; - this.nodes.push({ - id: modNodeId, - label: firstDoc.modName, - icon: firstDoc.modIcon, - type: "module", + const modNode: Node3D = { + id: `mod:${slug}:${mId}`, + label: docs[0].modName, + icon: docs[0].modIcon, + type: 'module', modId: mId, - space: spaceSlug, + space: slug, + parentId: spaceNode.id, color: modColor(mId), - x: mx, y: my, r: modR, - }); + baseRadius: 12, + x: spaceNode.x + mDist * Math.cos(mAngle), + y: spaceNode.y + mDist * Math.sin(mAngle), + z: spaceNode.z + rand(-40, 40), + vx: 0, vy: 0, vz: 0, + px: 0, py: 0, pr: 0, depth: 0, + collapsed: false, + childCount: docs.length, + hidden: false, + }; + this.nodes.push(modNode); + this.nodeMap.set(modNode.id, modNode); - this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor }); + this.edges.push({ from: spaceNode.id, to: modNode.id, color: spaceNode.color, style: 'solid' }); - // Doc nodes around module + // Doc nodes 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 dAngle = (2 * Math.PI * di / docs.length); + const dDist = 45 + rand(-10, 10); - const docNodeId = `doc:${doc.docId}`; - this.nodes.push({ - id: docNodeId, + const docNode: Node3D = { + id: `doc:${doc.docId}`, label: doc.title, - icon: "", - type: "doc", + icon: '', + type: 'doc', modId: mId, - space: spaceSlug, + space: slug, + parentId: modNode.id, color: modColor(mId), - x: dx, y: dy, r: docR, - }); + baseRadius: 5, + x: modNode.x + dDist * Math.cos(dAngle), + y: modNode.y + dDist * Math.sin(dAngle), + z: modNode.z + rand(-25, 25), + vx: 0, vy: 0, vz: 0, + px: 0, py: 0, pr: 0, depth: 0, + collapsed: false, + childCount: 0, + hidden: false, + }; + this.nodes.push(docNode); + this.nodeMap.set(docNode.id, docNode); - this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) }); + this.edges.push({ from: modNode.id, to: docNode.id, color: modNode.color, style: 'solid' }); } } } + + // Cross-space module links (same module type across different spaces) + if (spaceCount > 1) { + const modByType = new Map(); + for (const n of this.nodes) { + if (n.type !== 'module' || !n.modId) continue; + if (!modByType.has(n.modId)) modByType.set(n.modId, []); + modByType.get(n.modId)!.push(n.id); + } + for (const [, ids] of modByType) { + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = this.nodeMap.get(ids[i])!; + const b = this.nodeMap.get(ids[j])!; + if (a.space !== b.space) { + this.edges.push({ from: ids[i], to: ids[j], color: a.color, style: 'dotted' }); + } + } + } + } + } + + // Cross-module tag links (documents sharing tags) + const tagMap = new Map(); + for (const doc of this.docs) { + for (const tag of doc.tags) { + if (!tagMap.has(tag)) tagMap.set(tag, []); + tagMap.get(tag)!.push(`doc:${doc.docId}`); + } + } + for (const [, ids] of tagMap) { + if (ids.length < 2 || ids.length > 8) continue; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = this.nodeMap.get(ids[i]); + const b = this.nodeMap.get(ids[j]); + if (a && b && a.parentId !== b.parentId) { + this.edges.push({ from: ids[i], to: ids[j], color: '#ffffff', style: 'faint' }); + } + } + } + } + + // Seed edge particles on solid edges + for (const e of this.edges) { + if (e.style === 'solid') { + this.particles.push({ edge: e, t: Math.random(), speed: 0.002 + Math.random() * 0.003 }); + } + } + } + + // ── Force simulation ── + + private simulate() { + const visible = this.nodes.filter(n => !n.hidden); + const len = visible.length; + + // Repulsion (O(n²) — fine for <500 nodes) + for (let i = 0; i < len; i++) { + const a = visible[i]; + for (let j = i + 1; j < len; j++) { + const b = visible[j]; + let dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z; + let dist2 = dx * dx + dy * dy + dz * dz; + if (dist2 < 1) dist2 = 1; + const force = REPULSION / dist2; + const dist = Math.sqrt(dist2); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + const fz = (dz / dist) * force; + a.vx += fx * DT; a.vy += fy * DT; a.vz += fz * DT; + b.vx -= fx * DT; b.vy -= fy * DT; b.vz -= fz * DT; + } + } + + // Spring attraction along edges + for (const e of this.edges) { + const a = this.nodeMap.get(e.from); + const b = this.nodeMap.get(e.to); + if (!a || !b || a.hidden || b.hidden) continue; + + const rest = e.style === 'solid' + ? (a.type === 'space' || b.type === 'space' ? SPRING_REST * 1.2 : SPRING_REST * 0.7) + : SPRING_REST * 2; + const k = e.style === 'faint' ? SPRING_K * 0.3 : SPRING_K; + + const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; + const displacement = dist - rest; + const fx = k * displacement * (dx / dist); + const fy = k * displacement * (dy / dist); + const fz = k * displacement * (dz / dist); + a.vx += fx * DT; a.vy += fy * DT; a.vz += fz * DT; + b.vx -= fx * DT; b.vy -= fy * DT; b.vz -= fz * DT; + } + + // Center pull + damping + integrate + let totalV = 0; + for (const n of visible) { + n.vx -= n.x * CENTER_PULL; + n.vy -= n.y * CENTER_PULL; + n.vz -= n.z * CENTER_PULL; + + n.vx *= DAMPING; + n.vy *= DAMPING; + n.vz *= DAMPING; + + n.x += n.vx; + n.y += n.vy; + n.z += n.vz; + + totalV += Math.abs(n.vx) + Math.abs(n.vy) + Math.abs(n.vz); + } + + // Settle after enough frames with low velocity + if (this.frameCount > 200 && totalV / Math.max(len, 1) < 0.05) { + this.settled = true; + } + } + + // ── 3D → 2D projection ── + + private project() { + const cosX = Math.cos(this.rotX), sinX = Math.sin(this.rotX); + const cosY = Math.cos(this.rotY), sinY = Math.sin(this.rotY); + const cx = this.width / 2, cy = this.height / 2; + + for (const n of this.nodes) { + if (n.hidden) continue; + + // Rotate around Y axis (yaw) + const x1 = n.x * cosY - n.z * sinY; + const z1 = n.x * sinY + n.z * cosY; + // Rotate around X axis (pitch) + const y2 = n.y * cosX - z1 * sinX; + const z2 = n.y * sinX + z1 * cosX; + + // Perspective + const d = this.camDist + z2; + const scale = d > 50 ? FOV / d : FOV / 50; + + n.px = cx + x1 * scale; + n.py = cy + y2 * scale; + n.pr = n.baseRadius * scale; + n.depth = z2; + } } // ── 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 draw() { + const ctx = this.ctx; + const w = this.width, h = this.height; + ctx.clearRect(0, 0, w, h); - private renderLoading(): string { - return ` - - Loading your data cloud… - - `; - } + // Background gradient + const bg = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w * 0.7); + bg.addColorStop(0, 'rgba(30, 30, 50, 1)'); + bg.addColorStop(1, 'rgba(10, 10, 20, 1)'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, w, h); - private renderGraph(): string { - if (this.nodes.length === 0) { - return `
No data objects found
`; + // Draw edges + for (const e of this.edges) { + const a = this.nodeMap.get(e.from); + const b = this.nodeMap.get(e.to); + if (!a || !b || a.hidden || b.hidden) continue; + + const avgDepth = (a.depth + b.depth) / 2; + const depthFade = Math.max(0.03, Math.min(0.4, 1 - (avgDepth + this.camDist) / (this.camDist * 2.5))); + const alpha = e.style === 'faint' ? depthFade * 0.3 : e.style === 'dotted' ? depthFade * 0.5 : depthFade; + + ctx.beginPath(); + ctx.strokeStyle = e.color; + ctx.globalAlpha = alpha; + ctx.lineWidth = e.style === 'solid' ? 1.2 : 0.8; + if (e.style === 'dotted') ctx.setLineDash([4, 6]); + else if (e.style === 'faint') ctx.setLineDash([2, 4]); + else ctx.setLineDash([]); + ctx.moveTo(a.px, a.py); + ctx.lineTo(b.px, b.py); + ctx.stroke(); + ctx.setLineDash([]); } + ctx.globalAlpha = 1; - 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 += ``; + // Draw edge particles + for (const p of this.particles) { + const a = this.nodeMap.get(p.edge.from); + const b = this.nodeMap.get(p.edge.to); + if (!a || !b || a.hidden || b.hidden) continue; + const px = a.px + (b.px - a.px) * p.t; + const py = a.py + (b.py - a.py) * p.t; + const avgDepth = (a.depth + b.depth) / 2; + const depthAlpha = Math.max(0.1, Math.min(0.7, 1 - (avgDepth + this.camDist) / (this.camDist * 2.5))); + ctx.beginPath(); + ctx.arc(px, py, 1.5, 0, Math.PI * 2); + ctx.fillStyle = p.edge.color; + ctx.globalAlpha = depthAlpha; + ctx.fill(); } + ctx.globalAlpha = 1; - // 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; + // Sort nodes by depth for z-ordering (far first) + const visible = this.nodes.filter(n => !n.hidden); + visible.sort((a, b) => b.depth - a.depth); - 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)}`; + // Hovered node's connected edges + const hoveredEdges = new Set(); + if (this.hoveredNode) { + for (const e of this.edges) { + if (e.from === this.hoveredNode.id || e.to === this.hoveredNode.id) { + hoveredEdges.add(e.from); + hoveredEdges.add(e.to); } - } else { - // Doc — show label on hover via title + } + } + + // Draw nodes + for (const n of visible) { + const depthFade = Math.max(0.15, Math.min(1.0, 1 - (n.depth + this.camDist * 0.3) / (this.camDist * 1.8))); + const isHovered = this.hoveredNode?.id === n.id; + const isConnected = this.hoveredNode && hoveredEdges.has(n.id); + + // Glow for space nodes + if (n.type === 'space') { + const glow = ctx.createRadialGradient(n.px, n.py, n.pr * 0.5, n.px, n.py, n.pr * 2.5); + glow.addColorStop(0, n.color + '40'); + glow.addColorStop(1, n.color + '00'); + ctx.fillStyle = glow; + ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6); + ctx.beginPath(); + ctx.arc(n.px, n.py, n.pr * 2.5, 0, Math.PI * 2); + ctx.fill(); } - // 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)}`; + // Node circle + const fillAlpha = n.type === 'doc' ? 0.6 : n.type === 'module' ? 0.5 : 0.4; + const nodeAlpha = depthFade * (isHovered ? 1 : (isConnected ? 0.9 : fillAlpha)); + ctx.beginPath(); + ctx.arc(n.px, n.py, n.pr, 0, Math.PI * 2); + ctx.fillStyle = n.color; + ctx.globalAlpha = nodeAlpha; + ctx.fill(); - svg += ``; + // Stroke + ctx.strokeStyle = isHovered ? '#ffffff' : n.color; + ctx.lineWidth = isHovered ? 2.5 : (n.type === 'space' ? 1.8 : 1); + ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.8); + ctx.stroke(); + + ctx.globalAlpha = 1; + + // Labels + if (n.type === 'space' && n.pr > 6) { + const label = n.collapsed ? `${truncate(n.label, 10)} (${n.childCount})` : truncate(n.label, 14); + ctx.font = `600 ${Math.max(9, Math.min(12, n.pr * 0.7))}px system-ui, sans-serif`; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.globalAlpha = depthFade; + ctx.fillText(label, n.px, n.py + n.pr + 14); + ctx.globalAlpha = 1; + } else if (n.type === 'module' && n.pr > 4) { + ctx.font = `${Math.max(8, Math.min(14, n.pr * 1.1))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.globalAlpha = depthFade; + ctx.fillStyle = '#ffffff'; + ctx.fillText(n.icon, n.px, n.py); + if (n.pr > 7) { + ctx.font = `${Math.max(7, Math.min(10, n.pr * 0.6))}px system-ui, sans-serif`; + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText(truncate(n.label, 12), n.px, n.py + n.pr + 10); + } + ctx.textBaseline = 'alphabetic'; + ctx.globalAlpha = 1; + } } - svg += ``; - return svg; + // Tooltip + if (this.hoveredNode && !this.dragging) { + this.showTooltip(this.hoveredNode); + } else { + this.hideTooltip(); + } } - 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 }); + // ── Animation loop ── + + private tick = () => { + if (!this.isConnected) return; + this.frameCount++; + + if (!this.settled || this.dragging) { + this.simulate(); + } + + // Animate particles + for (const p of this.particles) { + p.t += p.speed; + if (p.t > 1) p.t -= 1; + } + + this.project(); + this.draw(); + this.animFrame = requestAnimationFrame(this.tick); + }; + + // ── Interaction ── + + private attachInteraction() { + this.canvas.addEventListener('mousedown', (e) => { + this.dragging = true; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragRotX = this.rotX; + this.dragRotY = this.rotY; + this.canvas.style.cursor = 'grabbing'; + }); + + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + + this.canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + this.camDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.camDist + e.deltaY * 0.5)); + this.settled = false; + }, { passive: false }); + + this.canvas.addEventListener('mousemove', (e) => { + if (this.dragging) return; + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + this.tooltipX = mx; + this.tooltipY = my; + + let found: Node3D | null = null; + let bestDist = Infinity; + // Check in reverse depth order (front first) + const visible = this.nodes.filter(n => !n.hidden); + visible.sort((a, b) => a.depth - b.depth); + for (const n of visible) { + const dx = mx - n.px, dy = my - n.py; + const dist = Math.sqrt(dx * dx + dy * dy); + const hitR = Math.max(n.pr, 8); + if (dist < hitR && dist < bestDist) { + bestDist = dist; + found = n; + } + } + this.hoveredNode = found; + this.canvas.style.cursor = found ? 'pointer' : 'grab'; + }); + + this.canvas.addEventListener('click', (e) => { + if (!this.hoveredNode) return; + const n = this.hoveredNode; + + if (n.type === 'space') { + this.toggleCollapse(n); + } else { + const space = n.space || this.space; + const modPath = n.modId ? (n.modId.startsWith('r') ? n.modId : `r${n.modId}`) : 'rspace'; + window.open(rspaceNavUrl(space, modPath), '_blank'); + } + }); + + // Touch support + let touchStart: { x: number; y: number; dist: number } | null = null; + this.canvas.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + const t = e.touches[0]; + touchStart = { x: t.clientX, y: t.clientY, dist: this.camDist }; + this.dragRotX = this.rotX; + this.dragRotY = this.rotY; + this.dragging = true; + } else if (e.touches.length === 2) { + const dx = e.touches[1].clientX - e.touches[0].clientX; + const dy = e.touches[1].clientY - e.touches[0].clientY; + touchStart = { x: 0, y: 0, dist: Math.sqrt(dx * dx + dy * dy) }; + } + e.preventDefault(); + }, { passive: false }); + + this.canvas.addEventListener('touchmove', (e) => { + if (!touchStart) return; + if (e.touches.length === 1) { + const t = e.touches[0]; + this.rotY = this.dragRotY + (t.clientX - touchStart.x) * 0.005; + this.rotX = this.dragRotX + (t.clientY - touchStart.y) * 0.005; + this.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotX)); + this.settled = false; + } else if (e.touches.length === 2) { + const dx = e.touches[1].clientX - e.touches[0].clientX; + const dy = e.touches[1].clientY - e.touches[0].clientY; + const pinchDist = Math.sqrt(dx * dx + dy * dy); + const ratio = touchStart.dist / pinchDist; + this.camDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, 500 * ratio)); + this.settled = false; + } + e.preventDefault(); + }, { passive: false }); + + this.canvas.addEventListener('touchend', () => { + touchStart = null; + this.dragging = false; + }); + } + + private onMouseMove = (e: MouseEvent) => { + if (!this.dragging) return; + this.rotY = this.dragRotY + (e.clientX - this.dragStartX) * 0.005; + this.rotX = this.dragRotX + (e.clientY - this.dragStartY) * 0.005; + this.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotX)); + this.settled = false; + }; + + private onMouseUp = () => { + if (this.dragging) { + this.dragging = false; + this.canvas.style.cursor = 'grab'; + } + }; + + // ── Collapse/Expand ── + + private toggleCollapse(spaceNode: Node3D) { + spaceNode.collapsed = !spaceNode.collapsed; + this.settled = false; + this.frameCount = 0; + + for (const n of this.nodes) { + if (n.type === 'module' && n.space === spaceNode.space) { + n.hidden = spaceNode.collapsed; + if (spaceNode.collapsed) { + n.x = spaceNode.x + rand(-5, 5); + n.y = spaceNode.y + rand(-5, 5); + n.z = spaceNode.z + rand(-5, 5); + } + } + if (n.type === 'doc' && n.space === spaceNode.space) { + n.hidden = spaceNode.collapsed; + if (spaceNode.collapsed) { + n.x = spaceNode.x + rand(-3, 3); + n.y = spaceNode.y + rand(-3, 3); + n.z = spaceNode.z + rand(-3, 3); + } + } + } + } + + // ── Tooltip ── + + private showTooltip(n: Node3D) { + const tip = this.shadow.querySelector('.dc-tooltip') as HTMLElement; + if (!tip) return; + + let text = ''; + if (n.type === 'space') { + const docCount = this.nodes.filter(nd => nd.space === n.space && nd.type === 'doc').length; + const modCount = this.nodes.filter(nd => nd.space === n.space && nd.type === 'module').length; + text = `${esc(n.label)}
${modCount} modules, ${docCount} docs
Click to ${n.collapsed ? 'expand' : 'collapse'}`; + } else if (n.type === 'module') { + const docCount = this.nodes.filter(nd => nd.parentId === n.id && nd.type === 'doc').length; + text = `${n.icon} ${esc(n.label)}
${docCount} documents
Click to open`; + } else { + text = `${esc(n.label)}
Click to open in ${n.modId || 'module'}`; + } + + tip.innerHTML = text; + tip.style.display = 'block'; + + let tx = this.tooltipX + 12; + let ty = this.tooltipY - 10; + if (tx + 180 > this.width) tx = this.tooltipX - 180; + if (ty < 10) ty = 10; + tip.style.left = `${tx}px`; + tip.style.top = `${ty}px`; + } + + private hideTooltip() { + const tip = this.shadow.querySelector('.dc-tooltip') as HTMLElement; + if (tip) tip.style.display = 'none'; + } + + // ── Legend ── + + private renderLegend() { + const legend = this.shadow.querySelector('.dc-legend') as HTMLElement; + if (!legend) return; + + 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, color: modColor(doc.modId) }); } - // 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
+ legend.innerHTML = ` +
+ Visibility: + ${[...visLevels].map(v => ` + + + ${esc(v)} + + `).join('')}
- `; - } - - // ── 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(/ + Modules: + ${[...mods.entries()].map(([, m]) => ` + ${m.icon} ${esc(m.name)} + `).join('')} +
+
Drag to orbit · Scroll to zoom · Click space to collapse · Click module/doc to open
`; } } -customElements.define("folk-data-cloud", FolkDataCloud); +// ── Helpers ── + +function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// ── Styles ── + +function styles(): string { + return ` + :host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; } + + .dc { + position: relative; + max-width: 100%; margin: 0 auto; + display: flex; flex-direction: column; align-items: center; + } + + .dc-banner { + width: 100%; text-align: center; padding: 0.5rem; + background: rgba(234, 179, 8, 0.1); border: 1px solid rgba(234, 179, 8, 0.3); + border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 0.5rem; + } + + .dc-loading { + text-align: center; padding: 3rem 1rem; + color: rgba(255,255,255,0.5); font-size: 0.9rem; + } + + .dc-canvas { + display: block; border-radius: 12px; + cursor: grab; max-width: 100%; + background: #0a0a14; + } + + .dc-tooltip { + position: absolute; pointer-events: none; + background: rgba(0, 0, 0, 0.85); color: #fff; + padding: 6px 10px; border-radius: 6px; + font-size: 0.78rem; line-height: 1.4; + max-width: 200px; z-index: 10; + border: 1px solid rgba(255,255,255,0.1); + backdrop-filter: blur(4px); + } + .dc-tooltip b { font-weight: 600; } + .dc-tooltip em { color: rgba(255,255,255,0.5); font-style: normal; font-size: 0.7rem; } + + .dc-legend { + width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem; + justify-content: center; align-items: center; + padding: 0.75rem; margin-top: 0.5rem; + border-top: 1px solid rgba(255,255,255,0.1); + } + .dc-legend__section { + display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; + } + .dc-legend__label { + font-size: 0.7rem; color: rgba(255,255,255,0.4); font-weight: 600; + } + .dc-legend__item { + display: flex; align-items: center; gap: 0.25rem; + font-size: 0.73rem; color: rgba(255,255,255,0.6); + } + .dc-legend__dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + } + .dc-legend__hint { + width: 100%; text-align: center; + font-size: 0.68rem; color: rgba(255,255,255,0.3); margin-top: 0.25rem; + } + + @media (max-width: 500px) { + .dc-legend { gap: 0.4rem; padding: 0.5rem; } + .dc-legend__item { font-size: 0.63rem; } + .dc-legend__hint { font-size: 0.6rem; } + } + `; +} + +customElements.define('folk-data-cloud', FolkDataCloud); diff --git a/modules/rdata/mod.ts b/modules/rdata/mod.ts index 86fb8d5a..7445df6f 100644 --- a/modules/rdata/mod.ts +++ b/modules/rdata/mod.ts @@ -241,7 +241,7 @@ routes.get("/api/content-tree", (c) => { const DATA_TABS = [ { id: "tree", label: "Content Tree", icon: "🌳" }, - { id: "cloud", label: "Cloud", icon: "☁️" }, + { id: "cloud", label: "Data Cloud", icon: "☁️" }, { id: "analytics", label: "Analytics", icon: "📊" }, ] as const; @@ -256,7 +256,7 @@ function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) const scripts = activeTab === "tree" ? `` : activeTab === "cloud" - ? `` + ? `` : ``; return renderShell({ @@ -274,7 +274,7 @@ function renderDataPage(space: string, activeTab: string, isSubdomain: boolean) // ── Page routes ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; - return c.html(renderDataPage(space, "tree", c.get("isSubdomain"))); + return c.html(renderDataPage(space, "cloud", c.get("isSubdomain"))); }); routes.get("/:tabId", (c, next) => { @@ -320,7 +320,7 @@ export const dataModule: RSpaceModule = { ], outputPaths: [ { path: "tree", name: "Content Tree", icon: "🌳", description: "Hierarchical view of all CRDT documents" }, - { path: "cloud", name: "Cloud", icon: "☁️", description: "Tag cloud visualization of content" }, + { path: "cloud", name: "Data Cloud", icon: "☁️", description: "3D force-directed graph of all data objects" }, { path: "analytics", name: "Analytics", icon: "📊", description: "Usage metrics and engagement data" }, ], acceptsFeeds: ["data", "economic"],