diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index fc01bbb..7fa4dce 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -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; // 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 = { 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 = { + economic: "#4ade80", trust: "#c4b5fd", data: "#60a5fa", + attention: "#fcd34d", governance: "#a78bfa", resource: "#6ee7b7", custom: "#94a3b8", +}; +const LAYER_FLOW_LABELS: Record = { + 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 = { 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 }> = 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 { +
@@ -589,6 +732,11 @@ class FolkGraphViewer extends HTMLElement {
+
+ +
People
Organizations
@@ -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 `
${disp?.label || a}${ew.toFixed(2)}
`; + const barPct = Math.min((ew / detailMaxEW) * 100, 100); + return `
${disp?.label || a}${ew}
+
base ${base} − ${away} delegated + ${recv} received
`; }).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 `${d?.label?.[0]}`; + }).join(`/`); + panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
${g.label} - ${g.nodes.length} + ${g.nodes.length}${authHeaders}
${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 `${Math.round(wa.effectiveWeight[a] || 0)}`; + }).join(`/`) : ""; return `
${this.esc(n.name)} - ${ew ? `${ew}` : ""} + ${weights ? `${weights}` : ""}
`; }).join("")}