/** * — community relationship graph. * * 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 { id: string; name: string; type: "person" | "company" | "opportunity"; workspace: string; role?: string; location?: string; description?: string; } interface GraphEdge { source: string; target: string; type: string; label?: string; } class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private workspaces: any[] = []; private info: any = null; private nodes: GraphNode[] = []; private edges: GraphEdge[] = []; private filter: "all" | "person" | "company" | "opportunity" = "all"; private searchQuery = ""; 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" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.render(); // Show loading state this.loadData(); // Async — will re-render when data arrives } private loadDemoData() { this.info = { name: "rSpace Community", member_count: 10, company_count: 3, opportunity_count: 0 }; this.workspaces = [ { name: "Commons DAO", slug: "commons-dao", nodeCount: 5, edgeCount: 4 }, { name: "Mycelial Lab", slug: "mycelial-lab", nodeCount: 5, edgeCount: 4 }, { name: "Regenerative Fund", slug: "regenerative-fund", nodeCount: 5, edgeCount: 4 }, ]; // Organizations this.nodes = [ { id: "org-1", name: "Commons DAO", type: "company", workspace: "Commons DAO", description: "Decentralized governance cooperative" }, { id: "org-2", name: "Mycelial Lab", type: "company", workspace: "Mycelial Lab", description: "Protocol research collective" }, { id: "org-3", name: "Regenerative Fund", type: "company", workspace: "Regenerative Fund", description: "Impact funding vehicle" }, // 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ã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ü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 { id: "p-8", name: "Hiro Tanaka", type: "person", workspace: "Regenerative Fund", role: "Research Lead", location: "Osaka" }, { id: "p-9", name: "Iris Patel", type: "person", workspace: "Regenerative Fund", role: "Developer Relations", location: "Mumbai" }, { id: "p-10", name: "James Wright", type: "person", workspace: "Regenerative Fund", role: "Security Auditor", location: "London" }, ]; // Edges: work_at links + cross-org point_of_contact this.edges = [ // Work_at — Commons DAO { source: "p-1", target: "org-1", type: "work_at" }, { source: "p-2", target: "org-1", type: "work_at" }, { source: "p-3", target: "org-1", type: "work_at" }, { source: "p-4", target: "org-1", type: "work_at" }, // Work_at — Mycelial Lab { source: "p-5", target: "org-2", type: "work_at" }, { source: "p-6", target: "org-2", type: "work_at" }, { source: "p-7", target: "org-2", type: "work_at" }, // Work_at — Regenerative Fund { source: "p-8", target: "org-3", type: "work_at" }, { source: "p-9", target: "org-3", type: "work_at" }, { 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 ↔ 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 { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); return match ? match[0] : ""; } private async loadData() { const base = this.getApiBase(); try { const [wsRes, infoRes, graphRes] = await Promise.all([ fetch(`${base}/api/workspaces`), fetch(`${base}/api/info`), fetch(`${base}/api/graph`), ]); if (wsRes.ok) this.workspaces = await wsRes.json(); if (infoRes.ok) this.info = await infoRes.json(); if (graphRes.ok) { const graph = await graphRes.json(); this.importGraph(graph); } } catch { /* offline */ } this.layoutDirty = true; this.render(); requestAnimationFrame(() => this.fitView()); } /** Map server /api/graph response to client GraphNode/GraphEdge format */ private importGraph(graph: { nodes?: any[]; edges?: any[] }) { if (!graph.nodes?.length) return; // Build company name lookup for workspace assignment const companyNames = new Map(); for (const n of graph.nodes) { if (n.type === "company") companyNames.set(n.id, n.label || n.name || "Unknown"); } // Build person→company lookup from edges const personCompany = new Map(); for (const e of graph.edges || []) { if (e.type === "works_at") personCompany.set(e.source, e.target); } // Edge type normalization: server → client const edgeTypeMap: Record = { works_at: "work_at", contact_of: "point_of_contact", involved_in: "point_of_contact", involves: "collaborates", }; this.nodes = graph.nodes.map((n: any) => { const name = n.label || n.name || "Unknown"; const companyId = personCompany.get(n.id); const workspace = n.type === "company" ? name : (companyId ? companyNames.get(companyId) || "" : ""); return { id: n.id, name, type: n.type, workspace, role: n.data?.role, location: n.data?.location, description: n.data?.email || n.data?.domain || n.data?.stage, } as GraphNode; }); this.edges = (graph.edges || []).map((e: any) => ({ source: e.source, target: e.target, type: edgeTypeMap[e.type] || e.type, label: e.label, } as GraphEdge)); // Update info stats from actual data this.info = { ...this.info, member_count: this.nodes.filter(n => n.type === "person").length, company_count: this.nodes.filter(n => n.type === "company").length, }; } private getFilteredNodes(): GraphNode[] { let filtered = this.nodes; if (this.filter !== "all") { filtered = filtered.filter(n => n.type === this.filter); } if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); filtered = filtered.filter(n => n.name.toLowerCase().includes(q) || n.workspace.toLowerCase().includes(q) || (n.role && n.role.toLowerCase().includes(q)) || (n.location && n.location.toLowerCase().includes(q)) || (n.description && n.description.toLowerCase().includes(q)) ); } 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 = {}; // Collect company nodes and position them evenly around center const companies = nodes.filter(n => n.type === "company"); const orgCenters: Record = {}; const cx = W / 2, cy = H / 2; const orbitR = Math.min(W, H) * 0.3; companies.forEach((org, i) => { const angle = -Math.PI / 2 + (2 * Math.PI * i) / Math.max(companies.length, 1); orgCenters[org.id] = { x: cx + orbitR * Math.cos(angle), y: cy + orbitR * Math.sin(angle) }; }); for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p }; // Build workspace→orgId lookup from actual data const orgNameToId: Record = {}; for (const org of companies) orgNameToId[org.name] = org.id; const peopleByOrg: Record = {}; for (const n of nodes) { if (n.type === "person") { const oid = orgNameToId[n.workspace]; if (oid) { (peopleByOrg[oid] ??= []).push(n); } } } for (const [oid, people] of Object.entries(peopleByOrg)) { const c = orgCenters[oid]; if (!c) continue; 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) }; }); } // Position any remaining nodes (opportunities, unlinked people) randomly 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 }; } } // Run force iterations const allIds = nodes.map(n => n.id).filter(id => pos[id]); for (let iter = 0; iter < 80; iter++) { const force: Record = {}; for (const id of allIds) force[id] = { fx: 0, fy: 0 }; // Repulsion between all nodes 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; } } // Attraction along edges 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 === "work_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) { const p = pos[id]; force[id].fx += (W / 2 - p.x) * 0.002; force[id].fy += (H / 2 - p.y) * 0.002; } // Apply forces with damping const damping = 0.4 * (1 - iter / 80); for (const id of allIds) { pos[id].x += force[id].fx * damping; pos[id].y += force[id].fy * damping; } } return pos; } private getTrustScore(nodeId: string): number { return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20); } private getConnectedNodes(nodeId: string): GraphNode[] { const connIds = new Set(); for (const e of this.edges) { if (e.source === nodeId) connIds.add(e.target); if (e.target === nodeId) connIds.add(e.source); } return this.nodes.filter(n => connIds.has(n.id)); } private renderDetailPanel(): string { if (!this.selectedNode) return ""; const n = this.selectedNode; const connected = this.getConnectedNodes(n.id); const trust = n.type === "person" ? this.getTrustScore(n.id) : -1; return `
${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}
${this.esc(n.name)}
${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
${n.description ? `

${this.esc(n.description)}

` : ""} ${trust >= 0 ? `
Trust Score${trust}
` : ""} ${connected.length > 0 ? `
Connected (${connected.length})
${connected.map(c => `
${this.esc(c.name)}${this.esc(c.role || c.type)}
`).join("")} ` : ""}
`; } // ── 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.

`; } if (filtered.length === 0) { return `

🕸️

Community Relationship Graph

Connect the force-directed layout engine to visualize your network.

Automerge CRDT sync + d3-force layout

`; } this.ensureLayout(); const positions = this.nodePositions; const filteredIds = new Set(filtered.map(n => n.id)); // Assign colors to companies dynamically const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"]; const companies = this.nodes.filter(n => n.type === "company"); const orgColors: Record = {}; companies.forEach((org, i) => { orgColors[org.id] = palette[i % palette.length]; }); // Cluster backgrounds based on computed positions const clustersSvg = companies.map(org => { const pos = positions[org.id]; if (!pos || !filteredIds.has(org.id)) return ""; const color = orgColors[org.id] || "#333"; return ``; }).join(""); // Render edges const edgesSvg: string[] = []; for (const edge of this.edges) { const sp = positions[edge.source]; const tp = positions[edge.target]; if (!sp || !tp) continue; if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue; if (edge.type === "work_at") { edgesSvg.push(``); } else if (edge.type === "point_of_contact") { const mx = (sp.x + tp.x) / 2; const my = (sp.y + tp.y) / 2; edgesSvg.push(``); if (edge.label) { edgesSvg.push(`${this.esc(edge.label)}`); } } else if (edge.type === "collaborates") { edgesSvg.push(``); } else { edgesSvg.push(``); } } // Render nodes const nodesSvg = filtered.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.selectedNode?.id === node.id; let label = this.esc(node.name); let sublabel = ""; if (isOrg && node.description) { sublabel = `${this.esc(node.description)}`; } else if (!isOrg && node.role) { sublabel = `${this.esc(node.role)}${node.location ? " · " + this.esc(node.location) : ""}`; } // Trust score badge for people const trust = !isOrg ? this.getTrustScore(node.id) : -1; const trustBadge = trust >= 0 ? ` ${trust} ` : ""; return ` ${isSelected ? `` : ""} ${isOrg ? `${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}` : ""} ${label} ${sublabel} ${trustBadge} `; }).join(""); return ` ${clustersSvg} ${edgesSvg.join("")} ${nodesSvg}
${Math.round(this.canvasZoom * 100)}%
`; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""}
Network Graph${this.space === "demo" ? 'Demo' : ""}
${this.info ? `
${this.info.member_count || 0}
People
${this.info.company_count || 0}
Organizations
${this.edges?.filter((e: GraphEdge) => e.type === "point_of_contact").length || 0}
Cross-org Links
` : ""}
${(["all", "person", "company", "opportunity"] as const).map(f => { const labels: Record = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" }; return ``; }).join("")}
${this.nodes.length > 0 ? this.renderGraphSVG() : `

🕸️

Community Relationship Graph

Connect the force-directed layout engine to visualize your network.

Automerge CRDT sync + d3-force layout

`}
${this.renderDetailPanel()}
People
Organizations
Works at
Point of contact
${this.workspaces.length > 0 ? `
${this.space === "demo" ? "Organizations" : "Workspaces"}
${this.workspaces.map(ws => `
${this.esc(ws.name || ws.slug)}
${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges
`).join("")}
` : ""} `; this.attachListeners(); } private attachListeners() { // Filter buttons this.shadow.querySelectorAll("[data-filter]").forEach(el => { el.addEventListener("click", () => { this.filter = (el as HTMLElement).dataset.filter as any; this.render(); requestAnimationFrame(() => this.fitView()); }); }); // Search let searchTimeout: any; this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.render(); requestAnimationFrame(() => this.fitView()); }, 200); }); // Close detail panel this.shadow.getElementById("close-detail")?.addEventListener("click", () => { this.selectedNode = null; this.render(); }); // Zoom controls this.shadow.getElementById("zoom-in")?.addEventListener("click", () => { const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; if (!svg) return; const rect = svg.getBoundingClientRect(); const cx = rect.width / 2, cy = rect.height / 2; this.zoomAt(cx, cy, 1.25); }); this.shadow.getElementById("zoom-out")?.addEventListener("click", () => { const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; if (!svg) return; const rect = svg.getBoundingClientRect(); const cx = rect.width / 2, cy = rect.height / 2; this.zoomAt(cx, cy, 0.8); }); this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => this.fitView()); // Canvas interactions const canvas = this.shadow.getElementById("graph-canvas"); const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; if (!svg || !canvas) return; // Wheel zoom svg.addEventListener("wheel", (e: WheelEvent) => { e.preventDefault(); const rect = svg.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const factor = 1 - e.deltaY * 0.003; this.zoomAt(mx, my, factor); }, { 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; const rect = svg.getBoundingClientRect(); if (target) { // Node drag const nodeId = target.dataset.nodeId!; this.draggingNodeId = nodeId; this.dragStartX = e.clientX; this.dragStartY = e.clientY; const pos = this.nodePositions[nodeId]; if (pos) { this.dragNodeStartX = pos.x; this.dragNodeStartY = pos.y; } svg.setPointerCapture(e.pointerId); e.preventDefault(); } else { // Canvas pan this.isPanning = true; this.panStartX = e.clientX; this.panStartY = e.clientY; this.panStartPanX = this.canvasPanX; this.panStartPanY = this.canvasPanY; canvas.classList.add("grabbing"); svg.setPointerCapture(e.pointerId); e.preventDefault(); } }); svg.addEventListener("pointermove", (e: PointerEvent) => { if (this.draggingNodeId) { const dx = (e.clientX - this.dragStartX) / this.canvasZoom; const dy = (e.clientY - this.dragStartY) / this.canvasZoom; const pos = this.nodePositions[this.draggingNodeId]; if (pos) { pos.x = this.dragNodeStartX + dx; pos.y = this.dragNodeStartY + dy; this.updateNodePosition(this.draggingNodeId); } } else if (this.isPanning) { this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); this.updateCanvasTransform(); } }); svg.addEventListener("pointerup", (e: PointerEvent) => { if (this.draggingNodeId) { // If barely moved, treat as click → select node const dx = Math.abs(e.clientX - this.dragStartX); const dy = Math.abs(e.clientY - this.dragStartY); if (dx < 4 && dy < 4) { const id = this.draggingNodeId; if (this.selectedNode?.id === id) { this.selectedNode = null; } else { this.selectedNode = this.nodes.find(n => n.id === id) || null; } this.draggingNodeId = null; this.render(); return; } this.draggingNodeId = null; } if (this.isPanning) { this.isPanning = false; canvas.classList.remove("grabbing"); } }); // Touch pinch-zoom svg.addEventListener("touchstart", (e: TouchEvent) => { if (e.touches.length === 2) { e.preventDefault(); this.isTouchPanning = true; const t0 = e.touches[0], t1 = e.touches[1]; this.lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); } }, { passive: false }); svg.addEventListener("touchmove", (e: TouchEvent) => { if (e.touches.length === 2 && this.isTouchPanning) { e.preventDefault(); const t0 = e.touches[0], t1 = e.touches[1]; const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); if (this.lastTouchCenter && this.lastTouchDist) { // Pan this.canvasPanX += center.x - this.lastTouchCenter.x; this.canvasPanY += center.y - this.lastTouchCenter.y; // Zoom const rect = svg.getBoundingClientRect(); const mx = center.x - rect.left; const my = center.y - rect.top; const factor = dist / this.lastTouchDist; this.zoomAt(mx, my, factor); } this.lastTouchCenter = center; this.lastTouchDist = dist; } }, { passive: false }); svg.addEventListener("touchend", () => { this.isTouchPanning = false; this.lastTouchCenter = null; this.lastTouchDist = null; }); } private zoomAt(screenX: number, screenY: number, factor: number) { const oldZoom = this.canvasZoom; const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); // Adjust pan so the point under the cursor stays fixed this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); this.canvasZoom = newZoom; this.updateCanvasTransform(); this.updateZoomDisplay(); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-graph-viewer", FolkGraphViewer);