Merge branch 'dev'
This commit is contained in:
commit
b5a7fd0ac0
|
|
@ -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,14 +573,17 @@ 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-canvas" id="graph-canvas">
|
<div class="graph-row" id="graph-row">
|
||||||
<div id="graph-3d-container" style="width:100%;height:100%"></div>
|
<div class="graph-canvas" id="graph-canvas">
|
||||||
<div class="zoom-controls">
|
<div id="graph-3d-container" style="width:100%;height:100%"></div>
|
||||||
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
<div class="zoom-controls">
|
||||||
<span class="zoom-level" id="zoom-level">100%</span>
|
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
||||||
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
<span class="zoom-level" id="zoom-level">100%</span>
|
||||||
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
||||||
|
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="member-list-panel" id="member-list-panel"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-panel" id="detail-panel"></div>
|
<div class="detail-panel" id="detail-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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue