diff --git a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Utilities/GraphDataHelper.cs b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Utilities/GraphDataHelper.cs index 696912b..86b6a24 100644 --- a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Utilities/GraphDataHelper.cs +++ b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Utilities/GraphDataHelper.cs @@ -8,6 +8,7 @@ // using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using Neo4j.Driver; @@ -47,17 +48,34 @@ namespace Xamarin.Neo4j.Utilities var nodesJson = "[" + string.Join(",", nodeDict.Values.Select(n => { var label = n.Labels.FirstOrDefault() ?? "Node"; - var title = string.Join(", ", n.Properties.Select(p => $"{p.Key}: {p.Value}")); + var title = string.Join(", ", n.Properties.Select(p => $"{p.Key}: {FormatValue(p.Value)}")); var color = LabelColorManager.GetColor(connectionId, label); return $"{{\"id\":{n.Id},\"label\":\"{EscapeJs(label)}\",\"title\":\"{EscapeJs(title)}\",\"color\":\"{color}\"}}"; })) + "]"; var edgesJson = "[" + string.Join(",", relationships.Select(r => - $"{{\"from\":{r.StartNodeId},\"to\":{r.EndNodeId},\"label\":\"{EscapeJs(r.Type)}\"}}")) + "]"; + { + var props = string.Join(", ", r.Properties.Select(p => $"{p.Key}: {FormatValue(p.Value)}")); + return $"{{\"from\":{r.StartNodeId},\"to\":{r.EndNodeId},\"label\":\"{EscapeJs(r.Type)}\",\"title\":\"{EscapeJs(props)}\"}}"; + })) + "]"; return (nodesJson, edgesJson); } + private static string FormatValue(object value) + { + if (value == null) return "null"; + + if (value is IList list) + return "[" + string.Join(", ", list.Cast().Select(FormatValue)) + "]"; + + if (value is IDictionary dict) + return "{" + string.Join(", ", dict.Keys.Cast() + .Select(k => $"{k}: {FormatValue(dict[k])}")) + "}"; + + return value.ToString(); + } + public static string EscapeJs(string s) { return s?.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "") ?? string.Empty; diff --git a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html index 8dde647..d94c160 100644 --- a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html +++ b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html @@ -59,7 +59,7 @@ 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 || '' }; + return { from: d.from, to: d.to, label: d.label || '', title: d.title || '' }; }); var nodeById = {}; nodes.forEach(function (n) { nodeById[n.id] = n; }); @@ -153,6 +153,7 @@ // 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; @@ -200,7 +201,8 @@ var NODE_SEL = '#E8A838'; var EDGE_CLR = '#666'; var TEXT_CLR = '{{textColor}}'; - var selected = null; + var selected = null; // selected node + var selectedEdge = null; // selected edge function draw() { ctx.save(); @@ -212,6 +214,7 @@ 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); @@ -219,8 +222,8 @@ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(tx, ty); - ctx.strokeStyle = EDGE_CLR; - ctx.lineWidth = 1.5; + ctx.strokeStyle = isSelEdge ? NODE_SEL : EDGE_CLR; + ctx.lineWidth = isSelEdge ? 3 : 1.5; ctx.stroke(); // arrowhead @@ -229,12 +232,12 @@ 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 = EDGE_CLR; + ctx.fillStyle = isSelEdge ? NODE_SEL : EDGE_CLR; ctx.fill(); if (e.label) { - ctx.fillStyle = TEXT_CLR; - ctx.font = '10px -apple-system, sans-serif'; + 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); @@ -265,11 +268,10 @@ } // ---- popup ---- - function showPopup(n) { - selected = n; - var html = '
' + escHtml(n.label) + '
'; - if (n.title) { - n.title.split(', ').forEach(function (pair) { + function buildPropsHtml(title) { + var html = ''; + if (title) { + title.split(', ').forEach(function (pair) { var idx = pair.indexOf(': '); if (idx > -1) { html += '
' + escHtml(pair.slice(0, idx)) + ': ' + escHtml(pair.slice(idx + 2)) + '
'; @@ -280,6 +282,24 @@ } else { html += '
No properties
'; } + return html; + } + + function showPopup(n) { + selected = n; + selectedEdge = null; + var html = '
' + escHtml(n.label) + '
'; + html += buildPropsHtml(n.title); + popup.innerHTML = html; + popup.style.display = 'block'; + draw(); + } + + function showEdgePopup(e) { + selectedEdge = e; + selected = null; + var html = '
' + escHtml(e.label || 'Relationship') + '
'; + html += buildPropsHtml(e.title); popup.innerHTML = html; popup.style.display = 'block'; draw(); @@ -287,6 +307,7 @@ function hidePopup() { selected = null; + selectedEdge = null; popup.style.display = 'none'; draw(); } @@ -420,6 +441,25 @@ // ---- interaction handling (touch + mouse/pointer) ---- var ptrStartX, ptrStartY, ptrEndX, ptrEndY, ptrStartTime, dragging = null, panning = false, mouseDown = false; + function edgeAt(sx, sy) { + var w = toWorld(sx, sy); + var threshold = 12 / zoom; // tap tolerance in world units + var best = null, bestDist = threshold; + edges.forEach(function (e) { + var a = nodeById[e.from], b = nodeById[e.to]; + if (!a || !b) return; + // point-to-segment distance + 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--) { @@ -483,10 +523,22 @@ else showPopup(hit); } else { lastTapNode = null; lastTapTime = 0; - hidePopup(); + // no node hit — check if an edge was tapped + var hitEdge = edgeAt(ptrStartX, ptrStartY); + if (hitEdge) { + if (hitEdge === selectedEdge) hidePopup(); + else showEdgePopup(hitEdge); + } else { + hidePopup(); + } } } + // pin node in place after dragging it + if (dragging && moved) { + dragging.pinned = true; + } + dragging = null; panning = false; mouseDown = false;