+
+
No nodes match your current filter.
+
100%
@@ -768,6 +884,7 @@ class FolkGraphViewer extends HTMLElement {
+
@@ -787,6 +904,7 @@ class FolkGraphViewer extends HTMLElement {
+
`;
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
{
+ const visited = new Set([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();
+ 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 = `
+ Network Metrics
+
+ Delegations${totalDelegations}
+ Gini Index${gini.toFixed(2)}
+ Participants${inDegree.size}
+ ${influencers.length > 0 ? `
+ Top Influencers
+ ${influencers.map(inf => `
+
+
+ ${this.esc(inf.name)}
+ ${inf.count} in
+
+ `).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();
});
diff --git a/modules/rnetwork/components/folk-trust-sankey.ts b/modules/rnetwork/components/folk-trust-sankey.ts
index 2a5e811..7d7fbc8 100644
--- a/modules/rnetwork/components/folk-trust-sankey.ts
+++ b/modules/rnetwork/components/folk-trust-sankey.ts
@@ -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 ``;
+ }
+
+ // 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;
diff --git a/modules/rnetwork/local-first-client.ts b/modules/rnetwork/local-first-client.ts
index 15f32d3..8fd198b 100644
--- a/modules/rnetwork/local-first-client.ts
+++ b/modules/rnetwork/local-first-client.ts
@@ -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(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 {
await this.#sync.flush();
this.#sync.disconnect();
diff --git a/modules/rnetwork/schemas.ts b/modules/rnetwork/schemas.ts
index 92abb64..8f64bc8 100644
--- a/modules/rnetwork/schemas.ts
+++ b/modules/rnetwork/schemas.ts
@@ -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;
relationships: Record;
graphLayout: GraphLayout;
+ layerConfigs: LayerConfig[];
+ crossLayerFlowConfigs: CrossLayerFlowConfig[];
}
// ── Schema registration ──
@@ -54,24 +73,28 @@ export interface NetworkDoc {
export const networkSchema: DocSchema = {
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;
},
};
diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts
index f255407..2bf5b6c 100644
--- a/src/encryptid/server.ts
+++ b/src/encryptid/server.ts
@@ -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');