Files
xamarin-neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html
Gerwin Kuijntjes f37c09a32a Fix: restore full graph interaction when node detail panel is open
- 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>
2026-04-02 12:25:09 +02:00

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ---- 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>