Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m11s
Details
CI/CD / deploy (push) Successful in 2m11s
Details
This commit is contained in:
commit
bbe93cde78
|
|
@ -172,6 +172,17 @@ class FolkCrmView extends HTMLElement {
|
|||
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() {
|
||||
const base = this.getApiBase();
|
||||
try {
|
||||
|
|
@ -690,10 +701,10 @@ class FolkCrmView extends HTMLElement {
|
|||
return `
|
||||
<div class="delegations-layout">
|
||||
<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 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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,20 +142,32 @@ class FolkDelegationManager extends HTMLElement {
|
|||
const authBase = this.getAuthBase();
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
delegateDid: this.modalDelegate,
|
||||
authority: this.modalAuthority,
|
||||
weight: this.modalWeight / 100,
|
||||
spaceSlug: this.space,
|
||||
maxDepth: this.modalMaxDepth,
|
||||
retainAuthority: this.modalRetainAuthority,
|
||||
});
|
||||
let res: Response;
|
||||
if (this.editingId) {
|
||||
// PATCH existing delegation
|
||||
const body = JSON.stringify({
|
||||
weight: this.modalWeight / 100,
|
||||
maxDepth: this.modalMaxDepth,
|
||||
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();
|
||||
|
||||
if (!res.ok) {
|
||||
this.error = data.error || "Failed to create delegation";
|
||||
this.error = data.error || "Failed to save delegation";
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
|
@ -164,8 +176,14 @@ class FolkDelegationManager extends HTMLElement {
|
|||
this.editingId = null;
|
||||
this.error = "";
|
||||
await this.loadData();
|
||||
|
||||
// Dispatch cross-component sync event
|
||||
this.dispatchEvent(new CustomEvent("delegations-updated", {
|
||||
bubbles: true, composed: true,
|
||||
detail: { space: this.space },
|
||||
}));
|
||||
} catch {
|
||||
this.error = "Network error creating delegation";
|
||||
this.error = "Network error saving delegation";
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +206,7 @@ class FolkDelegationManager extends HTMLElement {
|
|||
return;
|
||||
}
|
||||
await this.loadData();
|
||||
this.dispatchEvent(new CustomEvent("delegations-updated", { bubbles: true, composed: true, detail: { space: this.space } }));
|
||||
} catch {
|
||||
this.error = "Network error";
|
||||
this.render();
|
||||
|
|
@ -207,6 +226,7 @@ class FolkDelegationManager extends HTMLElement {
|
|||
return;
|
||||
}
|
||||
await this.loadData();
|
||||
this.dispatchEvent(new CustomEvent("delegations-updated", { bubbles: true, composed: true, detail: { space: this.space } }));
|
||||
} catch {
|
||||
this.error = "Network error";
|
||||
this.render();
|
||||
|
|
@ -239,6 +259,7 @@ class FolkDelegationManager extends HTMLElement {
|
|||
<span class="delegation-name">${this.esc(this.getUserName(d.delegateDid))}</span>
|
||||
<span class="delegation-weight">${Math.round(d.weight * 100)}%</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' ? `
|
||||
<button class="btn-sm btn-pause" data-pause="${d.id}" title="Pause">||</button>
|
||||
` : 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("")}
|
||||
</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">
|
||||
<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">
|
||||
|
||||
<label class="field-check">
|
||||
|
|
@ -356,6 +377,7 @@ class FolkDelegationManager extends HTMLElement {
|
|||
.btn-revoke:hover { color: #ef4444; }
|
||||
.btn-pause:hover { color: #f59e0b; }
|
||||
.btn-resume:hover { color: #22c55e; }
|
||||
.btn-edit:hover { color: #a78bfa; }
|
||||
|
||||
.modal-overlay {
|
||||
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
|
||||
this.shadow.querySelectorAll("[data-pause]").forEach(el => {
|
||||
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.modalWeight = parseInt((e.target as HTMLInputElement).value);
|
||||
const label = this.shadow.querySelector('.field-label:nth-of-type(3)');
|
||||
// live update shown via next render
|
||||
const label = this.shadow.getElementById("weight-label");
|
||||
if (label) label.textContent = `Weight: ${this.modalWeight}%`;
|
||||
});
|
||||
this.shadow.getElementById("modal-depth")?.addEventListener("input", (e) => {
|
||||
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.modalRetainAuthority = (e.target as HTMLInputElement).checked;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
|
||||
import { NetworkLocalFirstClient } from '../local-first-client';
|
||||
|
||||
interface WeightAccounting {
|
||||
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 _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() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
private _delegationsHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.renderDOM();
|
||||
this.loadData();
|
||||
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() {
|
||||
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) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
if (this._lfUnsubChange) { this._lfUnsubChange(); this._lfUnsubChange = null; }
|
||||
this._lfClient?.disconnect();
|
||||
this._lfClient = null;
|
||||
if (this.graph) {
|
||||
this.graph._destructor?.();
|
||||
this.graph = null;
|
||||
|
|
@ -199,6 +244,9 @@ class FolkGraphViewer extends HTMLElement {
|
|||
|
||||
private async loadData() {
|
||||
const base = this.getApiBase();
|
||||
// Show loading spinner
|
||||
const loader = this.shadow.getElementById("graph-loading");
|
||||
if (loader) loader.classList.remove("hidden");
|
||||
try {
|
||||
const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(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 */ }
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
// Hide loading spinner
|
||||
if (loader) loader.classList.add("hidden");
|
||||
this.updateStatsBar();
|
||||
this.updateAuthorityBar();
|
||||
this.updateWorkspaceList();
|
||||
|
|
@ -470,6 +520,57 @@ class FolkGraphViewer extends HTMLElement {
|
|||
}
|
||||
.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 {
|
||||
display: none; width: 260px; flex-shrink: 0;
|
||||
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; }
|
||||
}
|
||||
|
||||
.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) {
|
||||
.graph-row { flex-direction: column; }
|
||||
.graph-canvas { min-height: 300px; }
|
||||
|
|
@ -730,6 +840,9 @@ class FolkGraphViewer extends HTMLElement {
|
|||
.stats { flex-wrap: wrap; gap: 12px; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.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>
|
||||
|
||||
|
|
@ -754,6 +867,9 @@ class FolkGraphViewer extends HTMLElement {
|
|||
<div class="graph-row" id="graph-row">
|
||||
<div class="graph-canvas" id="graph-canvas">
|
||||
<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">
|
||||
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
||||
<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="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" id="flow-dialog"></div>
|
||||
</div>
|
||||
|
|
@ -787,6 +904,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
</div>
|
||||
|
||||
<div id="workspace-section"></div>
|
||||
<div class="graph-toast" id="graph-toast"></div>
|
||||
`;
|
||||
this.attachListeners();
|
||||
this.initGraph3D();
|
||||
|
|
@ -899,6 +1017,55 @@ class FolkGraphViewer extends HTMLElement {
|
|||
const btn = this.shadow.getElementById("layers-toggle");
|
||||
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) {
|
||||
|
|
@ -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 === "layer_internal") return 0.4;
|
||||
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;
|
||||
return style.width;
|
||||
|
|
@ -997,7 +1166,13 @@ class FolkGraphViewer extends HTMLElement {
|
|||
const tx = typeof t === "object" ? t.x : undefined;
|
||||
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) => {
|
||||
if (link.type === "cross_layer_flow") return 3;
|
||||
return link.type === "delegates_to" ? 4 : 0;
|
||||
|
|
@ -1024,6 +1199,12 @@ class FolkGraphViewer extends HTMLElement {
|
|||
}
|
||||
return "#c4b5fd";
|
||||
})
|
||||
.onNodeHover((node: GraphNode | null) => {
|
||||
clearTimeout(this._hoverDebounce);
|
||||
this._hoverDebounce = setTimeout(() => {
|
||||
this.handleNodeHover(node);
|
||||
}, 30);
|
||||
})
|
||||
.onNodeClick((node: GraphNode) => {
|
||||
// Layers mode: handle feed wiring
|
||||
if (this.layersMode && node.type === "feed") {
|
||||
|
|
@ -1036,8 +1217,15 @@ class FolkGraphViewer extends HTMLElement {
|
|||
// Toggle detail panel for inspection
|
||||
if (this.selectedNode?.id === node.id) {
|
||||
this.selectedNode = null;
|
||||
this._tracedPathNodes = null;
|
||||
} else {
|
||||
this.selectedNode = node;
|
||||
// Path tracing in trust mode
|
||||
if (this.trustMode) {
|
||||
this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
|
||||
} else {
|
||||
this._tracedPathNodes = null;
|
||||
}
|
||||
}
|
||||
this.updateDetailPanel();
|
||||
|
||||
|
|
@ -1051,6 +1239,8 @@ class FolkGraphViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
this.updateGraphData();
|
||||
// Update metrics when selection changes
|
||||
if (this.trustMode) this.updateMetricsPanel();
|
||||
})
|
||||
.d3AlphaDecay(0.02)
|
||||
.d3VelocityDecay(0.3)
|
||||
|
|
@ -1059,6 +1249,17 @@ class FolkGraphViewer extends HTMLElement {
|
|||
|
||||
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
|
||||
// Stronger repulsion — hub nodes push harder
|
||||
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 {
|
||||
// Import THREE from the global importmap
|
||||
const THREE = (window as any).__THREE_CACHE__ || null;
|
||||
|
|
@ -1150,13 +1385,18 @@ class FolkGraphViewer extends HTMLElement {
|
|||
const color = this.getNodeColor(node);
|
||||
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
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Sphere geometry
|
||||
const segments = (node.type === "module" || node.type === "feed") ? 24 : 12;
|
||||
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({
|
||||
color,
|
||||
transparent: true,
|
||||
|
|
@ -1188,7 +1428,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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) {
|
||||
const nodeLayerIdx = parseInt(node.layerId);
|
||||
if (nodeLayerIdx !== this.flowWiringSource.layerIdx) {
|
||||
|
|
@ -1197,18 +1437,35 @@ class FolkGraphViewer extends HTMLElement {
|
|||
if (srcLayer && tgtLayer) {
|
||||
const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
|
||||
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 pulseMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x4ade80, transparent: true, opacity: 0.5, side: THREE.DoubleSide,
|
||||
});
|
||||
const pulseRing = new THREE.Mesh(pulseGeo, pulseMat);
|
||||
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
|
||||
const label = this.createTextSprite(THREE, node);
|
||||
if (label) {
|
||||
|
|
@ -1318,6 +1575,10 @@ class FolkGraphViewer extends HTMLElement {
|
|||
if (!this.graph) return;
|
||||
|
||||
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));
|
||||
|
||||
// Compute max effective weight for filtered nodes (used by getNodeRadius)
|
||||
|
|
@ -1386,6 +1647,9 @@ class FolkGraphViewer extends HTMLElement {
|
|||
|
||||
// Refresh member list if visible
|
||||
if (this.showMemberList) this.updateMemberList();
|
||||
|
||||
// Show/update metrics panel in trust mode
|
||||
if (this.trustMode) this.updateMetricsPanel();
|
||||
}
|
||||
|
||||
// ── Ring layout ──
|
||||
|
|
@ -1791,10 +2055,12 @@ class FolkGraphViewer extends HTMLElement {
|
|||
|
||||
// Recompute weight accounting + refresh
|
||||
this.recomputeWeightAccounting();
|
||||
const delegateCount = this.selectedDelegates.size;
|
||||
this.selectedDelegates.clear();
|
||||
this.delegateSearchQuery = "";
|
||||
this.renderDelegationPanel();
|
||||
this.updateGraphData();
|
||||
this.showToast(`Delegated to ${delegateCount} member${delegateCount > 1 ? "s" : ""}`);
|
||||
}
|
||||
|
||||
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">×</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() {
|
||||
const panel = this.shadow.getElementById("member-list-panel");
|
||||
if (!panel) return;
|
||||
|
|
@ -2027,6 +2410,8 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this.layersMode = false;
|
||||
this.layersPanelOpen = false;
|
||||
this.flowWiringSource = null;
|
||||
this._pulseRings = [];
|
||||
this.updateWiringStatus();
|
||||
|
||||
// Remove layer plane objects
|
||||
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 } {
|
||||
const spacing = 100;
|
||||
const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing;
|
||||
|
|
@ -2224,6 +2670,12 @@ class FolkGraphViewer extends HTMLElement {
|
|||
private rebuildLayerGraph() {
|
||||
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
|
||||
this.removeLayerPlanes();
|
||||
|
||||
|
|
@ -2419,6 +2871,19 @@ class FolkGraphViewer extends HTMLElement {
|
|||
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) {
|
||||
if (!node.layerId || !node.feedId || !node.feedKind) return;
|
||||
const layerIdx = parseInt(node.layerId);
|
||||
|
|
@ -2435,6 +2900,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
// Highlight compatible targets by refreshing graph
|
||||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||||
this.graph?.refresh();
|
||||
this.updateWiringStatus();
|
||||
} else {
|
||||
// Check compatibility
|
||||
const src = this.flowWiringSource;
|
||||
|
|
@ -2443,6 +2909,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this.flowWiringSource = null;
|
||||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||||
this.graph?.refresh();
|
||||
this.updateWiringStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2456,6 +2923,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this.flowWiringSource = null;
|
||||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||||
this.graph?.refresh();
|
||||
this.updateWiringStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2523,6 +2991,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
this.flowWiringSource = null;
|
||||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||||
this.graph?.refresh();
|
||||
this.updateWiringStatus();
|
||||
});
|
||||
|
||||
// Create
|
||||
|
|
@ -2539,6 +3008,7 @@ class FolkGraphViewer extends HTMLElement {
|
|||
});
|
||||
overlay.style.display = "none";
|
||||
this.flowWiringSource = null;
|
||||
this.updateWiringStatus();
|
||||
this.renderLayersPanel();
|
||||
this.rebuildLayerGraph();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface DelegationFlow {
|
|||
weight: number;
|
||||
state: string;
|
||||
createdAt: number;
|
||||
revokedAt: number | null;
|
||||
}
|
||||
|
||||
interface TrustEvent {
|
||||
|
|
@ -53,11 +54,24 @@ class FolkTrustSankey extends HTMLElement {
|
|||
this.shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
private _delegationsHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.space = this.getAttribute("space") || "demo";
|
||||
this.authority = this.getAttribute("authority") || "gov-ops";
|
||||
this.render();
|
||||
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 {
|
||||
|
|
@ -81,10 +95,11 @@ class FolkTrustSankey extends HTMLElement {
|
|||
const apiBase = this.getApiBase();
|
||||
|
||||
try {
|
||||
// Fetch all space-level delegations and user directory in parallel
|
||||
const [delegRes, usersRes] = await Promise.all([
|
||||
fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}`),
|
||||
// Fetch delegations (including revoked), user directory, and trust events in parallel
|
||||
const [delegRes, usersRes, eventsRes] = await Promise.all([
|
||||
fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}&include_revoked=true`),
|
||||
fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`),
|
||||
fetch(`${authBase}/api/trust/events?space=${encodeURIComponent(this.space)}&limit=200`).catch(() => null),
|
||||
]);
|
||||
|
||||
const allFlows: DelegationFlow[] = [];
|
||||
|
|
@ -92,15 +107,16 @@ class FolkTrustSankey extends HTMLElement {
|
|||
const data = await delegRes.json();
|
||||
for (const d of data.delegations || []) {
|
||||
allFlows.push({
|
||||
id: d.id,
|
||||
id: d.id || "",
|
||||
fromDid: d.from,
|
||||
fromName: d.from.slice(0, 12) + "...",
|
||||
toDid: d.to,
|
||||
toName: d.to.slice(0, 12) + "...",
|
||||
authority: d.authority,
|
||||
weight: d.weight,
|
||||
state: "active",
|
||||
createdAt: Date.now(),
|
||||
state: d.state || "active",
|
||||
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)!;
|
||||
}
|
||||
}
|
||||
|
||||
// Load trust events for sparklines
|
||||
if (eventsRes && eventsRes.ok) {
|
||||
const evtData = await eventsRes.json();
|
||||
this.events = evtData.events || [];
|
||||
}
|
||||
} catch {
|
||||
this.error = "Failed to load delegation data";
|
||||
}
|
||||
|
|
@ -127,15 +149,22 @@ class FolkTrustSankey extends HTMLElement {
|
|||
}
|
||||
|
||||
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) {
|
||||
const times = filtered.map(f => f.createdAt).sort((a, b) => a - b);
|
||||
const earliest = times[0];
|
||||
const latest = Date.now();
|
||||
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;
|
||||
|
|
@ -176,7 +205,7 @@ class FolkTrustSankey extends HTMLElement {
|
|||
const f = flows[i];
|
||||
const y1 = leftPositions.get(f.fromDid)!;
|
||||
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;
|
||||
|
||||
// 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 {
|
||||
// Use delegation creation timestamps as data points
|
||||
const now = Date.now();
|
||||
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);
|
||||
if (relevant.length < 2) return "";
|
||||
|
||||
// Build cumulative weight over time
|
||||
const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt);
|
||||
const points: Array<{ t: number; w: number }> = [];
|
||||
let cumulative = 0;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage';
|
|||
import { DocSyncManager } from '../../shared/local-first/sync';
|
||||
import { DocCrypto } from '../../shared/local-first/crypto';
|
||||
import { networkSchema, networkDocId } from './schemas';
|
||||
import type { NetworkDoc, CrmContact, CrmRelationship } from './schemas';
|
||||
import type { NetworkDoc, CrmContact, CrmRelationship, LayerConfig, CrossLayerFlowConfig } from './schemas';
|
||||
|
||||
export class NetworkLocalFirstClient {
|
||||
#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> {
|
||||
await this.#sync.flush();
|
||||
this.#sync.disconnect();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,23 @@ export interface GraphLayout {
|
|||
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 {
|
||||
meta: {
|
||||
module: string;
|
||||
|
|
@ -47,6 +64,8 @@ export interface NetworkDoc {
|
|||
contacts: Record<string, CrmContact>;
|
||||
relationships: Record<string, CrmRelationship>;
|
||||
graphLayout: GraphLayout;
|
||||
layerConfigs: LayerConfig[];
|
||||
crossLayerFlowConfigs: CrossLayerFlowConfig[];
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
|
@ -54,24 +73,28 @@ export interface NetworkDoc {
|
|||
export const networkSchema: DocSchema<NetworkDoc> = {
|
||||
module: 'network',
|
||||
collection: 'crm',
|
||||
version: 1,
|
||||
version: 2,
|
||||
init: (): NetworkDoc => ({
|
||||
meta: {
|
||||
module: 'network',
|
||||
collection: 'crm',
|
||||
version: 1,
|
||||
version: 2,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
contacts: {},
|
||||
relationships: {},
|
||||
graphLayout: { positions: {}, zoom: 1, panX: 0, panY: 0 },
|
||||
layerConfigs: [],
|
||||
crossLayerFlowConfigs: [],
|
||||
}),
|
||||
migrate: (doc: any, _fromVersion: number) => {
|
||||
if (!doc.contacts) doc.contacts = {};
|
||||
if (!doc.relationships) doc.relationships = {};
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ import {
|
|||
cleanExpiredDelegations,
|
||||
logTrustEvent,
|
||||
getTrustEvents,
|
||||
getTrustEventsSince,
|
||||
getAggregatedTrustScores,
|
||||
getTrustScoresByAuthority,
|
||||
listAllUsersWithTrust,
|
||||
|
|
@ -8726,20 +8727,51 @@ app.delete('/api/delegations/:id', async (c) => {
|
|||
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) => {
|
||||
const spaceSlug = c.req.query('space');
|
||||
if (!spaceSlug) return c.json({ error: 'space query param required' }, 400);
|
||||
const authority = c.req.query('authority');
|
||||
const delegations = await listActiveDelegations(spaceSlug, authority || undefined);
|
||||
return c.json({
|
||||
delegations: delegations.map(d => ({
|
||||
const includeRevoked = c.req.query('include_revoked') === 'true';
|
||||
|
||||
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,
|
||||
from: d.delegatorDid,
|
||||
to: d.delegateDid,
|
||||
authority: d.authority,
|
||||
weight: d.weight,
|
||||
})),
|
||||
state: d.state,
|
||||
revokedAt: null,
|
||||
}));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
delegations,
|
||||
space: spaceSlug,
|
||||
authority: authority || 'all',
|
||||
});
|
||||
|
|
@ -8765,6 +8797,17 @@ app.get('/api/trust/scores/:did', async (c) => {
|
|||
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
|
||||
app.get('/api/trust/events/:did', async (c) => {
|
||||
const did = c.req.param('did');
|
||||
|
|
|
|||
Loading…
Reference in New Issue