Created
April 8, 2022 19:50
-
-
Save coughski/acaf54d0c18bbad7620f2c1e486d8169 to your computer and use it in GitHub Desktop.
A NYC subway departure timeline widget with status alerts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: deep-blue; icon-glyph: subway; | |
let GtfsRealtimeBindings = importModule("gtfs-realtime.js") // add this file to Scriptable from https://github.com/MobilityData/gtfs-realtime-bindings/blob/master/nodejs/gtfs-realtime.js | |
// also add https://github.com/protobufjs/protobuf.js/blob/master/dist/protobuf.min.js to Scriptable | |
const API_KEY = "paste_your_api_key_here" // get a free MTA API key at https://api.mta.info/#/landing | |
const ROOT = "entity" | |
const ALERT = "alert" | |
const ACTIVE = "active_period" | |
const INFORMED = "informed_entity" | |
const ROUTE = "route_id" | |
const HEADER = "header_text" // headline | |
const DESCR = "description_text" // details | |
const START = "start" | |
const END = "end" | |
const MERCURY = "transit_realtime.mercury_alert" | |
const TYPE = "alert_type" | |
const LANG_VALUE = "en" | |
const ROUTE_VALUE = "A" | |
let imageSize = new Size(329, 125) | |
let feed = await loadData() | |
let timeList = parseDepartures(feed) | |
let resp = await loadAlertData() | |
let reasons = Array.from(processAlerts(resp)) | |
console.log(reasons) | |
let widget = await createWidget(timeList, reasons) | |
Script.setWidget(widget) | |
await widget.presentMedium() | |
// console.log(timeList) | |
return timeList.slice(0, 3) | |
async function loadData() { | |
// select your train route feed here: https://api.mta.info/#/subwayRealTimeFeeds | |
const ACE_TRAIN_REALTIME_FEED = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace" | |
let request = new Request(ACE_TRAIN_REALTIME_FEED) | |
request.headers = { "x-api-key" : API_KEY } | |
let data = await request.load() | |
let bytes = data.getBytes() | |
var feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(bytes); | |
return feed | |
} | |
function parseDepartures(feed) { | |
// Find your route in routes.txt | |
const ROUTE_VALUE = "A" | |
// Find your stop in stops.txt in http://web.mta.info/developers/data/nyct/subway/google_transit.zip | |
const REALTIME_STOP_VALUE = "A42S" // 42nd St on the A line heading south | |
let times = new Set() | |
feed.entity.forEach(function (entity) { | |
if (entity.tripUpdate && entity.tripUpdate.trip.routeId == ROUTE_VALUE) { | |
for (let update of entity.tripUpdate.stopTimeUpdate) { | |
if (update.stopId == REALTIME_STOP_VALUE) { | |
let departDate = new Date(update.departure.time * 1000) | |
let now = new Date() | |
let millis_until_depart = departDate - now | |
if (millis_until_depart > 0) { | |
let minutes = Math.round((millis_until_depart / 1000) / 60) | |
if (minutes <= 60) { | |
times.add(minutes) | |
} | |
} | |
} | |
} | |
} | |
}); | |
let timeList = Array.from(times) | |
timeList.sort((a, b) => a - b) | |
return timeList | |
} | |
function renderImage(departures) { | |
// context settings | |
let width = imageSize.width | |
let height = imageSize.height | |
ctx = new DrawContext() | |
ctx.respectScreenScale = true | |
ctx.size = imageSize | |
ctx.opaque = false | |
let defaultDynamicColor = Color.dynamic(Color.black(), Color.white()) | |
let defaultDynamicFillColor = Color.dynamic(Color.white(), Color.black()) | |
let defaultThickness = 1 | |
// timeline settings | |
let timeLineHeight = height / 2 | |
let timePath = new Path() | |
let timeStart = new Point(0, timeLineHeight) | |
let timeEnd = new Point(width, timeLineHeight) | |
timePath.move(timeStart) | |
timePath.addLine(timeEnd) | |
// tick settings | |
let timeLength = 27 | |
let tickWidth = width / timeLength | |
let majorTickFreq = 5 | |
let minorTickHeight = 8 | |
let majorTickHeight = 20 | |
let tickPenetration = 0 | |
// text settings | |
let textRectWidth = 25 | |
let textRectHeight = 20 | |
let textPad = 4 | |
ctx.setTextColor(defaultDynamicColor) | |
ctx.setTextAlignedCenter() | |
// ctx.setFont(Font.systemFont(12)) | |
// draw ticks | |
for (let tick = 1; tick < timeLength; tick++) { | |
let major = tick % majorTickFreq == 0 | |
let thickness = defaultThickness | |
let color = defaultDynamicColor | |
if (tick != 5 && tick != 10) { | |
let tickPath = new Path() | |
let tickStart = new Point(tick * tickWidth, timeLineHeight - tickPenetration) | |
let tickEnd = new Point(tick * tickWidth, timeLineHeight + (major ? majorTickHeight : minorTickHeight)) | |
tickPath.move(tickStart) | |
tickPath.addLine(tickEnd) | |
ctx.addPath(tickPath) | |
} | |
ctx.setLineWidth(thickness) | |
ctx.setStrokeColor(color) | |
ctx.strokePath() | |
} | |
// draw timeline | |
ctx.setLineWidth(defaultThickness) | |
ctx.setStrokeColor(defaultDynamicColor) | |
ctx.addPath(timePath) | |
ctx.strokePath() | |
// red frame settings | |
let windowColor = Color.red() | |
let windowLineWidth = 2 | |
let windowHeight = 100 | |
let windowTickWidth = 5 | |
let windowWidth = tickWidth * windowTickWidth | |
let window = new Rect(tickWidth * windowTickWidth, timeLineHeight - windowHeight / 2, windowWidth, windowHeight) | |
let windowPath = new Path() | |
windowPath.addRoundedRect(window, 8, 8) | |
// train settings | |
let trainTickWidth = 2.4 | |
let trainWidth = trainTickWidth * tickWidth | |
let trainHeight = 13 | |
let trainPad = 3 | |
ctx.setLineWidth(defaultThickness) | |
ctx.setStrokeColor(defaultDynamicColor) | |
ctx.setFillColor(defaultDynamicFillColor) | |
// draw trains | |
for (let i = departures.length - 1; i >= 0; i--) { | |
let trainX = departures[i] * tickWidth | |
let trainY = timeLineHeight - trainHeight - trainPad | |
let trainRect = new Rect(trainX, trainY, trainWidth, trainHeight) | |
let trainPath = new Path() | |
trainPath.addRoundedRect(trainRect, 5, 7) | |
ctx.addPath(trainPath) | |
ctx.fillPath() | |
ctx.addPath(trainPath) | |
ctx.strokePath() | |
} | |
// draw red frame | |
ctx.setLineWidth(windowLineWidth) | |
ctx.setStrokeColor(windowColor) | |
ctx.addPath(windowPath) | |
ctx.strokePath() | |
// draw major tick text labels | |
for (let tick = 1; tick < timeLength; tick++) { | |
let major = tick % majorTickFreq == 0 | |
if (major) { | |
let textRect = new Rect(tick * tickWidth - textRectWidth / 2, timeLineHeight + majorTickHeight + textPad, textRectWidth, textRectHeight) | |
ctx.drawTextInRect(tick.toString(), textRect) | |
} | |
} | |
return ctx.getImage() | |
} | |
async function createWidget(departures, delayReasons) { | |
let render = renderImage(departures) | |
// let bgColor = Device.isUsingDarkAppearance() ? Color.black() : Color.white() | |
let w = new ListWidget() | |
// w.url = "https://new.mta.info" | |
// w.backgroundColor = Color.darkGray() | |
let stack = w.addStack() | |
stack.centerAlignContent() | |
stack.setPadding(10, 20, 0, 2) | |
if (delayReasons.length > 0) { | |
let warn = SFSymbol.named("exclamationmark.triangle.fill") | |
warn.applyFont(Font.systemFont(60)) | |
let img = stack.addImage(warn.image) | |
img.imageSize = new Size(16, 16) | |
stack.addSpacer(2) | |
txt = stack.addText(delayReasons[0]) | |
txt.font = Font.caption2() | |
} | |
stack.addSpacer() | |
street = stack.addText("42nd") | |
street.textOpacity = 0.5 | |
street.font = Font.caption1() | |
stack.addSpacer(10) | |
date = stack.addDate(new Date()) | |
date.applyTimeStyle() | |
date.textOpacity = 0.5 | |
date.rightAlignText() | |
date.font = Font.caption2() | |
w.addSpacer() | |
let img = w.addImage(render) | |
// img.imageSize = imageSize | |
img.resizable = false | |
img.centerAlignImage() | |
// img.applyFillingContentMode() | |
w.setPadding(0, 0, 0, 0) | |
return w | |
} | |
function processAlerts(resp) { | |
let alerts = resp[ROOT] | |
let interesting_alerts = alerts.filter(alert => timely(alert) && relevant(alert)) | |
let reasons = new Set() | |
for (let alert of interesting_alerts) { | |
reasons.add(alert[ALERT][MERCURY][TYPE].replace("Planned - ", "")) | |
} | |
return Array.from(reasons) | |
} | |
async function loadAlertData() { | |
const SUBWAY_ALERTS_ENDPOINT = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/camsys%2Fsubway-alerts.json" | |
let request = new Request(SUBWAY_ALERTS_ENDPOINT) | |
request.headers = { "x-api-key" : API_KEY } | |
let response = await request.loadJSON() | |
return response | |
} | |
function timely(alert) { | |
let now = new Date() | |
let active_periods = alert[ALERT][ACTIVE] | |
// (!A || (A && C)) && (!B || (B && D)) | |
function started(period) { | |
let start = new Date(period[START] * 1000) | |
return !(START in period) || (START in period && start <= now) | |
} | |
function ongoing(period) { | |
let end = new Date(period[END] * 1000) | |
return !(END in period) || (END in period && now <= end) | |
} | |
let active = (period) => started(period) && ongoing(period) | |
return active_periods.some(active) | |
} | |
function relevant(alert) { | |
let informed_entities = alert[ALERT][INFORMED] | |
const hasRelevantRoute = (entity) => (ROUTE in entity && entity[ROUTE] == ROUTE_VALUE) | |
return informed_entities.some(hasRelevantRoute) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hey! I’m having an issue when running the script. I have both modules in Scriptable but this is the error I keep getting:
Error on line 18:24 in gtfs-realtime module: ReferenceError: Can't find variable: require
Would you have any idea what went wrong?