From e5b5c551b17420dde2e29a5778a1c76f66905d99 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 16:54:45 -0700 Subject: [PATCH] feat(rnetwork): multi-select delegation with per-node sliders and fuzzy search - Click any person/member node to add them to delegation selection - Each selected node gets 3 inline sliders (Gov/Econ/Tech) for weight assignment - Fuzzy search input in delegation panel to find and add members by name - Remaining weight display per authority - "Confirm All Delegations" commits all at once, recomputes weights live - Replaces old two-step popup with single-panel multi-select UX Co-Authored-By: Claude Opus 4.6 --- .../rnetwork/components/folk-graph-viewer.ts | 386 ++++++++++++------ 1 file changed, 259 insertions(+), 127 deletions(-) diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index abc292b..73b607b 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -96,11 +96,12 @@ class FolkGraphViewer extends HTMLElement { private authority: AuthoritySelection = "gov-ops"; private layoutMode: "force" | "rings" = "force"; private ringGuides: any[] = []; - private delegationTarget: GraphNode | null = null; - private delegationTotal = 50; - private delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 }; private demoDelegations: GraphEdge[] = []; + // Multi-select delegation state + private selectedDelegates: Map }> = new Map(); + private delegateSearchQuery = ""; + // 3D graph instance private graph: any = null; private graphContainer: HTMLDivElement | null = null; @@ -458,36 +459,60 @@ class FolkGraphViewer extends HTMLElement { } .node-label-org { font-size: 11px; font-weight: 600; } - .btn-delegate { - padding: 6px 14px; border: 1px solid #a78bfa; border-radius: 8px; - background: rgba(167,139,250,0.1); color: #a78bfa; cursor: pointer; - font-size: 12px; font-weight: 600; margin-top: 8px; - } - .btn-delegate:hover { background: rgba(167,139,250,0.2); } - - .deleg-popup { + /* ── Delegation panel (bottom drawer) ── */ + .deleg-panel { display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); - border-radius: 12px; padding: 16px; margin-top: 12px; position: relative; + border-radius: 12px; padding: 12px 16px; margin-top: 8px; } - .deleg-popup.visible { display: block; } - .deleg-popup-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; } - .deleg-popup-close { - position: absolute; top: 10px; right: 12px; - background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px; + .deleg-panel.visible { display: block; } + .deleg-panel-header { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } - .deleg-slider-row { - display: flex; align-items: center; gap: 10px; margin: 8px 0; + .deleg-panel-title { font-size: 13px; font-weight: 600; flex: 1; } + .deleg-panel-close { + background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; } - .deleg-slider-label { font-size: 12px; font-weight: 500; min-width: 50px; } - .deleg-slider { flex: 1; accent-color: #a78bfa; } - .deleg-slider-val { font-size: 12px; font-weight: 700; min-width: 36px; text-align: right; } - .deleg-confirm { + .deleg-search-wrap { position: relative; margin-bottom: 8px; } + .deleg-search { + width: 100%; padding: 6px 10px; border: 1px solid var(--rs-input-border); + border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text); + font-size: 12px; outline: none; + } + .deleg-search:focus { border-color: #a78bfa; } + .deleg-results { + position: absolute; top: 100%; left: 0; right: 0; z-index: 20; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); + border-radius: 8px; max-height: 160px; overflow-y: auto; margin-top: 2px; + } + .deleg-result-item { + padding: 6px 10px; cursor: pointer; font-size: 12px; + display: flex; align-items: center; gap: 6px; + } + .deleg-result-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); } + .deleg-result-role { font-size: 10px; color: var(--rs-text-muted); margin-left: auto; } + + .deleg-row { + display: flex; align-items: center; gap: 6px; padding: 6px 0; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); + } + .deleg-row:last-child { border-bottom: none; } + .deleg-row-name { font-size: 12px; font-weight: 500; min-width: 90px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .deleg-row-sliders { display: flex; gap: 4px; flex: 1; align-items: center; } + .deleg-mini-slider { width: 60px; height: 4px; accent-color: #a78bfa; } + .deleg-mini-val { font-size: 10px; font-weight: 700; min-width: 24px; text-align: right; } + .deleg-row-remove { + background: none; border: none; color: var(--rs-text-muted); cursor: pointer; + font-size: 12px; padding: 2px 4px; border-radius: 4px; + } + .deleg-row-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); } + .deleg-confirm-all { padding: 6px 16px; border: none; border-radius: 8px; background: #a78bfa; color: #fff; cursor: pointer; - font-size: 13px; font-weight: 600; margin-top: 10px; + font-size: 12px; font-weight: 600; margin-top: 8px; width: 100%; } - .deleg-confirm:hover { background: #8b5cf6; } - .deleg-step-label { font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; } + .deleg-confirm-all:hover { background: #8b5cf6; } + .deleg-remaining { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; } + .deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; } @media (max-width: 768px) { .graph-canvas { min-height: 300px; } @@ -525,7 +550,7 @@ class FolkGraphViewer extends HTMLElement {
-
+
People
@@ -730,13 +755,26 @@ class FolkGraphViewer extends HTMLElement { return "#c4b5fd"; }) .onNodeClick((node: GraphNode) => { + const canDelegate = node.type === "rspace_user" || node.type === "person"; + + // Toggle detail panel for inspection if (this.selectedNode?.id === node.id) { this.selectedNode = null; } else { this.selectedNode = node; } this.updateDetailPanel(); - this.updateGraphData(); // refresh highlight + + // Add to delegation selection if delegatable + if (canDelegate && !this.selectedDelegates.has(node.id)) { + this.selectedDelegates.set(node.id, { + node, + weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 }, + }); + this.renderDelegationPanel(); + } + + this.updateGraphData(); }) .d3AlphaDecay(0.02) .d3VelocityDecay(0.3) @@ -1161,8 +1199,6 @@ class FolkGraphViewer extends HTMLElement { }).join(""); } - const canDelegate = n.type === "rspace_user" || n.type === "person"; - panel.classList.add("visible"); panel.innerHTML = `
@@ -1176,126 +1212,207 @@ class FolkGraphViewer extends HTMLElement { ${n.description ? `

${this.esc(n.description)}

` : ""} ${trust >= 0 ? `
Trust Score${trust}
` : ""} ${weightHtml} - ${canDelegate ? `` : ""} ${connected.length > 0 ? `
Connected (${connected.length})
${connected.map(c => `
${this.esc(c.name)}${this.esc(c.role || c.type)}
`).join("")} ` : ""} `; - - // Delegate button listener - this.shadow.getElementById("btn-delegate")?.addEventListener("click", () => { - this.delegationTarget = n; - this.delegationTotal = 50; - this.delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 }; - this.showDelegationPopup(); - }); } - private showDelegationPopup() { - const popup = this.shadow.getElementById("deleg-popup"); - if (!popup || !this.delegationTarget) return; + // ── Delegation panel (multi-select + fuzzy search) ── - const target = this.delegationTarget; - const total = this.delegationTotal; - const split = this.delegationSplit; + private renderDelegationPanel() { + const panel = this.shadow.getElementById("deleg-panel"); + if (!panel) return; - popup.classList.add("visible"); - popup.innerHTML = ` - -
Delegate to ${this.esc(target.name)}
+ if (this.selectedDelegates.size === 0) { + panel.classList.remove("visible"); + panel.innerHTML = ""; + return; + } -
Step 1: Total weight
-
- Total - - ${total}% + // Compute remaining weight per authority + const spent: Record = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 }; + this.selectedDelegates.forEach(({ weights }) => { + for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0; + }); + + panel.classList.add("visible"); + const rows: string[] = []; + this.selectedDelegates.forEach(({ node, weights }, id) => { + rows.push(` +
+ ${this.esc(node.name)} +
+ ${DELEGATION_AUTHORITIES.map(a => { + const disp = AUTHORITY_DISPLAY[a]; + const w = weights[a] || 0; + return `${disp?.label} + + ${w}%`; + }).join("")} +
+ +
+ `); + }); + + panel.innerHTML = ` +
+ Delegate Weight (${this.selectedDelegates.size} selected) +
-
Step 2: Domain split
- ${DELEGATION_AUTHORITIES.map(a => { - const disp = AUTHORITY_DISPLAY[a]; - const pct = split[a] || 0; - return `
- ${disp?.label || a} - - ${pct}% -
`; - }).join("")} +
+ + +
- + ${rows.join("")} + +
${DELEGATION_AUTHORITIES.map(a => { + const disp = AUTHORITY_DISPLAY[a]; + return `${disp?.label}: ${Math.max(0, 100 - spent[a])}% left`; + }).join(" · ")}
+ + `; + this.attachDelegationListeners(panel); + } + + private attachDelegationListeners(panel: HTMLElement) { // Close - this.shadow.getElementById("deleg-close")?.addEventListener("click", () => { - this.delegationTarget = null; - popup.classList.remove("visible"); + this.shadow.getElementById("deleg-panel-close")?.addEventListener("click", () => { + this.selectedDelegates.clear(); + this.delegateSearchQuery = ""; + this.renderDelegationPanel(); }); - // Total slider - this.shadow.getElementById("deleg-total")?.addEventListener("input", (e) => { - this.delegationTotal = parseInt((e.target as HTMLInputElement).value); - const valEl = this.shadow.getElementById("deleg-total-val"); - if (valEl) valEl.textContent = this.delegationTotal + "%"; + // Remove buttons + panel.querySelectorAll("[data-remove]").forEach(el => { + el.addEventListener("click", () => { + const id = (el as HTMLElement).dataset.remove!; + this.selectedDelegates.delete(id); + this.renderDelegationPanel(); + }); }); - // Domain sliders — adjust others proportionally - popup.querySelectorAll("[data-deleg-auth]").forEach(el => { + // Per-delegate per-authority sliders + panel.querySelectorAll("[data-did][data-auth]").forEach(el => { el.addEventListener("input", (e) => { - const auth = (el as HTMLElement).dataset.delegAuth!; - const newVal = parseInt((e.target as HTMLInputElement).value); - const others = DELEGATION_AUTHORITIES.filter(a => a !== auth); - const oldOtherSum = others.reduce((s, a) => s + (this.delegationSplit[a] || 0), 0); - this.delegationSplit[auth] = newVal; - - // Redistribute remaining to others proportionally - const remaining = 100 - newVal; - if (oldOtherSum > 0) { - for (const o of others) { - this.delegationSplit[o] = Math.round((this.delegationSplit[o] / oldOtherSum) * remaining); - } - } else { - const each = Math.round(remaining / others.length); - for (const o of others) this.delegationSplit[o] = each; - } - - // Update UI - for (const a of DELEGATION_AUTHORITIES) { - const slider = popup.querySelector(`[data-deleg-auth="${a}"]`) as HTMLInputElement; - const val = popup.querySelector(`[data-deleg-val="${a}"]`); - if (slider) slider.value = String(this.delegationSplit[a]); - if (val) val.textContent = this.delegationSplit[a] + "%"; + const id = (el as HTMLElement).dataset.did!; + const auth = (el as HTMLElement).dataset.auth!; + const val = parseInt((e.target as HTMLInputElement).value); + const entry = this.selectedDelegates.get(id); + if (entry) { + entry.weights[auth] = val; + const valEl = panel.querySelector(`[data-did-val="${id}-${auth}"]`); + if (valEl) valEl.textContent = val + "%"; + // Update remaining display + this.updateRemainingDisplay(panel); } }); }); - // Confirm - this.shadow.getElementById("deleg-confirm")?.addEventListener("click", () => { - this.confirmDelegation(); + // Fuzzy search + const searchInput = this.shadow.getElementById("deleg-search") as HTMLInputElement | null; + const resultsDiv = this.shadow.getElementById("deleg-results"); + let searchTimeout: any; + + searchInput?.addEventListener("input", () => { + this.delegateSearchQuery = searchInput.value; + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + this.updateSearchResults(searchInput.value, resultsDiv); + }, 150); + }); + + searchInput?.addEventListener("focus", () => { + if (searchInput.value.trim()) { + this.updateSearchResults(searchInput.value, resultsDiv); + } + }); + + // Confirm all + this.shadow.getElementById("deleg-confirm-all")?.addEventListener("click", () => { + this.confirmAllDelegations(); }); } - private confirmDelegation() { - if (!this.delegationTarget) return; - const target = this.delegationTarget; - const totalWeight = this.delegationTotal / 100; - - // Create delegation edges for each authority - for (const a of DELEGATION_AUTHORITIES) { - const pct = this.delegationSplit[a] || 0; - const weight = Math.round(totalWeight * (pct / 100) * 100) / 100; - if (weight <= 0) continue; - - const edge: GraphEdge = { - source: "me-demo", - target: target.id, - type: "delegates_to", - weight, - authority: a, - }; - this.edges.push(edge); - this.demoDelegations.push(edge); + private updateRemainingDisplay(panel: HTMLElement) { + const spent: Record = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 }; + this.selectedDelegates.forEach(({ weights }) => { + for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0; + }); + const remainEl = panel.querySelector(".deleg-remaining"); + if (remainEl) { + remainEl.innerHTML = DELEGATION_AUTHORITIES.map(a => { + const disp = AUTHORITY_DISPLAY[a]; + const left = Math.max(0, 100 - spent[a]); + return `${disp?.label}: ${left}% left`; + }).join(" · "); } + } + + private fuzzyMatch(query: string, name: string): boolean { + const q = query.toLowerCase(); + const n = name.toLowerCase(); + // Substring match + if (n.includes(q)) return true; + // Fuzzy: all query chars appear in order + let qi = 0; + for (let i = 0; i < n.length && qi < q.length; i++) { + if (n[i] === q[qi]) qi++; + } + return qi === q.length; + } + + private updateSearchResults(query: string, resultsDiv: HTMLElement | null) { + if (!resultsDiv) return; + const q = query.trim(); + if (!q) { + resultsDiv.style.display = "none"; + return; + } + + const matches = this.nodes.filter(n => + (n.type === "rspace_user" || n.type === "person") && + !this.selectedDelegates.has(n.id) && + this.fuzzyMatch(q, n.name) + ).slice(0, 8); + + if (matches.length === 0) { + resultsDiv.style.display = "none"; + return; + } + + resultsDiv.style.display = "block"; + resultsDiv.innerHTML = matches.map(n => + `
+ ${this.esc(n.name)} + ${n.role || n.type} +
` + ).join(""); + + resultsDiv.querySelectorAll("[data-add-id]").forEach(el => { + el.addEventListener("click", () => { + const id = (el as HTMLElement).dataset.addId!; + const node = this.nodes.find(n => n.id === id); + if (node) { + this.selectedDelegates.set(id, { + node, + weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 }, + }); + this.delegateSearchQuery = ""; + this.renderDelegationPanel(); + } + }); + }); + } + + private confirmAllDelegations() { + if (this.selectedDelegates.size === 0) return; // Ensure "me-demo" node exists if (!this.nodes.find(n => n.id === "me-demo")) { @@ -1308,13 +1425,28 @@ class FolkGraphViewer extends HTMLElement { }); } - // Recompute weight accounting - this.recomputeWeightAccounting(); + // Create delegation edges for each selected delegate + this.selectedDelegates.forEach(({ node, weights }) => { + for (const a of DELEGATION_AUTHORITIES) { + const weight = Math.round((weights[a] || 0) / 100 * 100) / 100; + if (weight <= 0) continue; + const edge: GraphEdge = { + source: "me-demo", + target: node.id, + type: "delegates_to", + weight, + authority: a, + }; + this.edges.push(edge); + this.demoDelegations.push(edge); + } + }); - // Close popup and refresh - this.delegationTarget = null; - const popup = this.shadow.getElementById("deleg-popup"); - if (popup) popup.classList.remove("visible"); + // Recompute weight accounting + refresh + this.recomputeWeightAccounting(); + this.selectedDelegates.clear(); + this.delegateSearchQuery = ""; + this.renderDelegationPanel(); this.updateGraphData(); }