/* 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")
var 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 legend_rect_size = 15; // size of rectangle in legend
const legend_height = 20;
const legend_max_length = 30;
const legend_spacing = 5;
const dot_radius = 6; // size of total line dot
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_areas = () => void 0;
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 if (category_type == 'area') {
margin = {top: 60, right: 150, bottom: 350, left: 90};
height = 1200 - margin.top - margin.bottom;
chart_title = "Tous agents confondus"
category_title = "Projet"
draw_areas = draw_projects_areas
colorScale = d3.scaleOrdinal(d3.schemeTableau10);
} else {
alert("ALERT ! Every body shall quit the boat, we are sinking ! ALERT !")
}
const xScale = d3.scaleBand()
.range([0, width])
.padding(0.4);
const yScale = d3.scaleLinear()
.range([height, 0]);
// 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 real_width = width + margin.left + margin.right;
const real_height = height + margin.top + margin.bottom;
const svg = d3.select(div_selector).append("svg")
.attr("viewBox", [0, 0, real_width, real_height])
.attr("class", "svg_chart")
.attr("width", real_width)
.attr("height", real_height)
.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")
}
function mouseoverlegend() {
var legend_category = $(this).attr("class");
var bar_category = document.getElementsByClassName(legend_category);
if (bar_category[0].tagName === "g") {
var rect_to_hover = bar_category[0].children;
(bar_category[1].children[0]).classList.add("brillance");
for (var i=0;i=0;i--) {
bar_category[i].classList.remove("brillance");
}
}
}
var addlegend = function (color_scale) {
// add horizontal legend
let legend_keys = color_scale.domain();
// Truncate legend keys when too long
legend_keys = legend_keys.map(function (l) {
const n = legend_max_length;
return (l.length > n) ? l.substr(0, n - 1) : l;
});
// Get Max legend key length for later display
const max_key_length = Math.max(...legend_keys.map(el => el.length));
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', function (d) { return d})
.on("mouseover", mouseoverlegend)
.on("mouseleave", mouseleavelegend);
legend.append('rect')
.attr('width', legend_rect_size)
.attr('height', legend_rect_size)
.style('fill', color_scale)
.attr('class', 'legend');
legend.append('text')
.attr('class', 'legend')
.attr('x', legend_rect_size + legend_spacing * 1.5)
.attr('y', 13)
.text(d => d);
var ypos = 0,
newxpos = 0,
maxwidth = 0,
xpos;
legend
.attr("transform", function (d, i) {
const key_length = 9*max_key_length;
xpos = newxpos;
if (width < xpos + key_length) {
newxpos = xpos = 0;
ypos += legend_height;
}
newxpos += key_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) {
// Get the max y to plot
// If no data at all, force to 0
if (categories.length == 0) {
y_max = 0
} else {
var y_max = d3.max(data, function (d) {
return d3.max(categories, function (c) {
return +d[c]
})
});
}
// Force maximum in any cases
if (y_max == 0) {
y_max = 100
}
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", d => d.key);
svg.selectAll("rect")
.on("mouseover", mouseovergrouped)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave);
}
function draw_projects_areas(data, projects) {
// Now build the stacked data for stacked bars
//
var stack = d3.stack()
.keys(projects)
// .order(d3.stackOrderNone)
// .offset(d3.stackOffsetNone);
var stacked_data = stack(data)
yScale.domain([0, 85]);
const area = d3.area()
.x(d => xScale(d.data.period))
.y0(d => yScale(d[0]))
.y1(d => yScale(d[1]))
let paths = svg.append("g")
.selectAll("path")
.data(stacked_data)
.join("path")
.attr("fill", ({key}) => colorScale(key))
.attr("stroke", 'black')
.attr("stroke-width", '0.2pt')
.attr("d", area)
.append("title")
.text(({key}) => key);
paths.selectAll("area")
.on("mouseover", mouseover)
.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
// If no data at all, force to 0
if (stacked_data.length == 0) {
y_max = 0
} else {
var y_max = d3.max(stacked_data[stacked_data.length - 1], d => d[1]);
}
// Force maximum in any cases
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")
.attr("class", d => d.key)
.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,
// var categories = data.columns.slice(1)
// but instead we want to filter by non zero, see later
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 areas, stacked.
//
draw_areas(data, categories)
//
// 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({})
}