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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 14:22:18 -07:00
parent 31b088543e
commit c36b0abc32
1 changed files with 458 additions and 12 deletions

View File

@ -35,6 +35,19 @@ interface Company {
createdAt?: string; 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"; type Tab = "pipeline" | "contacts" | "companies" | "graph";
const PIPELINE_STAGES = [ const PIPELINE_STAGES = [
@ -69,6 +82,26 @@ class FolkCrmView extends HTMLElement {
private loading = true; private loading = true;
private error = ""; private error = "";
// Graph state
private graphNodes: CrmGraphNode[] = [];
private graphEdges: CrmGraphEdge[] = [];
private graphPositions: Record<string, { x: number; y: number }> = {};
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 // Guided tour
private _tour!: TourEngine; private _tour!: TourEngine;
private static readonly TOUR_STEPS = [ private static readonly TOUR_STEPS = [
@ -128,6 +161,7 @@ class FolkCrmView extends HTMLElement {
} catch (e) { } catch (e) {
this.error = "Failed to load CRM data"; this.error = "Failed to load CRM data";
} }
this.graphLayoutDirty = true;
this.loading = false; this.loading = false;
this.render(); this.render();
} }
@ -306,12 +340,314 @@ class FolkCrmView extends HTMLElement {
<div class="table-footer">${filtered.length} compan${filtered.length !== 1 ? "ies" : "y"}</div>`; <div class="table-footer">${filtered.length} compan${filtered.length !== 1 ? "ies" : "y"}</div>`;
} }
// ── Graph link ── // ── Inline Graph ──
private buildGraphData(): { nodes: CrmGraphNode[]; edges: CrmGraphEdge[] } {
const nodes: CrmGraphNode[] = [];
const edges: CrmGraphEdge[] = [];
const seenEdges = new Set<string>();
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<string, { x: number; y: number }> {
const pos: Record<string, { x: number; y: number }> = {};
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<string, { x: number; y: number }> = {};
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<string, CrmGraphNode[]> = {};
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<string, { fx: number; fy: number }> = {};
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 { private renderGraphTab(): string {
const base = this.getApiBase().replace(/\/crm$/, ""); this.ensureGraphLayout();
return `<div class="graph-link-section"> const nodes = this.graphNodes;
<p>View the interactive force-directed relationship graph for this space.</p> const edges = this.graphEdges;
<a href="${base || `/${this.space}/rnetwork`}" class="graph-link-btn">Open Network Graph</a>
if (nodes.length === 0) {
return `<div class="empty-state">No data to graph. Add contacts and companies to see the relationship network.</div>`;
}
const positions = this.graphPositions;
// Color palette for companies
const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"];
const companyNodes = nodes.filter(n => n.type === "company");
const orgColors: Record<string, string> = {};
companyNodes.forEach((org, i) => { orgColors[org.id] = palette[i % palette.length]; });
// Cluster background circles
const clustersSvg = companyNodes.map(org => {
const pos = positions[org.id];
if (!pos) return "";
const color = orgColors[org.id] || "#333";
return `<circle data-cluster="${org.id}" cx="${pos.x}" cy="${pos.y}" r="120" fill="${color}" opacity="0.05" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
}).join("");
// Edges
const edgesSvg = edges.map(edge => {
const sp = positions[edge.source], tp = positions[edge.target];
if (!sp || !tp) return "";
if (edge.type === "works_at") {
return `<line data-edge="${edge.source}:${edge.target}" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" style="stroke:var(--rs-text-muted)" stroke-width="1" opacity="0.3"/>`;
} else {
return `<line data-edge="${edge.source}:${edge.target}" x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#c084fc" stroke-width="1.5" stroke-dasharray="6 3" opacity="0.6"/>`;
}
}).join("");
// Nodes
const nodesSvg = nodes.map(node => {
const pos = positions[node.id];
if (!pos) return "";
const isOrg = node.type === "company";
const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6";
const radius = isOrg ? 22 : 12;
const isSelected = this.graphSelectedId === node.id;
const label = this.esc(node.name);
const truncLabel = label.length > 14 ? label.slice(0, 12) + "\u2026" : label;
return `<g class="graph-node" data-node-id="${node.id}" style="cursor:pointer">
${isSelected ? `<circle cx="${pos.x}" cy="${pos.y}" r="${radius + 6}" fill="none" stroke="${color}" stroke-width="2" opacity="0.6"/>` : ""}
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${truncLabel}</text>` : ""}
<text x="${pos.x}" y="${pos.y + radius + 13}" style="fill:var(--rs-text-primary)" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
</g>`;
}).join("");
return `<div class="graph-container" id="graph-container">
<svg id="graph-svg" width="100%" height="100%">
<g id="graph-transform" transform="translate(${this.graphPanX},${this.graphPanY}) scale(${this.graphZoom})">
<g id="cluster-layer">${clustersSvg}</g>
<g id="edge-layer">${edgesSvg}</g>
<g id="node-layer">${nodesSvg}</g>
</g>
</svg>
<div class="graph-zoom-controls">
<button class="graph-zoom-btn" id="graph-zoom-in" title="Zoom in">+</button>
<span class="graph-zoom-level" id="graph-zoom-level">${Math.round(this.graphZoom * 100)}%</span>
<button class="graph-zoom-btn" id="graph-zoom-out" title="Zoom out">&minus;</button>
<button class="graph-zoom-btn graph-zoom-btn--fit" id="graph-zoom-fit" title="Fit to view">&#x2922;</button>
</div>
</div>
<div class="graph-legend">
<div class="graph-legend-item"><span class="graph-legend-dot" style="background:#3b82f6"></span> People</div>
<div class="graph-legend-item"><span class="graph-legend-dot" style="background:#22c55e"></span> Companies</div>
<div class="graph-legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" style="stroke:var(--rs-text-muted)" stroke-width="2"/></svg> Works at</div>
<div class="graph-legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"/></svg> Cross-org link</div>
</div>`; </div>`;
} }
@ -430,14 +766,31 @@ class FolkCrmView extends HTMLElement {
.table-footer { font-size: 12px; color: var(--rs-text-muted); padding: 10px 14px; } .table-footer { font-size: 12px; color: var(--rs-text-muted); padding: 10px 14px; }
/* ── Graph tab ── */ /* ── Graph tab ── */
.graph-link-section { text-align: center; padding: 60px 20px; } .graph-container {
.graph-link-section p { color: var(--rs-text-muted); font-size: 14px; margin-bottom: 16px; } position: relative; width: 100%; height: 420px;
.graph-link-btn { background: var(--rs-canvas-bg, #0a0a0f); border: 1px solid var(--rs-border);
display: inline-block; padding: 10px 24px; border-radius: 8px; border-radius: 12px; overflow: hidden; cursor: grab;
background: var(--rs-primary-hover); color: #fff; text-decoration: none;
font-size: 14px; font-weight: 500; transition: background 0.15s;
} }
.graph-link-btn:hover { background: var(--rs-primary); } .graph-container.grabbing { cursor: grabbing; }
.graph-container svg { display: block; width: 100%; height: 100%; }
.graph-node:hover circle { filter: brightness(1.2); }
.graph-zoom-controls {
position: absolute; bottom: 12px; right: 12px;
display: flex; align-items: center; gap: 4px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 8px; padding: 4px 6px; z-index: 5;
}
.graph-zoom-btn {
width: 28px; height: 28px; border: none; border-radius: 6px;
background: transparent; color: var(--rs-text-primary); font-size: 16px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.graph-zoom-btn:hover { background: var(--rs-bg-hover); }
.graph-zoom-btn--fit { font-size: 14px; }
.graph-zoom-level { font-size: 11px; color: var(--rs-text-muted); min-width: 36px; text-align: center; }
.graph-legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
.graph-legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); }
.graph-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
@media (max-width: 900px) { @media (max-width: 900px) {
.pipeline-grid { grid-template-columns: repeat(2, 1fr); } .pipeline-grid { grid-template-columns: repeat(2, 1fr); }
@ -448,6 +801,7 @@ class FolkCrmView extends HTMLElement {
.toolbar { flex-direction: column; align-items: stretch; } .toolbar { flex-direction: column; align-items: stretch; }
.search-input { width: 100%; } .search-input { width: 100%; }
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; } .data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
.graph-container { height: 300px; }
} }
</style> </style>
@ -511,6 +865,98 @@ class FolkCrmView extends HTMLElement {
this.render(); this.render();
}); });
}); });
// ── Graph interactions ──
if (this.activeTab === "graph") {
const container = this.shadow.getElementById("graph-container");
const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null;
if (svg && container) {
// Zoom controls
this.shadow.getElementById("graph-zoom-in")?.addEventListener("click", () => {
const rect = svg.getBoundingClientRect();
this.zoomGraphAt(rect.width / 2, rect.height / 2, 1.25);
});
this.shadow.getElementById("graph-zoom-out")?.addEventListener("click", () => {
const rect = svg.getBoundingClientRect();
this.zoomGraphAt(rect.width / 2, rect.height / 2, 0.8);
});
this.shadow.getElementById("graph-zoom-fit")?.addEventListener("click", () => this.fitGraphView());
// Wheel zoom
svg.addEventListener("wheel", (e: WheelEvent) => {
e.preventDefault();
const rect = svg.getBoundingClientRect();
this.zoomGraphAt(e.clientX - rect.left, e.clientY - rect.top, 1 - e.deltaY * 0.003);
}, { passive: false });
// Pointer down — node drag or canvas pan
svg.addEventListener("pointerdown", (e: PointerEvent) => {
if (e.button !== 0) return;
const target = (e.target as Element).closest("[data-node-id]") as HTMLElement | null;
if (target) {
const nodeId = target.dataset.nodeId!;
this.graphDraggingId = nodeId;
this.graphDragStartX = e.clientX;
this.graphDragStartY = e.clientY;
const pos = this.graphPositions[nodeId];
if (pos) { this.graphDragNodeStartX = pos.x; this.graphDragNodeStartY = pos.y; }
svg.setPointerCapture(e.pointerId);
e.preventDefault();
} else {
this.graphIsPanning = true;
this.graphPanStartX = e.clientX;
this.graphPanStartY = e.clientY;
this.graphPanStartPanX = this.graphPanX;
this.graphPanStartPanY = this.graphPanY;
container.classList.add("grabbing");
svg.setPointerCapture(e.pointerId);
e.preventDefault();
}
});
svg.addEventListener("pointermove", (e: PointerEvent) => {
if (this.graphDraggingId) {
const dx = (e.clientX - this.graphDragStartX) / this.graphZoom;
const dy = (e.clientY - this.graphDragStartY) / this.graphZoom;
const pos = this.graphPositions[this.graphDraggingId];
if (pos) {
pos.x = this.graphDragNodeStartX + dx;
pos.y = this.graphDragNodeStartY + dy;
this.updateGraphNodePosition(this.graphDraggingId);
}
} else if (this.graphIsPanning) {
this.graphPanX = this.graphPanStartPanX + (e.clientX - this.graphPanStartX);
this.graphPanY = this.graphPanStartPanY + (e.clientY - this.graphPanStartY);
this.updateGraphTransform();
}
});
svg.addEventListener("pointerup", (e: PointerEvent) => {
if (this.graphDraggingId) {
const dx = Math.abs(e.clientX - this.graphDragStartX);
const dy = Math.abs(e.clientY - this.graphDragStartY);
if (dx < 4 && dy < 4) {
// Click — toggle selection
const id = this.graphDraggingId;
this.graphSelectedId = this.graphSelectedId === id ? null : id;
this.graphDraggingId = null;
this.render();
return;
}
this.graphDraggingId = null;
}
if (this.graphIsPanning) {
this.graphIsPanning = false;
container.classList.remove("grabbing");
}
});
}
// Fit view on first render
requestAnimationFrame(() => this.fitGraphView());
}
} }
private esc(s: string): string { private esc(s: string): string {