feat(rdata): traversible Data Cloud — click-to-focus graph navigation
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 <noreply@anthropic.com>
This commit is contained in:
parent
fd7a92b908
commit
1ac3eecf44
|
|
@ -3,8 +3,10 @@
|
||||||
*
|
*
|
||||||
* Canvas 2D with perspective-projected 3D force simulation.
|
* Canvas 2D with perspective-projected 3D force simulation.
|
||||||
* Three-tier hierarchy: Space → Module → Document nodes.
|
* Three-tier hierarchy: Space → Module → Document nodes.
|
||||||
* Click space to collapse/expand, click module/doc to open in new tab.
|
* Click space to collapse/expand.
|
||||||
* Drag to orbit, scroll to zoom.
|
* 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';
|
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||||
|
|
@ -122,16 +124,32 @@ class FolkDataCloud extends HTMLElement {
|
||||||
private camDist = 500;
|
private camDist = 500;
|
||||||
private rotX = 0.3; // pitch
|
private rotX = 0.3; // pitch
|
||||||
private rotY = 0.0; // yaw
|
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
|
// Interaction
|
||||||
private dragging = false;
|
private dragging = false;
|
||||||
private dragStartX = 0;
|
private dragStartX = 0;
|
||||||
private dragStartY = 0;
|
private dragStartY = 0;
|
||||||
|
private dragMoved = false;
|
||||||
private dragRotX = 0;
|
private dragRotX = 0;
|
||||||
private dragRotY = 0;
|
private dragRotY = 0;
|
||||||
private hoveredNode: Node3D | null = null;
|
private hoveredNode: Node3D | null = null;
|
||||||
private tooltipX = 0;
|
private tooltipX = 0;
|
||||||
private tooltipY = 0;
|
private tooltipY = 0;
|
||||||
|
private clickTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Animation
|
// Animation
|
||||||
private animFrame = 0;
|
private animFrame = 0;
|
||||||
|
|
@ -155,7 +173,6 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
this.shadow.innerHTML = `<style>${styles()}</style>
|
this.shadow.innerHTML = `<style>${styles()}</style>
|
||||||
<div class="dc">
|
<div class="dc">
|
||||||
<div class="dc-banner" style="display:none">Sign in to see your data cloud</div>
|
|
||||||
<div class="dc-loading">Loading your data cloud…</div>
|
<div class="dc-loading">Loading your data cloud…</div>
|
||||||
<canvas class="dc-canvas"></canvas>
|
<canvas class="dc-canvas"></canvas>
|
||||||
<div class="dc-tooltip" style="display:none"></div>
|
<div class="dc-tooltip" style="display:none"></div>
|
||||||
|
|
@ -254,10 +271,6 @@ class FolkDataCloud extends HTMLElement {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
const loadingEl = this.shadow.querySelector('.dc-loading') as HTMLElement;
|
const loadingEl = this.shadow.querySelector('.dc-loading') as HTMLElement;
|
||||||
if (loadingEl) loadingEl.style.display = 'none';
|
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.buildGraph();
|
||||||
this.renderLegend();
|
this.renderLegend();
|
||||||
this.settled = false;
|
this.settled = false;
|
||||||
|
|
@ -524,8 +537,8 @@ class FolkDataCloud extends HTMLElement {
|
||||||
const d = this.camDist + z2;
|
const d = this.camDist + z2;
|
||||||
const scale = d > 50 ? FOV / d : FOV / 50;
|
const scale = d > 50 ? FOV / d : FOV / 50;
|
||||||
|
|
||||||
n.px = cx + x1 * scale;
|
n.px = cx + x1 * scale + this.camOffsetX;
|
||||||
n.py = cy + y2 * scale;
|
n.py = cy + y2 * scale + this.camOffsetY;
|
||||||
n.pr = n.baseRadius * scale;
|
n.pr = n.baseRadius * scale;
|
||||||
n.depth = z2;
|
n.depth = z2;
|
||||||
}
|
}
|
||||||
|
|
@ -545,6 +558,15 @@ class FolkDataCloud extends HTMLElement {
|
||||||
ctx.fillStyle = bg;
|
ctx.fillStyle = bg;
|
||||||
ctx.fillRect(0, 0, w, h);
|
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
|
// Draw edges
|
||||||
for (const e of this.edges) {
|
for (const e of this.edges) {
|
||||||
const a = this.nodeMap.get(e.from);
|
const a = this.nodeMap.get(e.from);
|
||||||
|
|
@ -553,7 +575,10 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
const avgDepth = (a.depth + b.depth) / 2;
|
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 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.beginPath();
|
||||||
ctx.strokeStyle = e.color;
|
ctx.strokeStyle = e.color;
|
||||||
|
|
@ -577,7 +602,8 @@ class FolkDataCloud extends HTMLElement {
|
||||||
const px = a.px + (b.px - a.px) * p.t;
|
const px = a.px + (b.px - a.px) * p.t;
|
||||||
const py = a.py + (b.py - a.py) * p.t;
|
const py = a.py + (b.py - a.py) * p.t;
|
||||||
const avgDepth = (a.depth + b.depth) / 2;
|
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.beginPath();
|
||||||
ctx.arc(px, py, 1.5, 0, Math.PI * 2);
|
ctx.arc(px, py, 1.5, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = p.edge.color;
|
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 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 isHovered = this.hoveredNode?.id === n.id;
|
||||||
const isConnected = this.hoveredNode && hoveredEdges.has(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
|
// Glow for space nodes
|
||||||
if (n.type === 'space') {
|
if (n.type === 'space') {
|
||||||
|
|
@ -613,7 +642,7 @@ class FolkDataCloud extends HTMLElement {
|
||||||
glow.addColorStop(0, n.color + '40');
|
glow.addColorStop(0, n.color + '40');
|
||||||
glow.addColorStop(1, n.color + '00');
|
glow.addColorStop(1, n.color + '00');
|
||||||
ctx.fillStyle = glow;
|
ctx.fillStyle = glow;
|
||||||
ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6);
|
ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6) * focusDim;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(n.px, n.py, n.pr * 2.5, 0, Math.PI * 2);
|
ctx.arc(n.px, n.py, n.pr * 2.5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
@ -621,7 +650,7 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
// Node circle
|
// Node circle
|
||||||
const fillAlpha = n.type === 'doc' ? 0.6 : n.type === 'module' ? 0.5 : 0.4;
|
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.beginPath();
|
||||||
ctx.arc(n.px, n.py, n.pr, 0, Math.PI * 2);
|
ctx.arc(n.px, n.py, n.pr, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = n.color;
|
ctx.fillStyle = n.color;
|
||||||
|
|
@ -629,9 +658,9 @@ class FolkDataCloud extends HTMLElement {
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Stroke
|
// Stroke
|
||||||
ctx.strokeStyle = isHovered ? '#ffffff' : n.color;
|
ctx.strokeStyle = isHovered || isSelected ? '#ffffff' : n.color;
|
||||||
ctx.lineWidth = isHovered ? 2.5 : (n.type === 'space' ? 1.8 : 1);
|
ctx.lineWidth = isHovered || isSelected ? 2.5 : (n.type === 'space' ? 1.8 : 1);
|
||||||
ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.8);
|
ctx.globalAlpha = depthFade * (isHovered || isSelected ? 1 : 0.8) * focusDim;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
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.font = `600 ${Math.max(9, Math.min(12, n.pr * 0.7))}px system-ui, sans-serif`;
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.globalAlpha = depthFade;
|
ctx.globalAlpha = depthFade * focusDim;
|
||||||
ctx.fillText(label, n.px, n.py + n.pr + 14);
|
ctx.fillText(label, n.px, n.py + n.pr + 14);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
} else if (n.type === 'module' && n.pr > 4) {
|
} 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.font = `${Math.max(8, Math.min(14, n.pr * 1.1))}px system-ui, sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.globalAlpha = depthFade;
|
ctx.globalAlpha = depthFade * focusDim;
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillText(n.icon, n.px, n.py);
|
ctx.fillText(n.icon, n.px, n.py);
|
||||||
if (n.pr > 7) {
|
if (n.pr > 7) {
|
||||||
|
|
@ -659,9 +688,24 @@ class FolkDataCloud extends HTMLElement {
|
||||||
}
|
}
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
ctx.globalAlpha = 1;
|
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
|
// Tooltip
|
||||||
if (this.hoveredNode && !this.dragging) {
|
if (this.hoveredNode && !this.dragging) {
|
||||||
this.showTooltip(this.hoveredNode);
|
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 ──
|
// ── Animation loop ──
|
||||||
|
|
||||||
private tick = () => {
|
private tick = () => {
|
||||||
|
|
@ -680,6 +792,22 @@ class FolkDataCloud extends HTMLElement {
|
||||||
this.simulate();
|
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
|
// Animate particles
|
||||||
for (const p of this.particles) {
|
for (const p of this.particles) {
|
||||||
p.t += p.speed;
|
p.t += p.speed;
|
||||||
|
|
@ -696,10 +824,11 @@ class FolkDataCloud extends HTMLElement {
|
||||||
private attachInteraction() {
|
private attachInteraction() {
|
||||||
this.canvas.addEventListener('mousedown', (e) => {
|
this.canvas.addEventListener('mousedown', (e) => {
|
||||||
this.dragging = true;
|
this.dragging = true;
|
||||||
|
this.dragMoved = false;
|
||||||
this.dragStartX = e.clientX;
|
this.dragStartX = e.clientX;
|
||||||
this.dragStartY = e.clientY;
|
this.dragStartY = e.clientY;
|
||||||
this.dragRotX = this.rotX;
|
this.dragRotX = this.targetRotX;
|
||||||
this.dragRotY = this.rotY;
|
this.dragRotY = this.targetRotY;
|
||||||
this.canvas.style.cursor = 'grabbing';
|
this.canvas.style.cursor = 'grabbing';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -708,7 +837,7 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
this.canvas.addEventListener('wheel', (e) => {
|
this.canvas.addEventListener('wheel', (e) => {
|
||||||
e.preventDefault();
|
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;
|
this.settled = false;
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
|
@ -722,7 +851,6 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
let found: Node3D | null = null;
|
let found: Node3D | null = null;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
// Check in reverse depth order (front first)
|
|
||||||
const visible = this.nodes.filter(n => !n.hidden);
|
const visible = this.nodes.filter(n => !n.hidden);
|
||||||
visible.sort((a, b) => a.depth - b.depth);
|
visible.sort((a, b) => a.depth - b.depth);
|
||||||
for (const n of visible) {
|
for (const n of visible) {
|
||||||
|
|
@ -738,17 +866,51 @@ class FolkDataCloud extends HTMLElement {
|
||||||
this.canvas.style.cursor = found ? 'pointer' : 'grab';
|
this.canvas.style.cursor = found ? 'pointer' : 'grab';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single click — focus module, select doc, collapse space, breadcrumb, or unfocus
|
||||||
this.canvas.addEventListener('click', (e) => {
|
this.canvas.addEventListener('click', (e) => {
|
||||||
if (!this.hoveredNode) return;
|
if (this.dragMoved) return;
|
||||||
const n = this.hoveredNode;
|
|
||||||
|
|
||||||
if (n.type === 'space') {
|
// Check breadcrumb hit first
|
||||||
this.toggleCollapse(n);
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
} else {
|
const mx = e.clientX - rect.left;
|
||||||
const space = n.space || this.space;
|
const my = e.clientY - rect.top;
|
||||||
const modPath = n.modId ? (n.modId.startsWith('r') ? n.modId : `r${n.modId}`) : 'rspace';
|
for (const br of this.breadcrumbRects) {
|
||||||
window.open(rspaceNavUrl(space, modPath), '_blank');
|
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
|
// Touch support
|
||||||
|
|
@ -757,9 +919,10 @@ class FolkDataCloud extends HTMLElement {
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
touchStart = { x: t.clientX, y: t.clientY, dist: this.camDist };
|
touchStart = { x: t.clientX, y: t.clientY, dist: this.camDist };
|
||||||
this.dragRotX = this.rotX;
|
this.dragRotX = this.targetRotX;
|
||||||
this.dragRotY = this.rotY;
|
this.dragRotY = this.targetRotY;
|
||||||
this.dragging = true;
|
this.dragging = true;
|
||||||
|
this.dragMoved = false;
|
||||||
} else if (e.touches.length === 2) {
|
} else if (e.touches.length === 2) {
|
||||||
const dx = e.touches[1].clientX - e.touches[0].clientX;
|
const dx = e.touches[1].clientX - e.touches[0].clientX;
|
||||||
const dy = e.touches[1].clientY - e.touches[0].clientY;
|
const dy = e.touches[1].clientY - e.touches[0].clientY;
|
||||||
|
|
@ -770,18 +933,19 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
this.canvas.addEventListener('touchmove', (e) => {
|
this.canvas.addEventListener('touchmove', (e) => {
|
||||||
if (!touchStart) return;
|
if (!touchStart) return;
|
||||||
|
this.dragMoved = true;
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
this.rotY = this.dragRotY + (t.clientX - touchStart.x) * 0.005;
|
this.targetRotY = this.dragRotY + (t.clientX - touchStart.x) * 0.005;
|
||||||
this.rotX = this.dragRotX + (t.clientY - touchStart.y) * 0.005;
|
this.targetRotX = this.dragRotX + (t.clientY - touchStart.y) * 0.005;
|
||||||
this.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotX));
|
this.targetRotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.targetRotX));
|
||||||
this.settled = false;
|
this.settled = false;
|
||||||
} else if (e.touches.length === 2) {
|
} else if (e.touches.length === 2) {
|
||||||
const dx = e.touches[1].clientX - e.touches[0].clientX;
|
const dx = e.touches[1].clientX - e.touches[0].clientX;
|
||||||
const dy = e.touches[1].clientY - e.touches[0].clientY;
|
const dy = e.touches[1].clientY - e.touches[0].clientY;
|
||||||
const pinchDist = Math.sqrt(dx * dx + dy * dy);
|
const pinchDist = Math.sqrt(dx * dx + dy * dy);
|
||||||
const ratio = touchStart.dist / pinchDist;
|
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;
|
this.settled = false;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -795,9 +959,15 @@ class FolkDataCloud extends HTMLElement {
|
||||||
|
|
||||||
private onMouseMove = (e: MouseEvent) => {
|
private onMouseMove = (e: MouseEvent) => {
|
||||||
if (!this.dragging) return;
|
if (!this.dragging) return;
|
||||||
this.rotY = this.dragRotY + (e.clientX - this.dragStartX) * 0.005;
|
const dx = e.clientX - this.dragStartX;
|
||||||
this.rotX = this.dragRotX + (e.clientY - this.dragStartY) * 0.005;
|
const dy = e.clientY - this.dragStartY;
|
||||||
this.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.rotX));
|
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;
|
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 ──
|
// ── Tooltip ──
|
||||||
|
|
||||||
private showTooltip(n: Node3D) {
|
private showTooltip(n: Node3D) {
|
||||||
|
|
@ -848,9 +1068,11 @@ class FolkDataCloud extends HTMLElement {
|
||||||
text = `<b>${esc(n.label)}</b><br>${modCount} modules, ${docCount} docs<br><em>Click to ${n.collapsed ? 'expand' : 'collapse'}</em>`;
|
text = `<b>${esc(n.label)}</b><br>${modCount} modules, ${docCount} docs<br><em>Click to ${n.collapsed ? 'expand' : 'collapse'}</em>`;
|
||||||
} else if (n.type === 'module') {
|
} else if (n.type === 'module') {
|
||||||
const docCount = this.nodes.filter(nd => nd.parentId === n.id && nd.type === 'doc').length;
|
const docCount = this.nodes.filter(nd => nd.parentId === n.id && nd.type === 'doc').length;
|
||||||
text = `<b>${n.icon} ${esc(n.label)}</b><br>${docCount} documents<br><em>Click to open</em>`;
|
text = `<b>${n.icon} ${esc(n.label)}</b><br>${docCount} documents<br><em>Click to focus · Double-click to open</em>`;
|
||||||
} else {
|
} else {
|
||||||
text = `<b>${esc(n.label)}</b><br><em>Click to open in ${n.modId || 'module'}</em>`;
|
const tags = this.docs.find(d => `doc:${d.docId}` === n.id)?.tags;
|
||||||
|
const tagStr = tags?.length ? `<br>Tags: ${tags.map(t => esc(t)).join(', ')}` : '';
|
||||||
|
text = `<b>${esc(n.label)}</b><br>${esc(n.modId || 'module')}${tagStr}<br><em>Click to select · Double-click to open</em>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
tip.innerHTML = text;
|
tip.innerHTML = text;
|
||||||
|
|
@ -898,7 +1120,7 @@ class FolkDataCloud extends HTMLElement {
|
||||||
<span class="dc-legend__item">${m.icon} ${esc(m.name)}</span>
|
<span class="dc-legend__item">${m.icon} ${esc(m.name)}</span>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="dc-legend__hint">Drag to orbit · Scroll to zoom · Click space to collapse · Click module/doc to open</div>
|
<div class="dc-legend__hint">Drag to orbit · Scroll to zoom · Click module to focus · Double-click to open</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -929,12 +1151,6 @@ function styles(): string {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
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 {
|
.dc-loading {
|
||||||
text-align: center; padding: 3rem 1rem;
|
text-align: center; padding: 3rem 1rem;
|
||||||
color: rgba(255,255,255,0.5); font-size: 0.9rem;
|
color: rgba(255,255,255,0.5); font-size: 0.9rem;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue