poop-graph/static/poopGraph.js

334 lines
11 KiB
JavaScript

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: 'scatterWithErrorBars',
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)',
spanGaps: true,
},
/* {
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: 'scatterWithErrorBars',
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)',
spanGaps: true,
},
{
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);
});
}