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>
670 lines
22 KiB
JavaScript
670 lines
22 KiB
JavaScript
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;
|
|
|
|
const getNodeType = d => {
|
|
if (d) {
|
|
if (d.type) {
|
|
const t = d.type.toLowerCase();
|
|
if (t === 'definition') return 'definition';
|
|
if (t === 'table') return 'table';
|
|
if (t === 'rule') return 'rule';
|
|
if (t === 'section') return 'section';
|
|
}
|
|
if (d.group) {
|
|
const g = d.group.toLowerCase();
|
|
if (g === 'definition') return 'definition';
|
|
if (g === 'table') return 'table';
|
|
if (g === 'rule') return 'rule';
|
|
if (g === 'section') return 'section';
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getNodeGroup = d => {
|
|
if (d && d.group) {
|
|
const g = d.group.toLowerCase();
|
|
if (g === 'bridge') return 'bridge';
|
|
if (g === 'current') return 'current';
|
|
if (g === 'concept') return 'concept';
|
|
}
|
|
return 'concept'; // fallback
|
|
};
|
|
|
|
const getCategoryStyle = d => {
|
|
const type = getNodeType(d);
|
|
const group = getNodeGroup(d);
|
|
|
|
// 1. Rule (red/coral)
|
|
if (type === 'rule') {
|
|
return {
|
|
color: '#ff4646',
|
|
fill: 'rgba(255, 70, 70, 0.1)',
|
|
opacity: 0.8,
|
|
glowKey: 'rule',
|
|
textColor: '#ff8b8b'
|
|
};
|
|
}
|
|
// 2. Definition (gold/amber)
|
|
if (type === 'definition') {
|
|
return {
|
|
color: '#ffb03a',
|
|
fill: 'rgba(255, 176, 58, 0.1)',
|
|
opacity: 0.8,
|
|
glowKey: 'definition',
|
|
textColor: '#ffd18c'
|
|
};
|
|
}
|
|
// 3. Table (purple/magenta)
|
|
if (type === 'table') {
|
|
return {
|
|
color: '#d946ef',
|
|
fill: 'rgba(217, 70, 239, 0.1)',
|
|
opacity: 0.8,
|
|
glowKey: 'table',
|
|
textColor: '#f5d0fe'
|
|
};
|
|
}
|
|
// 4. Section (blue/indigo)
|
|
if (type === 'section') {
|
|
return {
|
|
color: '#3b82f6',
|
|
fill: 'rgba(59, 130, 246, 0.1)',
|
|
opacity: 0.8,
|
|
glowKey: 'section',
|
|
textColor: '#93c5fd'
|
|
};
|
|
}
|
|
// 5. Bridge (cyan/comparison)
|
|
if (group === 'bridge') {
|
|
return {
|
|
color: '#06b6d4',
|
|
fill: 'rgba(6, 182, 212, 0.1)',
|
|
opacity: 0.7,
|
|
glowKey: 'bridge',
|
|
textColor: '#67e8f9'
|
|
};
|
|
}
|
|
// 6. Current (active/focus landmark - neon green)
|
|
if (group === 'current') {
|
|
return {
|
|
color: 'var(--nexus-neon)',
|
|
fill: 'rgba(0, 255, 153, 0.15)',
|
|
opacity: 0.9,
|
|
glowKey: 'current',
|
|
textColor: '#ffffff'
|
|
};
|
|
}
|
|
// 7. Concept / Default (subtle cool steel blue/teal)
|
|
return {
|
|
color: '#00d2c4',
|
|
fill: 'rgba(0, 210, 196, 0.05)',
|
|
opacity: 0.4,
|
|
glowKey: 'concept',
|
|
textColor: '#e0e0e0'
|
|
};
|
|
};
|
|
|
|
let simulation;
|
|
let zoomBehavior;
|
|
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;
|
|
|
|
currentDotNetHelper = dotNetHelper;
|
|
width = container.clientWidth || 400;
|
|
height = container.clientHeight || 400;
|
|
|
|
// Clean up any existing SVG to prevent duplicates
|
|
container.querySelectorAll("svg").forEach(el => el.remove());
|
|
|
|
// Create SVG
|
|
svgElement = d3.select(container).append("svg")
|
|
.attr("viewBox", [0, 0, width, height])
|
|
.attr("width", "100%")
|
|
.attr("height", "100%")
|
|
.style("background", "radial-gradient(circle, #1a1a1a 0%, #121212 100%)");
|
|
|
|
// Radial gradients for Nebula effects
|
|
const defs = svgElement.append("defs");
|
|
|
|
// Fallback radial gradient for legacy nebulaGlow
|
|
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);
|
|
|
|
const colors = {
|
|
'rule': '#ff4646',
|
|
'definition': '#ffb03a',
|
|
'table': '#d946ef',
|
|
'section': '#3b82f6',
|
|
'bridge': '#06b6d4',
|
|
'current': 'var(--nexus-neon)',
|
|
'concept': '#00d2c4'
|
|
};
|
|
|
|
Object.entries(colors).forEach(([key, color]) => {
|
|
const radGrad = defs.append("radialGradient")
|
|
.attr("id", `nebulaGlow-${key}`)
|
|
.attr("cx", "50%")
|
|
.attr("cy", "50%")
|
|
.attr("r", "50%");
|
|
radGrad.append("stop").attr("offset", "0%").attr("stop-color", color).attr("stop-opacity", 1);
|
|
radGrad.append("stop").attr("offset", "100%").attr("stop-color", color).attr("stop-opacity", 0);
|
|
});
|
|
|
|
// Root Group for Zoom
|
|
rootGroup = svgElement.append("g").attr("class", "zoom-containment");
|
|
|
|
// Container groups for links and nodes to keep order (links below nodes)
|
|
rootGroup.append("g").attr("class", "links-layer");
|
|
rootGroup.append("g").attr("class", "nodes-layer");
|
|
|
|
// Badge Element (TU JESTEŚ)
|
|
badge = rootGroup.append("g")
|
|
.attr("class", "active-badge")
|
|
.style("display", "none")
|
|
.style("pointer-events", "none");
|
|
|
|
badge.append("rect")
|
|
.attr("x", -35)
|
|
.attr("y", -35)
|
|
.attr("width", 70)
|
|
.attr("height", 20)
|
|
.attr("rx", 10)
|
|
.attr("fill", "var(--nexus-neon)");
|
|
|
|
badge.append("text")
|
|
.text("TU JESTEŚ")
|
|
.attr("text-anchor", "middle")
|
|
.attr("y", -21)
|
|
.attr("fill", "#000")
|
|
.attr("font-weight", "bold")
|
|
.attr("font-size", "0.6rem");
|
|
|
|
// Attach Zoom Behavior
|
|
zoomBehavior = d3.zoom()
|
|
.scaleExtent([0.3, 4])
|
|
.on("zoom", (e) => rootGroup.attr("transform", e.transform));
|
|
|
|
svgElement.call(zoomBehavior).on("wheel.zoom", null);
|
|
|
|
// Use ResizeObserver for more reliable container size tracking
|
|
resizeObserver = new ResizeObserver(entries => {
|
|
for (let entry of entries) {
|
|
handleResize(containerId);
|
|
}
|
|
});
|
|
resizeObserver.observe(container);
|
|
|
|
isMobileMode = window.innerWidth < 768;
|
|
|
|
simulation = d3.forceSimulation()
|
|
.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 => {
|
|
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) {
|
|
link.attr("d", d => {
|
|
const dx = d.target.x - d.source.x;
|
|
const dy = d.target.y - d.source.y;
|
|
const dr = Math.sqrt(dx * dx + dy * dy);
|
|
return `M${d.source.x},${d.source.y}A${dr},${dr} 0 0,1 ${d.target.x},${d.target.y}`;
|
|
});
|
|
}
|
|
|
|
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;
|
|
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") {
|
|
const activeData = badge.datum();
|
|
if (activeData) {
|
|
badge.attr("transform", `translate(${activeData.x},${activeData.y})`);
|
|
}
|
|
}
|
|
});
|
|
|
|
updateData(data);
|
|
}
|
|
|
|
export function updateData(data) {
|
|
if (!simulation || !rootGroup) return;
|
|
if (!data || !data.nodes) {
|
|
clear();
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
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;
|
|
}
|
|
});
|
|
|
|
// Sanitize links to filter out any references to non-existent nodes
|
|
const nodeIds = new Set(data.nodes.map(n => n.id));
|
|
const validLinks = (data.links || []).filter(l => {
|
|
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
|
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
return nodeIds.has(srcId) && nodeIds.has(tgtId);
|
|
});
|
|
|
|
// Update Links
|
|
link = rootGroup.select(".links-layer")
|
|
.selectAll("path")
|
|
.data(validLinks, d => {
|
|
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
return srcId + "-" + tgtId + "-" + d.type;
|
|
})
|
|
.join(
|
|
enter => enter.append("path")
|
|
.attr("stroke", d => {
|
|
if (d.type === 'Defines' || d.type === 'maps_to') return 'var(--nexus-accent, #00ffaa)';
|
|
if (d.type === 'Next' || d.type === 'relates_to') return 'rgba(255,255,255,0.2)';
|
|
if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)';
|
|
return 'rgba(255,255,255,0.1)';
|
|
})
|
|
.attr("fill", "none")
|
|
.attr("stroke-width", d => (d.type === 'Defines' || d.type === 'maps_to') ? 2 : 1)
|
|
.attr("stroke-dasharray", d => d.type === 'References' ? "5,5" : "0")
|
|
.style("opacity", 0)
|
|
.call(enter => enter.transition().duration(500).style("opacity", 1)),
|
|
update => update,
|
|
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
|
);
|
|
|
|
// Update Nodes
|
|
node = rootGroup.select(".nodes-layer")
|
|
.selectAll("g.node-group")
|
|
.data(data.nodes, d => d.id)
|
|
.join(
|
|
enter => {
|
|
const g = enter.append("g")
|
|
.attr("class", "node-group neon-flash-node")
|
|
.style("cursor", "pointer")
|
|
.style("opacity", 0)
|
|
.on("click", (e, d) => {
|
|
currentDotNetHelper.invokeMethodAsync('OnNodeClicked', d.id);
|
|
setActiveNode(d.id);
|
|
})
|
|
.call(drag(simulation));
|
|
|
|
g.append("circle")
|
|
.attr("r", 30)
|
|
.attr("fill", d => `url(#nebulaGlow-${getCategoryStyle(d).glowKey})`)
|
|
.attr("opacity", d => getCategoryStyle(d).opacity);
|
|
|
|
g.append("rect")
|
|
.attr("class", "node-pill")
|
|
.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")
|
|
.attr("text-anchor", "middle")
|
|
.attr("y", 5)
|
|
.attr("fill", d => getCategoryStyle(d).textColor);
|
|
|
|
g.append("title")
|
|
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
|
|
|
|
g.transition().duration(500).style("opacity", 1);
|
|
|
|
return g;
|
|
},
|
|
update => update.classed("neon-flash-node", false),
|
|
exit => exit.transition().duration(500).style("opacity", 0).remove()
|
|
);
|
|
|
|
updateNodeAppearances();
|
|
|
|
simulation.nodes(data.nodes);
|
|
simulation.force("link").links(validLinks);
|
|
simulation.alpha(0.5).restart();
|
|
|
|
// Trigger zoom to fit after a short delay to allow simulation to settle
|
|
setTimeout(zoomToFit, 100);
|
|
}
|
|
|
|
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 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()) {
|
|
dimNodes(null);
|
|
badge.style("display", "none");
|
|
return;
|
|
}
|
|
|
|
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);
|
|
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 (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,
|
|
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-d.x, -d.y)
|
|
);
|
|
}
|
|
|
|
export function dimNodes(activeNodeId) {
|
|
if (!node) return;
|
|
|
|
node.transition().duration(500)
|
|
.style("opacity", d => (activeNodeId === null || d.id === activeNodeId) ? 1 : 0.4);
|
|
|
|
if (link) {
|
|
link.transition().duration(500)
|
|
.style("opacity", d => {
|
|
if (activeNodeId === null) return 1;
|
|
// Check if this link is connected to the active node
|
|
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
return (sourceId === activeNodeId || targetId === activeNodeId) ? 1 : 0.1;
|
|
});
|
|
}
|
|
}
|
|
|
|
export function unmount(containerId) {
|
|
if (simulation) {
|
|
simulation.stop();
|
|
}
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect();
|
|
}
|
|
const container = document.getElementById(containerId);
|
|
if (container) {
|
|
container.innerHTML = ''; // clear svg
|
|
}
|
|
}
|
|
|
|
export function handleResize(containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container || !svgElement || !simulation) return;
|
|
|
|
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));
|
|
|
|
const prevMobileMode = isMobileMode;
|
|
isMobileMode = window.innerWidth < 768;
|
|
if (isMobileMode !== prevMobileMode) {
|
|
setMobileMode(isMobileMode);
|
|
} else {
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
}
|
|
|
|
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 zoomIn() {
|
|
if (svgElement && zoomBehavior) {
|
|
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 1.3);
|
|
}
|
|
}
|
|
|
|
export function zoomOut() {
|
|
if (svgElement && zoomBehavior) {
|
|
svgElement.transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
|
|
}
|
|
}
|
|
|
|
export function zoomReset() {
|
|
zoomToFit();
|
|
}
|
|
|
|
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 => {
|
|
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 || 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;
|
|
|
|
const padding = 60;
|
|
const scale = Math.min(
|
|
(width - padding) / graphWidth,
|
|
(height - padding) / graphHeight,
|
|
1.2 // Max scale
|
|
);
|
|
|
|
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
|
|
|
|
svgElement.transition().duration(750).call(
|
|
zoomBehavior.transform,
|
|
d3.zoomIdentity
|
|
.translate(width / 2, height / 2)
|
|
.scale(scale)
|
|
.translate(-midX, -midY)
|
|
);
|
|
}
|
|
|
|
export function clear() {
|
|
if (!rootGroup) return;
|
|
try {
|
|
rootGroup.select(".links-layer").selectAll("path").remove();
|
|
rootGroup.select(".nodes-layer").selectAll("g.node-group").remove();
|
|
if (badge) badge.style("display", "none");
|
|
if (simulation) {
|
|
simulation.stop();
|
|
const linkForce = simulation.force("link");
|
|
if (linkForce) {
|
|
linkForce.links([]);
|
|
}
|
|
simulation.nodes([]);
|
|
}
|
|
|
|
// Reset selections
|
|
link = null;
|
|
node = null;
|
|
|
|
// Reset D3 zoom transform to clean identity state
|
|
if (svgElement && zoomBehavior) {
|
|
svgElement.call(zoomBehavior.transform, d3.zoomIdentity);
|
|
}
|
|
} catch (e) {
|
|
console.warn("Failed to clear force simulation safely:", e);
|
|
}
|
|
}
|