f18663426b
Reorganized the reader toolbar and layout grid to improve visual consistency and layout robustness in Focus Mode. Fixed outline SVG rendering bugs that caused icons to show as solid dots. Closes #70 --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #69 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
670 lines
23 KiB
JavaScript
670 lines
23 KiB
JavaScript
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);
|
|
}
|
|
}
|