[Feature] Pathway SVG Export (#102)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#102
This commit is contained in:
2025-09-10 18:44:18 +12:00
parent e82fe7e87e
commit 31783306e2
7 changed files with 125 additions and 19 deletions

View File

@ -1657,8 +1657,8 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
"depth": self.depth, "depth": self.depth,
"url": self.url, "url": self.url,
"node_label_id": self.default_node_label.url, "node_label_id": self.default_node_label.url,
"image": self.url + '?image=svg', "image": f"{self.url}?image=svg",
"imageSize": 490, # TODO "image_svg": IndigoUtils.mol_to_svg(self.default_node_label.smiles, width=40, height=40),
"name": self.default_node_label.name, "name": self.default_node_label.name,
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{'name': s.name, 'url': s.url} for s in self.scenarios.all()], "scenarios": [{'name': s.name, 'url': s.url} for s in self.scenarios.all()],

View File

@ -1452,6 +1452,22 @@ def package_pathway(request, package_uuid, pathway_uuid):
return response return response
# Pathway d3_json() relies on a lot of related objects (Nodes, Structures, Edges, Reaction, Rules, ...)
# we will again fetch the current pathway identified by this url, but this time together with nearly all
# related objects
current_pathway = Pathway.objects.prefetch_related(
'node_set',
'node_set__out_edges',
'node_set__default_node_label',
'node_set__scenarios',
'edge_set',
'edge_set__start_nodes',
'edge_set__end_nodes',
'edge_set__edge_label',
'edge_set__scenarios'
).get(uuid=pathway_uuid)
context = get_base_context(request) context = get_base_context(request)
context['title'] = f'enviPath - {current_package.name} - {current_pathway.name}' context['title'] = f'enviPath - {current_package.name} - {current_pathway.name}'

View File

@ -384,12 +384,31 @@ function draw(pathway, elem) {
.style("fill", "#e8e8e8"); .style("fill", "#e8e8e8");
// Add image only for non pseudo nodes // Add image only for non pseudo nodes
node.filter(d => !d.pseudo).append("image") node.filter(d => !d.pseudo).each(function (d) {
.attr("xlink:href", d => d.image) const g = d3.select(this).append("g");
.attr("x", -nodeRadius)
.attr("y", -nodeRadius) // Parse the SVG string
.attr("width", nodeRadius * 2) const parser = new DOMParser();
.attr("height", nodeRadius * 2); const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
const svgContent = svgDoc.documentElement;
// Remove width/height so scaling works
svgContent.removeAttribute("width");
svgContent.removeAttribute("height");
// Move all children of the parsed SVG into our <g>
while (svgContent.firstChild) {
g.node().appendChild(svgContent.firstChild);
}
// Get viewBox dimensions for scaling
const vb = svgContent.viewBox.baseVal;
const svgWidth = vb.width || 40;
const svgHeight = vb.height || 40;
// Center and scale
g.attr("transform", `translate(${-svgWidth/2},${-svgHeight/2}) scale(${(nodeRadius*2)/svgWidth})`);
});
// add element to nodes array // add element to nodes array
node.each(function (d) { node.each(function (d) {
@ -397,4 +416,38 @@ function draw(pathway, elem) {
}); });
pop_add(node, "Compound", node_popup); 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);
});
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 downloadSVG(svgElement, filename = 'chart.svg') {
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);
}

View File

@ -14,8 +14,12 @@
<i class="glyphicon glyphicon-duplicate"></i> Copy</a> <i class="glyphicon glyphicon-duplicate"></i> Copy</a>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#download_pathway_modal"> <a class="button" data-toggle="modal" data-target="#download_pathway_csv_modal">
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway</a> <i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a>
</li>
<li>
<a class="button" data-toggle="modal" data-target="#download_pathway_image_modal">
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a>
</li> </li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>

View File

@ -1,24 +1,24 @@
{% load static %} {% load static %}
<!-- Download Pathway --> <!-- Download Pathway -->
<div id="download_pathway_modal" class="modal" tabindex="-1"> <div id="download_pathway_csv_modal" class="modal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Download Pathway</h3> <h3 class="modal-title">Download Pathway as CSV</h3>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
By clicking on Download the Pathway will be converted into a CSV and directly downloaded. By clicking on Download the Pathway will be converted into a CSV and directly downloaded.
<form id="download-pathway-modal-form" accept-charset="UTF-8" action="{{ pathway.url }}" <form id="download-pathway-csv-modal-form" accept-charset="UTF-8" action="{{ pathway.url }}"
data-remote="true" method="GET"> data-remote="true" method="GET">
<input type="hidden" name="download" value="true"/> <input type="hidden" name="download" value="true"/>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="download-pathway-modal-submit">Download</button> <button type="button" class="btn btn-primary" id="download-pathway-csv-modal-submit">Download</button>
</div> </div>
</div> </div>
</div> </div>
@ -26,10 +26,10 @@
<script> <script>
$(function () { $(function () {
$('#download-pathway-modal-submit').click(function (e) { $('#download-pathway-csv-modal-submit').click(function (e) {
e.preventDefault(); e.preventDefault();
$('#download-pathway-modal-form').submit(); $('#download-pathway-csv-modal-form').submit();
$('#download_pathway_modal').modal('hide'); $('#download_pathway_csv_modal').modal('hide');
}); });
}) })

View File

@ -0,0 +1,32 @@
{% load static %}
<!-- Download Pathway -->
<div id="download_pathway_image_modal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Download Pathway as Image</h3>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
By clicking on Download the Pathway will be saved as SVG.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="download-pathway-image-modal-submit">Download</button>
</div>
</div>
</div>
</div>
<script>
$(function () {
$('#download-pathway-image-modal-submit').click(function (e) {
e.preventDefault();
downloadSVG($('#pwsvg')[0], '{{ pathway.name.split|join:"_" }}.svg')
$('#download_pathway_image_modal').modal('hide');
});
})
</script>

View File

@ -81,7 +81,8 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/add_pathway_node_modal.html" %} {% include "modals/objects/add_pathway_node_modal.html" %}
{% include "modals/objects/add_pathway_edge_modal.html" %} {% include "modals/objects/add_pathway_edge_modal.html" %}
{% include "modals/objects/download_pathway_modal.html" %} {% include "modals/objects/download_pathway_csv_modal.html" %}
{% include "modals/objects/download_pathway_image_modal.html" %}
{% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_copy_object_modal.html" %}
{% include "modals/objects/edit_pathway_modal.html" %} {% include "modals/objects/edit_pathway_modal.html" %}
{% include "modals/objects/generic_set_scenario_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %}