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 <noreply@anthropic.com>
This commit is contained in:
parent
af43e98812
commit
98cd239418
|
|
@ -31,6 +31,11 @@ interface SpaceUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
const AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
||||||
|
const DM_AUTHORITY_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||||
|
"gov-ops": { label: "Gov", color: "#a78bfa" },
|
||||||
|
"fin-ops": { label: "Econ", color: "#10b981" },
|
||||||
|
"dev-ops": { label: "Tech", color: "#3b82f6" },
|
||||||
|
};
|
||||||
const AUTHORITY_ICONS: Record<string, string> = {
|
const AUTHORITY_ICONS: Record<string, string> = {
|
||||||
"gov-ops": "\u{1F3DB}\uFE0F",
|
"gov-ops": "\u{1F3DB}\uFE0F",
|
||||||
"fin-ops": "\u{1F4B0}",
|
"fin-ops": "\u{1F4B0}",
|
||||||
|
|
@ -219,7 +224,7 @@ class FolkDelegationManager extends HTMLElement {
|
||||||
<div class="authority-row">
|
<div class="authority-row">
|
||||||
<div class="authority-header">
|
<div class="authority-header">
|
||||||
<span class="authority-icon">${icon}</span>
|
<span class="authority-icon">${icon}</span>
|
||||||
<span class="authority-name">${authority}</span>
|
<span class="authority-name">${DM_AUTHORITY_DISPLAY[authority]?.label || authority}</span>
|
||||||
<span class="authority-pct">${pct}% delegated</span>
|
<span class="authority-pct">${pct}% delegated</span>
|
||||||
${inboundCount > 0 ? `<span class="authority-inbound">${inboundCount} received</span>` : ""}
|
${inboundCount > 0 ? `<span class="authority-inbound">${inboundCount} received</span>` : ""}
|
||||||
<button class="btn-add" data-add-authority="${authority}" title="Add delegation">+</button>
|
<button class="btn-add" data-add-authority="${authority}" title="Add delegation">+</button>
|
||||||
|
|
@ -261,7 +266,7 @@ class FolkDelegationManager extends HTMLElement {
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="field-label">Authority</label>
|
<label class="field-label">Authority</label>
|
||||||
<select class="field-select" id="modal-authority">
|
<select class="field-select" id="modal-authority">
|
||||||
${AUTHORITIES.map(a => `<option value="${a}" ${this.modalAuthority === a ? "selected" : ""}>${AUTHORITY_ICONS[a]} ${a}</option>`).join("")}
|
${AUTHORITIES.map(a => `<option value="${a}" ${this.modalAuthority === a ? "selected" : ""}>${AUTHORITY_ICONS[a]} ${DM_AUTHORITY_DISPLAY[a]?.label || a}</option>`).join("")}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="field-label">Delegate</label>
|
<label class="field-label">Delegate</label>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@
|
||||||
* Left-drag pans, scroll zooms, right-drag orbits.
|
* Left-drag pans, scroll zooms, right-drag orbits.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
interface WeightAccounting {
|
||||||
|
delegatedAway: Record<string, number>; // per authority: total weight delegated out
|
||||||
|
receivedWeight: Record<string, number>; // per authority: total weight received from others
|
||||||
|
effectiveWeight: Record<string, number>; // per authority: retained + received
|
||||||
|
}
|
||||||
|
|
||||||
interface GraphNode {
|
interface GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -16,10 +22,14 @@ interface GraphNode {
|
||||||
description?: string;
|
description?: string;
|
||||||
trustScore?: number;
|
trustScore?: number;
|
||||||
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
|
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
|
||||||
|
weightAccounting?: WeightAccounting;
|
||||||
// 3d-force-graph internal properties
|
// 3d-force-graph internal properties
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
z?: number;
|
z?: number;
|
||||||
|
fx?: number;
|
||||||
|
fy?: number;
|
||||||
|
fz?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GraphEdge {
|
interface GraphEdge {
|
||||||
|
|
@ -35,11 +45,18 @@ const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
||||||
type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
|
type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
|
||||||
type AuthoritySelection = "all" | DelegationAuthority;
|
type AuthoritySelection = "all" | DelegationAuthority;
|
||||||
|
|
||||||
// Per-authority edge colors for "all" overlay mode (governance, economics, technology)
|
// Authority display labels + colors (DB values unchanged)
|
||||||
|
const AUTHORITY_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||||
|
"gov-ops": { label: "Gov", color: "#a78bfa" }, // purple — governance
|
||||||
|
"fin-ops": { label: "Econ", color: "#10b981" }, // green — economics
|
||||||
|
"dev-ops": { label: "Tech", color: "#3b82f6" }, // blue — technology
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-authority edge colors for "all" overlay mode
|
||||||
const AUTHORITY_COLORS: Record<string, string> = {
|
const AUTHORITY_COLORS: Record<string, string> = {
|
||||||
"gov-ops": "#a78bfa", // purple — governance decisions
|
"gov-ops": "#a78bfa", // purple
|
||||||
"fin-ops": "#fbbf24", // amber — economic/financial decisions
|
"fin-ops": "#10b981", // green
|
||||||
"dev-ops": "#34d399", // green — technical decisions
|
"dev-ops": "#3b82f6", // blue
|
||||||
};
|
};
|
||||||
|
|
||||||
// Node colors by type
|
// Node colors by type
|
||||||
|
|
@ -77,6 +94,12 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
private selectedNode: GraphNode | null = null;
|
private selectedNode: GraphNode | null = null;
|
||||||
private trustMode = false;
|
private trustMode = false;
|
||||||
private authority: AuthoritySelection = "gov-ops";
|
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[] = [];
|
||||||
|
|
||||||
// 3D graph instance
|
// 3D graph instance
|
||||||
private graph: any = null;
|
private graph: any = null;
|
||||||
|
|
@ -185,21 +208,43 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
authority: e.authority,
|
authority: e.authority,
|
||||||
} as GraphEdge));
|
} as GraphEdge));
|
||||||
|
|
||||||
// Compute delegatedWeight for every node from delegation edges
|
// Weight accounting: per-authority delegated/received/effective
|
||||||
const nodeWeights = new Map<string, number>();
|
const authorities = ["gov-ops", "fin-ops", "dev-ops"];
|
||||||
|
const acctMap = new Map<string, WeightAccounting>();
|
||||||
|
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) {
|
for (const e of this.edges) {
|
||||||
if (e.type !== "delegates_to") continue;
|
if (e.type !== "delegates_to" || !e.authority) continue;
|
||||||
const sid = typeof e.source === "string" ? e.source : e.source.id;
|
const sid = typeof e.source === "string" ? e.source : e.source.id;
|
||||||
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
||||||
const w = e.weight || 0.5;
|
const w = e.weight || 0.5;
|
||||||
nodeWeights.set(sid, (nodeWeights.get(sid) || 0) + w);
|
const sAcct = acctMap.get(sid);
|
||||||
nodeWeights.set(tid, (nodeWeights.get(tid) || 0) + w);
|
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;
|
||||||
}
|
}
|
||||||
const maxWeight = Math.max(...nodeWeights.values(), 1);
|
acctMap.forEach((acct) => {
|
||||||
|
for (const a of authorities) {
|
||||||
|
acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Compute normalized delegatedWeight (for backwards compat) and attach accounting
|
||||||
|
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) {
|
for (const node of this.nodes) {
|
||||||
const w = nodeWeights.get(node.id);
|
const acct = acctMap.get(node.id);
|
||||||
if (w != null) {
|
if (acct) {
|
||||||
node.delegatedWeight = w / maxWeight;
|
node.weightAccounting = acct;
|
||||||
|
const avgEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0) / authorities.length;
|
||||||
|
node.delegatedWeight = avgEW / maxEW;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,20 +299,25 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
private getNodeRadius(node: GraphNode): number {
|
private getNodeRadius(node: GraphNode): number {
|
||||||
if (node.type === "company") return 22;
|
if (node.type === "company") return 22;
|
||||||
if (node.type === "space") return 16;
|
if (node.type === "space") return 16;
|
||||||
if (node.type === "rspace_user") {
|
if (this.trustMode && node.weightAccounting) {
|
||||||
if (this.trustMode) {
|
const acct = node.weightAccounting;
|
||||||
if (node.delegatedWeight != null) return 6 + node.delegatedWeight * 24;
|
let ew: number;
|
||||||
if (node.trustScore != null) return 6 + node.trustScore * 24;
|
if (this.authority !== "all") {
|
||||||
|
ew = acct.effectiveWeight[this.authority] || 0;
|
||||||
|
} else {
|
||||||
|
const vals = Object.values(acct.effectiveWeight);
|
||||||
|
ew = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||||||
}
|
}
|
||||||
return 10;
|
// Normalize against max in current filtered view
|
||||||
}
|
const maxEW = this._currentMaxEffectiveWeight || 1;
|
||||||
if (this.trustMode) {
|
return 4 + (ew / maxEW) * 26;
|
||||||
if (node.delegatedWeight != null) return 6 + node.delegatedWeight * 24;
|
|
||||||
if (node.trustScore != null) return 6 + node.trustScore * 24;
|
|
||||||
}
|
}
|
||||||
|
if (node.type === "rspace_user") return 10;
|
||||||
return 12;
|
return 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _currentMaxEffectiveWeight = 1;
|
||||||
|
|
||||||
private getConnectedNodes(nodeId: string): GraphNode[] {
|
private getConnectedNodes(nodeId: string): GraphNode[] {
|
||||||
const connIds = new Set<string>();
|
const connIds = new Set<string>();
|
||||||
for (const e of this.edges) {
|
for (const e of this.edges) {
|
||||||
|
|
@ -408,6 +458,37 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
.node-label-org { font-size: 11px; font-weight: 600; }
|
.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 {
|
||||||
|
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||||
|
border-radius: 12px; padding: 16px; margin-top: 12px; position: relative;
|
||||||
|
}
|
||||||
|
.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-slider-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
padding: 6px 16px; border: none; border-radius: 8px;
|
||||||
|
background: #a78bfa; color: #fff; cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: 600; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.graph-canvas { min-height: 300px; }
|
.graph-canvas { min-height: 300px; }
|
||||||
.workspace-list { grid-template-columns: 1fr; }
|
.workspace-list { grid-template-columns: 1fr; }
|
||||||
|
|
@ -425,11 +506,12 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
<button class="filter-btn" data-filter="opportunity">Opportunities</button>
|
<button class="filter-btn" data-filter="opportunity">Opportunities</button>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="authority-bar" id="authority-bar">
|
<div class="authority-bar" id="authority-bar">
|
||||||
<button class="authority-btn" data-authority="all">All</button>
|
<button class="authority-btn" data-authority="all">All</button>
|
||||||
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${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-canvas" id="graph-canvas">
|
||||||
|
|
@ -443,6 +525,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-panel" id="detail-panel"></div>
|
<div class="detail-panel" id="detail-panel"></div>
|
||||||
|
<div class="deleg-popup" id="deleg-popup"></div>
|
||||||
|
|
||||||
<div class="legend" id="legend">
|
<div class="legend" id="legend">
|
||||||
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
||||||
|
|
@ -454,9 +537,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"></line></svg> Point of contact</div>
|
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"></line></svg> Point of contact</div>
|
||||||
<div class="legend-item" id="legend-delegates" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"></line></svg> Delegates to</div>
|
<div class="legend-item" id="legend-delegates" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"></line></svg> Delegates to</div>
|
||||||
<span id="legend-authority-colors" style="display:none">
|
<span id="legend-authority-colors" style="display:none">
|
||||||
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Gov-Ops</div>
|
${DELEGATION_AUTHORITIES.map(a => `<div class="legend-item"><span class="legend-dot" style="background:${AUTHORITY_DISPLAY[a]?.color || '#a78bfa'}"></span> ${AUTHORITY_DISPLAY[a]?.label || a}</div>`).join("")}
|
||||||
<div class="legend-item"><span class="legend-dot" style="background:#fbbf24"></span> Fin-Ops</div>
|
|
||||||
<div class="legend-item"><span class="legend-dot" style="background:#34d399"></span> Dev-Ops</div>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -491,10 +572,29 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
this.trustMode = !this.trustMode;
|
this.trustMode = !this.trustMode;
|
||||||
const btn = this.shadow.getElementById("trust-toggle");
|
const btn = this.shadow.getElementById("trust-toggle");
|
||||||
if (btn) btn.classList.toggle("active", this.trustMode);
|
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.updateAuthorityBar();
|
||||||
this.loadData();
|
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
|
// Authority buttons
|
||||||
this.shadow.querySelectorAll("[data-authority]").forEach(el => {
|
this.shadow.querySelectorAll("[data-authority]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
|
|
@ -768,11 +868,20 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
group.add(label);
|
group.add(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust badge sprite
|
// Trust badge sprite — show per-authority effective weight in trust mode
|
||||||
if (node.type !== "company" && node.type !== "space") {
|
if (node.type !== "company" && node.type !== "space") {
|
||||||
|
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);
|
const trust = this.getTrustScore(node.id);
|
||||||
if (trust >= 0) {
|
if (trust >= 0) badgeText = String(trust);
|
||||||
const badge = this.createBadgeSprite(THREE, String(trust));
|
}
|
||||||
|
if (badgeText) {
|
||||||
|
const badge = this.createBadgeSprite(THREE, badgeText, badgeColor);
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.position.set(radius - 0.2, radius - 0.2, 0);
|
badge.position.set(radius - 0.2, radius - 0.2, 0);
|
||||||
group.add(badge);
|
group.add(badge);
|
||||||
|
|
@ -813,7 +922,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createBadgeSprite(THREE: any, text: string): any {
|
private createBadgeSprite(THREE: any, text: string, color = "#7c3aed"): any {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
|
|
@ -823,7 +932,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(32, 32, 28, 0, Math.PI * 2);
|
ctx.arc(32, 32, 28, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = "#7c3aed";
|
ctx.fillStyle = color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.font = "bold 24px system-ui, sans-serif";
|
ctx.font = "bold 24px system-ui, sans-serif";
|
||||||
|
|
@ -852,13 +961,37 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
const filtered = this.getFilteredNodes();
|
const filtered = this.getFilteredNodes();
|
||||||
const filteredIds = new Set(filtered.map(n => n.id));
|
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
|
// 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 sid = typeof e.source === "string" ? e.source : e.source.id;
|
||||||
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
||||||
return filteredIds.has(sid) && filteredIds.has(tid);
|
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({
|
this.graph.graphData({
|
||||||
nodes: filtered,
|
nodes: filtered,
|
||||||
links: filteredEdges,
|
links: filteredEdges,
|
||||||
|
|
@ -877,12 +1010,107 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none";
|
if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none";
|
||||||
if (authorityColors) authorityColors.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(() => {
|
setTimeout(() => {
|
||||||
if (this.graph) this.graph.zoomToFit(400, 40);
|
if (this.graph) this.graph.zoomToFit(400, 40);
|
||||||
}, 500);
|
}, 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 ──
|
// ── Incremental UI updates ──
|
||||||
|
|
||||||
private updateStatsBar() {
|
private updateStatsBar() {
|
||||||
|
|
@ -922,23 +1150,212 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
const connected = this.getConnectedNodes(n.id);
|
const connected = this.getConnectedNodes(n.id);
|
||||||
const trust = (n.type !== "company" && n.type !== "space") ? this.getTrustScore(n.id) : -1;
|
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 `<div class="detail-trust"><span class="trust-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span><span class="trust-bar"><span class="trust-fill" style="width:${Math.min(ew * 50, 100)}%;background:${disp?.color || '#a78bfa'}"></span></span><span class="trust-val" style="color:${disp?.color || '#a78bfa'}">${ew.toFixed(2)}</span></div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelegate = n.type === "rspace_user" || n.type === "person";
|
||||||
|
|
||||||
panel.classList.add("visible");
|
panel.classList.add("visible");
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}</span>
|
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}</span>
|
||||||
<div class="detail-info">
|
<div class="detail-info">
|
||||||
<div class="detail-name">${this.esc(n.name)}</div>
|
<div class="detail-name">${this.esc(n.name)}</div>
|
||||||
<div class="detail-type">${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)}` : ""}</div>
|
<div class="detail-type">${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)}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="detail-close" id="close-detail">\u2715</button>
|
<button class="detail-close" id="close-detail">\u2715</button>
|
||||||
</div>
|
</div>
|
||||||
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
||||||
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
||||||
|
${weightHtml}
|
||||||
|
${canDelegate ? `<button class="btn-delegate" id="btn-delegate">Delegate to ${this.esc(n.name.split(" ")[0])}</button>` : ""}
|
||||||
${connected.length > 0 ? `
|
${connected.length > 0 ? `
|
||||||
<div class="detail-section">Connected (${connected.length})</div>
|
<div class="detail-section">Connected (${connected.length})</div>
|
||||||
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
|
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).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 = `
|
||||||
|
<button class="deleg-popup-close" id="deleg-close">\u2715</button>
|
||||||
|
<div class="deleg-popup-title">Delegate to ${this.esc(target.name)}</div>
|
||||||
|
|
||||||
|
<div class="deleg-step-label">Step 1: Total weight</div>
|
||||||
|
<div class="deleg-slider-row">
|
||||||
|
<span class="deleg-slider-label">Total</span>
|
||||||
|
<input type="range" class="deleg-slider" id="deleg-total" min="0" max="100" value="${total}">
|
||||||
|
<span class="deleg-slider-val" id="deleg-total-val">${total}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deleg-step-label">Step 2: Domain split</div>
|
||||||
|
${DELEGATION_AUTHORITIES.map(a => {
|
||||||
|
const disp = AUTHORITY_DISPLAY[a];
|
||||||
|
const pct = split[a] || 0;
|
||||||
|
return `<div class="deleg-slider-row">
|
||||||
|
<span class="deleg-slider-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span>
|
||||||
|
<input type="range" class="deleg-slider" data-deleg-auth="${a}" min="0" max="100" value="${pct}" style="accent-color:${disp?.color || '#a78bfa'}">
|
||||||
|
<span class="deleg-slider-val" data-deleg-val="${a}">${pct}%</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}
|
||||||
|
|
||||||
|
<button class="deleg-confirm" id="deleg-confirm">Confirm Delegation</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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<string, WeightAccounting>();
|
||||||
|
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() {
|
private updateWorkspaceList() {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@ interface TrustEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
||||||
|
const SANKEY_AUTHORITY_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||||
|
"gov-ops": { label: "Gov", color: "#a78bfa" },
|
||||||
|
"fin-ops": { label: "Econ", color: "#10b981" },
|
||||||
|
"dev-ops": { label: "Tech", color: "#3b82f6" },
|
||||||
|
};
|
||||||
const FLOW_COLOR = "#a78bfa";
|
const FLOW_COLOR = "#a78bfa";
|
||||||
|
|
||||||
class FolkTrustSankey extends HTMLElement {
|
class FolkTrustSankey extends HTMLElement {
|
||||||
|
|
@ -321,7 +326,7 @@ class FolkTrustSankey extends HTMLElement {
|
||||||
<div class="sankey-header">
|
<div class="sankey-header">
|
||||||
<span class="sankey-title">Delegation Flows</span>
|
<span class="sankey-title">Delegation Flows</span>
|
||||||
<div class="authority-filter">
|
<div class="authority-filter">
|
||||||
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${a}</button>`).join("")}
|
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${SANKEY_AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,61 +218,172 @@ routes.get("/api/graph", async (c) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
isDemoData = true;
|
isDemoData = true;
|
||||||
|
|
||||||
// ── Demo: 48 members with delegation-based trust flows ──
|
// ── Demo: 150 members with delegation-based trust flows ──
|
||||||
// Members: id, name, role, delegatedWeight per authority (gov, fin, dev)
|
// 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]> = [
|
const members: Array<[string, string, string, number, number, number]> = [
|
||||||
// Stewards — top-level, high trust across boards
|
// ── Admins (m001-m015): high trust weights ──
|
||||||
["m01", "Alice Chen", "steward", 0.95, 0.50, 0.40],
|
["m001", "Alice Chen", "admin", 0.95, 0.50, 0.40],
|
||||||
["m02", "Bob Martinez", "steward", 0.45, 0.90, 0.35],
|
["m002", "Bob Martinez", "admin", 0.45, 0.90, 0.35],
|
||||||
["m03", "Carol Okafor", "steward", 0.40, 0.35, 0.95],
|
["m003", "Carol Okafor", "admin", 0.40, 0.35, 0.95],
|
||||||
// Council — secondary hubs
|
["m004", "David Kim", "admin", 0.80, 0.55, 0.45],
|
||||||
["m04", "David Kim", "council", 0.70, 0.40, 0.35],
|
["m005", "Eve Nakamura", "admin", 0.50, 0.40, 0.85],
|
||||||
["m05", "Eve Nakamura", "council", 0.35, 0.30, 0.75],
|
["m006", "Frank Osei", "admin", 0.35, 0.85, 0.30],
|
||||||
["m06", "Frank Osei", "council", 0.30, 0.80, 0.25],
|
["m007", "Grace Liu", "admin", 0.70, 0.30, 0.65],
|
||||||
["m25", "Anika Bergström", "council", 0.65, 0.30, 0.25],
|
["m008", "Hassan Patel", "admin", 0.30, 0.75, 0.40],
|
||||||
["m26", "Rafael Oliveira", "council", 0.25, 0.75, 0.30],
|
["m009", "Ingrid Svensson", "admin", 0.60, 0.45, 0.50],
|
||||||
["m27", "Chen Wei", "council", 0.30, 0.25, 0.70],
|
["m010", "Jorge Reyes", "admin", 0.40, 0.60, 0.55],
|
||||||
// Contributors — mid-tier, specialize in one vertical
|
["m011", "Kaia Tanaka", "admin", 0.55, 0.35, 0.80],
|
||||||
["m07", "Grace Liu", "contributor", 0.50, 0.20, 0.65],
|
["m012", "Leo Adeyemi", "admin", 0.45, 0.50, 0.70],
|
||||||
["m08", "Hassan Patel", "contributor", 0.20, 0.60, 0.30],
|
["m013", "Anika Bergström", "admin", 0.75, 0.40, 0.35],
|
||||||
["m09", "Ingrid Svensson", "contributor", 0.55, 0.25, 0.30],
|
["m014", "Rafael Oliveira", "admin", 0.35, 0.80, 0.45],
|
||||||
["m10", "Jorge Reyes", "contributor", 0.20, 0.55, 0.25],
|
["m015", "Chen Wei", "admin", 0.40, 0.35, 0.75],
|
||||||
["m11", "Kaia Tanaka", "contributor", 0.15, 0.20, 0.65],
|
// ── Members (m016-m050): medium weights ──
|
||||||
["m12", "Leo Adeyemi", "contributor", 0.20, 0.15, 0.60],
|
["m016", "Fatima Al-Hassan", "member", 0.50, 0.20, 0.15],
|
||||||
["m28", "Fatima Al-Hassan", "contributor", 0.50, 0.20, 0.15],
|
["m017", "Marcus Johnson", "member", 0.15, 0.55, 0.20],
|
||||||
["m29", "Marcus Johnson", "contributor", 0.15, 0.55, 0.20],
|
["m018", "Yuna Park", "member", 0.20, 0.15, 0.55],
|
||||||
["m30", "Yuna Park", "contributor", 0.20, 0.15, 0.55],
|
["m019", "Dmitri Volkov", "member", 0.45, 0.25, 0.20],
|
||||||
["m31", "Dmitri Volkov", "contributor", 0.45, 0.25, 0.20],
|
["m020", "Amara Diallo", "member", 0.15, 0.50, 0.25],
|
||||||
["m32", "Amara Diallo", "contributor", 0.15, 0.50, 0.25],
|
["m021", "Liam O'Connor", "member", 0.20, 0.20, 0.50],
|
||||||
["m33", "Liam O'Connor", "contributor", 0.20, 0.20, 0.50],
|
["m022", "Zara Hussain", "member", 0.45, 0.15, 0.20],
|
||||||
["m34", "Zara Hussain", "contributor", 0.45, 0.15, 0.20],
|
["m023", "Tomás Herrera", "member", 0.20, 0.50, 0.15],
|
||||||
["m35", "Tomás Herrera", "contributor", 0.20, 0.50, 0.15],
|
["m024", "Sakura Ito", "member", 0.15, 0.15, 0.50],
|
||||||
["m36", "Sakura Ito", "contributor", 0.15, 0.15, 0.50],
|
["m025", "Maya Johansson", "member", 0.30, 0.25, 0.35],
|
||||||
// Members — base layer, delegate upward
|
["m026", "Nia Mensah", "member", 0.15, 0.45, 0.20],
|
||||||
["m13", "Maya Johansson", "member", 0.15, 0.20, 0.30],
|
["m027", "Omar Farouk", "member", 0.40, 0.20, 0.25],
|
||||||
["m14", "Nia Mensah", "member", 0.10, 0.40, 0.15],
|
["m028", "Priya Sharma", "member", 0.15, 0.35, 0.30],
|
||||||
["m15", "Omar Farouk", "member", 0.30, 0.15, 0.20],
|
["m029", "Quinn O'Brien", "member", 0.30, 0.15, 0.35],
|
||||||
["m16", "Priya Sharma", "member", 0.10, 0.30, 0.20],
|
["m030", "Rosa Gutierrez", "member", 0.20, 0.30, 0.25],
|
||||||
["m17", "Quinn O'Brien", "member", 0.20, 0.10, 0.15],
|
["m031", "Sam Achebe", "member", 0.15, 0.15, 0.45],
|
||||||
["m18", "Rosa Gutierrez", "member", 0.15, 0.20, 0.10],
|
["m032", "Tara Singh", "member", 0.10, 0.40, 0.15],
|
||||||
["m19", "Sam Achebe", "member", 0.10, 0.10, 0.35],
|
["m033", "Uri Goldberg", "member", 0.35, 0.15, 0.20],
|
||||||
["m20", "Tara Singh", "member", 0.05, 0.25, 0.10],
|
["m034", "Valentina Costa", "member", 0.15, 0.30, 0.25],
|
||||||
["m21", "Uri Goldberg", "member", 0.15, 0.08, 0.12],
|
["m035", "Wei Zhang", "member", 0.25, 0.20, 0.35],
|
||||||
["m22", "Valentina Costa", "member", 0.08, 0.15, 0.10],
|
["m036", "Eleni Papadopoulos", "member", 0.40, 0.15, 0.15],
|
||||||
["m23", "Wei Zhang", "member", 0.20, 0.10, 0.08],
|
["m037", "Kwame Asante", "member", 0.10, 0.40, 0.20],
|
||||||
["m24", "Yuki Mori", "member", 0.06, 0.05, 0.18],
|
["m038", "Astrid Lindgren", "member", 0.15, 0.15, 0.40],
|
||||||
["m37", "Eleni Papadopoulos", "member", 0.25, 0.10, 0.10],
|
["m039", "Ravi Kapoor", "member", 0.35, 0.20, 0.15],
|
||||||
["m38", "Kwame Asante", "member", 0.10, 0.30, 0.08],
|
["m040", "Nadia Petrov", "member", 0.15, 0.35, 0.20],
|
||||||
["m39", "Astrid Lindgren", "member", 0.08, 0.10, 0.25],
|
["m041", "Javier Morales", "member", 0.20, 0.10, 0.35],
|
||||||
["m40", "Ravi Kapoor", "member", 0.22, 0.12, 0.10],
|
["m042", "Asha Nair", "member", 0.30, 0.15, 0.20],
|
||||||
["m41", "Nadia Petrov", "member", 0.10, 0.28, 0.12],
|
["m043", "Pierre Dubois", "member", 0.10, 0.30, 0.25],
|
||||||
["m42", "Javier Morales", "member", 0.12, 0.08, 0.22],
|
["m044", "Hana Novak", "member", 0.20, 0.15, 0.30],
|
||||||
["m43", "Asha Nair", "member", 0.20, 0.10, 0.08],
|
["m045", "Kofi Mensah", "member", 0.25, 0.20, 0.10],
|
||||||
["m44", "Pierre Dubois", "member", 0.08, 0.22, 0.10],
|
["m046", "Isabella Romano", "member", 0.10, 0.30, 0.15],
|
||||||
["m45", "Hana Novak", "member", 0.10, 0.08, 0.20],
|
["m047", "Lars Eriksson", "member", 0.15, 0.10, 0.30],
|
||||||
["m46", "Kofi Mensah", "member", 0.18, 0.12, 0.06],
|
["m048", "Miriam Bauer", "member", 0.30, 0.15, 0.25],
|
||||||
["m47", "Isabella Romano", "member", 0.06, 0.20, 0.10],
|
["m049", "Kwesi Boateng", "member", 0.15, 0.35, 0.15],
|
||||||
["m48", "Lars Eriksson", "member", 0.10, 0.06, 0.18],
|
["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) {
|
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 ──
|
// ── Generate delegation edges deterministically ──
|
||||||
// Gov-ops delegations — Alice (m01) is top target, m04/m25 are secondary hubs
|
function generateDemoDelegations(
|
||||||
const govDelegations: Array<[string, string, number]> = [
|
members: Array<[string, string, string, number, number, number]>,
|
||||||
// Council → stewards
|
authority: string,
|
||||||
["m04", "m01", 0.7], ["m05", "m01", 0.5], ["m06", "m01", 0.4],
|
weightIdx: number, // 3=gov, 4=fin, 5=dev
|
||||||
["m25", "m01", 0.8], ["m26", "m01", 0.3], ["m27", "m01", 0.3],
|
seed: number,
|
||||||
// Council cross-delegation
|
): Array<[string, string, number]> {
|
||||||
["m06", "m04", 0.3], ["m26", "m25", 0.4],
|
const rng = mulberry32(seed);
|
||||||
// Contributors → council/stewards
|
const result: Array<[string, string, number]> = [];
|
||||||
["m07", "m04", 0.6], ["m08", "m25", 0.4], ["m09", "m01", 0.6],
|
const outboundSum = new Map<string, number>();
|
||||||
["m10", "m04", 0.3], ["m11", "m25", 0.3], ["m12", "m04", 0.4],
|
|
||||||
["m28", "m01", 0.5], ["m29", "m25", 0.3], ["m30", "m04", 0.3],
|
const admins = members.filter(m => m[2] === "admin");
|
||||||
["m31", "m01", 0.5], ["m32", "m25", 0.3], ["m33", "m04", 0.3],
|
const mems = members.filter(m => m[2] === "member");
|
||||||
["m34", "m25", 0.5], ["m35", "m04", 0.3], ["m36", "m25", 0.3],
|
const viewers = members.filter(m => m[2] === "viewer");
|
||||||
// Members → contributors/council
|
|
||||||
["m13", "m09", 0.3], ["m14", "m28", 0.3], ["m15", "m01", 0.4],
|
// Viewers → members/admins (2-3 edges each)
|
||||||
["m16", "m04", 0.2], ["m17", "m31", 0.3], ["m18", "m09", 0.2],
|
for (const v of viewers) {
|
||||||
["m19", "m34", 0.2], ["m20", "m25", 0.2], ["m21", "m28", 0.3],
|
const edgeCount = 2 + (rng() < 0.4 ? 1 : 0);
|
||||||
["m22", "m04", 0.2], ["m23", "m01", 0.4], ["m24", "m31", 0.1],
|
const targets = [...mems, ...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
|
||||||
["m37", "m01", 0.4], ["m38", "m25", 0.2], ["m39", "m04", 0.2],
|
let remaining = Math.min(v[weightIdx], 0.9);
|
||||||
["m40", "m09", 0.3], ["m41", "m28", 0.2], ["m42", "m25", 0.2],
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
||||||
["m43", "m01", 0.3], ["m44", "m04", 0.2], ["m45", "m34", 0.2],
|
const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.6 * 100) / 100 : Math.round(remaining * 100) / 100;
|
||||||
["m46", "m25", 0.3], ["m47", "m31", 0.1], ["m48", "m04", 0.2],
|
const clamped = Math.min(w, remaining);
|
||||||
];
|
if (clamped > 0.01) {
|
||||||
// Fin-ops delegations — Bob (m02) is top target, m06/m26 are secondary hubs
|
result.push([v[0], targets[i][0], clamped]);
|
||||||
const finDelegations: Array<[string, string, number]> = [
|
remaining -= clamped;
|
||||||
// Council → stewards
|
outboundSum.set(v[0], (outboundSum.get(v[0]) || 0) + clamped);
|
||||||
["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
|
// Members → admins (3-4 edges each)
|
||||||
["m07", "m06", 0.4], ["m08", "m02", 0.7], ["m09", "m26", 0.3],
|
for (const m of mems) {
|
||||||
["m10", "m02", 0.6], ["m11", "m06", 0.4], ["m12", "m26", 0.3],
|
const edgeCount = 3 + (rng() < 0.3 ? 1 : 0);
|
||||||
["m28", "m06", 0.3], ["m29", "m02", 0.6], ["m30", "m26", 0.3],
|
const targets = [...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
|
||||||
["m31", "m06", 0.3], ["m32", "m02", 0.5], ["m33", "m26", 0.3],
|
let remaining = Math.min(m[weightIdx], 0.95) - (outboundSum.get(m[0]) || 0);
|
||||||
["m34", "m06", 0.2], ["m35", "m02", 0.6], ["m36", "m26", 0.3],
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
||||||
// Members → contributors/council
|
const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.5 * 100) / 100 : Math.round(remaining * 100) / 100;
|
||||||
["m13", "m06", 0.3], ["m14", "m02", 0.5], ["m15", "m26", 0.2],
|
const clamped = Math.min(w, remaining);
|
||||||
["m16", "m08", 0.4], ["m17", "m06", 0.2], ["m18", "m02", 0.3],
|
if (clamped > 0.01) {
|
||||||
["m19", "m10", 0.2], ["m20", "m26", 0.4], ["m21", "m06", 0.1],
|
result.push([m[0], targets[i][0], clamped]);
|
||||||
["m22", "m02", 0.3], ["m23", "m29", 0.2], ["m24", "m32", 0.1],
|
remaining -= clamped;
|
||||||
["m37", "m26", 0.2], ["m38", "m02", 0.4], ["m39", "m06", 0.2],
|
outboundSum.set(m[0], (outboundSum.get(m[0]) || 0) + clamped);
|
||||||
["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
|
// Admins → other admins (1-2 edges each)
|
||||||
const devDelegations: Array<[string, string, number]> = [
|
for (const a of admins) {
|
||||||
// Council → stewards
|
const edgeCount = 1 + (rng() < 0.4 ? 1 : 0);
|
||||||
["m04", "m03", 0.4], ["m05", "m03", 0.7], ["m06", "m03", 0.3],
|
const targets = admins.filter(t => t[0] !== a[0]).sort(() => rng() - 0.5).slice(0, edgeCount);
|
||||||
["m25", "m03", 0.3], ["m26", "m27", 0.3], ["m27", "m03", 0.7],
|
let remaining = Math.min(a[weightIdx] * 0.4, 0.5) - (outboundSum.get(a[0]) || 0);
|
||||||
// Council cross-delegation
|
for (let i = 0; i < targets.length && remaining > 0.02; i++) {
|
||||||
["m04", "m05", 0.3], ["m25", "m27", 0.3],
|
const w = Math.round(Math.min(rng() * 0.3 + 0.05, remaining) * 100) / 100;
|
||||||
// Contributors → council/stewards
|
if (w > 0.01) {
|
||||||
["m07", "m03", 0.7], ["m08", "m05", 0.4], ["m09", "m27", 0.4],
|
result.push([a[0], targets[i][0], w]);
|
||||||
["m10", "m05", 0.4], ["m11", "m03", 0.7], ["m12", "m27", 0.6],
|
remaining -= w;
|
||||||
["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],
|
return result;
|
||||||
["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],
|
const govDelegations = generateDemoDelegations(members, "gov-ops", 3, 42);
|
||||||
["m37", "m27", 0.2], ["m38", "m05", 0.1], ["m39", "m03", 0.3],
|
const finDelegations = generateDemoDelegations(members, "fin-ops", 4, 137);
|
||||||
["m40", "m30", 0.2], ["m41", "m27", 0.2], ["m42", "m03", 0.3],
|
const devDelegations = generateDemoDelegations(members, "dev-ops", 5, 271);
|
||||||
["m43", "m36", 0.1], ["m44", "m05", 0.2], ["m45", "m03", 0.3],
|
|
||||||
["m46", "m33", 0.1], ["m47", "m27", 0.2], ["m48", "m03", 0.3],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [from, to, weight] of govDelegations) {
|
for (const [from, to, weight] of govDelegations) {
|
||||||
edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "gov-ops" } as any);
|
edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "gov-ops" } as any);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue