import * as d3 from 'https://esm.sh/d3@7'; let simulation; let zoomBehavior; let svgElement; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeHandler; 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%"); // 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); resizeHandler = () => handleResize(containerId); window.addEventListener('resize', resizeHandler); 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(50)); 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 => `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") .call(e => e.transition().duration(500).attr("opacity", 1)), update => update, exit => exit.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") .style("cursor", "pointer") .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", 0) .transition().duration(1000).attr("opacity", d => d.group === 'current' ? 0.6 : 0.2); g.append("rect") .attr("class", "node-pill") .attr("x", d => -(d.label.length * 4 + 10)) .attr("y", -12) .attr("width", d => d.label.length * 8 + 20) .attr("height", 24) .attr("rx", 12) .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 => d.label) .attr("text-anchor", "middle") .attr("y", 4) .attr("fill", d => d.type === 'Definition' ? 'var(--nexus-accent)' : '#ccc') .attr("font-size", "0.8rem"); return g; }, update => update, exit => exit.remove() ); simulation.nodes(data.nodes); simulation.force("link").links(data.links); simulation.alpha(0.5).restart(); } 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()) 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})`); // 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 unmount(containerId) { if (simulation) { simulation.stop(); } if (resizeHandler) { window.removeEventListener('resize', resizeHandler); } 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() { if (svgElement && zoomBehavior) { svgElement.transition().duration(500).call(zoomBehavior.transform, d3.zoomIdentity); } } 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(); } }