forked from enviPath/enviPy
Initial bayer app Show Pack Classification Adjusted docker compose to bayer specifics Adjusted Dockerfile for Bayer Adding secret flags to group, add secret pools to packages Adjusted View for Package creation Prep configs, added Package Create Modal wip More on PES wip wip Wip minor PW interactions API PES wip Make Select Widget reflect required make required generallay available Update UI if pathway mode is set to build Added ais circle adjustments Initial Zoom, fix AD Creation wip
814 lines
30 KiB
JavaScript
814 lines
30 KiB
JavaScript
console.log("loaded pw.js")
|
|
|
|
function predictFromNode(url) {
|
|
fetch("", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
|
|
},
|
|
body: new URLSearchParams({node: url})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log("Success:", data);
|
|
window.location.href = data.success;
|
|
})
|
|
.catch(error => {
|
|
console.error("Error:", error);
|
|
});
|
|
}
|
|
|
|
// data = {{ pathway.d3_json | safe }};
|
|
// elem = 'vizdiv'
|
|
function draw(pathway, elem) {
|
|
const initialzoom = 2.5
|
|
const nodeRadius = 20;
|
|
const linkDistance = 100;
|
|
const chargeStrength = -200;
|
|
const depthSpacing = 150;
|
|
const width = document.getElementById(elem).offsetWidth, height = width * 0.75;
|
|
|
|
function assignPositions(nodes) {
|
|
const levelSpacing = 75; // vertical space between levels
|
|
const horizontalSpacing = 75; // horizontal space between nodes
|
|
const depthMap = new Map();
|
|
|
|
// Sort nodes by depth first to minimize crossings
|
|
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
|
|
|
|
sortedNodes.forEach(node => {
|
|
if (!depthMap.has(node.depth)) {
|
|
depthMap.set(node.depth, 0);
|
|
}
|
|
|
|
const nodesInLevel = nodes.filter(n => n.depth === node.depth).length;
|
|
|
|
// For pseudo nodes, try to position them to minimize crossings
|
|
if (node.pseudo) {
|
|
const parentLinks = links.filter(l => l.target.id === node.id);
|
|
const childLinks = links.filter(l => l.source.id === node.id);
|
|
|
|
if (parentLinks.length > 0 && childLinks.length > 0) {
|
|
const parentX = parentLinks[0].source.x || (width / 2);
|
|
const childrenX = childLinks.map(l => l.target.x || (width / 2));
|
|
const avgChildX = childrenX.reduce((sum, x) => sum + x, 0) / childrenX.length;
|
|
|
|
// Position pseudo node between parent and average child position
|
|
node.fx = (parentX + avgChildX) / 2;
|
|
} else {
|
|
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
|
|
}
|
|
} else {
|
|
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
|
|
}
|
|
|
|
node.fy = (node.depth + initialzoom + 0.5) * levelSpacing + 50;
|
|
depthMap.set(node.depth, depthMap.get(node.depth) + 1);
|
|
});
|
|
}
|
|
|
|
// Function to update pseudo node positions based on connected nodes
|
|
function updatePseudoNodePositions() {
|
|
nodes.forEach(node => {
|
|
if (node.pseudo && !node.isDragging) { // Don't auto-update if being dragged
|
|
const parentLinks = links.filter(l => l.target.id === node.id);
|
|
const childLinks = links.filter(l => l.source.id === node.id);
|
|
|
|
if (parentLinks.length > 0 && childLinks.length > 0) {
|
|
const parent = parentLinks[0].source;
|
|
const children = childLinks.map(l => l.target);
|
|
|
|
// Calculate optimal position to minimize crossing
|
|
const parentX = parent.x;
|
|
const parentY = parent.y;
|
|
const childrenX = children.map(c => c.x);
|
|
const childrenY = children.map(c => c.y);
|
|
const avgChildX = d3.mean(childrenX);
|
|
const avgChildY = d3.mean(childrenY);
|
|
|
|
// Position pseudo node between parent and average child position
|
|
node.fx = (parentX + avgChildX) / 2;
|
|
node.fy = (parentY + avgChildY) / 2; // Allow vertical movement too
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// Enhanced ticked function
|
|
function ticked() {
|
|
// Update pseudo node positions first
|
|
updatePseudoNodePositions();
|
|
|
|
link.attr("d", d => {
|
|
// Check if it's a self-loop (source equals target)
|
|
if (d.source.id === d.target.id) {
|
|
// Create a bezier curve for self-loops
|
|
const x = d.source.x;
|
|
const y = d.source.y;
|
|
const loopRadius = nodeRadius * 2; // Adjust size of the loop
|
|
|
|
// Create a circular path to the left of the node
|
|
return `M ${x},${y - nodeRadius}
|
|
C ${x - loopRadius},${y - nodeRadius - loopRadius}
|
|
${x - loopRadius},${y + nodeRadius + loopRadius}
|
|
${x},${y + nodeRadius}`;
|
|
} else {
|
|
// Regular straight line for normal edges
|
|
return `M ${d.source.x},${d.source.y} L ${d.target.x},${d.target.y}`;
|
|
}
|
|
});
|
|
|
|
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
|
}
|
|
|
|
function dragstarted(event, d) {
|
|
// Prevent zoom pan when dragging nodes
|
|
event.sourceEvent.stopPropagation();
|
|
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
|
|
// Mark if this node is being dragged
|
|
d.isDragging = true;
|
|
|
|
// If dragging a non-pseudo node, mark connected pseudo nodes for update
|
|
if (!d.pseudo) {
|
|
markConnectedPseudoNodes(d);
|
|
}
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
// Prevent zoom pan when dragging nodes
|
|
event.sourceEvent.stopPropagation();
|
|
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
|
|
// Update connected pseudo nodes in real-time
|
|
if (!d.pseudo) {
|
|
updateConnectedPseudoNodes(d);
|
|
}
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
// Prevent zoom pan when dragging nodes
|
|
event.sourceEvent.stopPropagation();
|
|
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
|
|
// Mark that dragging has ended
|
|
d.isDragging = false;
|
|
|
|
// Final update of connected pseudo nodes
|
|
if (!d.pseudo) {
|
|
updateConnectedPseudoNodes(d);
|
|
}
|
|
}
|
|
|
|
// Helper function to mark connected pseudo nodes
|
|
function markConnectedPseudoNodes(draggedNode) {
|
|
// Find pseudo nodes connected to this node
|
|
const connectedPseudos = new Set();
|
|
|
|
// Check as parent of pseudo nodes
|
|
links.filter(l => l.source.id === draggedNode.id && l.target.pseudo)
|
|
.forEach(l => connectedPseudos.add(l.target));
|
|
|
|
// Check as child of pseudo nodes
|
|
links.filter(l => l.target.id === draggedNode.id && l.source.pseudo)
|
|
.forEach(l => connectedPseudos.add(l.source));
|
|
|
|
return connectedPseudos;
|
|
}
|
|
|
|
// Helper function to update connected pseudo nodes
|
|
function updateConnectedPseudoNodes(draggedNode) {
|
|
const connectedPseudos = markConnectedPseudoNodes(draggedNode);
|
|
|
|
connectedPseudos.forEach(pseudoNode => {
|
|
if (!pseudoNode.isDragging) { // Don't update if pseudo node is being dragged
|
|
const parentLinks = links.filter(l => l.target.id === pseudoNode.id);
|
|
const childLinks = links.filter(l => l.source.id === pseudoNode.id);
|
|
|
|
if (parentLinks.length > 0 && childLinks.length > 0) {
|
|
const parent = parentLinks[0].source;
|
|
const children = childLinks.map(l => l.target);
|
|
|
|
const parentX = parent.fx || parent.x;
|
|
const parentY = parent.fy || parent.y;
|
|
const childrenX = children.map(c => c.fx || c.x);
|
|
const childrenY = children.map(c => c.fy || c.y);
|
|
const avgChildX = d3.mean(childrenX);
|
|
const avgChildY = d3.mean(childrenY);
|
|
|
|
// Update pseudo node position - allow both X and Y movement
|
|
pseudoNode.fx = (parentX + avgChildX) / 2;
|
|
pseudoNode.fy = (parentY + avgChildY) / 2;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Restart simulation with lower alpha to smooth the transition
|
|
simulation.alpha(0.1).restart();
|
|
}
|
|
|
|
|
|
// t -> ref to "this" from d3
|
|
function nodeClick(event, node, t) {
|
|
console.log(node);
|
|
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
|
|
}
|
|
|
|
// Wait before showing popup (ms)
|
|
var popupWaitBeforeShow = 1000;
|
|
|
|
// Custom popover element
|
|
let popoverTimeout = null;
|
|
|
|
function createPopover() {
|
|
const popover = document.createElement('div');
|
|
popover.id = 'custom-popover';
|
|
popover.className = 'fixed z-50';
|
|
popover.style.cssText = `
|
|
background: #ffffff;
|
|
border: 1px solid #d1d5db;
|
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
max-width: 320px;
|
|
padding: 0.75rem;
|
|
border-radius: 0.5rem;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 150ms ease-in-out, visibility 150ms ease-in-out;
|
|
pointer-events: auto;
|
|
`;
|
|
popover.setAttribute('role', 'tooltip');
|
|
popover.innerHTML = `
|
|
<div class="font-semibold mb-2 popover-title" style="font-weight: 600; margin-bottom: 0.5rem;"></div>
|
|
<div class="text-sm popover-content" style="font-size: 0.875rem;"></div>
|
|
`;
|
|
|
|
// Add styles for content images
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
#custom-popover img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 0.5rem 0;
|
|
}
|
|
#custom-popover a {
|
|
color: #2563eb;
|
|
text-decoration: none;
|
|
}
|
|
#custom-popover a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
`;
|
|
if (!document.getElementById('popover-styles')) {
|
|
style.id = 'popover-styles';
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Keep popover open when hovering over it
|
|
popover.addEventListener('mouseenter', () => {
|
|
if (popoverTimeout) {
|
|
clearTimeout(popoverTimeout);
|
|
popoverTimeout = null;
|
|
}
|
|
});
|
|
|
|
popover.addEventListener('mouseleave', () => {
|
|
hidePopover();
|
|
});
|
|
|
|
document.body.appendChild(popover);
|
|
return popover;
|
|
}
|
|
|
|
function getPopover() {
|
|
return document.getElementById('custom-popover') || createPopover();
|
|
}
|
|
|
|
function showPopover(element, title, content) {
|
|
const popover = getPopover();
|
|
popover.querySelector('.popover-title').textContent = title;
|
|
popover.querySelector('.popover-content').innerHTML = content;
|
|
|
|
// Make visible to measure
|
|
popover.style.visibility = 'hidden';
|
|
popover.style.opacity = '0';
|
|
popover.style.display = 'block';
|
|
|
|
// Smart positioning - avoid viewport overflow
|
|
const padding = 10;
|
|
const popoverRect = popover.getBoundingClientRect();
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
let left = clientX + 15;
|
|
let top = clientY - (popoverRect.height / 2);
|
|
|
|
// Prevent right overflow
|
|
if (left + popoverRect.width > viewportWidth - padding) {
|
|
left = clientX - popoverRect.width - 15;
|
|
}
|
|
|
|
// Prevent bottom overflow
|
|
if (top + popoverRect.height > viewportHeight - padding) {
|
|
top = viewportHeight - popoverRect.height - padding;
|
|
}
|
|
|
|
// Prevent top overflow
|
|
if (top < padding) {
|
|
top = padding;
|
|
}
|
|
|
|
popover.style.top = `${top}px`;
|
|
popover.style.left = `${left}px`;
|
|
popover.style.visibility = 'visible';
|
|
popover.style.opacity = '1';
|
|
|
|
currentElement = element;
|
|
}
|
|
|
|
function hidePopover() {
|
|
const popover = getPopover();
|
|
popover.style.opacity = '0';
|
|
popover.style.visibility = 'hidden';
|
|
currentElement = null;
|
|
}
|
|
|
|
function pop_add(objects, title, contentFunction) {
|
|
objects.each(function (d) {
|
|
const element = this;
|
|
|
|
element.addEventListener('mouseenter', () => {
|
|
if (popoverTimeout) clearTimeout(popoverTimeout);
|
|
|
|
popoverTimeout = setTimeout(() => {
|
|
if (element.matches(':hover')) {
|
|
const content = contentFunction(d);
|
|
showPopover(element, title, content);
|
|
}
|
|
}, popupWaitBeforeShow);
|
|
});
|
|
|
|
element.addEventListener('mouseleave', () => {
|
|
if (popoverTimeout) {
|
|
clearTimeout(popoverTimeout);
|
|
popoverTimeout = null;
|
|
}
|
|
|
|
// Delay hide to allow moving to popover
|
|
setTimeout(() => {
|
|
const popover = getPopover();
|
|
if (!popover.matches(':hover') && !element.matches(':hover')) {
|
|
hidePopover();
|
|
}
|
|
}, 100);
|
|
});
|
|
});
|
|
}
|
|
|
|
function node_popup(n) {
|
|
popupContent = "";
|
|
|
|
if (timeseriesViewEnabled && n.timeseries && n.timeseries.measurements) {
|
|
for (var s of n.scenarios) {
|
|
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
|
|
}
|
|
|
|
popupContent += '<div style="width:100%;height:120px"><canvas id="ts-popover-canvas"></canvas></div>';
|
|
const tsMeasurements = n.timeseries.measurements;
|
|
setTimeout(() => {
|
|
const canvas = document.getElementById('ts-popover-canvas');
|
|
if (canvas && window.Chart) {
|
|
const valid = tsMeasurements
|
|
.filter(m => m.timestamp != null && m.value != null)
|
|
.map(m => ({ ...m, timestamp: typeof m.timestamp === 'number' ? m.timestamp : new Date(m.timestamp).getTime() }))
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
const datasets = [];
|
|
|
|
// Error band (lower + upper with fill between)
|
|
const withErrors = valid.filter(m => m.error != null && m.error > 0);
|
|
if (withErrors.length > 0) {
|
|
datasets.push({
|
|
data: withErrors.map(m => ({ x: m.timestamp, y: m.value - m.error })),
|
|
borderColor: 'rgba(59,130,246,0.3)',
|
|
backgroundColor: 'rgba(59,130,246,0.15)',
|
|
pointRadius: 0,
|
|
fill: false,
|
|
tension: 0.1,
|
|
});
|
|
datasets.push({
|
|
data: withErrors.map(m => ({ x: m.timestamp, y: m.value + m.error })),
|
|
borderColor: 'rgba(59,130,246,0.3)',
|
|
backgroundColor: 'rgba(59,130,246,0.15)',
|
|
pointRadius: 0,
|
|
fill: '-1',
|
|
tension: 0.1,
|
|
});
|
|
}
|
|
|
|
// Main value line
|
|
datasets.push({
|
|
data: valid.map(m => ({ x: m.timestamp, y: m.value })),
|
|
borderColor: 'rgb(59,130,246)',
|
|
pointRadius: 0,
|
|
tension: 0.1,
|
|
fill: false,
|
|
});
|
|
|
|
new Chart(canvas.getContext('2d'), {
|
|
type: 'line',
|
|
data: { datasets },
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { enabled: false },
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: 'linear',
|
|
ticks: { font: { size: 10 } },
|
|
title: { display: false },
|
|
},
|
|
y: {
|
|
ticks: { font: { size: 10 } },
|
|
title: { display: false },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}, 0);
|
|
|
|
return popupContent;
|
|
}
|
|
|
|
if (n.stereo_removed) {
|
|
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
|
|
}
|
|
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>";
|
|
popupContent += "Depth " + n.depth + "<br>"
|
|
|
|
if (appDomainViewEnabled) {
|
|
if (n.app_domain != null) {
|
|
popupContent += "This compound is " + (n.app_domain['inside_app_domain'] ? "inside" : "outside") + " the Applicability Domain derived from the chemical (PCA) space constructed using the training data." + "<br>"
|
|
if (n.app_domain['uncovered_functional_groups']) {
|
|
popupContent += "Compound contains functional groups not covered by the training set <br>"
|
|
}
|
|
}
|
|
}
|
|
|
|
if (predictedPropertyViewEnabled) {
|
|
|
|
var tempContent = "";
|
|
|
|
if (Object.keys(n.predicted_properties).length > 0) {
|
|
|
|
if ("PepperPrediction" in n.predicted_properties) {
|
|
// TODO needs to be generic once we store it as AddInf
|
|
for (var s of n.predicted_properties["PepperPrediction"]) {
|
|
if (s["mean"] != null) {
|
|
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + " days<br>"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tempContent === "") {
|
|
tempContent = "<b>No predicted properties for this Node</b><br>";
|
|
}
|
|
|
|
popupContent += tempContent
|
|
}
|
|
|
|
popupContent += "<img src='" + n.image + "'><br>"
|
|
if (n.scenarios.length > 0) {
|
|
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
|
for (var s of n.scenarios) {
|
|
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
|
|
}
|
|
}
|
|
|
|
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
|
|
if (pathway.isIncremental && isLeaf) {
|
|
popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
|
|
}
|
|
|
|
return popupContent;
|
|
}
|
|
|
|
function edge_popup(e) {
|
|
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
|
|
|
|
if (e.reaction.rules) {
|
|
for (var rule of e.reaction.rules) {
|
|
popupContent += "Rule <a href='" + rule.url + "'>" + rule.name + "</a><br>";
|
|
}
|
|
}
|
|
|
|
if (e.app_domain) {
|
|
adcontent = "<p>";
|
|
if (e.app_domain["times_triggered"]) {
|
|
adcontent += "This rule triggered " + e.app_domain["times_triggered"] + " times in the training set<br>";
|
|
}
|
|
adcontent += "Reliability " + e.app_domain["reliability"].toFixed(2) + " (" + (e.app_domain["reliability"] > e.app_domain["reliability_threshold"] ? ">" : "<") + " Reliability Threshold of " + e.app_domain["reliability_threshold"] + ")<br>";
|
|
adcontent += "Local Compatibility " + e.app_domain["local_compatibility"].toFixed(2) + " (" + (e.app_domain["local_compatibility"] > e.app_domain["local_compatibility_threshold"] ? ">" : "<") + " Local Compatibility Threshold of " + e.app_domain["local_compatibility_threshold"] + ")<br>";
|
|
adcontent += "</p>";
|
|
popupContent += adcontent;
|
|
}
|
|
|
|
popupContent += "<img src='" + e.image + "'><br>"
|
|
if (e.reaction_probability) {
|
|
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
|
|
}
|
|
|
|
if (e.scenarios.length > 0) {
|
|
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
|
for (var s of e.scenarios) {
|
|
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
|
|
}
|
|
}
|
|
|
|
return popupContent;
|
|
}
|
|
|
|
var clientX;
|
|
var clientY;
|
|
document.addEventListener('mousemove', function (event) {
|
|
clientX = event.clientX;
|
|
clientY = event.clientY;
|
|
});
|
|
|
|
const zoomable = d3.select("#zoomable");
|
|
const svg = d3.select("#pwsvg");
|
|
const container = d3.select("#vizdiv");
|
|
|
|
// Set explicit SVG dimensions for proper zoom behavior
|
|
svg.attr("width", width)
|
|
.attr("height", height);
|
|
|
|
// Add background rectangle FIRST to enable pan/zoom on empty space
|
|
// This must be inserted before zoomable group so it's behind everything
|
|
svg.insert("rect", "#zoomable")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", width)
|
|
.attr("height", height)
|
|
.attr("fill", "transparent")
|
|
.attr("pointer-events", "all")
|
|
.style("cursor", "grab");
|
|
|
|
// Zoom Funktion aktivieren
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.5, 5])
|
|
.on("zoom", (event) => {
|
|
zoomable.attr("transform", event.transform);
|
|
})
|
|
|
|
|
|
// Apply zoom to the SVG element - this enables wheel zoom
|
|
svg.call(zoom);
|
|
svg.call(zoom.scaleBy, initialzoom);
|
|
// Also apply zoom to container to catch events that might not reach SVG
|
|
// This ensures drag-to-pan works even when clicking on empty space
|
|
container.call(zoom);
|
|
|
|
nodes = pathway['nodes'];
|
|
links = pathway['links'];
|
|
|
|
// Use "max" depth for a neater viz
|
|
nodes.forEach(n => {
|
|
const parents = links
|
|
.filter(l => l.target === n.id)
|
|
.map(l => l.source);
|
|
|
|
orig_depth = n.depth
|
|
// console.log(n.id, parents)
|
|
for (idx in parents) {
|
|
p = nodes[parents[idx]]
|
|
// console.log(p.depth)
|
|
// if (p.depth >= n.depth) {
|
|
// // keep the .5 steps for pseudo nodes
|
|
// n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1);
|
|
// // console.log("Adjusting", orig_depth, Math.floor(p.depth + 1));
|
|
// }
|
|
}
|
|
});
|
|
|
|
assignPositions(nodes);
|
|
|
|
const simulation = d3.forceSimulation(nodes)
|
|
.force("link", d3.forceLink(links).id(d => d.id).distance(linkDistance))
|
|
.force("charge", d3.forceManyBody().strength(chargeStrength))
|
|
.force("center", d3.forceCenter(width / 2, height / 4))
|
|
.on("tick", ticked);
|
|
|
|
// Kanten zeichnen
|
|
const link = zoomable.append("g")
|
|
.selectAll("path")
|
|
.data(links)
|
|
.enter().append("path")
|
|
// Check if target is pseudo and draw marker only if not pseudo
|
|
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
|
|
.attr("marker-end", d => {
|
|
if (d.target.pseudo) return '';
|
|
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops
|
|
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
|
|
})
|
|
.attr("fill", "none")
|
|
.on("click", function(event, d) {
|
|
const wasHighlighted = d3.select(this).classed("highlighted");
|
|
|
|
d3.selectAll("path").classed("highlighted", false);
|
|
|
|
if (!wasHighlighted) {
|
|
const toHighlight = [];
|
|
toHighlight.push(d.el);
|
|
|
|
if (d.source.pseudo || d.target.pseudo) {
|
|
if (d.target.pseudo) {
|
|
d3.selectAll("path").each(e => {
|
|
if (e !== undefined && e.source.id === d.target.id) {
|
|
toHighlight.push(e.el);
|
|
}
|
|
});
|
|
} else {
|
|
d3.selectAll("path").each(e => {
|
|
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
|
|
toHighlight.push(e.el);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const e of toHighlight) {
|
|
d3.select(e).classed("highlighted", true);
|
|
}
|
|
}
|
|
})
|
|
|
|
// add element to links array
|
|
link.each(function (d) {
|
|
d.el = this; // attach the DOM element to the data object
|
|
});
|
|
|
|
pop_add(link, "Reaction", edge_popup);
|
|
|
|
// Knoten zeichnen
|
|
const node = zoomable.append("g")
|
|
.selectAll("g")
|
|
.data(nodes)
|
|
.enter().append("g")
|
|
.call(d3.drag()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended))
|
|
.on("click", function (event, d) {
|
|
const wasHighlighted = d3.select(this).select("circle").classed("highlighted");
|
|
|
|
d3.selectAll('circle.highlighted').classed('highlighted', false);
|
|
|
|
if (!wasHighlighted) {
|
|
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
|
|
}
|
|
})
|
|
|
|
// Kreise für die Knoten hinzufügen
|
|
node.append("circle")
|
|
// make radius "invisible" for pseudo nodes
|
|
.attr("r", d => d.pseudo ? 0.01 : nodeRadius)
|
|
.style("fill", d => d.is_engineered_intermediate ? "#42eff5" : "#e8e8e8");
|
|
|
|
// Add image only for non pseudo nodes
|
|
node.filter(d => !d.pseudo).each(function (d, i) {
|
|
const g = d3.select(this);
|
|
|
|
if (d.image_type === "svg") {
|
|
// Parse the SVG string
|
|
const parser = new DOMParser();
|
|
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
|
|
const svgElem = svgDoc.documentElement;
|
|
|
|
// Create a unique prefix per node
|
|
const prefix = `node-${i}-`;
|
|
|
|
// Rename all IDs and fix <use> references
|
|
svgElem.querySelectorAll("[id]").forEach(el => {
|
|
const oldId = el.id;
|
|
const newId = prefix + oldId;
|
|
el.id = newId;
|
|
|
|
const XLINK_NS = "http://www.w3.org/1999/xlink";
|
|
// Update <use> elements that reference this old ID
|
|
const uses = Array.from(svgElem.querySelectorAll("use")).filter(
|
|
u => u.getAttributeNS(XLINK_NS, "href") === `#${oldId}`
|
|
);
|
|
|
|
uses.forEach(u => {
|
|
u.setAttributeNS(XLINK_NS, "href", `#${newId}`);
|
|
});
|
|
});
|
|
|
|
g.node().appendChild(svgElem);
|
|
|
|
const vb = svgElem.viewBox.baseVal;
|
|
const svgWidth = vb.width || 40;
|
|
const svgHeight = vb.height || 40;
|
|
|
|
const scale = (nodeRadius * 2) / Math.max(svgWidth, svgHeight);
|
|
|
|
g.select("svg")
|
|
.attr("width", svgWidth * scale)
|
|
.attr("height", svgHeight * scale)
|
|
.attr("x", -svgWidth * scale / 2)
|
|
.attr("y", -svgHeight * scale / 2);
|
|
} else {
|
|
// We have a image type different than svg
|
|
// include it via img url
|
|
g.append("svg:image")
|
|
.attr("xlink:href", d.image)
|
|
.attr("width", 40)
|
|
.attr("height", 40)
|
|
.attr("x", -20)
|
|
.attr("y", -20);
|
|
}
|
|
|
|
});
|
|
|
|
// add element to nodes array
|
|
node.each(function (d) {
|
|
d.el = this; // attach the DOM element to the data object
|
|
});
|
|
|
|
pop_add(node, "Compound", node_popup);
|
|
}
|
|
|
|
function serializeSVG(svgElement) {
|
|
|
|
svgElement.querySelectorAll("line.link").forEach(line => {
|
|
const style = getComputedStyle(line);
|
|
line.setAttribute("stroke", style.stroke);
|
|
line.setAttribute("stroke-width", style.strokeWidth);
|
|
line.setAttribute("fill", style.fill);
|
|
});
|
|
|
|
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => {
|
|
const style = getComputedStyle(line);
|
|
line.setAttribute("stroke", style.stroke);
|
|
line.setAttribute("stroke-width", style.strokeWidth);
|
|
line.setAttribute("fill", style.fill);
|
|
});
|
|
|
|
const serializer = new XMLSerializer();
|
|
let svgString = serializer.serializeToString(svgElement);
|
|
|
|
// Add namespace if missing
|
|
if (!svgString.includes('xmlns="http://www.w3.org/2000/svg"')) {
|
|
svgString = svgString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
}
|
|
|
|
return svgString;
|
|
}
|
|
|
|
function shrinkSVG(svgSelector) {
|
|
|
|
const svg = d3.select(svgSelector);
|
|
const node = svg.node();
|
|
|
|
// Compute bounding box of everything inside the SVG
|
|
const bbox = node.getBBox();
|
|
|
|
const padding = 10;
|
|
svg.attr("viewBox",
|
|
`${bbox.x - padding} ${bbox.y - padding} ${bbox.width + 2 * padding} ${bbox.height + 2 * padding}`
|
|
)
|
|
.attr("width", bbox.width + 2 * padding)
|
|
.attr("height", bbox.height + 2 * padding);
|
|
|
|
return bbox;
|
|
}
|
|
|
|
function downloadSVG(svgElement, filename = 'chart.svg') {
|
|
shrinkSVG("#" + svgElement.id);
|
|
const svgString = serializeSVG(svgElement);
|
|
const blob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|