/* Round float to two decimals only */ function roundToTwo(num) { return +(Math.round(num + "e+2") + "e-2"); } function build_chart(div_selector, data_url, entity_name, category_type) { const main_elt = document.getElementById("main") const margin = {top: 60, right: 150, bottom: 200, left: 90}, width = main_elt.offsetWidth * 0.95 - margin.left - margin.right, height = 600 - margin.top - margin.bottom; const height_ratio = 1.2; // how murch room to give above chart for lisibility const tooltip_offset = {dx: 0, dy: 100} const tooltip_offset_dot = {dx: 20, dy: 60} const y_ticks_num = 5 // dont show to many y ticks for lisibility const legendRectSize = 15; // size of rectangle in legend // const legend_cell_size = 15; // size of legend colored scare // const legend_x = width + 20; // begin legend a bit after the end of the chart // const legend_y = 600 - margin.bottom + 20; // begin legend a bit after the end of the chart const dot_radius = 6; // size of total line dot const xScale = d3.scaleBand() .range([0, width]) .padding(0.4); const yScale = d3.scaleLinear() .range([height, 0]); var colorScale = d3.scaleOrdinal([]); // Will be really set later by category type // // Configure chart by the category given as arg // var chart_title = "" var category_title = "" var draw_total_line = () => void 0; var draw_categories_bars = () => void 0; if (category_type == 'capacity') { chart_title = "Charges par fonction" category_title = "Fonction" //draw_total_line draw_categories_bars = draw_categories_grouped colorScale = d3.scaleOrdinal(d3.schemeSet3); } else if (category_type == 'service') { chart_title = "Charges par service" category_title = "Service" draw_total_line = draw_totalcharge_line draw_categories_bars = draw_categories_stacked colorScale = d3.scaleOrdinal(d3.schemeTableau10); } else if (category_type == 'project') { chart_title = "Charges par projet" category_title = "Projet" draw_categories_bars = draw_categories_stacked colorScale = d3.scaleOrdinal(d3.schemeTableau10); } else { alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !") } // Configure the tooltip to export svg to png or csv var update_export_menu = function (e, d) { const tooltip_test = document.getElementsByClassName('tooltip_hamburger'); if (tooltip_test.length == 0) { d3.select(".tooltip_hamburger").remove(); const tooltip_hamburger = hamburger.append("div") .style("opacity", 0) .attr("class", "tooltip tooltip_hamburger"); d3.select(this).transition() .duration(1) tooltip_hamburger .transition() .duration(200) .style("opacity", 1) tooltip_hamburger .html("Export " + "
  • To CSV
  • " + "
  • To PNG
  • ") d3.select("#to_png").on("click", download_png); d3.select("#to_csv").on("click", function(){download_csv(this, data_url);}) } else { d3.select(".tooltip_hamburger").remove(); } } // Create a download button inside the div contaning svg chart const hamburger = d3.select(div_selector).append('i') .attr('class', 'fas fa-bars fa-lg export') .on("click", update_export_menu); const svg = d3.select(div_selector).append("svg") .attr("class", "svg_chart") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); const tooltip = d3.select('html') .append("div") .style("opacity", 0) .attr("class", "tooltip") var mousemove = function (e, d) { tooltip .style("left", (e.pageX - tooltip_offset.dx) + "px") .style("top", (e.pageY - tooltip_offset.dy) + "px") } var mouseleave = function (d) { tooltip .transition() .duration(900) .style("opacity", 0) } var mouseover = function (e, d) { var category_name = d3.select(this.parentNode).datum().key var category_charge = d.data[category_name] show_tooltip(e, category_name, category_charge) } var mouseovergrouped = function (e, d) { var category_name = d.key var category_charge = d.value show_tooltip(e, category_name, category_charge) } var show_tooltip = function (e, category_name, category_charge) { tooltip .transition() .duration(200) .style("opacity", 1); tooltip .html("" + category_title + ": " + category_name + "
    " + "Charge: " + category_charge + " ETP") .style("left", (e.pageX - tooltip_offset.dx) + "px") .style("top", (e.pageY - tooltip_offset.dy) + "px") } var mouseleavedot = function (e, d) { d3.select(this).transition() .duration(1) .attr("r", dot_radius); mouseleave(d); } var mouseoverdot = function (e, d) { d3.select(this).transition() .duration(1) .attr("r", dot_radius * 1.5); tooltip .transition() .duration(200) .style("opacity", 1) tooltip .html("" + d.period + ": " + d.total + " ETP") .style("left", (e.pageX - tooltip_offset_dot.dx) + "px") .style("top", (e.pageY - tooltip_offset_dot.dy) + "px") } var addlegend = function (color_scale) { // add horizontal legend let legend_keys = color_scale.domain(); var legendSpacing = 5; var legendWrap = svg.append('g') .attr('width', '100%') .attr('class', 'legendwrap'); var legend = svg.select('.legendwrap').selectAll('.legend') .data(legend_keys) .enter() .append('g') .attr('class', 'legend'); legend.append('rect') .attr('width', legendRectSize) .attr('height', legendRectSize) .style('fill', color_scale) .attr('class', 'legend'); legend.append('text') .attr('class', 'legend') .attr('x', legendRectSize + legendSpacing * 1.5) .attr('y', 13) .text(function(d) { return d; }); var ypos = 0, newxpos = 0, maxwidth = 0, xpos; legend .attr("transform", function(d, i) { var length = d3.select(this).select("text").node().getComputedTextLength() + 40; xpos = newxpos; if (width < xpos + length) { newxpos = xpos = 0; ypos += 30; } newxpos += length; if (newxpos > maxwidth) maxwidth = newxpos; return 'translate(' + xpos + ',' + ypos + ')'; }); legendWrap .attr("transform", function(d, i) { return "translate(0 ," + (height + 100) + ")"; }); } function draw_categories_grouped(data, categories) { var y_max = d3.max(data, function (d) { return d3.max(categories, function (c) { return +d[c] }) }); y_max = y_max * height_ratio yScale.domain([0, y_max]) // Another scale for subgroup position var xCategories = d3.scaleBand() .domain(categories) .range([0, xScale.bandwidth()]) .padding([0.2]) svg.append("g") .selectAll("g") // Enter in data = loop group per group .data(data) .enter() .append("g") .attr("transform", d => "translate(" + xScale(d.period) + ",0)") .selectAll("rect") .data(function (d) { return categories.map(function (key) { return {key: key, value: d[key]}; }); }) .enter().append("rect") .attr("x", d => xCategories(d.key)) .attr("width", xCategories.bandwidth()) /* Transition part */ .attr("y", d => { return height; }) .attr("height", 0) .transition() .duration(750) .delay(function (d, i) { return i * 150; }) .attr("y", d => yScale(d.value)) .attr("height", d => height - yScale(d.value)) .attr("fill", d => colorScale(d.key)) .attr("class", "bar"); svg.selectAll("rect") .on("mouseover", mouseovergrouped) .on("mousemove", mousemove) .on("mouseleave", mouseleave); } function draw_categories_stacked(data, categories) { // Now build the stacked data for stacked bars // var stack = d3.stack() .keys(categories) // .order(d3.stackOrderNone) // .offset(d3.stackOffsetNone); var stacked_data = stack(data) // Get the max y to plot var y_max = d3.max(stacked_data[stacked_data.length - 1], d => d[1]); if (y_max == 0) { y_max = 100 } // Enhance it by %ratio to leave room on the top of the graph y_max = y_max * height_ratio yScale.domain([0, y_max]); // first draw one group for one category containing all bars over periods let groups = svg.selectAll("g.category") .data(stacked_data) .enter() .append("g") .style("fill", (d) => colorScale(d.key)); // then draw each period/category bar let rect = groups.selectAll("rect") .data(d => d) .enter() .append("rect") .attr("class", "bar") .attr("x", d => xScale(d.data.period)) .attr("width", xScale.bandwidth()) /* transition part */ .attr("y", d => { return height; }) .attr("height", 0) .transition() .duration(750) .delay(function (d, i) { return i * 150; }) .attr("y", d => yScale(d[1])) .attr("height", d => height - yScale(d[1] - d[0])) groups.selectAll("rect") .on("mouseover", mouseover) .on("mousemove", mousemove) .on("mouseleave", mouseleave); } function draw_totalcharge_line(data) { // Build the total charge by period; // it will be used for the line drawing above bars. // var periods_total_charge = [] data.forEach(function (d) { // get the list of values for all columns except the first one which is the period name var period_values = Object.values(d).slice(1) var row = {} row['period'] = d.period row['total'] = roundToTwo(d3.sum(period_values)) periods_total_charge.push(row) }); // the line itselet // const line = d3.line() .x(d => (xScale.bandwidth() / 2) + xScale(d.period)) // décalage pour centrer au milieu des barres .y(d => yScale(d.total)) .curve(d3.curveMonotoneX); // Fonction de courbe permettant de l'adoucir svg.append("path") .datum(periods_total_charge) .attr("class", "total-line") .attr("d", line) .lower();// set it below bars if called after // data dots at each point // svg.selectAll("line-circle") .data(periods_total_charge) .enter().append("circle") .attr("class", "total-circle") .attr("r", dot_radius) .attr("cx", function (d) { return (xScale.bandwidth() / 2) + xScale(d.period) }) .attr("cy", function (d) { return yScale(d.total); }) .on("mouseover", mouseoverdot) .on("mouseleave", mouseleavedot) .lower();// set it below bars if called after } d3.csv(data_url).then(data => { // we could get categories that way, // but instead we want to filter by non zero, see later // var categories = data.columns.slice(1) var periods = d3.map(data, d => d.period) xScale.domain(periods) // Filter datas to only keep non zero categories // TODO: should be done on server (python/flask) side // // 1- Get the charge sum for each category over periods // That will leave '0' to categories with no charge at all // TODO: to be done with Object.keys, Object.values and d3 filtering methods // var categories_total_charge = {} data.forEach(function (d) { Object.keys(d).forEach(function (k) { if (k === 'period') { return; } if (categories_total_charge.hasOwnProperty(k)) { categories_total_charge[k] += +d[k] } else { categories_total_charge[k] = +d[k] } categories_total_charge[k] = roundToTwo(categories_total_charge[k]) } ) }) // 2- Now, filter only categories that have a non-zero charge // TODO: to be done with Object.keys, Object.values and d3 filtering methods // var categories = [] for (var key in categories_total_charge) { if (categories_total_charge[key] > 0) { categories.push(key) } } // // Draw the bars, stacked or grouped // draw_categories_bars(data, categories) // // Draw the total charge line ( may be) // draw_total_line(data, categories) // // This former list we use as color_scale domain. // And that allows us to build the legend // colorScale.domain(categories) addlegend(colorScale) // Xaxis // const xAxis = d3.axisBottom(xScale) // Draw Xaxis svg.append("g") .attr("class", "x_axis") .attr("transform", "translate(0," + height + ")") .call(xAxis) .selectAll("text") .style("text-anchor", "end") .attr("dx", "-.9em") .attr("dy", ".15em") .attr("transform", "rotate(-65)"); // Yaxis // const yAxis = d3.axisLeft(yScale) .ticks(y_ticks_num) // Draw Yaxis svg.append("g") .attr("class", "y_axis") .call(yAxis) // Draw horizontal lines svg.selectAll("y axis") .data(yScale.ticks(y_ticks_num)) .enter() .append("line") .attr("class", d => (d == 0 ? "horizontalY0" : "horizontalY")) .attr("x1", 0) .attr("x2", width) .attr("y1", d => yScale(d)) .attr("y2", d => yScale(d)) .lower();// set it below bars if called after // Write Y axis title svg.append("text") .attr("text-anchor", "end") .attr("transform", "rotate(-90)") .attr("y", -margin.left + 40) .attr("x", -margin.top - 70) .text("Charge en ETP"); // // Write chart Title // // part 1 svg.append("text") .attr("x", (width / 2)) .attr("y", 0 - (margin.top / 2) - 10) .attr("text-anchor", "middle") .style("font-size", "16px") .text(entity_name); // part 2 svg.append("text") .attr("x", (width / 2)) .attr("y", 0 - (margin.top / 2) + 10) .attr("text-anchor", "middle") .style("font-size", "12px") .text(chart_title); }); // end of d3.csv().then({}) }