forked from enviPath/enviPy
[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:
265
static/js/alpine/index.js
Normal file
265
static/js/alpine/index.js
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Alpine.js Components for enviPath
|
||||
*
|
||||
* This module provides reusable Alpine.js data components for modals,
|
||||
* form validation, and form submission.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
/**
|
||||
* Modal Form Component
|
||||
*
|
||||
* Provides form validation using HTML5 Constraint Validation API,
|
||||
* loading states for submission, and error message management.
|
||||
*
|
||||
* Basic Usage:
|
||||
* <dialog x-data="modalForm()" @close="reset()">
|
||||
* <form id="my-form">
|
||||
* <input name="field" required>
|
||||
* </form>
|
||||
* <button @click="submit('my-form')" :disabled="isSubmitting">Submit</button>
|
||||
* </dialog>
|
||||
*
|
||||
* With Custom State:
|
||||
* <dialog x-data="modalForm({ state: { selectedItem: '', imageUrl: '' } })" @close="reset()">
|
||||
* <select x-model="selectedItem" @change="updateImagePreview(selectedItem + '?image=svg')">
|
||||
* <img :src="imageUrl" x-show="imageUrl">
|
||||
* </dialog>
|
||||
*
|
||||
* With AJAX:
|
||||
* <button @click="submitAsync('my-form', { onSuccess: (data) => console.log(data) })">
|
||||
*/
|
||||
Alpine.data('modalForm', (options = {}) => ({
|
||||
isSubmitting: false,
|
||||
errors: {},
|
||||
// Spread custom initial state from options
|
||||
...(options.state || {}),
|
||||
|
||||
/**
|
||||
* Validate a single field using HTML5 Constraint Validation API
|
||||
* @param {HTMLElement} field - The input/select/textarea element
|
||||
*/
|
||||
validateField(field) {
|
||||
const name = field.name || field.id;
|
||||
if (!name) return;
|
||||
|
||||
if (!field.validity.valid) {
|
||||
this.errors[name] = field.validationMessage;
|
||||
} else {
|
||||
delete this.errors[name];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error for a field (call on input)
|
||||
* @param {HTMLElement} field - The input element
|
||||
*/
|
||||
clearError(field) {
|
||||
const name = field.name || field.id;
|
||||
if (name && this.errors[name]) {
|
||||
delete this.errors[name];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get error message for a field
|
||||
* @param {string} name - Field name
|
||||
* @returns {string|undefined} Error message or undefined
|
||||
*/
|
||||
getError(name) {
|
||||
return this.errors[name];
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if form has any errors
|
||||
* @returns {boolean} True if there are errors
|
||||
*/
|
||||
hasErrors() {
|
||||
return Object.keys(this.errors).length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate all fields in a form
|
||||
* @param {string} formId - The form element ID
|
||||
* @returns {boolean} True if form is valid
|
||||
*/
|
||||
validateAll(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return false;
|
||||
|
||||
this.errors = {};
|
||||
const fields = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.name && !field.validity.valid) {
|
||||
this.errors[field.name] = field.validationMessage;
|
||||
}
|
||||
});
|
||||
|
||||
return !this.hasErrors();
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate that two password fields match
|
||||
* @param {string} password1Id - ID of first password field
|
||||
* @param {string} password2Id - ID of second password field
|
||||
* @returns {boolean} True if passwords match
|
||||
*/
|
||||
validatePasswordMatch(password1Id, password2Id) {
|
||||
const pw1 = document.getElementById(password1Id);
|
||||
const pw2 = document.getElementById(password2Id);
|
||||
|
||||
if (!pw1 || !pw2) return false;
|
||||
|
||||
if (pw1.value !== pw2.value) {
|
||||
this.errors[pw2.name || password2Id] = 'Passwords do not match';
|
||||
pw2.setCustomValidity('Passwords do not match');
|
||||
return false;
|
||||
}
|
||||
|
||||
delete this.errors[pw2.name || password2Id];
|
||||
pw2.setCustomValidity('');
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit a form with loading state
|
||||
* @param {string} formId - The form element ID
|
||||
*/
|
||||
submit(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
// Validate before submit
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set action to current URL if empty
|
||||
if (!form.action || form.action === window.location.href + '#') {
|
||||
form.action = window.location.href;
|
||||
}
|
||||
|
||||
// Set loading state and submit
|
||||
this.isSubmitting = true;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit form via AJAX (fetch)
|
||||
* @param {string} formId - The form element ID
|
||||
* @param {Object} options - Options { onSuccess, onError, closeOnSuccess }
|
||||
*/
|
||||
async submitAsync(formId, options = {}) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
// Validate before submit
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action || window.location.href, {
|
||||
method: form.method || 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.ok) {
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(data);
|
||||
}
|
||||
|
||||
if (data.redirect || data.success) {
|
||||
window.location.href = data.redirect || data.success;
|
||||
} else if (options.closeOnSuccess) {
|
||||
this.$el.closest('dialog')?.close();
|
||||
}
|
||||
} else {
|
||||
const errorMsg = data.error || data.message || `Error: ${response.status}`;
|
||||
this.errors['_form'] = errorMsg;
|
||||
|
||||
if (options.onError) {
|
||||
options.onError(errorMsg, data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.errors['_form'] = error.message;
|
||||
|
||||
if (options.onError) {
|
||||
options.onError(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set form action URL dynamically
|
||||
* @param {string} formId - The form element ID
|
||||
* @param {string} url - The URL to set as action
|
||||
*/
|
||||
setFormAction(formId, url) {
|
||||
const form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.action = url;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update image preview
|
||||
* @param {string} url - Image URL (with query params)
|
||||
* @param {string} targetId - Target element ID for the image
|
||||
*/
|
||||
updateImagePreview(url) {
|
||||
// Store URL for reactive binding with :src
|
||||
this.imageUrl = url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset form state (call on modal close)
|
||||
* Resets to initial state from options
|
||||
*/
|
||||
reset() {
|
||||
this.isSubmitting = false;
|
||||
this.errors = {};
|
||||
this.imageUrl = '';
|
||||
|
||||
// Reset custom state to initial values
|
||||
if (options.state) {
|
||||
Object.keys(options.state).forEach(key => {
|
||||
this[key] = options.state[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Call custom reset handler if provided
|
||||
if (options.onReset) {
|
||||
options.onReset.call(this);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Simple Modal Component (no form)
|
||||
*
|
||||
* For modals that don't need form validation.
|
||||
*
|
||||
* Usage:
|
||||
* <dialog x-data="modal()">
|
||||
* <button @click="$el.closest('dialog').close()">Close</button>
|
||||
* </dialog>
|
||||
*/
|
||||
Alpine.data('modal', () => ({
|
||||
// Placeholder for simple modals that may need state later
|
||||
}));
|
||||
});
|
||||
133
static/js/alpine/pagination.js
Normal file
133
static/js/alpine/pagination.js
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Alpine.js Pagination Component
|
||||
*
|
||||
* Provides client-side pagination for large lists.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('paginatedList', (initialItems = [], options = {}) => ({
|
||||
allItems: initialItems,
|
||||
filteredItems: [],
|
||||
currentPage: 1,
|
||||
perPage: options.perPage || 50,
|
||||
searchQuery: '',
|
||||
isReviewed: options.isReviewed || false,
|
||||
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
|
||||
|
||||
init() {
|
||||
this.filteredItems = this.allItems;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.filteredItems.length / this.perPage);
|
||||
},
|
||||
|
||||
get paginatedItems() {
|
||||
const start = (this.currentPage - 1) * this.perPage;
|
||||
const end = start + this.perPage;
|
||||
return this.filteredItems.slice(start, end);
|
||||
},
|
||||
|
||||
get totalItems() {
|
||||
return this.filteredItems.length;
|
||||
},
|
||||
|
||||
get showingStart() {
|
||||
if (this.totalItems === 0) return 0;
|
||||
return (this.currentPage - 1) * this.perPage + 1;
|
||||
},
|
||||
|
||||
get showingEnd() {
|
||||
return Math.min(this.currentPage * this.perPage, this.totalItems);
|
||||
},
|
||||
|
||||
search(query) {
|
||||
this.searchQuery = query.toLowerCase();
|
||||
if (this.searchQuery === '') {
|
||||
this.filteredItems = this.allItems;
|
||||
} else {
|
||||
this.filteredItems = this.allItems.filter(item =>
|
||||
item.name.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
}
|
||||
this.currentPage = 1;
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
}
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.currentPage = page;
|
||||
}
|
||||
},
|
||||
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const total = this.totalPages;
|
||||
const current = this.currentPage;
|
||||
|
||||
// Handle empty case
|
||||
if (total === 0) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (total <= 7) {
|
||||
// Show all pages if 7 or fewer
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
} else {
|
||||
// More than 7 pages - show first, last, and sliding window around current
|
||||
// Always show first page
|
||||
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
|
||||
|
||||
// Determine the start and end of the middle range
|
||||
let rangeStart, rangeEnd;
|
||||
|
||||
if (current <= 4) {
|
||||
// Near the beginning: show pages 2-5
|
||||
rangeStart = 2;
|
||||
rangeEnd = 5;
|
||||
} else if (current >= total - 3) {
|
||||
// Near the end: show last 4 pages before the last page
|
||||
rangeStart = total - 4;
|
||||
rangeEnd = total - 1;
|
||||
} else {
|
||||
// In the middle: show current page and one on each side
|
||||
rangeStart = current - 1;
|
||||
rangeEnd = current + 1;
|
||||
}
|
||||
|
||||
// Add ellipsis before range if there's a gap
|
||||
if (rangeStart > 2) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
|
||||
}
|
||||
|
||||
// Add pages in the range
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
|
||||
// Add ellipsis after range if there's a gap
|
||||
if (rangeEnd < total - 1) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}));
|
||||
});
|
||||
145
static/js/alpine/search.js
Normal file
145
static/js/alpine/search.js
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Search Modal Alpine.js Component
|
||||
*
|
||||
* Provides package selection, search mode switching, and results display
|
||||
* for the search modal.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
/**
|
||||
* Search Modal Component
|
||||
*
|
||||
* Usage:
|
||||
* <dialog x-data="searchModal()" @close="reset()">
|
||||
* ...
|
||||
* </dialog>
|
||||
*/
|
||||
Alpine.data('searchModal', () => ({
|
||||
// Package selector state
|
||||
selectedPackages: [],
|
||||
|
||||
// Search state
|
||||
searchMode: 'text',
|
||||
searchModeLabel: 'Text',
|
||||
query: '',
|
||||
|
||||
// Results state
|
||||
results: null,
|
||||
isSearching: false,
|
||||
error: null,
|
||||
|
||||
// Initialize on modal open
|
||||
init() {
|
||||
// Load reviewed packages by default
|
||||
this.loadInitialSelection();
|
||||
|
||||
// Watch for modal open to focus searchbar
|
||||
this.$watch('$el.open', (open) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.$refs.searchbar.focus();
|
||||
}, 320);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadInitialSelection() {
|
||||
// Select all reviewed packages by default
|
||||
const menuItems = this.$refs.packageDropdown.querySelectorAll('li');
|
||||
|
||||
for (const item of menuItems) {
|
||||
// Stop at 'Unreviewed Packages' section
|
||||
if (item.classList.contains('menu-title') &&
|
||||
item.textContent.trim() === 'Unreviewed Packages') {
|
||||
break;
|
||||
}
|
||||
|
||||
const packageOption = item.querySelector('.package-option');
|
||||
if (packageOption) {
|
||||
this.selectedPackages.push({
|
||||
url: packageOption.dataset.packageUrl,
|
||||
name: packageOption.dataset.packageName
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
togglePackage(url, name) {
|
||||
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
|
||||
|
||||
if (index !== -1) {
|
||||
this.selectedPackages.splice(index, 1);
|
||||
} else {
|
||||
this.selectedPackages.push({ url, name });
|
||||
}
|
||||
},
|
||||
|
||||
removePackage(url) {
|
||||
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
|
||||
if (index !== -1) {
|
||||
this.selectedPackages.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
isPackageSelected(url) {
|
||||
return this.selectedPackages.some(pkg => pkg.url === url);
|
||||
},
|
||||
|
||||
setSearchMode(mode, label) {
|
||||
this.searchMode = mode;
|
||||
this.searchModeLabel = label;
|
||||
this.$refs.modeDropdown.hidePopover();
|
||||
},
|
||||
|
||||
async performSearch(serverBase) {
|
||||
if (!this.query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedPackages.length < 1) {
|
||||
this.results = { error: 'no_packages' };
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
this.selectedPackages.forEach(pkg => params.append('packages', pkg.url));
|
||||
params.append('search', this.query.trim());
|
||||
params.append('mode', this.searchModeLabel.toLowerCase());
|
||||
|
||||
this.isSearching = true;
|
||||
this.results = null;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverBase}/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
|
||||
this.results = await response.json();
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
this.error = 'Search failed. Please try again.';
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
},
|
||||
|
||||
hasResults() {
|
||||
if (!this.results || this.results.error) return false;
|
||||
const categories = ['Compounds', 'Compound Structures', 'Rules', 'Reactions', 'Pathways'];
|
||||
return categories.some(cat => this.results[cat] && this.results[cat].length > 0);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.query = '';
|
||||
this.results = null;
|
||||
this.error = null;
|
||||
this.isSearching = false;
|
||||
}
|
||||
}));
|
||||
});
|
||||
1171
static/js/pps.js
1171
static/js/pps.js
File diff suppressed because it is too large
Load Diff
239
static/js/pw.js
239
static/js/pw.js
@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user