|
// Variables used by Scriptable. |
|
// These must be at the very top of the file. Do not edit. |
|
// icon-color: red; icon-glyph: briefcase-medical; |
|
|
|
// Corona widget for Catalunya |
|
|
|
// Widget content: |
|
// + Incidence per 100.000 people over the last 7 days |
|
// + Indicator if the cases are increasing/decreasing |
|
// + R-Value over the last 7 days |
|
// + Chart showing the Incidence pero 100.000 people over the last 83 days. |
|
|
|
// Calculation: |
|
// The calculation of the R-Value is done the same way as calculated by the RKI (Robert-Koch-Institut). |
|
// The data used for the calculation is always 3 days delayed, so that there is no reporting bias (not all labs report on weekends). |
|
|
|
// Data: analisi.transparenciacatalunya.cat |
|
|
|
// Big thanks to @kevinkub (https://github.com/kevinkub) who has created a great corona widget for Germany that inspired me: https://gist.github.com/kevinkub/46caebfebc7e26be63403a7f0587f664 |
|
// Also thanks to @rphl (https://github.com/rphl) and @tzschies (https://github.com/tzschies) for their help on the german widget. See https://gist.github.com/rphl/0491c5f9cb345bf831248732374c4ef5 and https://gist.github.com/tzschies/563fab70b37609bc8f2f630d566bcbc9. |
|
|
|
class IncidenceWidget { |
|
|
|
constructor() { |
|
this.reportingDelay = 3; // Amount of days set as buffer for reporting numbers |
|
this.previousDaysToLoad = 90; |
|
this.apiUrlDistricts = (location) => `https://analisi.transparenciacatalunya.cat/resource/bh64-c7uy.json?$select=nom_muni%20as%20name,codiine%20as%20code&$where=intersects(the_geom,%20%27POINT%20(${location.longitude.toFixed(3)}%20${location.latitude.toFixed(3)})%27)` |
|
this.apiUrlDistrictPopulation = (districtCode) => `https://analisi.transparenciacatalunya.cat/resource/epsm-zskb.json?codi_ine_5_txt=${districtCode}&$select=any%20as%20year,poblacio_padro%20as%20population&$where=NOT%20poblacio_padro%20=%20%270%27` |
|
this.apiUrlDistrictsHistory = (districtCode) => `https://analisi.transparenciacatalunya.cat/resource/jj6z-iyrp.json?municipicodi=${districtCode}&$select=data%20as%20date,sum(numcasos)%20as%20cases&$where=resultatcoviddescripcio%20not%20like%20%27Sospit%C3%B3s%27and%20data%20between%20%27${this.getDateString(-this.previousDaysToLoad)}%27%20and%20%27${this.getDateString(1)}%27&$group=data&$order=data` |
|
} |
|
|
|
async run() { |
|
let widget = await this.createWidget() |
|
if (!config.runsInWidget) { |
|
await widget.presentSmall() |
|
} |
|
Script.setWidget(widget) |
|
Script.complete() |
|
} |
|
|
|
async createWidget(items) { |
|
let data = await this.getData() |
|
|
|
// Basic widget setup |
|
let widget = new ListWidget() |
|
widget.setPadding(0, 0, 0, 0) |
|
let textStack = widget.addStack() |
|
textStack.setPadding(14, 14, 0, 14) |
|
textStack.layoutVertically() |
|
textStack.topAlignContent() |
|
|
|
if(data.error) { |
|
// Error handling |
|
let loadingIndicator = textStack.addText(data.error.toUpperCase()) |
|
textStack.setPadding(14, 14, 14, 14) |
|
loadingIndicator.font = Font.mediumSystemFont(13) |
|
loadingIndicator.textOpacity = 0.5 |
|
let spacer = textStack.addStack() |
|
spacer.addSpacer(); |
|
} else { |
|
// Enable caching |
|
widget.refreshAfterDate = new Date(Date.now() + 60*60*1000) |
|
|
|
// Header |
|
let header = textStack.addText("🦠 " + data.districtName.toUpperCase()) |
|
header.font = Font.mediumSystemFont(13) |
|
textStack.addSpacer() |
|
|
|
// textStack.addSpacer(5) |
|
let noteStack = textStack.addStack() |
|
noteStack.layoutHorizontally() |
|
let note = noteStack.addText("Últimos 7 días") |
|
note.font = Font.boldSystemFont(8) |
|
note.textColor = new Color('888888', .9); |
|
|
|
// Main stack for value and area name |
|
let incidenceStack = textStack.addStack() |
|
let valueStack = incidenceStack.addStack() |
|
let incidenceValueLabel = valueStack.addText(data.incidence + data.trendCases) |
|
incidenceValueLabel.font = Font.boldSystemFont(24) |
|
incidenceValueLabel.textColor = data.incidence >= 100 ? new Color("9e000a") : data.incidence >= 50 ? Color.red() : data.incidence >= 35 ? Color.yellow() : Color.green(); |
|
|
|
// Chip for displaying R value |
|
textStack.addSpacer(5) |
|
// textStack.addSpacer() |
|
let rStack = textStack.addStack() |
|
// let rStackWrapper = incidenceStack.addStack() |
|
// rStackWrapper.addSpacer(14) |
|
// let rStack = rStackWrapper.addStack() |
|
|
|
let rText = rStack.addText(data.rValue7Day + "") |
|
let rSymbol = rStack.addText("R") |
|
rStack.backgroundColor = new Color('888888', .03) |
|
rStack.borderWidth = 2 |
|
rStack.borderColor = new Color('888888', 0.45) |
|
rStack.cornerRadius = 4 |
|
rStack.setPadding(3, 5, 3, 5) |
|
rText.font = Font.mediumSystemFont(11) |
|
rText.textColor = data.rValue7Day > 1 ? new Color("9e000a") : data.rValue7Day == 1 ? Color.red() : data.rValue7Day > 1 ? Color.yellow() : Color.green(); |
|
|
|
rSymbol.font = Font.mediumSystemFont(6) |
|
rSymbol.textColor = data.rValue7Day > 1 ? new Color("9e000a") : data.rValue7Day == 1 ? Color.red() : data.rValue7Day > 1 ? Color.yellow() : Color.green(); |
|
|
|
// Chart |
|
let chart = new LineChart(400, 120, data.timeline).configure((ctx, path) => { |
|
ctx.opaque = false; |
|
ctx.setFillColor(new Color("888888", .30)); |
|
ctx.addPath(path); |
|
ctx.fillPath(path); |
|
}).getImage(); |
|
let chartStack = widget.addStack() |
|
chartStack.setPadding(0, 0, 0, 0) |
|
let img = chartStack.addImage(chart) |
|
img.applyFittingContentMode() |
|
} |
|
return widget |
|
} |
|
|
|
async getData() { |
|
try { |
|
let location = await this.getLocation() |
|
if (location) { |
|
let districts = await new Request(this.apiUrlDistricts(location)).loadJSON() |
|
let district = districts[0] |
|
|
|
let districtPopulation = await new Request(this.apiUrlDistrictPopulation(district.code)).loadJSON() |
|
district.population = parseInt(districtPopulation.sort((a, b) => b.year - a.year)[0].population) |
|
|
|
let historicalData = await new Request(this.apiUrlDistrictsHistory(district.code)).loadJSON() |
|
historicalData.forEach((element) => { |
|
element.date = element.date.split("T")[0] |
|
element.cases = parseInt(element.cases) |
|
}) |
|
|
|
historicalData.forEach((element) => { |
|
element.r7 = this.calc_r_value(historicalData, element.date, 7) |
|
|
|
let calc_7d_sum = this.calc_7d_sum(historicalData, element.date, 7) |
|
if (calc_7d_sum == "N/A") { |
|
element.index_d7 = "N/A" |
|
} else { |
|
element.index_d7 = parseInt((calc_7d_sum / district.population * 100_000).toFixed(0)) |
|
} |
|
}) |
|
|
|
let sortedHistory = historicalData.sort((a, b) => { |
|
if ( a.date < b.date ){ |
|
return -1; |
|
} |
|
if ( a.date > b.date ){ |
|
return 1; |
|
} |
|
return 0; |
|
}) |
|
let latestData = sortedHistory.pop() |
|
|
|
let aggregate = historicalData.filter((element) => element.index_d7 != "N/A").reduce((dict, date) => { |
|
dict[date["date"]] = (date[date["date"]] | 0) + parseInt(date["index_d7"]); |
|
return dict; |
|
}, {}); |
|
let timeline = Object.keys(aggregate).sort().map(k => aggregate[k]); |
|
let casesYesterday7 = sortedHistory.slice(-8, -1).map(element => element.cases).reduce(this.sum); |
|
let casesToday7 = sortedHistory.slice(-7).map(element => element.cases).reduce(this.sum); |
|
let trendCases = (casesToday7 == casesYesterday7) ? '→' : (casesToday7 > casesYesterday7) ? '↑' : '↓'; |
|
|
|
return { |
|
incidence: latestData.index_d7, |
|
rValue7Day: latestData.r7, |
|
districtName: district.name, |
|
trendCases: trendCases, |
|
timeline: timeline |
|
}; |
|
} |
|
return { error: "No se encuentra la ubicación." } |
|
} catch (e) { |
|
console.log(e) |
|
return { error: "Error al obtener los datos." }; |
|
} |
|
} |
|
|
|
calc_r_value(historicalData, date, intervalDays) { |
|
// https://www.augsburger-allgemeine.de/wissenschaft/Corona-Wie-wird-der-R-Wert-des-RKI-berechnet-id57395051.html |
|
// Latest cases |
|
let interval_latest_end = this.getDateString(-(this.reportingDelay + 1), date) |
|
let interval_latest_start = this.getDateString(-(intervalDays - 1), interval_latest_end) |
|
let interval_latest = this.getDaysArray(interval_latest_start, interval_latest_end) |
|
let interval_latest_data = historicalData.filter((element) => interval_latest.includes(element.date)) |
|
if (interval_latest_data.length < 1) { return "N/A" } |
|
let interval_latest_cases = interval_latest_data.map(element => element.cases).reduce(this.sum) |
|
|
|
// Reference cases |
|
let interval_before_end = this.getDateString(-1, interval_latest_start) |
|
let interval_before_start = this.getDateString(-(intervalDays - 1), interval_before_end) |
|
let interval_before = this.getDaysArray(interval_before_start, interval_before_end) |
|
let interval_before_data = historicalData.filter((element) => interval_before.includes(element.date) ) |
|
if (interval_before_data.length < 1) { return "N/A" } |
|
let interval_before_cases = interval_before_data.map(element => element.cases).reduce(this.sum) |
|
|
|
if (interval_before_cases == 0) { |
|
return "N/A" |
|
} else { |
|
return parseFloat((interval_latest_cases / interval_before_cases).toFixed(2)) |
|
} |
|
} |
|
|
|
calc_7d_sum(historicalData, date, intervalDays) { |
|
let end_date = this.getDateString(-(this.reportingDelay + 1), date) |
|
let start_date = this.getDateString(-(intervalDays - 1), end_date) |
|
let days = this.getDaysArray(start_date, end_date) |
|
let data = historicalData.filter((element) => days.includes(element.date)) |
|
if (data.length < 1) { return "N/A" } |
|
return data.map(element => element.cases).reduce(this.sum) |
|
} |
|
|
|
getDaysArray(startDate, endDate) { |
|
let dates = [] |
|
//to avoid modifying the original date |
|
const theDate = new Date(startDate) |
|
while (theDate <= new Date(endDate)) { |
|
dates = [...dates, theDate.toISOString().substring(0, 10)] |
|
theDate.setDate(theDate.getDate() + 1) |
|
} |
|
return dates |
|
} |
|
|
|
getDateString(addDays, date) { |
|
let referenceDate |
|
if (date) { |
|
referenceDate = new Date(date) |
|
} else { |
|
referenceDate = new Date() |
|
} |
|
|
|
addDays = addDays || 0; |
|
|
|
return new Date(referenceDate.setDate(referenceDate.getDate() + addDays)).toISOString().substring(0, 10) |
|
} |
|
|
|
async getLocation() { |
|
try { |
|
if(args.widgetParameter) { |
|
let fixedCoordinates = args.widgetParameter.split(",").map(parseFloat) |
|
return { latitude: fixedCoordinates[0], longitude: fixedCoordinates[1] } |
|
} else { |
|
Location.setAccuracyToKilometer() |
|
return await Location.current() |
|
} |
|
} catch(e) { |
|
return null; |
|
} |
|
} |
|
|
|
sum(a, b) { |
|
return a + b; |
|
} |
|
|
|
} |
|
|
|
class LineChart { |
|
// Full credits go to to @kevinkub (https://github.com/kevinkub) |
|
// https://gist.github.com/kevinkub/b74f9c16f050576ae760a7730c19b8e2 |
|
|
|
constructor(width, height, values) { |
|
this.ctx = new DrawContext() |
|
this.ctx.size = new Size(width, height) |
|
this.values = values; |
|
} |
|
|
|
_calculatePath() { |
|
let maxValue = Math.max(...this.values); |
|
let minValue = Math.min(...this.values); |
|
let difference = maxValue - minValue; |
|
let count = this.values.length; |
|
let step = this.ctx.size.width / (count - 1); |
|
let points = this.values.map((current, index, all) => { |
|
let x = step*index |
|
let y = this.ctx.size.height - (current - minValue) / difference * this.ctx.size.height; |
|
return new Point(x, y) |
|
}); |
|
return this._getSmoothPath(points); |
|
} |
|
|
|
_getSmoothPath(points) { |
|
let path = new Path() |
|
path.move(new Point(0, this.ctx.size.height)); |
|
path.addLine(points[0]); |
|
for(var i = 0; i < points.length-1; i ++) { |
|
let xAvg = (points[i].x + points[i+1].x) / 2; |
|
let yAvg = (points[i].y + points[i+1].y) / 2; |
|
let avg = new Point(xAvg, yAvg); |
|
let cp1 = new Point((xAvg + points[i].x) / 2, points[i].y); |
|
let next = new Point(points[i+1].x, points[i+1].y); |
|
let cp2 = new Point((xAvg + points[i+1].x) / 2, points[i+1].y); |
|
path.addQuadCurve(avg, cp1); |
|
path.addQuadCurve(next, cp2); |
|
} |
|
path.addLine(new Point(this.ctx.size.width, this.ctx.size.height)) |
|
path.closeSubpath() |
|
return path; |
|
} |
|
|
|
configure(fn) { |
|
let path = this._calculatePath() |
|
if(fn) { |
|
fn(this.ctx, path); |
|
} else { |
|
this.ctx.addPath(path); |
|
this.ctx.fillPath(path); |
|
} |
|
return this.ctx; |
|
} |
|
|
|
} |
|
|
|
await new IncidenceWidget().run(); |