diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 4d2a9210..0d4702b3 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -102,11 +102,15 @@ def calculate_report_data(model: models.ExposureModel): for interval in model.short_range.presence: short_range_intervals.append(list(interval.boundaries())) - concentrations = [ + short_range_concentrations = [ np.array(model.concentration(float(time))).mean() for time in times ] - highest_const = max(concentrations) + concentrations = [ + np.array(model.concentration_model.concentration(float(time))).mean() + for time in times + ] + highest_const = max(short_range_concentrations) prob = np.array(model.infection_probability()).mean() er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() exposed_occupants = model.exposed.number @@ -121,6 +125,7 @@ def calculate_report_data(model: models.ExposureModel): "short_range_intervals": short_range_intervals, "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), + "short_range_concentrations": short_range_concentrations, "concentrations": concentrations, "highest_const": highest_const, "prob_inf": prob, diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index ce9ef007..addafd98 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -522,8 +522,9 @@ function validateLunchTime(obj) { } function overlapped_times(obj, start_time, finish_time) { - removeErrorFor($(".short_range_option")); - $(".short_range_option").removeClass("red_border"); + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + let simulation_start = parseTimeToMins($("#exposed_start").val()) let simulation_finish = parseTimeToMins($("#exposed_finish").val()) @@ -856,7 +857,7 @@ $(document).ready(function () { }); // Validate row button (Save button) - $("body").on("click", ".validate_node_btn_frm_field", function(e) { + $("body").on("click", ".validate_node_btn_frm_field", function() { let index = $(this).attr('id').split('_').slice(-1)[0]; let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); @@ -889,33 +890,14 @@ $(document).ready(function () { $(this).closest(".form_field_outer_row").remove(); }); - //Short range modal - close button + //Short range modal - close and save button $("body").on("click", ".close_btn_frm_field", function() { - // var last_element = $(".form_field_outer").find(".form_field_outer_row").last().find(".short_range_option").prop("id"); - // if (!last_element) { - // $('#short_range_dialog').modal('hide'); - // $("input[type=radio][id=short_range_no]").prop("checked", true); - // on_short_range_option_change(); - // } - // else { - // let index = last_element.split("_").slice(-1)[0]; - // let activity = validate_sr_parameter('#sr_activity_no_' + String(index)[0], "You must specify the activity type."); - // let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "You must specify the start time."); - // let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "You must specify the duration."); - // if (activity && start && duration) { - // document.getElementById('sr_activity_no_' + String(index)).disabled = true; - // document.getElementById('sr_start_no_' + String(index)).disabled = true; - // document.getElementById('sr_duration_no_' + String(index)).disabled = true; - // document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important'; - // document.getElementById('validate_row_no_' + String(index)).style.cssText = 'display: none !important'; - // $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row").length); - // $('#short_range_dialog').modal('hide'); - // } - // } - $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row.row_validated").length); - $(".form_field_outer_row").not(".row_validated").remove(); - $('#short_range_dialog').modal('hide'); - + $(".validate_node_btn_frm_field").click(); + if ($(".form_field_outer").find(".form_field_outer_row.row_validated").length == $(".form_field_outer").find(".form_field_outer_row").length) { + $("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row.row_validated").length); + $(".form_field_outer_row").not(".row_validated").remove(); + $('#short_range_dialog').modal('hide'); + } }); //Short range modal - reset button diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index 4a69f18a..44c70ace 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -397,6 +397,445 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses } +function draw_plot(svg_id, times, concentrations, short_range_concentrations, cumulative_doses) { + + var data_for_graphs = { + 'concentrations': [], + 'short_range_concentrations': [], + 'cumulative_doses': [], + } + times.map((time, index) => data_for_graphs.concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index]})); + times.map((time, index) => data_for_graphs.short_range_concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': short_range_concentrations[index]})); + times.map((time, index) => data_for_graphs.cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': cumulative_doses[index]})); + + // Add main SVG element + var plot_div = document.getElementById(svg_id); + var vis = d3.select(plot_div).append('svg'); + + var time_format = d3.timeFormat('%H:%M'); + // H:M time format for x axis. + xRange = d3.scaleTime().domain([data_for_graphs.concentrations[0].hour, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].hour]), + xTimeRange = d3.scaleLinear().domain([data_for_graphs.concentrations[0].time, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].time]), + bisecHour = d3.bisector((d) => { return d.hour; }).left, + + yRange = d3.scaleLinear(), + yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]), + + yAxis = d3.axisLeft(); + yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4); + + // X axis declaration. + var xAxisEl = vis.append('svg:g') + .attr('class', 'x axis'); + + // X axis label. + var xAxisLabelEl = vis.append('text') + .attr('class', 'x label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Time of day') + + // Y axis declaration. + var yAxisEl = vis.append('svg:g') + .attr('class', 'y axis'); + + // Y axis label. + var yAxisLabelEl = vis.append('svg:text') + .attr('class', 'y label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Mean concentration (virions/m³)'); + + // Y cumulative concentration axis declaration. + var yAxisCumEl = vis.append('svg:g') + .attr('class', 'y axis') + .style('font-size', 14) + .style("stroke-dasharray", "5 5"); + + // Y cumulated concentration axis label. + var yAxisCumLabelEl = vis.append('svg:text') + .attr('class', 'y label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .text('Mean cumulative dose (infectious virus)'); + + // Legend for the plot elements - line and area. + var legendLineIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 3) + .style('fill', '#1f77b4'); + + var legendCumulativeIcon = vis.append('line') + .style("stroke-dasharray", "5 5") //dashed array for line + .attr('stroke-width', '2') + .style("stroke", '#1f77b4'); + + var legendAreaIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 15) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + + var legendShortRangeAreaIcon = vis.append('rect') + .attr('width', 20) + .attr('height', 15) + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + + var legendLineText = vis.append('text') + .text('Mean concentration') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendCumutiveText = vis.append('text') + .text('Cumulative dose') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendAreaText = vis.append('text') + .text('Presence of exposed person(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + var legendShortRangeText = vis.append('text') + .text('Short range interaction(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + // Legend bounding + var legendBBox = vis.append('rect') + .attr('width', 255) + .attr('height', 90) + .attr('stroke', 'lightgrey') + .attr('stroke-width', '2') + .attr('rx', '5px') + .attr('ry', '5px') + .attr('stroke-linejoin', 'round') + .attr('fill', 'none'); + + // Line representing the mean concentration. + var lineFunc = d3.line(); + var draw_line = vis.append('g') + .attr('clip-path', 'url(#clip)'); + draw_line.append('svg:path') + .attr('class', 'line') + .attr('stroke', '#1f77b4') + .attr('stroke-width', 2) + .attr('fill', 'none'); + + // Line representing the cumulative concentration. + var lineCumulative = d3.line(); + var draw_cumulative_line = vis.append('svg:path') + .attr('stroke', '#1f77b4') + .attr('stroke-width', 2) + .style("stroke-dasharray", "5 5") + .attr('fill', 'none'); + + // Area representing the presence of exposed person(s). + var exposedArea = {}; + var drawArea = {}; + exposed_presence_intervals.forEach((b, index) => { + exposedArea[index] = d3.area(); + drawArea[index] = draw_line.append('svg:path') + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + }); + + // Area representing the short range interaction(s). + var shortRangeArea = {}; + var drawShortRangeArea = {}; + short_range_intervals.forEach((b, index) => { + shortRangeArea[index] = d3.area(); + drawShortRangeArea[index] = draw_line.append('svg:path') + .attr('fill', '#1f00b4') + .attr('fill-opacity', '0.1'); + }); + + var clip = vis.append("defs").append("svg:clipPath") + .attr("id", "clip") + .append("svg:rect") + .attr("x", 0) + .attr("y", 30); + + var brush = d3.brushY(); + + function update_concentration_plot(data, data_for_graphs) { + yRange.domain([0., Math.max(...data)]); + yAxisEl.transition().duration(1000).call(yAxis); + + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line.select('.line') + .enter() + .merge(draw_line.select('.line')) + .transition() + .duration(1000) + .attr("d", lineFunc(data_for_graphs)); + + // Area. + exposed_presence_intervals.forEach((b, index) => { + exposedArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration) + ); + drawArea[index].transition().duration(1000).attr('d', exposedArea[index](data_for_graphs.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + + // Short Range Area. + short_range_intervals.forEach((b, index) => { + shortRangeArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration)); + + drawShortRangeArea[index].transition().duration(1000).attr('d', shortRangeArea[index](data_for_graphs.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + + brush.on("end", updateChart); // Each time the brush selection changes, trigger the 'updateChart' function + + // Brushing + draw_line.append("svg:g") + .attr("class", "brush") + .call(brush); + + // A function that set idleTimeOut to null + var idleTimeout + function idled() { idleTimeout = null; } + + // A function that updates the chart for given boundaries + function updateChart(event,d) { + // What are the selected boundaries? + extent = event.selection + + // If no selection, back to initial coordinate. Otherwise, update Y axis domain + if(!extent) { + if (!idleTimeout) return idleTimeout = setTimeout(idled, 350); // This allows to wait a little bit + } + else { + yRange.domain([ yRange.invert(extent[1]), yRange.invert(extent[0]) ]) + draw_line.select(".brush").call(brush.move, null) // This remove the grey brush area as soon as the selection has been done + } + + // Update axis and line position + yAxisEl.transition().duration(1000).call(d3.axisLeft(yRange)) + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line + .select('.line') + .transition() + .duration(1000) + .attr("d", lineFunc(data_for_graphs)); + + // Area. + exposed_presence_intervals.forEach((b, index) => { + exposedArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration)); + drawArea[index].transition().duration(1000).attr('d', exposedArea[index](data_for_graphs.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + + // Short Range Area. + short_range_intervals.forEach((b, index) => { + shortRangeArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration)); + + drawShortRangeArea[index].transition().duration(1000).attr('d', shortRangeArea[index](data_for_graphs.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + } + } + + function redraw() { + + // Define width and height according to the screen size. + var div_width = plot_div.clientWidth; + var div_height = plot_div.clientHeight; + graph_width = div_width; + graph_height = div_height + if (div_width >= 900) { // For screens with width > 900px legend can be on the graph's right side. + var margins = { top: 30, right: 20, bottom: 50, left: 60 }; + div_width = 900; + graph_width = div_width * (2/3); + const svg_margins = {'margin-left': '0rem', 'margin-top': '0rem'}; + Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); + } + else { + var margins = { top: 30, right: 20, bottom: 50, left: 40 }; + div_width = div_width * 1.1 + graph_width = div_width * .9; + graph_height = div_height * 0.65; // On mobile screen sizes we want the legend to be on the bottom of the graph. + const svg_margins = {'margin-left': '-1rem', 'margin-top': '3rem'}; + Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); + }; + + // Use the extracted size to set the size of the SVG element. + vis.attr("width", div_width) + .attr('height', div_height); + + // SVG components according to the width and height. + + // clipPath: everything out of this area won't be drawn. + clip.attr("width", graph_width - margins.right) + .attr("height", graph_height - margins.top - margins.bottom); + + // Add brushing + brush.extent([[margins.left, margins.top],[graph_width - margins.right, graph_height - margins.bottom ]]); + + // Axis ranges. + xRange.range([margins.left, graph_width - margins.right]); + xTimeRange.range([margins.left, graph_width - margins.right]); + yRange.range([graph_height - margins.bottom, margins.top]); + yCumulativeRange.range([graph_height - margins.bottom, margins.top]); + + // Axis. + var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)); + yAxis.scale(yRange); + + xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')') + .call(xAxis); + xAxisLabelEl.attr('x', (graph_width + margins.right) / 2) + .attr('y', graph_height * 0.97); + + yAxisEl.attr('transform', 'translate(' + margins.left + ',0)'); + yAxisLabelEl.attr('x', (graph_height * 0.9 + margins.bottom) / 2) + .attr('y', (graph_height + margins.left) * 0.9) + .attr('transform', 'rotate(-90, 0,' + graph_height + ')'); + + yAxisCumEl.attr('transform', 'translate(' + (graph_width - margins.right) + ',0)').call(yCumulativeAxis); + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom) / 2); + + if (plot_div.clientWidth >= 900) { + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom) / 2) + .attr('y', 1.71 * graph_width); + } + else { + yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') + .attr('x', (graph_height + margins.bottom * 0.55) / 2) + .attr('y', graph_width + 290); + } + + // Legend on right side. + const size = 20; + if (plot_div.clientWidth >= 900) { + legendLineIcon.attr('x', graph_width + size * 2.5) + .attr('y', margins.top + size); + legendLineText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + size); + + legendCumulativeIcon.attr("x1", graph_width + size + 30) + .attr("x2", graph_width + 2 * size + 32) + .attr("y1", 3.5 * size) + .attr("y2", 3.5 * size); + legendCumutiveText.attr('x', graph_width + 2.5 * size + 30) + .attr('y', margins.top + 2 * size); + + legendAreaIcon.attr('x', graph_width + size * 2.5) + .attr('y', margins.top + 2.6 * size); + legendAreaText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + 3 * size); + + legendShortRangeAreaIcon.attr('x', graph_width + size * 2.5) + .attr('y', margins.top + 3.6 * size); + legendShortRangeText.attr('x', graph_width + 4 * size) + .attr('y', margins.top + 4 * size); + + legendBBox.attr('x', graph_width * 1.07) + .attr('y', margins.top * 1.2); + } + // Legend on the bottom. + else { + legendLineIcon.attr('x', size * 0.5) + .attr('y', graph_height * 1.05); + legendLineText.attr('x', 2 * size) + .attr('y', graph_height * 1.05); + + legendCumulativeIcon.attr("x1", size * 0.5) + .attr("x2", size * 1.55) + .attr("y1", graph_height * 1.05 + size) + .attr("y2", graph_height * 1.05 + size); + legendCumutiveText.attr('x', 2 * size) + .attr('y', graph_height + 1.65 * size); + + legendAreaIcon.attr('x', size * 0.50) + .attr('y', graph_height * 1.09 + size); + legendAreaText.attr('x', 2 * size) + .attr('y', graph_height + 2.6 * size); + + legendShortRangeAreaIcon.attr('x', size * 0.50) + .attr('y', graph_height * 1.175 + size); + legendShortRangeText.attr('x', 2 * size) + .attr('y', graph_height + 3.65 * size); + + legendBBox.attr('x', 1) + .attr('y', graph_height); + } + + // Cumulative line. + lineCumulative.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yCumulativeRange(d.concentration)); + draw_cumulative_line.attr("d", lineCumulative(data_for_graphs.cumulative_doses)); + } + + document.getElementById("button_full_exposure").addEventListener("click", () => { + update_concentration_plot(short_range_concentrations, data_for_graphs.short_range_concentrations); + }); + document.getElementById("button_long_exposure").addEventListener("click", () => { + update_concentration_plot(concentrations, data_for_graphs.concentrations); + }); + + // If user double click, reinitialize the chart + vis.on("dblclick",function(){ + yRange.domain([0., Math.max(...short_range_concentrations)]) + yAxisEl.transition().call(d3.axisLeft(yRange)) + lineFunc.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)); + draw_line + .select('.line') + .transition() + .attr("d", lineFunc(data_for_graphs.short_range_concentrations)); + exposed_presence_intervals.forEach((b, index) => { + exposedArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration) + ); + drawArea[index].transition().duration(1000).attr('d', exposedArea[index](data_for_graphs.short_range_concentrations.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + short_range_intervals.forEach((b, index) => { + shortRangeArea[index].x(d => xTimeRange(d.time)) + .y0(graph_height - 50) + .y1(d => yRange(d.concentration)); + + drawShortRangeArea[index].transition().duration(1000).attr('d', shortRangeArea[index](data_for_graphs.short_range_concentrations.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))); + }); + }); + + // Draw for the first time to initialize. + redraw(); + update_concentration_plot(short_range_concentrations, data_for_graphs.short_range_concentrations); + + // Redraw based on the new size whenever the browser window is resized. + window.addEventListener("resize", redraw); + +} + + // Generate the alternative scenarios plot using d3 library. // 'alternative_scenarios' is a dictionary with all the alternative scenarios // 'times' is a list of times for all the scenarios diff --git a/cara/apps/templates/base/calculator.form.html.j2 b/cara/apps/templates/base/calculator.form.html.j2 index 88c8c492..171e5f88 100644 --- a/cara/apps/templates/base/calculator.form.html.j2 +++ b/cara/apps/templates/base/calculator.form.html.j2 @@ -377,7 +377,7 @@
0 short range interactions.
* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.
- + + +