Skip to content

Instantly share code, notes, and snippets.

@coughski
Created April 8, 2022 19:50
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save coughski/acaf54d0c18bbad7620f2c1e486d8169 to your computer and use it in GitHub Desktop.
Save coughski/acaf54d0c18bbad7620f2c1e486d8169 to your computer and use it in GitHub Desktop.
A NYC subway departure timeline widget with status alerts
// 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)
}
@Shadowspear123
Copy link

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?

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