312 lines
10 KiB
JavaScript
312 lines
10 KiB
JavaScript
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();
|
|
}
|
|
}
|