Commit 02130a912f5df7bc05660672d788693993c79a53
Exists in
master
and in
4 other branches
Export chart to PNG
Showing
8 changed files
with
163 additions
and
11 deletions
Show diff stats
CHANGELOG.md
VERSION.txt
app/main/static/css/charges.css
... | ... | @@ -45,7 +45,7 @@ |
45 | 45 | padding: 0.8em; |
46 | 46 | } |
47 | 47 | |
48 | -.charge_chart { | |
48 | +.svg_chart { | |
49 | 49 | background-color: #fAfAfA; |
50 | 50 | border: 1pt solid black; |
51 | 51 | display: inline-block; |
... | ... | @@ -53,10 +53,11 @@ |
53 | 53 | |
54 | 54 | text.legend { |
55 | 55 | font-size: 12px; |
56 | + font-family: sans-serif; | |
56 | 57 | } |
57 | 58 | |
58 | 59 | rect.legend { |
59 | - stroke: white; | |
60 | + stroke: grey; | |
60 | 61 | stroke-width: 0.5pt; |
61 | 62 | } |
62 | 63 | |
... | ... | @@ -87,3 +88,8 @@ rect.bar:hover { |
87 | 88 | max-width: 300px; |
88 | 89 | pointer-events: none; |
89 | 90 | } |
91 | + | |
92 | +button.export { | |
93 | + float: right; | |
94 | + margin-right: 0; | |
95 | +} | |
90 | 96 | \ No newline at end of file | ... | ... |
app/main/static/js/charges.js
1 | +/* Round float to two decimals only */ | |
1 | 2 | function roundToTwo(num) { |
2 | 3 | return +(Math.round(num + "e+2") + "e-2"); |
3 | 4 | } |
... | ... | @@ -64,8 +65,14 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
64 | 65 | alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !") |
65 | 66 | } |
66 | 67 | |
68 | + // Create a download button inside the div contaning svg chart | |
69 | + var dl_btn = d3.select(div_selector).append("button") | |
70 | + .text('png') | |
71 | + .attr('class', 'export') | |
72 | + .on('click', download_png) | |
73 | + | |
67 | 74 | const svg = d3.select(div_selector).append("svg") |
68 | - .attr("id", "svg") | |
75 | + .attr("class", "svg_chart") | |
69 | 76 | .attr("width", width + margin.left + margin.right) |
70 | 77 | .attr("height", height + margin.top + margin.bottom) |
71 | 78 | .append("g") |
... | ... | @@ -135,7 +142,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
135 | 142 | var addlegend = function (color_scale) { |
136 | 143 | |
137 | 144 | // add horizontal legend |
138 | - let reverse_keys = color_scale.domain().reverse(); | |
145 | + let legend_keys = color_scale.domain(); | |
139 | 146 | var legendSpacing = 5; |
140 | 147 | |
141 | 148 | var legendWrap = svg.append('g') |
... | ... | @@ -143,7 +150,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
143 | 150 | .attr('class', 'legendwrap'); |
144 | 151 | |
145 | 152 | var legend = svg.select('.legendwrap').selectAll('.legend') |
146 | - .data(reverse_keys) | |
153 | + .data(legend_keys) | |
147 | 154 | .enter() |
148 | 155 | .append('g') |
149 | 156 | .attr('class', 'legend'); |
... | ... | @@ -152,7 +159,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
152 | 159 | .attr('width', legendRectSize) |
153 | 160 | .attr('height', legendRectSize) |
154 | 161 | .style('fill', color_scale) |
155 | - .style('stroke', color_scale); | |
162 | + .attr('class', 'legend'); | |
156 | 163 | |
157 | 164 | legend.append('text') |
158 | 165 | .attr('class', 'legend') |
... | ... | @@ -387,7 +394,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
387 | 394 | |
388 | 395 | // Draw Xaxis |
389 | 396 | svg.append("g") |
390 | - .attr("class", "x axis") | |
397 | + .attr("class", "x_axis") | |
391 | 398 | .attr("transform", "translate(0," + height + ")") |
392 | 399 | .call(xAxis) |
393 | 400 | .selectAll("text") |
... | ... | @@ -403,7 +410,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
403 | 410 | |
404 | 411 | // Draw Yaxis |
405 | 412 | svg.append("g") |
406 | - .attr("class", "y axis") | |
413 | + .attr("class", "y_axis") | |
407 | 414 | .call(yAxis) |
408 | 415 | |
409 | 416 | // Draw horizontal lines | ... | ... |
... | ... | @@ -0,0 +1,128 @@ |
1 | +var download_png = function () { | |
2 | + // This callback is supposed to be called on the click event of a button child of the div containing the svg. | |
3 | + // We then get the parent of this btn to guess the chart's title, width and heigth | |
4 | + var chart_div = this.parentNode; | |
5 | + var chart_title = chart_div.id; | |
6 | + width = chart_div.offsetWidth; | |
7 | + height = chart_div.offsetHeight; | |
8 | + | |
9 | + // Then we can access the svg contained in that parent | |
10 | + svg = chart_div.getElementsByTagName('svg')[0] | |
11 | + | |
12 | + // Export to string and save | |
13 | + var svgString = getSVGString(svg); | |
14 | + svgString2Image(svgString, 2 * width, 2 * height, 'png', save); // passes Blob and filesize String to the callback | |
15 | + | |
16 | + function save(dataBlob, filesize) { | |
17 | + saveAs(dataBlob, chart_title); // FileSaver.js function | |
18 | + } | |
19 | +} | |
20 | + | |
21 | + | |
22 | +// Below are the functions that handle actual exporting: | |
23 | +// getSVGString ( svgNode ) and svgString2Image( svgString, width, height, format, callback ) | |
24 | +function getSVGString(svgNode) { | |
25 | + svgNode.setAttribute('xlink', 'http://www.w3.org/1999/xlink'); | |
26 | + var cssStyleText = getCSSStyles(svgNode); | |
27 | + appendCSS(cssStyleText, svgNode); | |
28 | + | |
29 | + var serializer = new XMLSerializer(); | |
30 | + var svgString = serializer.serializeToString(svgNode); | |
31 | + svgString = svgString.replace(/(\w+)?:?xlink=/g, 'xmlns:xlink='); // Fix root xlink without namespace | |
32 | + svgString = svgString.replace(/NS\d+:href/g, 'xlink:href'); // Safari NS namespace fix | |
33 | + | |
34 | + return svgString; | |
35 | + | |
36 | + function getCSSStyles(parentElement) { | |
37 | + var selectorTextArr = []; | |
38 | + | |
39 | + // Add Parent element Id and Classes to the list | |
40 | + selectorTextArr.push('#' + parentElement.id); | |
41 | + for (var c = 0; c < parentElement.classList.length; c++){ | |
42 | + if (!contains('.' + parentElement.classList[c], selectorTextArr)){ | |
43 | + selectorTextArr.push('.' + parentElement.classList[c]); | |
44 | + } | |
45 | + } | |
46 | + | |
47 | + // Add Children element Ids and Classes to the list | |
48 | + var nodes = parentElement.getElementsByTagName("*"); | |
49 | + for (var i = 0; i < nodes.length; i++) { | |
50 | + var id = nodes[i].id; | |
51 | + if (!contains('#' + id, selectorTextArr)) | |
52 | + selectorTextArr.push('#' + id); | |
53 | + | |
54 | + var classes = nodes[i].classList; | |
55 | + for (var c = 0; c < classes.length; c++){ | |
56 | + if (!contains('.' + classes[c], selectorTextArr)) | |
57 | + selectorTextArr.push('.' + classes[c]); | |
58 | + } | |
59 | + } | |
60 | + | |
61 | + // Extract CSS Rules | |
62 | + var extractedCSSText = ""; | |
63 | + for (var i = 0; i < document.styleSheets.length; i++) { | |
64 | + var s = document.styleSheets[i]; | |
65 | + | |
66 | + try { | |
67 | + if (!s.cssRules) continue; | |
68 | + } catch (e) { | |
69 | + if (e.name !== 'SecurityError') throw e; // for Firefox | |
70 | + continue; | |
71 | + } | |
72 | + | |
73 | + var cssRules = s.cssRules; | |
74 | + for (var r = 0; r < cssRules.length; r++) { | |
75 | + var cssRule = cssRules[r] | |
76 | + if (typeof cssRule.selectorText === 'undefined') { | |
77 | + continue; | |
78 | + } | |
79 | + var classFromSelector = '.'+cssRule.selectorText.split('.')[1] | |
80 | + if (contains(classFromSelector, selectorTextArr)) | |
81 | + extractedCSSText += cssRule.cssText; | |
82 | + } | |
83 | + } | |
84 | + | |
85 | + | |
86 | + return extractedCSSText; | |
87 | + | |
88 | + function contains(str, arr) { | |
89 | + return arr.indexOf(str) === -1 ? false : true; | |
90 | + } | |
91 | + | |
92 | + } | |
93 | + | |
94 | + function appendCSS(cssText, element) { | |
95 | + var styleElement = document.createElement("style"); | |
96 | + styleElement.setAttribute("type", "text/css"); | |
97 | + styleElement.innerHTML = cssText; | |
98 | + var refNode = element.hasChildNodes() ? element.children[0] : null; | |
99 | + element.insertBefore(styleElement, refNode); | |
100 | + } | |
101 | +} | |
102 | + | |
103 | +function svgString2Image(svgString, width, height, format, callback) { | |
104 | + var format = format ? format : 'png'; | |
105 | + | |
106 | + var imgsrc = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString))); // Convert SVG string to data URL | |
107 | + | |
108 | + var canvas = document.createElement("canvas"); | |
109 | + var context = canvas.getContext("2d"); | |
110 | + | |
111 | + canvas.width = width; | |
112 | + canvas.height = height; | |
113 | + | |
114 | + var image = new Image(); | |
115 | + image.onload = function () { | |
116 | + context.clearRect(0, 0, width, height); | |
117 | + context.drawImage(image, 0, 0, width, height); | |
118 | + | |
119 | + canvas.toBlob(function (blob) { | |
120 | + var filesize = Math.round(blob.length / 1024) + ' KB'; | |
121 | + if (callback) callback(blob, filesize); | |
122 | + }); | |
123 | + | |
124 | + | |
125 | + }; | |
126 | + | |
127 | + image.src = imgsrc; | |
128 | +} | ... | ... |
app/main/templates/agent.html
... | ... | @@ -34,7 +34,7 @@ |
34 | 34 | |
35 | 35 | {% block more_scripts %} |
36 | 36 | {% include 'd3js-includes.html' %} |
37 | -<script src="{{ url_for('main.static', filename='js/charges.js', version=config.VERSION) }}" type="text/javascript"></script> | |
37 | +{% include 'charges-includes.html' %} | |
38 | 38 | <script> |
39 | 39 | build_chart("#projects_chart", |
40 | 40 | "{{url_for('main.charge_agent_csv', agent_id=agent.id)}}", | ... | ... |
app/main/templates/project.html
... | ... | @@ -31,7 +31,7 @@ |
31 | 31 | |
32 | 32 | {% block more_scripts %} |
33 | 33 | {% include 'd3js-includes.html' %} |
34 | -<script src="{{ url_for('main.static', filename='js/charges.js', version=config.VERSION) }}" type="text/javascript"></script> | |
34 | +{% include 'charges-includes.html' %} | |
35 | 35 | <script> |
36 | 36 | build_chart("#project_services_chart", |
37 | 37 | "{{url_for('main.charge_project_csv', project_id=project.id, category='service')}}", | ... | ... |
... | ... | @@ -0,0 +1,7 @@ |
1 | +<script | |
2 | + src="https://cdn.rawgit.com/eligrey/canvas-toBlob.js/f1a01896135ab378aa5c0118eadd81da55e698d8/canvas-toBlob.js"></script> | |
3 | +<script | |
4 | + src="https://cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js"></script> | |
5 | +<script src="https://d3js.org/d3.v6.min.js"></script> | |
6 | +<script src="{{ url_for('main.static', filename='js/svg_to_png.js', version=config.VERSION) }}" type="text/javascript"></script> | |
7 | +<script src="{{ url_for('main.static', filename='js/charges.js', version=config.VERSION) }}" type="text/javascript"></script> | ... | ... |