From 8a2786333e20d1b7de186d54043bccd7c8398f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 9 May 2026 08:20:42 +0200 Subject: [PATCH 1/4] fix(d3/ai): implement pill-node geometry, text truncation, and resolve SignalR scroll loop [issue #21] --- .../Sync/UpdateReadingProgressCommand.cs | 2 +- .../UpdateReadingProgressCommandHandler.cs | 15 ++++++++++--- .../RealTime/SyncHub.cs | 2 +- .../Services/PromptRegistry.cs | 5 +++++ .../Molecules/AiAssistantBubble.razor | 2 +- .../Services/KnowledgeCoordinator.cs | 8 +++---- .../wwwroot/js/knowledgeGraph.js | 22 ++++++++++++------- 7 files changed, 38 insertions(+), 18 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 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..42aa778 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -1,5 +1,8 @@ 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; @@ -73,7 +76,7 @@ export function mount(containerId, data, dotNetHelper) { .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) { @@ -168,11 +171,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 +185,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); -- 2.52.0 From bd5d0fb5c4284d9a09cfe8e914e471dd9a3af4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 9 May 2026 08:25:04 +0200 Subject: [PATCH 2/4] fix(d3/ui): implement Zoom-to-Fit and Bound-Constrained Simulation [issue #22] --- .../wwwroot/js/knowledgeGraph.js | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 42aa778..8ef2fa7 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -7,7 +7,7 @@ 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); @@ -69,8 +69,13 @@ 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)) @@ -89,7 +94,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") { @@ -205,6 +217,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) { @@ -280,8 +295,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) { @@ -327,9 +342,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() { -- 2.52.0 From c77ff6f347c34fe0885e9b692ee789b41be33747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 9 May 2026 08:42:29 +0200 Subject: [PATCH 3/4] feat(ui): improve sidebar layering, visual hierarchy, and graph depth [issue #23] --- .../Molecules/IntelligenceToolbar.razor | 2 +- .../Molecules/IntelligenceToolbar.razor.css | 19 ++++++++++++ .../Organisms/KnowledgeGraph.razor.css | 29 +++++++++++-------- .../Layout/MainLayout.razor.css | 2 +- .../wwwroot/js/knowledgeGraph.js | 3 +- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index f603abb..9e4e831 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -38,7 +38,7 @@ - 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/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 8ef2fa7..62db8f0 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -21,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"); -- 2.52.0 From f319f2778697b9c74403658b2b73cf916c6aa601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 9 May 2026 09:01:47 +0200 Subject: [PATCH 4/4] fix(graph): resolve multi-node selection bug by enforcing unique concept IDs and hardening JS logic --- .../Services/PromptRegistry.cs | 3 ++- src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 2ba4759..766a75a 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -17,7 +17,8 @@ public static class PromptRegistry "You are an expert at information architecture. Extract key concepts and their relationships from the text to build a knowledge graph. " + "CRITICAL: Restrict 'label' to a maximum of 3 words. " + "CRITICAL: Extract a MAXIMUM of 15 key concepts/plot points and their relationships. " + - "CRITICAL: Each paragraph in the user text starts with [ID: some-id]. You MUST use these exact IDs as the 'id' for the nodes representing those blocks. " + + "CRITICAL: Each paragraph in the user text starts with [ID: some-id]. Use these IDs ONLY for nodes representing the blocks. " + + "CRITICAL: All other extracted 'concept' nodes MUST have unique, slug-style IDs based on their labels (e.g., 'dependency-injection'). " + "Include a 'current' node representing the block content itself if applicable. " + "CRITICAL: Limit the result to a MAXIMUM of 15 most relevant connections. " + "Return ONLY minified JSON. Schema: { \"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept|current\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } }"; diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 62db8f0..968e052 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -247,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); @@ -254,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) -- 2.52.0