Merge branch 'dev'
This commit is contained in:
commit
521ea557f4
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue