Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jrkager/f6519aaf39e8c081ce179967543e9a45 to your computer and use it in GitHub Desktop.
Save jrkager/f6519aaf39e8c081ce179967543e9a45 to your computer and use it in GitHub Desktop.
A scriptable widget to display and show the new cases of the last day and the current incidence of South Tyrol.
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-gray; icon-glyph: magic;
// script by @johk95
// data by @ivansieder (cases), Governo Italiano (vaccines), Markus Falk (R-value)
// help from @bmgnrs
// -- SETTINGS --
// set to false if you want to disable local (gemeinden) data
const showLocalData = true;
// number of days you want to display in the chart. set -1 to disable chart
const nDaysInChart = 45;
// languages
const locInfo = Device.locale().split("_");
const language = locInfo[0].toLowerCase();
//const locale = locInfo[1].toLowerCase();
const locale = language;
const fallback = "de";
// Define URLs
const dataUrl = "https://api.corona-bz.simedia.cloud";
const dateKey = "date"
const newPositivePCRKey = "newPositiveTested";
const newPositiveAntigenKey = "newPositiveAntigenTests";
const newTotalPositiveKey = "newTotalPositiveTested";
const pcrIncidenceKey = "sevenDaysIncidencePerOneHundredThousandPositiveTested";
const totalIncidenceKey = "sevenDaysIncidencePerOneHundredThousandTotalPositiveTested";
const totalIncidenceDays = 7;
const regionKey = "P.A. Bolzano";
const vaccinesUrl = (rkey) => `https://raw.githubusercontent.com/jrkager/covid-vaccinations-italy/main/vacc-history/${encodeURI(rkey)}.csv`;
const osmUrl = (location) =>
`https://nominatim.openstreetmap.org/reverse?lat=${location.latitude.toFixed(4)}&lon=${location.longitude.toFixed(4)}&zoom=10&accept-language=en&addressdetails=0&namedetails=1&extratags=1&format=json`;
const commUrl = (date) => `https://chart.corona-bz.simedia.cloud/municipality-data/${date}.json`;
const commIncidenceKey = "fourteenDaysPrevalencePerThousandNOTWORKING";
const commPcrKey = "increaseSinceDayBefore";
const commAgKey = "increasePositiveAntigenTests";
const commIncidenceDays = 14;
const rUrl = (istatcode) => `https://www.markusfalk.com/dashboard/rt.php?istatCode=${istatcode}`;
const rKey = "rt";
// localization
const newInfectionsLoc = {"de" : "Neuinfektionen", "it" : "Nuove infezioni", "en" : "New infections"};
const notAvailableLoc = {"de" : "Daten nicht verfügbar", "it" : "Dati non disponibili", "en" : "Data not available"};
const incidenceLoc = {"de" : "Inzidenz", "it" : "Incidenza", "en" : "Incidence"};
const incidenceInfoLoc = {"de" : "Tage", "it" : "gg.", "en" : "days"};
const updatedLoc = {"de" : "Akt. am", "it" : "Agg. il", "en" : "Updated"};
const southtyrolLoc = {"de" : "Südtirol", "it" : "Alto Adige", "en" : "South Tyrol"};
const chartStartLoc = {"de" : (ndays) => `Kurve: Inzidenz der letzten ${ndays} Tage`, "it" : (ndays) => `Diagr.: incidenza negli ultimi ${ndays} gg.`, "en" : (ndays) => `Chart: incidence of past ${ndays} days`};
const vaccinatedLoc = {"de" : "Geimpfte", "it" : "vaccinati", "en" : "vacc."};
const ofDosesLoc = {"de" : "der verfügbaren Dosen", "it" : "dei dosi consegnati", "en" : "of available doses"};
// classes
class Series{
constructor(data, ymin=null, ymax=null) {
this.data = data;
this.yMin = ymin !== null ? ymin : Math.min(...data);
this.yMax = ymax !== null ? ymax : Math.max(...data);
}
}
class LineChart {
// LineChart by https://kevinkub.de/
constructor(width, height, seriesA, seriesB, options) {
this.ctx = new DrawContext();
this.ctx.size = new Size(width, height);
this.seriesA = seriesA;
this.seriesB = seriesB;
}
_calculatePath(series, fillPath) {
if (series == null) {
return null;
}
let maxValue = series.yMax;
let minValue = series.yMin;
let difference = maxValue - minValue;
let count = series.data.length;
let step = this.ctx.size.width / (count - 1);
let points = series.data.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, fillPath);
}
_getSmoothPath(points, fillPath) {
let path = new Path();
if (fillPath) {
path.move(new Point(0, this.ctx.size.height));
path.addLine(points[0]);
} else {
path.move(points[0]);
}
for (let 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);
}
if (fillPath) {
path.addLine(new Point(this.ctx.size.width, this.ctx.size.height));
path.closeSubpath();
}
return path;
}
configure(fn) {
let pathA = this._calculatePath(this.seriesA, true);
let pathB = this._calculatePath(this.seriesB, false);
if (fn) {
fn(this.ctx, pathA, pathB);
} else {
this.ctx.addPath(pathA);
this.ctx.fillPath(pathA);
this.ctx.addPath(pathB);
this.ctx.fillPath(pathB);
}
return this.ctx;
}
}
class UI {
constructor(view) {
if (view instanceof UI) {
this.view = this.elem = view.elem
} else {
this.view = this.elem = view
}
}
stack(type = 'h', padding = false, borderBgColor = false, radius = false, borderWidth = false, size = false) {
this.elem = this.view.addStack()
if (radius) this.elem.cornerRadius = radius
if (borderWidth !== false) {
this.elem.borderWidth = borderWidth
this.elem.borderColor = new Color(borderBgColor)
} else if (borderBgColor) {
this.elem.backgroundColor = new Color(borderBgColor)
}
if (padding) this.elem.setPadding(...padding)
if (size) this.elem.size = new Size(size[0], size[1])
if (type === 'h') { this.elem.layoutHorizontally() } else { this.elem.layoutVertically() }
this.elem.centerAlignContent()
return this
}
text(text, font = false, color = false, maxLines = 0, minScale = 0.9) {
let t = this.elem.addText(text)
if (color) t.textColor = (typeof color === 'string') ? new Color(color) : color
t.font = (font) ? font : Font.mediumSystemFont(12)
t.lineLimit = (maxLines > 0 && minScale < 1) ? maxLines + 1 : maxLines
t.minimumScaleFactor = minScale
return this
}
space(size) {
this.elem.addSpacer(size)
return this
}
static paddedStack(list) {
const s = list.addStack();
s.setPadding(0,0,0,0);
return s;
}
}
// fetch JSON data
let allDays = await new Request(dataUrl).loadJSON();
// get latest day
let data = allDays[allDays.length - 1];
if (data) {
data.rValue = await getRValue("all");
}
let dateString = getLocaleDate(data[dateKey]);
// get local data
let commData = null;
if (showLocalData) {
const locData = await getIstatCode();
if (locData) {
let istatCode = locData.istatCode;
let names = locData.names;
// check if in South Tyrol
if (istatCode < 21001 || istatCode > 21115) {
// use Bolzano as fallback if location not available or user outside south tyrol. Or should we just return null?
log("Location fallback to Bolzano");
istatCode = 21008;
names = {"de" : "Bozen", "it" : "Bolzano"};
}
commData = await getLocalCovidData(istatCode);
commData.areaName = names[language in names ? language : fallback];
commData.rValue = await getRValue(istatCode);
if (commData.cases == null && commData.incidence == null) {
commData = null;
}
}
}
// Initialize Widget
let widget = await createWidget();
if (!config.runsInWidget) {
await widget.presentSmall();
}
Script.setWidget(widget);
Script.complete();
// Build Widget
async function createWidget(items) {
const list = new ListWidget();
list.setPadding(0, 1, 0, 0);
// refresh in an hour
list.refreshAfterDate = new Date(Date.now() + 60 * 60 * 1000);
let header, label;
let topBar = new UI(list).stack('h', [0, 0, 2, 0])
topBar.text("🦠", Font.mediumSystemFont(22))
topBar.space(2)
if ( ! data ) {
topBar.space()
list.addSpacer()
let statusError = new UI(list).stack('v', [4, 6, 4, 6])
//todo text translation
statusError.text('Daten konnten nicht geladen werden. \n\nBitte später nochmal versuchen.')
list.addSpacer(4)
return list
}
let topRStack = new UI(topBar).stack('v', [0,0,0,0]);
let rtext = ""
if ( data.rValue ) {
rtext = data.rValue.toLocaleString(locale) + 'ᴿ';
if (commData && commData.rValue) {
rtext = rtext + " (" + commData.rValue.toLocaleString(locale) + 'ᴿ)';
}
} else {
rtext = "N/A"
}
topRStack.text(rtext, Font.mediumSystemFont(15));
topRStack.text(dateString, Font.boldSystemFont(9), '#777');
// new cases
const casesStack = UI.paddedStack(list);
casesStack.layoutHorizontally();
casesStack.bottomAlignContent();
newCasesST = casesStack.addStack();
newCasesST.layoutVertically();
// fetch new cases
const newCasesData = getNewCasesData(data);
if (newCasesData) {
label = newCasesST.addText("+" + newCasesData.value.toLocaleString());
label.font = Font.mediumSystemFont(20);
const area = newCasesST.addText(newCasesData.areaName);
area.font = Font.mediumSystemFont(12);
area.textColor = Color.gray();
} else {
label = list.addText("-1");
label.font = Font.mediumSystemFont(24);
const err = list.addText(notAvailableLoc[language in notAvailableLoc ? language : fallback]);
err.font = Font.mediumSystemFont(12);
err.textColor = Color.red();
}
if (language == "en") {
casesStack.addSpacer(10);
} else {
casesStack.addSpacer(14);
}
newCasesComm = casesStack.addStack();
newCasesComm.layoutVertically();
// fetch new cases
if (commData) {
if (commData.cases != null) {
label = newCasesComm.addText("(+" + commData.cases.toLocaleString() +")");
} else {
label = newCasesComm.addText("");
}
label.font = Font.mediumSystemFont(18);
newCasesComm.addSpacer(2);
const area = newCasesComm.addText("(" + commData.areaName + ")");
area.font = Font.mediumSystemFont(12);
area.textColor = Color.gray();
}
list.addSpacer(4);
// incidence
const headerStack = UI.paddedStack(list);
headerStack.bottomAlignContent();
header = headerStack.addText(incidenceLoc[language in incidenceLoc ? language : fallback].toUpperCase() + ":");
header.font = Font.mediumSystemFont(10);
headerStack.addSpacer(6);
let incinfotext = totalIncidenceDays.toString();
if (commData && commData.incidence != null) {
incinfotext += " (" + commIncidenceDays.toString() + ")";
}
incinfotext += " " + incidenceInfoLoc[language in incidenceLoc ? language : fallback].toUpperCase();
incInfo = headerStack.addText(incinfotext);
incInfo.font = Font.mediumSystemFont(8);
const incStack = UI.paddedStack(list);
incStack.layoutHorizontally();
incStack.bottomAlignContent();
const incidenceData = getIncidenceData(data);
if (incidenceData) {
label = incStack.addText(incidenceData.value.toLocaleString(locale, {maximumFractionDigits:1,}));
label.font = Font.mediumSystemFont(22);
label.textColor = getIncidenceColor(incidenceData.value);
if (commData && commData.incidence != null) {
incStack.addSpacer(14);
label = incStack.addText("("+commData.incidence.toLocaleString(locale, {maximumFractionDigits:1,})+")");
label.font = Font.mediumSystemFont(18);
label.textColor = getIncidenceColor(incidenceData.value);
}
} else {
label = incStack.addText("NA");
label.font = Font.mediumSystemFont(22);
}
//list.addSpacer();
// fetch new vaccines
//let regions = await new Request(vaccinesUrl).loadJSON();
const vaccineData = await getVaccineData(regionKey);
let value1d;
let value2d;
let percInh1d;
let percInh2d;
let percDoses;
if (vaccineData) {
value1d = vaccineData.value1d.toLocaleString();
value2d = vaccineData.value2d.toLocaleString();
percInh1d = vaccineData.percOfInh1d.toLocaleString(locale, {maximumFractionDigits:1,});
percInh2d = vaccineData.percOfInh2d.toLocaleString(locale, {maximumFractionDigits:1,});
percDoses = vaccineData.percOfDoses.toLocaleString(locale, {maximumFractionDigits:0,});
}
const vaccStack = UI.paddedStack(list);
vaccStack.layoutHorizontally();
vaccStack.centerAlignContent();
//emoji = vaccStack.addStack();
vaccStack.addText("💉").font = Font.mediumSystemFont(12);
vaccStack.addSpacer(2);
vaccData = vaccStack.addStack();
vaccData.layoutVertically();
if (vaccineData) {
h1 = vaccData.addText(`1D: ${percInh1d}% / 2D: ${percInh2d}%`);
} else {
h1 = vaccData.addText("N/A");
}
h1.font = Font.mediumSystemFont(10);
h1.textColor = Color.gray()
vaccData.addSpacer(1);
if (vaccineData) {
h2 = vaccData.addText(`${percDoses}% ${ofDosesLoc[language in ofDosesLoc ? language : fallback]}`);
} else {
h2 = vaccData.addText("N/A");
}
h2.font = Font.mediumSystemFont(8);
h2.textColor = Color.gray();
list.addSpacer(2);
// plot chart
if (nDaysInChart > 0) {
let incidenceTL = getTimeline(allDays.slice(allDays.length - nDaysInChart, allDays.length), totalIncidenceKey);
const dateStack = UI.paddedStack(list);
const firstdate = dateStack.addText(chartStartLoc[language in chartStartLoc ? language : fallback](incidenceTL.ndays));
firstdate.font = Font.mediumSystemFont(7);
firstdate.textColor = Color.gray();
let chart = new LineChart(800, 800, null,
new Series(incidenceTL.timeline,0,1.1*Math.max(...incidenceTL.timeline))
).configure((ctx, pathA, pathB) => {
ctx.opaque = false;
ctx.setFillColor(new Color("888888", .5));
if (pathA) {
ctx.addPath(pathA);
ctx.fillPath(pathA);
}
if (pathB) {
ctx.addPath(pathB);
ctx.setStrokeColor(Color.dynamic(Color.black(), Color.white()));
ctx.setLineWidth(1);
ctx.strokePath();
}
}).getImage();
list.backgroundImage = chart;
}
return list;
}
function getLocaleDate(datestring, noday = false) {
const d = new Date(datestring);
let options = { year: 'numeric', month: 'short', day: 'numeric' };
const day = d.toLocaleString(locale, {weekday: "short"});
const rest = d.toLocaleString(locale, options);
if (noday) {
return rest;
} else {
return day + ", " + rest;
}
}
function getTimeline(data, key) {
var timeline = [];
var firstDate = "";
for (day of data) {
const component = day[key];
if (component) {
if (firstDate == "") {
firstDate = day[dateKey];
}
timeline.push(component);
}
}
const diffTime = Math.abs(new Date() - new Date(firstDate));
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return {"timeline" : timeline, "firstdate" : firstDate, "ndays" : diffDays};
}
function csvToJson(allText) {
var allTextLines = allText.split(/\r\n|\n/);
var headers = allTextLines[0].split(',');
var lines = [];
for (var i=1; i<allTextLines.length; i++) {
var data = allTextLines[i].split(',');
if (data.length == headers.length) {
var row = {};
for (var j=0; j<headers.length; j++) {
let v = parseFloat(data[j]);
if (v == parseInt(v)) {
v = parseInt(v);
}
row[headers[j]] = v;
}
lines.push(row);
}
}
return lines;
}
// Get number of given vaccines in region with regionkey
async function getVaccineData(regionkey) {
try {
let rdata = await new Request(vaccinesUrl(regionkey)).loadString();
let region = csvToJson(rdata);
const last = region.length - 1;
return {
value1d: region[last].sum_1d,
value2d: region[last].sum_1d,
percOfInh1d: region[last].perc_inh_1d,
percOfInh2d: region[last].perc_inh_2d,
percOfDoses: Math.min(100.0,region[last].perc_doses),
areaName: regionkey,
};
} catch (e) {
return null;
}
}
function getNewCasesData(data) {
try {
const newCases = data[newTotalPositiveKey];
return {
value: newCases,
areaName: southtyrolLoc[language in southtyrolLoc ? language : fallback],
};
} catch (e) {
return null;
}
}
function getIncidenceData(data) {
try {
const incidence = data[totalIncidenceKey];
return {
value: incidence,
areaName: southtyrolLoc[language in southtyrolLoc ? language : fallback],
};
} catch (e) {
return null;
}
}
function getIncidenceColor(value) {
if (value <= 0.01) {
return new Color("2C83B9");
} else if (value < 15) {
return new Color("80D38D");
} else if (value < 25) {
return new Color("FCF5B0");
} else if (value < 35) {
return new Color("FECA81");
} else if (value < 50) {
return new Color("F1894A");
} else if (value < 100) {
return new Color("EC3522");
} else if (value < 200) {
return new Color("AD2418");
} else if (value < 350) {
return new Color("B275DE");
} else {
return new Color("5F429A");
}
}
async function getRValue(istatCode) {
try {
let rdata = await new Request(rUrl(istatCode)).loadJSON();
return parseFloat(rdata[rKey]);
} catch (e) {
logWarning(e);
return null;
}
}
async function getLocalCovidData(istatCode) {
try {
// get latest data
testday = new Date();
let dateFormatter = new DateFormatter();
dateFormatter.dateFormat = "yyyy-MM-dd";
let data;
let run = 0;
const maxRuns = 3;
do {
todayString = dateFormatter.string(testday);
try {
data = await new Request(commUrl(todayString)).loadJSON();
} catch (e) {}
testday.setDate(testday.getDate() - 1);
run += 1;
} while (run <= maxRuns && (!data || Object.keys(data).length === 0));
// get data for current istat code
let comm;
do {
comm = data.pop();
} while (comm.municipalityIstatCode != istatCode && data.length);
// return specific values
if (comm.municipalityIstatCode != istatCode) {
return null;
} else {
let sum = null;
if (comm[commPcrKey] != null || comm[commAgKey] != null){
sum = comm[commPcrKey] ? comm[commPcrKey] : 0;
sum += comm[commAgKey] ? comm[commAgKey] : 0;
}
let incidence = comm[commIncidenceKey] != null ? comm[commIncidenceKey] * 100 : null;
return {
cases: sum,
incidence: incidence,
};
}
} catch (e) {
logWarning(e);
return null;
}
}
async function getIstatCode() {
try {
const location = await getLocation();
let istatCode = -1;
let names = {};
if (location) {
// get current ISTAT code
let geo = await new Request(osmUrl(location)).loadJSON();
istatCode = parseInt(geo.extratags["ref:ISTAT"]);
if (isNaN(istatCode)) {
return null;
}
if ("name:de" in geo.namedetails) {
names["de"] = geo.namedetails["name:de"];
} else {
names["de"] = geo.namedetails["name"];
}
if ("name:it" in geo.namedetails) {
names["it"] = geo.namedetails["name:it"];
} else {
names["it"] = geo.namedetails["name"];
}
log(location);
} else {
logWarning("No GPS data provided. Did you check the permissions for Scriptable?");
return null;
}
log({istatCode : istatCode, names : names});
return {istatCode : istatCode, names : names};
} catch (e) {
logWarning(e);
return null;
}
}
async function getLocation() {
try {
if (args.widgetParameter) {
const fixedCoordinates = args.widgetParameter.split(",").map(parseFloat);
return { latitude: fixedCoordinates[0], longitude: fixedCoordinates[1] };
} else {
Location.setAccuracyToHundredMeters();
return await Location.current();
}
} catch (e) {
return null;
}
}
@jrkager
Copy link
Author

jrkager commented Dec 30, 2020

Installation:

  • Scriptable installieren: https://apps.apple.com/dk/app/scriptable/id1405459188
  • Javascript Code kopieren und in neues Script in Scriptable einfügen
  • Auf dem Homescreen drücke irgendwo lang, um den "wiggle mode" zu aktivieren
  • Oben links auf "+" Symbol klicken, dann nach unten zu "Scriptable" blättern, die erste Widget-Größe (small) wählen und auf "Widget hinzufügen" tippen
  • Drücke im Wiggle Mode auf das Widget, um seine Einstellungen zu bearbeiten
  • Wähle unter "Script" das oben erstellte aus

Die angezeigte Ortschaft kann manuell gewählt werden, indem die GPS Koordinaten im Format "lat,long" als Parameter übergeben werden.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment