Merge branch 'main' into dev

This commit is contained in:
Jeff Emmett 2026-03-04 20:44:30 -08:00
commit 6822ee5b47
2 changed files with 361 additions and 35 deletions

View File

@ -3,6 +3,7 @@
*
* 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 {
@ -34,6 +35,26 @@ class FolkGraphViewer extends HTMLElement {
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" });
@ -64,12 +85,12 @@ class FolkGraphViewer extends HTMLElement {
// 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\u00e3o Paulo" },
{ 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\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" },
{ 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
@ -97,12 +118,14 @@ class FolkGraphViewer extends HTMLElement {
{ 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 \u2194 Frank" },
{ source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" },
{ source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" },
{ 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 {
@ -126,7 +149,9 @@ class FolkGraphViewer extends HTMLElement {
this.importGraph(graph);
}
} catch { /* offline */ }
this.layoutDirty = true;
this.render();
requestAnimationFrame(() => this.fitView());
}
/** Map server /api/graph response to client GraphNode/GraphEdge format */
@ -201,6 +226,13 @@ class FolkGraphViewer extends HTMLElement {
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 }> = {};
@ -292,8 +324,6 @@ class FolkGraphViewer extends HTMLElement {
for (const id of allIds) {
pos[id].x += force[id].fx * damping;
pos[id].y += force[id].fy * damping;
pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x));
pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y));
}
}
return pos;
@ -336,7 +366,116 @@ class FolkGraphViewer extends HTMLElement {
</div>`;
}
private renderGraphNodes(): string {
// ── 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>`;
@ -352,13 +491,10 @@ class FolkGraphViewer extends HTMLElement {
`;
}
const W = 700;
const H = 500;
this.ensureLayout();
const positions = this.nodePositions;
const filteredIds = new Set(filtered.map(n => n.id));
// Force-directed layout
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
// Assign colors to companies dynamically
const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"];
const companies = this.nodes.filter(n => n.type === "company");
@ -368,7 +504,7 @@ class FolkGraphViewer extends HTMLElement {
// Cluster backgrounds based on computed positions
const clustersSvg = companies.map(org => {
const pos = positions[org.id];
if (!pos) return "";
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("");
@ -393,7 +529,6 @@ class FolkGraphViewer extends HTMLElement {
} 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 {
// Fallback for any unknown edge type
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"/>`);
}
}
@ -412,7 +547,7 @@ class FolkGraphViewer extends HTMLElement {
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 ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
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
@ -434,19 +569,33 @@ class FolkGraphViewer extends HTMLElement {
`;
}).join("");
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" style="max-height:500px">${clustersSvg}${edgesSvg.join("")}${nodesSvg}</svg>`;
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">&minus;</button>
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</button>
</div>
`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
: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: 16px; align-items: center; flex-wrap: wrap; }
.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;
@ -460,14 +609,32 @@ class FolkGraphViewer extends HTMLElement {
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
.graph-canvas {
width: 100%; height: 500px; border-radius: 12px;
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;
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;
@ -517,7 +684,7 @@ class FolkGraphViewer extends HTMLElement {
.conn-role { font-size: 11px; color: var(--rs-text-muted); margin-left: auto; }
@media (max-width: 768px) {
.graph-canvas { height: 350px; }
.graph-canvas { min-height: 300px; }
.workspace-list { grid-template-columns: 1fr; }
.stats { flex-wrap: wrap; gap: 12px; }
.toolbar { flex-direction: column; align-items: stretch; }
@ -547,8 +714,8 @@ class FolkGraphViewer extends HTMLElement {
}).join("")}
</div>
<div class="graph-canvas">
${this.nodes.length > 0 ? this.renderGraphNodes() : `
<div class="graph-canvas" id="graph-canvas">
${this.nodes.length > 0 ? this.renderGraphSVG() : `
<div class="placeholder">
<p style="font-size:48px">&#x1F578;&#xFE0F;</p>
<p style="font-size:16px">Community Relationship Graph</p>
@ -583,27 +750,24 @@ class FolkGraphViewer extends HTMLElement {
}
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(), 200);
});
// Node click → detail panel
this.shadow.querySelectorAll("[data-node-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.nodeId!;
if (this.selectedNode?.id === id) { this.selectedNode = null; }
else { this.selectedNode = this.nodes.find(n => n.id === id) || null; }
searchTimeout = setTimeout(() => {
this.render();
});
requestAnimationFrame(() => this.fitView());
}, 200);
});
// Close detail panel
@ -611,6 +775,161 @@ class FolkGraphViewer extends HTMLElement {
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 {

View File

@ -1,6 +1,13 @@
/* Network module — dark theme */
folk-graph-viewer {
display: block;
display: flex;
flex-direction: column;
min-height: 400px;
padding: 20px;
height: calc(100vh - 60px);
}
/* Canvas cursor states */
folk-graph-viewer .graph-canvas { cursor: grab; }
folk-graph-viewer .graph-canvas.grabbing { cursor: grabbing; }
folk-graph-viewer .graph-canvas .graph-node { cursor: pointer; }