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; let simulation; let zoomBehavior; let svgElement; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; export function mount(containerId, data, dotNetHelper) { const container = document.getElementById(containerId); if (!container) return; currentDotNetHelper = dotNetHelper; width = container.clientWidth || 400; height = container.clientHeight || 400; // 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 gradient for Nebula effect const defs = svgElement.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 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); simulation = d3.forceSimulation() .force("link", d3.forceLink().id(d => d.id).distance(120)) .force("charge", d3.forceManyBody().strength(-400)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(d => (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 => { // 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 (oldNodes.has(d.id)) { const old = oldNodes.get(d.id); d.x = old.x; d.y = old.y; d.vx = old.vx; d.vy = old.vy; } }); // Update Links link = rootGroup.select(".links-layer") .selectAll("path") .data(data.links, d => d.source + "-" + d.target + "-" + d.relationType) .join( enter => enter.append("path") .attr("stroke", d => { if (d.relationType === 'Defines') return 'var(--nexus-accent)'; if (d.relationType === 'Next') return 'rgba(255,255,255,0.2)'; if (d.relationType === 'Contains') return 'var(--nexus-neon)'; return 'rgba(255,255,255,0.1)'; }) .attr("fill", "none") .attr("stroke-width", d => d.relationType === 'Defines' ? 2 : 1) .attr("stroke-dasharray", d => d.relationType === '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 => { if (d.type === 'Definition') return 'var(--nexus-accent)'; if (d.type === 'Table') return 'var(--nexus-neon)'; if (d.type === 'Rule') return '#ff4444'; return "url(#nebulaGlow)"; }) .attr("opacity", d => d.group === 'current' ? 0.6 : 0.2); 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.9)") .attr("stroke", d => { if (d.type === 'Definition') return 'var(--nexus-accent)'; if (d.type === 'Rule') return '#ff4444'; return "rgba(255, 255, 255, 0.1)"; }) .attr("stroke-width", 1); g.append("text") .text(d => getDisplayLabel(d)) .attr("text-anchor", "middle") .attr("y", 5) .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("font-size", "0.8rem"); g.append("title") .text(d => 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() ); 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) { 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; const targetNode = node.filter(d => d.id === nodeId); if (targetNode.empty()) { dimNodes(null); badge.style("display", "none"); return; } const d = targetNode.datum(); // Reset all active classes rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); targetNode.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 dimNodes(nodeId); // Smooth transition 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; width = container.clientWidth; height = container.clientHeight; svgElement.attr("viewBox", [0, 0, width, height]); simulation.force("center", d3.forceCenter(width / 2, height / 2)); 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; // 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() { if (!rootGroup) return; 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.nodes([]); simulation.force("link").links([]); simulation.stop(); } }