Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 17:45:01 -07:00
commit 521ea557f4
3 changed files with 899 additions and 17 deletions

View File

@ -359,6 +359,8 @@ class FolkGraphViewer extends HTMLElement {
} }
private getNodeRadius(node: GraphNode): number { private getNodeRadius(node: GraphNode): number {
if (node.type === "module") return 30;
if (node.type === "feed") return 15;
if (node.type === "company") return 22; if (node.type === "company") return 22;
if (node.type === "space") return 16; if (node.type === "space") return 16;
if (this.trustMode && node.weightAccounting) { if (this.trustMode && node.weightAccounting) {
@ -392,6 +394,12 @@ class FolkGraphViewer extends HTMLElement {
} }
private getNodeColor(node: GraphNode): number { private getNodeColor(node: GraphNode): number {
if (node.type === "module") return node.moduleColor || 0x6366f1;
if (node.type === "feed") {
const flowColor = LAYER_FLOW_COLORS[node.feedKind || "custom"];
if (flowColor) return parseInt(flowColor.replace("#", ""), 16);
return 0x94a3b8;
}
if (node.type === "company") { if (node.type === "company") {
return this.companyColors.get(node.id) || NODE_COLORS.company; return this.companyColors.get(node.id) || NODE_COLORS.company;
} }
@ -926,6 +934,10 @@ class FolkGraphViewer extends HTMLElement {
.linkSource("source") .linkSource("source")
.linkTarget("target") .linkTarget("target")
.linkColor((link: GraphEdge) => { .linkColor((link: GraphEdge) => {
if (link.type === "cross_layer_flow" && link.flowKind) {
return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8";
}
if (link.type === "layer_internal") return "rgba(255,255,255,0.15)";
if (link.type === "delegates_to") { if (link.type === "delegates_to") {
if (this.authority === "all" && link.authority) { if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || EDGE_STYLES.delegates_to.color; return AUTHORITY_COLORS[link.authority] || EDGE_STYLES.delegates_to.color;
@ -936,29 +948,40 @@ class FolkGraphViewer extends HTMLElement {
return style.color; return style.color;
}) })
.linkWidth((link: GraphEdge) => { .linkWidth((link: GraphEdge) => {
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") { if (link.type === "delegates_to") {
return 1 + (link.weight || 0.5) * 8; return 1 + (link.weight || 0.5) * 8;
} }
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.width; return style.width;
}) })
.linkCurvature((link: GraphEdge) => .linkCurvature((link: GraphEdge) => {
link.type === "delegates_to" ? 0.15 : 0 if (link.type === "cross_layer_flow") return 0.2;
) return link.type === "delegates_to" ? 0.15 : 0;
})
.linkCurveRotation("rotation") .linkCurveRotation("rotation")
.linkOpacity(0.6) .linkOpacity(0.6)
.linkDirectionalArrowLength((link: GraphEdge) => .linkDirectionalArrowLength((link: GraphEdge) => {
link.type === "delegates_to" ? 4 : 0 if (link.type === "cross_layer_flow") return 3;
) return link.type === "delegates_to" ? 4 : 0;
})
.linkDirectionalArrowRelPos(1) .linkDirectionalArrowRelPos(1)
.linkDirectionalParticles((link: GraphEdge) => .linkDirectionalParticles((link: GraphEdge) => {
link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0 if (link.type === "cross_layer_flow") return Math.ceil((link.strength || 0.5) * 3);
) return link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0;
.linkDirectionalParticleSpeed(0.004) })
.linkDirectionalParticleWidth((link: GraphEdge) => .linkDirectionalParticleSpeed((link: GraphEdge) =>
link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0 link.type === "cross_layer_flow" ? 0.006 : 0.004
) )
.linkDirectionalParticleWidth((link: GraphEdge) => {
if (link.type === "cross_layer_flow") return 1.5;
return link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0;
})
.linkDirectionalParticleColor((link: GraphEdge) => { .linkDirectionalParticleColor((link: GraphEdge) => {
if (link.type === "cross_layer_flow" && link.flowKind) {
return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8";
}
if (link.type !== "delegates_to") return null; if (link.type !== "delegates_to") return null;
if (this.authority === "all" && link.authority) { if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || "#c4b5fd"; return AUTHORITY_COLORS[link.authority] || "#c4b5fd";
@ -966,6 +989,12 @@ class FolkGraphViewer extends HTMLElement {
return "#c4b5fd"; return "#c4b5fd";
}) })
.onNodeClick((node: GraphNode) => { .onNodeClick((node: GraphNode) => {
// Layers mode: handle feed wiring
if (this.layersMode && node.type === "feed") {
this.handleLayerNodeClick(node);
return;
}
const canDelegate = node.type === "rspace_user" || node.type === "person"; const canDelegate = node.type === "rspace_user" || node.type === "person";
// Toggle detail panel for inspection // Toggle detail panel for inspection
@ -1089,15 +1118,27 @@ class FolkGraphViewer extends HTMLElement {
const group = new THREE.Group(); const group = new THREE.Group();
// Sphere geometry // Sphere geometry
const geometry = new THREE.SphereGeometry(radius, 16, 12); const segments = (node.type === "module" || node.type === "feed") ? 24 : 16;
const geometry = new THREE.SphereGeometry(radius, segments, segments * 3 / 4);
const opacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75;
const material = new THREE.MeshLambertMaterial({ const material = new THREE.MeshLambertMaterial({
color, color,
transparent: true, transparent: true,
opacity: node.type === "company" ? 0.9 : 0.75, opacity,
}); });
const sphere = new THREE.Mesh(geometry, material); const sphere = new THREE.Mesh(geometry, material);
group.add(sphere); group.add(sphere);
// Glow ring for module hub nodes
if (node.type === "module") {
const glowGeo = new THREE.RingGeometry(radius + 0.2, radius + 0.6, 32);
const glowMat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.3, side: THREE.DoubleSide,
});
const glow = new THREE.Mesh(glowGeo, glowMat);
group.add(glow);
}
// Selection ring // Selection ring
if (isSelected) { if (isSelected) {
const ringGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.5, 32); const ringGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.5, 32);
@ -1111,6 +1152,27 @@ class FolkGraphViewer extends HTMLElement {
group.add(ring); group.add(ring);
} }
// Wiring highlight for compatible feed targets
if (this.layersMode && this.flowWiringSource && node.type === "feed" && node.layerId) {
const nodeLayerIdx = parseInt(node.layerId);
if (nodeLayerIdx !== this.flowWiringSource.layerIdx) {
const srcLayer = this.layerInstances[this.flowWiringSource.layerIdx];
const tgtLayer = this.layerInstances[nodeLayerIdx];
if (srcLayer && tgtLayer) {
const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
if (compat.size > 0) {
// Pulse ring for compatible target
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);
}
}
}
}
// Text label as sprite // Text label as sprite
const label = this.createTextSprite(THREE, node); const label = this.createTextSprite(THREE, node);
if (label) { if (label) {
@ -1119,7 +1181,7 @@ class FolkGraphViewer extends HTMLElement {
} }
// Trust badge sprite — show per-authority effective weight in trust mode // Trust badge sprite — show per-authority effective weight in trust mode
if (node.type !== "company" && node.type !== "space") { if (node.type !== "company" && node.type !== "space" && node.type !== "module" && node.type !== "feed") {
let badgeText = ""; let badgeText = "";
let badgeColor = "#7c3aed"; let badgeColor = "#7c3aed";
if (this.trustMode && node.weightAccounting && this.authority !== "all") { if (this.trustMode && node.weightAccounting && this.authority !== "all") {
@ -1148,11 +1210,11 @@ class FolkGraphViewer extends HTMLElement {
if (!ctx) return null; if (!ctx) return null;
const text = node.name; const text = node.name;
const fontSize = node.type === "company" ? 42 : 36; const fontSize = node.type === "module" ? 44 : node.type === "company" ? 42 : node.type === "feed" ? 30 : 36;
canvas.width = 512; canvas.width = 512;
canvas.height = 96; canvas.height = 96;
ctx.font = `${node.type === "company" ? "600" : "500"} ${fontSize}px system-ui, sans-serif`; ctx.font = `${(node.type === "company" || node.type === "module") ? "600" : "500"} ${fontSize}px system-ui, sans-serif`;
ctx.fillStyle = "#e2e8f0"; ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
@ -1828,6 +1890,606 @@ class FolkGraphViewer extends HTMLElement {
</div> </div>
`; `;
} }
// ══════════════════════════════════════════════════════════════
// ═══ LAYERS MODE ═══════════════════════════════════════════
// ══════════════════════════════════════════════════════════════
private getAvailableModules(): Array<{ id: string; name: string; icon: string; feeds: any[]; acceptsFeeds: string[] }> {
const modList = (window as any).__rspaceModuleList as any[] || [];
return modList
.filter((m: any) => !m.hidden && (m.feeds?.length > 0 || m.acceptsFeeds?.length > 0))
.map((m: any) => ({
id: m.id,
name: m.name || m.id,
icon: m.icon || "\u{1F4E6}",
feeds: m.feeds || [],
acceptsFeeds: m.acceptsFeeds || [],
}));
}
private getModuleOutputKinds(moduleId: string): Set<string> {
const mods = (window as any).__rspaceModuleList as any[] || [];
const mod = mods.find((m: any) => m.id === moduleId);
if (!mod?.feeds) return new Set();
return new Set(mod.feeds.map((f: any) => f.kind));
}
private getModuleInputKinds(moduleId: string): Set<string> {
const mods = (window as any).__rspaceModuleList as any[] || [];
const mod = mods.find((m: any) => m.id === moduleId);
if (!mod?.acceptsFeeds) return new Set();
return new Set(mod.acceptsFeeds);
}
private getCompatibleFlowKinds(srcModuleId: string, tgtModuleId: string): Set<string> {
const srcOutputs = this.getModuleOutputKinds(srcModuleId);
const tgtInputs = this.getModuleInputKinds(tgtModuleId);
if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set();
const compatible = new Set<string>();
Array.from(srcOutputs).forEach(kind => {
if (tgtInputs.has(kind)) compatible.add(kind);
});
return compatible;
}
private enterLayersMode() {
if (this.layersMode) return;
this.layersMode = true;
this.layersPanelOpen = true;
// Save current graph state
this.savedGraphState = {
nodes: [...this.nodes],
edges: [...this.edges],
};
// Save camera constraints
const controls = this.graph?.controls();
if (controls) {
this.savedCameraControls = {
minPolar: controls.minPolarAngle,
maxPolar: controls.maxPolarAngle,
minDist: controls.minDistance,
maxDist: controls.maxDistance,
};
// Free camera
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI;
controls.minDistance = 20;
controls.maxDistance = 2000;
}
// Clear existing ring guides
this.removeRingGuides();
// Show layers panel
this.renderLayersPanel();
this.rebuildLayerGraph();
}
private exitLayersMode() {
if (!this.layersMode) return;
this.layersMode = false;
this.layersPanelOpen = false;
this.flowWiringSource = null;
// Remove layer plane objects
this.removeLayerPlanes();
// Restore camera constraints
const controls = this.graph?.controls();
if (controls && this.savedCameraControls) {
controls.minPolarAngle = this.savedCameraControls.minPolar;
controls.maxPolarAngle = this.savedCameraControls.maxPolar;
controls.minDistance = this.savedCameraControls.minDist;
controls.maxDistance = this.savedCameraControls.maxDist;
}
this.savedCameraControls = null;
// Restore original graph data
if (this.savedGraphState) {
this.nodes = this.savedGraphState.nodes;
this.edges = this.savedGraphState.edges;
this.savedGraphState = null;
}
// Clear layer state
this.layerInstances = [];
this.crossLayerFlows = [];
// Hide panels
const panel = this.shadow.getElementById("layers-panel");
if (panel) panel.classList.remove("visible");
const flowOverlay = this.shadow.getElementById("flow-dialog-overlay");
if (flowOverlay) flowOverlay.style.display = "none";
this.updateGraphData();
}
private renderLayersPanel() {
const panel = this.shadow.getElementById("layers-panel");
if (!panel) return;
if (!this.layersPanelOpen) {
panel.classList.remove("visible");
return;
}
panel.classList.add("visible");
const available = this.getAvailableModules();
const selectedIds = new Set(this.layerInstances.map(l => l.moduleId));
const moduleButtons = available.map(m => {
const isSelected = selectedIds.has(m.id);
const atMax = this.layerInstances.length >= 3 && !isSelected;
return `<button class="module-pick-btn ${isSelected ? 'selected' : ''} ${atMax ? 'disabled' : ''}" data-mod-id="${m.id}" ${atMax ? 'disabled' : ''}>${m.icon} ${this.esc(m.name)}</button>`;
}).join("");
const layerRows = this.layerInstances.map((layer, idx) => {
const feedList = layer.feeds.map(f =>
`<span style="color:${LAYER_FLOW_COLORS[f.kind] || '#94a3b8'}">${f.name}</span>`
).join(", ");
return `
<div class="layer-row">
<span class="layer-row-icon">${layer.moduleIcon}</span>
<span class="layer-row-name">${this.esc(layer.moduleName)}</span>
<select class="axis-select" data-layer-idx="${idx}">
<option value="xy" ${layer.axis === "xy" ? "selected" : ""}>XY (front)</option>
<option value="xz" ${layer.axis === "xz" ? "selected" : ""}>XZ (floor)</option>
<option value="yz" ${layer.axis === "yz" ? "selected" : ""}>YZ (side)</option>
</select>
<button class="layer-row-remove" data-remove-idx="${idx}" title="Remove layer">\u2715</button>
</div>
${feedList ? `<div class="layer-feeds-hint">Feeds: ${feedList}</div>` : ""}
`;
}).join("");
const flowRows = this.crossLayerFlows.map((flow, idx) => {
const srcLayer = this.layerInstances[flow.sourceLayerIdx];
const tgtLayer = this.layerInstances[flow.targetLayerIdx];
if (!srcLayer || !tgtLayer) return "";
return `<div class="layer-row">
<span style="color:${LAYER_FLOW_COLORS[flow.kind] || '#94a3b8'}">\u25CF</span>
<span class="layer-row-name" style="font-size:11px">${srcLayer.moduleIcon} \u2192 ${tgtLayer.moduleIcon} (${LAYER_FLOW_LABELS[flow.kind] || flow.kind})</span>
<span style="font-size:10px;color:var(--rs-text-muted)">${Math.round(flow.strength * 100)}%</span>
<button class="layer-row-remove" data-remove-flow="${idx}" title="Remove flow">\u2715</button>
</div>`;
}).join("");
panel.innerHTML = `
<div class="layers-panel-header">
<span class="layers-panel-title">Layer Visualization</span>
<button class="layers-panel-close" id="layers-panel-close">\u2715</button>
</div>
<div class="module-picker">${moduleButtons}</div>
${layerRows}
${this.crossLayerFlows.length > 0 ? `<div style="font-size:11px;font-weight:600;color:var(--rs-text-muted);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em">Flows</div>${flowRows}` : ""}
${this.layerInstances.length >= 2 ? '<div class="layer-feeds-hint" style="margin-top:8px">Click a feed node, then click a compatible target to create a flow.</div>' : ""}
`;
this.attachLayersPanelListeners(panel);
}
private attachLayersPanelListeners(panel: HTMLElement) {
// Close
this.shadow.getElementById("layers-panel-close")?.addEventListener("click", () => {
this.layersPanelOpen = false;
this.renderLayersPanel();
});
// Module pick buttons
panel.querySelectorAll("[data-mod-id]").forEach(el => {
el.addEventListener("click", () => {
const modId = (el as HTMLElement).dataset.modId!;
const existIdx = this.layerInstances.findIndex(l => l.moduleId === modId);
if (existIdx >= 0) {
// Deselect
this.layerInstances.splice(existIdx, 1);
// Remove any flows referencing this layer
this.crossLayerFlows = this.crossLayerFlows.filter(f =>
f.sourceLayerIdx !== existIdx && f.targetLayerIdx !== existIdx
).map(f => ({
...f,
sourceLayerIdx: f.sourceLayerIdx > existIdx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx,
targetLayerIdx: f.targetLayerIdx > existIdx ? f.targetLayerIdx - 1 : f.targetLayerIdx,
}));
} else if (this.layerInstances.length < 3) {
const mods = this.getAvailableModules();
const mod = mods.find(m => m.id === modId);
if (mod) {
const axes: AxisPlane[] = ["xy", "xz", "yz"];
const usedAxes = new Set(this.layerInstances.map(l => l.axis));
const axis = axes.find(a => !usedAxes.has(a)) || "xy";
this.layerInstances.push({
moduleId: mod.id,
moduleName: mod.name,
moduleIcon: mod.icon,
moduleColor: MODULE_PALETTE[this.layerInstances.length % MODULE_PALETTE.length],
axis,
feeds: mod.feeds.map((f: any) => ({ id: f.id, name: f.name, kind: f.kind })),
acceptsFeeds: mod.acceptsFeeds,
});
}
}
this.renderLayersPanel();
this.rebuildLayerGraph();
});
});
// Axis select
panel.querySelectorAll("[data-layer-idx]").forEach(el => {
el.addEventListener("change", () => {
const idx = parseInt((el as HTMLElement).dataset.layerIdx!);
const val = (el as HTMLSelectElement).value as AxisPlane;
if (this.layerInstances[idx]) {
this.layerInstances[idx].axis = val;
this.rebuildLayerGraph();
}
});
});
// Remove layer
panel.querySelectorAll("[data-remove-idx]").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.removeIdx!);
this.layerInstances.splice(idx, 1);
this.crossLayerFlows = this.crossLayerFlows.filter(f =>
f.sourceLayerIdx !== idx && f.targetLayerIdx !== idx
).map(f => ({
...f,
sourceLayerIdx: f.sourceLayerIdx > idx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx,
targetLayerIdx: f.targetLayerIdx > idx ? f.targetLayerIdx - 1 : f.targetLayerIdx,
}));
this.renderLayersPanel();
this.rebuildLayerGraph();
});
});
// Remove flow
panel.querySelectorAll("[data-remove-flow]").forEach(el => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.removeFlow!);
this.crossLayerFlows.splice(idx, 1);
this.renderLayersPanel();
this.rebuildLayerGraph();
});
});
}
private getPlaneOffset(axis: AxisPlane, layerIdx: number): { x: number; y: number; z: number } {
const spacing = 100;
const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing;
switch (axis) {
case "xy": return { x: 0, y: 0, z: offset };
case "xz": return { x: 0, y: offset, z: 0 };
case "yz": return { x: offset, y: 0, z: 0 };
}
}
private rebuildLayerGraph() {
if (!this.graph) return;
// Remove old planes
this.removeLayerPlanes();
if (this.layerInstances.length === 0) {
this.graph.graphData({ nodes: [], links: [] });
return;
}
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
// Build nodes/edges for each layer
for (let i = 0; i < this.layerInstances.length; i++) {
const layer = this.layerInstances[i];
const offset = this.getPlaneOffset(layer.axis, i);
// Hub node (module center)
const hubId = `layer-${i}-hub`;
nodes.push({
id: hubId,
name: layer.moduleName,
type: "module",
workspace: "",
layerId: String(i),
moduleColor: layer.moduleColor,
fx: offset.x,
fy: offset.y,
fz: offset.z,
});
// Feed nodes arranged in circle around hub
const feedCount = layer.feeds.length;
const feedRadius = 25;
for (let j = 0; j < feedCount; j++) {
const feed = layer.feeds[j];
const angle = (2 * Math.PI * j) / feedCount;
// Calculate feed position on the plane
let fx: number, fy: number, fz: number;
switch (layer.axis) {
case "xy":
fx = offset.x + Math.cos(angle) * feedRadius;
fy = offset.y + Math.sin(angle) * feedRadius;
fz = offset.z;
break;
case "xz":
fx = offset.x + Math.cos(angle) * feedRadius;
fy = offset.y;
fz = offset.z + Math.sin(angle) * feedRadius;
break;
case "yz":
fx = offset.x;
fy = offset.y + Math.cos(angle) * feedRadius;
fz = offset.z + Math.sin(angle) * feedRadius;
break;
}
const feedNodeId = `layer-${i}-feed-${feed.id}`;
nodes.push({
id: feedNodeId,
name: feed.name,
type: "feed",
workspace: "",
layerId: String(i),
feedId: feed.id,
feedKind: feed.kind,
moduleColor: layer.moduleColor,
fx, fy, fz,
});
// Internal edge: hub → feed
edges.push({
source: hubId,
target: feedNodeId,
type: "layer_internal",
});
}
// Add plane visual
this.addLayerPlane(layer, i, offset);
}
// Cross-layer flow edges
for (const flow of this.crossLayerFlows) {
const srcFeedId = `layer-${flow.sourceLayerIdx}-feed-${flow.sourceFeedId}`;
const tgtFeedId = `layer-${flow.targetLayerIdx}-feed-${flow.targetFeedId}`;
edges.push({
source: srcFeedId,
target: tgtFeedId,
type: "cross_layer_flow",
flowKind: flow.kind,
strength: flow.strength,
});
}
this.graph.graphData({ nodes, links: edges });
// Fly camera to overview
setTimeout(() => {
if (this.graph) this.graph.zoomToFit(500, 40);
}, 300);
}
private addLayerPlane(layer: LayerInstance, idx: number, offset: { x: number; y: number; z: number }) {
const THREE = this._threeModule;
if (!THREE || !this.graph) return;
const scene = this.graph.scene();
if (!scene) return;
const planeSize = 80;
const geometry = new THREE.PlaneGeometry(planeSize, planeSize);
const colorHex = layer.moduleColor;
const material = new THREE.MeshBasicMaterial({
color: colorHex,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(geometry, material);
plane.position.set(offset.x, offset.y, offset.z);
// Rotate based on axis
switch (layer.axis) {
case "xy": break; // default orientation faces Z
case "xz": plane.rotation.x = Math.PI / 2; break; // floor
case "yz": plane.rotation.y = Math.PI / 2; break; // side wall
}
// Disable raycasting so clicks pass through
plane.raycast = () => {};
scene.add(plane);
this.layerPlaneObjects.push(plane);
// Grid overlay
const gridHelper = new THREE.GridHelper(planeSize, 8, colorHex, colorHex);
gridHelper.material.transparent = true;
gridHelper.material.opacity = 0.08;
gridHelper.position.set(offset.x, offset.y, offset.z);
switch (layer.axis) {
case "xy": gridHelper.rotation.x = Math.PI / 2; break;
case "xz": break; // GridHelper is already on XZ
case "yz": gridHelper.rotation.z = Math.PI / 2; break;
}
gridHelper.raycast = () => {};
scene.add(gridHelper);
this.layerPlaneObjects.push(gridHelper);
// Module name label sprite above plane
const labelCanvas = document.createElement("canvas");
const ctx = labelCanvas.getContext("2d");
if (ctx) {
labelCanvas.width = 512;
labelCanvas.height = 96;
ctx.font = "bold 36px system-ui, sans-serif";
ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.8)";
ctx.shadowBlur = 4;
ctx.fillText(`${layer.moduleIcon} ${layer.moduleName}`, 256, 48);
const texture = new THREE.CanvasTexture(labelCanvas);
texture.needsUpdate = true;
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(20, 5, 1);
// Position label above plane
switch (layer.axis) {
case "xy": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break;
case "xz": sprite.position.set(offset.x, offset.y + 5, offset.z - planeSize / 2 - 5); break;
case "yz": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break;
}
sprite.raycast = () => {};
scene.add(sprite);
this.layerPlaneObjects.push(sprite);
}
}
private removeLayerPlanes() {
const scene = this.graph?.scene();
if (!scene) return;
for (const obj of this.layerPlaneObjects) {
scene.remove(obj);
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (obj.material.map) obj.material.map.dispose();
obj.material.dispose();
}
}
this.layerPlaneObjects = [];
}
private handleLayerNodeClick(node: GraphNode) {
if (!node.layerId || !node.feedId || !node.feedKind) return;
const layerIdx = parseInt(node.layerId);
const layer = this.layerInstances[layerIdx];
if (!layer) return;
if (!this.flowWiringSource) {
// Start wiring — set source
this.flowWiringSource = {
layerIdx,
feedId: node.feedId,
feedKind: node.feedKind,
};
// Highlight compatible targets by refreshing graph
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh();
} else {
// Check compatibility
const src = this.flowWiringSource;
if (src.layerIdx === layerIdx) {
// Same layer — cancel wiring
this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh();
return;
}
const srcLayer = this.layerInstances[src.layerIdx];
const tgtLayer = this.layerInstances[layerIdx];
if (!srcLayer || !tgtLayer) return;
const compatible = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
if (compatible.size === 0) {
// No compatible flows — cancel
this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh();
return;
}
// Show flow creation dialog
this.showFlowDialog(src, { layerIdx, feedId: node.feedId, feedKind: node.feedKind }, compatible);
}
}
private showFlowDialog(
src: { layerIdx: number; feedId: string; feedKind: string },
tgt: { layerIdx: number; feedId: string; feedKind: string },
compatibleKinds: Set<string>,
) {
const overlay = this.shadow.getElementById("flow-dialog-overlay");
const dialog = this.shadow.getElementById("flow-dialog");
if (!overlay || !dialog) return;
const srcLayer = this.layerInstances[src.layerIdx];
const tgtLayer = this.layerInstances[tgt.layerIdx];
const kindsArr = Array.from(compatibleKinds);
dialog.innerHTML = `
<div class="flow-dialog-title">Create Flow</div>
<div class="flow-dialog-pair">${srcLayer.moduleIcon} ${this.esc(srcLayer.moduleName)} \u2192 ${tgtLayer.moduleIcon} ${this.esc(tgtLayer.moduleName)}</div>
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:8px">Select flow type:</div>
${kindsArr.map((kind, i) => `
<div class="flow-kind-option ${i === 0 ? 'selected' : ''}" data-flow-kind="${kind}">
<span class="flow-kind-dot" style="background:${LAYER_FLOW_COLORS[kind] || '#94a3b8'}"></span>
<span class="flow-kind-label">${LAYER_FLOW_LABELS[kind] || kind}</span>
</div>
`).join("")}
<div class="flow-strength-row">
<span class="flow-strength-label">Strength</span>
<input type="range" class="flow-strength-slider" id="flow-strength" min="10" max="100" value="50">
<span class="flow-strength-val" id="flow-strength-val">50%</span>
</div>
<div class="flow-dialog-actions">
<button class="flow-dialog-btn flow-dialog-cancel" id="flow-cancel">Cancel</button>
<button class="flow-dialog-btn flow-dialog-create" id="flow-create">Create</button>
</div>
`;
overlay.style.display = "flex";
let selectedKind = kindsArr[0];
// Kind selection
dialog.querySelectorAll("[data-flow-kind]").forEach(el => {
el.addEventListener("click", () => {
dialog.querySelectorAll("[data-flow-kind]").forEach(e => e.classList.remove("selected"));
el.classList.add("selected");
selectedKind = (el as HTMLElement).dataset.flowKind!;
});
});
// Strength slider
const slider = this.shadow.getElementById("flow-strength") as HTMLInputElement;
const valEl = this.shadow.getElementById("flow-strength-val");
slider?.addEventListener("input", () => {
if (valEl) valEl.textContent = slider.value + "%";
});
// Cancel
this.shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
overlay.style.display = "none";
this.flowWiringSource = null;
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
this.graph?.refresh();
});
// Create
this.shadow.getElementById("flow-create")?.addEventListener("click", () => {
const strength = parseInt(slider?.value || "50") / 100;
this.crossLayerFlows.push({
id: `flow-${Date.now()}`,
sourceLayerIdx: src.layerIdx,
sourceFeedId: src.feedId,
targetLayerIdx: tgt.layerIdx,
targetFeedId: tgt.feedId,
kind: selectedKind,
strength,
});
overlay.style.display = "none";
this.flowWiringSource = null;
this.renderLayersPanel();
this.rebuildLayerGraph();
});
}
} }
customElements.define("folk-graph-viewer", FolkGraphViewer); customElements.define("folk-graph-viewer", FolkGraphViewer);

View File

@ -0,0 +1,137 @@
/**
* rNetwork Local-First Client
*
* Wraps the shared local-first stack for collaborative CRM data.
* Contact metadata, relationships, and graph layout sync in real-time.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
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';
export class NetworkLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(networkSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('network', 'crm');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<NetworkDoc>(docId, networkSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[NetworkClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<NetworkDoc | null> {
const docId = networkDocId(this.#space) as DocumentId;
let doc = this.#documents.get<NetworkDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<NetworkDoc>(docId, networkSchema, binary)
: this.#documents.open<NetworkDoc>(docId, networkSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): NetworkDoc | undefined {
return this.#documents.get<NetworkDoc>(networkDocId(this.#space) as DocumentId);
}
onChange(cb: (doc: NetworkDoc) => void): () => void {
return this.#sync.onChange(networkDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
// ── Contact CRUD ──
saveContact(contact: CrmContact): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Save contact ${contact.name}`, (d) => {
d.contacts[contact.did] = contact;
});
}
deleteContact(did: string): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Delete contact`, (d) => {
delete d.contacts[did];
// Clean up relationships involving this contact
for (const key of Object.keys(d.relationships)) {
const rel = d.relationships[key];
if (rel.fromDid === did || rel.toDid === did) delete d.relationships[key];
}
});
}
// ── Relationship CRUD ──
saveRelationship(relationship: CrmRelationship): void {
const docId = networkDocId(this.#space) as DocumentId;
const key = `${relationship.fromDid}:${relationship.toDid}`;
this.#sync.change<NetworkDoc>(docId, `Save relationship`, (d) => {
d.relationships[key] = relationship;
});
}
deleteRelationship(fromDid: string, toDid: string): void {
const docId = networkDocId(this.#space) as DocumentId;
const key = `${fromDid}:${toDid}`;
this.#sync.change<NetworkDoc>(docId, `Delete relationship`, (d) => {
delete d.relationships[key];
});
}
// ── Graph Layout ──
saveGraphLayout(positions: Record<string, { x: number; y: number }>, zoom: number, panX: number, panY: number): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Save layout`, (d) => {
d.graphLayout.positions = positions as any;
d.graphLayout.zoom = zoom;
d.graphLayout.panX = panX;
d.graphLayout.panY = panY;
});
}
saveNodePosition(did: string, x: number, y: number): void {
const docId = networkDocId(this.#space) as DocumentId;
this.#sync.change<NetworkDoc>(docId, `Move node`, (d) => {
if (!d.graphLayout.positions) d.graphLayout.positions = {} as any;
d.graphLayout.positions[did] = { x, y } as any;
});
}
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -0,0 +1,83 @@
/**
* rNetwork Automerge document schemas.
*
* Stores CRM relationship metadata for collaborative network management.
* Delegations remain in PostgreSQL (trust-engine); this doc syncs
* contact info, relationship notes, and graph layout positions.
*
* DocId format: {space}:network:crm
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export interface CrmContact {
did: string;
name: string;
role: string;
tags: string[];
addedBy: string | null;
addedAt: number;
}
export interface CrmRelationship {
fromDid: string;
toDid: string;
type: string;
weight: number;
note: string;
}
export interface GraphLayout {
positions: Record<string, { x: number; y: number }>;
zoom: number;
panX: number;
panY: number;
}
export interface NetworkDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
contacts: Record<string, CrmContact>;
relationships: Record<string, CrmRelationship>;
graphLayout: GraphLayout;
}
// ── Schema registration ──
export const networkSchema: DocSchema<NetworkDoc> = {
module: 'network',
collection: 'crm',
version: 1,
init: (): NetworkDoc => ({
meta: {
module: 'network',
collection: 'crm',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
contacts: {},
relationships: {},
graphLayout: { positions: {}, zoom: 1, panX: 0, panY: 0 },
}),
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;
return doc;
},
};
// ── Helpers ──
export function networkDocId(space: string) {
return `${space}:network:crm` as const;
}