21c9a66cce
# Description This Pull Request integrates the premium mobile-first layout enhancements, a responsive, full-bleed Reader Toolbar, and critical authorization flow stabilizations for the NexusReader Blazor application (targeting .NET 10 with Native AOT compatibility). Resolves #62 Resolves #63 Resolves #15 ## Key Changes ### 📱 1. Mobile-First Reader Layout & Toolbar - **Full-Bleed Responsive Layout**: Redesigned `ReaderLayout` to feature a premium mobile-first three-tab bottom navigation system (Chapters, Graph, Assistant) and a glassmorphic floating action button (FAB) for the AI assistant. - **Header & Escaping Routes**: Built `MobileReaderToolbar` with seamless exit paths back to the "Pulpit" (dashboard) and smooth transitions. - **Custom Iconography**: Added the custom `NexusIcon` component supporting dynamic theme styling and responsive layouts without relying on external CSS frameworks. ### 🔐 2. Authentication Flow UX Stabilization - **WASM Transition Hydration**: Implemented `AuthenticationStatePersister` and loading preloaders to eliminate authorization race conditions during Blazor WASM interactive hydration. - **AOT-Compatible JWT Validation**: Integrated a robust, AOT-compatible `JwtTokenValidator` with unit tests (`JwtTokenValidatorTests.cs`) to cleanly parse claims without throwing performance-heavy runtime exceptions. - **Secure Header Propagation**: Standardized token transmission in WASM (`AuthenticationHeaderHandler.cs`) and MAUI Hybrid client layers (`MobileAuthenticationHeaderHandler.cs`), ensuring cookies are correctly propagated. ### 📊 3. D3.js Knowledge Graph & Interaction - **Dynamic Viewport Synchronization**: Refactored `knowledgeGraph.js` to ensure the SVG graph behaves correctly under flexbox containment, handles panel expansion/collapse gracefully, and avoids infinite loop redraws. - **Interaction Hook**: Connected graph node clicks directly to chapter jumps via a new `IReaderInteractionService` abstraction. ### 🏗️ 4. Infrastructure & Central Package Management (CPM) - **Beta Deployment Configuration**: Added `.env.test.template`, `docker-compose.test.yml`, and `appsettings.Test.json` with hardened environment security guards. - **Docker-Compose Cache Optimization**: Maintained CPM consistency during multi-stage Docker builds. ## Verification & Build Results - Run a successful local build check: ```bash dotnet build NexusReader.slnx --no-restore ``` **Status**: Successfully completed with `0` compilation errors. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #61 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
154 lines
4.5 KiB
JavaScript
154 lines
4.5 KiB
JavaScript
import * as d3 from 'https://esm.sh/d3@7';
|
|
|
|
let simulation;
|
|
|
|
export function mount(containerId, data, dotNetHelper) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
const width = container.clientWidth || 400;
|
|
const height = container.clientHeight || 400;
|
|
|
|
// Clean up any existing SVG to prevent duplicates
|
|
container.querySelectorAll("svg").forEach(el => el.remove());
|
|
|
|
// Create SVG
|
|
const svg = d3.select(container).append("svg")
|
|
.attr("viewBox", [0, 0, width, height])
|
|
.attr("width", "100%")
|
|
.attr("height", "100%");
|
|
|
|
// Radial gradient for Nebula effect
|
|
const defs = svg.append("defs");
|
|
const radialGradient = defs.append("radialGradient")
|
|
.attr("id", "nebulaGlow")
|
|
.attr("cx", "50%")
|
|
.attr("cy", "50%")
|
|
.attr("r", "50%");
|
|
radialGradient.append("stop").attr("offset", "0%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 1);
|
|
radialGradient.append("stop").attr("offset", "100%").attr("stop-color", "var(--nexus-neon)").attr("stop-opacity", 0);
|
|
|
|
// Root Group for Zoom
|
|
const rootGroup = svg.append("g").attr("class", "zoom-containment");
|
|
|
|
// Attach Zoom Behavior
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.5, 4])
|
|
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
|
svg.call(zoom);
|
|
|
|
// Subtle Link Distance & Charge
|
|
simulation = d3.forceSimulation(data.nodes)
|
|
.force("link", d3.forceLink(data.links).id(d => d.id).distance(60))
|
|
.force("charge", d3.forceManyBody().strength(-150))
|
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
|
.force("collide", d3.forceCollide().radius(25));
|
|
|
|
// Links
|
|
const link = rootGroup.append("g")
|
|
.selectAll("line")
|
|
.data(data.links)
|
|
.join("line")
|
|
.attr("stroke", "#444")
|
|
.attr("stroke-opacity", 0.6)
|
|
.attr("stroke-width", 1.5);
|
|
|
|
// Nodes
|
|
const node = rootGroup.append("g")
|
|
.selectAll("g")
|
|
.data(data.nodes)
|
|
.join("g")
|
|
.style("cursor", "pointer")
|
|
.on("click", (e, d) => {
|
|
// Remove active state from all, add to clicked
|
|
node.select("circle.node-core").classed("nexus-node-active", false);
|
|
d3.select(e.currentTarget).select("circle.node-core").classed("nexus-node-active", true);
|
|
dotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
|
})
|
|
.call(drag(simulation));
|
|
|
|
// Outer glow for nodes
|
|
node.append("circle")
|
|
.attr("r", 14)
|
|
.attr("fill", "url(#nebulaGlow)")
|
|
.attr("opacity", 0.4);
|
|
|
|
// Core circle
|
|
node.append("circle")
|
|
.attr("class", "node-core")
|
|
.attr("r", 6)
|
|
.attr("fill", "#888")
|
|
.attr("stroke", "#222")
|
|
.attr("stroke-width", 2);
|
|
|
|
// Labels
|
|
node.append("text")
|
|
.text(d => d.label)
|
|
.attr("x", 12)
|
|
.attr("y", 4)
|
|
.attr("fill", "#ccc")
|
|
.attr("font-family", "var(--nexus-font-sans)")
|
|
.attr("font-size", "0.75rem");
|
|
|
|
simulation.on("tick", () => {
|
|
link
|
|
.attr("x1", d => d.source.x)
|
|
.attr("y1", d => d.source.y)
|
|
.attr("x2", d => d.target.x)
|
|
.attr("y2", d => d.target.y);
|
|
|
|
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
|
});
|
|
}
|
|
|
|
function drag(simulation) {
|
|
function dragstarted(event) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
event.subject.fx = event.subject.x;
|
|
event.subject.fy = event.subject.y;
|
|
}
|
|
function dragged(event) {
|
|
event.subject.fx = event.x;
|
|
event.subject.fy = event.y;
|
|
}
|
|
function dragended(event) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
event.subject.fx = null;
|
|
event.subject.fy = null;
|
|
}
|
|
return d3.drag()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended);
|
|
}
|
|
|
|
export function unmount(containerId) {
|
|
if (simulation) {
|
|
simulation.stop();
|
|
}
|
|
const container = document.getElementById(containerId);
|
|
if (container) {
|
|
container.innerHTML = ''; // clear svg
|
|
}
|
|
}
|
|
|
|
export function scrollToNode(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}
|
|
|
|
export function pause() {
|
|
if (simulation) {
|
|
simulation.stop();
|
|
}
|
|
}
|
|
|
|
export function resume() {
|
|
if (simulation) {
|
|
// give it a gentle kick to settle if moved
|
|
simulation.alphaTarget(0.1).restart();
|
|
}
|
|
}
|