diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 5686448..ffa5f7b 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -3,6 +3,7 @@ * * Displays network nodes (people, companies, opportunities) * and edges in a force-directed layout with search and filtering. + * Interactive canvas with pan/zoom/drag (rFlows-style). */ interface GraphNode { @@ -34,6 +35,26 @@ class FolkGraphViewer extends HTMLElement { private error = ""; private selectedNode: GraphNode | null = null; + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private isTouchPanning = false; + private lastTouchCenter: { x: number; y: number } | null = null; + private lastTouchDist: number | null = null; + private nodePositions: Record = {}; + private layoutDirty = true; // recompute layout when true + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -64,12 +85,12 @@ class FolkGraphViewer extends HTMLElement { // People — Commons DAO { id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" }, { id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" }, - { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "S\u00e3o Paulo" }, + { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "São Paulo" }, { id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" }, // People — Mycelial Lab { id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" }, - { id: "p-6", name: "Frank M\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, + { id: "p-6", name: "Frank Müller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, { id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" }, // People — Regenerative Fund @@ -97,12 +118,14 @@ class FolkGraphViewer extends HTMLElement { { source: "p-10", target: "org-3", type: "work_at" }, // Cross-org point_of_contact edges - { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice \u2194 Frank" }, - { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" }, - { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" }, + { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice ↔ Frank" }, + { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob ↔ Carol" }, + { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave ↔ Grace" }, ]; + this.layoutDirty = true; this.render(); + requestAnimationFrame(() => this.fitView()); } private getApiBase(): string { @@ -126,7 +149,9 @@ class FolkGraphViewer extends HTMLElement { this.importGraph(graph); } } catch { /* offline */ } + this.layoutDirty = true; this.render(); + requestAnimationFrame(() => this.fitView()); } /** Map server /api/graph response to client GraphNode/GraphEdge format */ @@ -201,6 +226,13 @@ class FolkGraphViewer extends HTMLElement { return filtered; } + private ensureLayout() { + if (!this.layoutDirty && Object.keys(this.nodePositions).length > 0) return; + const W = 800, H = 600; + this.nodePositions = this.computeForceLayout(this.nodes, this.edges, W, H); + this.layoutDirty = false; + } + private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record { const pos: Record = {}; @@ -292,8 +324,6 @@ class FolkGraphViewer extends HTMLElement { for (const id of allIds) { pos[id].x += force[id].fx * damping; pos[id].y += force[id].fy * damping; - pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x)); - pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y)); } } return pos; @@ -336,7 +366,116 @@ class FolkGraphViewer extends HTMLElement { `; } - private renderGraphNodes(): string { + // ── Canvas transform helpers ── + + private updateCanvasTransform() { + const g = this.shadow.getElementById("canvas-transform"); + if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + } + + private fitView() { + const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; + if (!svg) return; + this.ensureLayout(); + const filtered = this.getFilteredNodes(); + const positions = filtered.map(n => this.nodePositions[n.id]).filter(Boolean); + if (positions.length === 0) return; + + const pad = 60; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of positions) { + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + // Include cluster radius for org nodes + minX -= 40; minY -= 40; maxX += 40; maxY += 40; + + const contentW = maxX - minX; + const contentH = maxY - minY; + const rect = svg.getBoundingClientRect(); + const svgW = rect.width || 800; + const svgH = rect.height || 600; + + const zoom = Math.min((svgW - pad * 2) / Math.max(contentW, 1), (svgH - pad * 2) / Math.max(contentH, 1), 2); + this.canvasZoom = Math.max(0.1, Math.min(zoom, 4)); + this.canvasPanX = (svgW / 2) - ((minX + maxX) / 2) * this.canvasZoom; + this.canvasPanY = (svgH / 2) - ((minY + maxY) / 2) * this.canvasZoom; + this.updateCanvasTransform(); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById("zoom-level"); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + // ── Incremental node update (drag) ── + + private updateNodePosition(nodeId: string) { + const pos = this.nodePositions[nodeId]; + if (!pos) return; + const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; + if (!g) return; + + // Update all circles and texts that use absolute coordinates + const node = this.nodes.find(n => n.id === nodeId); + if (!node) return; + const isOrg = node.type === "company"; + const radius = isOrg ? 22 : 12; + + // Update circles + g.querySelectorAll("circle").forEach(circle => { + const rAttr = circle.getAttribute("r"); + const r = parseFloat(rAttr || "0"); + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + }); + + // Update text elements by relative offset from center + const texts = g.querySelectorAll("text"); + if (isOrg) { + // [0] inner label, [1] name below, [2] description, [3+] trust badge + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + 4)); } + if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 13)); } + if (texts[2]) { texts[2].setAttribute("x", String(pos.x)); texts[2].setAttribute("y", String(pos.y + radius + 26)); } + } else { + // [0] name below, [1] role/location, [2] trust text + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); } + if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 24)); } + if (texts[2]) { texts[2].setAttribute("x", String(pos.x + radius - 2)); texts[2].setAttribute("y", String(pos.y - radius + 5.5)); } + } + + // Update connected edges + for (const edge of this.edges) { + if (edge.source !== nodeId && edge.target !== nodeId) continue; + const sp = this.nodePositions[edge.source]; + const tp = this.nodePositions[edge.target]; + if (!sp || !tp) continue; + + // Find the edge line element + const lines = this.shadow.querySelectorAll(`#edge-layer line`); + for (const line of lines) { + const x1 = parseFloat(line.getAttribute("x1") || "0"); + const y1 = parseFloat(line.getAttribute("y1") || "0"); + const x2 = parseFloat(line.getAttribute("x2") || "0"); + const y2 = parseFloat(line.getAttribute("y2") || "0"); + // Match by checking if this line connects these nodes (approximate) + if (edge.source === nodeId) { + // Check if endpoint matches old position pattern — just update all matching edges + line.setAttribute("x1", String(sp.x)); + line.setAttribute("y1", String(sp.y)); + } + if (edge.target === nodeId) { + line.setAttribute("x2", String(tp.x)); + line.setAttribute("y2", String(tp.y)); + } + } + } + } + + private renderGraphSVG(): string { const filtered = this.getFilteredNodes(); if (filtered.length === 0 && this.nodes.length > 0) { return `

No nodes match current filter.

`; @@ -352,13 +491,10 @@ class FolkGraphViewer extends HTMLElement { `; } - const W = 700; - const H = 500; + this.ensureLayout(); + const positions = this.nodePositions; const filteredIds = new Set(filtered.map(n => n.id)); - // Force-directed layout - const positions = this.computeForceLayout(this.nodes, this.edges, W, H); - // Assign colors to companies dynamically const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"]; const companies = this.nodes.filter(n => n.type === "company"); @@ -368,7 +504,7 @@ class FolkGraphViewer extends HTMLElement { // Cluster backgrounds based on computed positions const clustersSvg = companies.map(org => { const pos = positions[org.id]; - if (!pos) return ""; + if (!pos || !filteredIds.has(org.id)) return ""; const color = orgColors[org.id] || "#333"; return ``; }).join(""); @@ -393,7 +529,6 @@ class FolkGraphViewer extends HTMLElement { } else if (edge.type === "collaborates") { edgesSvg.push(``); } else { - // Fallback for any unknown edge type edgesSvg.push(``); } } @@ -412,7 +547,7 @@ class FolkGraphViewer extends HTMLElement { if (isOrg && node.description) { sublabel = `${this.esc(node.description)}`; } else if (!isOrg && node.role) { - sublabel = `${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}`; + sublabel = `${this.esc(node.role)}${node.location ? " · " + this.esc(node.location) : ""}`; } // Trust score badge for people @@ -434,19 +569,33 @@ class FolkGraphViewer extends HTMLElement { `; }).join(""); - return `${clustersSvg}${edgesSvg.join("")}${nodesSvg}`; + return ` + + + ${clustersSvg} + ${edgesSvg.join("")} + ${nodesSvg} + + +
+ + ${Math.round(this.canvasZoom * 100)}% + + +
+ `; } private render() { this.shadow.innerHTML = `