rspace-online/modules/rdata/components/folk-data-cloud.ts

1210 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.
* 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 { 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<string, string> = {
private: '#ef4444',
permissioned: '#eab308',
public: '#22c55e',
};
const MOD_COLORS: Record<string, string> = {
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<string, Node3D>();
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
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<typeof setTimeout> | null = null;
// 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 = `<style>${styles()}</style>
<div class="dc">
<div class="dc-loading">Loading your data cloud…</div>
<canvas class="dc-canvas"></canvas>
<div class="dc-tooltip" style="display:none"></div>
<div class="dc-legend"></div>
</div>`;
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';
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<string, { name: string; vis: string; mods: Map<string, DocNode[]> }>();
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<string, string[]>();
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<string, string[]>();
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 + this.camOffsetX;
n.py = cy + y2 * scale + this.camOffsetY;
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);
// 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);
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)));
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;
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;
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;
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<string>();
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);
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') {
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) * focusDim;
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 || 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;
ctx.globalAlpha = nodeAlpha;
ctx.fill();
// Stroke
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;
// 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 * 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 * focusDim;
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;
} 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);
} else {
this.hideTooltip();
}
}
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 = () => {
if (!this.isConnected) return;
this.frameCount++;
if (!this.settled || this.dragging) {
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;
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.dragMoved = false;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragRotX = this.targetRotX;
this.dragRotY = this.targetRotY;
this.canvas.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
this.targetCamDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.targetCamDist + 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;
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';
});
// Single click — focus module, select doc, collapse space, breadcrumb, or unfocus
this.canvas.addEventListener('click', (e) => {
if (this.dragMoved) return;
// 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
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.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;
touchStart = { x: 0, y: 0, dist: Math.sqrt(dx * dx + dy * dy) };
}
e.preventDefault();
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
if (!touchStart) return;
this.dragMoved = true;
if (e.touches.length === 1) {
const t = e.touches[0];
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.targetCamDist = 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;
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;
};
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);
}
}
}
}
// ── 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) {
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 = `<b>${esc(n.label)}</b><br>${modCount} modules, ${docCount} docs<br><em>Click to ${n.collapsed ? 'expand' : 'collapse'}</em>`;
} else if (n.type === 'module') {
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 focus · Double-click to open</em>`;
} else {
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.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<string, { name: string; icon: string; color: string }>();
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<string>();
for (const doc of this.docs) visLevels.add(doc.visibility);
legend.innerHTML = `
<div class="dc-legend__section">
<span class="dc-legend__label">Visibility:</span>
${[...visLevels].map(v => `
<span class="dc-legend__item">
<span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
${esc(v)}
</span>
`).join('')}
</div>
<div class="dc-legend__section">
<span class="dc-legend__label">Modules:</span>
${[...mods.entries()].map(([, m]) => `
<span class="dc-legend__item">${m.icon} ${esc(m.name)}</span>
`).join('')}
</div>
<div class="dc-legend__hint">Drag to orbit · Scroll to zoom · Click module to focus · Double-click to open</div>
`;
}
}
// ── 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── 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-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);