diff --git a/.gitignore b/.gitignore index 80fb727..4e7568f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ _apikey server.js node_modules npm-debug.log -server/settings.js */node_modules +.DS_Store +*/.DS_Store diff --git a/server/settings.example.js b/server/settings.js similarity index 63% rename from server/settings.example.js rename to server/settings.js index 64bc386..a7e3a13 100644 --- a/server/settings.example.js +++ b/server/settings.js @@ -1,8 +1,4 @@ -// add your API key here -// const api_key = 'XXXXXXXXXX'; - -// define all RBLs of stops you want to display -const api_ids = [ +let api_ids = [ "252", // Rathaus – 2 (Richtung Friedrich-Engels-Platz) "269", // Rathaus – 2 (Richtung Ottakringer Str./Erdbrustgasse) "4205", // Rathaus – U2 (Richtung Karlsplatz) @@ -23,21 +19,18 @@ const api_ids = [ "5691", // Auerspergstraße – N46 (stadtauswärts) ]; -const api_url = 'https://www.wienerlinien.at/ogd_realtime/monitor' + +let api_url = 'https://www.wienerlinien.at/ogd_realtime/monitor' + '?activateTrafficInfo=stoerunglang' + // `&sender=${api_key}`+ '&rbl=' + api_ids.join("&rbl="); - -// define filters to exclude specific departures from the monitor -// currently you can exclude lines as a whole or only at certain stops -const filters = [ +let filters = [ { - line: ['VRT'], // excludes whole line (VRT = tourist line) + line: ['VRT'], }, { line: ['D', '1', '71'], - stop: ['Rathausplatz/Burgtheater'], // excludes lines only at given stop + stop: ['Rathausplatz/Burgtheater'], }, { line: ['2'], @@ -45,11 +38,9 @@ const filters = [ }, ]; -// define your current location -const location_coordinate = '16.3509389,48.2103151' +let location_coordinate = '16.3509389,48.2103151' -// define OSRM server for routing to stops. Empty string to disable feature -const osrm_api_url = 'https://router.project-osrm.org/route/v1/foot/' + location_coordinate + ';' +let osrm_api_url = 'https://router.project-osrm.org/route/v1/foot/' + location_coordinate + ';' module.exports = { @@ -57,7 +48,7 @@ module.exports = { // 'api_key' : api_key, 'api_ids' : api_ids, 'filters' : filters, - 'api_cache_msec' : 6000, // cache API responses for this many milliseconds; default: 6s - 'listen_port' : 8080, // port to listen on + 'api_cache_msec' : 6000, + 'listen_port' : 8080, 'osrm_api_url' : osrm_api_url }; diff --git a/site/assets/u1.svg b/site/assets/u1.svg deleted file mode 100644 index 05d3746..0000000 --- a/site/assets/u1.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - -]> - - - - - - - - - - - - - - - - - - diff --git a/site/assets/u2.svg b/site/assets/u2.svg deleted file mode 100644 index 4522cd0..0000000 --- a/site/assets/u2.svg +++ /dev/null @@ -1,30 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/site/assets/u3.svg b/site/assets/u3.svg deleted file mode 100644 index 1303a6b..0000000 --- a/site/assets/u3.svg +++ /dev/null @@ -1,30 +0,0 @@ - -image/svg+xml \ No newline at end of file diff --git a/site/assets/u4.svg b/site/assets/u4.svg deleted file mode 100644 index c06c6a1..0000000 --- a/site/assets/u4.svg +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - -]> - - - - - - - - - - - - - - - - - - - diff --git a/site/assets/u5.svg b/site/assets/u5.svg deleted file mode 100644 index 469f886..0000000 --- a/site/assets/u5.svg +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - -]> - - - - - - - - - - - - - - - - - - - - - diff --git a/site/assets/u6.svg b/site/assets/u6.svg deleted file mode 100644 index f788e4c..0000000 --- a/site/assets/u6.svg +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - -]> - - - - - - - - - - - - - - - - - diff --git a/site/index.html b/site/index.html index 2bb199a..a1e1ccd 100644 --- a/site/index.html +++ b/site/index.html @@ -1,40 +1,37 @@ - - - - - - Öffimonitor - - -
-

Next public transport connections from Metalab

- - - - - - - - - - - -
DepartureLineClosest StationDestination
-
-
-

- - -

-
-
-

- Oh noes, something broke! - - • Last data update: - -

-
- + + + + + Öffimonitor — Metalab + + + + +
+
+ Metalab + Rathausstraße 6, 1010 Wien +
+
+ + +
+
+ +
+ + + +
+ + + + + diff --git a/site/oeffis.css b/site/oeffis.css deleted file mode 100644 index d9d0a94..0000000 --- a/site/oeffis.css +++ /dev/null @@ -1,155 +0,0 @@ -@font-face { - font-family: 'Roboto Condensed'; - src: url('assets/RobotoCondensed-Regular.ttf'); -} -@font-face { - font-family: 'Roboto Condensed'; - src: url('assets/RobotoCondensed-Bold.ttf'); - font-weight: bold; -} - -body { - margin:0; - font-family: 'Roboto Condensed', Helvetica, Arial, sans-serif; - background-color: black; - color: #eee; -} - -h1 { margin: 20px; } - -#error { - background-color: #F2DEDE; - color: black; - position: fixed; - border-top: 4px solid #A94442; - bottom: 0%; - left: 0%; - z-index: 100; - margin: 0; - padding: 0 1em; - width: 100%; -} -#warning { - display: none; - background-color: #fcf8e3; - color: black; - position: fixed; - border-top: 4px solid #f0ad4e; - bottom: 0%; - left: 0%; - z-index: 100; - margin: 0; - padding: 0; - width: 100%; -} -#warning p { - padding: 0 1em; -} -#warning_counter { - color: #f0ad4e; - float: right; -} - -#currentTime{ - float:right; - font-weight:bold; -} -table { - border: none; - border-spacing: 0px; - text-align: left; - width: 100%; -} - -thead { font-weight: bold; } -tbody > tr:nth-child(2n) { background: #222; } -tbody > tr:nth-child(2n+1) { background: #333; } -td { border: none; } -tr > td:nth-child(1) { text-align: center; } - -tr > td:nth-child(2) { - text-align: center; - position: relative; -} -tr > td:nth-child(2) span, tr > td:nth-child(2) img { - position: absolute; - top: 50%; - left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); -} -tr > td:nth-child(2) span { - padding: 0 3px; - font-size: 90%; - font-weight: bold; -} - -tr > td:nth-child(2) img { height: 80%; } -tr > td:nth-child(3) { padding-right: 10px; } - -td.time:nth-child(1)::before { - content: "●\00a0"; - opacity:0; -} -td.hurry:nth-child(1)::before { - animation: blink 1s steps(5, start) infinite; - -webkit-animation: blink 1s steps(5, start) infinite; - color: #c0392b; - opacity:1; -} -td.soon:nth-child(1)::before { - animation: blink 1s steps(5, start) infinite; - -webkit-animation: blink 1s steps(5, start) infinite; - color: #27ae60; - opacity:1; -} -.tram { - -webkit-border-radius: 50%; - -moz-border-radius: 50%; - border-radius: 50%; - background: #000; - color: #FFF; -} -.bus { - padding: 0 3px; - background: #030e6b; - color: #FFF; -} -.nightline { - padding: 0 3px; - background: #140D4F; - color: #F9EC28; -} - -@media (max-width: 1280px) { - body { overflow: hidden; } - h1 { font-size: 50px; } - - #error { - font-size:30px; - line-height:30px; - } - #warning { - font-size:30px; - line-height:30px; - } - td { - height: 60px; - font-size: 48px !important; - line-height: 58px; - overflow: hidden; - white-space: nowrap; - } - tr > td:nth-child(2) span, tr > td:nth-child(2) img { - height: 58px; - min-width: 58px; - padding: 0 !important; - } - tr > td:nth-child(3) { - padding-left: 10px; - } -} - -@keyframes blink { - to { visibility: hidden; } -} diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..9613560 --- /dev/null +++ b/site/style.css @@ -0,0 +1,384 @@ +:root { + --wl-u1: #bb232a; + --wl-u2: #9c4ba1; + --wl-u3: #e97c00; + --wl-u4: #00945e; + --wl-u6: #855e2c; + --wl-tram: #0d0d0d; + --wl-bus: #1f3f8a; + --wl-night: #1a1a1a; + --wl-sbahn: #006fc4; + --fg: #f5f5f5; + --fg-dim: #8a8a8a; + --warn: #ffb454; + --danger: #ff5050; +} + +* { + box-sizing: border-box; +} +html, body { + margin: 0; + padding: 0; +} +ul { + list-style: none; + padding: 0; + margin: 0; +} + +body { + font-family: Arial, Helvetica, sans-serif; + color: var(--fg); + background: #000; + height: 100vh; + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr auto; +} + + +/* topbar with logo and time */ +.topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 0.8rem 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: #000; +} + +.location { + display: flex; + align-items: baseline; + gap: 0.8rem; +} + +.location-name { + font-family: monospace; + font-size: 1.2rem; + font-weight: bold; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.location-sub { + font-size: 0.75rem; + color: var(--fg-dim); +} + +.clock { + display: flex; + align-items: baseline; + gap: 1rem; +} + +#clock-time { + font-family: monospace; + font-size: 1.5rem; + font-weight: bold; +} + +.clock-date { + font-family: monospace; + font-size: 0.75rem; + color: var(--fg-dim); + text-transform: lowercase; +} + + +/* main content */ +main { + overflow: hidden; + display: flex; + flex-direction: column; +} + +.departures { + display: flex; + flex-direction: column; + flex: 1; +} + + +/* departure cards */ +.dep { + display: grid; + grid-template-columns: auto 1fr auto 1fr auto; + grid-template-rows: auto 1fr; + align-items: center; + column-gap: 1.5rem; + row-gap: 0.4rem; + overflow: hidden; + padding: 1rem 1.5rem; + border-bottom: 2px solid rgba(255, 255, 255, 0.08); + color: white; + background: #222; + position: relative; +} + +/* dep cards */ +.dep.u1 { + background: var(--wl-u1); +} +.dep.u2 { + background: var(--wl-u2); +} +.dep.u3 { + background: var(--wl-u3); +} +.dep.u4 { + background: var(--wl-u4); + } +.dep.u6 { + background: var(--wl-u6); +} +.dep.tram { + background: var(--wl-tram); +} +.dep.bus { + background: var(--wl-bus); +} +.dep.night { + background: var(--wl-night); +} +.dep.sbahn { + background: var(--wl-sbahn); +} + + +/* dep name */ +.stop-name { + grid-column: 1 / -1; + grid-row: 1; + text-align: center; + font-size: 1rem; + font-weight: bold; + opacity: 0.9; +} + +.circle, .center, .side { + grid-row: 2; +} + + +/* center column */ +.center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 1rem; +} + +.line-name { + font-weight: 900; + font-size: 3rem; + line-height: 1; +} + +.walk { + font-size: 0.85rem; + opacity: 0.85; + white-space: nowrap; + margin-top: 0.3rem; +} + + +/* left and right columns */ +.side { + display: flex; + flex-direction: column; + min-width: 0; +} + +.side-right { + text-align: right; + align-items: flex-end; +} + +.label { + font-size: 0.85rem; + opacity: 0.85; +} + +.dest { + font-size: 1.8rem; + font-weight: bold; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dest.strike { + text-decoration: line-through; + opacity: 0.6; +} + +.next { + font-size: 0.85rem; + opacity: 0.85; + margin-top: 0.3rem; +} + +.next.warn { + opacity: 1; + font-weight: bold; +} + +.badge-icon { + font-size: 0.6em; + opacity: 0.75; + margin-left: 0.2em; +} + + +/* countdown circlws */ +.circle { + width: 5rem; + height: 5rem; + border-radius: 50%; + background: white; + color: #222; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + font-weight: 900; + font-size: 2.2rem; +} + +.circle .num { + line-height: 1; +} + +.circle .unit { + font-size: 0.65rem; + font-weight: bold; + letter-spacing: 0.1em; + margin-top: 0.2em; + opacity: 0.6; +} + +/* blinking */ +.circle.imminent { + animation: pulse-circle 1.2s ease-in-out infinite; +} + +.circle.last, +.circle.empty { + background: transparent; + color: white; + border: 3px solid rgba(255, 255, 255, 0.5); +} + + +/* disruption notice at the bottom of the card */ +.disruption { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + text-align: center; + font-size: 0.85rem; + color: var(--warn); + font-weight: bold; + padding: 0.3rem 0.6rem; + background: rgba(0, 0, 0, 0.8); + white-space: normal; + word-break: break-word; +} + + +/* down status bar */ +.statusbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + flex-shrink: 0; + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: #000; + font-family: monospace; + font-size: 0.75rem; + color: var(--fg-dim); + text-transform: uppercase; +} + +.source-badge { + display: inline-flex; + align-items: center; + gap: 0.5em; + padding: 0.2em 0.6em; + border: 1px solid currentColor; + font-weight: bold; +} + +.source-badge::before { + content: ""; + width: 0.5em; + height: 0.5em; + background: currentColor; +} + +.source-live { + color: #5dd97f; +} +.source-mock { + color: var(--warn); +} +.source-loading { + color: var(--fg); +} +.source-error { + color: var(--danger); +} + +.warnings { + margin-left: auto; + color: var(--warn); + text-transform: none; +} + +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-dim); + font-family: monospace; + font-size: 1rem; + text-transform: uppercase; +} + +.hidden { + display: none; +} + + +/* blinking animation */ +@keyframes pulse-circle { + 0% { transform: scale(1); } + 50% { transform: scale(1.06); } + 100% { transform: scale(1); } +} + + +/* small screens */ +@media (max-width: 800px) { + .line-name { + font-size: 2rem; + } + .dest { + font-size: 1.2rem; + } + .circle { + width: 3.5rem; + height: 3.5rem; + font-size: 1.5rem; + } +} + diff --git a/site/update.js b/site/update.js index 118ec74..7389096 100644 --- a/site/update.js +++ b/site/update.js @@ -1,182 +1,360 @@ -/*** - * Öffimonitor - display the Wiener Linien timetable for nearby bus/tram/subway - * lines on a screen in the Metalab Hauptraum - * - * Copyright (C) 2015-2016 Moritz Wilhelmy - * Copyright (C) 2015-2016 Bernhard Hayden - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ -// vim: set ts=8 noet: Use tabs, not spaces! + "use strict"; -var cached_json = {}; +var API_URL = 'http://localhost:8080/api'; +var UPDATE_INTERVAL = 10000; +var CLOCK_INTERVAL = 1000; +var WARNING_INTERVAL = 6000; +var MAX_CARDS = 8; + +var TYPE_TO_CLASS = { + 'ptMetro': null, + 'ptTram': 'tram', + 'ptTramWLB': 'tram', + 'ptBusCity': 'bus', + 'ptBusNight': 'night', + 'ptTrainS': 'sbahn' +}; + +var state = { + departures: [], + warnings: [], + last_update: null, + current_warning: 0, + source_status: 'loading' +}; + -function capitalizeFirstLetter(str) { - return str.replace(/\w[^- ]*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); +function getMockData() { + var now = Date.now(); + var m = function (offset) { return new Date(now + offset * 60000).toISOString(); }; + return { + status: 'ok', + departures: [ + { stop: 'Rathaus', line: 'U2', type: 'ptMetro', towards: 'Seestadt', barrierFree: true, time: m(2), walkDuration: 180 }, + { stop: 'Rathaus', line: 'U2', type: 'ptMetro', towards: 'Seestadt', barrierFree: true, time: m(8), walkDuration: 180 }, + { stop: 'Rathaus', line: 'U2', type: 'ptMetro', towards: 'Karlsplatz', barrierFree: true, time: m(3), walkDuration: 180 }, + { stop: 'Rathaus', line: 'U2', type: 'ptMetro', towards: 'Karlsplatz', barrierFree: false, time: m(9), walkDuration: 180 }, + { stop: 'Schottentor', line: '41', type: 'ptTram', towards: 'Pötzleinsdorf', barrierFree: false, time: m(4), walkDuration: 240 }, + { stop: 'Schottentor', line: '41', type: 'ptTram', towards: 'Pötzleinsdorf', barrierFree: false, time: m(11), walkDuration: 240 }, + { stop: 'Schottentor', line: '41', type: 'ptTram', towards: 'Schottentor', barrierFree: true, time: m(5), walkDuration: 240 }, + { stop: 'Landesgerichtsstraße',line: '43', type: 'ptTram', towards: 'Neuwaldegg', barrierFree: true, time: m(6), walkDuration: 300 }, + { stop: 'Landesgerichtsstraße',line: '43', type: 'ptTram', towards: 'Neuwaldegg', barrierFree: false, time: m(14), walkDuration: 300 }, + { stop: 'Rathausplatz', line: '1', type: 'ptTram', towards: 'Prater Hauptallee', barrierFree: true, time: m(3), walkDuration: 120 }, + { stop: 'Rathausplatz', line: '1', type: 'ptTram', towards: 'Prater Hauptallee', barrierFree: false, time: m(12), walkDuration: 120 }, + ], + warnings: [ + { title: 'U2 Betriebsstörung', description: 'Eingeschränkter Betrieb zwischen Schottentor und Karlsplatz.' } + ] + }; } -function addZeroBefore(n) { - return (n < 10 ? '0' : '') + n; + + +function pad2(n) { + return (n < 10 ? '0' : '') + n; } -function showError(error) { - document.querySelector('tbody').innerHTML = ''; - var last_update_string = '–'; - if (cached_json.departures) { - cached_json.departures.forEach(function (departure) { - addDeparture(departure); +function capitalize(str) { + if (!str) return ''; + return str.replace(/\w[^- ]*/g, function (txt) { + return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); }); - last_update_string = new Date(cached_json.last_update).toTimeString(); - } +} + +function classForLine(type, line) { + if (type === 'ptMetro') { + var num = (line || '').toLowerCase(); + if (num === 'u1' || num === 'u2' || num === 'u3' || + num === 'u4' || num === 'u6') { + return num; + } + return 'u1'; + } + return TYPE_TO_CLASS[type] || 'tram'; +} - document.getElementById("error").style.display = "block"; - document.getElementById("error_msg").innerHTML = error; - document.getElementById("error_last_update").innerHTML = last_update_string; +function isReachable(dep) { + var now = Date.now(); + var depTime = new Date(dep.time).getTime(); + var diff = (depTime - now) / 1000; + var walk = dep.walkDuration || 0; + if (diff < 0) return false; + if (walk * 0.9 > diff) return false; + return true; +} - if(document.getElementById("warning").style.display === "block") { - document.getElementById("warning").style.bottom = document.getElementById("error").offsetHeight + 'px'; - } - console.log(error); +function countdownMinutes(dep) { + var now = Date.now(); + var depTime = new Date(dep.time).getTime(); + var diff = Math.floor((depTime - now) / 1000 / 60); + return Math.max(0, diff); } -function warning() { - if (!cached_json.warnings || cached_json.warnings.length === 0) { - document.getElementById("warning").style.display = "none"; - return; - } - if (!cached_json.currentWarning) { - cached_json.currentWarning = 0; - } - var currentWarning = cached_json.warnings[cached_json.currentWarning]; - document.getElementById("warning").style.display = "block"; - document.getElementById("warning_counter").innerHTML = (cached_json.currentWarning + 1) + '/' + cached_json.warnings.length; - document.getElementById("warning_text").innerHTML = '' + currentWarning.title + ' ' + currentWarning.description; +function groupDepartures(departures) { + var groups = {}; + + departures.forEach(function (dep) { + if (!isReachable(dep)) return; + + var key = dep.stop + '|' + dep.line; + if (!groups[key]) { + groups[key] = { + stop: dep.stop, + line: dep.line, + type: dep.type, + walkDuration: dep.walkDuration || 0, + byTowards: {} + }; + } + var g = groups[key]; + var towards = capitalize(dep.towards || ''); + if (!g.byTowards[towards]) { + g.byTowards[towards] = { + towards: towards, + barrierFree: !!dep.barrierFree, + deps: [] + }; + } + g.byTowards[towards].deps.push(dep); + if (dep.barrierFree) g.byTowards[towards].barrierFree = true; + }); + + var cards = []; + Object.keys(groups).forEach(function (key) { + var g = groups[key]; + var directions = Object.keys(g.byTowards).map(function (t) { + var d = g.byTowards[t]; + d.deps.sort(function (a, b) { + return new Date(a.time) - new Date(b.time); + }); + return { + towards: d.towards, + barrierFree: d.barrierFree, + times: d.deps.slice(0, 4).map(countdownMinutes) + }; + }); + directions.sort(function (a, b) { + return (a.times[0] || 999) - (b.times[0] || 999); + }); + cards.push({ + stop: g.stop, + line: g.line, + type: g.type, + walkDuration: g.walkDuration, + directions: directions + }); + }); + + cards.sort(function (a, b) { + var ta = (a.directions[0] && a.directions[0].times[0]) || 999; + var tb = (b.directions[0] && b.directions[0].times[0]) || 999; + return ta - tb; + }); - if (cached_json.warnings.length - 1 > cached_json.currentWarning) { - cached_json.currentWarning++; - } else { - cached_json.currentWarning = 0; - } + return cards.slice(0, MAX_CARDS); } +function renderSide(dir, isRight) { + var sideClass = 'side' + (isRight ? ' side-right' : ''); -function clock() { - var currentTime = new Date(); - document.getElementById('currentTime').innerHTML = addZeroBefore(currentTime.getHours()) + ":" - + addZeroBefore(currentTime.getMinutes()) + ":" - + addZeroBefore(currentTime.getSeconds()); + if (!dir) { + return ( + '
' + + '-' + + '
' + + '
-
' + ); + } + + var first = dir.times[0]; + var rest = dir.times.slice(1); + + var circleClass = 'circle'; + if (first <= 1) circleClass += ' imminent'; + if (dir.times.length === 1) circleClass += ' last'; + + var bf = dir.barrierFree + ? ' ' + : ''; + + var nextStr = rest.length + ? 'danach in ' + rest.join(', ') + ' Minuten' + : ''; + + var nextWarn = (rest[0] !== undefined && rest[0] <= 2) ? ' warn' : ''; + + var circleHtml = + '
' + + '' + first + '' + + '
'; + + var sideHtml = + '
' + + 'Nächste Abfahrt nach' + + '' + dir.towards + bf + '' + + (nextStr + ? '' + nextStr + '' + : '') + + '
'; + + return isRight ? (sideHtml + circleHtml) : (circleHtml + sideHtml); } -function update() { - document.getElementById("error").style.display = "none"; - if(document.getElementById("warning").style.display === "block") { - document.getElementById("warning").style.bottom = '0%'; - } - - var req = new XMLHttpRequest(); - req.open('GET', '/api'); - req.onreadystatechange = function () { - if (req.readyState !== 4) { return } - - if (req.status !== 200) { - showError('No connection to server'); - return; +function renderCard(card) { + var li = document.createElement('li'); + li.className = 'dep ' + classForLine(card.type, card.line); + + var walkMin = Math.round((card.walkDuration || 0) / 60); + var walkStr = walkMin > 0 + ? 'Gehzeit ~' + walkMin + ' Minuten' + : ''; + + var left = card.directions[0] || null; + var right = card.directions[1] || null; + + li.innerHTML = + '
' + card.stop + '
' + + renderSide(left, false) + + '
' + + '' + card.line + '' + + (walkStr ? '' + walkStr + '' : '') + + '
' + + renderSide(right, true); + + return li; +} + +function renderAll() { + var ul = document.getElementById('departures'); + var empty = document.getElementById('empty-state'); + if (!ul) return; + + var cards = groupDepartures(state.departures); + + ul.innerHTML = ''; + if (cards.length === 0) { + ul.classList.add('hidden'); + if (empty) empty.classList.remove('hidden'); + } else { + ul.classList.remove('hidden'); + if (empty) empty.classList.add('hidden'); + cards.forEach(function (c) { + ul.appendChild(renderCard(c)); + }); + } +} + +function renderStatusbar() { + var badge = document.getElementById('source-badge'); + var warn = document.getElementById('warnings'); + if (!badge || !warn) return; + + badge.className = 'source-badge source-' + state.source_status; + if (state.source_status === 'live') badge.textContent = 'LIVE'; + else if (state.source_status === 'mock') badge.textContent = 'MOCK'; + else if (state.source_status === 'error') badge.textContent = 'ERROR'; + else if (state.source_status === 'loading') badge.textContent = '...'; + else badge.textContent = state.source_status.toUpperCase(); + + if (state.warnings && state.warnings.length > 0) { + var w = state.warnings[state.current_warning % state.warnings.length]; + warn.textContent = + '(' + ((state.current_warning % state.warnings.length) + 1) + + '/' + state.warnings.length + ') ' + + (w.title || '') + ' — ' + (w.description || ''); + warn.style.color = ''; + } else if (state.last_update) { + var d = new Date(state.last_update); + warn.textContent = 'UPDATED ' + + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); + warn.style.color = 'var(--fg-dim)'; + } else { + warn.textContent = ''; } +} - try { - var json = JSON.parse(req.responseText); - if (json.status && json.status === 'error') { - throw(json.error); - } else if (json.status && json.status !== 'ok') { - throw('Server response unvalid') - } - - document.querySelector('tbody').innerHTML = ''; - json.departures.forEach(function (departure) { - addDeparture(departure); - }); - cached_json.departures = json.departures; - cached_json.warnings = json.warnings; - cached_json.last_update = new Date().toString(); - } catch (e) { - showError(e); +function clock() { + var now = document.getElementById('clock-time'); + var dt = document.getElementById('clock-date'); + var d = new Date(); + if (now) { + now.textContent = pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); + } + if (dt) { + var weekday = d.toLocaleDateString('de-AT', { weekday: 'short' }); + var datePart = d.toLocaleDateString('de-AT', { day: 'numeric', month: 'long' }); + dt.textContent = weekday + ' ' + datePart; } - }; - req.send(); -} - -function addDeparture(departure) { - var departureRow = document.createElement('tr'); - var now = new Date(); - var departureTime = new Date(departure.time); - var difference = (departureTime.getTime() - now.getTime()) / 1000; - var walkDuration = departure.walkDuration; - var walkStatus = departure.walkStatus; - - if (difference < 0 || walkDuration * 0.9 > difference) { - walkStatus = 'too late'; - return false; - } else if (walkDuration + 2 * 60 > difference) { - walkStatus = 'hurry'; - } else if (walkDuration + 5 * 60 > difference) { - walkStatus = 'soon'; - } - - var line = departure.line; - var type = departure.type; - - if (type === 'ptMetro') { - line = ''; - } else if (type === 'ptTram') { - line = '' + line + ''; - } else if (type === 'ptBusCity') { - line = '' + line + ''; - } else if (type === 'ptBusNight') { - line = '' + line + ''; - } - - var timeString = '' + addZeroBefore(departureTime.getHours()) + - ':' + addZeroBefore(departureTime.getMinutes()) + - ' '; - - var differenceString = '+'; - - if (difference > 3600) { - differenceString += Math.floor(difference / 3600) + 'h'; - difference = difference % 3600; - } - - differenceString += addZeroBefore(Math.floor(difference / 60)) + 'm'; - difference = difference % 60; - - differenceString += parseInt(difference / 10) + '0s'; - - departureRow.innerHTML = '' + timeString + differenceString + '' + - '' + line + '' + departure.stop + - '' + capitalizeFirstLetter(departure.towards) + - ''; - document.querySelector('tbody').appendChild(departureRow); } +function tickWarnings() { + if (!state.warnings || state.warnings.length === 0) return; + state.current_warning = (state.current_warning + 1) % state.warnings.length; + renderStatusbar(); +} + +function tickCountdown() { + if (state.departures && state.departures.length > 0) { + renderAll(); + } +} + + + +function useMockData() { + var json = getMockData(); + state.departures = json.departures; + state.warnings = json.warnings; + state.last_update = new Date(); + state.current_warning = 0; + state.source_status = 'mock'; + renderAll(); + renderStatusbar(); +} + +function update() { + var req = new XMLHttpRequest(); + req.open('GET', API_URL); + req.timeout = 3000; + req.ontimeout = useMockData; + req.onreadystatechange = function () { + if (req.readyState !== 4) return; + + if (req.status !== 200) { + useMockData(); + return; + } + + try { + var json = JSON.parse(req.responseText); + if (json.status && json.status === 'error') { + throw new Error(json.error || 'API error'); + } + state.departures = json.departures || []; + state.warnings = json.warnings || []; + state.last_update = new Date(); + state.current_warning = 0; + state.source_status = 'live'; + renderAll(); + renderStatusbar(); + } catch (e) { + useMockData(); + console.log('Öffimonitor: ' + (e.message || e)); + } + }; + try { req.send(); } catch (e) { useMockData(); } +} + +//Bootstrap + window.onload = function () { - clock(); - update(); - warning(); - window.setInterval(clock, 1000); - window.setInterval(update, 10000); - window.setInterval(warning, 5000); + clock(); + renderStatusbar(); + update(); + + setInterval(clock, CLOCK_INTERVAL); + setInterval(update, UPDATE_INTERVAL); + setInterval(tickCountdown, 30000); + setInterval(tickWarnings, WARNING_INTERVAL); };