diff --git a/.gitignore b/.gitignore index 2b55a73..715c4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ .env -public/stylesheets/* -public/scripts/* -!public/scripts/.gitkeep -!public/stylesheets/.gitkeep - node_modules dist/* diff --git a/public/scripts/cta.min.js b/public/scripts/cta.min.js new file mode 100644 index 0000000..8801e25 --- /dev/null +++ b/public/scripts/cta.min.js @@ -0,0 +1 @@ +const pageTypeMetaElement = document.querySelector('meta[name="tracker"]'); if (!pageTypeMetaElement) throw new Error("Page is missing meta element indicating whether this should be a train or a bus tracker"); const pageType = pageTypeMetaElement.getAttribute("content"); const modules = []; let className; switch (pageType) { case "train": { modules.push("client/ts/boards/TrainSignboard"); className = "TrainSignboard"; break } case "bus": { modules.push("client/ts/boards/BusSignboard"); className = "BusSignboard"; break } default: { throw new Error(`Invalid page type: ${pageType}. Expected 'train' or 'bus'.`) } }const getVersion = async () => { const result = await fetch("/client-version"); if (!result.ok) { console.error("Trouble fetching current client version."); return null } return await result.text() }; const setupVersionCheck = async () => { window["clientVersion"] = await getVersion(); window.setInterval(async () => { const newVersion = await getVersion(); if (newVersion !== window["clientVersion"]) window.location.reload() }, 9e5) }; setupVersionCheck().then(); require(modules, module => { window[className] = new module[className] }); define("client/ts/panes/IPane", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }) }); define("client/ts/boards/AbstractSignboard", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class AbstractSignboard { PANES = []; ERRORS = []; constructor() { if (document.readyState === "complete" || document.readyState === "interactive") { this.init() } else { window.addEventListener("DOMContentLoaded", this.init) } } init = () => { this.PANES.push(...this.getPanes()); this.ERRORS.push(""); const cycleTick = this.cycle(); cycleTick().then() }; cycle = () => { let currentPane = -1; const cycleTick = async () => { const prevPane = currentPane !== -1 ? this.PANES[currentPane] : null; let nextPane; do { ++currentPane; currentPane = currentPane % this.PANES.length; nextPane = this.PANES[currentPane] } while (nextPane.shouldSkip()); console.log(`Progressing to pane ${currentPane}`); try { await nextPane.prepare(); this.ERRORS[currentPane] = "" } catch (e) { if (e instanceof Error) this.ERRORS[currentPane] = e["message"] } if (this.ERRORS.find(it => !!it)) this.showError(this.ERRORS.join(" ")); else this.clearError(); await prevPane?.hide(); await nextPane.show(); if (!window.location.hash.includes("pause")) window.setTimeout(cycleTick, this.getCycleTime()) }; return cycleTick } } exports.default = AbstractSignboard }); define("client/ts/util/Wait", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = async ms => new Promise(resolve => setTimeout(resolve, ms)) }); define("client/ts/panes/AbstractPane", ["require", "exports", "client/ts/util/Wait"], function (require, exports, Wait_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class AbstractPane { rootDom; HIDE_DELAY_MS = 1.25 * 1e3; SHOW_DELAY_MS = .25 * 1e3; constructor(el) { this.rootDom = el } static findElementByClass(className) { const elements = document.getElementsByClassName(className); if (elements.length === 0) { throw new Error(`Couldn't find element with class '${className}'`) } return elements[0] } shouldSkip() { return false } async show() { this.rootDom.classList.remove("gone"); await (0, Wait_1.default)(this.SHOW_DELAY_MS); this.rootDom.classList.remove("hidden") } async hide() { this.rootDom.classList.add("hidden"); await (0, Wait_1.default)(this.HIDE_DELAY_MS); this.rootDom.classList.add("gone") } } exports.default = AbstractPane }); define("shared/models/Alert", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Alert { start; end; icon; id; headline; description; impact; timestampString() { const dateOptions = { weekday: "short", month: "short", day: "2-digit" }; const timeOptions = { hour: "numeric", minute: "2-digit" }; const startDate = this.start.toLocaleDateString("en-US", dateOptions); const startTime = this.start.toLocaleTimeString("en-US", timeOptions).replace(/\s/g, ""); if (this.end === null || this.end.getTime() === 0) { return `(${startDate} ${startTime} — ??)` } const endDate = this.end.toLocaleDateString("en-US", dateOptions); const endTime = this.end.toLocaleTimeString("en-US", timeOptions); return endDate === startDate ? `(${startDate} ${startTime} — ${endTime})` : `(${startDate} ${startTime} — ${endDate} ${endTime})` } } exports.default = Alert }); define("client/ts/models/Alert", ["require", "exports", "shared/models/Alert"], function (require, exports, Alert_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Alert extends Alert_1.default { constructor(apiJson) { super(); this.id = apiJson.id; this.icon = apiJson.icon; this.headline = apiJson.headline; this.description = apiJson.description; this.impact = apiJson.impact; this.start = new Date(apiJson.start); if (apiJson.end) this.end = new Date(apiJson.end) } static async getAlerts(stationId) { const alertsRaw = await fetch(`/train/api/alerts/${stationId}`); const alertsJson = await alertsRaw.json(); const alerts = []; for (const alert of alertsJson) { alerts.push(new Alert(alert)) } return alerts } static async getCurrentAlerts(stationId) { return (await Alert.getAlerts(stationId)).filter(x => x.start.getTime() <= Date.now()) } getDescription() { return this.description } getIcon() { return this.icon } getImpact() { return this.impact } } exports.default = Alert }); define("client/ts/util/StationID", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = () => { const domElement = document.querySelector("meta[name='station_id']"); if (!domElement) throw new Error("Missing station ID meta element."); const rawValue = domElement.getAttribute("content"); if (!rawValue) throw new Error("Station ID meta element is missing content attribute."); return parseInt(rawValue) } }); define("client/ts/panes/AlertsPane", ["require", "exports", "client/ts/panes/AbstractPane", "client/ts/models/Alert", "client/ts/util/StationID"], function (require, exports, AbstractPane_1, Alert_2, StationID_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class AlertsPane extends AbstractPane_1.default { static ALERTS_REFRESH_DELAY_MS = 60 * 1e3; currentError = ""; alerts = []; currentAlert = -1; constructor() { super(AlertsPane.findElement()); this.updateAlerts().then(); window.setInterval(this.updateAlerts, AlertsPane.ALERTS_REFRESH_DELAY_MS) } static findElement = () => { return AbstractPane_1.default.findElementByClass("alerts") }; prepare = async () => { this.reRenderAlert(); if (this.currentError) throw new Error(this.currentError) }; shouldSkip = () => { return this.alerts.length === 0 }; updateAlerts = async () => { const stationId = (0, StationID_1.default)(); try { this.alerts = await Alert_2.default.getCurrentAlerts(stationId); this.currentError = "" } catch (e) { this.currentError = "Trouble fetching alerts from server." } }; reRenderAlert = () => { if (this.alerts.length === 0) return; ++this.currentAlert; this.currentAlert = this.currentAlert % this.alerts.length; const alert = this.alerts[this.currentAlert]; const alertBody = this.rootDom.getElementsByClassName("alert-body")[0]; const alertTypeImage = this.rootDom.querySelector("#alert-footer-type-image"); const alertType = this.rootDom.getElementsByClassName("alert-footer-type")[0]; const alertTimestamp = this.rootDom.getElementsByClassName("alert-footer-timestamp")[0]; alertBody.textContent = alert.getDescription(); alertTypeImage.src = `/static/images/${alert.getIcon()}.svg`; alertType.textContent = alert.getImpact(); alertTimestamp.innerHTML = alert.timestampString(); console.log(`Re-rendered alerts. Now on alert ${this.currentAlert}`) } } exports.default = AlertsPane }); define("client/ts/models/Weather", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Weather { TEMP; ICON; MAIN; constructor(weatherObj) { this.TEMP = Weather.kelvinToFahrenheit(weatherObj.main.temp); const icoPrefix = weatherObj.weather[0].icon.substr(0, 2); this.ICON = `https://openweathermap.org/img/wn/${icoPrefix}d@2x.png`; this.MAIN = weatherObj.weather[0].main } static async get() { const newWeatherData = await fetch("/train/api/weather"); const weatherJson = await newWeatherData.json(); return new Weather(weatherJson) } static kelvinToFahrenheit = temp => Math.round((temp - 273.15) * (9 / 5) + 32); getTemp = () => { return this.TEMP }; getIcon = () => { return this.ICON }; getMain = () => { return this.MAIN } } exports.default = Weather }); define("client/ts/panes/InfoPane", ["require", "exports", "client/ts/panes/AbstractPane", "client/ts/models/Weather"], function (require, exports, AbstractPane_2, Weather_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class InfoPane extends AbstractPane_2.default { static TIME_TICK_DELAY_MS = .5 * 1e3; static WEATHER_UPDATE_DELAY_MS = 15 * 60 * 1e3; TICK_INTERVAL; WEATHER_UPDATE_INTERVAL; currentWeather; constructor() { super(InfoPane.findElement()); this.timeTick(); this.TICK_INTERVAL = window.setInterval(this.timeTick, InfoPane.TIME_TICK_DELAY_MS); this.updateWeather().then(); this.WEATHER_UPDATE_INTERVAL = window.setInterval(this.updateWeather, InfoPane.WEATHER_UPDATE_DELAY_MS) } static findElement = () => { return AbstractPane_2.default.findElementByClass("info") }; prepare = async () => void 0; updateWeather = async () => { this.currentWeather = await Weather_1.default.get(); this.reRenderWeather() }; reRenderWeather = () => { const temperatureText = this.rootDom.querySelector("#temperature-text"); const temperatureIcon = this.rootDom.querySelector("#temperature-icon"); const weatherDescription = this.rootDom.querySelector("#weather-description"); if (!temperatureText || !weatherDescription) throw new Error("DOM not properly structured: missing #temperature-text or #weather-description"); temperatureText.innerHTML = `${this.currentWeather.getTemp()}°`; temperatureIcon.src = this.currentWeather.getIcon(); weatherDescription.textContent = this.currentWeather.getMain() }; timeTick = () => { const currentTime = new Date; const tsOptions = { hour: "numeric", minute: "2-digit" }; const [timeString, amPm] = currentTime.toLocaleTimeString("en-US", tsOptions).split(" "); const dsOptions = { month: "short", day: "numeric" }; const dateString = currentTime.toLocaleDateString("en-US", dsOptions); const timeField = this.rootDom.querySelector("#timefield"); const amPmMarker = this.rootDom.querySelector("#ampm-marker"); const dateEl = this.rootDom.querySelector("#date"); if (!timeField) throw new Error("DOM is missing #timefield"); if (!amPmMarker) throw new Error("DOM is missing #ampm-marker"); if (!dateEl) throw new Error("DOM is missing #date"); timeField.textContent = timeString; amPmMarker.textContent = amPm.toLowerCase(); dateEl.textContent = dateString } } exports.default = InfoPane }); define("shared/util/CTAData", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getDestinationCode = exports.findPossibleLines = exports.validateDestination = exports.stationId = exports.uriSafeStationIdMap = exports.lineMap = exports.ServiceType = void 0; var ServiceType; (function (ServiceType) { ServiceType["SYSTEM_WIDE"] = "X"; ServiceType["RAIL"] = "R"; ServiceType["BUS"] = "B"; ServiceType["TRAIN"] = "T" })(ServiceType = exports.ServiceType || (exports.ServiceType = {})); exports.lineMap = { Red: "Red", Org: "Orange", Y: "Yellow", G: "Green", Blue: "Blue", P: "Purple", Pink: "Pink", Brn: "Brown" }; const destinations = { Howard: ["Red", "Purple", "Yellow"], Loop: ["Red", "Orange", "Green", "Blue", "Purple", "Pink", "Brown"], "95th": ["Red"], "Dempster-Skokie": ["Yellow"], "Harlem/Lake": ["Green"], "Ashland/63rd": ["Green"], "Cottage Grove": ["Green"], "O'Hare": ["Blue"], "Forest Park": ["Blue"], Linden: ["Purple"], "54th/Cermak": ["Pink"], Kimball: ["Brown"], Midway: ["Orange"] }; const staticDestinationCodes = { Red: { "95th": 5, Howard: 1 }, Orange: { Midway: 5, Loop: 1 }, Yellow: { "Dempster-Skokie": 1, Howard: 5 }, Green: { "Harlem/Lake": 1, "Ashland/63rd": 5, "Cottage Grove": 5 }, Blue: { "O'Hare": 1, "Forest Park": 5 }, Purple: { Howard: 5, Linden: 1 }, Pink: { "54th/Cermak": 5, Loop: 1 }, Brown: { Kimball: 1, Loop: 5 } }; const lineStations = { Red: [["47th", 41230, 1], ["63rd", 40910, 1], ["69th", 40990, 1], ["79th", 40240, 1], ["87th", 41430, 1], ["95th/Dan Ryan", 40450, 1], ["Addison", 41420, 5], ["Argyle", 41200, 5], ["Belmont", 41320, 5], ["Berwyn", 40340, 5], ["Bryn Mawr", 41380, 5], ["Cermak-Chinatown", 41e3, 1], ["Chicago", 41450, 5], ["Clark/Division", 40630, 5], ["Fullerton", 41220, 5], ["Garfield", 41170, 1], ["Grand", 40330, 5], ["Granville", 40760, 5], ["Harrison", 41490, 1], ["Howard", 40900, 5], ["Jackson", 40560, -1], ["Jarvis", 41190, 5], ["Lake", 41660, -1], ["Lawrence", 40770, 5], ["Loyola", 41300, 5], ["Monroe", 41090, -1], ["Morse", 40100, 5], ["North/Clybourn", 40650, 5], ["Roosevelt", 41400, 1], ["Sheridan", 40080, 5], ["Sox-35th", 40190, 1], ["Thorndale", 40880, 5], ["Wilson", 40540, 5]], Blue: [["Addison", 41240, 5], ["Austin", 40010, 1], ["Belmont", 40060, 5], ["California", 40570, 5], ["Chicago", 41410, 5], ["Cicero", 40970, 1], ["Clark/Lake", 40380, -1], ["Clinton", 40430, 1], ["Cumberland", 40230, 5], ["Damen", 40590, 5], ["Division", 40320, 5], ["Forest Park", 40390, 1], ["Grand", 40490, 5], ["Harlem (Forest Park)", 40980, 1], ["Harlem (O'Hare)", 40750, 5], ["Illinois Medical District", 40810, 1], ["Irving Park", 40550, 5], ["Jackson", 40070, -1], ["Jefferson Park", 41280, 5], ["Kedzie-Homan", 40250, 1], ["LaSalle", 41340, -1], ["Logan Square", 41020, 5], ["Monroe", 40790, -1], ["Montrose", 41330, 5], ["O'Hare", 40890, 5], ["Oak Park", 40180, 1], ["Pulaski", 40920, 1], ["Racine", 40470, 1], ["Rosemont", 40820, 5], ["UIC-Halsted", 40350, 1], ["Washington", 40370, -1], ["Western (Forest Park)", 40220, 1], ["Western (O'Hare)", 40670, 5]], Orange: [["35th/Archer", 40120, -1], ["Adams/Wabash", 40680, -1], ["Ashland", 41060, -1], ["Clark/Lake", 40380, -1], ["Halsted", 41130, -1], ["Harold Washington Library-State/Van Buren", 40850, -1], ["Kedzie", 41150, -1], ["LaSalle/Van Buren", 40160, -1], ["Midway", 40930, -1], ["Pulaski", 40960, -1], ["Quincy", 40040, -1], ["Roosevelt", 41400, -1], ["State/Lake", 40260, -1], ["Washington/Wabash", 41700, -1], ["Washington/Wells", 40730, -1], ["Western", 40310, -1]], Yellow: [["Dempster-Skokie", 40140, -1], ["Howard", 40900, -1], ["Oakton-Skokie", 41680, -1]], Green: [["35th-Bronzeville-IIT", 41220, 1], ["43rd", 41270, 1], ["47th", 41080, 1], ["51st", 40130, 1], ["Adams/Wabash", 40680, -1], ["Ashland", 40170, 5], ["Ashland/63rd", 40290, 1], ["Austin", 41260, 5], ["California", 41360, 5], ["Central", 40280, 5], ["Cermak-McCormick Place", 41690, 1], ["Cicero", 40480, 5], ["Clark/Lake", 40380, -1], ["Clinton", 41160, 5], ["Conservatory-Central Park Drive", 41670, 5], ["Cottage Grove", 40720, 1], ["Garfield", 40510, 1], ["Halsted", 40940, 1], ["Harlem/Lake", 40020, 5], ["Indiana", 40300, 1], ["Kedzie", 41070, 5], ["King Drive", 41140, 1], ["Laramie", 40700, 5], ["Morgan", 41510, 5], ["Oak Park", 41350, 5], ["Pulaski", 40030, 5], ["Ridgeland", 40610, 5], ["Roosevelt", 41400, 1], ["State/Lake", 40260, -1], ["Washington/Wabash", 41700, -1]], Purple: [["Adams/Wabash", 40680, -1], ["Armitage", 40660, -1], ["Belmont", 41320, -1], ["Central", 41250, -1], ["Chicago", 40710, -1], ["Clark/Lake", 40380, -1], ["Davis", 40050, -1], ["Dempster", 40690, -1], ["Diversey", 40530, -1], ["Foster", 40520, -1], ["Fullerton", 41220, -1], ["Harold Washington Library-State/Van Buren", 40850, -1], ["Howard", 40900, -1], ["LaSalle/Van Buren", 40160, -1], ["Linden", 41070, -1], ["Main", 40270, -1], ["Merchandise Mart", 40460, -1], ["Noyes", 40400, -1], ["Quincy", 40040, -1], ["Sedgwick", 40800, -1], ["South Boulevard", 40840, -1], ["State/Lake", 40260, -1], ["Washington/Wabash", 41700, -1], ["Washington/Wells", 40730, -1], ["Wellington", 41210, -1], ["Wilson", 40540, -1]], Pink: [["18th", 40830, -1], ["54th/Cermak", 40580, -1], ["Adams/Wabash", 40680, -1], ["Ashland", 40170, -1], ["California", 40440, -1], ["Central Park", 40780, -1], ["Cicero", 40420, -1], ["Clark/Lake", 40380, -1], ["Clinton", 41160, -1], ["Damen", 40210, -1], ["Harold Washington Library-State/Van Buren", 40850, -1], ["Kedzie", 41040, -1], ["Kostner", 40600, -1], ["LaSalle/Van Buren", 40160, -1], ["Morgan", 41510, -1], ["Polk", 41030, -1], ["Pulaski", 40150, -1], ["Quincy", 40040, -1], ["State/Lake", 40260, -1], ["Washington/Wabash", 41700, -1], ["Washington/Wells", 40730, -1], ["Western", 40740, -1]], Brown: [["Adams/Wabash", 40680, -1], ["Addison", 41440, -1], ["Armitage", 40660, -1], ["Belmont", 41320, -1], ["Chicago", 40710, -1], ["Clark/Lake", 40380, -1], ["Damen", 40090, -1], ["Diversey", 40530, -1], ["Francisco", 40870, -1], ["Fullerton", 41220, -1], ["Harold Washington Library-State/Van Buren", 40850, -1], ["Irving Park", 41460, -1], ["Kedzie", 41180, -1], ["Kimball", 41290, -1], ["LaSalle/Van Buren", 40160, -1], ["Merchandise Mart", 40460, -1], ["Montrose", 41500, -1], ["Paulina", 41310, -1], ["Quincy", 40040, -1], ["Rockwell", 41010, -1], ["Sedgwick", 40800, -1], ["Southport", 40360, -1], ["State/Lake", 40260, -1], ["Washington/Wabash", 41700, -1], ["Washington/Wells", 40730, -1], ["Wellington", 41210, -1], ["Western", 41480, -1]] }; exports.uriSafeStationIdMap = { "18th": 40830, "35th-bronzeville-iit": 41220, "35th-archer": 40120, "43rd": 41270, "47th-green": 41080, "47th-red": 41230, "51st": 40130, "54th-cermak": 40580, "63rd": 40910, "69th": 40990, "79th": 40240, "87th": 41430, "95th-danryan": 40450, "adams-wabash": 40680, "addison-brown": 41440, "addison-blue": 41240, "addison-red": 41420, argyle: 41200, armitage: 40660, ashland: 40170, "ashland-orange": 41060, "ashland-63rd": 40290, "austin-blue": 40010, "austin-green": 41260, belmont: 41320, "belmont-blue": 40060, berwyn: 40340, brynmawr: 41380, "california-pink": 40440, "california-blue": 40570, "california-green": 41360, "central-purple": 41250, "central-green": 40280, centralpark: 40780, "cermak-chinatown": 41e3, "cermak-mccormickplace": 41690, "cermak-mcp": 41690, "chicago-purple-brown": 40710, "chicago-blue": 41410, "chicago-red": 41450, "cicero-pink": 40420, "cicero-blue": 40970, "cicero-green": 40480, "clark-division": 40630, "clark-lake": 40380, clinton: 41160, "clinton-blue": 40430, "conservatory-centralparkdrive": 41670, conservatory: 41670, cottagegrove: 40720, cumberland: 40230, "damen-brown": 40090, "damen-pink": 40210, "damen-blue": 40590, davis: 40050, dempster: 40690, "dempster-skokie": 40140, diversey: 40530, division: 40320, forestpark: 40390, foster: 40520, francisco: 40870, fullerton: 41220, "garfield-green": 40510, "garfield-red": 41170, "grand-blue": 40490, "grand-red": 40330, granville: 40760, "halsted-green": 40940, "halsted-orange": 41130, "harlem-fp": 40980, "harlem-ohare": 40750, "harlem-lake": 40020, "haroldwashingtonlibrary-state-vanburen": 40850, library: 40850, harrison: 41490, howard: 40900, illinoismedicaldistrict: 40810, imd: 40810, indiana: 40300, "irvingpark-brown": 41460, "irvingpark-blue": 40550, "jackson-blue": 40070, "jackson-red": 40560, jarvis: 41190, jeffersonpark: 41280, "kedzie-brown": 41180, "kedzie-pink": 41040, "kedzie-green": 41070, "kedzie-orange": 41150, "kedzie-homan": 40250, kimball: 41290, kingdrive: 41140, kostner: 40600, lake: 41660, laramie: 40700, lasalle: 41340, "lasalle-vanburen": 40160, lawrence: 40770, linden: 41070, logansquare: 41020, loyola: 41300, main: 40270, merchandisemart: 40460, midway: 40930, "monroe-blue": 40790, "monroe-red": 41090, "montrose-brown": 41500, "montrose-blue": 41330, morgan: 41510, morse: 40100, "north-clybourn": 40650, noyes: 40400, ohare: 40890, "oakpark-blue": 40180, "oakpark-green": 41350, "oakton-skokie": 41680, paulina: 41310, polk: 41030, "pulaski-pink": 40150, "pulaski-blue": 40920, "pulaski-green": 40030, "pulaski-orange": 40960, quincy: 40040, racine: 40470, ridgeland: 40610, rockwell: 41010, roosevelt: 41400, rosemont: 40820, sedgwick: 40800, sheridan: 40080, southboulevard: 40840, southport: 40360, "sox-35th": 40190, "state-lake": 40260, thorndale: 40880, "uic-halsted": 40350, washington: 40370, "washington-wabash": 41700, "washington-wells": 40730, wellington: 41210, "western-brown": 41480, "western-pink": 40740, "western-fp": 40220, "western-ohare": 40670, "western-orange": 40310, wilson: 40540 }; const resolvedStation = (station, line) => lineStations[line].find(x => x[0] === station); const stationId = (station, line) => { const res = resolvedStation(station, line); if (!res) return null; return res[1] }; exports.stationId = stationId; const validateDestination = (destination, line) => { const found = destinations[destination]; if (!found) return false; return found.includes(line) }; exports.validateDestination = validateDestination; const findPossibleLines = destination => { const found = destinations[destination]; if (!found) return null; return found }; exports.findPossibleLines = findPossibleLines; const getDestinationCode = (destination, line, station) => { const foundDestinationCodes = staticDestinationCodes[line]; if (foundDestinationCodes) { const foundDestinationCode = foundDestinationCodes[destination]; if (foundDestinationCode) return foundDestinationCode } const res = resolvedStation(station, line); if (!res) return null; return res[2] }; exports.getDestinationCode = getDestinationCode }); define("shared/models/Arrival", ["require", "exports"], function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Arrival { delayed; scheduled; fault; approaching; stopDescription; runNumber; line; destination; predictionTs; arrivalTs; direction; countdown(ts) { return Math.round(this.arrivalMs(ts) / (1e3 * 60)) } arrivalMs(ts) { return this.arrivalTs.getTime() - ts.getTime() } } exports.default = Arrival }); define("client/ts/models/Arrival", ["require", "exports", "shared/models/Arrival"], function (require, exports, Arrival_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class Arrival extends Arrival_1.default { constructor(apiObj) { super(); this.delayed = apiObj.delayed; this.scheduled = apiObj.scheduled; this.fault = apiObj.fault; this.approaching = apiObj.approaching; this.stopDescription = apiObj.stopDescription; this.runNumber = apiObj.runNumber; this.line = apiObj.line; this.destination = apiObj.destination; this.predictionTs = new Date(apiObj.predictionTs); this.arrivalTs = new Date(apiObj.arrivalTs); this.direction = apiObj.direction } static async getArrivals(stationId) { const resRaw = await fetch(`/train/api/arrivals/${stationId}`); const res = await resRaw.json(); const arrivals = []; for (const arrival of res) { arrivals.push(new Arrival(arrival)) } return arrivals } getArrivalTs() { return this.arrivalTs } genDom(number) { const container = document.createElement("div"); container.classList.add("train-entry"); container.dataset.line = this.line.toLowerCase(); if (this.line === "Green" && this.destination === "Cottage Grove") { container.classList.add("inverted") } const numberDom = document.createElement("div"); numberDom.classList.add("train-entry-number"); numberDom.textContent = `${number}`; container.append(numberDom); const bodyDom = document.createElement("div"); bodyDom.classList.add("train-entry-body"); const details = document.createElement("div"); details.classList.add("train-entry-details"); const runInfo = document.createElement("span"); runInfo.classList.add("train-entry-run-info"); runInfo.textContent = `${this.line} Line #${this.runNumber} to`; const destDom = document.createElement("span"); destDom.classList.add("train-entry-dest"); destDom.textContent = this.destination; details.append(runInfo, destDom); bodyDom.append(details); const timing = document.createElement("div"); timing.classList.add("train-entry-timing"); const time = this.countdown(new Date); if (time === 0 || time === 1) { if (this.delayed) { const delayed = document.createElement("span"); delayed.classList.add("bold"); delayed.textContent = "Delayed"; timing.append(delayed) } else { const due = document.createElement("span"); due.classList.add("bold"); due.textContent = "Due"; timing.append(due) } } else { const timeNumber = document.createElement("span"); timeNumber.classList.add("bold"); timeNumber.textContent = `${time}`; const suffix = document.createTextNode(" min"); timing.append(timeNumber, suffix) } bodyDom.append(timing); const tracking = document.createElement("div"); tracking.classList.add("train-entry-tracking"); const trackingImage = document.createElement("img"); trackingImage.alt = ""; trackingImage.src = this.scheduled ? "/static/images/scheduled.svg" : "/static/images/live-tracked.svg"; tracking.append(trackingImage); bodyDom.append(tracking); container.append(bodyDom); return container } } exports.default = Arrival }); define("client/ts/panes/ArrivalsPane", ["require", "exports", "client/ts/panes/AbstractPane", "client/ts/models/Arrival", "client/ts/util/StationID"], function (require, exports, AbstractPane_3, Arrival_2, StationID_2) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); class ArrivalsPane extends AbstractPane_3.default { arrivals; lastSuccessfulUpdate = null; constructor() { super(ArrivalsPane.findElement()); this.updateArrivals().then(); window.addEventListener("resize", this.reRenderArrivals) } static findElement = () => { return AbstractPane_3.default.findElementByClass("arrivals") }; static calculateMaxArrivals = () => { const w = window.innerWidth; return Math.floor(window.innerHeight / (1.05 * (.089 * w))) }; prepare = async () => { try { await this.updateArrivals() } finally { this.reRenderArrivals() } }; updateArrivals = async () => { const stationId = (0, StationID_2.default)(); try { this.arrivals = await Arrival_2.default.getArrivals(stationId); console.log(`Updated arrivals: ${this.arrivals.length}`); this.lastSuccessfulUpdate = new Date } catch (e) { let message = "Trouble fetching arrivals from server."; if (this.lastSuccessfulUpdate) message += " Showing data from " + this.lastSuccessfulUpdate.toLocaleString("en-US") + "."; throw new Error(message) } }; reRenderArrivals = () => { const domElement = this.rootDom; while (domElement.firstElementChild) domElement.removeChild(domElement.firstElementChild); const maxScn = ArrivalsPane.calculateMaxArrivals(); const ts = new Date; const realArrivals = this.arrivals.filter(x => x.getArrivalTs().getTime() > ts.getTime()); realArrivals.sort((arrival1, arrival2) => arrival1.arrivalMs(ts) - arrival2.arrivalMs(ts)); console.log(maxScn); const realMax = Math.min(maxScn, realArrivals.length); const arrivalsDom = []; for (let i = 0; i < realMax; ++i) { arrivalsDom.push(realArrivals[i].genDom(i + 1)) } domElement.append(...arrivalsDom); console.log("Arrivals re-rendered") } } exports.default = ArrivalsPane }); define("client/ts/boards/TrainSignboard", ["require", "exports", "client/ts/panes/AlertsPane", "client/ts/boards/AbstractSignboard", "client/ts/panes/InfoPane", "client/ts/panes/ArrivalsPane"], function (require, exports, AlertsPane_1, AbstractSignboard_1, InfoPane_1, ArrivalsPane_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TrainSignboard = void 0; class TrainSignboard extends AbstractSignboard_1.default { getCycleTime() { return 9 * 1e3 } getPanes() { const panes = { info: new InfoPane_1.default, alerts: new AlertsPane_1.default, arrivals: new ArrivalsPane_1.default }; const hash = window.location.hash; if (hash !== "") { const parts = hash.substr(1).split(","); for (const paneName of Object.keys(panes)) { if (!parts.includes(paneName)) delete panes[paneName] } } return Object.values(panes) } showError(msg) { const div = this.errorDiv(); const detail = this.errorDetail(); if (detail) detail.textContent = msg; div?.classList.add("active") } clearError() { this.errorDiv()?.classList.remove("active") } errorDiv() { return document.getElementById("error") } errorDetail() { return this.errorDiv()?.children[0]?.children[0] } } exports.TrainSignboard = TrainSignboard }); \ No newline at end of file diff --git a/public/stylesheets/cta.css b/public/stylesheets/cta.css new file mode 100644 index 0000000..85ed6c8 --- /dev/null +++ b/public/stylesheets/cta.css @@ -0,0 +1,286 @@ +.hidden { + opacity: 0 +} + +div.gone:not(p) { + display: none +} + +.bold { + font-weight: bold +} + +div.train-entry[data-line=red]>div.train-entry-body { + background-color: #c60c30 +} + +div.train-entry[data-line=blue]>div.train-entry-body { + background-color: #00a1de +} + +div.train-entry[data-line=brown]>div.train-entry-body { + background-color: #62361b +} + +div.train-entry[data-line=green]>div.train-entry-body { + background-color: #009b3a +} + +div.train-entry[data-line=orange]>div.train-entry-body { + background-color: #f9461c +} + +div.train-entry[data-line=pink]>div.train-entry-body { + background-color: #e27ea6 +} + +div.train-entry[data-line=purple]>div.train-entry-body { + background-color: #522398 +} + +div.train-entry[data-line=yellow]>div.train-entry-body { + background-color: #f9e300 +} + +div.arrivals div.train-entry[data-line=green].inverted>div.train-entry-body { + background-color: #fff; + color: #009b3a +} + +div.alert-container { + background-color: #000; + color: #fff; + height: 100vh; + padding: 2vw; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between +} + +div.alert-container>header.alert-header { + font-size: 4vw +} + +div.alert-container>header.alert-header span.alert-header-text { + margin-left: 1vw +} + +div.alert-container>div.alert-body { + font-weight: bold; + font-size: 4vw +} + +div.alert-container>footer.alert-footer { + display: flex; + font-size: 3vw; + color: #aaa +} + +div.alert-container>footer.alert-footer>span.alert-footer-type-image-container { + margin-right: 1vw +} + +div.alert-container>footer.alert-footer>span.alert-footer-type { + margin-right: 2vw +} + +div.alert-container>footer.alert-footer img { + height: 3vw; + width: auto +} + +div.arrivals { + background-color: #000; + height: 100vh; + display: flex; + flex-direction: column; + padding: 2vw; + box-sizing: border-box; + justify-content: center; + align-items: center +} + +div.arrivals div.train-entry { + display: flex; + margin-bottom: 1px; + width: 100% +} + +div.arrivals div.train-entry:not([data-line=yellow])>div.train-entry-body { + color: #fff +} + +div.arrivals div.train-entry[data-line=yellow]>div.train-entry-body { + color: #000 +} + +div.arrivals div.train-entry>div.train-entry-number { + background-color: #333; + color: #ddd; + font-weight: bold; + padding: .25em .5em 0 .5em; + font-size: 2.1vw +} + +div.arrivals div.train-entry>div.train-entry-body { + flex-grow: 1; + padding: .25em .5em; + display: flex; + align-items: center +} + +div.arrivals div.train-entry>div.train-entry-body>div.train-entry-details { + display: flex; + flex-direction: column; + flex-grow: 1 +} + +div.arrivals div.train-entry>div.train-entry-body>div.train-entry-details>span.train-entry-run-info { + font-size: 2.1vw +} + +div.arrivals div.train-entry>div.train-entry-body>div.train-entry-details>span.train-entry-dest { + font-weight: bold; + font-size: 5vw +} + +div.arrivals div.train-entry>div.train-entry-body>div.train-entry-timing { + font-size: 5vw +} + +div.arrivals div.train-entry>div.train-entry-body>div.train-entry-tracking>img { + height: 3.5vw; + width: auto; + padding: 0 1.7vw +} + +.info { + background-color: #000; + color: #fff; + display: flex; + height: 100vh; + justify-content: space-evenly; + align-items: center +} + +.info>div { + width: 23vw; + height: 23vw; + background-color: #333; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 1vw; + box-sizing: border-box +} + +.info #time { + font-weight: bold; + font-size: 5.6vw +} + +.info #ampm-marker { + font-size: 4.5vw +} + +.info #date { + font-size: 3.8vw +} + +.info #temperature-text { + font-weight: bold +} + +.info #weather-description { + font-size: 3.8vw +} + +.info #temperature-text { + font-size: 5.6vw; + font-weight: bold +} + +.info #temperature-icon { + position: relative; + top: 2vw; + height: 8vw; + width: auto +} + +.info #no-smoking-text { + font-size: 3.8vw +} + +.info #no-smoking-icon { + height: 7vw; + width: auto +} + +.info #cta-logo { + height: 7vw; + width: auto +} + +html, +body { + font-family: sans-serif; + height: 100%; + margin: 0; + background-color: #000 +} + +body>div:not(#error) { + transition: opacity 1s ease-out +} + +#error { + position: fixed; + inset: 0 0 auto auto; + z-index: 9000; + opacity: 0; + transition: opacity .9s; + pointer-events: none +} + +#error.active { + opacity: 1; + pointer-events: all +} + +#error:before { + content: "⚠️"; + font-size: 5vw +} + +#error>.detail-container { + position: absolute; + top: 100%; + right: 0; + pointer-events: none; + width: 25vw; + display: flex; + justify-content: end +} + +#error>.detail-container>.detail { + padding: 10px; + margin: 3px; + background-color: #263238; + border-radius: 5px; + opacity: 0; + pointer-events: none; + transition: opacity .25s; + color: #eee; + font-size: 1.5vw +} + +#error>.detail-container:hover>.detail { + opacity: 1 +} + +#error:hover .detail { + opacity: 1 +} + +/*# sourceMappingURL=cta.css.map */ \ No newline at end of file diff --git a/src/client/scss/_colors.scss b/src/client/scss/_colors.scss index 620db7e..44a10fe 100644 --- a/src/client/scss/_colors.scss +++ b/src/client/scss/_colors.scss @@ -21,4 +21,8 @@ div.arrivals { background-color: #fff; color: map.get($lines, "green"); } + div.train-entry[data-line="blue"].inverted > div.train-entry-body { + background-color: #fff; + color: map.get($lines, "blue"); + } } diff --git a/src/client/ts/boards/TrainSignboard.ts b/src/client/ts/boards/TrainSignboard.ts index d40ec93..34acf0b 100644 --- a/src/client/ts/boards/TrainSignboard.ts +++ b/src/client/ts/boards/TrainSignboard.ts @@ -6,7 +6,7 @@ import IPane from '../panes/IPane'; export class TrainSignboard extends AbstractSignboard { protected getCycleTime(): number { - return 9 * 1000; + return 5 * 1000; } protected getPanes(): Array { diff --git a/src/client/ts/models/Arrival.ts b/src/client/ts/models/Arrival.ts index 4215171..16f580d 100644 --- a/src/client/ts/models/Arrival.ts +++ b/src/client/ts/models/Arrival.ts @@ -45,6 +45,9 @@ export default class Arrival extends SharedArrival { if (this.line === 'Green' && this.destination === 'Cottage Grove') { container.classList.add('inverted'); } + else if (this.line === 'Blue' && this.destination === 'UIC-Halsted') { + container.classList.add('inverted'); + } const numberDom = document.createElement('div'); numberDom.classList.add('train-entry-number'); diff --git a/src/shared/util/CTAData.ts b/src/shared/util/CTAData.ts index 54a90f6..15f80fa 100644 --- a/src/shared/util/CTAData.ts +++ b/src/shared/util/CTAData.ts @@ -11,7 +11,8 @@ export type Destination = 'Linden' | '54th/Cermak' | 'Kimball' | - 'Midway'; + 'Midway' | + 'UIC-Halsted'; export type ExternalLineName = 'Red' | 'Org' | 'Y' | 'G' | 'Blue' | 'P' | 'Pink' | 'Brn'; export type DisplayLineName = 'Red' | 'Orange' | 'Yellow' | 'Green' | 'Blue' | 'Purple' | 'Pink' | 'Brown'; @@ -53,6 +54,7 @@ const destinations: DestinationLineMap = { 'Cottage Grove': ['Green'], 'O\'Hare': ['Blue'], 'Forest Park': ['Blue'], + 'UIC-Halsted': ['Blue'], 'Linden': ['Purple'], '54th/Cermak': ['Pink'], 'Kimball': ['Brown'], @@ -80,7 +82,8 @@ const staticDestinationCodes: StaticDestinationCodes = { }, 'Blue': { 'O\'Hare': 1, - 'Forest Park': 5 + 'Forest Park': 5, + 'UIC-Halsted': 5 }, 'Purple': { 'Howard': 5,