Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 17:39:12 -07:00
commit c1b572ef68
1 changed files with 206 additions and 17 deletions

View File

@ -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} &minus; ${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>