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

542 lines
18 KiB
TypeScript

/**
* folk-data-cloud — Graph visualization of all data objects (documents)
* across the user's spaces. Nodes represent individual documents,
* grouped radially by module around a central space node.
*
* Click any node → opens that module in a new tab.
* Demo mode shows dummy document nodes when unauthenticated.
*/
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;
space: string;
spaceName: string;
visibility: string;
}
interface GraphNode {
id: string;
label: string;
icon: string;
type: "space" | "module" | "doc";
modId?: string;
space?: string;
color: string;
x: number;
y: number;
r: number;
}
interface GraphEdge {
from: string;
to: string;
color: string;
}
// ── Colors ──
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",
};
function modColor(modId: string): string {
return MOD_COLORS[modId] || "#94a3b8";
}
// ── Demo data ──
const DEMO_DOCS: DocNode[] = [
{ docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:notes:notebooks:nb3", title: "Research Log", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:vote:proposals:p1", title: "Dark mode proposal", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:cal:calendars:c2", title: "Personal", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", modId: "wallet", modName: "rWallet", modIcon: "💰", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:docs:notebooks:d1", title: "Onboarding Guide", modId: "docs", modName: "rDocs", modIcon: "📓", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:pubs:pages:pub1", title: "Launch Announcement", modId: "pubs", modName: "rPubs", modIcon: "📰", space: "demo", spaceName: "Demo Space", visibility: "public" },
];
// ── Component ──
class FolkDataCloud extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private docs: DocNode[] = [];
private nodes: GraphNode[] = [];
private edges: GraphEdge[] = [];
private loading = true;
private isDemo = false;
private hoveredId: string | null = null;
private width = 700;
private height = 700;
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._resizeObserver = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width || 700;
this.width = Math.min(w, 900);
this.height = Math.max(this.width * 0.85, 500);
if (!this.loading) { this.layout(); this.render(); }
});
this._resizeObserver.observe(this);
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
this.loadData();
}
disconnectedCallback() {
this._stopPresence?.();
this._resizeObserver?.disconnect();
}
// ── Data loading ──
private async loadData() {
this.loading = true;
this.render();
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,
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;
this.layout();
this.render();
}
// ── Graph layout ──
// Central node per space, module nodes around it, doc nodes orbiting modules.
private layout() {
this.nodes = [];
this.edges = [];
const cx = this.width / 2;
const cy = this.height / 2;
const mobile = this.width < 500;
// Group docs by space, then by 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;
// Single space → center layout. Multiple → distribute around center.
const spaceR = mobile ? 18 : 24;
const modR = mobile ? 12 : 16;
const docR = mobile ? 6 : 8;
const orbitMod = mobile ? 70 : 100; // module distance from space center
const orbitDoc = mobile ? 28 : 38; // doc distance from module center
for (let si = 0; si < spaceCount; si++) {
const spaceSlug = spaceKeys[si];
const sp = spaceMap.get(spaceSlug)!;
const visColor = VIS_COLORS[sp.vis] || VIS_COLORS.private;
// Space position
let sx: number, sy: number;
if (spaceCount === 1) {
sx = cx; sy = cy;
} else {
const spaceOrbit = Math.min(this.width, this.height) * 0.3;
const spAngle = (2 * Math.PI * si / spaceCount) - Math.PI / 2;
sx = cx + spaceOrbit * Math.cos(spAngle);
sy = cy + spaceOrbit * Math.sin(spAngle);
}
const spaceNodeId = `space:${spaceSlug}`;
this.nodes.push({
id: spaceNodeId,
label: sp.name,
icon: "",
type: "space",
space: spaceSlug,
color: visColor,
x: sx, y: sy, r: spaceR,
});
// Modules around space
const modKeys = [...sp.mods.keys()];
const modCount = modKeys.length;
const actualModOrbit = Math.min(orbitMod, (spaceCount === 1 ? orbitMod * 1.5 : orbitMod));
for (let mi = 0; mi < modCount; mi++) {
const mId = modKeys[mi];
const docs = sp.mods.get(mId)!;
const firstDoc = docs[0];
const mAngle = (2 * Math.PI * mi / modCount) - Math.PI / 2;
const mx = sx + actualModOrbit * Math.cos(mAngle);
const my = sy + actualModOrbit * Math.sin(mAngle);
const modNodeId = `mod:${spaceSlug}:${mId}`;
this.nodes.push({
id: modNodeId,
label: firstDoc.modName,
icon: firstDoc.modIcon,
type: "module",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: mx, y: my, r: modR,
});
this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor });
// Doc nodes around module
for (let di = 0; di < docs.length; di++) {
const doc = docs[di];
const dAngle = (2 * Math.PI * di / docs.length) - Math.PI / 2;
// Offset by module angle to spread outward
const dx = mx + orbitDoc * Math.cos(dAngle);
const dy = my + orbitDoc * Math.sin(dAngle);
const docNodeId = `doc:${doc.docId}`;
this.nodes.push({
id: docNodeId,
label: doc.title,
icon: "",
type: "doc",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: dx, y: dy, r: docR,
});
this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) });
}
}
}
}
// ── Rendering ──
private render() {
this.shadow.innerHTML = `
<style>${this.styles()}</style>
<div class="dc">
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
${this.loading ? this.renderLoading() : this.renderGraph()}
${!this.loading ? this.renderLegend() : ""}
</div>
`;
this.attachEvents();
}
private renderLoading(): string {
return `
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
<text x="${this.width / 2}" y="${this.height / 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud…</text>
</svg>
`;
}
private renderGraph(): string {
if (this.nodes.length === 0) {
return `<div class="dc-empty">No data objects found</div>`;
}
const mobile = this.width < 500;
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
// Edges first (behind nodes)
for (const edge of this.edges) {
const from = this.nodes.find(n => n.id === edge.from);
const to = this.nodes.find(n => n.id === edge.to);
if (!from || !to) continue;
svg += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"
stroke="${edge.color}" stroke-width="1" opacity="0.2"/>`;
}
// Nodes
for (const node of this.nodes) {
const isHovered = this.hoveredId === node.id;
const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2);
const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15);
const hoverOpacity = isHovered ? 0.8 : fillOpacity;
svg += `<g class="dc-node" data-id="${this.escAttr(node.id)}" data-space="${this.escAttr(node.space || "")}" data-mod="${this.escAttr(node.modId || "")}" style="cursor:pointer">`;
// Circle
svg += `<circle cx="${node.x}" cy="${node.y}" r="${node.r}"
fill="${node.color}" fill-opacity="${hoverOpacity}"
stroke="${node.color}" stroke-width="${strokeW}"/>`;
// Labels
if (node.type === "space") {
const label = mobile ? node.label.slice(0, 8) : (node.label.length > 14 ? node.label.slice(0, 13) + "…" : node.label);
svg += `<text x="${node.x}" y="${node.y + node.r + 14}" text-anchor="middle"
fill="var(--rs-text-primary)" font-size="${mobile ? 9 : 11}" font-weight="600"
pointer-events="none">${this.esc(label)}</text>`;
} else if (node.type === "module") {
svg += `<text x="${node.x}" y="${node.y + 1}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 10 : 12}"
pointer-events="none">${node.icon}</text>`;
if (!mobile) {
svg += `<text x="${node.x}" y="${node.y + node.r + 12}" text-anchor="middle"
fill="var(--rs-text-secondary)" font-size="9" pointer-events="none">${this.esc(node.label)}</text>`;
}
} else {
// Doc — show label on hover via title
}
// Tooltip
const tooltipText = node.type === "space"
? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)`
: node.type === "module"
? `${node.label} — click to open in new tab`
: `${node.label} — click to open in new tab`;
svg += `<title>${this.esc(tooltipText)}</title>`;
svg += `</g>`;
}
svg += `</svg>`;
return svg;
}
private renderLegend(): string {
// Collect unique modules present
const mods = new Map<string, { name: string; icon: string }>();
for (const doc of this.docs) {
if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon });
}
// Collect unique visibility levels
const visLevels = new Set<string>();
for (const doc of this.docs) visLevels.add(doc.visibility);
return `
<div class="dc-legend">
<div class="dc-legend__section">
${[...visLevels].map(v => `
<span class="dc-legend__item">
<span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
${this.esc(v)}
</span>
`).join("")}
</div>
<div class="dc-legend__section">
${[...mods.entries()].map(([, m]) => `
<span class="dc-legend__item">${m.icon} ${this.esc(m.name)}</span>
`).join("")}
</div>
<div class="dc-legend__hint">Click any node to open in new tab</div>
</div>
`;
}
// ── Events ──
private attachEvents() {
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-node")) {
const nodeId = g.dataset.id!;
const space = g.dataset.space || "";
const modId = g.dataset.mod || "";
g.addEventListener("click", () => {
if (!space) return;
const modPath = modId
? (modId.startsWith("r") ? modId : `r${modId}`)
: "rspace";
window.open(rspaceNavUrl(space, modPath), "_blank");
});
g.addEventListener("mouseenter", () => {
this.hoveredId = nodeId;
const circle = g.querySelector("circle") as SVGCircleElement;
if (circle) {
circle.setAttribute("stroke-width", "2.5");
circle.setAttribute("fill-opacity", "0.8");
}
// Highlight connected edges
const connectedEdges = this.edges.filter(e => e.from === nodeId || e.to === nodeId);
for (const edge of connectedEdges) {
const lines = this.shadow.querySelectorAll<SVGLineElement>("line");
for (const line of lines) {
const fromNode = this.nodes.find(n => n.id === edge.from);
const toNode = this.nodes.find(n => n.id === edge.to);
if (!fromNode || !toNode) continue;
if (Math.abs(parseFloat(line.getAttribute("x1")!) - fromNode.x) < 1 &&
Math.abs(parseFloat(line.getAttribute("y1")!) - fromNode.y) < 1) {
line.setAttribute("opacity", "0.6");
line.setAttribute("stroke-width", "2");
}
}
}
});
g.addEventListener("mouseleave", () => {
this.hoveredId = null;
const circle = g.querySelector("circle") as SVGCircleElement;
if (circle) {
const node = this.nodes.find(n => n.id === nodeId);
if (node) {
circle.setAttribute("stroke-width", node.type === "space" ? "2" : "1.2");
const fo = node.type === "doc" ? "0.5" : (node.type === "module" ? "0.3" : "0.15");
circle.setAttribute("fill-opacity", fo);
}
}
// Reset edges
for (const line of this.shadow.querySelectorAll<SVGLineElement>("line")) {
line.setAttribute("opacity", "0.2");
line.setAttribute("stroke-width", "1");
}
});
}
}
// ── Helpers ──
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
private styles(): string {
return `
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); }
.dc { max-width: 900px; 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: 1rem;
}
.dc-empty {
text-align: center; padding: 3rem 1rem;
color: var(--rs-text-muted); font-size: 0.9rem;
}
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
/* Legend */
.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 var(--rs-border);
}
.dc-legend__section {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
}
.dc-legend__item {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.75rem; color: var(--rs-text-secondary);
}
.dc-legend__dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dc-legend__hint {
width: 100%; text-align: center;
font-size: 0.7rem; color: var(--rs-text-muted); margin-top: 0.25rem;
}
@media (max-width: 500px) {
.dc-legend { gap: 0.4rem; padding: 0.5rem; }
.dc-legend__item { font-size: 0.65rem; }
}
`;
}
}
customElements.define("folk-data-cloud", FolkDataCloud);