const startInput = document.getElementById('startDate'); const endInput = document.getElementById('endDate'); const cutSpikesInput = document.getElementById('cutSpikes'); const hashParams = new URLSearchParams(window.location.hash.substring(1)); startInput.value = hashParams.get('start'); endInput.value = hashParams.get('end'); cutSpikesInput.checked = hashParams.get('cutSpikes') === 'true'; const SPIKE_PERCENTILE = 95; const Y_ROUNDING = 500; fetch('/data.json') .then(resp => resp.json()) .then(plot) .catch(err => { console.log(err); }); function average(window) { let y_sum = 0; let count = 0; for (const sample of window) { if (sample['y'] !== undefined) { y_sum += sample['y']; count += 1; } } if (count === 0) { return undefined; } return y_sum / count; } function localLinearRegression(slopeWindowSize, averageWindowSize) { return (window) => { let x_sum = 0; let y_sum = 0; let count = 0; for (const sample of window.slice(-slopeWindowSize)) { if (sample['y'] !== undefined) { x_sum += sample['x']; y_sum += sample['y']; count += 1; } } if (count === 0) { return undefined; } const x_avg = x_sum / count; const y_avg = y_sum / count; let numerator = 0; let denominator = 0; for (const sample of window) { if (sample['y'] !== undefined) { const x_err = sample['x'] - x_avg; const y_err = sample['y'] - y_avg; numerator += x_err * y_err; denominator += x_err * x_err; } } if (denominator === 0) { return window.at(-1)['y']; } const slope = numerator / denominator; let short_x_sum = 0; let short_y_sum = 0; let short_count = 0; for (const sample of window.slice(-averageWindowSize)) { if (sample['y'] !== undefined) { short_x_sum += sample['x']; short_y_sum += sample['y']; short_count += 1; } } if (short_count === 0) { return y_avg + (window.at(-1)['x'] - x_avg) * slope; } const short_x_avg = short_x_sum / short_count; const short_y_avg = short_y_sum / short_count; return short_y_avg + (window.at(-1)['x'] - short_x_avg) * slope; }; } function smooth(data, column, windowSize, smoothFunc) { const smoothed = []; const window = []; for (const row of data) { window.push({ x: new Date(row['x']).getTime(), y: row[column] }); if (window.length > windowSize) { window.shift(); } smoothed.push({ x: row['x'], y: smoothFunc(window) }); } return smoothed; } function extractWithErrorBars(data, region) { const extracted = []; for (const row of data['rows']) { const y = row[region + ' (copies/mL)']; if (y !== undefined) { extracted.push({ x: row['Date'], y: y, yMin: y - (row[region + ' Low Confidence Interval'] || 0), yMax: y + (row[region + ' High Confidence Interval'] || 0), }); } } return extracted; } function getPercentile(data, start, end, percentile) { let sorted = data .filter((row) => (start === "" || row.x >= start) && (end === "" || row.x <= end)) .map((row) => row.yMax) .sort((a,b) => a - b); return sorted[Math.ceil(sorted.length*percentile/100) - 1]; } function updateShareLink(chart, link) { const ctx = chart.canvas.getContext('2d'); ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = 'white'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); link.href = chart.canvas.toDataURL(); chart.update(); } function plot(data) { const northData = extractWithErrorBars(data, 'Northern'); const southData = extractWithErrorBars(data, 'Southern'); const northCtx = document.getElementById('northCanvas'); const southCtx = document.getElementById('southCanvas'); const getOptions = (region) => { return { aspectRatio: 1, interaction: { intersect: false, }, scales: { x: { type: 'time', min: startInput.value, max: endInput.value, time: { tooltipFormat: 'MMM d, yyyy', }, }, y: { min: 0, max: null, }, }, plugins: { title: { display: true, text: region + ' System Wastewater COVID RNA Signal (copies/mL)', }, }, elements: { pointWithErrorBar: { errorBarWhiskerSize: 5, }, }, animation: { duration: 0, }, }; }; const northOptions = getOptions("North"); const southOptions = getOptions("South"); const updateYMax = () => { const cutoffPercentile = cutSpikesInput.checked ? SPIKE_PERCENTILE : 100; const rawMax = getPercentile(northData.concat(southData), startInput.value, endInput.value, cutoffPercentile); const max = Y_ROUNDING * Math.ceil(rawMax / Y_ROUNDING); northOptions.scales.y.max = max; southOptions.scales.y.max = max; }; updateYMax(); const northChart = new Chart(northCtx, { data: { datasets: [ { type: 'lineWithErrorBars', label: 'Measured value', data: northData, backgroundColor: 'rgba(0,0,255,0.5)', color: 'rgba(0,0,255,0.5)', borderColor: 'rgba(0,0,255,0.5)', showLine: false, }, /* { type: 'line', label: 'smoothing 18/14', data: smooth(northData, 'y', 18, localLinearRegression(18, 14)), pointRadius: 0, borderColor: 'rgba(0,0,255,0.6)', }, { type: 'line', label: 'smoothing 14/14', data: smooth(northData, 'y', 14, localLinearRegression(14, 14)), pointRadius: 0, borderColor: 'rgba(255,0,255,0.6)', },*/ { type: 'line', label: '7-day average (low)', data: smooth(northData, 'yMin', 7, average), pointRadius: 0, borderColor: 'rgba(0,255,0,0.6)', fill: '+1', backgroundColor: 'rgba(0,255,0,0.2)', }, { type: 'line', label: '7-day average', data: smooth(northData, 'y', 7, average), pointRadius: 0, borderColor: 'rgba(0,128,0,0.9)', backgroundColor: 'rgba(0,128,0,0.9)', }, { type: 'line', label: '7-day average (high)', data: smooth(northData, 'yMax', 7, average), pointRadius: 0, borderColor: 'rgba(0,255,0,0.6)', fill: '-1', backgroundColor: 'rgba(0,255,0,0.2)', }, ] }, options: northOptions, }); const southChart = new Chart(southCtx, { data: { datasets: [ { type: 'lineWithErrorBars', label: 'Measured value', data: southData, backgroundColor: 'rgba(255,0,0,0.5)', color: 'rgba(255,0,0,0.5)', borderColor: 'rgba(255,0,0,0.5)', showLine: false, }, { type: 'line', label: '7-day average (low)', data: smooth(southData, 'yMin', 7, average), pointRadius: 0, borderColor: 'rgba(255,127,0,0.6)', fill: '+1', backgroundColor: 'rgba(255,127,0,0.2)', }, { type: 'line', label: '7-day average', data: smooth(southData, 'y', 7, average), pointRadius: 0, borderColor: 'rgba(127,63,0,0.9)', backgroundColor: 'rgba(127,63,0,0.9)', }, { type: 'line', label: '7-day average (high)', data: smooth(southData, 'yMax', 7, average), pointRadius: 0, borderColor: 'rgba(255,127,0,0.6)', fill: '-1', backgroundColor: 'rgba(255,127,0,0.2)', }, ], }, options: southOptions, }); const update = () => { updateYMax(); setTimeout(() => { northChart.update(); southChart.update(); }, 10); const params = new URLSearchParams(); const start = startInput.value; if (start !== '') { params.set('start', start); } const end = endInput.value; if (end !== '') { params.set('end', end); } if (cutSpikesInput.checked) { params.set('cutSpikes', 'true'); } window.location.hash = params.toString(); }; startInput.addEventListener('input', (e) => { northOptions.scales.x.min = e.target.value; southOptions.scales.x.min = e.target.value; update(); }); endInput.addEventListener('input', (e) => { northOptions.scales.x.max = e.target.value; southOptions.scales.x.max = e.target.value; update(); }); cutSpikesInput.addEventListener('change', (e) => { update(); }); const northShare = document.getElementById('northShare'); northShare.addEventListener('click', () => { updateShareLink(northChart, northShare); }); const southShare = document.getElementById('southShare'); southShare.addEventListener('click', () => { updateShareLink(southChart, southShare); }); }