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:
parent
31b088543e
commit
c36b0abc32
|
|
@ -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<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
|
||||
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 {
|
|||
<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 {
|
||||
const base = this.getApiBase().replace(/\/crm$/, "");
|
||||
return `<div class="graph-link-section">
|
||||
<p>View the interactive force-directed relationship graph for this space.</p>
|
||||
<a href="${base || `/${this.space}/rnetwork`}" class="graph-link-btn">Open Network Graph</a>
|
||||
this.ensureGraphLayout();
|
||||
const nodes = this.graphNodes;
|
||||
const edges = this.graphEdges;
|
||||
|
||||
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">−</button>
|
||||
<button class="graph-zoom-btn graph-zoom-btn--fit" id="graph-zoom-fit" title="Fit to view">⤢</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>`;
|
||||
}
|
||||
|
||||
|
|
@ -430,14 +766,31 @@ class FolkCrmView extends HTMLElement {
|
|||
.table-footer { font-size: 12px; color: var(--rs-text-muted); padding: 10px 14px; }
|
||||
|
||||
/* ── Graph tab ── */
|
||||
.graph-link-section { text-align: center; padding: 60px 20px; }
|
||||
.graph-link-section p { color: var(--rs-text-muted); font-size: 14px; margin-bottom: 16px; }
|
||||
.graph-link-btn {
|
||||
display: inline-block; padding: 10px 24px; border-radius: 8px;
|
||||
background: var(--rs-primary-hover); color: #fff; text-decoration: none;
|
||||
font-size: 14px; font-weight: 500; transition: background 0.15s;
|
||||
.graph-container {
|
||||
position: relative; width: 100%; height: 420px;
|
||||
background: var(--rs-canvas-bg, #0a0a0f); border: 1px solid var(--rs-border);
|
||||
border-radius: 12px; overflow: hidden; cursor: grab;
|
||||
}
|
||||
.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) {
|
||||
.pipeline-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
|
|
@ -448,6 +801,7 @@ class FolkCrmView extends HTMLElement {
|
|||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.search-input { width: 100%; }
|
||||
.data-table td, .data-table th { padding: 8px 10px; font-size: 12px; }
|
||||
.graph-container { height: 300px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -511,6 +865,98 @@ class FolkCrmView extends HTMLElement {
|
|||
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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue