const xAxisSelect = document.querySelector(".x-axis-value") const yAxisSelect = document.querySelector(".y-axis-value") const xAxisDetails = document.querySelector(".x-axis-details") const emptyGraph = document.querySelector(".empty-graph") const exportButton = document.querySelector(".export") // Get the DOM elements for all of the axes, so that we can add event listeners for when they are changed const axesSettings = document.querySelectorAll(".axis-setting") // Colours for our graph const fill = "steelblue" const hoverFill = "#2D4053" // Get the graph data const data = graphData["chart_data"] // Set graph dimensions var width = document.getElementById('graph').clientWidth; var height = document.getElementById('graph').clientHeight; // Re set dimensions on window resize window.onresize = function(){ width = document.getElementById('graph').clientWidth; height = document.getElementById('graph').clientHeight; svg.attr('width', width).attr('height', height); } // Set margins around graph for axis and labels const margin = { top: 20, right: 20, bottom: 60, left: 80 }; // Set the graph width and height to account for axes const gWidth = width - margin.left - margin.right; const gHeight = height - margin.top - margin.bottom; // Create SVG ready for graph const svg = d3.select('#graph').append("svg").attr("width", width).attr("height", height).attr("viewBox", "0 0 " + width + " " + height).attr("preserveAspectRatio", "none") // Add the graph area to the SVG, factoring in the margin dimensions var graph = svg.append('g').attr("transform", `translate(${margin.left}, ${margin.top})`) // EVENT LISTENERS // When the axes are altered, we need to re-group the data depending on the variables set axesSettings.forEach(setting => { setting.onchange = function(){ axisChange() } }) // If the value is not equal to an empty string on page load (i.e. user is editing graph) if(xAxisSelect.options[xAxisSelect.selectedIndex].value != ''){ axisChange() } // Export button that allows user to export and download the SVG as a PNG image exportButton.addEventListener("click", () => { let title = document.querySelector(".title").value let exportTitle = title == "" ? "plot.png": `${title}.png` saveSvgAsPng(document.getElementsByTagName("svg")[0], exportTitle, {scale: 2, backgroundColor: "#FFFFFF"}); }) function axisChange (){ let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; // Hide the overlay emptyGraph.classList.remove("visible"); emptyGraph.classList.add("invisible"); // Remove the ' -- select an option -- ' option yAxisSelect.firstChild.hidden = true; // Reveal the y-axis variable for the user to select xAxisDetails.classList.remove('hidden-down') let dataStats = getData(yAxisValue, xAxisValue) // re-draw the graph with the chosen variables render(dataStats); } // Function used for getting different stats needed for box-whisker plot and for each group // Returns an function that can calculate the stats for each x-axis group. function getData(yAxisValue, xAxisValue){ // We use our nested data to calculate the first quartile, third quartile, median, min and max var dataStats = d3.nest() // nest function allows to group the calculation per level of a factor // Depending if user selects a x-axis variable, we group the data .key(function(d) { return xAxisValue == "" ? 1 : d[xAxisValue]}) .rollup(function(d) { let orderedArray = d.map(g => g[yAxisValue]).sort(d3.ascending) q1 = d3.quantile(orderedArray,.25) median = d3.quantile(orderedArray,.5) q3 = d3.quantile(orderedArray,.75) interQuantileRange = q3 - q1 // Whiskers can have multiple different meanings. In mine, I have decided to // go with the commonly used highest-value and lowest-values // min = q1 - 1.5 * interQuantileRange // max = q3 + 1.5 * interQuantileRange min = d3.min(orderedArray) max = d3.max(orderedArray) return({q1: q1, median: median, q3: q3, interQuantileRange: interQuantileRange, min: min, max: max}) }) .entries(data) return dataStats } function render(dataStats){ let xAxisValue = xAxisSelect.options[xAxisSelect.selectedIndex].value; let yAxisValue = yAxisSelect.options[yAxisSelect.selectedIndex].value; // Specify the x-axis values and the y-axis valus const xValues = d => d.key; const hoverText = d => ` Q1: ${d.value.q1} Q3: ${d.value.q3} Median: ${d.value.median} Max: ${d.value.max} Min: ${d.value.min} `; // Remove old axes labels (if they exist) d3.selectAll('.label').remove(); dataStats.sort(function(a, b) { return d3.ascending(parseInt(a.key), parseInt(b.key))}); // Get the highest value from our objects so we can base the y-axis on that. // I could instead find the highest value in that column. However, doing it this way // it means I can change the definition of the whiskers without needing to change // in both places. See above. let yMax = Math.max.apply(Math, dataStats.map(function(object) { return object.value.max})) // Set the scale for the y-axis const yScale = d3.scaleLinear() .domain([0, yMax]).nice() .range([gHeight, 0]) // Set the scale for the x-axis const xScale = d3.scaleBand() .domain(dataStats.map(xValues)) .range([0, gWidth]) .paddingInner(.3) .paddingOuter(.1) // Select the axes (if they exist) var yAxis = d3.selectAll(".yAxis") var xAxis = d3.selectAll(".xAxis") // If they dont exist, we create them. If they do, we update them if (yAxis.empty() && xAxis.empty()){ // For the x-axis we create an axisBottom and 'translate' it so it appears on the bottom of the graph graph.append('g').attr("class", "xAxis").call(d3.axisBottom(xScale)) .attr("transform", `translate(0, ${gHeight})`) // For y axis we do not need to translate it, as the default is on the left graph.append('g').attr("class", "yAxis").call(d3.axisLeft(yScale)) } else { // Adjust the x-axis according the x-axis variable data xAxis.transition() .duration(1000) .call(d3.axisBottom(xScale)) // Adjust the y-axis according the y-axis variable data yAxis.transition() .duration(1000) .call(d3.axisLeft(yScale)) } let yAxisLabel = yAxisValue // Add y axis label svg.append("text") .attr("transform", "rotate(-90)") .attr("class", "label") .attr("y", 0) .attr("x",0 - (gHeight / 2)) .attr("dy", "1em") .style("text-anchor", "middle") .text(yAxisLabel); // Add x axis label svg.append("text") .attr("transform",`translate(${width/2}, ${gHeight + margin.top + 55})`) .attr("class", "label") .style("text-anchor", "middle") .text(xAxisValue); let boxDom = graph.selectAll("rect").data(dataStats) let medianDom = graph.selectAll(".medianLine").data(dataStats) let vertDom = graph.selectAll(".vertLine").data(dataStats) let minDom = graph.selectAll(".minLine").data(dataStats) let maxDom = graph.selectAll(".maxLine").data(dataStats) // Exit functions, for when the SVG elements are no longer linked with data elements medianDom.exit().remove() vertDom.exit().remove() minDom.exit().remove() maxDom.exit().remove() boxDom.exit() .transition() .duration(600) .attr("height", 0) .attr("width", 0) .remove() // ------Vertical line enter, merge and exit functions------ let vertical = vertDom .enter() .append("line") .attr("class", "vertLine") .attr("x1", d => xScale(xValues(d)) + xScale.bandwidth()/2) .attr("x2", d => xScale(xValues(d)) + xScale.bandwidth()/2) .attr("y1", d => yScale(d.value.median)) .attr("y2", d => yScale(d.value.median)) .attr("stroke", "black") .style("width", "40") vertical.merge(vertDom) .transition() .delay(d => xScale(xValues(d))/2 ) .duration(300) .attr("x1", d => xScale(xValues(d)) + xScale.bandwidth()/2) .attr("x2", d => xScale(xValues(d)) + xScale.bandwidth()/2) .attr("y1", d => {return yScale(d.value.min)}) .attr("y2", d => {return yScale(d.value.max)}) // ------Box enter, merge and exit functions------ let box = boxDom .enter() .append("rect") .attr("x", d => xScale(d.key) + xScale.bandwidth()/2) .attr("y", d => yScale(d.value.median)) .attr("height", 0) .attr("width", 0) .attr("stroke", "black") .style('fill', fill) setTooltip(box, hoverText, xValues) box.merge(boxDom) // 'merge' merges the 'enter' and 'update' groups .transition() .delay(d => xScale(xValues(d))/2 ) .duration(600) .attr("x", d => {return xScale(xValues(d))}) .attr("y", d => {return yScale(d.value.q3) }) .attr("width", xScale.bandwidth()) .attr("height", d => {return (yScale(d.value.q1)-yScale(d.value.q3)) }) // ------Median enter, merge and exit functions------ let median = medianDom .enter() .append("line") .attr("class", "medianLine") .attr("y1", d => yScale(d.value.median)) .attr("y2", d => yScale(d.value.median)) .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) .attr("stroke", "black") .style("width", 80) median.merge(medianDom) // 'merge' merges the 'enter' and 'update' groups .transition() .delay(d => xScale(xValues(d))/2 ) .duration(600) .attr("y1", d => yScale(d.value.median)) .attr("y2", d => yScale(d.value.median)) .attr("x1", d => xScale(d.key)) .attr("x2", d => xScale(d.key)+xScale.bandwidth()) // ------Min line enter, merge and exit functions------ let min = minDom .enter() .append("line") .attr("class", "minLine") .attr("y1", d => yScale(d.value.min)) .attr("y2", d => yScale(d.value.min)) .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) .attr("stroke", "black") .style("width", 80) min.merge(minDom) // 'merge' merges the 'enter' and 'update' groups .transition() .delay(d => 300 + xScale(xValues(d))/2 ) .duration(600) .attr("y1", d => yScale(d.value.min)) .attr("y2", d => yScale(d.value.min)) .attr("x1", d => xScale(d.key)+xScale.bandwidth()/4) .attr("x2", d => xScale(d.key)+xScale.bandwidth()*3/4) // ------Min line enter, merge and exit functions------ let max = maxDom .enter() .append("line") .attr("class", "maxLine") .attr("y1", d => yScale(d.value.max)) .attr("y2", d => yScale(d.value.max)) .attr("x1", d => xScale(d.key)+xScale.bandwidth()/2) .attr("x2", d => xScale(d.key)+xScale.bandwidth()/2) .attr("stroke", "black") .style("width", 80) max.merge(maxDom) // 'merge' merges the 'enter' and 'update' groups .transition() .delay(d => 300 + xScale(xValues(d))/2 ) .duration(600) .attr("y1", d => yScale(d.value.max)) .attr("y2", d => yScale(d.value.max)) .attr("x1", d => xScale(d.key)+xScale.bandwidth()/4) .attr("x2", d => xScale(d.key)+xScale.bandwidth()*3/4) } // Function that sets tooltip over each box and whisker when hovered over function setTooltip(box, hoverText, xValues){ box.on('mouseenter', function(d) { d3.select(this) .transition() .duration(100) .style('fill', hoverFill) let tooltip = d3.select(".graph-tooltip") // 80 chosen as it positions the tooltip in an appropriate location to the boxes let tooltipOffset = (d3.select(this).attr("width") - 80)/2; // To position the tool tip when the user hovers. Use the window and calculate the offset let position = this.getScreenCTM() .translate(+ this.getAttribute("x"), + this.getAttribute("y")); // Now give the tooltip the data it needs to show and the position it should be. tooltip .html(xValues(d) + "
" + hoverText(d)) .style("left", (window.pageXOffset + position.e + tooltipOffset) + "px") // Center it horizontally over the box .style("top", (window.pageYOffset + position.f - 80) + "px"); // Shift it 80 px above the box // Finally remove the 'hidden' class tooltip.classed("tooltip-hidden", false) }).on('mouseout', function() { d3.select(this) .transition() .duration(100) .style('fill', fill) // When the mouse is removed from the box we can add the hidden class to the tooltip again d3.select(".graph-tooltip").classed("tooltip-hidden", true); }) } // Function required to activate the 'help' tooltip on the axis $(function () { $("[data-toggle='help']").tooltip(); }); // When the form is submitted, we want to get a jpg image of the svg $('form').submit(function (e) { // prevent default form submission e.preventDefault(); // call function to post form (separate js file) postgraph(width, height) });