feat(ui): implement premium mobile-first reader layout with three-tab bottom navigation and assistant FAB (#57)

This pull request delivers a comprehensive mobile-first user experience overhaul for the NexusReader SaaS platform, specifically optimizing the Reader Canvas, D3.js Knowledge Graph representation, Dashboard card grid layout, and the application-wide navigation shell on mobile viewports (< 768px).

### Key Enhancements:
1. **Interactive Three-Tab Bottom Navigation**: Added premium, frosted glassy bottom-bar for mobile viewports to switch between standard reading, D3.js graph visual workspace, and structural concept reviews/quizzes.
2. **Contextual Floating Action Button (FAB)**: Introduced the AI Assistant FAB on mobile canvas layout with responsive animation, state-synchronization to trigger corresponding quiz views, and pulsing badge notification when new quizzes are dynamically generated.
3. **Adaptive D3.js Simulation & Rendering**: Integrated `setMobileMode(isMobile)` logic inside the D3 simulation engine, optimizing forces, rendering compact glyph pills, and installing auto-resize observers.
4. **Architectural & Native AOT Cleanliness**: Clean separation of layouts, fully scoped CSS configurations, functional-safe event orchestration inside `IReaderInteractionService`, and zero compiler errors.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #57
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #57.
This commit is contained in:
2026-05-27 07:44:44 +00:00
committed by Marek Jaisński
parent a9a670d776
commit 30f445ea89
9 changed files with 947 additions and 111 deletions
@@ -113,6 +113,85 @@ let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
let isMobileMode = false;
let activeNodeId = null;
const getNodeGlyph = d => {
if (!d) return 'C';
const type = getNodeType(d);
const group = getNodeGroup(d);
if (type === 'rule') return '§';
if (type === 'definition') return 'D';
if (type === 'table') return 'T';
if (type === 'section') return 'S';
if (group === 'bridge') return 'B';
if (group === 'current') return '★';
return 'C';
};
function updateNodeAppearances() {
if (!node) return;
node.each(function(d) {
const g = d3.select(this);
const rect = g.select(".node-pill");
const text = g.select("text");
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
const showFull = !isMobileMode || isSelected || isCurrent;
if (showFull) {
rect.transition().duration(250)
.attr("x", -getPillWidth(d) / 2)
.attr("width", getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getDisplayLabel(d))
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
} else {
rect.transition().duration(250)
.attr("x", -15)
.attr("width", 30)
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getNodeGlyph(d))
.attr("font-size", "0.9rem")
.attr("font-weight", "bold");
}
});
}
export function setMobileMode(isMobile) {
isMobileMode = isMobile;
if (!simulation) return;
if (isMobile) {
simulation.force("charge", d3.forceManyBody().strength(-60));
simulation.force("link").distance(180);
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
} else {
simulation.force("charge", d3.forceManyBody().strength(-400));
simulation.force("link").distance(120);
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
}
updateNodeAppearances();
simulation.alpha(0.3).restart();
}
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
if (!container) return;
@@ -204,11 +283,21 @@ export function mount(containerId, data, dotNetHelper) {
});
resizeObserver.observe(container);
isMobileMode = window.innerWidth < 768;
simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
.force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
.force("collide", d3.forceCollide().radius(d => {
if (isMobileMode) {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) return (getPillWidth(d) / 2) + 15;
return 20;
}
return (getPillWidth(d) / 2) + 20;
}));
simulation.on("tick", () => {
if (link) {
@@ -317,22 +406,14 @@ export function updateData(data) {
g.append("rect")
.attr("class", "node-pill")
.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.95)")
.attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 5)
.attr("fill", d => getCategoryStyle(d).textColor)
.attr("font-size", "0.8rem")
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
.attr("fill", d => getCategoryStyle(d).textColor);
g.append("title")
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
@@ -345,6 +426,8 @@ export function updateData(data) {
exit => exit.transition().duration(500).style("opacity", 0).remove()
);
updateNodeAppearances();
simulation.nodes(data.nodes);
simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
@@ -377,6 +460,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) {
if (!svgElement || !node) return;
activeNodeId = nodeId;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) {
@@ -399,6 +483,20 @@ export function setActiveNode(nodeId) {
// Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId);
// Dynamic collision update if in mobile mode to expand active node
if (isMobileMode && simulation) {
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
}
updateNodeAppearances();
// Smooth transition to the first matching node
svgElement.transition().duration(1000).call(
zoomBehavior.transform,
@@ -446,7 +544,14 @@ export function handleResize(containerId) {
svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
const prevMobileMode = isMobileMode;
isMobileMode = window.innerWidth < 768;
if (isMobileMode !== prevMobileMode) {
setMobileMode(isMobileMode);
} else {
simulation.alpha(0.3).restart();
}
}
export function scrollToNode(id) {