diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 8e3ddd0b..332f6fe2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -127,7 +127,7 @@ deploy_to_test:
rules:
- if: '$CI_COMMIT_BRANCH == "live/test-cara" && $OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET'
script:
- - curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-router/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
+ - curl -X POST -k https://api.paas.okd.cern.ch/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-router/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
oci_calculator:
diff --git a/README.md b/README.md
index 7853027d..d6d770f9 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ Andre Henriques1, Luis Aleixo1, Marco Andreini1
5Information Technology Department, Collaboration, Devices & Applications Group, CERN
6Norwegian University of Science and Technology (NTNU)
-### citation
+### Citation
A. Henriques, M. Andreini, G. Azzopardi, J. Devine, P. Elson, N. Mounet, M. Kongstein, N. Tarocco. CARA - COVID Airborne Risk Assessment tools. CERN (2021).
diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py
index a60d5f74..407382e3 100644
--- a/cara/apps/calculator/__init__.py
+++ b/cara/apps/calculator/__init__.py
@@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
# increase the overall CARA version (found at ``cara.__version__``).
-__version__ = "3.0.1"
+__version__ = "3.2.0"
class BaseRequestHandler(RequestHandler):
diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py
index 1cb3c5d3..9776e558 100644
--- a/cara/apps/calculator/model_generator.py
+++ b/cara/apps/calculator/model_generator.py
@@ -305,7 +305,7 @@ class FormData:
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise
if self.ventilation_type == 'natural_ventilation':
if self.window_opening_regime == 'windows_open_periodically':
- window_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration)
+ window_interval = models.PeriodicInterval(self.windows_frequency, self.windows_duration, min(self.infected_start, self.exposed_start))
else:
window_interval = always_on
diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py
index 773481f3..59ab1302 100644
--- a/cara/apps/calculator/report_generator.py
+++ b/cara/apps/calculator/report_generator.py
@@ -1,17 +1,15 @@
import concurrent.futures
import base64
import dataclasses
-from datetime import datetime, timedelta
+from datetime import datetime
import io
+import json
import typing
import urllib
import zlib
-import loky
import jinja2
import numpy as np
-import qrcode
-import json
from cara import models
from ... import monte_carlo as mc
@@ -110,10 +108,15 @@ def calculate_report_data(model: models.ExposureModel):
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
exposed_occupants = model.exposed.number
expected_new_cases = np.array(model.expected_new_cases()).mean()
+ cumulative_doses = np.cumsum([
+ np.array(model.exposure_between_bounds(float(time1), float(time2))).mean()
+ for time1, time2 in zip(times[:-1], times[1:])
+ ])
return {
"times": list(times),
"exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()],
+ "cumulative_doses": list(cumulative_doses),
"concentrations": concentrations,
"highest_const": highest_const,
"prob_inf": prob,
@@ -123,7 +126,7 @@ def calculate_report_data(model: models.ExposureModel):
}
-def generate_qr_code(base_url, calculator_prefix, form: FormData):
+def generate_permalink(base_url, calculator_prefix, form: FormData):
form_dict = FormData.to_dict(form, strip_defaults=True)
# Generate the calculator URL arguments that would be needed to re-create this
@@ -136,20 +139,9 @@ def generate_qr_code(base_url, calculator_prefix, form: FormData):
qr_url = f"{base_url}/_c/{compressed_args}"
url = f"{base_url}{calculator_prefix}?{args}"
- qr = qrcode.QRCode(
- version=1,
- error_correction=qrcode.constants.ERROR_CORRECT_H,
- box_size=10,
- border=4,
- )
- qr.add_data(qr_url)
- qr.make(fit=True)
- img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
-
return {
- 'image': img2base64(_img2bytes(img)),
'link': url,
- 'qr_url': qr_url,
+ 'shortened': qr_url,
}
@@ -313,14 +305,14 @@ class ReportGenerator:
context['alternative_scenarios'] = comparison_report(
alternative_scenarios, scenario_sample_times, executor_factory=executor_factory,
)
- context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form)
+ context['permalink'] = generate_permalink(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
context['scale_warning'] = {
- 'level': 'yellow-2',
- 'incidence_rate': 'lower than 25 new cases per 100 000 inhabitants',
- 'onsite_access': 'of about 8000',
+ 'level': 'orange-3',
+ 'incidence_rate': 'somewhere in between 25 and 100 new cases per 100 000 inhabitants',
+ 'onsite_access': 'of about 5000',
'threshold': ''
- }
+ }
return context
def _template_environment(self) -> jinja2.Environment:
diff --git a/cara/apps/calculator/static/css/form.css b/cara/apps/calculator/static/css/form.css
index 65e64531..4543fefc 100644
--- a/cara/apps/calculator/static/css/form.css
+++ b/cara/apps/calculator/static/css/form.css
@@ -18,6 +18,18 @@
font-size: 9pt;
}
+.center_radio {
+ align-self: center;
+}
+
+.start_time, .finish_time {
+ margin-bottom: 10px;
+}
+
+.sub_title {
+ margin-bottom: 5px;
+}
+
/* -------Tool tip ------- */
.tooltip_text {
diff --git a/cara/apps/calculator/static/css/report.css b/cara/apps/calculator/static/css/report.css
index 0e0a7cf7..d2b096e3 100644
--- a/cara/apps/calculator/static/css/report.css
+++ b/cara/apps/calculator/static/css/report.css
@@ -56,7 +56,7 @@ p.notes {
margin: 1%
}
-#pdf-qr-code {
+#pdf_qrcode_aref {
margin-right: 1%;
width: 100pt;
}
@@ -102,11 +102,6 @@ p.notes {
border-radius: 5px;
}
-.print-button {
- margin-left: auto;
- margin-right: 1%;
-}
-
/* @media (width: 1200px) { */
@media print {
/* #body {
@@ -120,7 +115,7 @@ p.notes {
#link_reproduce_results {
display: none!important;
}
- #pdf-qr-code {
+ #pdf_qrcode_aref {
visibility: inherit!important;
}
.collapse {
@@ -138,17 +133,15 @@ p.notes {
.icon_button {
display: none!important;
}
- .print-button {
- display: none!important;
- }
.card {
page-break-inside: avoid;
}
- /* CSS styling to avoid page breaks. */
- .break-after {
- page-break-after: always;
+ #link-results {
+ display: none;
}
- .break-avoid {
+ #disclaimer {
+ border: 2px solid black;
+ padding: 15px;
page-break-inside: avoid;
}
}
@@ -162,7 +155,8 @@ p.notes {
position: relative;
text-align: center;
border-radius: 100px;
- z-index: 1
+ z-index: 1;
+ -webkit-print-color-adjust: exact!important;
}
.intro-banner-vdo-play-btn i {
@@ -235,7 +229,7 @@ p.notes {
.split>* {
flex-basis: 100%;
}
- .header-text {
+ .paragraph-title {
text-align: left;
}
.split>*+* {
diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js
index abb888c5..5a20f025 100644
--- a/cara/apps/calculator/static/js/form.js
+++ b/cara/apps/calculator/static/js/form.js
@@ -236,6 +236,20 @@ function on_ventilation_type_change() {
});
}
+function on_wearing_mask_change() {
+ wearing_mask = $('input[type=radio][name=mask_wearing_option]')
+ wearing_mask.each(function (index) {
+ if (this.checked) {
+ getChildElement($(this)).show();
+ require_fields(this);
+ }
+ else {
+ getChildElement($(this)).hide();
+ require_fields(this);
+ }
+ })
+}
+
/* -------UI------- */
function show_disclaimer() {
@@ -365,6 +379,14 @@ function validate_form(form) {
}
}
+ if (submit) {
+ $("#generate_report").prop("disabled", true);
+ //Add spinner to button
+ $("#generate_report").html(
+ `Loading...`
+ );
+ }
+
return submit;
}
@@ -479,12 +501,17 @@ function parseTimeToMins(cTime) {
return parseInt(time[1]*60) + parseInt(time[2]);
}
+// Prevent spinner when clicking on back button
+window.onpagehide = function(){
+ $('loading_spinner').remove();
+ $("#generate_report").prop("disabled", false).html(`Generate report`);
+};
+
/* -------On Load------- */
$(document).ready(function () {
var url = new URL(decodeURIComponent(window.location.href));
//Pre-fill form with known values
url.searchParams.forEach((value, name) => {
-
//If element exists
if(document.getElementsByName(name).length > 0) {
var elemObj = document.getElementsByName(name)[0];
@@ -545,6 +572,12 @@ $(document).ready(function () {
// Call the function now to handle forward/back button presses in the browser.
on_ventilation_type_change();
+ // When the mask_wearing_option changes we want to make its respective
+ // children show/hide.
+ $("input[type=radio][name=mask_wearing_option]").change(on_wearing_mask_change);
+ // Call the function now to handle forward/back button presses in the browser.
+ on_wearing_mask_change();
+
// Setup the maximum number of people at page load (to handle back/forward),
// and update it when total people is changed.
setMaxInfectedPeople();
@@ -598,7 +631,7 @@ $(document).ready(function () {
},
cache: true
},
- placeholder: 'Search for a location',
+ placeholder: 'Geneva, CHE',
minimumInputLength: 1,
templateResult: formatlocation,
templateSelection: formatLocationSelection
diff --git a/cara/apps/calculator/static/js/pdf.js b/cara/apps/calculator/static/js/pdf.js
deleted file mode 100644
index 6afb4fea..00000000
--- a/cara/apps/calculator/static/js/pdf.js
+++ /dev/null
@@ -1,29 +0,0 @@
-function generate_pdf_version(qr_link) {
- const pdf_version = this.document.getElementById("body");
-
- // PDF styling
- var opt = {
- filename: 'myfile.pdf',
- image: { type: 'jpeg', quality: 0.98 },
- html2canvas: { scale: 2, width: 1200, windowWidth: 1200 },
- enableLinks: false,
- jsPDF: {
- unit: 'pt',
- format: 'letter',
- orientation: 'portrait',
- },
- pagebreak: { mode: '', avoid: '.break-avoid' },
- };
- html2pdf().set(opt).from(pdf_version).toPdf().get('pdf').then(function(pdf) {
- var totalPages = pdf.internal.getNumberOfPages();
- pdf.setPage(1);
- pdf.link(530, 25, 60, 60, { url: qr_link }); //Hyperlink to reproduce results
-
- for (i = 1; i <= totalPages; i++) {
- pdf.setPage(i);
- pdf.setFontSize(10);
- pdf.setTextColor(150);
- pdf.text('Page ' + i + ' of ' + totalPages, (pdf.internal.pageSize.getWidth() / 2.25), (pdf.internal.pageSize.getHeight() - 10));
- }
- }).save();
-};
\ No newline at end of file
diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js
index b815da2f..2a916544 100644
--- a/cara/apps/calculator/static/js/report.js
+++ b/cara/apps/calculator/static/js/report.js
@@ -1,93 +1,135 @@
/* Generate the concentration plot using d3 library. */
-function draw_concentration_plot(svg_id, times, concentrations, exposed_presence_intervals) {
- var visBoundingBox = d3.select(svg_id)
- .node()
- .getBoundingClientRect();
+function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals) {
+
+ console.log(cumulative_doses)
var time_format = d3.timeFormat('%H:%M');
- var data = []
- times.map((time, index) => data.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index] }))
+ var data_for_graphs = {
+ '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.cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': cumulative_doses[index]}));
- var vis = d3.select(svg_id),
- width = visBoundingBox.width - 300,
- height = visBoundingBox.height,
- margins = { top: 30, right: 20, bottom: 50, left: 50 },
+ // Add main SVG element
+ var plot_div = document.getElementById(svg_id);
+ var vis = d3.select(plot_div).append('svg');
- // H:M time format for x axis.
- xRange = d3.scaleTime().range([margins.left, width - margins.right]).domain([data[0].hour, data[data.length - 1].hour]),
- xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([data[0].time, data[data.length - 1].time]),
- bisecHour = d3.bisector((d) => { return d.hour; }).left,
+ // 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().range([height - margins.bottom, margins.top]).domain([0., Math.max(...concentrations)]),
+ yRange = d3.scaleLinear().domain([0., Math.max(...concentrations)]),
+ yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]),
- xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
- yAxis = d3.axisLeft(yRange);
-
- // Plot tittle.
- plot_title(vis, width, margins.top, 'Mean concentration of virions');
+ xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
+ yAxis = d3.axisLeft(yRange).ticks(4),
+ yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4);
// Line representing the mean concentration.
- plot_scenario_data(vis, data, xTimeRange, yRange, '#1f77b4');
+ var lineFunc = d3.line();
+ var draw_line = vis.append('svg:path')
+ .attr('stroke', '#1f77b4')
+ .attr('stroke-width', 2)
+ .attr('fill', 'none');
- // X axis.
- plot_x_axis(vis, height, width, margins, xAxis, 'Time of day');
-
- // Y axis
- plot_y_axis(vis, height, width, margins, yAxis, 'Mean concentration (virions/m³)')
+ 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).
- exposed_presence_intervals.forEach(b => {
- var curveFunc = d3.area()
- .x(d => xTimeRange(d.time))
- .y0(height - margins.bottom)
- .y1(d => yRange(d.concentration));
-
- vis.append('svg:path')
- .attr('d', curveFunc(data.filter(d => {
- return d.time >= b[0] && d.time <= b[1]
- })))
+ var exposedArea = {};
+ var drawArea = {};
+ exposed_presence_intervals.forEach((b, index) => {
+ exposedArea[index] = d3.area();
+ drawArea[index] = vis.append('svg:path')
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
- })
+ });
+
+ // Plot tittle.
+ var plotTitleEl = vis.append('svg:foreignObject')
+ .attr("background-color", "transparent")
+ .attr('height', 30)
+ .style('text-align', 'center')
+ .html('Mean concentration of virions');
+
+ // 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 (virions)');
// Legend for the plot elements - line and area.
- var size = 20
- vis.append('rect')
- .attr('x', width + size)
- .attr('y', margins.top + size)
+ var legendLineIcon = vis.append('rect')
.attr('width', 20)
.attr('height', 3)
.style('fill', '#1f77b4');
- vis.append('rect')
- .attr('x', width + size)
- .attr('y', 3 * size)
+ 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', 20)
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
- vis.append('text')
- .attr('x', width + 3 * size)
- .attr('y', margins.top + size)
+ var legendLineText = vis.append('text')
.text('Mean concentration')
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
- vis.append('text')
- .attr('x', width + 3 * size)
- .attr('y', margins.top + 2 * size)
+ 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');
- // Legend bounding box.
- vis.append('rect')
- .attr('width', 275)
- .attr('height', 50)
- .attr('x', width * 1.005)
- .attr('y', margins.top + 5)
+ // Legend bounding
+ var legendBBox = vis.append('rect')
+ .attr('width', 255)
+ .attr('height', 70)
.attr('stroke', 'lightgrey')
.attr('stroke-width', '2')
.attr('rx', '5px')
@@ -96,57 +138,239 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
.attr('fill', 'none');
// Tooltip.
- var focus = vis.append('svg:g')
- .style('display', 'none');
+ var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
+ for (const [concentration, data] of Object.entries(data_for_graphs)) {
- focus.append('circle')
- .attr('r', 3);
+ focus[concentration] = vis.append('svg:g')
+ .style('display', 'none');
- focus.append('rect')
- .attr('fill', 'white')
- .attr('stroke', '#000')
- .attr('width', 80)
- .attr('height', 50)
- .attr('x', 10)
- .attr('y', -22)
- .attr('rx', 4)
- .attr('ry', 4);
+ focus[concentration].append('circle')
+ .attr('r', 3);
- focus.append('text')
- .attr('id', 'tooltip-time')
- .attr('x', 18)
- .attr('y', -2);
+ tooltip_rect[concentration] = focus[concentration].append('rect')
+ .attr('fill', 'white')
+ .attr('stroke', '#000')
+ .attr('width', 85)
+ .attr('height', 50)
+ .attr('x', 10)
+ .attr('y', -22)
+ .attr('rx', 4)
+ .attr('ry', 4);
- focus.append('text')
- .attr('id', 'tooltip-concentration')
- .attr('x', 18)
- .attr('y', 18);
+ tooltip_time[concentration] = focus[concentration].append('text')
+ .attr('id', 'tooltip-time')
+ .attr('x', 18)
+ .attr('y', -2);
- vis.append('rect')
- .attr('fill', 'none')
- .attr('pointer-events', 'all')
- .attr('width', width - margins.right)
- .attr('height', height)
- .on('mouseover', () => { focus.style('display', null); })
- .on('mouseout', () => { focus.style('display', 'none'); })
- .on('mousemove', mousemove);
+ tooltip_concentration[concentration] = focus[concentration].append('text')
+ .attr('id', 'tooltip-concentration')
+ .attr('x', 18)
+ .attr('y', 18);
+
+ toolBox[concentration] = vis.append('rect')
+ .attr('fill', 'none')
+ .attr('pointer-events', 'all')
+ .on('mouseover', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', null); })
+ .on('mouseout', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', 'none'); })
+ .on('mousemove', mousemove);
+ }
+
+ var graph_width;
+ var graph_height;
+
+ 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.
+
+ // 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]);
+
+ // Line.
+ lineFunc.defined(d => !isNaN(d.concentration))
+ .x(d => xTimeRange(d.time))
+ .y(d => yRange(d.concentration));
+ draw_line.attr("d", lineFunc(data_for_graphs.concentrations));
+
+ // 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));
+
+ // Area.
+ exposed_presence_intervals.forEach((b, index) => {
+ exposedArea[index].x(d => xTimeRange(d.time))
+ .y0(graph_height - margins.bottom)
+ .y1(d => yRange(d.concentration));
+
+ drawArea[index].attr('d', exposedArea[index](data_for_graphs.concentrations.filter(d => {
+ return d.time >= b[0] && d.time <= b[1]
+ })));
+ });
+
+ // Title.
+ plotTitleEl.attr('width', graph_width);
+
+ // Axis.
+ var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
+ var yAxis = d3.axisLeft(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)').call(yAxis);
+ 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.5 * size);
+ legendAreaText.attr('x', graph_width + 4 * size)
+ .attr('y', margins.top + 3 * 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.7 * size);
+
+ legendBBox.attr('x', 1)
+ .attr('y', graph_height);
+ }
+
+ // ToolBox.
+ for (const [concentration, data] of Object.entries(data_for_graphs)) {
+ toolBox[concentration].attr('width', graph_width - margins.right)
+ .attr('height', graph_height);
+ }
+ }
+
+ // Draw for the first time to initialize.
+ redraw();
function mousemove() {
+ for (const [scenario, data] of Object.entries(data_for_graphs)) {
+ if (d3.pointer(event)[0] < graph_width / 2) {
+ tooltip_rect[scenario].attr('x', 10)
+ tooltip_time[scenario].attr('x', 18)
+ tooltip_concentration[scenario].attr('x', 18);
+ }
+ else {
+ tooltip_rect[scenario].attr('x', -90)
+ tooltip_time[scenario].attr('x', -82)
+ tooltip_concentration[scenario].attr('x', -82)
+ }
+ }
+ // Concentration line
var x0 = xRange.invert(d3.pointer(event, this)[0]),
- i = bisecHour(data, x0, 1),
- d0 = data[i - 1],
- d1 = data[i],
- d = x0 - d0.hour > d1.hour - x0 ? d1 : d0;
- focus.attr('transform', 'translate(' + xRange(d.hour) + ',' + yRange(d.concentration) + ')');
- focus.select('#tooltip-time').text('x = ' + time_format(d.hour));
- focus.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
+ i = bisecHour(data_for_graphs.concentrations, x0, 1),
+ d0 = data_for_graphs.concentrations[i - 1],
+ d1 = data_for_graphs.concentrations[i];
+ if (d1) {
+ var d = x0 - d0.hour > d1.hour - x0 ? d1 : d0;
+ focus.concentrations.attr('transform', 'translate(' + xRange(d.hour) + ',' + yRange(d.concentration) + ')');
+ focus.concentrations.select('#tooltip-time').text('x = ' + time_format(d.hour));
+ focus.concentrations.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
+ }
+ // Cumulative line
+ var x0 = xRange.invert(d3.pointer(event, this)[0]),
+ i = bisecHour(data_for_graphs.cumulative_doses, x0, 1),
+ d0 = data_for_graphs.cumulative_doses[i - 1],
+ d1 = data_for_graphs.cumulative_doses[i];
+ if (d1 && d1.concentration) {
+ var d = x0 - d0.hour > d1.hour - x0 ? d1 : d0;
+ focus.cumulative_doses.attr('transform', 'translate(' + xRange(d.hour) + ',' + yCumulativeRange(d.concentration) + ')');
+ focus.cumulative_doses.select('#tooltip-time').text('x = ' + time_format(d.hour));
+ focus.cumulative_doses.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
+ }
}
+
+ // 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
-function draw_alternative_scenarios_plot(svg_id, width, height, alternative_scenarios, times) {
+function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id, times, alternative_scenarios) {
// H:M format
var time_format = d3.timeFormat('%H:%M');
// D3 array of ten categorical colors represented as RGB hexadecimal strings.
@@ -171,148 +395,252 @@ function draw_alternative_scenarios_plot(svg_id, width, height, alternative_scen
// We need one scenario to get the time range
var first_scenario = Object.values(data_for_scenarios)[0]
- var vis = d3.select(svg_id),
- width = width,
- height = height,
- margins = { top: 30, right: 20, bottom: 50, left: 50 },
+ // Add main SVG element
+ var alternative_plot_div = document.getElementById(alternative_plot_svg_id);
+ var vis = d3.select(alternative_plot_div).append('svg');
- // H:M time format for x axis.
- xRange = d3.scaleTime().range([margins.left, width - margins.right]).domain([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]),
- xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([times[0], times[times.length - 1]]),
+ var xRange = d3.scaleTime().domain([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]);
+ var xTimeRange = d3.scaleLinear().domain([times[0], times[times.length - 1]]);
+ var bisecHour = d3.bisector((d) => { return d.hour; }).left;
- yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([0., highest_concentration]),
-
- xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
- yAxis = d3.axisLeft(yRange);
-
- // Plot title.
- plot_title(vis, width, margins.top, 'Mean concentration of virions');
+ var yRange = d3.scaleLinear().domain([0., highest_concentration]);
// Line representing the mean concentration for each scenario.
+ var lineFuncs = {}, draw_lines = {}, label_icons = {}, label_text = {};
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
// Line representing the mean concentration.
- plot_scenario_data(vis, data, xTimeRange, yRange, colors[scenario_index])
+ lineFuncs[scenario_name] = d3.line();
+
+ draw_lines[scenario_name] = vis.append('svg:path')
+ .attr("stroke", colors[scenario_index])
+ .attr('stroke-width', 2)
+ .attr('fill', 'none');
// Legend for the plot elements - lines.
- var size = 20 * (scenario_index + 1)
- vis.append('rect')
- .attr('x', width + 20)
- .attr('y', margins.top + size)
+ label_icons[scenario_name] = vis.append('rect')
.attr('width', 20)
.attr('height', 3)
.style('fill', colors[scenario_index]);
- vis.append('text')
- .attr('x', width + 3 * 20)
- .attr('y', margins.top + size)
+ label_text[scenario_name] = vis.append('text')
.text(scenario_name)
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
}
+ // Plot title.
+ var plotTitleEl = vis.append('svg:foreignObject')
+ .attr("background-color", "transparent")
+ .attr('height', 30)
+ .style('text-align', 'center')
+ .html('Mean concentration of virions');
+
+
// X axis.
- plot_x_axis(vis, height, width, margins, xAxis, "Time of day");
+ 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.
- vis.append('svg:g')
- .attr('class', 'y axis')
- .attr('transform', 'translate(' + margins.left + ',0)')
- .call(yAxis);
+ var yAxisEl = vis.append('svg:g')
+ .attr('class', 'y axis');
// Y axis label.
- vis.append('svg:text')
+ var yAxisLabelEl = vis.append('svg:text')
.attr('class', 'y label')
.attr('fill', 'black')
- .attr('transform', 'rotate(-90, 0,' + height + ')')
.attr('text-anchor', 'middle')
- .attr('x', (height + margins.bottom) / 2)
- .attr('y', (height + margins.left) * 0.92)
.text('Mean concentration (virions/m³)');
// Legend bounding box.
- vis.append('rect')
+ var legendBBox = vis.append('rect')
.attr('width', 275)
.attr('height', 25 * (Object.keys(data_for_scenarios).length))
- .attr('x', width * 1.005)
- .attr('y', margins.top + 5)
.attr('stroke', 'lightgrey')
.attr('stroke-width', '2')
.attr('rx', '5px')
.attr('ry', '5px')
.attr('stroke-linejoin', 'round')
.attr('fill', 'none');
+
+ // Tooltip.
+ var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
+ for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
+
+ focus[scenario_name] = vis.append('svg:g')
+ .style('display', 'none');
+
+ focus[scenario_name].append('circle')
+ .attr('r', 3);
+
+ tooltip_rect[scenario_name] = focus[scenario_name].append('rect')
+ .attr('fill', 'white')
+ .attr('stroke', '#000')
+ .attr('width', 80)
+ .attr('height', 50)
+ .attr('y', -22)
+ .attr('rx', 4)
+ .attr('ry', 4);
+
+ tooltip_time[scenario_name] = focus[scenario_name].append('text')
+ .attr('id', 'tooltip-time')
+ .attr('y', -2);
+
+ tooltip_concentration[scenario_name] = focus[scenario_name].append('text')
+ .attr('id', 'tooltip-concentration')
+ .attr('y', 18);
+
+ toolBox[scenario_name] = vis.append('rect')
+ .attr('fill', 'none')
+ .attr('pointer-events', 'all')
+ .on('mouseover', () => { for (const [scenario_name, data] of Object.entries(focus)) focus[scenario_name].style('display', null); })
+ .on('mouseout', () => { for (const [scenario_name, data] of Object.entries(focus)) focus[scenario_name].style('display', 'none'); })
+ .on('mousemove', mousemove);
+ }
+
+ var graph_width;
+ var graph_height;
+
+ function redraw() {
+ // Define width and height according to the screen size.
+ var div_width = document.getElementById(concentration_plot_svg_id).clientWidth;
+ var div_height = document.getElementById(concentration_plot_svg_id).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'};
+ 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 * .95;
+ 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'};
+ 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.
+
+ // 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]);
+
+ for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
+ var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
+ // Lines.
+ lineFuncs[scenario_name].defined(d => !isNaN(d.concentration))
+ .x(d => xTimeRange(d.time))
+ .y(d => yRange(d.concentration));
+ draw_lines[scenario_name].attr("d", lineFuncs[scenario_name](data));
+
+ // Legend on right side.
+ var size = 20 * (scenario_index + 1);
+ if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) {
+ label_icons[scenario_name].attr('x', graph_width + 20)
+ .attr('y', margins.top + size);
+ label_text[scenario_name].attr('x', graph_width + 3 * 20)
+ .attr('y', margins.top + size);
+ }
+ // Legend on the bottom.
+ else {
+ label_icons[scenario_name].attr('x', margins.left * 0.3)
+ .attr('y', graph_height + size);
+ label_text[scenario_name].attr('x', margins.left * 1.4)
+ .attr('y', graph_height + size);
+ }
+
+ }
+
+ // Title.
+ plotTitleEl.attr('width', graph_width);
+
+ // Axis.
+ var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
+ var yAxis = d3.axisLeft(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)')
+ .call(yAxis);
+ yAxisLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')')
+ .attr('x', (graph_height * 0.9 + margins.bottom) / 2)
+ .attr('y', (graph_height + margins.left) * 0.90);
+
+ // Legend on right side.
+ if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) {
+ legendBBox.attr('x', graph_width * 1.02)
+ .attr('y', margins.top * 1.15);
+
+ }
+ // Legend on the bottom.
+ else {
+ legendBBox.attr('x', 1)
+ .attr('y', graph_height * 1.02)
+ }
+
+ // ToolBox.
+ for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
+ toolBox[scenario_name].attr('width', graph_width - margins.right)
+ .attr('height', graph_height);
+ }
+ }
+
+ // Draw for the first time to initialize.
+ redraw();
+
+ function mousemove() {
+ for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
+ if (d3.pointer(event)[0] < graph_width / 2) {
+ tooltip_rect[scenario_name].attr('x', 10)
+ tooltip_time[scenario_name].attr('x', 18)
+ tooltip_concentration[scenario_name].attr('x', 18);
+ }
+ else {
+ tooltip_rect[scenario_name].attr('x', -90)
+ tooltip_time[scenario_name].attr('x', -82)
+ tooltip_concentration[scenario_name].attr('x', -82)
+ }
+ var x0 = xRange.invert(d3.pointer(event, this)[0]),
+ i = bisecHour(data, x0, 1),
+ d0 = data[i - 1],
+ d1 = data[i],
+ d = x0 - d0.hour > d1.hour - x0 ? d1 : d0;
+ focus[scenario_name].attr('transform', 'translate(' + xRange(d.hour) + ',' + yRange(d.concentration) + ')');
+ focus[scenario_name].select('#tooltip-time').text('x = ' + time_format(d.hour));
+ focus[scenario_name].select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
+ }
+ }
+
+ // Redraw based on the new size whenever the browser window is resized.
+ window.addEventListener("resize", redraw);
}
+function copy_clipboard(shareable_link) {
-// Functions used to build the plots' components
-
-function plot_title(vis, width, margin_top, title) {
- vis.append('svg:foreignObject')
- .attr('width', width)
- .attr('height', margin_top)
- .attr('fill', 'none')
- .append('xhtml:div')
- .style('text-align', 'center')
- .html(title);
-
- return vis;
-}
-
-function plot_x_axis(vis, height, width, margins, xAxis, label) {
- // X axis declaration
- vis.append('svg:g')
- .attr('class', 'x axis')
- .attr('transform', 'translate(0,' + (height - margins.bottom) + ')')
- .call(xAxis);
-
- // X axis label.
- vis.append('text')
- .attr('class', 'x label')
- .attr('fill', 'black')
- .attr('text-anchor', 'middle')
- .attr('x', (width + margins.right) / 2)
- .attr('y', height * 0.97)
- .text(label);
-
- return vis;
-}
-
-function plot_y_axis(vis, height, width, margins, yAxis, label) {
- // Y axis declaration.
- vis.append('svg:g')
- .attr('class', 'y axis')
- .attr('transform', 'translate(' + margins.left + ',0)')
- .call(yAxis);
-
- // Y axis label.
- vis.append('svg:text')
- .attr('class', 'y label')
- .attr('fill', 'black')
- .attr('transform', 'rotate(-90, 0,' + height + ')')
- .attr('text-anchor', 'middle')
- .attr('x', (height + margins.bottom) / 2)
- .attr('y', (height + margins.left) * 0.92)
- .text(label);
-
- return vis;
-
-}
-
-function plot_scenario_data(vis, data, xTimeRange, yRange, line_color) {
- var lineFunc = d3.line()
- .defined(d => !isNaN(d.concentration))
- .x(d => xTimeRange(d.time))
- .y(d => yRange(d.concentration))
- .curve(d3.curveBasis);
-
- vis.append('svg:path')
- .attr('d', lineFunc(data))
- .attr("stroke", line_color)
- .attr('stroke-width', 2)
- .attr('fill', 'none');
-
- return vis;
+ $("#mobile_link").attr('title', 'Copied!')
+ .tooltip('_fixTitle')
+ .tooltip('show');
+
+ navigator.clipboard.writeText(shareable_link);
}
\ No newline at end of file
diff --git a/cara/apps/calculator/templates/base/calculator.report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2
index 0f55fd75..123ae79c 100644
--- a/cara/apps/calculator/templates/base/calculator.report.html.j2
+++ b/cara/apps/calculator/templates/base/calculator.report.html.j2
@@ -2,12 +2,13 @@
- Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
+
+ Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.
- + +