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

437 lines
16 KiB
TypeScript

/**
* folk-data-cloud — Concentric-ring SVG visualization of data objects
* across user spaces, grouped by visibility level (private/permissioned/public).
*
* Two-level interaction: click space bubble → detail panel with modules,
* click module row → navigate to that module page.
*/
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
interface SpaceInfo {
slug: string;
name: string;
visibility: string;
role?: string;
relationship?: string;
}
interface ModuleSummary {
id: string;
name: string;
icon: string;
docCount: number;
}
interface SpaceBubble extends SpaceInfo {
docCount: number;
modules: ModuleSummary[];
}
type Ring = "private" | "permissioned" | "public";
const RING_CONFIG: Record<Ring, { color: string; label: string; radius: number }> = {
private: { color: "#ef4444", label: "Private", radius: 0.28 },
permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 },
public: { color: "#22c55e", label: "Public", radius: 0.80 },
};
const RINGS: Ring[] = ["private", "permissioned", "public"];
const DEMO_SPACES: SpaceBubble[] = [
{ slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [
{ id: "notes", name: "rNotes", icon: "📝", docCount: 5 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 2 },
]},
{ slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 3 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
]},
{ slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 6 },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 4 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 3 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 4 },
]},
{ slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [
{ id: "vote", name: "rVote", icon: "🗳", docCount: 7 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 4 },
]},
{ slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [
{ id: "notes", name: "rNotes", icon: "📝", docCount: 3 },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 2 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 1 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 5 },
]},
{ slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 4 },
{ id: "pubs", name: "rPubs", icon: "📰", docCount: 5 },
]},
];
class FolkDataCloud extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private spaces: SpaceBubble[] = [];
private loading = true;
private isDemo = false;
private selected: string | null = null;
private hoveredSlug: string | null = null;
private width = 600;
private height = 600;
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 || 600;
this.width = Math.min(w, 800);
this.height = this.width;
if (!this.loading) this.render();
});
this._resizeObserver.observe(this);
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
this.loadData();
}
disconnectedCallback() {
this._stopPresence?.();
this._resizeObserver?.disconnect();
}
private async loadData() {
this.loading = true;
this.render();
const token = localStorage.getItem("rspace_auth");
if (!token) {
this.isDemo = true;
this.spaces = DEMO_SPACES;
this.loading = false;
this.render();
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 spacesData: { spaces: SpaceInfo[] } = await spacesResp.json();
// Fetch content-tree for each space in parallel
const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, "");
const bubbles: SpaceBubble[] = await Promise.all(
spacesData.spaces.map(async (sp) => {
try {
const treeResp = await fetch(
`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
{ signal: AbortSignal.timeout(8000) }
);
if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] };
const tree = await treeResp.json();
const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({
id: m.id,
name: m.name,
icon: m.icon,
docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0),
}));
const docCount = modules.reduce((s, m) => s + m.docCount, 0);
return { ...sp, docCount, modules };
} catch {
return { ...sp, docCount: 0, modules: [] };
}
})
);
this.spaces = bubbles;
this.isDemo = false;
} catch {
this.isDemo = true;
this.spaces = DEMO_SPACES;
}
this.loading = false;
this.render();
}
private groupByRing(): Record<Ring, SpaceBubble[]> {
const groups: Record<Ring, SpaceBubble[]> = { private: [], permissioned: [], public: [] };
for (const sp of this.spaces) {
const ring = (sp.visibility as Ring) || "private";
(groups[ring] || groups.private).push(sp);
}
return groups;
}
private isMobile(): boolean {
return this.width < 500;
}
private render() {
const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null;
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.renderSVG()}
${selected ? this.renderDetailPanel(selected) : ""}
</div>
`;
this.attachEvents();
}
private renderLoading(): string {
const cx = this.width / 2;
const cy = this.height / 2;
return `
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
${RINGS.map(ring => {
const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9;
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="var(--rs-border)" stroke-width="1" stroke-dasharray="4 4" opacity="0.3"/>`;
}).join("")}
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud…</text>
</svg>
`;
}
private renderSVG(): string {
const groups = this.groupByRing();
const cx = this.width / 2;
const cy = this.height / 2;
const scale = (this.width / 2) * 0.9;
const mobile = this.isMobile();
const bubbleR = mobile ? 20 : 28;
const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount));
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
// Render rings (outer to inner so inner draws on top)
for (const ring of [...RINGS].reverse()) {
const cfg = RING_CONFIG[ring];
const r = cfg.radius * scale;
svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="${cfg.color}" stroke-width="1.5" stroke-dasharray="6 4" opacity="0.4"/>`;
// Ring label at top
const labelY = cy - r - 8;
svg += `<text x="${cx}" y="${labelY}" text-anchor="middle" fill="${cfg.color}"
font-size="${mobile ? 10 : 12}" font-weight="600" opacity="0.7">${cfg.label}</text>`;
}
// Render bubbles per ring
for (const ring of RINGS) {
const cfg = RING_CONFIG[ring];
const ringR = cfg.radius * scale;
const ringSpaces = groups[ring];
if (ringSpaces.length === 0) continue;
const angleStep = (2 * Math.PI) / ringSpaces.length;
const startAngle = -Math.PI / 2; // Start from top
for (let i = 0; i < ringSpaces.length; i++) {
const sp = ringSpaces[i];
const angle = startAngle + i * angleStep;
const bx = cx + ringR * Math.cos(angle);
const by = cy + ringR * Math.sin(angle);
// Scale bubble size by doc count (min 60%, max 100%)
const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount);
const r = bubbleR * sizeScale;
const isSelected = this.selected === sp.slug;
const isHovered = this.hoveredSlug === sp.slug;
const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5);
const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1);
// Bubble circle
svg += `<g class="dc-bubble" data-slug="${this.escAttr(sp.slug)}" style="cursor:pointer">`;
if (isSelected) {
svg += `<circle cx="${bx}" cy="${by}" r="${r + 5}" fill="none"
stroke="${cfg.color}" stroke-width="2" stroke-dasharray="4 3" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1s" repeatCount="indefinite"/>
</circle>`;
}
svg += `<circle cx="${bx}" cy="${by}" r="${r}" fill="${cfg.color}" fill-opacity="${fillOpacity}"
stroke="${cfg.color}" stroke-width="${strokeW}"/>`;
// Label
const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name);
svg += `<text x="${bx}" y="${by - 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 8 : 10}" font-weight="500"
pointer-events="none">${this.esc(label)}</text>`;
// Doc count badge
svg += `<text x="${bx}" y="${by + (mobile ? 9 : 11)}" text-anchor="middle"
fill="${cfg.color}" font-size="${mobile ? 7 : 9}" font-weight="600"
pointer-events="none">${sp.docCount}</text>`;
// Tooltip (title element)
svg += `<title>${this.esc(sp.name)}${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})</title>`;
svg += `</g>`;
}
}
// Center label
const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0);
svg += `<text x="${cx}" y="${cy - 6}" text-anchor="middle" fill="var(--rs-text-primary)"
font-size="${mobile ? 16 : 20}" font-weight="700">${totalDocs}</text>`;
svg += `<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="var(--rs-text-muted)"
font-size="${mobile ? 9 : 11}">total documents</text>`;
svg += `</svg>`;
return svg;
}
private renderDetailPanel(sp: SpaceBubble): string {
const ring = (sp.visibility as Ring) || "private";
const cfg = RING_CONFIG[ring];
const visBadgeColor = cfg.color;
return `
<div class="dc-panel">
<div class="dc-panel__header">
<span class="dc-panel__name">${this.esc(sp.name)}</span>
<span class="dc-panel__vis" style="color:${visBadgeColor};border-color:${visBadgeColor}">${sp.visibility}</span>
<span class="dc-panel__count">${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}</span>
</div>
${sp.modules.length === 0
? `<div class="dc-panel__empty">No documents in this space</div>`
: `<div class="dc-panel__modules">
${sp.modules.map(m => `
<div class="dc-panel__mod" data-nav-space="${this.escAttr(sp.slug)}" data-nav-mod="${this.escAttr(m.id)}">
<span class="dc-panel__mod-icon">${m.icon}</span>
<span class="dc-panel__mod-name">${this.esc(m.name)}</span>
<span class="dc-panel__mod-count">${m.docCount}</span>
</div>
`).join("")}
</div>`
}
</div>
`;
}
private attachEvents() {
// Bubble click — toggle selection
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-bubble")) {
const slug = g.dataset.slug!;
g.addEventListener("click", () => {
this.selected = this.selected === slug ? null : slug;
this.render();
});
g.addEventListener("mouseenter", () => {
this.hoveredSlug = slug;
// Update stroke without full re-render for perf
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
if (circle) circle.setAttribute("stroke-width", "2.5");
});
g.addEventListener("mouseleave", () => {
this.hoveredSlug = null;
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5");
});
}
// Module row click — navigate
for (const row of this.shadow.querySelectorAll<HTMLElement>(".dc-panel__mod")) {
row.addEventListener("click", () => {
const spaceSlug = row.dataset.navSpace!;
const modId = row.dataset.navMod!;
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
window.location.href = `/${spaceSlug}/${modPath}`;
});
}
}
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-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
/* Detail panel */
.dc-panel {
width: 100%; max-width: 500px; margin-top: 1rem;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border);
border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out;
}
@keyframes dc-slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.dc-panel__header {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border);
}
.dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; }
.dc-panel__vis {
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 10px;
border: 1px solid; text-transform: uppercase; font-weight: 600; letter-spacing: 0.03em;
}
.dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); }
.dc-panel__empty {
text-align: center; padding: 1rem; color: var(--rs-text-muted); font-size: 0.85rem;
}
.dc-panel__modules { display: flex; flex-direction: column; gap: 0.25rem; }
.dc-panel__mod {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer;
transition: background 0.1s;
}
.dc-panel__mod:hover { background: rgba(34, 211, 238, 0.08); }
.dc-panel__mod-icon { font-size: 1rem; flex-shrink: 0; }
.dc-panel__mod-name { flex: 1; font-size: 0.85rem; }
.dc-panel__mod-count {
padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem;
background: var(--rs-bg-primary, #0f172a); border: 1px solid var(--rs-border);
color: var(--rs-text-muted);
}
@media (max-width: 500px) {
.dc-panel { max-height: 50vh; overflow-y: auto; }
.dc-panel__name { font-size: 0.9rem; }
}
`;
}
}
customElements.define("folk-data-cloud", FolkDataCloud);