Files
enviPy-bayer/static/js/pw.js
Tobias O 1a2c9bb543 [Feature] Modern UI roll out (#236)
This PR moves all the collection pages into the new UI in a rough push.
I did not put the same amount of care into these as into search, index, and predict.

## Major changes

- All modals are now migrated to a state based alpine.js implementation.
- jQuery is no longer present in the base layout; ajax is replace by native fetch api
- most of the pps.js is now obsolte (as I understand it; the code is not referenced any more @jebus  please double check)
- in-memory pagination for large result lists (set to 50; we can make that configurable later; performance degrades at around 1k) stukk a bit rough tracked in #235

## Minor things

- Sarch and index also use alpine now
- The loading spinner is now CSS animated (not sure if it currently gets correctly called)

## Not done

- Ihave not even cheked the admin pages. Not sure If these need migrations
- The temporary migration pages still use the old template. Not sure what is supposed to happen with those? @jebus

## What I did to test

- opend all pages in browse, and user ; plus all pages reachable from there.
- Interacted and tested the functionality of each modal superfically with exception of the API key modal (no functional test).

---
This PR is massive sorry for that; just did not want to push half-brokenn state.
@jebus @liambrydon I would be glad if you could click around and try to break it :)

Finally closes #133

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#236
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-26 23:16:44 +13:00

636 lines
22 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 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 = `
<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 = "<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>"
}
}
}
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>";
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"] ? "&gt" : "&lt") + " 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"] ? "&gt" : "&lt") + " 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);
// 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 <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);
});
// 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);
}