943 lines
38 KiB
TypeScript
943 lines
38 KiB
TypeScript
/**
|
|
* <folk-graph-viewer> — 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<string, { x: number; y: number }> = {};
|
|
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<string, string>();
|
|
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<string, string>();
|
|
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<string, GraphEdge["type"]> = {
|
|
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<string, { x: number; y: number }> {
|
|
const pos: Record<string, { x: number; y: number }> = {};
|
|
|
|
// Collect company nodes and position them evenly around center
|
|
const companies = nodes.filter(n => n.type === "company");
|
|
const orgCenters: Record<string, { x: number; y: number }> = {};
|
|
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<string, string> = {};
|
|
for (const org of companies) orgNameToId[org.name] = org.id;
|
|
|
|
const peopleByOrg: Record<string, GraphNode[]> = {};
|
|
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<string, { fx: number; fy: number }> = {};
|
|
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<string>();
|
|
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 `
|
|
<div class="detail-panel" id="detail-panel">
|
|
<div class="detail-header">
|
|
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span>
|
|
<div class="detail-info">
|
|
<div class="detail-name">${this.esc(n.name)}</div>
|
|
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
|
|
</div>
|
|
<button class="detail-close" id="close-detail">\u2715</button>
|
|
</div>
|
|
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
|
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
|
${connected.length > 0 ? `
|
|
<div class="detail-section">Connected (${connected.length})</div>
|
|
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
|
|
` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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 `<div class="placeholder"><p style="font-size:14px;color:var(--rs-text-muted)">No nodes match current filter.</p></div>`;
|
|
}
|
|
if (filtered.length === 0) {
|
|
return `
|
|
<div class="placeholder">
|
|
<p style="font-size:48px">🕸️</p>
|
|
<p style="font-size:16px">Community Relationship Graph</p>
|
|
<p>Connect the force-directed layout engine to visualize your network.</p>
|
|
<p style="font-size:12px;color:var(--rs-text-muted)">Automerge CRDT sync + d3-force layout</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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<string, string> = {};
|
|
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 `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
|
}).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(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" style="stroke:var(--rs-text-muted)" stroke-width="1" opacity="0.35"/>`);
|
|
} else if (edge.type === "point_of_contact") {
|
|
const mx = (sp.x + tp.x) / 2;
|
|
const my = (sp.y + tp.y) / 2;
|
|
edgesSvg.push(`<line 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"/>`);
|
|
if (edge.label) {
|
|
edgesSvg.push(`<text x="${mx}" y="${my - 6}" fill="#c084fc" font-size="8" text-anchor="middle" opacity="0.7">${this.esc(edge.label)}</text>`);
|
|
}
|
|
} else if (edge.type === "collaborates") {
|
|
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.4"/>`);
|
|
} else {
|
|
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" style="stroke:var(--rs-text-muted)" stroke-width="1" opacity="0.25"/>`);
|
|
}
|
|
}
|
|
|
|
// 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 = `<text x="${pos.x}" y="${pos.y + radius + 26}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.description)}</text>`;
|
|
} else if (!isOrg && node.role) {
|
|
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" style="fill:var(--rs-text-muted)" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " · " + this.esc(node.location) : ""}</text>`;
|
|
}
|
|
|
|
// Trust score badge for people
|
|
const trust = !isOrg ? this.getTrustScore(node.id) : -1;
|
|
const trustBadge = trust >= 0 ? `
|
|
<circle cx="${pos.x + radius - 2}" cy="${pos.y - radius + 2}" r="8" fill="#7c3aed" style="stroke:var(--rs-canvas-bg)" stroke-width="1.5"/>
|
|
<text x="${pos.x + radius - 2}" y="${pos.y - radius + 5.5}" fill="#fff" font-size="7" font-weight="700" text-anchor="middle">${trust}</text>
|
|
` : "";
|
|
|
|
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">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</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>
|
|
${sublabel}
|
|
${trustBadge}
|
|
</g>
|
|
`;
|
|
}).join("");
|
|
|
|
return `
|
|
<svg id="graph-svg" width="100%" height="100%">
|
|
<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
|
|
<g id="cluster-layer">${clustersSvg}</g>
|
|
<g id="edge-layer">${edgesSvg.join("")}</g>
|
|
<g id="node-layer">${nodesSvg}</g>
|
|
</g>
|
|
</svg>
|
|
<div class="zoom-controls">
|
|
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
|
<span class="zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
|
|
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
|
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: flex; flex-direction: column; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); height: 100%; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); }
|
|
|
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
|
|
.search-input {
|
|
border: 1px solid var(--rs-input-border); border-radius: 8px; padding: 8px 12px;
|
|
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 13px; width: 200px; outline: none;
|
|
}
|
|
.search-input:focus { border-color: var(--rs-primary-hover); }
|
|
.filter-btn {
|
|
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--rs-input-border);
|
|
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer; font-size: 12px;
|
|
}
|
|
.filter-btn:hover { border-color: var(--rs-border-strong); }
|
|
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
|
|
|
|
.graph-canvas {
|
|
width: 100%; flex: 1; min-height: 400px; border-radius: 12px;
|
|
background: var(--rs-canvas-bg); border: 1px solid var(--rs-border);
|
|
display: flex; align-items: center; justify-content: center;
|
|
position: relative; overflow: hidden; cursor: grab;
|
|
}
|
|
.graph-canvas.grabbing { cursor: grabbing; }
|
|
.graph-canvas svg { display: block; }
|
|
.placeholder { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
|
.placeholder p { margin: 6px 0; }
|
|
|
|
.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;
|
|
}
|
|
.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;
|
|
transition: background 0.15s;
|
|
}
|
|
.zoom-btn:hover { background: var(--rs-bg-surface-raised); }
|
|
.zoom-btn--fit { font-size: 14px; }
|
|
.zoom-level { font-size: 11px; color: var(--rs-text-muted); min-width: 36px; text-align: center; }
|
|
|
|
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
|
.ws-card {
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
|
|
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
|
}
|
|
.ws-card:hover { border-color: var(--rs-text-muted); }
|
|
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
|
.ws-meta { font-size: 12px; color: var(--rs-text-muted); }
|
|
|
|
.legend { display: flex; gap: 16px; margin-top: 12px; }
|
|
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); }
|
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
.legend-line { display: inline-block; }
|
|
.dot-person { background: #3b82f6; }
|
|
.dot-company { background: #22c55e; }
|
|
.dot-opportunity { background: #f59e0b; }
|
|
|
|
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
|
.stat { text-align: center; }
|
|
.stat-value { font-size: 24px; font-weight: 700; color: var(--rs-primary-hover); }
|
|
.stat-label { font-size: 11px; color: var(--rs-text-muted); }
|
|
|
|
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
|
|
|
|
.graph-node:hover circle:first-child { filter: brightness(1.2); }
|
|
|
|
.detail-panel {
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
|
|
padding: 16px; margin-top: 12px;
|
|
}
|
|
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
.detail-icon { font-size: 24px; }
|
|
.detail-info { flex: 1; }
|
|
.detail-name { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); }
|
|
.detail-type { font-size: 12px; color: var(--rs-text-secondary); }
|
|
.detail-close { background: none; border: none; color: var(--rs-text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
|
|
.detail-close:hover { color: var(--rs-text-primary); }
|
|
.detail-desc { font-size: 13px; color: var(--rs-text-secondary); line-height: 1.5; margin: 8px 0; }
|
|
.detail-trust { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
|
|
.trust-label { font-size: 11px; color: var(--rs-text-muted); min-width: 70px; }
|
|
.trust-bar { flex: 1; height: 6px; background: var(--rs-bg-surface-raised); border-radius: 3px; overflow: hidden; }
|
|
.trust-fill { display: block; height: 100%; background: #7c3aed; border-radius: 3px; transition: width 0.3s; }
|
|
.trust-val { font-size: 12px; font-weight: 700; color: #a78bfa; min-width: 24px; text-align: right; }
|
|
.detail-section { font-size: 11px; font-weight: 600; color: var(--rs-text-muted); margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.detail-conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-primary); padding: 4px 0; }
|
|
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.conn-role { font-size: 11px; color: var(--rs-text-muted); margin-left: auto; }
|
|
|
|
@media (max-width: 768px) {
|
|
.graph-canvas { min-height: 300px; }
|
|
.workspace-list { grid-template-columns: 1fr; }
|
|
.stats { flex-wrap: wrap; gap: 12px; }
|
|
.toolbar { flex-direction: column; align-items: stretch; }
|
|
.search-input { width: 100%; }
|
|
}
|
|
</style>
|
|
|
|
${this.error ? `<div style="color:var(--rs-error);text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
|
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Network Graph${this.space === "demo" ? '<span class="demo-badge">Demo</span>' : ""}</span>
|
|
</div>
|
|
|
|
${this.info ? `
|
|
<div class="stats">
|
|
<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">People</div></div>
|
|
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div>
|
|
<div class="stat"><div class="stat-value">${this.edges?.filter((e: GraphEdge) => e.type === "point_of_contact").length || 0}</div><div class="stat-label">Cross-org Links</div></div>
|
|
</div>
|
|
` : ""}
|
|
|
|
<div class="toolbar">
|
|
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
|
${(["all", "person", "company", "opportunity"] as const).map(f => {
|
|
const labels: Record<string, string> = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" };
|
|
return `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${labels[f]}</button>`;
|
|
}).join("")}
|
|
</div>
|
|
|
|
<div class="graph-canvas" id="graph-canvas">
|
|
${this.nodes.length > 0 ? this.renderGraphSVG() : `
|
|
<div class="placeholder">
|
|
<p style="font-size:48px">🕸️</p>
|
|
<p style="font-size:16px">Community Relationship Graph</p>
|
|
<p>Connect the force-directed layout engine to visualize your network.</p>
|
|
<p style="font-size:12px;color:var(--rs-text-muted)">Automerge CRDT sync + d3-force layout</p>
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
${this.renderDetailPanel()}
|
|
|
|
<div class="legend">
|
|
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
|
|
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
|
|
<div class="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="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> Point of contact</div>
|
|
</div>
|
|
|
|
${this.workspaces.length > 0 ? `
|
|
<div style="margin-top:20px;font-size:14px;font-weight:600;color:var(--rs-text-secondary)">${this.space === "demo" ? "Organizations" : "Workspaces"}</div>
|
|
<div class="workspace-list">
|
|
${this.workspaces.map(ws => `
|
|
<div class="ws-card">
|
|
<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
|
|
<div class="ws-meta">${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
`;
|
|
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);
|