mirror of
https://github.com/awatertrevi/xamarin-neo4j.git
synced 2026-09-22 09:05:29 +00:00
- Remove backdrop inset:0 that was intercepting all canvas touches; backdrop is now pointer-events:none and only covers the area above the panel (bottom: 55%) so touch events reach the canvas - Replace blanket `if (panelOpen) return` in handleStart with a guard that only skips the two-finger pinch (handled by its own listener), restoring single-finger pan, node tap, and pinch-zoom while panel is open - Add user-scalable=no viewport flag to prevent WebView native zoom from scaling the panel UI - Add singleTapTimer for double-tap detection: single tap delays 350ms so double-tap is distinguished and only triggers expand/collapse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1269 lines
51 KiB
HTML
1269 lines
51 KiB
HTML
<!doctype html>
|
|
<html lang="en-US">
|
|
<head>
|
|
<title>Graph</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; }
|
|
canvas { display: block; position: absolute; top: 0; left: 0; }
|
|
|
|
/* ── Slide-up detail panel ── */
|
|
#panel-backdrop {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 55%;
|
|
z-index: 90;
|
|
pointer-events: none;
|
|
}
|
|
#panel {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: {{panelBg}};
|
|
border-top: 1px solid {{borderColor}};
|
|
border-radius: 16px 16px 0 0;
|
|
height: 55%;
|
|
transform: translateY(100%);
|
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), height 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
|
z-index: 100;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
overflow: hidden;
|
|
will-change: transform, height;
|
|
}
|
|
#panel.open { transform: translateY(0); }
|
|
#panel.dragging { transition: none !important; }
|
|
|
|
/* Drag handle */
|
|
.panel-handle {
|
|
width: 36px;
|
|
height: 5px;
|
|
border-radius: 3px;
|
|
background: {{mutedColor}};
|
|
opacity: 0.4;
|
|
margin: 10px auto 0;
|
|
flex-shrink: 0;
|
|
}
|
|
.panel-handle-zone {
|
|
padding: 6px 0 2px;
|
|
cursor: grab;
|
|
flex-shrink: 0;
|
|
touch-action: none;
|
|
}
|
|
|
|
/* Action buttons row */
|
|
.panel-actions {
|
|
display: flex;
|
|
gap: 0;
|
|
padding: 8px 12px 4px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid {{borderColor}};
|
|
}
|
|
.panel-actions button {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 3px;
|
|
padding: 8px 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: {{mutedColor}};
|
|
font-size: 10px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
border-radius: 8px;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.panel-actions button:active {
|
|
background: {{borderColor}};
|
|
}
|
|
.panel-actions button svg {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: {{accentColor}};
|
|
}
|
|
.panel-actions button.destructive svg { fill: {{errorColor}}; }
|
|
|
|
/* Label header */
|
|
.panel-label {
|
|
padding: 12px 16px 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
.panel-label .node-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #fff;
|
|
}
|
|
.panel-label .node-id {
|
|
font-size: 11px;
|
|
color: {{mutedColor}};
|
|
margin-left: 8px;
|
|
}
|
|
.panel-label .edge-type {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: {{primaryColor}};
|
|
}
|
|
|
|
/* Scrollable properties */
|
|
.panel-props {
|
|
overflow-y: auto;
|
|
padding: 8px 16px 24px;
|
|
-webkit-overflow-scrolling: touch;
|
|
flex: 1;
|
|
}
|
|
.panel-props table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.panel-props td {
|
|
padding: 6px 0;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
vertical-align: top;
|
|
border-bottom: 1px solid {{borderColor}};
|
|
}
|
|
.panel-props td.pk {
|
|
color: {{mutedColor}};
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
padding-right: 12px;
|
|
width: 1%;
|
|
}
|
|
.panel-props td.pv {
|
|
color: {{primaryColor}};
|
|
word-break: break-word;
|
|
}
|
|
.panel-props tr:last-child td { border-bottom: none; }
|
|
.panel-props .no-props {
|
|
color: {{mutedColor}};
|
|
font-size: 13px;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* JSON view */
|
|
.json-view-wrapper {
|
|
position: relative;
|
|
}
|
|
.json-view-wrapper pre {
|
|
background: {{borderColor}};
|
|
color: {{primaryColor}};
|
|
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
overflow: auto;
|
|
max-height: 40vh;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
margin: 0;
|
|
}
|
|
.json-copy-btn {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
width: 28px;
|
|
height: 28px;
|
|
border: none;
|
|
background: {{panelBg}};
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0.8;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.json-copy-btn:active { opacity: 1; }
|
|
.json-copy-btn svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
fill: {{accentColor}};
|
|
}
|
|
|
|
/* Relations list */
|
|
.relations-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.relations-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid {{borderColor}};
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.relations-list li:last-child { border-bottom: none; }
|
|
.rel-type {
|
|
font-weight: 600;
|
|
color: {{accentColor}};
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
background: {{borderColor}};
|
|
white-space: nowrap;
|
|
}
|
|
.rel-arrow {
|
|
color: {{mutedColor}};
|
|
font-size: 14px;
|
|
flex-shrink: 0;
|
|
}
|
|
.rel-node {
|
|
color: {{primaryColor}};
|
|
font-weight: 500;
|
|
word-break: break-word;
|
|
}
|
|
.rel-empty {
|
|
color: {{mutedColor}};
|
|
font-size: 13px;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Toast notification */
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
background: {{panelBg}};
|
|
color: {{primaryColor}};
|
|
border: 1px solid {{borderColor}};
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
font-family: -apple-system, sans-serif;
|
|
font-size: 13px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
z-index: 200;
|
|
pointer-events: none;
|
|
}
|
|
#toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="c"></canvas>
|
|
|
|
<!-- Backdrop to catch taps outside panel -->
|
|
<div id="panel-backdrop"></div>
|
|
|
|
<!-- Slide-up detail panel -->
|
|
<div id="panel">
|
|
<div class="panel-handle-zone" id="panel-handle-zone">
|
|
<div class="panel-handle"></div>
|
|
</div>
|
|
<div class="panel-actions" id="panel-actions"></div>
|
|
<div class="panel-label" id="panel-label"></div>
|
|
<div class="panel-props" id="panel-props"></div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
(function () {
|
|
var nodesData = {{nodes}};
|
|
var edgesData = {{edges}};
|
|
|
|
if (nodesData.length === 0) {
|
|
document.body.innerHTML = "<div style='padding:24px;font-family:-apple-system,sans-serif;color:#888;font-size:15px'>No graph data — query must return nodes.</div>";
|
|
return;
|
|
}
|
|
|
|
var canvas = document.getElementById('c');
|
|
var ctx = canvas.getContext('2d');
|
|
var panel = document.getElementById('panel');
|
|
var panelBackdrop = document.getElementById('panel-backdrop');
|
|
var panelActions = document.getElementById('panel-actions');
|
|
var panelLabel = document.getElementById('panel-label');
|
|
var panelProps = document.getElementById('panel-props');
|
|
var panelHandleZone = document.getElementById('panel-handle-zone');
|
|
var W = 0, H = 0, dpr = window.devicePixelRatio || 1;
|
|
|
|
// ---- node/edge state ----
|
|
var nodes = nodesData.map(function (d, i) {
|
|
return { id: d.id, label: d.label || String(d.id), title: d.title || '', color: d.color || '#5A99D4', x: 0, y: 0, vx: 0, vy: 0 };
|
|
});
|
|
var edges = edgesData.map(function (d) {
|
|
return { from: d.from, to: d.to, label: d.label || '', title: d.title || '' };
|
|
});
|
|
var nodeById = {};
|
|
nodes.forEach(function (n) { nodeById[n.id] = n; });
|
|
|
|
// ---- compute degree per node ----
|
|
nodes.forEach(function (n) { n.degree = 0; });
|
|
edges.forEach(function (e) {
|
|
if (nodeById[e.from]) nodeById[e.from].degree++;
|
|
if (nodeById[e.to]) nodeById[e.to].degree++;
|
|
});
|
|
var maxDegree = 1;
|
|
nodes.forEach(function (n) { if (n.degree > maxDegree) maxDegree = n.degree; });
|
|
|
|
// ---- resize ----
|
|
function resize() {
|
|
W = document.documentElement.clientWidth || window.innerWidth || document.body.clientWidth || 300;
|
|
H = document.documentElement.clientHeight || window.innerHeight || document.body.clientHeight || 400;
|
|
canvas.style.width = W + 'px';
|
|
canvas.style.height = H + 'px';
|
|
canvas.width = W * dpr;
|
|
canvas.height = H * dpr;
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
|
|
// place nodes in a circle around center
|
|
var N = nodes.length;
|
|
function placeInitial() {
|
|
var r = Math.min(W, H) * (0.32 + Math.min(N / 200, 0.5));
|
|
nodes.forEach(function (n, i) {
|
|
var a = (2 * Math.PI * i) / N - Math.PI / 2;
|
|
n.x = W / 2 + r * Math.cos(a);
|
|
n.y = H / 2 + r * Math.sin(a);
|
|
});
|
|
}
|
|
|
|
// ---- physics (scaled by node count) ----
|
|
var large = N > 50;
|
|
var BASE_REPULSION = large ? 15000 : 5000;
|
|
var LEAF_SPRING = large ? 60 : 60;
|
|
var HUB_SPRING = large ? 300 : 200;
|
|
var SPRING_K = 0.03;
|
|
var DAMP = large ? 0.65 : 0.8;
|
|
var GRAVITY = large ? 0.003 : 0.008;
|
|
var MAX_VEL = large ? 12 : 15;
|
|
|
|
// pre-tag hubs vs leaves for fast lookup
|
|
var HUB_THRESHOLD = Math.max(3, maxDegree * 0.3);
|
|
nodes.forEach(function (n) { n.isHub = n.degree >= HUB_THRESHOLD; });
|
|
|
|
function step() {
|
|
var i, j, a, b, dx, dy, d, f;
|
|
|
|
// repulsion — much stronger between hubs to separate clusters
|
|
var cutoff = large ? 1500 : Infinity;
|
|
for (i = 0; i < N; i++) {
|
|
for (j = i + 1; j < N; j++) {
|
|
a = nodes[i]; b = nodes[j];
|
|
dx = b.x - a.x; dy = b.y - a.y;
|
|
d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
if (d > cutoff) continue;
|
|
var rep = BASE_REPULSION;
|
|
if (a.isHub && b.isHub) rep *= 3;
|
|
else if (a.isHub || b.isHub) rep *= 1.5;
|
|
f = rep / (d * d);
|
|
var fx = f * dx / d, fy = f * dy / d;
|
|
a.vx -= fx; a.vy -= fy;
|
|
b.vx += fx; b.vy += fy;
|
|
}
|
|
}
|
|
|
|
// spring attraction — short for leaves, long for hub-to-hub
|
|
edges.forEach(function (e) {
|
|
a = nodeById[e.from]; b = nodeById[e.to];
|
|
if (!a || !b) return;
|
|
var len;
|
|
if (a.isHub && b.isHub) {
|
|
len = HUB_SPRING;
|
|
} else {
|
|
var hubDeg = Math.max(a.degree, b.degree);
|
|
len = LEAF_SPRING + (HUB_SPRING - LEAF_SPRING) * 0.3 * (hubDeg / maxDegree);
|
|
}
|
|
dx = b.x - a.x; dy = b.y - a.y;
|
|
d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
f = SPRING_K * (d - len);
|
|
var fx = f * dx / d, fy = f * dy / d;
|
|
a.vx += fx; a.vy += fy;
|
|
b.vx -= fx; b.vy -= fy;
|
|
});
|
|
|
|
// gravity toward center + damping + velocity cap
|
|
nodes.forEach(function (n) {
|
|
if (n.pinned) { n.vx = 0; n.vy = 0; return; }
|
|
n.vx += (W / 2 - n.x) * GRAVITY;
|
|
n.vy += (H / 2 - n.y) * GRAVITY;
|
|
n.vx *= DAMP; n.vy *= DAMP;
|
|
var v = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
|
if (v > MAX_VEL) { n.vx *= MAX_VEL / v; n.vy *= MAX_VEL / v; }
|
|
n.x += n.vx; n.y += n.vy;
|
|
});
|
|
}
|
|
|
|
// auto-fit: zoom and pan so all nodes are visible with padding
|
|
function fitToView() {
|
|
if (N === 0) return;
|
|
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
nodes.forEach(function (n) {
|
|
if (n.x < minX) minX = n.x;
|
|
if (n.y < minY) minY = n.y;
|
|
if (n.x > maxX) maxX = n.x;
|
|
if (n.y > maxY) maxY = n.y;
|
|
});
|
|
var pad = NODE_R * 3;
|
|
var gw = (maxX - minX) + pad * 2;
|
|
var gh = (maxY - minY) + pad * 2;
|
|
if (gw < 1) gw = 1;
|
|
if (gh < 1) gh = 1;
|
|
var newZoom = Math.min(W / gw, H / gh, 2);
|
|
newZoom = Math.max(newZoom, 0.05);
|
|
var cx = (minX + maxX) / 2;
|
|
var cy = (minY + maxY) / 2;
|
|
zoom = newZoom;
|
|
panX = W / 2 - cx * zoom;
|
|
panY = H / 2 - cy * zoom;
|
|
}
|
|
|
|
// ---- camera (pan + zoom, initial zoom based on node count) ----
|
|
var initialZoom = N <= 10 ? 1 : Math.max(0.05, 1 / Math.sqrt(N / 10));
|
|
var panX = 0, panY = 0, zoom = initialZoom;
|
|
var autoZoomEnabled = {{autoZoom}};
|
|
|
|
// Smoothly animate camera to center on a node
|
|
function zoomToNode(n, targetZoom, delay) {
|
|
if (!autoZoomEnabled) return;
|
|
function doZoom() {
|
|
targetZoom = targetZoom || Math.max(zoom, 1.2);
|
|
var startPanX = panX, startPanY = panY, startZoom = zoom;
|
|
// If panel is open, center in the visible area above the panel
|
|
var panelH = panelOpen ? (panel.offsetHeight || H * 0.55) : 0;
|
|
var visibleCenterY = (H - panelH) / 2;
|
|
var endPanX = W / 2 - n.x * targetZoom;
|
|
var endPanY = visibleCenterY - n.y * targetZoom;
|
|
var duration = 400, startTime = Date.now();
|
|
function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
|
|
function animate() {
|
|
var t = Math.min(1, (Date.now() - startTime) / duration);
|
|
var p = ease(t);
|
|
panX = startPanX + (endPanX - startPanX) * p;
|
|
panY = startPanY + (endPanY - startPanY) * p;
|
|
zoom = startZoom + (targetZoom - startZoom) * p;
|
|
draw();
|
|
if (t < 1) requestAnimationFrame(animate);
|
|
}
|
|
requestAnimationFrame(animate);
|
|
}
|
|
if (delay) setTimeout(doZoom, delay);
|
|
else doZoom();
|
|
}
|
|
|
|
// screen coords -> world coords
|
|
function toWorld(sx, sy) {
|
|
return { x: (sx - panX) / zoom, y: (sy - panY) / zoom };
|
|
}
|
|
|
|
// ---- draw ----
|
|
var NODE_R = 20;
|
|
var NODE_SEL = '#E8A838';
|
|
var EDGE_CLR = '#666';
|
|
var TEXT_CLR = '{{textColor}}';
|
|
var selected = null; // selected node
|
|
var selectedEdge = null; // selected edge
|
|
|
|
function draw() {
|
|
ctx.save();
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.translate(panX, panY);
|
|
ctx.scale(zoom, zoom);
|
|
|
|
// edges
|
|
edges.forEach(function (e) {
|
|
var a = nodeById[e.from], b = nodeById[e.to];
|
|
if (!a || !b) return;
|
|
var isSelEdge = e === selectedEdge;
|
|
var ang = Math.atan2(b.y - a.y, b.x - a.x);
|
|
var tx = b.x - NODE_R * Math.cos(ang);
|
|
var ty = b.y - NODE_R * Math.sin(ang);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(a.x, a.y);
|
|
ctx.lineTo(tx, ty);
|
|
ctx.strokeStyle = isSelEdge ? NODE_SEL : EDGE_CLR;
|
|
ctx.lineWidth = isSelEdge ? 3 : 1.5;
|
|
ctx.stroke();
|
|
|
|
// arrowhead
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx, ty);
|
|
ctx.lineTo(tx - 9 * Math.cos(ang - 0.4), ty - 9 * Math.sin(ang - 0.4));
|
|
ctx.lineTo(tx - 9 * Math.cos(ang + 0.4), ty - 9 * Math.sin(ang + 0.4));
|
|
ctx.closePath();
|
|
ctx.fillStyle = isSelEdge ? NODE_SEL : EDGE_CLR;
|
|
ctx.fill();
|
|
|
|
if (e.label) {
|
|
ctx.fillStyle = isSelEdge ? NODE_SEL : TEXT_CLR;
|
|
ctx.font = (isSelEdge ? 'bold ' : '') + '10px -apple-system, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(e.label, (a.x + b.x) / 2, (a.y + b.y) / 2 - 5);
|
|
}
|
|
});
|
|
|
|
// nodes
|
|
nodes.forEach(function (n) {
|
|
var isSel = n === selected;
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, NODE_R, 0, 2 * Math.PI);
|
|
ctx.fillStyle = isSel ? NODE_SEL : n.color;
|
|
ctx.fill();
|
|
if (isSel) {
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.stroke();
|
|
}
|
|
|
|
var lbl = n.label.length > 10 ? n.label.slice(0, 9) + '\u2026' : n.label;
|
|
ctx.fillStyle = '#fff';
|
|
ctx.font = 'bold 11px -apple-system, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(lbl, n.x, n.y);
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
// ---- panel ----
|
|
var panelOpen = false;
|
|
var panelViewState = 'props'; // 'props', 'json', 'relations'
|
|
var panelCurrentItem = null;
|
|
var panelCurrentType = null; // 'node' or 'edge'
|
|
|
|
// SVG icon paths
|
|
var ICONS = {
|
|
copy: '<svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>',
|
|
json: '<svg viewBox="0 0 24 24"><path d="M5 3h2v2H5v5a2 2 0 01-2 2 2 2 0 012 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 00-2-2H0v-2h1a2 2 0 002-2V5a2 2 0 012-2m14 0a2 2 0 012 2v4a2 2 0 002 2h1v2h-1a2 2 0 00-2 2v4a2 2 0 01-2 2h-2v-2h2v-5a2 2 0 012-2 2 2 0 01-2-2V5h-2V3h2z"/></svg>',
|
|
expand: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15v-4H7v-2h4V7h2v4h4v2h-4v4h-2z"/></svg>',
|
|
collapse: '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"/></svg>',
|
|
close: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
|
|
props: '<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>'
|
|
};
|
|
|
|
function parseProps(title) {
|
|
var props = [];
|
|
if (!title) return props;
|
|
title.split(', ').forEach(function (pair) {
|
|
var idx = pair.indexOf(': ');
|
|
if (idx > -1) {
|
|
props.push({ key: pair.slice(0, idx), value: pair.slice(idx + 2) });
|
|
} else if (pair.trim()) {
|
|
props.push({ key: '', value: pair });
|
|
}
|
|
});
|
|
return props;
|
|
}
|
|
|
|
function propsToText(label, props) {
|
|
var lines = [label];
|
|
props.forEach(function (p) {
|
|
lines.push(p.key ? p.key + ': ' + p.value : p.value);
|
|
});
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function propsToJson(n) {
|
|
var obj = { id: n.id, label: n.label, properties: {} };
|
|
parseProps(n.title).forEach(function (p) {
|
|
if (p.key) obj.properties[p.key] = p.value;
|
|
});
|
|
return JSON.stringify(obj, null, 2);
|
|
}
|
|
|
|
function edgePropsToJson(e) {
|
|
var obj = { type: e.label, from: e.from, to: e.to, properties: {} };
|
|
parseProps(e.title).forEach(function (p) {
|
|
if (p.key) obj.properties[p.key] = p.value;
|
|
});
|
|
return JSON.stringify(obj, null, 2);
|
|
}
|
|
|
|
function showToast(msg) {
|
|
var t = document.getElementById('toast');
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
setTimeout(function () { t.classList.remove('show'); }, 1500);
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
showToast('Copied to clipboard');
|
|
});
|
|
} else {
|
|
// Fallback for WebView
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
showToast('Copied to clipboard');
|
|
}
|
|
}
|
|
|
|
function getRelationsForNode(nodeId) {
|
|
var rels = [];
|
|
edges.forEach(function (e) {
|
|
if (e.from === nodeId) {
|
|
var targetNode = nodeById[e.to];
|
|
rels.push({
|
|
type: e.label || 'RELATED',
|
|
direction: 'out',
|
|
nodeLabel: targetNode ? targetNode.label : String(e.to),
|
|
nodeId: e.to
|
|
});
|
|
}
|
|
if (e.to === nodeId) {
|
|
var sourceNode = nodeById[e.from];
|
|
rels.push({
|
|
type: e.label || 'RELATED',
|
|
direction: 'in',
|
|
nodeLabel: sourceNode ? sourceNode.label : String(e.from),
|
|
nodeId: e.from
|
|
});
|
|
}
|
|
});
|
|
return rels;
|
|
}
|
|
|
|
function buildRelationsHtml(nodeId) {
|
|
var rels = getRelationsForNode(nodeId);
|
|
if (rels.length === 0) {
|
|
return '<div class="rel-empty">Loading relations\u2026</div>';
|
|
}
|
|
var html = '<ul class="relations-list">';
|
|
rels.forEach(function (r) {
|
|
var arrow = r.direction === 'out' ? '\u2192' : '\u2190';
|
|
html += '<li>';
|
|
html += '<span class="rel-type">' + escHtml(r.type) + '</span>';
|
|
html += '<span class="rel-arrow">' + arrow + '</span>';
|
|
html += '<span class="rel-node">' + escHtml(r.nodeLabel) + '</span>';
|
|
html += '</li>';
|
|
});
|
|
html += '</ul>';
|
|
return html;
|
|
}
|
|
|
|
function buildJsonViewHtml(jsonStr) {
|
|
return '<div class="json-view-wrapper">' +
|
|
'<button class="json-copy-btn" onclick="window._panelCopyJsonClipboard()">' + ICONS.copy + '</button>' +
|
|
'<pre>' + escHtml(jsonStr) + '</pre>' +
|
|
'</div>';
|
|
}
|
|
|
|
function buildActionsForView(type, item, viewState) {
|
|
var html = '';
|
|
|
|
// Copy as text
|
|
html += '<button onclick="window._panelCopyText()">' + ICONS.copy + '<span>Copy</span></button>';
|
|
|
|
// JSON / Properties toggle
|
|
if (viewState === 'json') {
|
|
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
|
|
} else {
|
|
html += '<button onclick="window._panelShowJson()">' + ICONS.json + '<span>JSON</span></button>';
|
|
}
|
|
|
|
if (type === 'node') {
|
|
if (viewState === 'relations') {
|
|
// Show Properties button to go back
|
|
html += '<button onclick="window._panelShowProps()">' + ICONS.props + '<span>Properties</span></button>';
|
|
} else {
|
|
// Expand / collapse relations
|
|
var isExpanded = !!expandedBy[item.id];
|
|
if (isExpanded) {
|
|
html += '<button onclick="window._panelCollapse()">' + ICONS.collapse + '<span>Collapse</span></button>';
|
|
} else {
|
|
html += '<button onclick="window._panelExpand()">' + ICONS.expand + '<span>Relations</span></button>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close
|
|
html += '<button class="destructive" onclick="window._panelClose()">' + ICONS.close + '<span>Close</span></button>';
|
|
|
|
return html;
|
|
}
|
|
|
|
function buildPropsHtml(title) {
|
|
var props = parseProps(title);
|
|
if (!props.length) return '<div class="no-props">No properties</div>';
|
|
var html = '<table>';
|
|
props.forEach(function (p) {
|
|
html += '<tr><td class="pk">' + escHtml(p.key) + '</td><td class="pv">' + escHtml(p.value) + '</td></tr>';
|
|
});
|
|
html += '</table>';
|
|
return html;
|
|
}
|
|
|
|
function updatePanelContent() {
|
|
if (!panelCurrentItem) return;
|
|
var item = panelCurrentItem;
|
|
var type = panelCurrentType;
|
|
|
|
if (panelViewState === 'json') {
|
|
var jsonStr = type === 'node' ? propsToJson(item) : edgePropsToJson(item);
|
|
panelProps.innerHTML = buildJsonViewHtml(jsonStr);
|
|
window._panelCopyJsonClipboard = function () {
|
|
copyToClipboard(jsonStr);
|
|
};
|
|
} else if (panelViewState === 'relations' && type === 'node') {
|
|
panelProps.innerHTML = buildRelationsHtml(item.id);
|
|
} else {
|
|
panelProps.innerHTML = buildPropsHtml(item.title);
|
|
}
|
|
|
|
panelActions.innerHTML = buildActionsForView(type, item, panelViewState);
|
|
wireUpActions(type, item);
|
|
}
|
|
|
|
function wireUpActions(type, item) {
|
|
window._panelCopyText = function () {
|
|
var label = type === 'node' ? item.label : (item.label || 'Relationship');
|
|
copyToClipboard(propsToText(label, parseProps(item.title)));
|
|
};
|
|
window._panelShowJson = function () {
|
|
panelViewState = 'json';
|
|
updatePanelContent();
|
|
};
|
|
window._panelShowProps = function () {
|
|
panelViewState = 'props';
|
|
updatePanelContent();
|
|
};
|
|
window._panelClose = function () { hidePanel(); };
|
|
|
|
if (type === 'node') {
|
|
window._panelExpand = function () {
|
|
// Trigger expand
|
|
toggleExpandCollapse(item);
|
|
// Switch to relations view
|
|
panelViewState = 'relations';
|
|
updatePanelContent();
|
|
// After expand completes, the addGraphData callback will refresh
|
|
setTimeout(function () {
|
|
if (panelOpen && panelViewState === 'relations') {
|
|
updatePanelContent();
|
|
}
|
|
}, 500);
|
|
};
|
|
window._panelCollapse = function () {
|
|
toggleExpandCollapse(item);
|
|
setTimeout(function () {
|
|
updatePanelContent();
|
|
}, 300);
|
|
};
|
|
}
|
|
}
|
|
|
|
function showNodePanel(n) {
|
|
selected = n;
|
|
selectedEdge = null;
|
|
panelCurrentItem = n;
|
|
panelCurrentType = 'node';
|
|
panelViewState = 'props';
|
|
|
|
// Label header
|
|
panelLabel.innerHTML = '<span class="node-badge" style="background:' + n.color + '">' + escHtml(n.label) + '</span>' +
|
|
'<span class="node-id">ID: ' + n.id + '</span>';
|
|
|
|
updatePanelContent();
|
|
openPanel();
|
|
zoomToNode(n);
|
|
draw();
|
|
}
|
|
|
|
function showEdgePanel(e) {
|
|
selectedEdge = e;
|
|
selected = null;
|
|
panelCurrentItem = e;
|
|
panelCurrentType = 'edge';
|
|
panelViewState = 'props';
|
|
|
|
// Label
|
|
panelLabel.innerHTML = '<span class="edge-type">' + escHtml(e.label || 'Relationship') + '</span>' +
|
|
'<span class="node-id" style="margin-left:8px">' + e.from + ' \u2192 ' + e.to + '</span>';
|
|
|
|
updatePanelContent();
|
|
openPanel();
|
|
draw();
|
|
}
|
|
|
|
function notifyApp(url) {
|
|
var f = document.createElement('iframe');
|
|
f.style.display = 'none';
|
|
f.src = url;
|
|
document.body.appendChild(f);
|
|
setTimeout(function () { document.body.removeChild(f); }, 200);
|
|
}
|
|
|
|
function openPanel() {
|
|
panelOpen = true;
|
|
panel.style.height = '55%';
|
|
panelBackdrop.style.display = 'block';
|
|
panel.offsetHeight;
|
|
panel.classList.add('open');
|
|
notifyApp('app://panelopen');
|
|
}
|
|
|
|
function hidePanel() {
|
|
selected = null;
|
|
selectedEdge = null;
|
|
panelOpen = false;
|
|
panelCurrentItem = null;
|
|
panelCurrentType = null;
|
|
panelViewState = 'props';
|
|
panel.classList.remove('open');
|
|
panel.style.transform = '';
|
|
panel.style.height = '';
|
|
panelBackdrop.style.display = 'none';
|
|
draw();
|
|
notifyApp('app://panelclose');
|
|
}
|
|
|
|
panelBackdrop.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
|
|
panelBackdrop.addEventListener('touchend', function (e) { e.stopPropagation(); hidePanel(); });
|
|
|
|
// ---- panel drag (up to expand, down to dismiss) ----
|
|
var panelDragStartY = 0;
|
|
var panelDragDeltaY = 0;
|
|
var panelIsDragging = false;
|
|
var panelStartHeight = 0;
|
|
var PANEL_MIN_H = 120;
|
|
|
|
function panelDragStart(clientY, e) {
|
|
if (!panelOpen) return;
|
|
panelDragStartY = clientY;
|
|
panelDragDeltaY = 0;
|
|
panelStartHeight = panel.offsetHeight;
|
|
panelIsDragging = true;
|
|
panel.classList.add('dragging');
|
|
if (e) e.stopPropagation();
|
|
}
|
|
|
|
panelHandleZone.addEventListener('touchstart', function (e) {
|
|
panelDragStart(e.touches[0].clientY, e);
|
|
}, { passive: false });
|
|
|
|
panelHandleZone.addEventListener('mousedown', function (e) {
|
|
panelDragStart(e.clientY, e);
|
|
});
|
|
|
|
function panelDragMove(clientY) {
|
|
if (!panelIsDragging) return;
|
|
var dy = clientY - panelDragStartY;
|
|
panelDragDeltaY = dy;
|
|
if (dy >= 0) {
|
|
// Dragging down — translate panel down (toward dismiss)
|
|
panel.style.height = panelStartHeight + 'px';
|
|
panel.style.transform = 'translateY(' + dy + 'px)';
|
|
} else {
|
|
// Dragging up — expand panel height
|
|
var newH = Math.min(panelStartHeight - dy, H * 0.92);
|
|
newH = Math.max(newH, PANEL_MIN_H);
|
|
panel.style.height = newH + 'px';
|
|
panel.style.transform = 'translateY(0)';
|
|
}
|
|
}
|
|
|
|
function panelDragEnd() {
|
|
if (!panelIsDragging) return;
|
|
panelIsDragging = false;
|
|
panel.classList.remove('dragging');
|
|
if (panelDragDeltaY > 80) {
|
|
hidePanel();
|
|
} else {
|
|
// Keep the new height if dragged up, snap translate back
|
|
panel.style.transform = '';
|
|
}
|
|
}
|
|
|
|
document.addEventListener('touchmove', function (e) {
|
|
if (panelIsDragging) {
|
|
panelDragMove(e.touches[0].clientY);
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
document.addEventListener('touchend', function () {
|
|
if (panelIsDragging) panelDragEnd();
|
|
});
|
|
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (panelIsDragging) {
|
|
panelDragMove(e.clientY);
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', function () {
|
|
if (panelIsDragging) panelDragEnd();
|
|
});
|
|
|
|
document.addEventListener('mouseup', function (e) {
|
|
if (panelIsDragging) {
|
|
panelDragEnd();
|
|
}
|
|
});
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
// ---- expand / collapse ----
|
|
var lastTapTime = 0, lastTapNode = null, singleTapTimer = null;
|
|
var expandedBy = {}; // nodeId -> [childIds added by expanding that node]
|
|
|
|
// initial nodes are never collapsible
|
|
var initialNodeIds = {};
|
|
nodes.forEach(function (n) { initialNodeIds[n.id] = true; });
|
|
|
|
function recomputeDegrees() {
|
|
N = nodes.length;
|
|
nodes.forEach(function (n) { n.degree = 0; });
|
|
edges.forEach(function (e) {
|
|
if (nodeById[e.from]) nodeById[e.from].degree++;
|
|
if (nodeById[e.to]) nodeById[e.to].degree++;
|
|
});
|
|
maxDegree = 1;
|
|
nodes.forEach(function (n) { if (n.degree > maxDegree) maxDegree = n.degree; });
|
|
HUB_THRESHOLD = Math.max(3, maxDegree * 0.3);
|
|
nodes.forEach(function (n) { n.isHub = n.degree >= HUB_THRESHOLD; });
|
|
}
|
|
|
|
function toggleExpandCollapse(n) {
|
|
if (expandedBy[n.id]) {
|
|
collapseNode(n);
|
|
} else {
|
|
expandNode(n);
|
|
}
|
|
}
|
|
|
|
function expandNode(n) {
|
|
notifyApp('app://expand?nodeId=' + n.id);
|
|
}
|
|
|
|
function collapseNode(n) {
|
|
var childIds = expandedBy[n.id];
|
|
if (!childIds) return;
|
|
|
|
var otherOwned = {};
|
|
Object.keys(expandedBy).forEach(function (ownerId) {
|
|
if (ownerId == n.id) return;
|
|
expandedBy[ownerId].forEach(function (cid) { otherOwned[cid] = true; });
|
|
});
|
|
|
|
var toRemove = {};
|
|
childIds.forEach(function (cid) {
|
|
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
|
});
|
|
|
|
Object.keys(toRemove).forEach(function (rid) {
|
|
if (expandedBy[rid]) {
|
|
expandedBy[rid].forEach(function (cid) {
|
|
if (!initialNodeIds[cid] && !otherOwned[cid]) toRemove[cid] = true;
|
|
});
|
|
delete expandedBy[rid];
|
|
}
|
|
});
|
|
|
|
edges = edges.filter(function (e) {
|
|
return !toRemove[e.from] && !toRemove[e.to];
|
|
});
|
|
|
|
nodes = nodes.filter(function (nd) { return !toRemove[nd.id]; });
|
|
Object.keys(toRemove).forEach(function (rid) { delete nodeById[rid]; });
|
|
|
|
delete expandedBy[n.id];
|
|
|
|
if (selected && toRemove[selected.id]) hidePanel();
|
|
|
|
recomputeDegrees();
|
|
resumeLoop();
|
|
}
|
|
|
|
// called from C# via EvaluateJavaScriptAsync after expand query completes
|
|
window.addGraphData = function (newNodes, newEdges, originId) {
|
|
var origin = nodeById[originId];
|
|
var ox = origin ? origin.x : W / 2;
|
|
var oy = origin ? origin.y : H / 2;
|
|
var addedIds = [];
|
|
|
|
newNodes.forEach(function (d) {
|
|
if (nodeById[d.id]) {
|
|
addedIds.push(d.id);
|
|
return;
|
|
}
|
|
var angle = Math.random() * 2 * Math.PI;
|
|
var nd = {
|
|
id: d.id, label: d.label || String(d.id), title: d.title || '',
|
|
color: d.color || '#5A99D4',
|
|
x: ox + (40 + Math.random() * 40) * Math.cos(angle),
|
|
y: oy + (40 + Math.random() * 40) * Math.sin(angle),
|
|
vx: 0, vy: 0, degree: 0
|
|
};
|
|
nodes.push(nd);
|
|
nodeById[nd.id] = nd;
|
|
addedIds.push(d.id);
|
|
});
|
|
|
|
newEdges.forEach(function (d) {
|
|
var exists = edges.some(function (e) {
|
|
return e.from === d.from && e.to === d.to && e.label === (d.label || '');
|
|
});
|
|
if (exists) return;
|
|
if (!nodeById[d.from] || !nodeById[d.to]) return;
|
|
edges.push({ from: d.from, to: d.to, label: d.label || '', title: d.title || '' });
|
|
});
|
|
|
|
expandedBy[originId] = addedIds;
|
|
|
|
recomputeDegrees();
|
|
resumeLoop();
|
|
|
|
// Zoom to the expanded node
|
|
var origin = nodeById[originId];
|
|
if (origin) zoomToNode(origin, null, 800);
|
|
|
|
// Update panel if still showing this node
|
|
if (selected && selected.id === originId && panelOpen) {
|
|
updatePanelContent();
|
|
}
|
|
};
|
|
|
|
// ---- interaction handling (touch + mouse/pointer) ----
|
|
var ptrStartX, ptrStartY, ptrEndX, ptrEndY, ptrStartTime, dragging = null, panning = false, mouseDown = false;
|
|
var interacted = false;
|
|
function notifyInteraction() {
|
|
if (interacted) return;
|
|
interacted = true;
|
|
notifyApp('app://interaction');
|
|
}
|
|
|
|
function edgeAt(sx, sy) {
|
|
var w = toWorld(sx, sy);
|
|
var threshold = 12 / zoom;
|
|
var best = null, bestDist = threshold;
|
|
edges.forEach(function (e) {
|
|
var a = nodeById[e.from], b = nodeById[e.to];
|
|
if (!a || !b) return;
|
|
var dx = b.x - a.x, dy = b.y - a.y;
|
|
var lenSq = dx * dx + dy * dy;
|
|
if (lenSq === 0) return;
|
|
var t = Math.max(0, Math.min(1, ((w.x - a.x) * dx + (w.y - a.y) * dy) / lenSq));
|
|
var px = a.x + t * dx, py = a.y + t * dy;
|
|
var dist = Math.hypot(w.x - px, w.y - py);
|
|
if (dist < bestDist) { bestDist = dist; best = e; }
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function nodeAt(sx, sy) {
|
|
var w = toWorld(sx, sy);
|
|
for (var i = nodes.length - 1; i >= 0; i--) {
|
|
var n = nodes[i];
|
|
if (Math.hypot(n.x - w.x, n.y - w.y) <= (NODE_R + 8) / zoom) return n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function coordFromEvent(e) {
|
|
var rect = canvas.getBoundingClientRect();
|
|
var src = e.touches ? e.touches[0] : e;
|
|
return { x: src.clientX - rect.left, y: src.clientY - rect.top };
|
|
}
|
|
|
|
function handleStart(e) {
|
|
if (e.touches) e.preventDefault();
|
|
if (e.touches && e.touches.length >= 2) return; // pinch handled separately
|
|
notifyInteraction();
|
|
var p = coordFromEvent(e);
|
|
ptrStartX = ptrEndX = p.x;
|
|
ptrStartY = ptrEndY = p.y;
|
|
ptrStartTime = Date.now();
|
|
dragging = nodeAt(p.x, p.y);
|
|
panning = !dragging;
|
|
mouseDown = true;
|
|
}
|
|
|
|
function handleMove(e) {
|
|
if (e.touches) e.preventDefault();
|
|
if (!mouseDown) return;
|
|
var p = coordFromEvent(e);
|
|
var dx = p.x - ptrEndX, dy = p.y - ptrEndY;
|
|
ptrEndX = p.x; ptrEndY = p.y;
|
|
if (panning) {
|
|
panX += dx; panY += dy;
|
|
draw();
|
|
return;
|
|
}
|
|
if (!dragging) return;
|
|
var w = toWorld(p.x, p.y);
|
|
dragging.x = w.x; dragging.y = w.y;
|
|
dragging.vx = 0; dragging.vy = 0;
|
|
draw();
|
|
}
|
|
|
|
function handleEnd(e) {
|
|
if (e.touches) e.preventDefault();
|
|
if (!mouseDown) return;
|
|
var dt = Date.now() - ptrStartTime;
|
|
var moved = Math.hypot(ptrEndX - ptrStartX, ptrEndY - ptrStartY) > 8;
|
|
|
|
if (!moved && dt < 300) {
|
|
var hit = nodeAt(ptrStartX, ptrStartY);
|
|
var now = Date.now();
|
|
// double-tap detection — only expand/collapse, no panel
|
|
if (hit && hit === lastTapNode && (now - lastTapTime) < 400) {
|
|
lastTapNode = null; lastTapTime = 0;
|
|
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
|
toggleExpandCollapse(hit);
|
|
} else if (hit) {
|
|
lastTapNode = hit; lastTapTime = now;
|
|
// Delay panel open to distinguish from double-tap
|
|
var tappedNode = hit;
|
|
if (singleTapTimer) clearTimeout(singleTapTimer);
|
|
singleTapTimer = setTimeout(function () {
|
|
singleTapTimer = null;
|
|
if (tappedNode === selected && panelOpen) hidePanel();
|
|
else showNodePanel(tappedNode);
|
|
}, 350);
|
|
} else {
|
|
lastTapNode = null; lastTapTime = 0;
|
|
if (singleTapTimer) { clearTimeout(singleTapTimer); singleTapTimer = null; }
|
|
var hitEdge = edgeAt(ptrStartX, ptrStartY);
|
|
if (hitEdge) {
|
|
if (hitEdge === selectedEdge && panelOpen) hidePanel();
|
|
else showEdgePanel(hitEdge);
|
|
} else {
|
|
if (panelOpen) hidePanel();
|
|
}
|
|
}
|
|
}
|
|
|
|
// pin node in place after dragging it
|
|
if (dragging && moved) {
|
|
dragging.pinned = true;
|
|
}
|
|
|
|
dragging = null;
|
|
panning = false;
|
|
mouseDown = false;
|
|
resumeLoop();
|
|
}
|
|
|
|
// touch events
|
|
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
|
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
|
canvas.addEventListener('touchend', handleEnd, { passive: false });
|
|
|
|
// mouse events (for simulator / desktop WebView)
|
|
canvas.addEventListener('mousedown', handleStart);
|
|
canvas.addEventListener('mousemove', handleMove);
|
|
canvas.addEventListener('mouseup', handleEnd);
|
|
|
|
// ---- zoom: scroll wheel ----
|
|
canvas.addEventListener('wheel', function (e) {
|
|
e.preventDefault();
|
|
var rect = canvas.getBoundingClientRect();
|
|
var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
var newZoom = Math.min(Math.max(zoom * factor, 0.1), 10);
|
|
panX = mx - (mx - panX) * (newZoom / zoom);
|
|
panY = my - (my - panY) * (newZoom / zoom);
|
|
zoom = newZoom;
|
|
draw();
|
|
}, { passive: false });
|
|
|
|
// ---- zoom: pinch (two-finger touch) ----
|
|
var pinchStartDist = 0, pinchStartZoom = 1;
|
|
|
|
canvas.addEventListener('touchstart', function (e) {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
var dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
pinchStartDist = Math.hypot(dx, dy) || 1;
|
|
pinchStartZoom = zoom;
|
|
dragging = null;
|
|
panning = false;
|
|
}
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('touchmove', function (e) {
|
|
if (e.touches.length === 2) {
|
|
e.preventDefault();
|
|
var dx = e.touches[0].clientX - e.touches[1].clientX;
|
|
var dy = e.touches[0].clientY - e.touches[1].clientY;
|
|
var dist = Math.hypot(dx, dy) || 1;
|
|
var rect = canvas.getBoundingClientRect();
|
|
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
|
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
|
var newZoom = Math.min(Math.max(pinchStartZoom * (dist / pinchStartDist), 0.1), 10);
|
|
panX = mx - (mx - panX) * (newZoom / zoom);
|
|
panY = my - (my - panY) * (newZoom / zoom);
|
|
zoom = newZoom;
|
|
draw();
|
|
}
|
|
}, { passive: false });
|
|
|
|
// ---- animation loop ----
|
|
var ticks = 0, rafId = null;
|
|
var maxTicks = large ? 250 : 400;
|
|
|
|
function loop() {
|
|
step();
|
|
draw();
|
|
ticks++;
|
|
if (ticks < maxTicks) rafId = requestAnimationFrame(loop);
|
|
else rafId = null;
|
|
}
|
|
|
|
function resumeLoop() {
|
|
ticks = 0;
|
|
if (!rafId) rafId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
// ---- init ----
|
|
var initRetries = 0;
|
|
|
|
function init() {
|
|
resize();
|
|
if ((W <= 1 || H <= 1) && initRetries < 20) {
|
|
initRetries++;
|
|
setTimeout(init, 50);
|
|
return;
|
|
}
|
|
placeInitial();
|
|
panX = W / 2 - (W / 2) * zoom;
|
|
panY = H / 2 - (H / 2) * zoom;
|
|
loop();
|
|
}
|
|
|
|
window.addEventListener('resize', function () {
|
|
resize();
|
|
draw();
|
|
});
|
|
|
|
requestAnimationFrame(function () {
|
|
requestAnimationFrame(init);
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|