Skip to content

Instantly share code, notes, and snippets.

@gitviola
Last active January 11, 2021 17:41
Show Gist options
  • Save gitviola/7083242f72b06acd83a79a9cf0919ebd to your computer and use it in GitHub Desktop.
Save gitviola/7083242f72b06acd83a79a9cf0919ebd to your computer and use it in GitHub Desktop.
Catalunya COVID-19 Widget for iOS (Scriptable)

👉 First of all: Open Data is awesome!

Catalunya COVID-19 Widget for iOS (through Scriptable)

A widget that works for all districts in Catalunya. It's using the data published on the official OpenData platform of Catalonia.

widget light mode widget dark mode

Why I did this (and why you might want it too)

When you think back to late 2019, did you see this coming? Neither did I. In times of uncertainty, we start to stress out and desperately keep checking the news. I found myself doing that multiple times a day and it started to have a really negative impact on my mental health.

Instead of browsing 5 different news websites just to find incomplete and not updated charts squeezed in between negative headlines, I wanted to have something less invading. Something that doesn't take much of my time, has reliable data and keeps me informed about the trend.

My solution was this iOS widget.

How reliable is the data?

My main goal was to provide as reliable data as possible. So I had to find a good source and a good way of calculating the numbers.

My solution:

Features

  • 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
  • Provide coordinates (latitude,longitude) as script parameters to set up multiple widgets for different groups (you can even create a widget group from them)

How to get it

You will need to have at least iOS 14 on your phone.

  • Download the Scriptable app
  • Create a new script inside Scriptable
  • Copy the javascript code that you see below and paste it into the blank script
  • Test it by hitting the play button and if it works then click done
  • Add a new widget as you would normally do and select the Scriptable app (square version of the widget)
  • Stay in the edit view and click the widget you just placed on your screen to select the script
  • (optional) enter the latitude and longitude as a parameter if you don't want to use your current location. For example setting the parameter to 41.398357,2.1731713 would show you Barcelona. You can find out the coordinates by clicking on a place inside Google Maps (on your Computer) and inside the URL you will find the coordinates. To use the current location leave the parameters empty

Example configuration for Barcelona:

configuration example for Barcelona

Credits

Comments

Yes, lot to catch up with for me in the javascript world


👉 Remember: Open Data is awesome!

// 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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment