Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m11s Details

This commit is contained in:
Jeff Emmett 2026-04-01 14:46:28 -07:00
commit bbe93cde78
7 changed files with 711 additions and 45 deletions

View File

@ -172,6 +172,17 @@ class FolkCrmView extends HTMLElement {
return match ? match[0] : ""; return match ? match[0] : "";
} }
private getAuthUrl(): string {
// Check attribute first, then meta tag, then location-based fallback
const attr = this.getAttribute('auth-url');
if (attr) return attr;
const meta = document.querySelector('meta[name="encryptid-url"]');
if (meta) return meta.getAttribute('content') || '';
// Fallback: same origin for dev, auth subdomain for production
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return '';
return `https://auth.${location.hostname.split('.').slice(-2).join('.')}`;
}
private async loadData() { private async loadData() {
const base = this.getApiBase(); const base = this.getApiBase();
try { try {
@ -690,10 +701,10 @@ class FolkCrmView extends HTMLElement {
return ` return `
<div class="delegations-layout"> <div class="delegations-layout">
<div class="delegations-col"> <div class="delegations-col">
<folk-delegation-manager space="${this.space}" auth-url="https://auth.rspace.online"></folk-delegation-manager> <folk-delegation-manager space="${this.space}" auth-url="${this.getAuthUrl()}"></folk-delegation-manager>
</div> </div>
<div class="delegations-col"> <div class="delegations-col">
<folk-trust-sankey space="${this.space}" auth-url="https://auth.rspace.online"></folk-trust-sankey> <folk-trust-sankey space="${this.space}" auth-url="${this.getAuthUrl()}"></folk-trust-sankey>
</div> </div>
</div>`; </div>`;
} }

View File

@ -142,20 +142,32 @@ class FolkDelegationManager extends HTMLElement {
const authBase = this.getAuthBase(); const authBase = this.getAuthBase();
try { try {
const body = JSON.stringify({ let res: Response;
delegateDid: this.modalDelegate, if (this.editingId) {
authority: this.modalAuthority, // PATCH existing delegation
weight: this.modalWeight / 100, const body = JSON.stringify({
spaceSlug: this.space, weight: this.modalWeight / 100,
maxDepth: this.modalMaxDepth, maxDepth: this.modalMaxDepth,
retainAuthority: this.modalRetainAuthority, retainAuthority: this.modalRetainAuthority,
}); });
res = await fetch(`${authBase}/api/delegations/${this.editingId}`, { method: "PATCH", headers, body });
} else {
// POST new delegation
const body = JSON.stringify({
delegateDid: this.modalDelegate,
authority: this.modalAuthority,
weight: this.modalWeight / 100,
spaceSlug: this.space,
maxDepth: this.modalMaxDepth,
retainAuthority: this.modalRetainAuthority,
});
res = await fetch(`${authBase}/api/delegations`, { method: "POST", headers, body });
}
const res = await fetch(`${authBase}/api/delegations`, { method: "POST", headers, body });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
this.error = data.error || "Failed to create delegation"; this.error = data.error || "Failed to save delegation";
this.render(); this.render();
return; return;
} }
@ -164,8 +176,14 @@ class FolkDelegationManager extends HTMLElement {
this.editingId = null; this.editingId = null;
this.error = ""; this.error = "";
await this.loadData(); await this.loadData();
// Dispatch cross-component sync event
this.dispatchEvent(new CustomEvent("delegations-updated", {
bubbles: true, composed: true,
detail: { space: this.space },
}));
} catch { } catch {
this.error = "Network error creating delegation"; this.error = "Network error saving delegation";
this.render(); this.render();
} }
} }
@ -188,6 +206,7 @@ class FolkDelegationManager extends HTMLElement {
return; return;
} }
await this.loadData(); await this.loadData();
this.dispatchEvent(new CustomEvent("delegations-updated", { bubbles: true, composed: true, detail: { space: this.space } }));
} catch { } catch {
this.error = "Network error"; this.error = "Network error";
this.render(); this.render();
@ -207,6 +226,7 @@ class FolkDelegationManager extends HTMLElement {
return; return;
} }
await this.loadData(); await this.loadData();
this.dispatchEvent(new CustomEvent("delegations-updated", { bubbles: true, composed: true, detail: { space: this.space } }));
} catch { } catch {
this.error = "Network error"; this.error = "Network error";
this.render(); this.render();
@ -239,6 +259,7 @@ class FolkDelegationManager extends HTMLElement {
<span class="delegation-name">${this.esc(this.getUserName(d.delegateDid))}</span> <span class="delegation-name">${this.esc(this.getUserName(d.delegateDid))}</span>
<span class="delegation-weight">${Math.round(d.weight * 100)}%</span> <span class="delegation-weight">${Math.round(d.weight * 100)}%</span>
<span class="delegation-state">${d.state}</span> <span class="delegation-state">${d.state}</span>
<button class="btn-sm btn-edit" data-edit="${d.id}" data-edit-auth="${d.authority}" data-edit-did="${d.delegateDid}" data-edit-weight="${Math.round(d.weight * 100)}" data-edit-depth="${d.maxDepth}" data-edit-retain="${d.retainAuthority}" title="Edit">\u270E</button>
${d.state === 'active' ? ` ${d.state === 'active' ? `
<button class="btn-sm btn-pause" data-pause="${d.id}" title="Pause">||</button> <button class="btn-sm btn-pause" data-pause="${d.id}" title="Pause">||</button>
` : d.state === 'paused' ? ` ` : d.state === 'paused' ? `
@ -275,11 +296,11 @@ class FolkDelegationManager extends HTMLElement {
${this.users.map(u => `<option value="${u.did}" ${this.modalDelegate === u.did ? "selected" : ""}>${this.esc(u.displayName || u.username)}</option>`).join("")} ${this.users.map(u => `<option value="${u.did}" ${this.modalDelegate === u.did ? "selected" : ""}>${this.esc(u.displayName || u.username)}</option>`).join("")}
</select> </select>
<label class="field-label">Weight: ${this.modalWeight}%</label> <label class="field-label" id="weight-label">Weight: ${this.modalWeight}%</label>
<input type="range" min="1" max="${Math.max(maxWeight, 1)}" value="${this.modalWeight}" class="field-slider" id="modal-weight"> <input type="range" min="1" max="${Math.max(maxWeight, 1)}" value="${this.modalWeight}" class="field-slider" id="modal-weight">
<div class="field-hint">Available: ${maxWeight}% of ${this.modalAuthority}</div> <div class="field-hint">Available: ${maxWeight}% of ${DM_AUTHORITY_DISPLAY[this.modalAuthority]?.label || this.modalAuthority}</div>
<label class="field-label">Re-delegation depth: ${this.modalMaxDepth}</label> <label class="field-label" id="depth-label">Re-delegation depth: ${this.modalMaxDepth}</label>
<input type="range" min="0" max="5" value="${this.modalMaxDepth}" class="field-slider" id="modal-depth"> <input type="range" min="0" max="5" value="${this.modalMaxDepth}" class="field-slider" id="modal-depth">
<label class="field-check"> <label class="field-check">
@ -356,6 +377,7 @@ class FolkDelegationManager extends HTMLElement {
.btn-revoke:hover { color: #ef4444; } .btn-revoke:hover { color: #ef4444; }
.btn-pause:hover { color: #f59e0b; } .btn-pause:hover { color: #f59e0b; }
.btn-resume:hover { color: #22c55e; } .btn-resume:hover { color: #22c55e; }
.btn-edit:hover { color: #a78bfa; }
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100;
@ -467,6 +489,22 @@ class FolkDelegationManager extends HTMLElement {
}); });
}); });
// Edit buttons
this.shadow.querySelectorAll("[data-edit]").forEach(el => {
el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset;
this.editingId = ds.edit!;
this.modalAuthority = ds.editAuth!;
this.modalDelegate = ds.editDid!;
this.modalWeight = parseInt(ds.editWeight!) || 50;
this.modalMaxDepth = parseInt(ds.editDepth!) || 3;
this.modalRetainAuthority = ds.editRetain === "true";
this.error = "";
this.showModal = true;
this.render();
});
});
// Pause/Resume/Revoke buttons // Pause/Resume/Revoke buttons
this.shadow.querySelectorAll("[data-pause]").forEach(el => { this.shadow.querySelectorAll("[data-pause]").forEach(el => {
el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.pause!, { state: "paused" })); el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.pause!, { state: "paused" }));
@ -516,11 +554,13 @@ class FolkDelegationManager extends HTMLElement {
}); });
this.shadow.getElementById("modal-weight")?.addEventListener("input", (e) => { this.shadow.getElementById("modal-weight")?.addEventListener("input", (e) => {
this.modalWeight = parseInt((e.target as HTMLInputElement).value); this.modalWeight = parseInt((e.target as HTMLInputElement).value);
const label = this.shadow.querySelector('.field-label:nth-of-type(3)'); const label = this.shadow.getElementById("weight-label");
// live update shown via next render if (label) label.textContent = `Weight: ${this.modalWeight}%`;
}); });
this.shadow.getElementById("modal-depth")?.addEventListener("input", (e) => { this.shadow.getElementById("modal-depth")?.addEventListener("input", (e) => {
this.modalMaxDepth = parseInt((e.target as HTMLInputElement).value); this.modalMaxDepth = parseInt((e.target as HTMLInputElement).value);
const label = this.shadow.getElementById("depth-label");
if (label) label.textContent = `Re-delegation depth: ${this.modalMaxDepth}`;
}); });
this.shadow.getElementById("modal-retain")?.addEventListener("change", (e) => { this.shadow.getElementById("modal-retain")?.addEventListener("change", (e) => {
this.modalRetainAuthority = (e.target as HTMLInputElement).checked; this.modalRetainAuthority = (e.target as HTMLInputElement).checked;

View File

@ -7,6 +7,7 @@
*/ */
import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { NetworkLocalFirstClient } from '../local-first-client';
interface WeightAccounting { interface WeightAccounting {
delegatedAway: Record<string, number>; // per authority: total weight delegated out delegatedAway: Record<string, number>; // per authority: total weight delegated out
@ -167,24 +168,68 @@ class FolkGraphViewer extends HTMLElement {
private _badgeSpriteCache = new Map<string, any>(); private _badgeSpriteCache = new Map<string, any>();
private _stopPresence: (() => void) | null = null; private _stopPresence: (() => void) | null = null;
// Local-first client for layer config persistence
private _lfClient: NetworkLocalFirstClient | null = null;
private _lfUnsubChange: (() => void) | null = null;
private _pulseRings: Array<{ mesh: any; baseOpacity: number }> = [];
private _tickHandler: (() => void) | null = null;
// Hover glow state
private _hoverGlowNode: GraphNode | null = null;
private _hoverGlowMesh: any = null;
private _hoverDebounce: any = null;
// Path tracing state (Phase 3)
private _tracedPathNodes: Set<string> | null = null;
// Keyboard handler ref for cleanup
private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
constructor() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
} }
private _delegationsHandler: ((e: Event) => void) | null = null;
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.renderDOM(); this.renderDOM();
this.loadData(); this.loadData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: 'Network Graph' })); this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: 'Network Graph' }));
// Listen for cross-component delegation updates
this._delegationsHandler = () => {
this._textSpriteCache.clear();
this._badgeSpriteCache.clear();
this.loadData();
};
document.addEventListener("delegations-updated", this._delegationsHandler);
// Init local-first client for layer config persistence
this._lfClient = new NetworkLocalFirstClient(this.space);
this._lfClient.init().then(() => this._lfClient!.subscribe()).then(() => {
this.restoreLayerConfig();
}).catch(() => { /* offline is fine */ });
} }
disconnectedCallback() { disconnectedCallback() {
this._stopPresence?.(); this._stopPresence?.();
if (this._keyHandler) {
document.removeEventListener("keydown", this._keyHandler);
this._keyHandler = null;
}
if (this._delegationsHandler) {
document.removeEventListener("delegations-updated", this._delegationsHandler);
this._delegationsHandler = null;
}
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
this.resizeObserver = null; this.resizeObserver = null;
} }
if (this._lfUnsubChange) { this._lfUnsubChange(); this._lfUnsubChange = null; }
this._lfClient?.disconnect();
this._lfClient = null;
if (this.graph) { if (this.graph) {
this.graph._destructor?.(); this.graph._destructor?.();
this.graph = null; this.graph = null;
@ -199,6 +244,9 @@ class FolkGraphViewer extends HTMLElement {
private async loadData() { private async loadData() {
const base = this.getApiBase(); const base = this.getApiBase();
// Show loading spinner
const loader = this.shadow.getElementById("graph-loading");
if (loader) loader.classList.remove("hidden");
try { try {
const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : ""; const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : "";
const graphCacheKey = `rnetwork:graph:${this.space}:${this.trustMode}:${this.authority}`; const graphCacheKey = `rnetwork:graph:${this.space}:${this.trustMode}:${this.authority}`;
@ -229,6 +277,8 @@ class FolkGraphViewer extends HTMLElement {
try { sessionStorage.setItem(graphCacheKey, JSON.stringify({ data: graphData, ts: Date.now() })); } catch { /* quota */ } try { sessionStorage.setItem(graphCacheKey, JSON.stringify({ data: graphData, ts: Date.now() })); } catch { /* quota */ }
} }
} catch { /* offline */ } } catch { /* offline */ }
// Hide loading spinner
if (loader) loader.classList.add("hidden");
this.updateStatsBar(); this.updateStatsBar();
this.updateAuthorityBar(); this.updateAuthorityBar();
this.updateWorkspaceList(); this.updateWorkspaceList();
@ -470,6 +520,57 @@ class FolkGraphViewer extends HTMLElement {
} }
.graph-canvas canvas { border-radius: 12px; } .graph-canvas canvas { border-radius: 12px; }
/* Loading spinner overlay */
.graph-loading {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.4); z-index: 10; border-radius: 12px;
}
.graph-loading.hidden { display: none; }
.spinner {
width: 36px; height: 36px; border: 3px solid rgba(167,139,250,0.2);
border-top-color: #a78bfa; border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */
.graph-empty {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--rs-text-muted); font-size: 13px; z-index: 5; pointer-events: none;
}
.graph-empty.hidden { display: none; }
/* Toast notification */
.graph-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: 8px; background: #1e1b4b; border: 1px solid #a78bfa;
color: #e2e8f0; font-size: 13px; z-index: 1000; opacity: 0;
transition: opacity 0.3s; pointer-events: none;
}
.graph-toast.visible { opacity: 1; }
/* Metrics sidebar (Phase 3) */
.metrics-panel {
display: none; position: absolute; top: 12px; left: 12px;
width: 200px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 10px; padding: 12px; z-index: 6; font-size: 12px;
max-height: calc(100% - 24px); overflow-y: auto;
}
.metrics-panel.visible { display: block; }
.metrics-title { font-size: 11px; font-weight: 700; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
.metrics-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; }
.metrics-value { font-weight: 700; color: #a78bfa; }
.metrics-section { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); margin: 8px 0 4px; text-transform: uppercase; }
.metrics-influencer { display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; border-radius: 4px; }
.metrics-influencer:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
.metrics-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.metrics-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metrics-score { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); }
.metrics-toggle {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
font-size: 12px; padding: 2px; position: absolute; top: 8px; right: 8px;
}
.member-list-panel { .member-list-panel {
display: none; width: 260px; flex-shrink: 0; display: none; width: 260px; flex-shrink: 0;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
@ -722,6 +823,15 @@ class FolkGraphViewer extends HTMLElement {
50% { box-shadow: 0 0 12px 4px currentColor; } 50% { box-shadow: 0 0 12px 4px currentColor; }
} }
.wiring-status {
position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
background: rgba(167,139,250,0.9); color: #fff; padding: 6px 16px;
border-radius: 20px; font-size: 12px; font-weight: 600;
pointer-events: none; z-index: 50; white-space: nowrap;
animation: feed-pulse 1.5s ease-in-out infinite;
}
.wiring-status.hidden { display: none; }
@media (max-width: 768px) { @media (max-width: 768px) {
.graph-row { flex-direction: column; } .graph-row { flex-direction: column; }
.graph-canvas { min-height: 300px; } .graph-canvas { min-height: 300px; }
@ -730,6 +840,9 @@ class FolkGraphViewer extends HTMLElement {
.stats { flex-wrap: wrap; gap: 12px; } .stats { flex-wrap: wrap; gap: 12px; }
.toolbar { flex-direction: column; align-items: stretch; } .toolbar { flex-direction: column; align-items: stretch; }
.search-input { width: 100%; } .search-input { width: 100%; }
.zoom-btn { width: 40px; height: 40px; font-size: 20px; }
.filter-btn { min-height: 40px; }
.metrics-panel { width: 160px; font-size: 11px; }
} }
</style> </style>
@ -754,6 +867,9 @@ class FolkGraphViewer extends HTMLElement {
<div class="graph-row" id="graph-row"> <div class="graph-row" id="graph-row">
<div class="graph-canvas" id="graph-canvas"> <div class="graph-canvas" id="graph-canvas">
<div id="graph-3d-container" style="width:100%;height:100%"></div> <div id="graph-3d-container" style="width:100%;height:100%"></div>
<div class="graph-loading" id="graph-loading"><div class="spinner"></div></div>
<div class="graph-empty hidden" id="graph-empty">No nodes match your current filter.</div>
<div class="metrics-panel" id="metrics-panel"></div>
<div class="zoom-controls"> <div class="zoom-controls">
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button> <button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
<span class="zoom-level" id="zoom-level">100%</span> <span class="zoom-level" id="zoom-level">100%</span>
@ -768,6 +884,7 @@ class FolkGraphViewer extends HTMLElement {
<div class="deleg-panel" id="deleg-panel"></div> <div class="deleg-panel" id="deleg-panel"></div>
<div class="layers-panel" id="layers-panel"></div> <div class="layers-panel" id="layers-panel"></div>
<div id="wiring-status" class="wiring-status hidden"></div>
<div class="flow-dialog-overlay" id="flow-dialog-overlay" style="display:none"> <div class="flow-dialog-overlay" id="flow-dialog-overlay" style="display:none">
<div class="flow-dialog" id="flow-dialog"></div> <div class="flow-dialog" id="flow-dialog"></div>
</div> </div>
@ -787,6 +904,7 @@ class FolkGraphViewer extends HTMLElement {
</div> </div>
<div id="workspace-section"></div> <div id="workspace-section"></div>
<div class="graph-toast" id="graph-toast"></div>
`; `;
this.attachListeners(); this.attachListeners();
this.initGraph3D(); this.initGraph3D();
@ -899,6 +1017,55 @@ class FolkGraphViewer extends HTMLElement {
const btn = this.shadow.getElementById("layers-toggle"); const btn = this.shadow.getElementById("layers-toggle");
if (btn) btn.classList.toggle("active", this.layersMode); if (btn) btn.classList.toggle("active", this.layersMode);
}); });
// Keyboard shortcuts
if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
this._keyHandler = (e: KeyboardEvent) => {
// Don't intercept when typing in inputs
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
switch (e.key) {
case "Escape":
if (this.selectedNode) {
this.selectedNode = null;
this._tracedPathNodes = null;
this.updateDetailPanel();
this.updateGraphData();
} else if (this.showMemberList) {
this.showMemberList = false;
const listBtn = this.shadow.getElementById("list-toggle");
if (listBtn) listBtn.classList.remove("active");
this.updateMemberList();
} else if (this.layersPanelOpen) {
this.layersPanelOpen = false;
this.renderLayersPanel();
}
break;
case "f":
case "F":
if (this.graph) this.graph.zoomToFit(300, 20);
break;
case "t":
case "T":
this.shadow.getElementById("trust-toggle")?.click();
break;
case "l":
case "L":
this.shadow.getElementById("list-toggle")?.click();
break;
}
};
document.addEventListener("keydown", this._keyHandler);
}
/** Show a brief toast notification */
private showToast(message: string, durationMs = 2500) {
const toast = this.shadow.getElementById("graph-toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("visible");
setTimeout(() => toast.classList.remove("visible"), durationMs);
} }
private animateCameraDistance(targetDist: number) { private animateCameraDistance(targetDist: number) {
@ -978,7 +1145,9 @@ class FolkGraphViewer extends HTMLElement {
if (link.type === "cross_layer_flow") return 1.5 + (link.strength || 0.5) * 3; if (link.type === "cross_layer_flow") return 1.5 + (link.strength || 0.5) * 3;
if (link.type === "layer_internal") return 0.4; if (link.type === "layer_internal") return 0.4;
if (link.type === "delegates_to") { if (link.type === "delegates_to") {
return 1 + (link.weight || 0.5) * 8; // Log-scale: 5% weight = ~2px, 50% = ~4px, 100% = ~5px
const w = link.weight || 0.05;
return 1.5 + Math.log10(1 + w * 9) * 3;
} }
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.width; return style.width;
@ -997,7 +1166,13 @@ class FolkGraphViewer extends HTMLElement {
const tx = typeof t === "object" ? t.x : undefined; const tx = typeof t === "object" ? t.x : undefined;
return sx != null && !isNaN(sx) && tx != null && !isNaN(tx); return sx != null && !isNaN(sx) && tx != null && !isNaN(tx);
}) })
.linkOpacity(0.6) .linkOpacity((link: GraphEdge) => {
if (!this._tracedPathNodes) return 0.6;
const sid = typeof link.source === "string" ? link.source : (link.source as any)?.id;
const tid = typeof link.target === "string" ? link.target : (link.target as any)?.id;
const onPath = this._tracedPathNodes.has(sid) && this._tracedPathNodes.has(tid);
return onPath ? 1.0 : 0.1;
})
.linkDirectionalArrowLength((link: GraphEdge) => { .linkDirectionalArrowLength((link: GraphEdge) => {
if (link.type === "cross_layer_flow") return 3; if (link.type === "cross_layer_flow") return 3;
return link.type === "delegates_to" ? 4 : 0; return link.type === "delegates_to" ? 4 : 0;
@ -1024,6 +1199,12 @@ class FolkGraphViewer extends HTMLElement {
} }
return "#c4b5fd"; return "#c4b5fd";
}) })
.onNodeHover((node: GraphNode | null) => {
clearTimeout(this._hoverDebounce);
this._hoverDebounce = setTimeout(() => {
this.handleNodeHover(node);
}, 30);
})
.onNodeClick((node: GraphNode) => { .onNodeClick((node: GraphNode) => {
// Layers mode: handle feed wiring // Layers mode: handle feed wiring
if (this.layersMode && node.type === "feed") { if (this.layersMode && node.type === "feed") {
@ -1036,8 +1217,15 @@ class FolkGraphViewer extends HTMLElement {
// Toggle detail panel for inspection // Toggle detail panel for inspection
if (this.selectedNode?.id === node.id) { if (this.selectedNode?.id === node.id) {
this.selectedNode = null; this.selectedNode = null;
this._tracedPathNodes = null;
} else { } else {
this.selectedNode = node; this.selectedNode = node;
// Path tracing in trust mode
if (this.trustMode) {
this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
} else {
this._tracedPathNodes = null;
}
} }
this.updateDetailPanel(); this.updateDetailPanel();
@ -1051,6 +1239,8 @@ class FolkGraphViewer extends HTMLElement {
} }
this.updateGraphData(); this.updateGraphData();
// Update metrics when selection changes
if (this.trustMode) this.updateMetricsPanel();
}) })
.d3AlphaDecay(0.02) .d3AlphaDecay(0.02)
.d3VelocityDecay(0.3) .d3VelocityDecay(0.3)
@ -1059,6 +1249,17 @@ class FolkGraphViewer extends HTMLElement {
this.graph = graph; this.graph = graph;
// Pulse animation for compatible feed target rings during wiring
this._tickHandler = () => {
if (!this.flowWiringSource || this._pulseRings.length === 0) return;
const t = performance.now() / 1000;
const pulse = 0.3 + 0.4 * (0.5 + 0.5 * Math.sin(t * 4));
for (const pr of this._pulseRings) {
if (pr.mesh?.material) pr.mesh.material.opacity = pulse;
}
};
graph.onEngineTick(this._tickHandler);
// Custom d3 forces for better clustering and readability // Custom d3 forces for better clustering and readability
// Stronger repulsion — hub nodes push harder // Stronger repulsion — hub nodes push harder
const chargeForce = graph.d3Force('charge'); const chargeForce = graph.d3Force('charge');
@ -1124,6 +1325,40 @@ class FolkGraphViewer extends HTMLElement {
} }
} }
private handleNodeHover(node: GraphNode | null) {
const THREE = this._threeModule;
const scene = this.graph?.scene();
if (!THREE || !scene) return;
// Remove previous glow
if (this._hoverGlowMesh) {
scene.remove(this._hoverGlowMesh);
this._hoverGlowMesh.geometry?.dispose();
this._hoverGlowMesh.material?.dispose();
this._hoverGlowMesh = null;
}
this._hoverGlowNode = null;
if (!node || node.x == null || node.y == null || node.z == null) return;
const radius = this.getNodeRadius(node) / 10;
const color = this.getNodeColor(node);
const glowGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.8, 32);
const glowMat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.4, side: THREE.DoubleSide,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
glow.position.set(node.x, node.y, node.z);
// Billboard: face camera
glow.lookAt(this.graph.camera().position);
glow.raycast = () => {};
scene.add(glow);
this._hoverGlowMesh = glow;
this._hoverGlowNode = node;
}
private createNodeObject(node: GraphNode): any { private createNodeObject(node: GraphNode): any {
// Import THREE from the global importmap // Import THREE from the global importmap
const THREE = (window as any).__THREE_CACHE__ || null; const THREE = (window as any).__THREE_CACHE__ || null;
@ -1150,13 +1385,18 @@ class FolkGraphViewer extends HTMLElement {
const color = this.getNodeColor(node); const color = this.getNodeColor(node);
const isSelected = this.selectedNode?.id === node.id; const isSelected = this.selectedNode?.id === node.id;
// Path dimming: if tracing and node is not on path, dim to 20%
const isOnPath = !this._tracedPathNodes || this._tracedPathNodes.has(node.id);
const pathDimFactor = isOnPath ? 1.0 : 0.2;
// Create a group to hold sphere + label // Create a group to hold sphere + label
const group = new THREE.Group(); const group = new THREE.Group();
// Sphere geometry // Sphere geometry
const segments = (node.type === "module" || node.type === "feed") ? 24 : 12; const segments = (node.type === "module" || node.type === "feed") ? 24 : 12;
const geometry = new THREE.SphereGeometry(radius, segments, Math.round(segments * 3 / 4)); const geometry = new THREE.SphereGeometry(radius, segments, Math.round(segments * 3 / 4));
const opacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75; const baseOpacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75;
const opacity = baseOpacity * pathDimFactor;
const material = new THREE.MeshLambertMaterial({ const material = new THREE.MeshLambertMaterial({
color, color,
transparent: true, transparent: true,
@ -1188,7 +1428,7 @@ class FolkGraphViewer extends HTMLElement {
group.add(ring); group.add(ring);
} }
// Wiring highlight for compatible feed targets // Wiring highlight for compatible feed targets (pulse animated via onEngineTick)
if (this.layersMode && this.flowWiringSource && node.type === "feed" && node.layerId) { if (this.layersMode && this.flowWiringSource && node.type === "feed" && node.layerId) {
const nodeLayerIdx = parseInt(node.layerId); const nodeLayerIdx = parseInt(node.layerId);
if (nodeLayerIdx !== this.flowWiringSource.layerIdx) { if (nodeLayerIdx !== this.flowWiringSource.layerIdx) {
@ -1197,18 +1437,35 @@ class FolkGraphViewer extends HTMLElement {
if (srcLayer && tgtLayer) { if (srcLayer && tgtLayer) {
const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId); const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
if (compat.size > 0) { if (compat.size > 0) {
// Pulse ring for compatible target // Pulse ring for compatible target — animated via _pulseRings
const pulseGeo = new THREE.RingGeometry(radius + 0.4, radius + 0.8, 32); const pulseGeo = new THREE.RingGeometry(radius + 0.4, radius + 0.8, 32);
const pulseMat = new THREE.MeshBasicMaterial({ const pulseMat = new THREE.MeshBasicMaterial({
color: 0x4ade80, transparent: true, opacity: 0.5, side: THREE.DoubleSide, color: 0x4ade80, transparent: true, opacity: 0.5, side: THREE.DoubleSide,
}); });
const pulseRing = new THREE.Mesh(pulseGeo, pulseMat); const pulseRing = new THREE.Mesh(pulseGeo, pulseMat);
group.add(pulseRing); group.add(pulseRing);
this._pulseRings.push({ mesh: pulseRing, baseOpacity: 0.5 });
} }
} }
} }
} }
// Transitive chain indicator: outer torus ring when >30% of effective weight is from received delegations
if (this.trustMode && node.weightAccounting && (node.type === "rspace_user" || node.type === "person") && isOnPath) {
const acct = node.weightAccounting;
const authorities = ["gov-ops", "fin-ops", "dev-ops"];
const totalEW = authorities.reduce((s, a) => s + (acct.effectiveWeight[a] || 0), 0);
const totalRecv = authorities.reduce((s, a) => s + (acct.receivedWeight[a] || 0), 0);
if (totalEW > 0 && totalRecv / totalEW > 0.3) {
const torusGeo = new THREE.TorusGeometry(radius + 0.5, 0.12, 8, 32);
const torusMat = new THREE.MeshBasicMaterial({
color: 0xc4b5fd, transparent: true, opacity: 0.5,
});
const torus = new THREE.Mesh(torusGeo, torusMat);
group.add(torus);
}
}
// Text label as sprite // Text label as sprite
const label = this.createTextSprite(THREE, node); const label = this.createTextSprite(THREE, node);
if (label) { if (label) {
@ -1318,6 +1575,10 @@ class FolkGraphViewer extends HTMLElement {
if (!this.graph) return; if (!this.graph) return;
const filtered = this.getFilteredNodes(); const filtered = this.getFilteredNodes();
// Show/hide empty state
const emptyEl = this.shadow.getElementById("graph-empty");
if (emptyEl) emptyEl.classList.toggle("hidden", filtered.length > 0 || this.nodes.length === 0);
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) // Compute max effective weight for filtered nodes (used by getNodeRadius)
@ -1386,6 +1647,9 @@ class FolkGraphViewer extends HTMLElement {
// Refresh member list if visible // Refresh member list if visible
if (this.showMemberList) this.updateMemberList(); if (this.showMemberList) this.updateMemberList();
// Show/update metrics panel in trust mode
if (this.trustMode) this.updateMetricsPanel();
} }
// ── Ring layout ── // ── Ring layout ──
@ -1791,10 +2055,12 @@ class FolkGraphViewer extends HTMLElement {
// Recompute weight accounting + refresh // Recompute weight accounting + refresh
this.recomputeWeightAccounting(); this.recomputeWeightAccounting();
const delegateCount = this.selectedDelegates.size;
this.selectedDelegates.clear(); this.selectedDelegates.clear();
this.delegateSearchQuery = ""; this.delegateSearchQuery = "";
this.renderDelegationPanel(); this.renderDelegationPanel();
this.updateGraphData(); this.updateGraphData();
this.showToast(`Delegated to ${delegateCount} member${delegateCount > 1 ? "s" : ""}`);
} }
private recomputeWeightAccounting() { private recomputeWeightAccounting() {
@ -1845,6 +2111,123 @@ class FolkGraphViewer extends HTMLElement {
} }
} }
/** BFS from a node along delegation edges (inbound + outbound), return all reachable node IDs within maxDepth */
private computeDelegationPaths(nodeId: string, maxDepth: number): Set<string> {
const visited = new Set<string>([nodeId]);
let frontier = [nodeId];
for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
const next: string[] = [];
for (const nid of frontier) {
for (const e of this.edges) {
if (e.type !== "delegates_to") continue;
const sid = typeof e.source === "string" ? e.source : e.source.id;
const tid = typeof e.target === "string" ? e.target : e.target.id;
if (sid === nid && !visited.has(tid)) { visited.add(tid); next.push(tid); }
if (tid === nid && !visited.has(sid)) { visited.add(sid); next.push(sid); }
}
}
frontier = next;
}
return visited;
}
/** Update the network metrics sidebar (Phase 3) */
private updateMetricsPanel() {
const panel = this.shadow.getElementById("metrics-panel");
if (!panel) return;
if (!this.trustMode) {
panel.classList.remove("visible");
return;
}
panel.classList.add("visible");
// Compute metrics
const delegEdges = this.edges.filter(e => e.type === "delegates_to");
const totalDelegations = delegEdges.length;
// In-degree map
const inDegree = new Map<string, number>();
for (const e of delegEdges) {
const tid = typeof e.target === "string" ? e.target : e.target.id;
inDegree.set(tid, (inDegree.get(tid) || 0) + 1);
}
// Top 5 influencers by in-degree
const influencers = [...inDegree.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([id, count]) => {
const node = this.nodes.find(n => n.id === id);
return { id, name: node?.name || id.slice(0, 12), count };
});
// Gini concentration index on effective weights
const weights = this.nodes
.filter(n => n.weightAccounting)
.map(n => {
const acct = n.weightAccounting!;
return Object.values(acct.effectiveWeight).reduce((s, v) => s + v, 0);
})
.sort((a, b) => a - b);
let gini = 0;
if (weights.length > 1) {
const n = weights.length;
const mean = weights.reduce((s, v) => s + v, 0) / n;
if (mean > 0) {
let sumDiff = 0;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
sumDiff += Math.abs(weights[i] - weights[j]);
}
}
gini = sumDiff / (n * n * mean);
}
}
panel.innerHTML = `
<div class="metrics-title">Network Metrics</div>
<button class="metrics-toggle" id="metrics-close">&times;</button>
<div class="metrics-row"><span>Delegations</span><span class="metrics-value">${totalDelegations}</span></div>
<div class="metrics-row"><span>Gini Index</span><span class="metrics-value">${gini.toFixed(2)}</span></div>
<div class="metrics-row"><span>Participants</span><span class="metrics-value">${inDegree.size}</span></div>
${influencers.length > 0 ? `
<div class="metrics-section">Top Influencers</div>
${influencers.map(inf => `
<div class="metrics-influencer" data-focus-id="${inf.id}">
<span class="metrics-dot" style="background:#a78bfa"></span>
<span class="metrics-name">${this.esc(inf.name)}</span>
<span class="metrics-score">${inf.count} in</span>
</div>
`).join("")}
` : ""}
`;
// Click handlers
panel.querySelector("#metrics-close")?.addEventListener("click", () => {
panel.classList.remove("visible");
});
panel.querySelectorAll("[data-focus-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.focusId!;
const node = this.nodes.find(n => n.id === id);
if (node && this.graph && node.x != null && node.y != null && node.z != null) {
this.selectedNode = node;
this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
this.updateDetailPanel();
this.updateGraphData();
const dist = 60;
this.graph.cameraPosition(
{ x: node.x + dist, y: node.y + dist * 0.3, z: node.z + dist },
{ x: node.x, y: node.y, z: node.z },
400
);
}
});
});
}
private updateMemberList() { private updateMemberList() {
const panel = this.shadow.getElementById("member-list-panel"); const panel = this.shadow.getElementById("member-list-panel");
if (!panel) return; if (!panel) return;
@ -2027,6 +2410,8 @@ class FolkGraphViewer extends HTMLElement {
this.layersMode = false; this.layersMode = false;
this.layersPanelOpen = false; this.layersPanelOpen = false;
this.flowWiringSource = null; this.flowWiringSource = null;
this._pulseRings = [];
this.updateWiringStatus();
// Remove layer plane objects // Remove layer plane objects
this.removeLayerPlanes(); this.removeLayerPlanes();
@ -2211,6 +2596,67 @@ class FolkGraphViewer extends HTMLElement {
}); });
} }
private persistLayerConfig() {
if (!this._lfClient) return;
const layers = this.layerInstances.map(l => ({
moduleId: l.moduleId,
moduleName: l.moduleName,
moduleIcon: l.moduleIcon,
axis: l.axis,
}));
const flows = this.crossLayerFlows.map(f => ({
id: f.id,
sourceLayerIdx: f.sourceLayerIdx,
sourceFeedId: f.sourceFeedId,
targetLayerIdx: f.targetLayerIdx,
targetFeedId: f.targetFeedId,
kind: f.kind,
strength: f.strength,
}));
this._lfClient.saveLayerConfig(layers, flows);
}
private restoreLayerConfig() {
if (!this._lfClient) return;
const saved = this._lfClient.getLayerConfig();
if (!saved || saved.layers.length === 0) return;
// Only restore if not already in layers mode
if (this.layersMode && this.layerInstances.length > 0) return;
const available = this.getAvailableModules();
const restored: LayerInstance[] = [];
for (const lc of saved.layers) {
const mod = available.find(m => m.id === lc.moduleId);
if (!mod) continue;
restored.push({
moduleId: lc.moduleId,
moduleName: lc.moduleName,
moduleIcon: lc.moduleIcon,
moduleColor: MODULE_PALETTE[restored.length % MODULE_PALETTE.length],
axis: (lc.axis as AxisPlane) || "xy",
feeds: mod.feeds,
acceptsFeeds: mod.acceptsFeeds,
});
}
if (restored.length === 0) return;
this.layerInstances = restored;
this.crossLayerFlows = saved.flows.filter(f =>
f.sourceLayerIdx < restored.length && f.targetLayerIdx < restored.length
);
// Enter layers mode
if (!this.layersMode) {
this.layersMode = true;
this.savedGraphState = { nodes: [...this.nodes], edges: [...this.edges] };
}
this.rebuildLayerGraph();
this.renderLayersPanel();
}
private getPlaneOffset(axis: AxisPlane, layerIdx: number): { x: number; y: number; z: number } { private getPlaneOffset(axis: AxisPlane, layerIdx: number): { x: number; y: number; z: number } {
const spacing = 100; const spacing = 100;
const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing; const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing;
@ -2224,6 +2670,12 @@ class FolkGraphViewer extends HTMLElement {
private rebuildLayerGraph() { private rebuildLayerGraph() {
if (!this.graph) return; if (!this.graph) return;
// Clear pulse ring references (will be re-populated by createNodeObject)
this._pulseRings = [];
// Persist layer config to CRDT doc
this.persistLayerConfig();
// Remove old planes // Remove old planes
this.removeLayerPlanes(); this.removeLayerPlanes();
@ -2419,6 +2871,19 @@ class FolkGraphViewer extends HTMLElement {
this.layerPlaneObjects = []; this.layerPlaneObjects = [];
} }
private updateWiringStatus() {
const el = this.shadow.getElementById("wiring-status");
if (!el) return;
if (this.flowWiringSource) {
const layer = this.layerInstances[this.flowWiringSource.layerIdx];
el.textContent = `Wiring: click a compatible target from ${layer?.moduleIcon || ''} ${layer?.moduleName || ''}...`;
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
this._pulseRings = [];
}
}
private handleLayerNodeClick(node: GraphNode) { private handleLayerNodeClick(node: GraphNode) {
if (!node.layerId || !node.feedId || !node.feedKind) return; if (!node.layerId || !node.feedId || !node.feedKind) return;
const layerIdx = parseInt(node.layerId); const layerIdx = parseInt(node.layerId);
@ -2435,6 +2900,7 @@ class FolkGraphViewer extends HTMLElement {
// Highlight compatible targets by refreshing graph // Highlight compatible targets by refreshing graph
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh(); this.graph?.refresh();
this.updateWiringStatus();
} else { } else {
// Check compatibility // Check compatibility
const src = this.flowWiringSource; const src = this.flowWiringSource;
@ -2443,6 +2909,7 @@ class FolkGraphViewer extends HTMLElement {
this.flowWiringSource = null; this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh(); this.graph?.refresh();
this.updateWiringStatus();
return; return;
} }
@ -2456,6 +2923,7 @@ class FolkGraphViewer extends HTMLElement {
this.flowWiringSource = null; this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh(); this.graph?.refresh();
this.updateWiringStatus();
return; return;
} }
@ -2523,6 +2991,7 @@ class FolkGraphViewer extends HTMLElement {
this.flowWiringSource = null; this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh(); this.graph?.refresh();
this.updateWiringStatus();
}); });
// Create // Create
@ -2539,6 +3008,7 @@ class FolkGraphViewer extends HTMLElement {
}); });
overlay.style.display = "none"; overlay.style.display = "none";
this.flowWiringSource = null; this.flowWiringSource = null;
this.updateWiringStatus();
this.renderLayersPanel(); this.renderLayersPanel();
this.rebuildLayerGraph(); this.rebuildLayerGraph();
}); });

View File

@ -17,6 +17,7 @@ interface DelegationFlow {
weight: number; weight: number;
state: string; state: string;
createdAt: number; createdAt: number;
revokedAt: number | null;
} }
interface TrustEvent { interface TrustEvent {
@ -53,11 +54,24 @@ class FolkTrustSankey extends HTMLElement {
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
} }
private _delegationsHandler: ((e: Event) => void) | null = null;
connectedCallback() { connectedCallback() {
this.space = this.getAttribute("space") || "demo"; this.space = this.getAttribute("space") || "demo";
this.authority = this.getAttribute("authority") || "gov-ops"; this.authority = this.getAttribute("authority") || "gov-ops";
this.render(); this.render();
this.loadData(); this.loadData();
// Listen for cross-component sync
this._delegationsHandler = () => this.loadData();
document.addEventListener("delegations-updated", this._delegationsHandler);
}
disconnectedCallback() {
if (this._delegationsHandler) {
document.removeEventListener("delegations-updated", this._delegationsHandler);
this._delegationsHandler = null;
}
} }
private getAuthBase(): string { private getAuthBase(): string {
@ -81,10 +95,11 @@ class FolkTrustSankey extends HTMLElement {
const apiBase = this.getApiBase(); const apiBase = this.getApiBase();
try { try {
// Fetch all space-level delegations and user directory in parallel // Fetch delegations (including revoked), user directory, and trust events in parallel
const [delegRes, usersRes] = await Promise.all([ const [delegRes, usersRes, eventsRes] = await Promise.all([
fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}`), fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}&include_revoked=true`),
fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`), fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`),
fetch(`${authBase}/api/trust/events?space=${encodeURIComponent(this.space)}&limit=200`).catch(() => null),
]); ]);
const allFlows: DelegationFlow[] = []; const allFlows: DelegationFlow[] = [];
@ -92,15 +107,16 @@ class FolkTrustSankey extends HTMLElement {
const data = await delegRes.json(); const data = await delegRes.json();
for (const d of data.delegations || []) { for (const d of data.delegations || []) {
allFlows.push({ allFlows.push({
id: d.id, id: d.id || "",
fromDid: d.from, fromDid: d.from,
fromName: d.from.slice(0, 12) + "...", fromName: d.from.slice(0, 12) + "...",
toDid: d.to, toDid: d.to,
toName: d.to.slice(0, 12) + "...", toName: d.to.slice(0, 12) + "...",
authority: d.authority, authority: d.authority,
weight: d.weight, weight: d.weight,
state: "active", state: d.state || "active",
createdAt: Date.now(), createdAt: d.createdAt || Date.now(),
revokedAt: d.revokedAt || null,
}); });
} }
} }
@ -118,6 +134,12 @@ class FolkTrustSankey extends HTMLElement {
if (nameMap.has(f.toDid)) f.toName = nameMap.get(f.toDid)!; if (nameMap.has(f.toDid)) f.toName = nameMap.get(f.toDid)!;
} }
} }
// Load trust events for sparklines
if (eventsRes && eventsRes.ok) {
const evtData = await eventsRes.json();
this.events = evtData.events || [];
}
} catch { } catch {
this.error = "Failed to load delegation data"; this.error = "Failed to load delegation data";
} }
@ -127,15 +149,22 @@ class FolkTrustSankey extends HTMLElement {
} }
private getFilteredFlows(): DelegationFlow[] { private getFilteredFlows(): DelegationFlow[] {
let filtered = this.flows.filter(f => f.authority === this.authority && f.state === "active"); let filtered = this.flows.filter(f => f.authority === this.authority);
// Time slider: filter flows created before the time cutoff // Time slider: revocation-aware filtering
// Active at time T = createdAt <= T AND (state=active OR revokedAt > T)
if (this.timeSliderValue < 100 && filtered.length > 0) { if (this.timeSliderValue < 100 && filtered.length > 0) {
const times = filtered.map(f => f.createdAt).sort((a, b) => a - b); const times = filtered.map(f => f.createdAt).sort((a, b) => a - b);
const earliest = times[0]; const earliest = times[0];
const latest = Date.now(); const latest = Date.now();
const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100); const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100);
filtered = filtered.filter(f => f.createdAt <= cutoff); filtered = filtered.filter(f =>
f.createdAt <= cutoff &&
(f.state === "active" || (f.revokedAt != null && f.revokedAt > cutoff))
);
} else {
// At "Now" (100%), only show active flows
filtered = filtered.filter(f => f.state === "active");
} }
return filtered; return filtered;
@ -176,7 +205,7 @@ class FolkTrustSankey extends HTMLElement {
const f = flows[i]; const f = flows[i];
const y1 = leftPositions.get(f.fromDid)!; const y1 = leftPositions.get(f.fromDid)!;
const y2 = rightPositions.get(f.toDid)!; const y2 = rightPositions.get(f.toDid)!;
const thickness = Math.max(2, f.weight * 20); const thickness = 1.5 + Math.log10(1 + f.weight * 9) * 3;
const midX = (leftX + nodeW + rightX - nodeW) / 2; const midX = (leftX + nodeW + rightX - nodeW) / 2;
// Bezier path // Bezier path
@ -245,15 +274,46 @@ class FolkTrustSankey extends HTMLElement {
`; `;
} }
/** Render a tiny sparkline SVG (60x16) showing recent weight trend */ /** Render a tiny sparkline SVG (60x16) showing recent weight trend from trust events */
private renderSparkline(did: string, days: number): string { private renderSparkline(did: string, days: number): string {
// Use delegation creation timestamps as data points
const now = Date.now(); const now = Date.now();
const cutoff = now - days * 24 * 60 * 60 * 1000; const cutoff = now - days * 24 * 60 * 60 * 1000;
// Prefer trust events with weightDelta for accurate sparklines
const relevantEvents = this.events.filter(e =>
e.targetDid === did &&
(e.authority === this.authority || e.authority === null) &&
e.createdAt > cutoff &&
e.weightDelta != null
);
if (relevantEvents.length >= 2) {
// Build cumulative weight from events
const sorted = [...relevantEvents].sort((a, b) => a.createdAt - b.createdAt);
const points: Array<{ t: number; w: number }> = [];
let cumulative = 0;
for (const evt of sorted) {
cumulative += (evt.weightDelta || 0);
points.push({ t: evt.createdAt, w: Math.max(0, cumulative) });
}
const w = 50, h = 12;
const tMin = cutoff, tMax = now;
const wMax = Math.max(...points.map(p => p.w), 0.01);
const pathData = points.map((p, i) => {
const x = ((p.t - tMin) / (tMax - tMin)) * w;
const y = h - (p.w / wMax) * h;
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
}).join(" ");
return `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
}
// Fallback: use delegation creation timestamps
const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff); const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff);
if (relevant.length < 2) return ""; if (relevant.length < 2) return "";
// Build cumulative weight over time
const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt); const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt);
const points: Array<{ t: number; w: number }> = []; const points: Array<{ t: number; w: number }> = [];
let cumulative = 0; let cumulative = 0;

View File

@ -11,7 +11,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync'; import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto'; import { DocCrypto } from '../../shared/local-first/crypto';
import { networkSchema, networkDocId } from './schemas'; import { networkSchema, networkDocId } from './schemas';
import type { NetworkDoc, CrmContact, CrmRelationship } from './schemas'; import type { NetworkDoc, CrmContact, CrmRelationship, LayerConfig, CrossLayerFlowConfig } from './schemas';
export class NetworkLocalFirstClient { export class NetworkLocalFirstClient {
#space: string; #space: string;
@ -130,6 +130,25 @@ export class NetworkLocalFirstClient {
}); });
} }
// ── Layer Config ──
saveLayerConfig(layers: LayerConfig[], flows: CrossLayerFlowConfig[]): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Save layer config`, (d) => {
d.layerConfigs = layers as any;
d.crossLayerFlowConfigs = flows as any;
});
}
getLayerConfig(): { layers: LayerConfig[]; flows: CrossLayerFlowConfig[] } | null {
const doc = this.getDoc();
if (!doc || !doc.layerConfigs?.length) return null;
return {
layers: [...doc.layerConfigs] as LayerConfig[],
flows: [...(doc.crossLayerFlowConfigs || [])] as CrossLayerFlowConfig[],
};
}
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
await this.#sync.flush(); await this.#sync.flush();
this.#sync.disconnect(); this.#sync.disconnect();

View File

@ -36,6 +36,23 @@ export interface GraphLayout {
panY: number; panY: number;
} }
export interface LayerConfig {
moduleId: string;
moduleName: string;
moduleIcon: string;
axis: string;
}
export interface CrossLayerFlowConfig {
id: string;
sourceLayerIdx: number;
sourceFeedId: string;
targetLayerIdx: number;
targetFeedId: string;
kind: string;
strength: number;
}
export interface NetworkDoc { export interface NetworkDoc {
meta: { meta: {
module: string; module: string;
@ -47,6 +64,8 @@ export interface NetworkDoc {
contacts: Record<string, CrmContact>; contacts: Record<string, CrmContact>;
relationships: Record<string, CrmRelationship>; relationships: Record<string, CrmRelationship>;
graphLayout: GraphLayout; graphLayout: GraphLayout;
layerConfigs: LayerConfig[];
crossLayerFlowConfigs: CrossLayerFlowConfig[];
} }
// ── Schema registration ── // ── Schema registration ──
@ -54,24 +73,28 @@ export interface NetworkDoc {
export const networkSchema: DocSchema<NetworkDoc> = { export const networkSchema: DocSchema<NetworkDoc> = {
module: 'network', module: 'network',
collection: 'crm', collection: 'crm',
version: 1, version: 2,
init: (): NetworkDoc => ({ init: (): NetworkDoc => ({
meta: { meta: {
module: 'network', module: 'network',
collection: 'crm', collection: 'crm',
version: 1, version: 2,
spaceSlug: '', spaceSlug: '',
createdAt: Date.now(), createdAt: Date.now(),
}, },
contacts: {}, contacts: {},
relationships: {}, relationships: {},
graphLayout: { positions: {}, zoom: 1, panX: 0, panY: 0 }, graphLayout: { positions: {}, zoom: 1, panX: 0, panY: 0 },
layerConfigs: [],
crossLayerFlowConfigs: [],
}), }),
migrate: (doc: any, _fromVersion: number) => { migrate: (doc: any, _fromVersion: number) => {
if (!doc.contacts) doc.contacts = {}; if (!doc.contacts) doc.contacts = {};
if (!doc.relationships) doc.relationships = {}; if (!doc.relationships) doc.relationships = {};
if (!doc.graphLayout) doc.graphLayout = { positions: {}, zoom: 1, panX: 0, panY: 0 }; if (!doc.graphLayout) doc.graphLayout = { positions: {}, zoom: 1, panX: 0, panY: 0 };
doc.meta.version = 1; if (!doc.layerConfigs) doc.layerConfigs = [];
if (!doc.crossLayerFlowConfigs) doc.crossLayerFlowConfigs = [];
doc.meta.version = 2;
return doc; return doc;
}, },
}; };

View File

@ -118,6 +118,7 @@ import {
cleanExpiredDelegations, cleanExpiredDelegations,
logTrustEvent, logTrustEvent,
getTrustEvents, getTrustEvents,
getTrustEventsSince,
getAggregatedTrustScores, getAggregatedTrustScores,
getTrustScoresByAuthority, getTrustScoresByAuthority,
listAllUsersWithTrust, listAllUsersWithTrust,
@ -8726,20 +8727,51 @@ app.delete('/api/delegations/:id', async (c) => {
return c.json({ success: true }); return c.json({ success: true });
}); });
// GET /api/delegations/space — all active delegations for a space (internal use, no auth) // GET /api/delegations/space — all active (or all incl. revoked) delegations for a space (internal use, no auth)
app.get('/api/delegations/space', async (c) => { app.get('/api/delegations/space', async (c) => {
const spaceSlug = c.req.query('space'); const spaceSlug = c.req.query('space');
if (!spaceSlug) return c.json({ error: 'space query param required' }, 400); if (!spaceSlug) return c.json({ error: 'space query param required' }, 400);
const authority = c.req.query('authority'); const authority = c.req.query('authority');
const delegations = await listActiveDelegations(spaceSlug, authority || undefined); const includeRevoked = c.req.query('include_revoked') === 'true';
return c.json({
delegations: delegations.map(d => ({ let delegations;
if (includeRevoked) {
// Query all delegations regardless of state
const rows = authority
? await sql`
SELECT * FROM delegations
WHERE space_slug = ${spaceSlug} AND authority = ${authority}
ORDER BY created_at DESC
`
: await sql`
SELECT * FROM delegations
WHERE space_slug = ${spaceSlug}
ORDER BY created_at DESC
`;
delegations = rows.map((r: any) => ({
id: r.id,
from: r.delegator_did,
to: r.delegate_did,
authority: r.authority,
weight: parseFloat(r.weight),
state: r.state,
revokedAt: r.state === 'revoked' ? new Date(r.updated_at).getTime() : null,
}));
} else {
const active = await listActiveDelegations(spaceSlug, authority || undefined);
delegations = active.map(d => ({
id: d.id, id: d.id,
from: d.delegatorDid, from: d.delegatorDid,
to: d.delegateDid, to: d.delegateDid,
authority: d.authority, authority: d.authority,
weight: d.weight, weight: d.weight,
})), state: d.state,
revokedAt: null,
}));
}
return c.json({
delegations,
space: spaceSlug, space: spaceSlug,
authority: authority || 'all', authority: authority || 'all',
}); });
@ -8765,6 +8797,17 @@ app.get('/api/trust/scores/:did', async (c) => {
return c.json({ did, scores, space: spaceSlug }); return c.json({ did, scores, space: spaceSlug });
}); });
// GET /api/trust/events — space-level trust event history
app.get('/api/trust/events', async (c) => {
const spaceSlug = c.req.query('space');
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
const limit = parseInt(c.req.query('limit') || '200');
const events = await getTrustEventsSince(spaceSlug, Date.now() - 90 * 24 * 60 * 60 * 1000);
// Sort by created_at desc and limit
const limited = events.sort((a, b) => b.createdAt - a.createdAt).slice(0, Math.min(limit, 500));
return c.json({ events: limited, space: spaceSlug });
});
// GET /api/trust/events/:did — event history for one user // GET /api/trust/events/:did — event history for one user
app.get('/api/trust/events/:did', async (c) => { app.get('/api/trust/events/:did', async (c) => {
const did = c.req.param('did'); const did = c.req.param('did');