From 98cd2394184fcdaf0b9a7ce8f64cd47c26502168 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 15 Mar 2026 16:34:54 -0700 Subject: [PATCH] feat(rnetwork): weight accounting, ring layout, inline delegation UI - Per-authority effective weight computation (delegated/received/retained) - Concentric ring layout (admin/member/viewer) with visual guides - Inline delegation popup with total + domain split sliders - Authority labels renamed: Gov/Econ/Tech with consistent colors - Authority-filtered edge view in trust mode - Demo delegation preview with live graph updates - Trust API endpoints for delegation CRUD and score queries Co-Authored-By: Claude Opus 4.6 --- .../components/folk-delegation-manager.ts | 9 +- .../rnetwork/components/folk-graph-viewer.ts | 487 ++++++++++++++++-- .../rnetwork/components/folk-trust-sankey.ts | 7 +- modules/rnetwork/mod.ts | 356 ++++++++----- 4 files changed, 697 insertions(+), 162 deletions(-) diff --git a/modules/rnetwork/components/folk-delegation-manager.ts b/modules/rnetwork/components/folk-delegation-manager.ts index 1c2d176..4a1f62d 100644 --- a/modules/rnetwork/components/folk-delegation-manager.ts +++ b/modules/rnetwork/components/folk-delegation-manager.ts @@ -31,6 +31,11 @@ interface SpaceUser { } const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; +const DM_AUTHORITY_DISPLAY: Record = { + "gov-ops": { label: "Gov", color: "#a78bfa" }, + "fin-ops": { label: "Econ", color: "#10b981" }, + "dev-ops": { label: "Tech", color: "#3b82f6" }, +}; const AUTHORITY_ICONS: Record = { "gov-ops": "\u{1F3DB}\uFE0F", "fin-ops": "\u{1F4B0}", @@ -219,7 +224,7 @@ class FolkDelegationManager extends HTMLElement {
${icon} - ${authority} + ${DM_AUTHORITY_DISPLAY[authority]?.label || authority} ${pct}% delegated ${inboundCount > 0 ? `${inboundCount} received` : ""} @@ -261,7 +266,7 @@ class FolkDelegationManager extends HTMLElement {
- ${DELEGATION_AUTHORITIES.map(a => ``).join("")} + ${DELEGATION_AUTHORITIES.map(a => ``).join("")}
@@ -443,6 +525,7 @@ class FolkGraphViewer extends HTMLElement {
+
People
@@ -454,9 +537,7 @@ class FolkGraphViewer extends HTMLElement {
Point of contact
@@ -491,10 +572,29 @@ class FolkGraphViewer extends HTMLElement { this.trustMode = !this.trustMode; const btn = this.shadow.getElementById("trust-toggle"); if (btn) btn.classList.toggle("active", this.trustMode); + // Auto-enable rings when trust mode is turned on + if (this.trustMode && this.layoutMode !== "rings") { + this.layoutMode = "rings"; + const ringsBtn = this.shadow.getElementById("rings-toggle"); + if (ringsBtn) ringsBtn.classList.add("active"); + } this.updateAuthorityBar(); this.loadData(); }); + // Rings toggle + this.shadow.getElementById("rings-toggle")?.addEventListener("click", () => { + this.layoutMode = this.layoutMode === "rings" ? "force" : "rings"; + const btn = this.shadow.getElementById("rings-toggle"); + if (btn) btn.classList.toggle("active", this.layoutMode === "rings"); + if (this.layoutMode === "rings") { + this.applyRingLayout(); + } else { + this.clearFixedPositions(); + this.removeRingGuides(); + } + }); + // Authority buttons this.shadow.querySelectorAll("[data-authority]").forEach(el => { el.addEventListener("click", () => { @@ -768,11 +868,20 @@ class FolkGraphViewer extends HTMLElement { group.add(label); } - // Trust badge sprite + // Trust badge sprite — show per-authority effective weight in trust mode if (node.type !== "company" && node.type !== "space") { - const trust = this.getTrustScore(node.id); - if (trust >= 0) { - const badge = this.createBadgeSprite(THREE, String(trust)); + let badgeText = ""; + let badgeColor = "#7c3aed"; + if (this.trustMode && node.weightAccounting && this.authority !== "all") { + const ew = node.weightAccounting.effectiveWeight[this.authority] || 0; + badgeText = ew.toFixed(1); + badgeColor = AUTHORITY_COLORS[this.authority] || "#7c3aed"; + } else { + const trust = this.getTrustScore(node.id); + if (trust >= 0) badgeText = String(trust); + } + if (badgeText) { + const badge = this.createBadgeSprite(THREE, badgeText, badgeColor); if (badge) { badge.position.set(radius - 0.2, radius - 0.2, 0); group.add(badge); @@ -813,7 +922,7 @@ class FolkGraphViewer extends HTMLElement { return sprite; } - private createBadgeSprite(THREE: any, text: string): any { + private createBadgeSprite(THREE: any, text: string, color = "#7c3aed"): any { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return null; @@ -823,7 +932,7 @@ class FolkGraphViewer extends HTMLElement { ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); - ctx.fillStyle = "#7c3aed"; + ctx.fillStyle = color; ctx.fill(); ctx.font = "bold 24px system-ui, sans-serif"; @@ -852,13 +961,37 @@ class FolkGraphViewer extends HTMLElement { const filtered = this.getFilteredNodes(); const filteredIds = new Set(filtered.map(n => n.id)); + // Compute max effective weight for filtered nodes (used by getNodeRadius) + if (this.trustMode) { + let maxEW = 0; + for (const n of filtered) { + if (!n.weightAccounting) continue; + if (this.authority !== "all") { + maxEW = Math.max(maxEW, n.weightAccounting.effectiveWeight[this.authority] || 0); + } else { + const vals = Object.values(n.weightAccounting.effectiveWeight); + const avg = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + maxEW = Math.max(maxEW, avg); + } + } + this._currentMaxEffectiveWeight = maxEW || 1; + } + // Filter edges to only include those between visible nodes - const filteredEdges = this.edges.filter(e => { + let filteredEdges = this.edges.filter(e => { const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; return filteredIds.has(sid) && filteredIds.has(tid); }); + // Authority-filtered edge view: in trust mode with specific authority, only show that authority's delegation edges + if (this.trustMode && this.authority !== "all") { + filteredEdges = filteredEdges.filter(e => { + if (e.type !== "delegates_to") return true; + return e.authority === this.authority; + }); + } + this.graph.graphData({ nodes: filtered, links: filteredEdges, @@ -877,12 +1010,107 @@ class FolkGraphViewer extends HTMLElement { if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none"; if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none"; - // Fit view after data settles + // Apply ring layout if active, otherwise fit view + if (this.layoutMode === "rings") { + this.applyRingLayout(); + } setTimeout(() => { if (this.graph) this.graph.zoomToFit(400, 40); }, 500); } + // ── Ring layout ── + + private applyRingLayout() { + if (!this.graph) return; + const data = this.graph.graphData(); + const nodes = data.nodes as GraphNode[]; + + // Group by role + const adminNodes = nodes.filter(n => n.role === "admin"); + const memberNodes = nodes.filter(n => n.role === "member"); + const viewerNodes = nodes.filter(n => n.role === "viewer"); + const otherNodes = nodes.filter(n => n.type === "space"); + + // Place space hub at center + for (const n of otherNodes) { + n.fx = 0; n.fy = 0; n.fz = 0; + } + + this.placeRing(adminNodes, 30); + this.placeRing(memberNodes, 80); + this.placeRing(viewerNodes, 160); + + this.graph.graphData(data); + this.graph.d3ReheatSimulation(); + + // Add ring guides + this.removeRingGuides(); + this.addRingGuides(); + } + + private placeRing(nodes: GraphNode[], radius: number) { + const count = nodes.length; + if (count === 0) return; + for (let i = 0; i < count; i++) { + const angle = (2 * Math.PI * i) / count; + nodes[i].fx = Math.cos(angle) * radius; + nodes[i].fy = 0; + nodes[i].fz = Math.sin(angle) * radius; + } + } + + private clearFixedPositions() { + if (!this.graph) return; + const data = this.graph.graphData(); + for (const n of data.nodes as GraphNode[]) { + delete n.fx; + delete n.fy; + delete n.fz; + } + this.graph.graphData(data); + this.graph.d3ReheatSimulation(); + } + + private addRingGuides() { + const THREE = this._threeModule; + if (!THREE || !this.graph) return; + const scene = this.graph.scene(); + if (!scene) return; + + const ringRadii = [ + { r: 30, color: 0xa78bfa, label: "Admin" }, + { r: 80, color: 0x10b981, label: "Member" }, + { r: 160, color: 0x3b82f6, label: "Viewer" }, + ]; + + for (const { r, color } of ringRadii) { + const geometry = new THREE.RingGeometry(r - 0.5, r + 0.5, 128); + const material = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.15, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = -Math.PI / 2; // flat on XZ plane + mesh.position.y = 0; + scene.add(mesh); + this.ringGuides.push(mesh); + } + } + + private removeRingGuides() { + const scene = this.graph?.scene(); + if (!scene) return; + for (const mesh of this.ringGuides) { + scene.remove(mesh); + mesh.geometry?.dispose(); + mesh.material?.dispose(); + } + this.ringGuides = []; + } + // ── Incremental UI updates ── private updateStatsBar() { @@ -922,23 +1150,212 @@ class FolkGraphViewer extends HTMLElement { const connected = this.getConnectedNodes(n.id); const trust = (n.type !== "company" && n.type !== "space") ? this.getTrustScore(n.id) : -1; + // Per-authority effective weight display + let weightHtml = ""; + if (n.weightAccounting && (n.type === "rspace_user" || n.type === "person")) { + const acct = n.weightAccounting; + weightHtml = DELEGATION_AUTHORITIES.map(a => { + const ew = Math.round((acct.effectiveWeight[a] || 0) * 100) / 100; + const disp = AUTHORITY_DISPLAY[a]; + return `
${disp?.label || a}${ew.toFixed(2)}
`; + }).join(""); + } + + const canDelegate = n.type === "rspace_user" || n.type === "person"; + panel.classList.add("visible"); panel.innerHTML = `
${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}
${this.esc(n.name)}
-
${this.esc(n.type === "company" ? "Organization" : n.type === "space" ? "Space" : n.type === "rspace_user" ? "Member" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
+
${this.esc(n.type === "company" ? "Organization" : n.type === "space" ? "Space" : n.type === "rspace_user" ? (n.role || "Member") : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
${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; + + const target = this.delegationTarget; + const total = this.delegationTotal; + const split = this.delegationSplit; + + popup.classList.add("visible"); + popup.innerHTML = ` + +
Delegate to ${this.esc(target.name)}
+ +
Step 1: Total weight
+
+ Total + + ${total}% +
+ +
Step 2: Domain split
+ ${DELEGATION_AUTHORITIES.map(a => { + const disp = AUTHORITY_DISPLAY[a]; + const pct = split[a] || 0; + return `
+ ${disp?.label || a} + + ${pct}% +
`; + }).join("")} + + + `; + + // Close + this.shadow.getElementById("deleg-close")?.addEventListener("click", () => { + this.delegationTarget = null; + popup.classList.remove("visible"); + }); + + // 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 + "%"; + }); + + // Domain sliders — adjust others proportionally + popup.querySelectorAll("[data-deleg-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] + "%"; + } + }); + }); + + // Confirm + this.shadow.getElementById("deleg-confirm")?.addEventListener("click", () => { + this.confirmDelegation(); + }); + } + + 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); + } + + // Ensure "me-demo" node exists + if (!this.nodes.find(n => n.id === "me-demo")) { + this.nodes.push({ + id: "me-demo", + name: "You", + type: "rspace_user", + workspace: "", + role: "member", + }); + } + + // Recompute weight accounting + this.recomputeWeightAccounting(); + + // Close popup and refresh + this.delegationTarget = null; + const popup = this.shadow.getElementById("deleg-popup"); + if (popup) popup.classList.remove("visible"); + this.updateGraphData(); + } + + private recomputeWeightAccounting() { + const authorities = ["gov-ops", "fin-ops", "dev-ops"]; + const acctMap = new Map(); + for (const node of this.nodes) { + acctMap.set(node.id, { + delegatedAway: Object.fromEntries(authorities.map(a => [a, 0])), + receivedWeight: Object.fromEntries(authorities.map(a => [a, 0])), + effectiveWeight: Object.fromEntries(authorities.map(a => [a, 0])), + }); + } + for (const e of this.edges) { + if (e.type !== "delegates_to" || !e.authority) continue; + const sid = typeof e.source === "string" ? e.source : e.source.id; + const tid = typeof e.target === "string" ? e.target : e.target.id; + const w = e.weight || 0.5; + const sAcct = acctMap.get(sid); + const tAcct = acctMap.get(tid); + if (sAcct) sAcct.delegatedAway[e.authority] = (sAcct.delegatedAway[e.authority] || 0) + w; + if (tAcct) tAcct.receivedWeight[e.authority] = (tAcct.receivedWeight[e.authority] || 0) + w; + } + acctMap.forEach((acct) => { + for (const a of authorities) { + acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]); + } + }); + let maxEW = 0; + acctMap.forEach((acct) => { + for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]); + }); + if (maxEW === 0) maxEW = 1; + for (const node of this.nodes) { + const acct = acctMap.get(node.id); + if (acct) { + node.weightAccounting = acct; + const avgEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0) / authorities.length; + node.delegatedWeight = avgEW / maxEW; + } + } } private updateWorkspaceList() { diff --git a/modules/rnetwork/components/folk-trust-sankey.ts b/modules/rnetwork/components/folk-trust-sankey.ts index be9ac7c..2a5e811 100644 --- a/modules/rnetwork/components/folk-trust-sankey.ts +++ b/modules/rnetwork/components/folk-trust-sankey.ts @@ -30,6 +30,11 @@ interface TrustEvent { } const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; +const SANKEY_AUTHORITY_DISPLAY: Record = { + "gov-ops": { label: "Gov", color: "#a78bfa" }, + "fin-ops": { label: "Econ", color: "#10b981" }, + "dev-ops": { label: "Tech", color: "#3b82f6" }, +}; const FLOW_COLOR = "#a78bfa"; class FolkTrustSankey extends HTMLElement { @@ -321,7 +326,7 @@ class FolkTrustSankey extends HTMLElement {
Delegation Flows
- ${SANKEY_AUTHORITIES.map(a => ``).join("")} + ${SANKEY_AUTHORITIES.map(a => ``).join("")}
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index eb00936..24028c3 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -218,61 +218,172 @@ routes.get("/api/graph", async (c) => { if (!token) { isDemoData = true; - // ── Demo: 48 members with delegation-based trust flows ── - // Members: id, name, role, delegatedWeight per authority (gov, fin, dev) + // ── Demo: 150 members with delegation-based trust flows ── + // Deterministic PRNG for reproducible delegation edges + function mulberry32(seed: number) { + return () => { + seed |= 0; seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + + // Members: [id, name, permissionLevel, govW, finW, devW] const members: Array<[string, string, string, number, number, number]> = [ - // Stewards — top-level, high trust across boards - ["m01", "Alice Chen", "steward", 0.95, 0.50, 0.40], - ["m02", "Bob Martinez", "steward", 0.45, 0.90, 0.35], - ["m03", "Carol Okafor", "steward", 0.40, 0.35, 0.95], - // Council — secondary hubs - ["m04", "David Kim", "council", 0.70, 0.40, 0.35], - ["m05", "Eve Nakamura", "council", 0.35, 0.30, 0.75], - ["m06", "Frank Osei", "council", 0.30, 0.80, 0.25], - ["m25", "Anika Bergström", "council", 0.65, 0.30, 0.25], - ["m26", "Rafael Oliveira", "council", 0.25, 0.75, 0.30], - ["m27", "Chen Wei", "council", 0.30, 0.25, 0.70], - // Contributors — mid-tier, specialize in one vertical - ["m07", "Grace Liu", "contributor", 0.50, 0.20, 0.65], - ["m08", "Hassan Patel", "contributor", 0.20, 0.60, 0.30], - ["m09", "Ingrid Svensson", "contributor", 0.55, 0.25, 0.30], - ["m10", "Jorge Reyes", "contributor", 0.20, 0.55, 0.25], - ["m11", "Kaia Tanaka", "contributor", 0.15, 0.20, 0.65], - ["m12", "Leo Adeyemi", "contributor", 0.20, 0.15, 0.60], - ["m28", "Fatima Al-Hassan", "contributor", 0.50, 0.20, 0.15], - ["m29", "Marcus Johnson", "contributor", 0.15, 0.55, 0.20], - ["m30", "Yuna Park", "contributor", 0.20, 0.15, 0.55], - ["m31", "Dmitri Volkov", "contributor", 0.45, 0.25, 0.20], - ["m32", "Amara Diallo", "contributor", 0.15, 0.50, 0.25], - ["m33", "Liam O'Connor", "contributor", 0.20, 0.20, 0.50], - ["m34", "Zara Hussain", "contributor", 0.45, 0.15, 0.20], - ["m35", "Tomás Herrera", "contributor", 0.20, 0.50, 0.15], - ["m36", "Sakura Ito", "contributor", 0.15, 0.15, 0.50], - // Members — base layer, delegate upward - ["m13", "Maya Johansson", "member", 0.15, 0.20, 0.30], - ["m14", "Nia Mensah", "member", 0.10, 0.40, 0.15], - ["m15", "Omar Farouk", "member", 0.30, 0.15, 0.20], - ["m16", "Priya Sharma", "member", 0.10, 0.30, 0.20], - ["m17", "Quinn O'Brien", "member", 0.20, 0.10, 0.15], - ["m18", "Rosa Gutierrez", "member", 0.15, 0.20, 0.10], - ["m19", "Sam Achebe", "member", 0.10, 0.10, 0.35], - ["m20", "Tara Singh", "member", 0.05, 0.25, 0.10], - ["m21", "Uri Goldberg", "member", 0.15, 0.08, 0.12], - ["m22", "Valentina Costa", "member", 0.08, 0.15, 0.10], - ["m23", "Wei Zhang", "member", 0.20, 0.10, 0.08], - ["m24", "Yuki Mori", "member", 0.06, 0.05, 0.18], - ["m37", "Eleni Papadopoulos", "member", 0.25, 0.10, 0.10], - ["m38", "Kwame Asante", "member", 0.10, 0.30, 0.08], - ["m39", "Astrid Lindgren", "member", 0.08, 0.10, 0.25], - ["m40", "Ravi Kapoor", "member", 0.22, 0.12, 0.10], - ["m41", "Nadia Petrov", "member", 0.10, 0.28, 0.12], - ["m42", "Javier Morales", "member", 0.12, 0.08, 0.22], - ["m43", "Asha Nair", "member", 0.20, 0.10, 0.08], - ["m44", "Pierre Dubois", "member", 0.08, 0.22, 0.10], - ["m45", "Hana Novak", "member", 0.10, 0.08, 0.20], - ["m46", "Kofi Mensah", "member", 0.18, 0.12, 0.06], - ["m47", "Isabella Romano", "member", 0.06, 0.20, 0.10], - ["m48", "Lars Eriksson", "member", 0.10, 0.06, 0.18], + // ── Admins (m001-m015): high trust weights ── + ["m001", "Alice Chen", "admin", 0.95, 0.50, 0.40], + ["m002", "Bob Martinez", "admin", 0.45, 0.90, 0.35], + ["m003", "Carol Okafor", "admin", 0.40, 0.35, 0.95], + ["m004", "David Kim", "admin", 0.80, 0.55, 0.45], + ["m005", "Eve Nakamura", "admin", 0.50, 0.40, 0.85], + ["m006", "Frank Osei", "admin", 0.35, 0.85, 0.30], + ["m007", "Grace Liu", "admin", 0.70, 0.30, 0.65], + ["m008", "Hassan Patel", "admin", 0.30, 0.75, 0.40], + ["m009", "Ingrid Svensson", "admin", 0.60, 0.45, 0.50], + ["m010", "Jorge Reyes", "admin", 0.40, 0.60, 0.55], + ["m011", "Kaia Tanaka", "admin", 0.55, 0.35, 0.80], + ["m012", "Leo Adeyemi", "admin", 0.45, 0.50, 0.70], + ["m013", "Anika Bergström", "admin", 0.75, 0.40, 0.35], + ["m014", "Rafael Oliveira", "admin", 0.35, 0.80, 0.45], + ["m015", "Chen Wei", "admin", 0.40, 0.35, 0.75], + // ── Members (m016-m050): medium weights ── + ["m016", "Fatima Al-Hassan", "member", 0.50, 0.20, 0.15], + ["m017", "Marcus Johnson", "member", 0.15, 0.55, 0.20], + ["m018", "Yuna Park", "member", 0.20, 0.15, 0.55], + ["m019", "Dmitri Volkov", "member", 0.45, 0.25, 0.20], + ["m020", "Amara Diallo", "member", 0.15, 0.50, 0.25], + ["m021", "Liam O'Connor", "member", 0.20, 0.20, 0.50], + ["m022", "Zara Hussain", "member", 0.45, 0.15, 0.20], + ["m023", "Tomás Herrera", "member", 0.20, 0.50, 0.15], + ["m024", "Sakura Ito", "member", 0.15, 0.15, 0.50], + ["m025", "Maya Johansson", "member", 0.30, 0.25, 0.35], + ["m026", "Nia Mensah", "member", 0.15, 0.45, 0.20], + ["m027", "Omar Farouk", "member", 0.40, 0.20, 0.25], + ["m028", "Priya Sharma", "member", 0.15, 0.35, 0.30], + ["m029", "Quinn O'Brien", "member", 0.30, 0.15, 0.35], + ["m030", "Rosa Gutierrez", "member", 0.20, 0.30, 0.25], + ["m031", "Sam Achebe", "member", 0.15, 0.15, 0.45], + ["m032", "Tara Singh", "member", 0.10, 0.40, 0.15], + ["m033", "Uri Goldberg", "member", 0.35, 0.15, 0.20], + ["m034", "Valentina Costa", "member", 0.15, 0.30, 0.25], + ["m035", "Wei Zhang", "member", 0.25, 0.20, 0.35], + ["m036", "Eleni Papadopoulos", "member", 0.40, 0.15, 0.15], + ["m037", "Kwame Asante", "member", 0.10, 0.40, 0.20], + ["m038", "Astrid Lindgren", "member", 0.15, 0.15, 0.40], + ["m039", "Ravi Kapoor", "member", 0.35, 0.20, 0.15], + ["m040", "Nadia Petrov", "member", 0.15, 0.35, 0.20], + ["m041", "Javier Morales", "member", 0.20, 0.10, 0.35], + ["m042", "Asha Nair", "member", 0.30, 0.15, 0.20], + ["m043", "Pierre Dubois", "member", 0.10, 0.30, 0.25], + ["m044", "Hana Novak", "member", 0.20, 0.15, 0.30], + ["m045", "Kofi Mensah", "member", 0.25, 0.20, 0.10], + ["m046", "Isabella Romano", "member", 0.10, 0.30, 0.15], + ["m047", "Lars Eriksson", "member", 0.15, 0.10, 0.30], + ["m048", "Miriam Bauer", "member", 0.30, 0.15, 0.25], + ["m049", "Kwesi Boateng", "member", 0.15, 0.35, 0.15], + ["m050", "Sonia Pereira", "member", 0.20, 0.15, 0.35], + // ── Viewers (m051-m150): lower weights ── + ["m051", "Aiden Murphy", "viewer", 0.10, 0.08, 0.05], + ["m052", "Bianca Rossi", "viewer", 0.05, 0.12, 0.06], + ["m053", "Carlos Vega", "viewer", 0.08, 0.05, 0.10], + ["m054", "Diana Popescu", "viewer", 0.12, 0.06, 0.05], + ["m055", "Erik Johansson", "viewer", 0.05, 0.10, 0.08], + ["m056", "Fiona Walsh", "viewer", 0.06, 0.05, 0.12], + ["m057", "Gustavo Lima", "viewer", 0.10, 0.08, 0.06], + ["m058", "Helen Payne", "viewer", 0.05, 0.12, 0.08], + ["m059", "Ivan Kozlov", "viewer", 0.08, 0.05, 0.10], + ["m060", "Julia Fernández", "viewer", 0.12, 0.10, 0.05], + ["m061", "Kenji Yamamoto", "viewer", 0.05, 0.06, 0.12], + ["m062", "Luna Martínez", "viewer", 0.08, 0.10, 0.06], + ["m063", "Mateo Cruz", "viewer", 0.10, 0.05, 0.08], + ["m064", "Nina Kowalski", "viewer", 0.06, 0.12, 0.05], + ["m065", "Oscar Blom", "viewer", 0.05, 0.08, 0.10], + ["m066", "Petra Schwarzer", "viewer", 0.12, 0.05, 0.06], + ["m067", "Ricardo Alves", "viewer", 0.08, 0.10, 0.05], + ["m068", "Sofia Andersson", "viewer", 0.05, 0.06, 0.12], + ["m069", "Tariq Ahmed", "viewer", 0.10, 0.08, 0.06], + ["m070", "Uma Krishnan", "viewer", 0.06, 0.05, 0.10], + ["m071", "Viktor Novak", "viewer", 0.08, 0.12, 0.05], + ["m072", "Wendy Chu", "viewer", 0.05, 0.10, 0.08], + ["m073", "Xander Visser", "viewer", 0.10, 0.05, 0.06], + ["m074", "Yasmin El-Amin", "viewer", 0.06, 0.08, 0.12], + ["m075", "Zoran Petrović", "viewer", 0.12, 0.06, 0.05], + ["m076", "Ada Lovelace", "viewer", 0.05, 0.10, 0.08], + ["m077", "Bruno Martins", "viewer", 0.08, 0.05, 0.12], + ["m078", "Clara Bianchi", "viewer", 0.10, 0.12, 0.06], + ["m079", "Daniel Okafor", "viewer", 0.06, 0.08, 0.10], + ["m080", "Emilia Sánchez", "viewer", 0.12, 0.05, 0.08], + ["m081", "Felix Braun", "viewer", 0.05, 0.10, 0.06], + ["m082", "Greta Holm", "viewer", 0.08, 0.06, 0.12], + ["m083", "Hugo Perrin", "viewer", 0.10, 0.08, 0.05], + ["m084", "Isla Campbell", "viewer", 0.06, 0.12, 0.10], + ["m085", "Jan Kowalczyk", "viewer", 0.05, 0.10, 0.08], + ["m086", "Kira Sokolova", "viewer", 0.12, 0.05, 0.06], + ["m087", "Luca Ferrari", "viewer", 0.08, 0.10, 0.05], + ["m088", "Mila Horvat", "viewer", 0.05, 0.06, 0.12], + ["m089", "Nils Hedberg", "viewer", 0.10, 0.08, 0.06], + ["m090", "Olivia Jensen", "viewer", 0.06, 0.05, 0.10], + ["m091", "Pavel Dvořák", "viewer", 0.08, 0.12, 0.05], + ["m092", "Rosa Delgado", "viewer", 0.05, 0.10, 0.08], + ["m093", "Stefan Ionescu", "viewer", 0.10, 0.05, 0.12], + ["m094", "Teresa Gomes", "viewer", 0.06, 0.08, 0.10], + ["m095", "Udo Fischer", "viewer", 0.12, 0.06, 0.05], + ["m096", "Vera Smirnova", "viewer", 0.05, 0.10, 0.06], + ["m097", "William Park", "viewer", 0.08, 0.05, 0.12], + ["m098", "Xia Chen", "viewer", 0.10, 0.12, 0.08], + ["m099", "Youssef Karam", "viewer", 0.06, 0.08, 0.05], + ["m100", "Zlata Bogdanović", "viewer", 0.12, 0.05, 0.10], + ["m101", "Arjun Mehta", "viewer", 0.05, 0.10, 0.06], + ["m102", "Beatriz Nunes", "viewer", 0.08, 0.06, 0.12], + ["m103", "Conrad Lehmann", "viewer", 0.10, 0.08, 0.05], + ["m104", "Dahlia Osman", "viewer", 0.06, 0.12, 0.10], + ["m105", "Elio Conti", "viewer", 0.05, 0.10, 0.08], + ["m106", "Freya Bergman", "viewer", 0.12, 0.05, 0.06], + ["m107", "George Adamu", "viewer", 0.08, 0.10, 0.05], + ["m108", "Hilde Strand", "viewer", 0.05, 0.06, 0.12], + ["m109", "Isak Nilsson", "viewer", 0.10, 0.08, 0.06], + ["m110", "Jade Thompson", "viewer", 0.06, 0.05, 0.10], + ["m111", "Karim Bouzid", "viewer", 0.08, 0.12, 0.05], + ["m112", "Leila Sharif", "viewer", 0.05, 0.10, 0.08], + ["m113", "Marco Colombo", "viewer", 0.10, 0.05, 0.12], + ["m114", "Naomi Okeke", "viewer", 0.06, 0.08, 0.10], + ["m115", "Otto Muller", "viewer", 0.12, 0.06, 0.05], + ["m116", "Pilar Reyes", "viewer", 0.05, 0.10, 0.06], + ["m117", "Ragnar Haugen", "viewer", 0.08, 0.05, 0.12], + ["m118", "Selma Kaya", "viewer", 0.10, 0.12, 0.08], + ["m119", "Theo Laurent", "viewer", 0.06, 0.08, 0.05], + ["m120", "Ulrike Becker", "viewer", 0.12, 0.05, 0.10], + ["m121", "Vito Moretti", "viewer", 0.05, 0.10, 0.06], + ["m122", "Wanda Kwiatkowska", "viewer", 0.08, 0.06, 0.12], + ["m123", "Xavier Dumont", "viewer", 0.10, 0.08, 0.05], + ["m124", "Yara Costa", "viewer", 0.06, 0.12, 0.10], + ["m125", "Zane Mitchell", "viewer", 0.05, 0.10, 0.08], + ["m126", "Anya Volkov", "viewer", 0.12, 0.05, 0.06], + ["m127", "Bastian Krüger", "viewer", 0.08, 0.10, 0.05], + ["m128", "Celeste Dupont", "viewer", 0.05, 0.06, 0.12], + ["m129", "Dario Mancini", "viewer", 0.10, 0.08, 0.06], + ["m130", "Elena Todorov", "viewer", 0.06, 0.05, 0.10], + ["m131", "Finn O'Sullivan", "viewer", 0.08, 0.12, 0.05], + ["m132", "Giulia Rizzo", "viewer", 0.05, 0.10, 0.08], + ["m133", "Henrik Dahl", "viewer", 0.10, 0.05, 0.12], + ["m134", "Irene Papazoglou", "viewer", 0.06, 0.08, 0.10], + ["m135", "Jakob Andersen", "viewer", 0.12, 0.06, 0.05], + ["m136", "Kamila Szymańska", "viewer", 0.05, 0.10, 0.06], + ["m137", "Leon Hartmann", "viewer", 0.08, 0.05, 0.12], + ["m138", "Maria Alonso", "viewer", 0.10, 0.12, 0.08], + ["m139", "Noah Bakker", "viewer", 0.06, 0.08, 0.05], + ["m140", "Olga Fedorova", "viewer", 0.12, 0.05, 0.10], + ["m141", "Patrick Byrne", "viewer", 0.05, 0.10, 0.06], + ["m142", "Renata Vlad", "viewer", 0.08, 0.06, 0.12], + ["m143", "Sven Lund", "viewer", 0.10, 0.08, 0.05], + ["m144", "Tatiana Morozova", "viewer", 0.06, 0.12, 0.10], + ["m145", "Umberto Greco", "viewer", 0.05, 0.10, 0.08], + ["m146", "Violeta Stoica", "viewer", 0.12, 0.05, 0.06], + ["m147", "Walter Schmidt", "viewer", 0.08, 0.10, 0.05], + ["m148", "Xiomara Ríos", "viewer", 0.05, 0.06, 0.12], + ["m149", "Yannick Morel", "viewer", 0.10, 0.08, 0.06], + ["m150", "Zuzana Horváthová", "viewer", 0.06, 0.05, 0.10], ]; for (const [id, name, role, govW, finW, devW] of members) { @@ -288,76 +399,73 @@ routes.get("/api/graph", async (c) => { }); } - // ── Delegation edges: who delegates to whom, per authority ── - // Gov-ops delegations — Alice (m01) is top target, m04/m25 are secondary hubs - const govDelegations: Array<[string, string, number]> = [ - // Council → stewards - ["m04", "m01", 0.7], ["m05", "m01", 0.5], ["m06", "m01", 0.4], - ["m25", "m01", 0.8], ["m26", "m01", 0.3], ["m27", "m01", 0.3], - // Council cross-delegation - ["m06", "m04", 0.3], ["m26", "m25", 0.4], - // Contributors → council/stewards - ["m07", "m04", 0.6], ["m08", "m25", 0.4], ["m09", "m01", 0.6], - ["m10", "m04", 0.3], ["m11", "m25", 0.3], ["m12", "m04", 0.4], - ["m28", "m01", 0.5], ["m29", "m25", 0.3], ["m30", "m04", 0.3], - ["m31", "m01", 0.5], ["m32", "m25", 0.3], ["m33", "m04", 0.3], - ["m34", "m25", 0.5], ["m35", "m04", 0.3], ["m36", "m25", 0.3], - // Members → contributors/council - ["m13", "m09", 0.3], ["m14", "m28", 0.3], ["m15", "m01", 0.4], - ["m16", "m04", 0.2], ["m17", "m31", 0.3], ["m18", "m09", 0.2], - ["m19", "m34", 0.2], ["m20", "m25", 0.2], ["m21", "m28", 0.3], - ["m22", "m04", 0.2], ["m23", "m01", 0.4], ["m24", "m31", 0.1], - ["m37", "m01", 0.4], ["m38", "m25", 0.2], ["m39", "m04", 0.2], - ["m40", "m09", 0.3], ["m41", "m28", 0.2], ["m42", "m25", 0.2], - ["m43", "m01", 0.3], ["m44", "m04", 0.2], ["m45", "m34", 0.2], - ["m46", "m25", 0.3], ["m47", "m31", 0.1], ["m48", "m04", 0.2], - ]; - // Fin-ops delegations — Bob (m02) is top target, m06/m26 are secondary hubs - const finDelegations: Array<[string, string, number]> = [ - // Council → stewards - ["m04", "m02", 0.6], ["m05", "m06", 0.5], ["m06", "m02", 0.7], - ["m25", "m02", 0.3], ["m26", "m02", 0.8], ["m27", "m06", 0.3], - // Council cross-delegation - ["m04", "m06", 0.3], ["m25", "m26", 0.3], - // Contributors → council/stewards - ["m07", "m06", 0.4], ["m08", "m02", 0.7], ["m09", "m26", 0.3], - ["m10", "m02", 0.6], ["m11", "m06", 0.4], ["m12", "m26", 0.3], - ["m28", "m06", 0.3], ["m29", "m02", 0.6], ["m30", "m26", 0.3], - ["m31", "m06", 0.3], ["m32", "m02", 0.5], ["m33", "m26", 0.3], - ["m34", "m06", 0.2], ["m35", "m02", 0.6], ["m36", "m26", 0.3], - // Members → contributors/council - ["m13", "m06", 0.3], ["m14", "m02", 0.5], ["m15", "m26", 0.2], - ["m16", "m08", 0.4], ["m17", "m06", 0.2], ["m18", "m02", 0.3], - ["m19", "m10", 0.2], ["m20", "m26", 0.4], ["m21", "m06", 0.1], - ["m22", "m02", 0.3], ["m23", "m29", 0.2], ["m24", "m32", 0.1], - ["m37", "m26", 0.2], ["m38", "m02", 0.4], ["m39", "m06", 0.2], - ["m40", "m29", 0.2], ["m41", "m02", 0.4], ["m42", "m26", 0.1], - ["m43", "m06", 0.2], ["m44", "m02", 0.3], ["m45", "m32", 0.1], - ["m46", "m26", 0.2], ["m47", "m02", 0.3], ["m48", "m35", 0.1], - ]; - // Dev-ops delegations — Carol (m03) is top target, m05/m27 are secondary hubs - const devDelegations: Array<[string, string, number]> = [ - // Council → stewards - ["m04", "m03", 0.4], ["m05", "m03", 0.7], ["m06", "m03", 0.3], - ["m25", "m03", 0.3], ["m26", "m27", 0.3], ["m27", "m03", 0.7], - // Council cross-delegation - ["m04", "m05", 0.3], ["m25", "m27", 0.3], - // Contributors → council/stewards - ["m07", "m03", 0.7], ["m08", "m05", 0.4], ["m09", "m27", 0.4], - ["m10", "m05", 0.4], ["m11", "m03", 0.7], ["m12", "m27", 0.6], - ["m28", "m05", 0.3], ["m29", "m27", 0.3], ["m30", "m03", 0.6], - ["m31", "m05", 0.3], ["m32", "m27", 0.3], ["m33", "m03", 0.5], - ["m34", "m27", 0.3], ["m35", "m05", 0.2], ["m36", "m03", 0.6], - // Members → contributors/council - ["m13", "m07", 0.4], ["m14", "m05", 0.2], ["m15", "m27", 0.3], - ["m16", "m11", 0.3], ["m17", "m07", 0.2], ["m18", "m30", 0.2], - ["m19", "m03", 0.5], ["m20", "m27", 0.2], ["m21", "m11", 0.2], - ["m22", "m05", 0.2], ["m23", "m33", 0.1], ["m24", "m03", 0.3], - ["m37", "m27", 0.2], ["m38", "m05", 0.1], ["m39", "m03", 0.3], - ["m40", "m30", 0.2], ["m41", "m27", 0.2], ["m42", "m03", 0.3], - ["m43", "m36", 0.1], ["m44", "m05", 0.2], ["m45", "m03", 0.3], - ["m46", "m33", 0.1], ["m47", "m27", 0.2], ["m48", "m03", 0.3], - ]; + // ── Generate delegation edges deterministically ── + function generateDemoDelegations( + members: Array<[string, string, string, number, number, number]>, + authority: string, + weightIdx: number, // 3=gov, 4=fin, 5=dev + seed: number, + ): Array<[string, string, number]> { + const rng = mulberry32(seed); + const result: Array<[string, string, number]> = []; + const outboundSum = new Map(); + + const admins = members.filter(m => m[2] === "admin"); + const mems = members.filter(m => m[2] === "member"); + const viewers = members.filter(m => m[2] === "viewer"); + + // Viewers → members/admins (2-3 edges each) + for (const v of viewers) { + const edgeCount = 2 + (rng() < 0.4 ? 1 : 0); + const targets = [...mems, ...admins].sort(() => rng() - 0.5).slice(0, edgeCount); + let remaining = Math.min(v[weightIdx], 0.9); + for (let i = 0; i < targets.length && remaining > 0.02; i++) { + const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.6 * 100) / 100 : Math.round(remaining * 100) / 100; + const clamped = Math.min(w, remaining); + if (clamped > 0.01) { + result.push([v[0], targets[i][0], clamped]); + remaining -= clamped; + outboundSum.set(v[0], (outboundSum.get(v[0]) || 0) + clamped); + } + } + } + + // Members → admins (3-4 edges each) + for (const m of mems) { + const edgeCount = 3 + (rng() < 0.3 ? 1 : 0); + const targets = [...admins].sort(() => rng() - 0.5).slice(0, edgeCount); + let remaining = Math.min(m[weightIdx], 0.95) - (outboundSum.get(m[0]) || 0); + for (let i = 0; i < targets.length && remaining > 0.02; i++) { + const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.5 * 100) / 100 : Math.round(remaining * 100) / 100; + const clamped = Math.min(w, remaining); + if (clamped > 0.01) { + result.push([m[0], targets[i][0], clamped]); + remaining -= clamped; + outboundSum.set(m[0], (outboundSum.get(m[0]) || 0) + clamped); + } + } + } + + // Admins → other admins (1-2 edges each) + for (const a of admins) { + const edgeCount = 1 + (rng() < 0.4 ? 1 : 0); + const targets = admins.filter(t => t[0] !== a[0]).sort(() => rng() - 0.5).slice(0, edgeCount); + let remaining = Math.min(a[weightIdx] * 0.4, 0.5) - (outboundSum.get(a[0]) || 0); + for (let i = 0; i < targets.length && remaining > 0.02; i++) { + const w = Math.round(Math.min(rng() * 0.3 + 0.05, remaining) * 100) / 100; + if (w > 0.01) { + result.push([a[0], targets[i][0], w]); + remaining -= w; + } + } + } + + return result; + } + + const govDelegations = generateDemoDelegations(members, "gov-ops", 3, 42); + const finDelegations = generateDemoDelegations(members, "fin-ops", 4, 137); + const devDelegations = generateDemoDelegations(members, "dev-ops", 5, 271); for (const [from, to, weight] of govDelegations) { edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "gov-ops" } as any);