From c36b0abc325c51cb12644b1f3cf321f84e3be8d9 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 14:22:18 -0700 Subject: [PATCH] feat(rnetwork): inline force-directed graph in CRM view Replace the Graph tab's external link with an interactive SVG graph rendered directly from CRM data (people, companies, opportunities). Companies appear as colored clusters with people orbiting around them, and cross-org opportunity links shown as dashed purple edges. Includes pan/zoom/drag interactions and auto fit-to-view. Co-Authored-By: Claude Opus 4.6 --- modules/rnetwork/components/folk-crm-view.ts | 470 ++++++++++++++++++- 1 file changed, 458 insertions(+), 12 deletions(-) diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 709ed81..9ed42e0 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -35,6 +35,19 @@ interface Company { createdAt?: string; } +interface CrmGraphNode { + id: string; + name: string; + type: "person" | "company"; + companyId?: string; +} + +interface CrmGraphEdge { + source: string; + target: string; + type: "works_at" | "point_of_contact"; +} + type Tab = "pipeline" | "contacts" | "companies" | "graph"; const PIPELINE_STAGES = [ @@ -69,6 +82,26 @@ class FolkCrmView extends HTMLElement { private loading = true; private error = ""; + // Graph state + private graphNodes: CrmGraphNode[] = []; + private graphEdges: CrmGraphEdge[] = []; + private graphPositions: Record = {}; + private graphLayoutDirty = true; + private graphZoom = 1; + private graphPanX = 0; + private graphPanY = 0; + private graphDraggingId: string | null = null; + private graphDragStartX = 0; + private graphDragStartY = 0; + private graphDragNodeStartX = 0; + private graphDragNodeStartY = 0; + private graphIsPanning = false; + private graphPanStartX = 0; + private graphPanStartY = 0; + private graphPanStartPanX = 0; + private graphPanStartPanY = 0; + private graphSelectedId: string | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -128,6 +161,7 @@ class FolkCrmView extends HTMLElement { } catch (e) { this.error = "Failed to load CRM data"; } + this.graphLayoutDirty = true; this.loading = false; this.render(); } @@ -306,12 +340,314 @@ class FolkCrmView extends HTMLElement { `; } - // ── Graph link ── + // ── Inline Graph ── + + private buildGraphData(): { nodes: CrmGraphNode[]; edges: CrmGraphEdge[] } { + const nodes: CrmGraphNode[] = []; + const edges: CrmGraphEdge[] = []; + const seenEdges = new Set(); + + for (const c of this.companies) { + nodes.push({ id: "co:" + c.id, name: c.name || "Unknown", type: "company" }); + } + + for (const p of this.people) { + const name = this.personName(p.name); + const companyId = p.company?.id ? "co:" + p.company.id : undefined; + nodes.push({ id: "p:" + p.id, name, type: "person", companyId }); + if (companyId) { + edges.push({ source: "p:" + p.id, target: companyId, type: "works_at" }); + } + } + + // Cross-org edges from opportunities + for (const opp of this.opportunities) { + if (!opp.pointOfContact?.id || !opp.company?.id) continue; + const person = this.people.find(p => p.id === opp.pointOfContact!.id); + if (!person?.company?.id || person.company.id === opp.company.id) continue; + const key = `p:${person.id}->co:${opp.company.id}`; + if (seenEdges.has(key)) continue; + seenEdges.add(key); + edges.push({ source: "p:" + person.id, target: "co:" + opp.company.id, type: "point_of_contact" }); + } + + return { nodes, edges }; + } + + private computeGraphLayout(nodes: CrmGraphNode[], edges: CrmGraphEdge[]): Record { + const pos: Record = {}; + const W = 800, H = 600; + const cx = W / 2, cy = H / 2; + + const companies = nodes.filter(n => n.type === "company"); + const orbitR = Math.min(W, H) * 0.3; + const orgCenters: Record = {}; + + companies.forEach((org, i) => { + const angle = -Math.PI / 2 + (2 * Math.PI * i) / Math.max(companies.length, 1); + const p = { x: cx + orbitR * Math.cos(angle), y: cy + orbitR * Math.sin(angle) }; + orgCenters[org.id] = p; + pos[org.id] = { ...p }; + }); + + // Fan people around their company + const peopleByOrg: Record = {}; + for (const n of nodes) { + if (n.type === "person" && n.companyId && orgCenters[n.companyId]) { + (peopleByOrg[n.companyId] ??= []).push(n); + } + } + for (const [oid, people] of Object.entries(peopleByOrg)) { + const c = orgCenters[oid]; + const base = Math.atan2(c.y - cy, c.x - cx); + const spread = Math.PI * 0.8; + people.forEach((p, i) => { + const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1); + pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) }; + }); + } + + // Unlinked nodes near center + for (const n of nodes) { + if (!pos[n.id]) { + pos[n.id] = { x: cx + (Math.random() - 0.5) * W * 0.4, y: cy + (Math.random() - 0.5) * H * 0.4 }; + } + } + + // Force iterations + const allIds = nodes.map(n => n.id).filter(id => pos[id]); + for (let iter = 0; iter < 70; iter++) { + const force: Record = {}; + for (const id of allIds) force[id] = { fx: 0, fy: 0 }; + + // Repulsion + for (let i = 0; i < allIds.length; i++) { + for (let j = i + 1; j < allIds.length; j++) { + const a = pos[allIds[i]], b = pos[allIds[j]]; + let dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const repel = 3000 / (dist * dist); + dx /= dist; dy /= dist; + force[allIds[i]].fx -= dx * repel; + force[allIds[i]].fy -= dy * repel; + force[allIds[j]].fx += dx * repel; + force[allIds[j]].fy += dy * repel; + } + } + + // Edge attraction + for (const edge of edges) { + const a = pos[edge.source], b = pos[edge.target]; + if (!a || !b) continue; + const dx = b.x - a.x, dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const idealLen = edge.type === "works_at" ? 100 : 200; + const attract = (dist - idealLen) * 0.01; + const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1); + if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; } + if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; } + } + + // Center gravity + for (const id of allIds) { + force[id].fx += (W / 2 - pos[id].x) * 0.002; + force[id].fy += (H / 2 - pos[id].y) * 0.002; + } + + // Apply with damping + const damping = 0.4 * (1 - iter / 70); + for (const id of allIds) { + pos[id].x += force[id].fx * damping; + pos[id].y += force[id].fy * damping; + } + } + return pos; + } + + private ensureGraphLayout() { + if (!this.graphLayoutDirty && Object.keys(this.graphPositions).length > 0) return; + const { nodes, edges } = this.buildGraphData(); + this.graphNodes = nodes; + this.graphEdges = edges; + this.graphPositions = this.computeGraphLayout(nodes, edges); + this.graphLayoutDirty = false; + } + + private fitGraphView() { + const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; + if (!svg) return; + this.ensureGraphLayout(); + const positions = Object.values(this.graphPositions); + 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; + } + minX -= 40; minY -= 40; maxX += 40; maxY += 40; + + const rect = svg.getBoundingClientRect(); + const svgW = rect.width || 800; + const svgH = rect.height || 600; + const zoom = Math.min((svgW - pad * 2) / Math.max(maxX - minX, 1), (svgH - pad * 2) / Math.max(maxY - minY, 1), 2); + this.graphZoom = Math.max(0.1, Math.min(zoom, 4)); + this.graphPanX = (svgW / 2) - ((minX + maxX) / 2) * this.graphZoom; + this.graphPanY = (svgH / 2) - ((minY + maxY) / 2) * this.graphZoom; + this.updateGraphTransform(); + this.updateGraphZoomDisplay(); + } + + private updateGraphTransform() { + const g = this.shadow.getElementById("graph-transform"); + if (g) g.setAttribute("transform", `translate(${this.graphPanX},${this.graphPanY}) scale(${this.graphZoom})`); + } + + private updateGraphZoomDisplay() { + const el = this.shadow.getElementById("graph-zoom-level"); + if (el) el.textContent = `${Math.round(this.graphZoom * 100)}%`; + } + + private zoomGraphAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.graphZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.graphPanX = screenX - (screenX - this.graphPanX) * (newZoom / oldZoom); + this.graphPanY = screenY - (screenY - this.graphPanY) * (newZoom / oldZoom); + this.graphZoom = newZoom; + this.updateGraphTransform(); + this.updateGraphZoomDisplay(); + } + + private updateGraphNodePosition(nodeId: string) { + const pos = this.graphPositions[nodeId]; + if (!pos) return; + + const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; + if (!g) return; + + const node = this.graphNodes.find(n => n.id === nodeId); + if (!node) return; + const isOrg = node.type === "company"; + const radius = isOrg ? 22 : 12; + + // Update circles (includes selection ring if present) + g.querySelectorAll("circle").forEach(circle => { + circle.setAttribute("cx", String(pos.x)); + circle.setAttribute("cy", String(pos.y)); + }); + + // Update text + const texts = g.querySelectorAll("text"); + if (isOrg) { + 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)); } + } else { + if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); } + } + + // Update cluster circle for company nodes + if (isOrg) { + const cluster = this.shadow.querySelector(`[data-cluster="${nodeId}"]`) as SVGCircleElement | null; + if (cluster) { + cluster.setAttribute("cx", String(pos.x)); + cluster.setAttribute("cy", String(pos.y)); + } + } + + // Update connected edges + for (const edge of this.graphEdges) { + if (edge.source !== nodeId && edge.target !== nodeId) continue; + const sp = this.graphPositions[edge.source]; + const tp = this.graphPositions[edge.target]; + if (!sp || !tp) continue; + const line = this.shadow.querySelector(`[data-edge="${edge.source}:${edge.target}"]`) as SVGLineElement | null; + if (line) { + line.setAttribute("x1", String(sp.x)); + line.setAttribute("y1", String(sp.y)); + line.setAttribute("x2", String(tp.x)); + line.setAttribute("y2", String(tp.y)); + } + } + } + private renderGraphTab(): string { - const base = this.getApiBase().replace(/\/crm$/, ""); - return `