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: 'var(--nexus-node-rule, #ff4646)', fill: 'var(--nexus-node-rule-bg, rgba(255, 70, 70, 0.1))', opacity: 0.8, glowKey: 'rule', textColor: 'var(--nexus-node-rule-text, #ff8b8b)' }; } // 2. Definition (gold/amber) if (type === 'definition') { return { color: 'var(--nexus-node-definition, #ffb03a)', fill: 'var(--nexus-node-definition-bg, rgba(255, 176, 58, 0.1))', opacity: 0.8, glowKey: 'definition', textColor: 'var(--nexus-node-definition-text, #ffd18c)' }; } // 3. Table (purple/magenta) if (type === 'table') { return { color: 'var(--nexus-node-table, #d946ef)', fill: 'var(--nexus-node-table-bg, rgba(217, 70, 239, 0.1))', opacity: 0.8, glowKey: 'table', textColor: 'var(--nexus-node-table-text, #f5d0fe)' }; } // 4. Section (blue/indigo) if (type === 'section') { return { color: 'var(--nexus-node-section, #3b82f6)', fill: 'var(--nexus-node-section-bg, rgba(59, 130, 246, 0.1))', opacity: 0.8, glowKey: 'section', textColor: 'var(--nexus-node-section-text, #93c5fd)' }; } // 5. Bridge (cyan/comparison) if (group === 'bridge') { return { color: 'var(--nexus-node-bridge, #06b6d4)', fill: 'var(--nexus-node-bridge-bg, rgba(6, 182, 212, 0.1))', opacity: 0.7, glowKey: 'bridge', textColor: 'var(--nexus-node-bridge-text, #67e8f9)' }; } // 6. Current (active/focus landmark - neon green) if (group === 'current') { return { color: 'var(--nexus-node-current, var(--nexus-neon))', fill: 'var(--nexus-node-current-bg, rgba(0, 255, 153, 0.15))', opacity: 0.9, glowKey: 'current', textColor: 'var(--nexus-node-current-text, #ffffff)' }; } // 7. Concept / Default (subtle cool steel blue/teal) return { color: 'var(--nexus-node-concept, #00d2c4)', fill: 'var(--nexus-node-concept-bg, rgba(0, 210, 196, 0.05))', opacity: 0.4, glowKey: 'concept', textColor: 'var(--nexus-node-concept-text, #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", "var(--nexus-graph-bg, 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': 'var(--nexus-node-rule, #ff4646)', 'definition': 'var(--nexus-node-definition, #ffb03a)', 'table': 'var(--nexus-node-table, #d946ef)', 'section': 'var(--nexus-node-section, #3b82f6)', 'bridge': 'var(--nexus-node-bridge, #06b6d4)', 'current': 'var(--nexus-node-current, var(--nexus-neon))', 'concept': 'var(--nexus-node-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 'var(--nexus-graph-link-secondary, rgba(255,255,255,0.2))'; if (d.type === 'Contains' || d.type === 'contains') return 'var(--nexus-neon)'; return 'var(--nexus-graph-link-default, 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", "var(--nexus-node-pill-bg, 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); } }