feat(rnetwork): absolute token weights instead of fractional averages
Weight accounting now uses actual base weights per authority (×100 for integer tokens). Formula: effective = max(0, base - delegatedAway) + received. If you have 95 Gov tokens and delegate 48, you retain 47; the recipient gains 48 on top of their own base. - Detail panel shows breakdown: base − delegated + received = effective - Badge shows integer token count per authority - Member list sidebar shows per-authority G/E/T weights (color-coded) - Sorted by total effective weight (sum across all authorities) - No more averages — absolute weight of voice Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0db93695d8
commit
20c4a19e06
|
|
@ -15,14 +15,20 @@ interface WeightAccounting {
|
|||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "person" | "company" | "opportunity" | "rspace_user" | "space";
|
||||
type: "person" | "company" | "opportunity" | "rspace_user" | "space" | "module" | "feed";
|
||||
workspace: string;
|
||||
role?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
trustScore?: number;
|
||||
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
|
||||
baseWeights?: Record<string, number>; // per-authority base weights (0-1 scale from server)
|
||||
weightAccounting?: WeightAccounting;
|
||||
// Layers mode fields
|
||||
layerId?: string;
|
||||
feedId?: string;
|
||||
feedKind?: string; // FlowKind
|
||||
moduleColor?: number;
|
||||
// 3d-force-graph internal properties
|
||||
x?: number;
|
||||
y?: number;
|
||||
|
|
@ -39,6 +45,30 @@ interface GraphEdge {
|
|||
label?: string;
|
||||
weight?: number;
|
||||
authority?: string;
|
||||
flowKind?: string; // FlowKind, for cross_layer_flow edges
|
||||
strength?: number; // 0-1, for cross_layer_flow edges
|
||||
}
|
||||
|
||||
type AxisPlane = "xy" | "xz" | "yz";
|
||||
|
||||
interface LayerInstance {
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
moduleIcon: string;
|
||||
moduleColor: number;
|
||||
axis: AxisPlane;
|
||||
feeds: Array<{ id: string; name: string; kind: string }>;
|
||||
acceptsFeeds: string[];
|
||||
}
|
||||
|
||||
interface CrossLayerFlow {
|
||||
id: string;
|
||||
sourceLayerIdx: number;
|
||||
sourceFeedId: string;
|
||||
targetLayerIdx: number;
|
||||
targetFeedId: string;
|
||||
kind: string; // FlowKind
|
||||
strength: number;
|
||||
}
|
||||
|
||||
const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
|
||||
|
|
@ -70,6 +100,17 @@ const NODE_COLORS: Record<string, number> = {
|
|||
|
||||
const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4];
|
||||
|
||||
// Layer/Flow constants (mirrored from lib/layer-types.ts for browser use)
|
||||
const LAYER_FLOW_COLORS: Record<string, string> = {
|
||||
economic: "#4ade80", trust: "#c4b5fd", data: "#60a5fa",
|
||||
attention: "#fcd34d", governance: "#a78bfa", resource: "#6ee7b7", custom: "#94a3b8",
|
||||
};
|
||||
const LAYER_FLOW_LABELS: Record<string, string> = {
|
||||
economic: "Economic", trust: "Trust", data: "Data",
|
||||
attention: "Attention", governance: "Delegation", resource: "Resource", custom: "Custom",
|
||||
};
|
||||
const MODULE_PALETTE = [0x6366f1, 0x10b981, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4];
|
||||
|
||||
// Edge colors/widths by type
|
||||
const EDGE_STYLES: Record<string, { color: string; width: number; opacity: number; dashed?: boolean }> = {
|
||||
work_at: { color: "#888888", width: 0.5, opacity: 0.35 },
|
||||
|
|
@ -99,6 +140,16 @@ class FolkGraphViewer extends HTMLElement {
|
|||
private demoDelegations: GraphEdge[] = [];
|
||||
private showMemberList = false;
|
||||
|
||||
// Layers mode state
|
||||
private layersMode = false;
|
||||
private layerInstances: LayerInstance[] = [];
|
||||
private crossLayerFlows: CrossLayerFlow[] = [];
|
||||
private flowWiringSource: { layerIdx: number; feedId: string; feedKind: string } | null = null;
|
||||
private layersPanelOpen = false;
|
||||
private layerPlaneObjects: any[] = [];
|
||||
private savedGraphState: { nodes: GraphNode[]; edges: GraphEdge[] } | null = null;
|
||||
private savedCameraControls: { minPolar: number; maxPolar: number; minDist: number; maxDist: number } | null = null;
|
||||
|
||||
// Multi-select delegation state
|
||||
private selectedDelegates: Map<string, { node: GraphNode; weights: Record<string, number> }> = new Map();
|
||||
private delegateSearchQuery = "";
|
||||
|
|
@ -198,6 +249,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
location: n.data?.location,
|
||||
description: n.data?.email || n.data?.domain || n.data?.stage,
|
||||
trustScore: n.data?.trustScore,
|
||||
baseWeights: n.data?.trustScores || {},
|
||||
} as GraphNode;
|
||||
});
|
||||
|
||||
|
|
@ -230,12 +282,20 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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) => {
|
||||
// Effective weight = retained base + received delegations (absolute tokens, ×100)
|
||||
const nodeById = new Map(this.nodes.map(n => [n.id, n]));
|
||||
acctMap.forEach((acct, nodeId) => {
|
||||
const node = nodeById.get(nodeId);
|
||||
for (const a of authorities) {
|
||||
acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]);
|
||||
const base = (node?.baseWeights?.[a] || 0) * 100;
|
||||
const away = acct.delegatedAway[a] * 100;
|
||||
const recv = acct.receivedWeight[a] * 100;
|
||||
acct.delegatedAway[a] = away;
|
||||
acct.receivedWeight[a] = recv;
|
||||
acct.effectiveWeight[a] = Math.max(0, base - away) + recv;
|
||||
}
|
||||
});
|
||||
// Compute normalized delegatedWeight (for backwards compat) and attach accounting
|
||||
// Attach accounting to nodes
|
||||
let maxEW = 0;
|
||||
acctMap.forEach((acct) => {
|
||||
for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]);
|
||||
|
|
@ -245,8 +305,8 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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;
|
||||
const totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0);
|
||||
node.delegatedWeight = totalEW / (maxEW * 3);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,7 +460,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
.member-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
|
||||
.member-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.member-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.member-weight { font-size: 10px; color: var(--rs-text-muted); font-weight: 600; min-width: 28px; text-align: right; }
|
||||
.member-weight { font-size: 10px; font-weight: 600; min-width: 70px; text-align: right; white-space: nowrap; }
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute; bottom: 12px; right: 12px;
|
||||
|
|
@ -545,6 +605,88 @@ class FolkGraphViewer extends HTMLElement {
|
|||
.deleg-remaining { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; }
|
||||
.deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; }
|
||||
|
||||
/* ── Layers panel ── */
|
||||
.layers-panel {
|
||||
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||
border-radius: 12px; padding: 12px 16px; margin-top: 8px;
|
||||
}
|
||||
.layers-panel.visible { display: block; }
|
||||
.layers-panel-header {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.layers-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
|
||||
.layers-panel-close {
|
||||
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.module-picker {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;
|
||||
}
|
||||
.module-pick-btn {
|
||||
padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
|
||||
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer;
|
||||
font-size: 11px; display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.module-pick-btn:hover { border-color: var(--rs-border-strong); }
|
||||
.module-pick-btn.selected { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
|
||||
.module-pick-btn.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.layer-row {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
.layer-row:last-child { border-bottom: none; }
|
||||
.layer-row-icon { font-size: 16px; }
|
||||
.layer-row-name { font-size: 12px; font-weight: 500; flex: 1; }
|
||||
.axis-select {
|
||||
padding: 3px 6px; border-radius: 4px; border: 1px solid var(--rs-input-border);
|
||||
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.layer-row-remove {
|
||||
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
|
||||
font-size: 12px; padding: 2px 4px; border-radius: 4px;
|
||||
}
|
||||
.layer-row-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
|
||||
.layer-feeds-hint { font-size: 10px; color: var(--rs-text-muted); margin-top: 2px; }
|
||||
|
||||
/* ── Flow dialog overlay ── */
|
||||
.flow-dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.flow-dialog {
|
||||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
||||
border-radius: 12px; padding: 20px; min-width: 300px; max-width: 400px;
|
||||
}
|
||||
.flow-dialog-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
||||
.flow-dialog-pair { font-size: 12px; color: var(--rs-text-muted); margin-bottom: 10px; }
|
||||
.flow-kind-option {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
||||
border-radius: 6px; cursor: pointer; margin-bottom: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.flow-kind-option:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
|
||||
.flow-kind-option.selected { border-color: var(--rs-primary-hover); }
|
||||
.flow-kind-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.flow-kind-label { font-size: 12px; flex: 1; }
|
||||
.flow-strength-row { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
|
||||
.flow-strength-label { font-size: 11px; color: var(--rs-text-muted); min-width: 60px; }
|
||||
.flow-strength-slider { flex: 1; accent-color: #a78bfa; }
|
||||
.flow-strength-val { font-size: 11px; font-weight: 600; min-width: 30px; text-align: right; }
|
||||
.flow-dialog-actions { display: flex; gap: 8px; margin-top: 14px; }
|
||||
.flow-dialog-btn {
|
||||
padding: 6px 16px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; flex: 1;
|
||||
}
|
||||
.flow-dialog-create { background: #a78bfa; color: #fff; }
|
||||
.flow-dialog-create:hover { background: #8b5cf6; }
|
||||
.flow-dialog-cancel { background: var(--rs-bg-surface-raised, rgba(255,255,255,0.08)); color: var(--rs-text-primary); }
|
||||
.flow-dialog-cancel:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.12)); }
|
||||
|
||||
/* Wiring pulse animation for compatible feed targets */
|
||||
@keyframes feed-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px 1px currentColor; }
|
||||
50% { box-shadow: 0 0 12px 4px currentColor; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.graph-row { flex-direction: column; }
|
||||
.graph-canvas { min-height: 300px; }
|
||||
|
|
@ -566,6 +708,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
<button class="filter-btn" id="trust-toggle" title="Toggle trust-weighted view">Trust</button>
|
||||
<button class="filter-btn" id="rings-toggle" title="Toggle concentric ring layout">Rings</button>
|
||||
<button class="filter-btn" id="list-toggle" title="Toggle member list sidebar">List</button>
|
||||
<button class="filter-btn" id="layers-toggle" title="Toggle multi-layer rApp visualization">Layers</button>
|
||||
</div>
|
||||
|
||||
<div class="authority-bar" id="authority-bar">
|
||||
|
|
@ -589,6 +732,11 @@ class FolkGraphViewer extends HTMLElement {
|
|||
<div class="detail-panel" id="detail-panel"></div>
|
||||
<div class="deleg-panel" id="deleg-panel"></div>
|
||||
|
||||
<div class="layers-panel" id="layers-panel"></div>
|
||||
<div class="flow-dialog-overlay" id="flow-dialog-overlay" style="display:none">
|
||||
<div class="flow-dialog" id="flow-dialog"></div>
|
||||
</div>
|
||||
|
||||
<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-company"></span> Organizations</div>
|
||||
|
|
@ -705,6 +853,17 @@ class FolkGraphViewer extends HTMLElement {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Layers toggle
|
||||
this.shadow.getElementById("layers-toggle")?.addEventListener("click", () => {
|
||||
if (this.layersMode) {
|
||||
this.exitLayersMode();
|
||||
} else {
|
||||
this.enterLayersMode();
|
||||
}
|
||||
const btn = this.shadow.getElementById("layers-toggle");
|
||||
if (btn) btn.classList.toggle("active", this.layersMode);
|
||||
});
|
||||
}
|
||||
|
||||
private animateCameraDistance(targetDist: number) {
|
||||
|
|
@ -965,7 +1124,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
let badgeColor = "#7c3aed";
|
||||
if (this.trustMode && node.weightAccounting && this.authority !== "all") {
|
||||
const ew = node.weightAccounting.effectiveWeight[this.authority] || 0;
|
||||
badgeText = ew.toFixed(1);
|
||||
badgeText = String(Math.round(ew));
|
||||
badgeColor = AUTHORITY_COLORS[this.authority] || "#7c3aed";
|
||||
} else {
|
||||
const trust = this.getTrustScore(node.id);
|
||||
|
|
@ -1251,10 +1410,23 @@ class FolkGraphViewer extends HTMLElement {
|
|||
let weightHtml = "";
|
||||
if (n.weightAccounting && (n.type === "rspace_user" || n.type === "person")) {
|
||||
const acct = n.weightAccounting;
|
||||
// Find max effective weight across all nodes for bar scaling
|
||||
let detailMaxEW = 1;
|
||||
for (const nd of this.nodes) {
|
||||
if (!nd.weightAccounting) continue;
|
||||
for (const a of DELEGATION_AUTHORITIES) {
|
||||
detailMaxEW = Math.max(detailMaxEW, nd.weightAccounting.effectiveWeight[a] || 0);
|
||||
}
|
||||
}
|
||||
weightHtml = DELEGATION_AUTHORITIES.map(a => {
|
||||
const ew = Math.round((acct.effectiveWeight[a] || 0) * 100) / 100;
|
||||
const ew = Math.round(acct.effectiveWeight[a] || 0);
|
||||
const base = Math.round((n.baseWeights?.[a] || 0) * 100);
|
||||
const recv = Math.round(acct.receivedWeight[a] || 0);
|
||||
const away = Math.round(acct.delegatedAway[a] || 0);
|
||||
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>`;
|
||||
const barPct = Math.min((ew / detailMaxEW) * 100, 100);
|
||||
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:${barPct}%;background:${disp?.color || '#a78bfa'}"></span></span><span class="trust-val" style="color:${disp?.color || '#a78bfa'}">${ew}</span></div>
|
||||
<div style="font-size:10px;color:var(--rs-text-muted);margin:-4px 0 6px 76px">base ${base} − ${away} delegated + ${recv} received</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
|
|
@ -1529,9 +1701,17 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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) => {
|
||||
// Absolute token weights: base×100 - delegated + received
|
||||
const nodeById2 = new Map(this.nodes.map(n => [n.id, n]));
|
||||
acctMap.forEach((acct, nodeId) => {
|
||||
const node = nodeById2.get(nodeId);
|
||||
for (const a of authorities) {
|
||||
acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]);
|
||||
const base = (node?.baseWeights?.[a] || 0) * 100;
|
||||
const away = acct.delegatedAway[a] * 100;
|
||||
const recv = acct.receivedWeight[a] * 100;
|
||||
acct.delegatedAway[a] = away;
|
||||
acct.receivedWeight[a] = recv;
|
||||
acct.effectiveWeight[a] = Math.max(0, base - away) + recv;
|
||||
}
|
||||
});
|
||||
let maxEW = 0;
|
||||
|
|
@ -1543,8 +1723,8 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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;
|
||||
const totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0);
|
||||
node.delegatedWeight = totalEW / (maxEW * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1580,18 +1760,27 @@ class FolkGraphViewer extends HTMLElement {
|
|||
});
|
||||
}
|
||||
|
||||
const authHeaders = DELEGATION_AUTHORITIES.map(a => {
|
||||
const d = AUTHORITY_DISPLAY[a];
|
||||
return `<span style="color:${d?.color};font-size:9px;font-weight:700">${d?.label?.[0]}</span>`;
|
||||
}).join(`<span style="opacity:0.3">/</span>`);
|
||||
|
||||
panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
|
||||
<div class="member-group">
|
||||
<div class="member-group-header" style="color:${g.color}">
|
||||
${g.label}
|
||||
<span class="member-group-count">${g.nodes.length}</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><span class="member-group-count">${g.nodes.length}</span><span class="member-weight" style="font-size:9px">${authHeaders}</span></span>
|
||||
</div>
|
||||
${g.nodes.map(n => {
|
||||
const ew = n.weightAccounting ? (Object.values(n.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) / 3).toFixed(1) : "";
|
||||
const wa = n.weightAccounting;
|
||||
const weights = wa ? DELEGATION_AUTHORITIES.map(a => {
|
||||
const disp = AUTHORITY_DISPLAY[a];
|
||||
return `<span style="color:${disp?.color}">${Math.round(wa.effectiveWeight[a] || 0)}</span>`;
|
||||
}).join(`<span style="color:var(--rs-text-muted);opacity:0.4">/</span>`) : "";
|
||||
return `<div class="member-item" data-member-id="${n.id}">
|
||||
<span class="member-dot" style="background:${g.color}"></span>
|
||||
<span class="member-name" title="${this.esc(n.name)}">${this.esc(n.name)}</span>
|
||||
${ew ? `<span class="member-weight">${ew}</span>` : ""}
|
||||
${weights ? `<span class="member-weight">${weights}</span>` : ""}
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue