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 @@
-
-
\ 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 @@
-
-
\ 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
-
-
-
- | Departure |
- Line |
- Closest Station |
- Destination |
-
-
-
-
-
-
-
-
-
- Oh noes, something broke!
-
- • Last data update:
-
-
-
-
+
+
+
+
+ Öffimonitor — Metalab
+
+
+
+
+
+
+
+
+
+
+ No departures in sight.
+
+
+
+
+
+
+
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);
};