994 lines
31 KiB
TypeScript
994 lines
31 KiB
TypeScript
/**
|
|
* 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<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
|
|
|
|
// 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 = `<style>${styles()}</style>
|
|
<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>
|
|
<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';
|
|
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<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;
|
|
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<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);
|
|
|
|
// 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 = `<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 open</em>`;
|
|
} else {
|
|
text = `<b>${esc(n.label)}</b><br><em>Click to open in ${n.modId || 'module'}</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 space to collapse · Click module/doc 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, '&').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);
|