feat(rnetwork): responsive zoom, larger nodes/labels, member list sidebar

- Zoom: 2x/0.5x steps (was 1.33x/0.75x), 200ms animation, scroll speed 2.5x
- Node sizing: range 6-56px in trust mode (was 4-30px) for dramatic differentiation
- Text labels: 512x96 canvas with 36px font, 14x3.5 sprite scale (was 256x64, 24px, 8x2)
- Member list sidebar: toggled via "List" button, shows admins/members/viewers grouped
  with effective weight, click to fly camera to node, responsive mobile stack layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:21:23 -07:00
parent 668c239cf3
commit 7cab8d6187
1 changed files with 150 additions and 23 deletions

View File

@ -97,6 +97,7 @@ class FolkGraphViewer extends HTMLElement {
private layoutMode: "force" | "rings" = "force"; private layoutMode: "force" | "rings" = "force";
private ringGuides: any[] = []; private ringGuides: any[] = [];
private demoDelegations: GraphEdge[] = []; private demoDelegations: GraphEdge[] = [];
private showMemberList = false;
// Multi-select delegation state // Multi-select delegation state
private selectedDelegates: Map<string, { node: GraphNode; weights: Record<string, number> }> = new Map(); private selectedDelegates: Map<string, { node: GraphNode; weights: Record<string, number> }> = new Map();
@ -309,9 +310,9 @@ class FolkGraphViewer extends HTMLElement {
const vals = Object.values(acct.effectiveWeight); const vals = Object.values(acct.effectiveWeight);
ew = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; ew = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
} }
// Normalize against max in current filtered view // Normalize against max in current filtered view — dramatic sizing
const maxEW = this._currentMaxEffectiveWeight || 1; const maxEW = this._currentMaxEffectiveWeight || 1;
return 4 + (ew / maxEW) * 26; return 6 + (ew / maxEW) * 50;
} }
if (node.type === "rspace_user") return 10; if (node.type === "rspace_user") return 10;
return 12; return 12;
@ -364,13 +365,43 @@ class FolkGraphViewer extends HTMLElement {
.filter-btn:hover { border-color: var(--rs-border-strong); } .filter-btn:hover { border-color: var(--rs-border-strong); }
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); } .filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
.graph-row {
display: flex; flex: 1; gap: 0; min-height: 400px;
}
.graph-canvas { .graph-canvas {
width: 100%; flex: 1; min-height: 400px; border-radius: 12px; flex: 1; min-height: 400px; border-radius: 12px;
background: var(--rs-canvas-bg); border: 1px solid var(--rs-border); background: var(--rs-canvas-bg); border: 1px solid var(--rs-border);
position: relative; overflow: hidden; position: relative; overflow: hidden;
} }
.graph-canvas canvas { border-radius: 12px; } .graph-canvas canvas { border-radius: 12px; }
.member-list-panel {
display: none; width: 260px; flex-shrink: 0;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 0 12px 12px 0; overflow-y: auto;
font-size: 12px; margin-left: -1px;
}
.member-list-panel.visible { display: block; }
.member-group { padding: 8px 12px; }
.member-group-header {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; padding: 6px 0 4px; border-bottom: 1px solid var(--rs-border);
display: flex; justify-content: space-between; align-items: center;
}
.member-group-count {
font-size: 10px; font-weight: 400; color: var(--rs-text-muted);
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06));
padding: 1px 6px; border-radius: 8px;
}
.member-item {
padding: 4px 0; display: flex; align-items: center; gap: 6px;
cursor: pointer; border-radius: 4px;
}
.member-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
.member-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.member-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.member-weight { font-size: 10px; color: var(--rs-text-muted); font-weight: 600; min-width: 28px; text-align: right; }
.zoom-controls { .zoom-controls {
position: absolute; bottom: 12px; right: 12px; position: absolute; bottom: 12px; right: 12px;
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;
@ -515,7 +546,9 @@ class FolkGraphViewer extends HTMLElement {
.deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; } .deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; }
@media (max-width: 768px) { @media (max-width: 768px) {
.graph-row { flex-direction: column; }
.graph-canvas { min-height: 300px; } .graph-canvas { min-height: 300px; }
.member-list-panel { width: 100%; max-height: 200px; border-radius: 0 0 12px 12px; margin-left: 0; margin-top: -1px; }
.workspace-list { grid-template-columns: 1fr; } .workspace-list { grid-template-columns: 1fr; }
.stats { flex-wrap: wrap; gap: 12px; } .stats { flex-wrap: wrap; gap: 12px; }
.toolbar { flex-direction: column; align-items: stretch; } .toolbar { flex-direction: column; align-items: stretch; }
@ -532,6 +565,7 @@ class FolkGraphViewer extends HTMLElement {
<button class="filter-btn" data-filter="rspace_user">Members</button> <button class="filter-btn" data-filter="rspace_user">Members</button>
<button class="filter-btn" id="trust-toggle" title="Toggle trust-weighted view">Trust</button> <button class="filter-btn" id="trust-toggle" title="Toggle trust-weighted view">Trust</button>
<button class="filter-btn" id="rings-toggle" title="Toggle concentric ring layout">Rings</button> <button class="filter-btn" id="rings-toggle" title="Toggle concentric ring layout">Rings</button>
<button class="filter-btn" id="list-toggle" title="Toggle member list sidebar">List</button>
</div> </div>
<div class="authority-bar" id="authority-bar"> <div class="authority-bar" id="authority-bar">
@ -539,6 +573,7 @@ class FolkGraphViewer extends HTMLElement {
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")} ${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
</div> </div>
<div class="graph-row" id="graph-row">
<div class="graph-canvas" id="graph-canvas"> <div class="graph-canvas" id="graph-canvas">
<div id="graph-3d-container" style="width:100%;height:100%"></div> <div id="graph-3d-container" style="width:100%;height:100%"></div>
<div class="zoom-controls"> <div class="zoom-controls">
@ -548,6 +583,8 @@ class FolkGraphViewer extends HTMLElement {
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</button> <button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</button>
</div> </div>
</div> </div>
<div class="member-list-panel" id="member-list-panel"></div>
</div>
<div class="detail-panel" id="detail-panel"></div> <div class="detail-panel" id="detail-panel"></div>
<div class="deleg-panel" id="deleg-panel"></div> <div class="deleg-panel" id="deleg-panel"></div>
@ -637,21 +674,36 @@ class FolkGraphViewer extends HTMLElement {
} }
}); });
// Zoom controls // Zoom controls — aggressive steps for fast navigation
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => { this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
if (!this.graph) return; if (!this.graph) return;
const cam = this.graph.camera(); const cam = this.graph.camera();
const dist = cam.position.length(); const dist = cam.position.length();
this.animateCameraDistance(dist * 0.75); this.animateCameraDistance(dist * 0.5);
}); });
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => { this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
if (!this.graph) return; if (!this.graph) return;
const cam = this.graph.camera(); const cam = this.graph.camera();
const dist = cam.position.length(); const dist = cam.position.length();
this.animateCameraDistance(dist * 1.33); this.animateCameraDistance(dist * 2);
}); });
this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => { this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => {
if (this.graph) this.graph.zoomToFit(400, 40); if (this.graph) this.graph.zoomToFit(300, 20);
});
// Member list toggle
this.shadow.getElementById("list-toggle")?.addEventListener("click", () => {
this.showMemberList = !this.showMemberList;
const btn = this.shadow.getElementById("list-toggle");
if (btn) btn.classList.toggle("active", this.showMemberList);
this.updateMemberList();
// Resize graph when panel toggles
requestAnimationFrame(() => {
if (this.graph && this.graphContainer) {
const rect = this.graphContainer.getBoundingClientRect();
if (rect.width > 0) this.graph.width(rect.width);
}
});
}); });
} }
@ -663,7 +715,7 @@ class FolkGraphViewer extends HTMLElement {
this.graph.cameraPosition( this.graph.cameraPosition(
{ x: target.x, y: target.y, z: target.z }, { x: target.x, y: target.y, z: target.z },
undefined, undefined,
600 200
); );
} }
@ -816,6 +868,7 @@ class FolkGraphViewer extends HTMLElement {
controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 }; controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 };
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.12; controls.dampingFactor = 0.12;
controls.zoomSpeed = 2.5; // faster scroll wheel zoom
} }
// ResizeObserver for responsive canvas // ResizeObserver for responsive canvas
@ -936,17 +989,17 @@ class FolkGraphViewer extends HTMLElement {
if (!ctx) return null; if (!ctx) return null;
const text = node.name; const text = node.name;
const fontSize = node.type === "company" ? 28 : 24; const fontSize = node.type === "company" ? 42 : 36;
canvas.width = 256; canvas.width = 512;
canvas.height = 64; canvas.height = 96;
ctx.font = `${node.type === "company" ? "600" : "400"} ${fontSize}px system-ui, sans-serif`; ctx.font = `${node.type === "company" ? "600" : "500"} ${fontSize}px system-ui, sans-serif`;
ctx.fillStyle = "#e2e8f0"; ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 4; ctx.shadowBlur = 6;
ctx.fillText(text.length > 20 ? text.slice(0, 18) + "\u2026" : text, 128, 32); ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 256, 48);
const texture = new THREE.CanvasTexture(canvas); const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true; texture.needsUpdate = true;
@ -956,7 +1009,7 @@ class FolkGraphViewer extends HTMLElement {
depthTest: false, depthTest: false,
}); });
const sprite = new THREE.Sprite(spriteMaterial); const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(8, 2, 1); sprite.scale.set(14, 3.5, 1);
return sprite; return sprite;
} }
@ -1055,6 +1108,9 @@ class FolkGraphViewer extends HTMLElement {
setTimeout(() => { setTimeout(() => {
if (this.graph) this.graph.zoomToFit(400, 40); if (this.graph) this.graph.zoomToFit(400, 40);
}, 500); }, 500);
// Refresh member list if visible
if (this.showMemberList) this.updateMemberList();
} }
// ── Ring layout ── // ── Ring layout ──
@ -1493,6 +1549,77 @@ class FolkGraphViewer extends HTMLElement {
} }
} }
private updateMemberList() {
const panel = this.shadow.getElementById("member-list-panel");
if (!panel) return;
if (!this.showMemberList) {
panel.classList.remove("visible");
return;
}
panel.classList.add("visible");
const groups: { label: string; color: string; role: string; nodes: GraphNode[] }[] = [
{ label: "Admins", color: "#a78bfa", role: "admin", nodes: [] },
{ label: "Members", color: "#10b981", role: "member", nodes: [] },
{ label: "Viewers", color: "#3b82f6", role: "viewer", nodes: [] },
];
const filtered = this.getFilteredNodes().filter(n => n.type === "rspace_user" || n.type === "person");
for (const n of filtered) {
const g = groups.find(g => g.role === n.role) || groups[2];
g.nodes.push(n);
}
// Sort each group by effective weight (descending)
for (const g of groups) {
g.nodes.sort((a, b) => {
const aw = a.weightAccounting ? Object.values(a.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
const bw = b.weightAccounting ? Object.values(b.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
return bw - aw;
});
}
panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
<div class="member-group">
<div class="member-group-header" style="color:${g.color}">
${g.label}
<span class="member-group-count">${g.nodes.length}</span>
</div>
${g.nodes.map(n => {
const ew = n.weightAccounting ? (Object.values(n.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) / 3).toFixed(1) : "";
return `<div class="member-item" data-member-id="${n.id}">
<span class="member-dot" style="background:${g.color}"></span>
<span class="member-name" title="${this.esc(n.name)}">${this.esc(n.name)}</span>
${ew ? `<span class="member-weight">${ew}</span>` : ""}
</div>`;
}).join("")}
</div>
`).join("");
// Click to select/focus node in graph
panel.querySelectorAll("[data-member-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.memberId!;
const node = this.nodes.find(n => n.id === id);
if (node && this.graph) {
this.selectedNode = node;
this.updateDetailPanel();
this.updateGraphData();
// Fly camera to node
if (node.x != null && node.y != null && node.z != null) {
const dist = 60;
this.graph.cameraPosition(
{ x: node.x + dist, y: node.y + dist * 0.3, z: node.z + dist },
{ x: node.x, y: node.y, z: node.z },
400
);
}
}
});
});
}
private updateWorkspaceList() { private updateWorkspaceList() {
const section = this.shadow.getElementById("workspace-section"); const section = this.shadow.getElementById("workspace-section");
if (!section) return; if (!section) return;