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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user