feat: Mobile-First Layout Redesign & D3.js Graph Stabilization (#58)

This PR implements a comprehensive mobile-first design overhaul for the Reader, Dashboard, and Navigation layouts.

### Key Accomplishments
1. **Dynamic Viewport Synchronization**: Installed robust `ResizeObserver` listener on the client side with automatic reactive toggling of `platform-mobile`/`platform-desktop` CSS classes.
2. **Tab Controller & Visibility Fixes**: Refactored visibility constraints in `ReaderLayout.razor.css` to prevent layout clipping and DOM bloat. Standardized the mobile tab content selectors to ensure active views display perfectly.
3. **D3.js Graph Stabilization**:
   * Added checks to bypass resize callbacks when the graph container is hidden (`clientWidth <= 0` or `clientHeight <= 0`).
   * Guarded coordination ticks, node focus transformations, and zoom transitions against `NaN` parameters.
4. **Interactive Mobile UX Enhancements**: Optimized touch target sizing (44px target bounds) and interactive transitions for a state-of-the-art visual presentation.

This has been successfully compiled and verified against the standard .NET 10 compilation gates.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #58
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #58.
This commit is contained in:
2026-05-27 09:56:09 +00:00
committed by Marek Jaisński
parent 30f445ea89
commit 76b828395d
14 changed files with 353 additions and 16 deletions
@@ -311,6 +311,8 @@ export function mount(containerId, data, dotNetHelper) {
if (node) {
node.attr("transform", d => {
if (d.x === undefined || isNaN(d.x) || !isFinite(d.x)) d.x = width / 2;
if (d.y === undefined || isNaN(d.y) || !isFinite(d.y)) d.y = height / 2;
// Keep within bounds with padding
const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2;
@@ -341,10 +343,12 @@ export function updateData(data) {
// Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
data.nodes.forEach(d => {
if (d.x !== undefined && (!isFinite(d.x) || isNaN(d.x))) d.x = undefined;
if (d.y !== undefined && (!isFinite(d.y) || isNaN(d.y))) d.y = undefined;
if (oldNodes.has(d.id)) {
const old = oldNodes.get(d.id);
d.x = old.x;
d.y = old.y;
if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
d.vx = old.vx;
d.vy = old.vy;
}
@@ -471,6 +475,7 @@ export function setActiveNode(nodeId) {
const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum();
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
// Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
@@ -539,8 +544,14 @@ export function handleResize(containerId) {
const container = document.getElementById(containerId);
if (!container || !svgElement || !simulation) return;
width = container.clientWidth;
height = container.clientHeight;
const newWidth = container.clientWidth;
const newHeight = container.clientHeight;
// If container is hidden (size is 0), skip resize to avoid collapsing coordinates to (0,0) or NaN
if (newWidth <= 0 || newHeight <= 0) return;
width = newWidth;
height = newHeight;
svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2));
@@ -585,21 +596,26 @@ export function zoomReset() {
export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return;
if (width <= 0 || height <= 0 || isNaN(width) || isNaN(height)) 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 (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
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;
if (minX === Infinity || maxX === minX || maxY === minY) return;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
@@ -610,6 +626,8 @@ export function zoomToFit() {
1.2 // Max scale
);
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
svgElement.transition().duration(750).call(
zoomBehavior.transform,
d3.zoomIdentity