From 34794db2096e2c76c1102c8eaf1e6e3bb515e8c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 9 May 2026 09:36:23 +0000 Subject: [PATCH] feat(ui/graph): Knowledge Graph Refinement and Sidebar Hierarchy (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR addresses several UI/UX and architectural refinements for the Knowledge Graph and Intelligence Sidebar. ### Key Changes: - **Knowledge Graph (#21, #22)**: - Implemented \"pill-shaped\" nodes with dynamic label truncation and SVG tooltips. - Added bound-constrained simulation to keep nodes within the viewport. - Integrated `ResizeObserver` for dynamic layout handling. - Implemented Zoom-to-Fit functionality. - Enforced unique concept IDs in AI prompts and hardened JS logic to prevent multi-selection bugs. - **Intelligence Sidebar (#23)**: - Improved visual depth with a radial gradient background for the graph. - Increased sidebar divider contrast for better layering. - Transformed graph controls into a floating glassmorphism panel. - Relocated the \"Logout\" action to the toolbar bottom and rebranded it as \"Exit\". Fixes #21 Fixes #22 Fixes #23 Reviewed-on: https://git.archimap.cloud/mjasin/Nexus.Reader/pulls/25 Co-authored-by: Marek Jasiński Co-committed-by: Marek Jasiński --- .../Sync/UpdateReadingProgressCommand.cs | 2 +- .../UpdateReadingProgressCommandHandler.cs | 15 ++- .../RealTime/SyncHub.cs | 2 +- .../Services/PromptRegistry.cs | 8 +- .../Molecules/AiAssistantBubble.razor | 2 +- .../Molecules/IntelligenceToolbar.razor | 2 +- .../Molecules/IntelligenceToolbar.razor.css | 19 ++++ .../Organisms/KnowledgeGraph.razor.css | 29 ++--- .../Layout/MainLayout.razor.css | 2 +- .../Services/KnowledgeCoordinator.cs | 8 +- .../wwwroot/js/knowledgeGraph.js | 102 ++++++++++++++---- 11 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs index 8310339..2c6a4a2 100644 --- a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs +++ b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs @@ -3,4 +3,4 @@ using MediatR; namespace NexusReader.Application.Commands.Sync; -public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest; +public record UpdateReadingProgressCommand(string PageId, string UserId, string? ExcludedConnectionId = null) : IRequest; diff --git a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs index 1f5d82f..f82a82c 100644 --- a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs +++ b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs @@ -38,9 +38,18 @@ public class UpdateReadingProgressCommandHandler : IRequestHandler - diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css index 521ddd1..a5fc31e 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor.css @@ -75,3 +75,22 @@ color: #ff4d4d; background: rgba(255, 77, 77, 0.1); } + +.toolbar-item.logout-item { + margin-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 1.5rem; + height: auto; + width: 100%; + display: flex; + justify-content: center; + border-radius: 0; + color: #444; +} + +.toolbar-item.logout-item:hover { + color: #ff4d4d; + background: none; + filter: drop-shadow(0 0 8px rgba(255, 77, 77, 0.4)); +} + diff --git a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css index 5e7ea58..cdd58b8 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/KnowledgeGraph.razor.css @@ -12,28 +12,33 @@ .graph-controls { position: absolute; - bottom: 1rem; + bottom: 1.5rem; right: 1.5rem; display: flex; - flex-direction: column; - gap: 0.5rem; + flex-direction: row; + gap: 0.25rem; + background: rgba(20, 20, 20, 0.4); + backdrop-filter: blur(12px); + padding: 0.35rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); z-index: 10; } .zoom-btn { - width: 28px; - height: 28px; - background: rgba(18, 18, 18, 0.8); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: #888; - font-size: 1rem; + width: 32px; + height: 32px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 6px; + color: #aaa; + font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } .zoom-btn:hover { diff --git a/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css b/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css index 4d8621b..644093c 100644 --- a/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css +++ b/src/NexusReader.UI.Shared/Layout/MainLayout.razor.css @@ -33,7 +33,7 @@ main { height: 100%; background: #0d0d0d; box-shadow: -10px 0 30px rgba(0, 0, 0, 0.3); - border-left: 1px solid rgba(255, 255, 255, 0.05); + border-left: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden; z-index: 10; } diff --git a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs index eb90650..9de6c62 100644 --- a/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs +++ b/src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs @@ -79,7 +79,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable public async Task RequestSummaryAndQuizAsync(string content, string tenantId = "global") { - _quizService.SetHydrating(true); + await _quizService.SetHydrating(true); LogRequestingSummary(tenantId); try { @@ -91,7 +91,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable .Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex)) .ToList(); - _quizService.SetQuiz(null, new QuizDto(quizQuestions)); + await _quizService.SetQuiz(null, new QuizDto(quizQuestions)); await _platformService.VibrateSuccessAsync(); return packet; } @@ -104,7 +104,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable } finally { - _quizService.SetHydrating(false); + await _quizService.SetHydrating(false); } return null; } @@ -112,7 +112,7 @@ public sealed partial class KnowledgeCoordinator : IDisposable public async Task ClearAsync() { await _graphService.Clear(); - _quizService.SetQuiz(null, null); + await _quizService.SetQuiz(null, null); } public void Dispose() diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 8119ff0..968e052 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -1,10 +1,13 @@ import * as d3 from 'https://esm.sh/d3@7'; +const getDisplayLabel = d => d.label.length > 20 ? d.label.substring(0, 17) + "..." : d.label; +const getPillWidth = d => getDisplayLabel(d).length * 8 + 30; + let simulation; let zoomBehavior; let svgElement; -let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler; +let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; export function mount(containerId, data, dotNetHelper) { const container = document.getElementById(containerId); @@ -18,7 +21,8 @@ export function mount(containerId, data, dotNetHelper) { svgElement = d3.select(container).append("svg") .attr("viewBox", [0, 0, width, height]) .attr("width", "100%") - .attr("height", "100%"); + .attr("height", "100%") + .style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)"); // Radial gradient for Nebula effect const defs = svgElement.append("defs"); @@ -66,14 +70,19 @@ export function mount(containerId, data, dotNetHelper) { svgElement.call(zoomBehavior).on("wheel.zoom", null); - resizeHandler = () => handleResize(containerId); - window.addEventListener('resize', resizeHandler); + // Use ResizeObserver for more reliable container size tracking + resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + handleResize(containerId); + } + }); + resizeObserver.observe(container); simulation = d3.forceSimulation() .force("link", d3.forceLink().id(d => d.id).distance(120)) .force("charge", d3.forceManyBody().strength(-400)) .force("center", d3.forceCenter(width / 2, height / 2)) - .force("collide", d3.forceCollide().radius(50)); + .force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20)); simulation.on("tick", () => { if (link) { @@ -86,7 +95,14 @@ export function mount(containerId, data, dotNetHelper) { } if (node) { - node.attr("transform", d => `translate(${d.x},${d.y})`); + node.attr("transform", d => { + // Keep within bounds with padding + const pillWidth = getPillWidth(d); + const halfWidth = pillWidth / 2; + d.x = Math.max(halfWidth + 20, Math.min(width - halfWidth - 20, d.x)); + d.y = Math.max(35, Math.min(height - 35, d.y)); + return `translate(${d.x},${d.y})`; + }); } if (badge && badge.style("display") !== "none") { @@ -168,11 +184,11 @@ export function updateData(data) { g.append("rect") .attr("class", "node-pill") - .attr("x", d => -(d.label.length * 4 + 10)) - .attr("y", -12) - .attr("width", d => d.label.length * 8 + 20) - .attr("height", 24) - .attr("rx", 12) + .attr("x", d => -getPillWidth(d) / 2) + .attr("y", -15) + .attr("width", d => getPillWidth(d)) + .attr("height", 30) + .attr("rx", 15) .attr("fill", "rgba(20, 20, 20, 0.9)") .attr("stroke", d => { if (d.type === 'Definition') return 'var(--nexus-accent)'; @@ -182,11 +198,14 @@ export function updateData(data) { .attr("stroke-width", 1); g.append("text") - .text(d => d.label) + .text(d => getDisplayLabel(d)) .attr("text-anchor", "middle") - .attr("y", 4) + .attr("y", 5) .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("font-size", "0.8rem"); + + g.append("title") + .text(d => d.label); g.transition().duration(500).style("opacity", 1); @@ -199,6 +218,9 @@ export function updateData(data) { simulation.nodes(data.nodes); simulation.force("link").links(data.links); simulation.alpha(0.5).restart(); + + // Trigger zoom to fit after a short delay to allow simulation to settle + setTimeout(zoomToFit, 100); } function drag(simulation) { @@ -225,6 +247,7 @@ function drag(simulation) { export function setActiveNode(nodeId) { if (!svgElement || !node) return; + // Safety check: ensure we only target the first occurrence if IDs are duplicated const targetNode = node.filter(d => d.id === nodeId); if (targetNode.empty()) { dimNodes(null); @@ -232,20 +255,21 @@ export function setActiveNode(nodeId) { return; } - const d = targetNode.datum(); + const firstMatch = targetNode.filter((d, i) => i === 0); + const d = firstMatch.datum(); // Reset all active classes rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); - targetNode.select(".node-pill").classed("nexus-node-active", true); + firstMatch.select(".node-pill").classed("nexus-node-active", true); // Position badge badge.style("display", "block").datum(d); badge.attr("transform", `translate(${d.x},${d.y})`); - // Dim others + // Dim others (only exact matches for nodeId will be fully opaque) dimNodes(nodeId); - // Smooth transition + // Smooth transition to the first matching node svgElement.transition().duration(1000).call( zoomBehavior.transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y) @@ -274,8 +298,8 @@ export function unmount(containerId) { if (simulation) { simulation.stop(); } - if (resizeHandler) { - window.removeEventListener('resize', resizeHandler); + if (resizeObserver) { + resizeObserver.disconnect(); } const container = document.getElementById(containerId); if (container) { @@ -321,9 +345,43 @@ export function zoomOut() { } export function zoomReset() { - if (svgElement && zoomBehavior) { - svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity); - } + zoomToFit(); +} + +export function zoomToFit() { + if (!node || node.empty() || !svgElement || !zoomBehavior) return; + + // Get the actual bounding box of the nodes + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + node.each(d => { + const pw = getPillWidth(d) / 2; + minX = Math.min(minX, d.x - pw); + maxX = Math.max(maxX, d.x + pw); + minY = Math.min(minY, d.y - 15); + maxY = Math.max(maxY, d.y + 15); + }); + + if (minX === Infinity) return; + + const graphWidth = maxX - minX; + const graphHeight = maxY - minY; + const midX = (minX + maxX) / 2; + const midY = (minY + maxY) / 2; + + const padding = 60; + const scale = Math.min( + (width - padding) / graphWidth, + (height - padding) / graphHeight, + 1.2 // Max scale + ); + + svgElement.transition().duration(750).call( + zoomBehavior.transform, + d3.zoomIdentity + .translate(width / 2, height / 2) + .scale(scale) + .translate(-midX, -midY) + ); } export function clear() {