Commit 6c51f5770678962a71a1ebb2719d6ff3248f98b6
Exists in
master
and in
4 other branches
Projects Stats Chart
Showing
8 changed files
with
208 additions
and
60 deletions
Show diff stats
CHANGELOG.md
VERSION.txt
app/db_mgr.py
... | ... | @@ -202,6 +202,28 @@ def charges_by_project_stacked(project_id, category="service"): |
202 | 202 | return all_charges |
203 | 203 | |
204 | 204 | |
205 | +def charges_for_projects_stacked(): | |
206 | + sql_req = """ | |
207 | + select p.name, sum(charge_rate) as tot_charg | |
208 | + from charge | |
209 | + join project p on p.id = charge.project_id | |
210 | + where period_id = {} | |
211 | + group by project_id | |
212 | + """ | |
213 | + projects_req = "select name from project order by id" | |
214 | + projects_names = [name for (name,) in db.session.execute(projects_req)] | |
215 | + headers = ['period'] + projects_names | |
216 | + all_charges = [headers] | |
217 | + for (period_id, period_name) in db.session.execute("select id, name from period order by id"): | |
218 | + project_charges = [period_name] | |
219 | + charges_for_projects_req = sql_req.format(period_id) | |
220 | + for( project_name, project_charge) in db.session.execute(charges_for_projects_req): | |
221 | + project_charge = str(round(project_charge / charge_unit, 2)) if project_charge else '0' | |
222 | + project_charges.append(project_charge) | |
223 | + all_charges.append(project_charges) | |
224 | + return all_charges | |
225 | + | |
226 | + | |
205 | 227 | def charges_by_agent_stacked(agent_id): |
206 | 228 | """ |
207 | 229 | Build the list of charges for all projects of one agent, period by period | ... | ... |
app/main/routes.py
... | ... | @@ -54,6 +54,13 @@ def projects(): |
54 | 54 | projects=all_projects) |
55 | 55 | |
56 | 56 | |
57 | +@bp.route('/projects/stats') | |
58 | +@role_required('project') | |
59 | +def projects_stats(): | |
60 | + num_projects = len(Project.query.all()) | |
61 | + return render_template('projects_stats.html', subtitle="Statistiques des projets ({})".format(num_projects)) | |
62 | + | |
63 | + | |
57 | 64 | @bp.route('/agents') |
58 | 65 | @role_required('project') |
59 | 66 | def agents(): |
... | ... | @@ -277,3 +284,16 @@ def charge_agent_csv(agent_id): |
277 | 284 | resp = make_response("\n".join(csv_table)) |
278 | 285 | resp.headers['Content-Type'] = 'text/plain;charset=utf8' |
279 | 286 | return resp |
287 | + | |
288 | + | |
289 | +@bp.route('/charge/projects') | |
290 | +@role_required('project') | |
291 | +def projects_stats_csv(): | |
292 | + csv_table = [] | |
293 | + for line in db_mgr.charges_for_projects_stacked(): | |
294 | + line = [cell.replace(",", "-") for cell in line] | |
295 | + line_string = ",".join(line) | |
296 | + csv_table.append(line_string) | |
297 | + resp = make_response("\n".join(csv_table)) | |
298 | + resp.headers['Content-Type'] = 'text/plain;charset=utf8' | |
299 | + return resp | ... | ... |
app/main/static/js/charges.js
... | ... | @@ -7,7 +7,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
7 | 7 | |
8 | 8 | const main_elt = document.getElementById("main") |
9 | 9 | |
10 | - const margin = {top: 60, right: 150, bottom: 200, left: 90}, | |
10 | + var margin = {top: 60, right: 100, bottom: 200, left: 90}, | |
11 | 11 | width = main_elt.offsetWidth * 0.95 - margin.left - margin.right, |
12 | 12 | height = 600 - margin.top - margin.bottom; |
13 | 13 | |
... | ... | @@ -18,32 +18,29 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
18 | 18 | |
19 | 19 | const y_ticks_num = 5 // dont show to many y ticks for lisibility |
20 | 20 | |
21 | - const legendRectSize = 15; // size of rectangle in legend | |
22 | - | |
23 | - // const legend_cell_size = 15; // size of legend colored scare | |
24 | - // const legend_x = width + 20; // begin legend a bit after the end of the chart | |
25 | - // const legend_y = 600 - margin.bottom + 20; // begin legend a bit after the end of the chart | |
21 | + const legend_rect_size = 15; // size of rectangle in legend | |
22 | + const legend_height = 20; | |
23 | + const legend_max_length = 20; | |
24 | + const legend_spacing = 5; | |
26 | 25 | |
27 | 26 | const dot_radius = 6; // size of total line dot |
28 | 27 | |
29 | - const xScale = d3.scaleBand() | |
30 | - .range([0, width]) | |
31 | - .padding(0.4); | |
32 | - | |
33 | - const yScale = d3.scaleLinear() | |
34 | - .range([height, 0]); | |
35 | 28 | |
36 | 29 | var colorScale = d3.scaleOrdinal([]); // Will be really set later by category type |
37 | 30 | |
38 | - | |
39 | 31 | // |
40 | 32 | // Configure chart by the category given as arg |
41 | 33 | // |
42 | 34 | var chart_title = "" |
43 | 35 | var category_title = "" |
36 | + var draw_areas = () => void 0; | |
44 | 37 | var draw_total_line = () => void 0; |
45 | 38 | var draw_categories_bars = () => void 0; |
46 | 39 | |
40 | + var mouseoverlegend = category_mouseoverlegend; | |
41 | + var mouseleavelegend = category_mouseleavelegend; | |
42 | + | |
43 | + | |
47 | 44 | if (category_type == 'capacity') { |
48 | 45 | chart_title = "Charges par fonction" |
49 | 46 | category_title = "Fonction" |
... | ... | @@ -61,10 +58,28 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
61 | 58 | category_title = "Projet" |
62 | 59 | draw_categories_bars = draw_categories_stacked |
63 | 60 | colorScale = d3.scaleOrdinal(d3.schemeTableau10); |
61 | + } else if (category_type == 'area') { | |
62 | + margin.bottom = 400 | |
63 | + height = 1200 - margin.top - margin.bottom; | |
64 | + chart_title = "Tous agents confondus" | |
65 | + category_title = "Projet" | |
66 | + draw_areas = draw_projects_areas | |
67 | + colorScale = d3.scaleOrdinal(d3.schemeTableau10); | |
68 | + mouseoverlegend = () => void 0; | |
69 | + mouseleavelegend = () => void 0; | |
64 | 70 | } else { |
65 | 71 | alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !") |
66 | 72 | } |
67 | 73 | |
74 | + | |
75 | + const xScale = d3.scaleBand() | |
76 | + .range([0, width]) | |
77 | + .padding(0.4); | |
78 | + | |
79 | + const yScale = d3.scaleLinear() | |
80 | + .range([height, 0]); | |
81 | + | |
82 | + | |
68 | 83 | // Configure the tooltip to export svg to png or csv |
69 | 84 | var update_export_menu = function (e, d) { |
70 | 85 | |
... | ... | @@ -134,6 +149,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
134 | 149 | var category_name = d3.select(this.parentNode).datum().key |
135 | 150 | var category_charge = d.data[category_name] |
136 | 151 | show_tooltip(e, category_name, category_charge) |
152 | + console.log("HELLO") | |
137 | 153 | } |
138 | 154 | |
139 | 155 | var mouseovergrouped = function (e, d) { |
... | ... | @@ -173,34 +189,34 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
173 | 189 | .style("top", (e.pageY - tooltip_offset_dot.dy) + "px") |
174 | 190 | } |
175 | 191 | |
176 | - function mouseoverlegend() { | |
192 | + function category_mouseoverlegend() { | |
177 | 193 | var legend_category = $(this).attr("class"); |
178 | 194 | var bar_category = document.getElementsByClassName(legend_category); |
179 | 195 | if (bar_category[0].tagName === "g") { |
180 | 196 | var rect_to_hover = bar_category[0].children; |
181 | 197 | (bar_category[1].children[0]).classList.add("brillance"); |
182 | - for (var i=0;i<rect_to_hover.length;i++) { | |
198 | + for (var i = 0; i < rect_to_hover.length; i++) { | |
183 | 199 | rect_to_hover[i].classList.add("brillance"); |
184 | 200 | } |
185 | 201 | } else { |
186 | - for (var i=0;i<bar_category.length;i++) { | |
202 | + for (var i = 0; i < bar_category.length; i++) { | |
187 | 203 | bar_category[i].classList.add("brillance"); |
188 | 204 | } |
189 | 205 | } |
190 | 206 | } |
191 | 207 | |
192 | - function mouseleavelegend() { | |
208 | + function category_mouseleavelegend() { | |
193 | 209 | var legend_category = $(this).attr("class"); |
194 | 210 | var bar_category = document.getElementsByClassName(legend_category); |
195 | 211 | if (bar_category[0].tagName === "g") { |
196 | 212 | var rect_to_hover = bar_category[0].children; |
197 | 213 | (bar_category[1].children[0]).classList.remove("brillance"); |
198 | - for (var i=0;i<rect_to_hover.length;i++) { | |
214 | + for (var i = 0; i < rect_to_hover.length; i++) { | |
199 | 215 | rect_to_hover[i].classList.remove("brillance"); |
200 | 216 | } |
201 | 217 | } else { |
202 | 218 | var lenght_i = bar_category.length - 1; |
203 | - for (var i=lenght_i;i>=0;i--) { | |
219 | + for (var i = lenght_i; i >= 0; i--) { | |
204 | 220 | bar_category[i].classList.remove("brillance"); |
205 | 221 | } |
206 | 222 | } |
... | ... | @@ -210,7 +226,13 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
210 | 226 | |
211 | 227 | // add horizontal legend |
212 | 228 | let legend_keys = color_scale.domain(); |
213 | - var legendSpacing = 5; | |
229 | + | |
230 | + // Truncate legend keys when too long | |
231 | + legend_keys = legend_keys.map(function (l) { | |
232 | + const n = legend_max_length; | |
233 | + return (l.length > n) ? l.substr(0, n - 1) : l; | |
234 | + }); | |
235 | + | |
214 | 236 | |
215 | 237 | var legendWrap = svg.append('g') |
216 | 238 | .attr('width', '100%') |
... | ... | @@ -220,38 +242,40 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
220 | 242 | .data(legend_keys) |
221 | 243 | .enter() |
222 | 244 | .append('g') |
223 | - .attr('class', function (d) { return d}) | |
245 | + .attr('class', d => d) | |
224 | 246 | .on("mouseover", mouseoverlegend) |
225 | 247 | .on("mouseleave", mouseleavelegend); |
226 | 248 | |
227 | 249 | legend.append('rect') |
228 | - .attr('width', legendRectSize) | |
229 | - .attr('height', legendRectSize) | |
250 | + .attr('width', legend_rect_size) | |
251 | + .attr('height', legend_rect_size) | |
230 | 252 | .style('fill', color_scale) |
231 | 253 | .attr('class', 'legend'); |
232 | 254 | |
233 | 255 | legend.append('text') |
234 | 256 | .attr('class', 'legend') |
235 | - .attr('x', legendRectSize + legendSpacing * 1.5) | |
257 | + .attr('x', legend_rect_size + legend_spacing * 1.5) | |
236 | 258 | .attr('y', 13) |
237 | - .text(function (d) { | |
238 | - return d; | |
239 | - }); | |
259 | + .text(d => d); | |
240 | 260 | |
241 | 261 | var ypos = 0, |
242 | 262 | newxpos = 0, |
243 | 263 | maxwidth = 0, |
244 | 264 | xpos; |
245 | 265 | |
266 | + // Get Max legend key length for later display | |
267 | + const text_length_list = legend.selectAll('text.legend')._groups.map(el => el[0].getComputedTextLength()) | |
268 | + const keycol_length = Math.max(...text_length_list) + 40 | |
269 | + | |
246 | 270 | legend |
247 | 271 | .attr("transform", function (d, i) { |
248 | - var length = d3.select(this).select("text").node().getComputedTextLength() + 40; | |
249 | 272 | xpos = newxpos; |
250 | - if (width < xpos + length) { | |
273 | + if (width < xpos + keycol_length) { | |
251 | 274 | newxpos = xpos = 0; |
252 | - ypos += 30; | |
275 | + ypos += legend_height; | |
276 | + | |
253 | 277 | } |
254 | - newxpos += length; | |
278 | + newxpos += keycol_length; | |
255 | 279 | if (newxpos > maxwidth) maxwidth = newxpos; |
256 | 280 | |
257 | 281 | return 'translate(' + xpos + ',' + ypos + ')'; |
... | ... | @@ -263,11 +287,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
263 | 287 | .attr("transform", function (d, i) { |
264 | 288 | return "translate(0 ," + (height + 100) + ")"; |
265 | 289 | }); |
266 | - | |
267 | 290 | } |
268 | 291 | |
269 | 292 | function draw_categories_grouped(data, categories) { |
270 | 293 | |
294 | + // TODO: use ymax_from_stacked | |
271 | 295 | // Get the max y to plot |
272 | 296 | // If no data at all, force to 0 |
273 | 297 | if (categories.length == 0) { |
... | ... | @@ -307,14 +331,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
307 | 331 | .enter().append("rect") |
308 | 332 | .attr("x", d => xCategories(d.key)) |
309 | 333 | .attr("width", xCategories.bandwidth()) |
310 | - /* Transition part */ | |
311 | - .attr("y", d => { return height; }) | |
312 | - .attr("height", 0) | |
313 | - .transition() | |
314 | - .duration(750) | |
315 | - .delay(function (d, i) { | |
316 | - return i * 150; | |
317 | - }) | |
334 | + /* Transition part */ | |
335 | + .attr("y", d => { | |
336 | + return height; | |
337 | + }) | |
338 | + .attr("height", 0) | |
339 | + .transition() | |
340 | + .duration(750) | |
341 | + .delay(function (d, i) { | |
342 | + return i * 150; | |
343 | + }) | |
318 | 344 | .attr("y", d => yScale(d.value)) |
319 | 345 | .attr("height", d => height - yScale(d.value)) |
320 | 346 | .attr("fill", d => colorScale(d.key)) |
... | ... | @@ -328,15 +354,7 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
328 | 354 | |
329 | 355 | } |
330 | 356 | |
331 | - function draw_categories_stacked(data, categories) { | |
332 | - | |
333 | - // Now build the stacked data for stacked bars | |
334 | - // | |
335 | - var stack = d3.stack() | |
336 | - .keys(categories) | |
337 | - // .order(d3.stackOrderNone) | |
338 | - // .offset(d3.stackOffsetNone); | |
339 | - var stacked_data = stack(data) | |
357 | + function ymax_from_stacked(stacked_data) { | |
340 | 358 | |
341 | 359 | // Get the max y to plot |
342 | 360 | // If no data at all, force to 0 |
... | ... | @@ -352,6 +370,52 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
352 | 370 | // Enhance it by %ratio to leave room on the top of the graph |
353 | 371 | y_max = y_max * height_ratio |
354 | 372 | |
373 | + return y_max | |
374 | + } | |
375 | + | |
376 | + function draw_projects_areas(data, projects) { | |
377 | + | |
378 | + // Now build the stacked data for stacked bars | |
379 | + // | |
380 | + var stack = d3.stack() | |
381 | + .keys(projects) | |
382 | + // .order(d3.stackOrderNone) | |
383 | + // .offset(d3.stackOffsetNone); | |
384 | + var stacked_data = stack(data) | |
385 | + | |
386 | + const y_max = ymax_from_stacked(stacked_data); | |
387 | + yScale.domain([0, y_max]); | |
388 | + | |
389 | + const area = d3.area() | |
390 | + .x(d => xScale(d.data.period)) | |
391 | + .y0(d => yScale(d[0])) | |
392 | + .y1(d => yScale(d[1])) | |
393 | + | |
394 | + let paths = svg.append("g") | |
395 | + .selectAll("path") | |
396 | + .data(stacked_data) | |
397 | + .join("path") | |
398 | + .attr("fill", ({key}) => colorScale(key)) | |
399 | + .attr("stroke", 'black') | |
400 | + .attr("stroke-width", '0.2pt') | |
401 | + .attr("d", area) | |
402 | + .append("title") | |
403 | + .text(({key}) => key); | |
404 | + | |
405 | + } | |
406 | + | |
407 | + | |
408 | + function draw_categories_stacked(data, categories) { | |
409 | + | |
410 | + // Now build the stacked data for stacked bars | |
411 | + // | |
412 | + var stack = d3.stack() | |
413 | + .keys(categories) | |
414 | + // .order(d3.stackOrderNone) | |
415 | + // .offset(d3.stackOffsetNone); | |
416 | + var stacked_data = stack(data) | |
417 | + | |
418 | + const y_max = ymax_from_stacked(stacked_data); | |
355 | 419 | yScale.domain([0, y_max]); |
356 | 420 | |
357 | 421 | // first draw one group for one category containing all bars over periods |
... | ... | @@ -370,14 +434,16 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
370 | 434 | .attr("class", "bar") |
371 | 435 | .attr("x", d => xScale(d.data.period)) |
372 | 436 | .attr("width", xScale.bandwidth()) |
373 | - /* transition part */ | |
374 | - .attr("y", d => { return height; }) | |
375 | - .attr("height", 0) | |
376 | - .transition() | |
377 | - .duration(750) | |
378 | - .delay(function (d, i) { | |
379 | - return i * 150; | |
380 | - }) | |
437 | + /* transition part */ | |
438 | + .attr("y", d => { | |
439 | + return height; | |
440 | + }) | |
441 | + .attr("height", 0) | |
442 | + .transition() | |
443 | + .duration(750) | |
444 | + .delay(function (d, i) { | |
445 | + return i * 150; | |
446 | + }) | |
381 | 447 | .attr("y", d => yScale(d[1])) |
382 | 448 | .attr("height", d => height - yScale(d[1] - d[0])) |
383 | 449 | |
... | ... | @@ -437,8 +503,8 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
437 | 503 | |
438 | 504 | d3.csv(data_url).then(data => { |
439 | 505 | // we could get categories that way, |
440 | - // but instead we want to filter by non zero, see later | |
441 | 506 | // var categories = data.columns.slice(1) |
507 | + // but instead we want to filter by non zero, see later | |
442 | 508 | |
443 | 509 | var periods = d3.map(data, d => d.period) |
444 | 510 | xScale.domain(periods) |
... | ... | @@ -477,6 +543,11 @@ function build_chart(div_selector, data_url, entity_name, category_type) { |
477 | 543 | } |
478 | 544 | |
479 | 545 | // |
546 | + // Draw the areas, stacked. | |
547 | + // | |
548 | + draw_areas(data, categories) | |
549 | + | |
550 | + // | |
480 | 551 | // Draw the bars, stacked or grouped |
481 | 552 | // |
482 | 553 | draw_categories_bars(data, categories) | ... | ... |
... | ... | @@ -0,0 +1,25 @@ |
1 | +{% extends "base_page.html" %} | |
2 | +{% block more_heads %} | |
3 | + <link href="{{ url_for('main.static', filename='css/charges.css', version=config.VERSION) }}" rel="stylesheet" | |
4 | + type="text/css"/> | |
5 | +{% endblock %} | |
6 | + | |
7 | +{% block content %} | |
8 | + | |
9 | + <!-- Invisible span to definte wich ul and a in the navbar are actived --> | |
10 | + <span id="nav_actived" style="display: none">project,projects_stats</span> | |
11 | + | |
12 | + <div class="charge_chart" id="projects_stats_chart"></div> | |
13 | +{% endblock %} | |
14 | + | |
15 | +{% block more_scripts %} | |
16 | + {% include 'd3js-includes.html' %} | |
17 | + {% include 'charges-includes.html' %} | |
18 | + | |
19 | + <script> | |
20 | + build_chart("#projects_stats_chart", | |
21 | + "{{url_for('main.projects_stats_csv')}}", | |
22 | + "Charge des projets", | |
23 | + "area"); | |
24 | + </script> | |
25 | +{% endblock %} | ... | ... |
app/templates/base_page.html
... | ... | @@ -71,7 +71,7 @@ |
71 | 71 | <li class="nav-item"><a id="categories" class="sub_link nav-link" href="{{ url_for('main.categories') }}">Liste des catégories</a></li> |
72 | 72 | <li class="nav-item"><a id="labels" class="sub_link nav-link" href="{{ url_for('main.labels') }}">Liste des labels</a></li> |
73 | 73 | <li class="nav-item"><a class="sub_link nav-link disabled" href="#">Listes des statuts de projets</a> |
74 | - <li class="nav-item"><a class="sub_link nav-link disabled" href="#">Statistiques</a></li> | |
74 | + <li class="nav-item"><a id="projects_stats" class="sub_link nav-link" href="{{ url_for('main.projects_stats') }}">Statistiques</a></li> | |
75 | 75 | </li> |
76 | 76 | </ul> |
77 | 77 | </li> | ... | ... |
tests/backend_tests.py
... | ... | @@ -75,6 +75,12 @@ class DbMgrTestCase(BaseTestCase): |
75 | 75 | # Waiting for 17 periods + headers line |
76 | 76 | self.assertEqual(18, len(stacked_charges)) |
77 | 77 | |
78 | + def test_charges_for_projects_stacked(self): | |
79 | + stacked_charges = db_mgr.charges_for_projects_stacked() | |
80 | + # Waiting for 17 periods + headers line | |
81 | + self.assertEqual(18, len(stacked_charges)) | |
82 | + self.assertEqual(102, len(stacked_charges[0])) | |
83 | + | |
78 | 84 | def test_category_labels(self): |
79 | 85 | category_labels = db_mgr.category_labels() |
80 | 86 | categories = [_cl['name'] for _cl in category_labels] | ... | ... |