[Feature] Sync Features from Client Repo (#425)

- New PackageImporter
- Fix persisting Molfile on Structure creation
- Add NonPersistent Prediction
- Pathway View Options
- Edit Node Fix

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#425
This commit is contained in:
2026-07-16 07:50:46 +12:00
parent 2c2437e3f5
commit 72a63b4876
16 changed files with 733 additions and 627 deletions

View File

@ -67,6 +67,9 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes
const depthMap = new Map();
// Avoid leaving unconnected Nodes leaving the viewport
nodes.forEach(node => {if (node.depth < 0) node.depth = 0;});
// Sort nodes by depth first to minimize crossings
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
@ -154,6 +157,11 @@ function draw(pathway, elem) {
});
node.attr("transform", d => `translate(${d.x},${d.y})`);
linkText
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
}
function dragstarted(event, d) {
@ -256,7 +264,7 @@ function draw(pathway, elem) {
}
// Wait before showing popup (ms)
var popupWaitBeforeShow = 1000;
var popupWaitBeforeShow = 750;
// Custom popover element
let popoverTimeout = null;
@ -488,7 +496,7 @@ function draw(pathway, elem) {
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 += "<a href='" + n.url + "'>" + n.plain_name + "</a><br>";
popupContent += "Depth " + n.depth + "<br>"
if (appDomainViewEnabled) {
@ -528,6 +536,21 @@ function draw(pathway, elem) {
popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of n.scenarios) {
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
for (var prop in n.proposed) {
if (n.proposed[prop].scenarioId === s.url) {
if("proposed" in n.proposed[prop]){
popupContent += "This compound is a proposed intermediate" + "<br>";
}
if("Confidence" in n.proposed[prop]){
popupContent += "Confidence: " + n.proposed[prop]["Confidence"] + "<br>";
}
if("Transformation product importance" in n.proposed[prop]){
popupContent += "Transformation product importance: " + n.proposed[prop]["Transformation product importance"] + "<br>";
}
}
}
popupContent += "<br>";
}
}
@ -540,7 +563,7 @@ function draw(pathway, elem) {
}
function edge_popup(e) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
popupContent = "<a href='" + e.url + "'>" + e.plain_name + "</a><br><br>";
if (e.reaction.rules) {
for (var rule of e.reaction.rules) {
@ -591,7 +614,7 @@ function draw(pathway, elem) {
// 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")
const rect = svg.insert("rect", "#zoomable")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
@ -600,6 +623,30 @@ function draw(pathway, elem) {
.attr("pointer-events", "all")
.style("cursor", "grab");
// Click on empty SVG space (not on a node or link)
rect.on("click", function(event) {
// Only treat it as a background click if the direct target is the rect itself
// (clicks on nodes/links bubble up but originate from different elements)
const target = event.target;
const isNode = target.closest && (
target.closest("circle") !== null ||
target.closest("image") !== null ||
target.closest(".node") !== null
);
const isLink = target.closest && (
target.closest("path.link") !== null ||
target.closest("path.link_no_arrow") !== null
);
if (!isNode && !isLink) {
// console.log("Clicked outside a node or link");
// Clear all highlights
d3.selectAll("circle").classed("highlighted", false);
d3.selectAll("path").classed("highlighted", false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
}
});
// Zoom Funktion aktivieren
const zoom = d3.zoom()
.scaleExtent([0.5, 5])
@ -646,22 +693,32 @@ function draw(pathway, elem) {
.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) {
// Draw Edges
const linkGroup = zoomable.append("g")
.selectAll("g")
.data(links)
.enter()
.append("g")
.attr("class", "link-group");
const link = linkGroup
.append("path")
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)")
.attr("fill", "none")
// 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);
d3.selectAll("path").classed("highlighted", false);
if (!wasHighlighted) {
const toHighlight = [];
toHighlight.push(d.el);
@ -688,6 +745,32 @@ function draw(pathway, elem) {
}
});
function reaction_name(d) {
const to_pseudo = d?.to_pseudo || false;
if (to_pseudo) {
return "";
}
var suffix = "";
if ("reaction_probability" in d && d["reaction_probability"] !== null) {
suffix = " (p= " + d["reaction_probability"].toFixed(2) + ")"
}
return d.plain_name + suffix;
}
const linkText = linkGroup
.append("text")
.attr("class", "link-label")
.attr("text-anchor", "middle")
.attr("dy", -6)
.style("font-size", "4px")
.style("pointer-events", "none")
.style("display", "none")
.text(d => reaction_name(d))
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
// add element to links array
link.each(function (d) {
d.el = this; // attach the DOM element to the data object
@ -695,7 +778,7 @@ function draw(pathway, elem) {
pop_add(link, "Reaction", edge_popup);
// Knoten zeichnen
// Draw Nodes
const node = zoomable.append("g")
.selectAll("g")
.data(nodes)