[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>
This commit is contained in:
2025-11-26 23:16:44 +13:00
committed by jebus
parent 7f6f209b4a
commit 1a2c9bb543
110 changed files with 10784 additions and 9465 deletions

View File

@ -1,15 +1,22 @@
console.log("loaded pw.js")
function predictFromNode(url) {
$.post("", {node: url})
.done(function (data) {
console.log("Success:", data);
window.location.href = data.success;
})
.fail(function (xhr, status, error) {
console.error("Error:", xhr.status, xhr.responseText);
// show user-friendly message or log error
});
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 }};
@ -103,6 +110,9 @@ function draw(pathway, elem) {
}
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;
@ -117,6 +127,9 @@ function draw(pathway, elem) {
}
function dragged(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
d.fx = event.x;
d.fy = event.y;
@ -127,6 +140,9 @@ function draw(pathway, elem) {
}
function dragended(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0);
// Mark that dragging has ended
@ -192,52 +208,153 @@ function draw(pathway, elem) {
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
}
// Wait one second before showing popup
// Wait before showing popup (ms)
var popupWaitBeforeShow = 1000;
// Keep Popup at least for one second
var popushowAtLeast = 1000;
function pop_show_e(element) {
var e = element;
setTimeout(function () {
if ($(e).is(':hover')) { // if element is still hovered
$(e).popover("show");
// Custom popover element
let popoverTimeout = null;
// workaround to set fixed positions
pop = $(e).attr("aria-describedby")
h = $('#' + pop).height();
$('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`)
setTimeout(function () {
var close = setInterval(function () {
if (!$(".popover:hover").length // mouse outside popover
&& !$(e).is(':hover')) { // mouse outside element
$(e).popover('hide');
clearInterval(close);
}
}, 100);
}, popushowAtLeast);
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;
}
}, popupWaitBeforeShow);
#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.attr("id", "pop")
.attr("data-container", "body")
.attr("data-toggle", "popover")
.attr("data-placement", "right")
.attr("title", title);
objects.each(function (d) {
const element = this;
objects.each(function (d, i) {
options = {trigger: "manual", html: true, animation: false};
this_ = this;
var p = $(this).popover(options).on("mouseenter", function () {
pop_show_e(this);
element.addEventListener('mouseenter', () => {
if (popoverTimeout) clearTimeout(popoverTimeout);
popoverTimeout = setTimeout(() => {
if (element.matches(':hover')) {
const content = contentFunction(d);
showPopover(element, title, content);
}
}, popupWaitBeforeShow);
});
p.on("show.bs.popover", function (e) {
// this is to dynamically ajdust the content and bounds of the popup
p.attr('data-content', contentFunction(d));
p.data("bs.popover").setContent();
p.data("bs.popover").tip().css({"max-width": "1000px"});
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);
});
});
}
@ -255,7 +372,7 @@ function draw(pathway, elem) {
}
}
popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><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) {
@ -265,7 +382,7 @@ function draw(pathway, elem) {
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
if (pathway.isIncremental && isLeaf) {
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
}
return popupContent;
@ -285,7 +402,7 @@ function draw(pathway, elem) {
popupContent += adcontent;
}
popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>"
popupContent += "<img src='" + e.image + "'><br>"
if (e.reaction_probability) {
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
}
@ -308,6 +425,23 @@ function draw(pathway, elem) {
});
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()
@ -316,7 +450,12 @@ function draw(pathway, elem) {
zoomable.attr("transform", event.transform);
});
d3.select("svg").call(zoom);
// 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'];