/** * folk-data-cloud — 3D force-directed graph of all data objects across spaces. * * 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'; import { rspaceNavUrl } from '../../../shared/url-helpers'; // ── Types ── interface DocNode { docId: string; title: string; modId: string; modName: string; modIcon: string; tags: string[]; space: string; spaceName: string; visibility: string; } interface Node3D { id: string; label: string; icon: string; type: 'space' | 'module' | 'doc'; modId?: string; space: string; parentId: string | null; color: string; 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 Edge3D { from: string; to: string; color: string; style: 'solid' | 'dotted' | 'faint'; } // ── Constants ── 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', data: '#94a3b8', maps: '#059669', }; 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: '📝', 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 canvas!: HTMLCanvasElement; private ctx!: CanvasRenderingContext2D; private space = 'demo'; private docs: DocNode[] = []; private nodes: Node3D[] = []; private edges: Edge3D[] = []; private nodeMap = new Map(); private loading = true; private isDemo = false; 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' }); } connectedCallback() { 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 || 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.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() { 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, tags: item.tags || [], 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; 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 construction ── private buildGraph() { this.nodes = []; this.edges = []; this.nodeMap.clear(); this.particles = []; // Group docs by space → 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; const spaceSpread = spaceCount > 1 ? 200 : 0; // Create space nodes for (let si = 0; si < spaceCount; si++) { 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; const spaceNode: Node3D = { id: `space:${slug}`, label: sp.name, 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); // Module nodes const modKeys = [...sp.mods.keys()]; for (let mi = 0; mi < modKeys.length; mi++) { const mId = modKeys[mi]; const docs = sp.mods.get(mId)!; const mAngle = (2 * Math.PI * mi / modKeys.length); const mDist = 100 + rand(-15, 15); const modNode: Node3D = { id: `mod:${slug}:${mId}`, label: docs[0].modName, icon: docs[0].modIcon, type: 'module', modId: mId, space: slug, parentId: spaceNode.id, color: modColor(mId), 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: spaceNode.id, to: modNode.id, color: spaceNode.color, style: 'solid' }); // Doc nodes for (let di = 0; di < docs.length; di++) { const doc = docs[di]; const dAngle = (2 * Math.PI * di / docs.length); const dDist = 45 + rand(-10, 10); const docNode: Node3D = { id: `doc:${doc.docId}`, label: doc.title, icon: '', type: 'doc', modId: mId, space: slug, parentId: modNode.id, color: modColor(mId), 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: 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 draw() { const ctx = this.ctx; const w = this.width, h = this.height; ctx.clearRect(0, 0, w, h); // 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); // 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; // 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; // 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); // 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); } } } // 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(); } // 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(); // 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; } } // Tooltip if (this.hoveredNode && !this.dragging) { this.showTooltip(this.hoveredNode); } else { this.hideTooltip(); } } // ── 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) }); } const visLevels = new Set(); for (const doc of this.docs) visLevels.add(doc.visibility); legend.innerHTML = `
Visibility: ${[...visLevels].map(v => ` ${esc(v)} `).join('')}
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
`; } } // ── 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);