From 8eb4e3cd5e91ee4e57cf507eaabb21a0963030b9 Mon Sep 17 00:00:00 2001 From: Gerwin Kuijntjes Date: Thu, 2 Apr 2026 11:51:31 +0200 Subject: [PATCH] Feature: redesign graph node panel with slide-up sheet, actions, and auto-zoom - Replace basic popup with animated slide-up panel using app theme colors - Add action buttons: Copy text, Show JSON (inline with copy), Relations list - Panel is bi-directional draggable: swipe down to dismiss, drag up to expand - Panel view states: properties, JSON, relations with navigation between them - Auto-zoom and center on node tap (accounts for panel height) - Delayed zoom after expand so physics can settle nodes first - Notify C# on panel open/close to expand inline WebView height - Add backdrop overlay to dismiss panel on outside tap - Toast notification for clipboard copies Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Controls/QueryResultView.xaml.cs | 28 + .../Xamarin.Neo4j/Visualization/visgraph.html | 754 ++++++++++++++++-- 2 files changed, 700 insertions(+), 82 deletions(-) diff --git a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Controls/QueryResultView.xaml.cs b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Controls/QueryResultView.xaml.cs index b1b6244..54b6ecb 100644 --- a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Controls/QueryResultView.xaml.cs +++ b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Controls/QueryResultView.xaml.cs @@ -4,6 +4,7 @@ using System.Reflection; using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Xaml; +using Microsoft.Maui.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui; using Xamarin.Neo4j.Managers; @@ -42,8 +43,28 @@ namespace Xamarin.Neo4j.Controls graphView.Source = new HtmlWebViewSource { Html = QueryResult?.NeovisHtml }; } + private double _originalHeight; + private async void OnGraphViewNavigating(object sender, WebNavigatingEventArgs e) { + // Panel open/close — expand WebView so panel has room + if (e.Url.Contains("panelopen")) + { + e.Cancel = true; + _originalHeight = graphView.HeightRequest; + var screenService = IPlatformApplication.Current.Services.GetRequiredService(); + var screenHeight = screenService.GetScreenHeight(); + graphView.HeightRequest = Math.Max(_originalHeight, screenHeight * 0.7); + return; + } + if (e.Url.Contains("panelclose")) + { + e.Cancel = true; + if (_originalHeight > 0) + graphView.HeightRequest = _originalHeight; + return; + } + if (!e.Url.Contains("expand") || !e.Url.Contains("nodeId")) return; e.Cancel = true; @@ -110,6 +131,13 @@ namespace Xamarin.Neo4j.Controls html = html.Replace("{{edges}}", edgesJson); html = html.Replace("{{backgroundColor}}", isDark ? "#292C31" : "#FFFFFF"); html = html.Replace("{{textColor}}", isDark ? "#FFFFFF" : "#000000"); + html = html.Replace("{{panelBg}}", isDark ? "#141414" : "#ffffff"); + html = html.Replace("{{borderColor}}", isDark ? "#2a2a2a" : "#e0e0e0"); + html = html.Replace("{{mutedColor}}", isDark ? "#868686" : "#868a92"); + html = html.Replace("{{accentColor}}", isDark ? "#e2e2e2" : "#0c0c0c"); + html = html.Replace("{{primaryColor}}", isDark ? "#e2e2e2" : "#0c0c0c"); + html = html.Replace("{{errorColor}}", isDark ? "#ff6b6b" : "#c62828"); + html = html.Replace("{{autoZoom}}", Preferences.Default.Get("auto_zoom", true) ? "true" : "false"); QueryResult.NeovisHtml = html; } diff --git a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html index f0a9f1e..f327e6b 100644 --- a/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html +++ b/Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j/Visualization/visgraph.html @@ -8,36 +8,269 @@ html, body { width: 100%; height: 100%; overflow: hidden; background: {{backgroundColor}}; } canvas { display: block; position: absolute; top: 0; left: 0; } - #popup { + /* ── Slide-up detail panel ── */ + #panel-backdrop { display: none; position: fixed; - bottom: 24px; - left: 16px; - right: 16px; - background: rgba(20, 20, 30, 0.95); - color: #fff; + inset: 0; + z-index: 90; + } + #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; - padding: 14px 16px; + 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; - line-height: 1.5; - max-height: 40%; - overflow-y: auto; - z-index: 100; + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + z-index: 200; + pointer-events: none; } - #popup .lbl { - font-size: 15px; - font-weight: 600; - margin-bottom: 6px; - color: #7EC8E3; + #toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); } - #popup .prop { color: #ccc; } - #popup .prop b { color: #fff; } - + + +
+ + +
+
+
+
+
+
+
+
+ + +