From 8470ff42f470a2bb337fa2099014480d845de1c3 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 15 Dec 2021 14:33:57 +0000 Subject: [PATCH] Updated test values with Toronto temperatures. --- .gitlab-ci.yml | 2 +- README.md | 2 +- cara/apps/calculator/__init__.py | 2 +- cara/apps/calculator/model_generator.py | 2 +- cara/apps/calculator/report_generator.py | 36 +- cara/apps/calculator/static/css/form.css | 12 + cara/apps/calculator/static/css/report.css | 26 +- cara/apps/calculator/static/js/form.js | 37 +- cara/apps/calculator/static/js/pdf.js | 29 - cara/apps/calculator/static/js/report.js | 718 +- .../templates/base/calculator.report.html.j2 | 113 +- .../templates/calculator.form.html.j2 | 541 +- .../calculator/templates/userguide.html.j2 | 117 +- .../cern/templates/calculator.report.html.j2 | 33 +- cara/apps/static/css/cern-theme.css | 2979 -------- cara/apps/static/css/cern-theme2.css | 4 - cara/apps/static/css/cern-theme3.css | 2723 ------- cara/apps/static/css/cern-theme4.css | 6720 ----------------- cara/apps/static/css/colorbox.css | 220 - cara/apps/static/css/colors.css | 985 --- cara/apps/static/css/style.css | 396 + cara/apps/static/icons/calculator.svg | 51 + cara/apps/static/icons/expert.svg | 14 + cara/apps/static/images/CARA_1_Vs3_Colour.jpg | Bin 0 -> 976583 bytes cara/apps/static/images/cara_full_logo.png | Bin 0 -> 20857 bytes cara/apps/static/images/cara_full_text.png | Bin 0 -> 66257 bytes cara/apps/static/images/masks/ffp2.png | Bin 0 -> 3714 bytes cara/apps/static/images/masks/t1.png | Bin 0 -> 3395 bytes .../static/images/nat_vent_dimensions.png | Bin 0 -> 20546 bytes cara/apps/templates/about.html.j2 | 54 +- cara/apps/templates/common_text.md.j2 | 75 +- cara/apps/templates/index.html.j2 | 138 +- cara/apps/templates/layout.html.j2 | 363 +- cara/apps/templates/page.html.j2 | 40 +- cara/data/__init__.py | 78 +- cara/models.py | 39 +- cara/scripts/themes/base/cara_script.command | 15 + cara/scripts/themes/base/cara_script.sh | 9 + cara/scripts/themes/cern/cara_script.command | 15 + cara/scripts/themes/cern/cara_script.sh | 9 + .../apps/calculator/test_model_generator.py | 6 +- cara/tests/apps/calculator/test_webapp.py | 16 +- cara/tests/models/test_exposure_model.py | 1 - cara/tests/test_known_quantities.py | 14 +- cara/tests/test_monte_carlo_full_models.py | 4 +- requirements.txt | 1 - server-performance-tests/README.md | 18 + server-performance-tests/locust.py | 42 + setup.py | 3 +- 49 files changed, 1969 insertions(+), 14733 deletions(-) delete mode 100644 cara/apps/calculator/static/js/pdf.js delete mode 100644 cara/apps/static/css/cern-theme.css delete mode 100644 cara/apps/static/css/cern-theme2.css delete mode 100644 cara/apps/static/css/cern-theme3.css delete mode 100644 cara/apps/static/css/cern-theme4.css delete mode 100644 cara/apps/static/css/colorbox.css delete mode 100644 cara/apps/static/css/colors.css create mode 100644 cara/apps/static/css/style.css create mode 100644 cara/apps/static/icons/calculator.svg create mode 100644 cara/apps/static/icons/expert.svg create mode 100644 cara/apps/static/images/CARA_1_Vs3_Colour.jpg create mode 100644 cara/apps/static/images/cara_full_logo.png create mode 100644 cara/apps/static/images/cara_full_text.png create mode 100644 cara/apps/static/images/masks/ffp2.png create mode 100644 cara/apps/static/images/masks/t1.png create mode 100644 cara/apps/static/images/nat_vent_dimensions.png create mode 100755 cara/scripts/themes/base/cara_script.command create mode 100755 cara/scripts/themes/base/cara_script.sh create mode 100755 cara/scripts/themes/cern/cara_script.command create mode 100755 cara/scripts/themes/cern/cara_script.sh create mode 100644 server-performance-tests/README.md create mode 100644 server-performance-tests/locust.py 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 @@ - + Report | CARA (COVID Airborne Risk Assessment) + @@ -17,15 +18,14 @@ {% block report_header %} -
- -
-

CARA - CALCULATOR REPORT

-

Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}

+
+ +
+

REPORT

+

Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}

- - {# To be replaced by "Generate PDF" #} - + +
{% endblock report_header %} @@ -60,38 +60,43 @@

- -
+
+
Probability of infection (%)
{% block warning_animation %} -
+
{{prob_inf | non_zero_percentage}} - - - + + +
{% endblock warning_animation %}
- - {% block report_summary %} - - {% endblock report_summary %} +
+ {% block report_summary %} + + {% endblock report_summary %} +
+
+
{% block report_summary_footnote %} {% endblock report_summary_footnote %}

* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.

- + +

@@ -108,12 +113,13 @@
- +
+
{% block report_scenarios_summary_table %} @@ -151,7 +157,7 @@ {% endblock report_results %} {% block report_footer %} -