diff --git a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 42aa778..8ef2fa7 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -7,7 +7,7 @@ let simulation; let zoomBehavior; let svgElement; -let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler; +let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; export function mount(containerId, data, dotNetHelper) { const container = document.getElementById(containerId); @@ -69,8 +69,13 @@ export function mount(containerId, data, dotNetHelper) { svgElement.call(zoomBehavior).on("wheel.zoom", null); - resizeHandler = () => handleResize(containerId); - window.addEventListener('resize', resizeHandler); + // Use ResizeObserver for more reliable container size tracking + resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + handleResize(containerId); + } + }); + resizeObserver.observe(container); simulation = d3.forceSimulation() .force("link", d3.forceLink().id(d => d.id).distance(120)) @@ -89,7 +94,14 @@ export function mount(containerId, data, dotNetHelper) { } if (node) { - node.attr("transform", d => `translate(${d.x},${d.y})`); + node.attr("transform", d => { + // 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") { @@ -205,6 +217,9 @@ export function updateData(data) { simulation.nodes(data.nodes); simulation.force("link").links(data.links); simulation.alpha(0.5).restart(); + + // Trigger zoom to fit after a short delay to allow simulation to settle + setTimeout(zoomToFit, 100); } function drag(simulation) { @@ -280,8 +295,8 @@ export function unmount(containerId) { if (simulation) { simulation.stop(); } - if (resizeHandler) { - window.removeEventListener('resize', resizeHandler); + if (resizeObserver) { + resizeObserver.disconnect(); } const container = document.getElementById(containerId); if (container) { @@ -327,9 +342,43 @@ export function zoomOut() { } export function zoomReset() { - if (svgElement && zoomBehavior) { - svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity); - } + zoomToFit(); +} + +export function zoomToFit() { + if (!node || node.empty() || !svgElement || !zoomBehavior) 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 (minX === Infinity) return; + + const graphWidth = maxX - minX; + const graphHeight = maxY - minY; + 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 + ); + + svgElement.transition().duration(750).call( + zoomBehavior.transform, + d3.zoomIdentity + .translate(width / 2, height / 2) + .scale(scale) + .translate(-midX, -midY) + ); } export function clear() {