From 1ac3eecf44b0f170b2e72afe491f080e870107e6 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 13:41:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(rdata):=20traversible=20Data=20Cloud=20?= =?UTF-8?q?=E2=80=94=20click-to-focus=20graph=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-click module to zoom into cluster with smooth camera animation, revealing doc labels. Double-click to open in new tab. Breadcrumb bar for back-navigation. Non-focused nodes dim to 15% for context. Co-Authored-By: Claude Opus 4.6 --- modules/rdata/components/folk-data-cloud.ts | 312 +++++++++++++++++--- 1 file changed, 264 insertions(+), 48 deletions(-) diff --git a/modules/rdata/components/folk-data-cloud.ts b/modules/rdata/components/folk-data-cloud.ts index 617461a3..01cfb7b3 100644 --- a/modules/rdata/components/folk-data-cloud.ts +++ b/modules/rdata/components/folk-data-cloud.ts @@ -3,8 +3,10 @@ * * 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. + * Click space to collapse/expand. + * Single-click module to focus/zoom into cluster; double-click to open in new tab. + * Single-click doc to select & show detail; double-click to open in new tab. + * Drag to orbit, scroll to zoom. Breadcrumb bar for navigation. */ import { startPresenceHeartbeat } from '../../../shared/collab-presence'; @@ -122,16 +124,32 @@ class FolkDataCloud extends HTMLElement { private camDist = 500; private rotX = 0.3; // pitch private rotY = 0.0; // yaw + private camOffsetX = 0; + private camOffsetY = 0; + + // Camera animation targets + private targetCamDist = 500; + private targetRotX = 0.3; + private targetRotY = 0.0; + private targetOffsetX = 0; + private targetOffsetY = 0; + + // Focus state + private focusedNodeId: string | null = null; + private selectedNodeId: string | null = null; + private breadcrumbRects: { x: number; y: number; w: number; h: number; action: 'all' | 'space' }[] = []; // Interaction private dragging = false; private dragStartX = 0; private dragStartY = 0; + private dragMoved = false; private dragRotX = 0; private dragRotY = 0; private hoveredNode: Node3D | null = null; private tooltipX = 0; private tooltipY = 0; + private clickTimer: ReturnType | null = null; // Animation private animFrame = 0; @@ -155,7 +173,6 @@ class FolkDataCloud extends HTMLElement { this.shadow.innerHTML = `
-
Loading your data cloud…
@@ -254,10 +271,6 @@ class FolkDataCloud extends HTMLElement { 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; @@ -524,8 +537,8 @@ class FolkDataCloud extends HTMLElement { const d = this.camDist + z2; const scale = d > 50 ? FOV / d : FOV / 50; - n.px = cx + x1 * scale; - n.py = cy + y2 * scale; + n.px = cx + x1 * scale + this.camOffsetX; + n.py = cy + y2 * scale + this.camOffsetY; n.pr = n.baseRadius * scale; n.depth = z2; } @@ -545,6 +558,15 @@ class FolkDataCloud extends HTMLElement { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); + // Focus-mode helpers + const focused = this.focusedNodeId; + const focusedNode = focused ? this.nodeMap.get(focused) : null; + const isFocusChild = (n: Node3D) => focused && (n.id === focused || n.parentId === focused); + const isFocusFamily = (n: Node3D) => { + if (!focused || !focusedNode) return false; + return n.id === focused || n.parentId === focused || n.id === focusedNode.parentId; + }; + // Draw edges for (const e of this.edges) { const a = this.nodeMap.get(e.from); @@ -553,7 +575,10 @@ class FolkDataCloud extends HTMLElement { 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; + let alpha = e.style === 'faint' ? depthFade * 0.3 : e.style === 'dotted' ? depthFade * 0.5 : depthFade; + + // Dim non-focused edges + if (focused && !isFocusFamily(a) && !isFocusFamily(b)) alpha *= 0.15; ctx.beginPath(); ctx.strokeStyle = e.color; @@ -577,7 +602,8 @@ class FolkDataCloud extends HTMLElement { 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))); + let depthAlpha = Math.max(0.1, Math.min(0.7, 1 - (avgDepth + this.camDist) / (this.camDist * 2.5))); + if (focused && !isFocusFamily(a) && !isFocusFamily(b)) depthAlpha *= 0.15; ctx.beginPath(); ctx.arc(px, py, 1.5, 0, Math.PI * 2); ctx.fillStyle = p.edge.color; @@ -606,6 +632,9 @@ class FolkDataCloud extends HTMLElement { 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); + const isSelected = this.selectedNodeId === n.id; + const inFocus = isFocusFamily(n); + const focusDim = focused && !inFocus ? 0.15 : 1; // Glow for space nodes if (n.type === 'space') { @@ -613,7 +642,7 @@ class FolkDataCloud extends HTMLElement { glow.addColorStop(0, n.color + '40'); glow.addColorStop(1, n.color + '00'); ctx.fillStyle = glow; - ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6); + ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6) * focusDim; ctx.beginPath(); ctx.arc(n.px, n.py, n.pr * 2.5, 0, Math.PI * 2); ctx.fill(); @@ -621,7 +650,7 @@ class FolkDataCloud extends HTMLElement { // 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)); + const nodeAlpha = depthFade * (isHovered || isSelected ? 1 : (isConnected ? 0.9 : fillAlpha)) * focusDim; ctx.beginPath(); ctx.arc(n.px, n.py, n.pr, 0, Math.PI * 2); ctx.fillStyle = n.color; @@ -629,9 +658,9 @@ class FolkDataCloud extends HTMLElement { 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.strokeStyle = isHovered || isSelected ? '#ffffff' : n.color; + ctx.lineWidth = isHovered || isSelected ? 2.5 : (n.type === 'space' ? 1.8 : 1); + ctx.globalAlpha = depthFade * (isHovered || isSelected ? 1 : 0.8) * focusDim; ctx.stroke(); ctx.globalAlpha = 1; @@ -642,14 +671,14 @@ class FolkDataCloud extends HTMLElement { 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.globalAlpha = depthFade * focusDim; 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.globalAlpha = depthFade * focusDim; ctx.fillStyle = '#ffffff'; ctx.fillText(n.icon, n.px, n.py); if (n.pr > 7) { @@ -659,9 +688,24 @@ class FolkDataCloud extends HTMLElement { } ctx.textBaseline = 'alphabetic'; ctx.globalAlpha = 1; + } else if (n.type === 'doc' && focused && isFocusChild(n)) { + // Show doc title labels when parent module is focused + ctx.font = `500 ${Math.max(8, Math.min(11, n.pr * 0.9))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.globalAlpha = depthFade; + ctx.fillText(truncate(n.label, 18), n.px, n.py + n.pr + 12); + ctx.globalAlpha = 1; } } + // Draw breadcrumb bar when focused + if (focusedNode) { + this.drawBreadcrumb(ctx, focusedNode, w); + } else { + this.breadcrumbRects = []; + } + // Tooltip if (this.hoveredNode && !this.dragging) { this.showTooltip(this.hoveredNode); @@ -670,6 +714,74 @@ class FolkDataCloud extends HTMLElement { } } + private drawBreadcrumb(ctx: CanvasRenderingContext2D, focusedNode: Node3D, w: number) { + this.breadcrumbRects = []; + const barH = 28; + const barY = 8; + const padX = 12; + + // Build segments: ☁ All > {SpaceName} > {ModuleName} + const spaceNode = focusedNode.parentId ? this.nodeMap.get(focusedNode.parentId) : null; + const segments = ['☁ All']; + if (spaceNode) segments.push(spaceNode.label); + segments.push(`${focusedNode.icon} ${focusedNode.label}`); + + // Measure total width + ctx.font = '600 11px system-ui, sans-serif'; + const sepW = ctx.measureText(' › ').width; + const segWidths = segments.map(s => ctx.measureText(s).width); + const totalW = segWidths.reduce((a, b) => a + b, 0) + sepW * (segments.length - 1) + padX * 2; + const barX = (w - totalW) / 2; + + // Background pill + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.globalAlpha = 1; + ctx.beginPath(); + const r = barH / 2; + ctx.moveTo(barX + r, barY); + ctx.lineTo(barX + totalW - r, barY); + ctx.arcTo(barX + totalW, barY, barX + totalW, barY + r, r); + ctx.arcTo(barX + totalW, barY + barH, barX + totalW - r, barY + barH, r); + ctx.lineTo(barX + r, barY + barH); + ctx.arcTo(barX, barY + barH, barX, barY + barH - r, r); + ctx.arcTo(barX, barY, barX + r, barY, r); + ctx.fill(); + + // Border + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Draw text segments + let x = barX + padX; + const textY = barY + barH / 2 + 4; + for (let i = 0; i < segments.length; i++) { + const isClickable = i < segments.length - 1; // All and Space are clickable + const isLast = i === segments.length - 1; + + ctx.fillStyle = isLast ? '#ffffff' : 'rgba(140, 180, 255, 0.9)'; + ctx.globalAlpha = 1; + ctx.font = isLast ? '600 11px system-ui, sans-serif' : '500 11px system-ui, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(segments[i], x, textY); + + // Store clickable rect for hit detection + if (isClickable) { + this.breadcrumbRects.push({ + x, y: barY, w: segWidths[i], h: barH, + action: i === 0 ? 'all' : 'space', + }); + } + + x += segWidths[i]; + if (i < segments.length - 1) { + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillText(' › ', x, textY); + x += sepW; + } + } + } + // ── Animation loop ── private tick = () => { @@ -680,6 +792,22 @@ class FolkDataCloud extends HTMLElement { this.simulate(); } + // Camera lerp (smooth spring animation toward targets) + const LERP = 0.08; + let camMoving = false; + const lerpField = (cur: number, tgt: number): [number, boolean] => { + const delta = tgt - cur; + if (Math.abs(delta) < 0.01) return [tgt, false]; + return [cur + delta * LERP, true]; + }; + let v: number; let m: boolean; + [v, m] = lerpField(this.camDist, this.targetCamDist); this.camDist = v; camMoving ||= m; + [v, m] = lerpField(this.rotX, this.targetRotX); this.rotX = v; camMoving ||= m; + [v, m] = lerpField(this.rotY, this.targetRotY); this.rotY = v; camMoving ||= m; + [v, m] = lerpField(this.camOffsetX, this.targetOffsetX); this.camOffsetX = v; camMoving ||= m; + [v, m] = lerpField(this.camOffsetY, this.targetOffsetY); this.camOffsetY = v; camMoving ||= m; + if (camMoving) this.settled = false; + // Animate particles for (const p of this.particles) { p.t += p.speed; @@ -696,10 +824,11 @@ class FolkDataCloud extends HTMLElement { private attachInteraction() { this.canvas.addEventListener('mousedown', (e) => { this.dragging = true; + this.dragMoved = false; this.dragStartX = e.clientX; this.dragStartY = e.clientY; - this.dragRotX = this.rotX; - this.dragRotY = this.rotY; + this.dragRotX = this.targetRotX; + this.dragRotY = this.targetRotY; this.canvas.style.cursor = 'grabbing'; }); @@ -708,7 +837,7 @@ class FolkDataCloud extends HTMLElement { this.canvas.addEventListener('wheel', (e) => { e.preventDefault(); - this.camDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.camDist + e.deltaY * 0.5)); + this.targetCamDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.targetCamDist + e.deltaY * 0.5)); this.settled = false; }, { passive: false }); @@ -722,7 +851,6 @@ class FolkDataCloud extends HTMLElement { 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) { @@ -738,17 +866,51 @@ class FolkDataCloud extends HTMLElement { this.canvas.style.cursor = found ? 'pointer' : 'grab'; }); + // Single click — focus module, select doc, collapse space, breadcrumb, or unfocus this.canvas.addEventListener('click', (e) => { - if (!this.hoveredNode) return; - const n = this.hoveredNode; + if (this.dragMoved) return; - 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'); + // Check breadcrumb hit first + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + for (const br of this.breadcrumbRects) { + if (mx >= br.x && mx <= br.x + br.w && my >= br.y && my <= br.y + br.h) { + this.unfocus(); + return; + } } + + // Debounce for double-click detection + if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; } + + const n = this.hoveredNode; + this.clickTimer = setTimeout(() => { + this.clickTimer = null; + if (!n) { + // Click empty space → exit focus + if (this.focusedNodeId) this.unfocus(); + return; + } + if (n.type === 'space') { + this.toggleCollapse(n); + } else if (n.type === 'module') { + this.focusOnNode(n); + } else if (n.type === 'doc') { + this.selectedNodeId = this.selectedNodeId === n.id ? null : n.id; + this.settled = false; + } + }, 250); + }); + + // Double click — open in new tab + this.canvas.addEventListener('dblclick', (e) => { + if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; } + const n = this.hoveredNode; + if (!n || n.type === 'space') return; + 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 @@ -757,9 +919,10 @@ class FolkDataCloud extends HTMLElement { 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.dragRotX = this.targetRotX; + this.dragRotY = this.targetRotY; this.dragging = true; + this.dragMoved = 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; @@ -770,18 +933,19 @@ class FolkDataCloud extends HTMLElement { this.canvas.addEventListener('touchmove', (e) => { if (!touchStart) return; + this.dragMoved = true; 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.targetRotY = this.dragRotY + (t.clientX - touchStart.x) * 0.005; + this.targetRotX = this.dragRotX + (t.clientY - touchStart.y) * 0.005; + this.targetRotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.targetRotX)); 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.targetCamDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, 500 * ratio)); this.settled = false; } e.preventDefault(); @@ -795,9 +959,15 @@ class FolkDataCloud extends HTMLElement { 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)); + const dx = e.clientX - this.dragStartX; + const dy = e.clientY - this.dragStartY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.dragMoved = true; + this.targetRotY = this.dragRotY + dx * 0.005; + this.targetRotX = this.dragRotX + dy * 0.005; + this.targetRotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.targetRotX)); + // Snap rotation immediately during drag for responsiveness + this.rotX = this.targetRotX; + this.rotY = this.targetRotY; this.settled = false; }; @@ -835,6 +1005,56 @@ class FolkDataCloud extends HTMLElement { } } + // ── Focus / Unfocus ── + + private focusOnNode(node: Node3D) { + this.focusedNodeId = node.id; + this.selectedNodeId = null; + + // Target zoom in + this.targetCamDist = 250; + + // Compute where node projects at target cam dist + current rotation (without offsets) + const cosX = Math.cos(this.rotX), sinX = Math.sin(this.rotX); + const cosY = Math.cos(this.rotY), sinY = Math.sin(this.rotY); + const x1 = node.x * cosY - node.z * sinY; + const z1 = node.x * sinY + node.z * cosY; + const y2 = node.y * cosX - z1 * sinX; + const z2 = node.y * sinX + z1 * cosX; + const d = this.targetCamDist + z2; + const scale = d > 50 ? FOV / d : FOV / 50; + this.targetOffsetX = -x1 * scale; + this.targetOffsetY = -y2 * scale; + + // Enlarge focused module's doc children + for (const n of this.nodes) { + if (n.type === 'doc') { + n.baseRadius = n.parentId === node.id ? 8 : 5; + } + } + + this.settled = false; + this.frameCount = 0; + } + + private unfocus() { + this.focusedNodeId = null; + this.selectedNodeId = null; + + // Reset camera to defaults + this.targetCamDist = 500; + this.targetOffsetX = 0; + this.targetOffsetY = 0; + + // Restore doc node radii + for (const n of this.nodes) { + if (n.type === 'doc') n.baseRadius = 5; + } + + this.settled = false; + this.frameCount = 0; + } + // ── Tooltip ── private showTooltip(n: Node3D) { @@ -848,9 +1068,11 @@ class FolkDataCloud extends HTMLElement { 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`; + text = `${n.icon} ${esc(n.label)}
${docCount} documents
Click to focus · Double-click to open`; } else { - text = `${esc(n.label)}
Click to open in ${n.modId || 'module'}`; + const tags = this.docs.find(d => `doc:${d.docId}` === n.id)?.tags; + const tagStr = tags?.length ? `
Tags: ${tags.map(t => esc(t)).join(', ')}` : ''; + text = `${esc(n.label)}
${esc(n.modId || 'module')}${tagStr}
Click to select · Double-click to open`; } tip.innerHTML = text; @@ -898,7 +1120,7 @@ class FolkDataCloud extends HTMLElement { ${m.icon} ${esc(m.name)} `).join('')}
-
Drag to orbit · Scroll to zoom · Click space to collapse · Click module/doc to open
+
Drag to orbit · Scroll to zoom · Click module to focus · Double-click to open
`; } } @@ -929,12 +1151,6 @@ function styles(): string { 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;