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 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 * 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("x1", d => d.source.x) .attr("y1", d => d.source.y) .attr("x2", d => d.target.x) .attr("y2", d => 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 = `
`; // 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 = "" + n.name + "
"; popupContent += "Depth " + n.depth + "
" 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." + "
" if (n.app_domain['uncovered_functional_groups']) { popupContent += "Compound contains functional groups not covered by the training set
" } } } popupContent += "
" if (n.scenarios.length > 0) { popupContent += 'Half-lives and related scenarios:
' for (var s of n.scenarios) { popupContent += "" + s.name + "
"; } } var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0; if (pathway.isIncremental && isLeaf) { popupContent += '
Predict from here
'; } return popupContent; } function edge_popup(e) { popupContent = "" + e.name + "
"; if (e.app_domain) { adcontent = "

"; if (e.app_domain["times_triggered"]) { adcontent += "This rule triggered " + e.app_domain["times_triggered"] + " times in the training set
"; } 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"] + ")
"; 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"] + ")
"; adcontent += "

"; popupContent += adcontent; } popupContent += "
" if (e.reaction_probability) { popupContent += 'Probability:
' + e.reaction_probability.toFixed(3) + '
'; } if (e.scenarios.length > 0) { popupContent += 'Half-lives and related scenarios:
' for (var s of e.scenarios) { popupContent += "" + s.name + "
"; } } 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); // 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("line") .data(links) .enter().append("line") // 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 => d.target.pseudo ? '' : 'url(#arrow)') // 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) { 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", "#e8e8e8"); // Add image only for non pseudo nodes node.filter(d => !d.pseudo).each(function (d, i) { const g = d3.select(this); // 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 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 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); }); // 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('