forked from enviPath/enviPy
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>
636 lines
22 KiB
JavaScript
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"] ? ">" : "<") + " 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"] ? ">" : "<") + " 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);
|
|
}
|