hvvstuhl.de/elevators.js
kritzl e72ad0cf07 add offline functionality by making this a PWA
- added favicons
- added manifest
- split javascript into main.js and elevators.js
2024-06-08 23:12:00 +02:00

723 lines
26 KiB
JavaScript

const internalData = {
lastUpdate: 0,
stations: [],
};
let geolocationPermission = false;
let geolocation = null;
const openStations = new Set();
let sortByDistance = false;
const substituteData = [
{
name: 'Borgweg (Stadtpark)',
coordinates: [53.5907696, 10.0147719],
},
{
name: 'Emilienstraße',
coordinates: [53.5716862, 9.9525424],
},
{
name: 'Garstedt',
coordinates: [53.6844739, 9.9860415],
},
{
name: 'Hagenbecks Tierpark',
coordinates: [53.5925874, 9.9440359],
},
{
name: 'Hamburg Hbf',
searchTarget: "Hauptbahnhof",
},
{
name: 'Jungfernstieg',
searchTarget: "Rathaus",
},
{
name: 'Rathaus',
searchTarget: "Jungfernstieg",
},
{
name: 'Stephansplatz (Oper/CCH)',
searchTarget: "Dammtor (Messe/CCH)",
},
{
name: 'Dammtor (Messe/CCH)',
searchTarget: "Stephansplatz (Oper/CCH)",
},
{
name: 'Hauptbahnhof Nord',
coordinates: [53.5541197, 10.0061270],
},
{
name: 'Hoheneichen',
coordinates: [53.6355141, 10.0677176],
},
{
name: 'Kornweg (Klein Borstel)',
coordinates: [53.6324430, 10.0541722],
},
{
name: 'Lauenbrück',
coordinates: [53.1971209, 9.5640765],
},
{
name: 'Lutterothstraße',
coordinates: [53.5819938, 9.9476215],
},
{
name: 'Meckelfeld',
coordinates: [53.4248897, 10.0291223],
},
{
name: 'Sengelmannstraße (City Nord)',
coordinates: [53.6093953, 10.0220004],
},
{
name: 'St.Pauli',
coordinates: [53.5507957, 9.9700752],
},
{
name: 'Winsen(Luhe)',
coordinates: [53.3534304, 10.2086841],
},
]
async function loadElevators() {
document.querySelector('#errorMessage').classList.add('hidden');
const res = await fetch('https://www.hvv.de/elevators', {
referrer: "",
}).catch(e => {
document.querySelector('#errorMessage').classList.remove('hidden');
});
const data = await res.json();
const stations = data['stations'];
stations.sort(sortStations);
internalData.lastUpdate = new Date(data['lastUpdate']);
let stationIndex = 0;
for (const station of stations) {
const stationName = station['mainSubStation']['stationName'];
const stationComment = station['mainSubStation']['comment'];
let searchTarget = undefined;
const substitute = substituteData.filter(subs => subs.name === stationName)
if (substitute.length && substitute[0].hasOwnProperty('searchTarget')) {
searchTarget = substitute[0].searchTarget;
}
const lines = new Set();
const elevators = [];
for (const elevatorKey of Object.keys(station.elevators)) {
const elevatorApi = station.elevators[elevatorKey];
const elevatorLines = [];
for (let line of elevatorApi.lines) {
line = line.replace(/[()]/g, "");
lines.add(line);
elevatorLines.push({
line: line,
type: getType(line),
});
}
// try to detect levels from description
let levels = [];
if (elevatorApi.description.search('<->') >= 0) {
levels = elevatorApi.description.split('<->').map(level => level.trim());
} else if (elevatorApi.description.search('<>') >= 0) {
levels = elevatorApi.description.split('<>').map(level => level.trim());
} else if (elevatorApi.description.search('/ ') >= 0) {
levels = elevatorApi.description.split('/ ').map(level => level.trim());
}
const elevator = {
working: elevatorApi['state'] === 1,
stateUnavailable: elevatorApi['state'] === -1,
dimensions: {
width: elevatorApi['cabinWidth'],
length: elevatorApi['cabinLength'],
door: elevatorApi['doorWidth'],
},
description: elevatorApi['description'],
label: elevatorApi['label'],
type: elevatorApi['type'].replace('_', ' ').capitalize(),
braille: elevatorApi['tasterType'] === 'UNBEKANNT' ? -1 : elevatorApi['tasterType'] === 'KOMBI' || elevatorApi['tasterType'] === 'BRAILLE',
speaker: elevatorApi['tasterType'] === 'UNBEKANNT' ? -1 : elevatorApi['tasterType'] === 'KOMBI',
lines: elevatorLines,
levels: levels,
instCause: elevatorApi['instCause'],
osmNodeId: elevatorApi['osmId'],
}
elevators.push(elevator);
}
let stationState = {
unavailable: 0,
working: 0,
outOfOrder: 0,
}
for (const elevator of elevators) {
if (elevator.stateUnavailable) {
stationState.unavailable++;
} else if (elevator.working) {
stationState.working++;
} else {
stationState.outOfOrder++;
}
}
const stationLines = Array.from(lines);
const stationTypes = Array.from(getTypes(stationLines));
internalData.stations[stationIndex++] = {
name: stationName,
comment: stationComment,
searchTarget: searchTarget,
state: stationState,
lines: stationLines,
types: stationTypes,
elevators: elevators,
}
}
localStorage.setItem("internal_data", JSON.stringify({
api: minorVersion,
...internalData
}));
}
async function loadOsmData() {
const nodeIdList = [];
for (const station of internalData.stations) {
for (const elevator of station.elevators) {
nodeIdList.push(elevator.osmNodeId)
}
}
const osmResponse = await fetch(`https://overpass-api.de/api/interpreter?data=[out:json];node(id:${nodeIdList.join(',')});out%20body;`, {});
const osmJson = await osmResponse.json();
if (!osmJson.hasOwnProperty('elements')) return;
const osmNodes = {};
for await (const node of osmJson.elements) {
if (node.hasOwnProperty('tags')) {
const tags = node['tags'];
if (tags['highway'] === 'elevator') {
osmNodes[node['id']] = node;
} else {
console.warn(`OSM Node is not an elevator. (NodeID: ${node['id']})`);
}
} else {
console.warn(`OSM Node has no Tags. (NodeID: ${node['id']})`);
}
}
//update coordinates in stations
for (const stationIndex in internalData.stations) {
const station = internalData.stations[stationIndex];
for (const elevator of station.elevators) {
const node = osmNodes[elevator.osmNodeId]
if (node) {
internalData.stations[stationIndex]['coordinates'] = [
node['lat'],
node['lon'],
]
}
}
if (!internalData.stations[stationIndex].hasOwnProperty('coordinates')) {
const substitute = substituteData.filter(subs => subs.name === internalData.stations[stationIndex].name)
if (substitute.length && substitute.hasOwnProperty('coordinates')) {
internalData.stations[stationIndex]['coordinates'] = substitute[0].coordinates;
}
}
}
localStorage.setItem("osm_data", JSON.stringify({
api: minorVersion,
lastUpdate: new Date(),
nodes: osmNodes
}));
localStorage.setItem("internal_data", JSON.stringify({
api: minorVersion,
...internalData
}));
}
function distance([lat1, lon1], [lat2, lon2]) {
if (!lat1 || !lat2 || !lon1 || !lon2) return null
const R = 6371e3; // metres
const phi1 = lat1 * Math.PI / 180; // φ, λ in radians
const phi2 = lat2 * Math.PI / 180;
const dPhi = (lat2 - lat1) * Math.PI / 180;
const dLambda = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // in metres
}
function registerGeolocationWatcher() {
navigator.geolocation.watchPosition((pos) => {
if (geolocation === null) {
geolocation = [pos.coords.latitude, pos.coords.longitude];
renderData(geolocation);
}
}, (e) => {
console.warn(e)
}, {
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 0,
})
}
function checkGeolocationPermission() {
navigator.permissions.query({name: "geolocation"}).then((result) => {
geolocationPermission = result.state
if (result.state === 'granted') {
registerGeolocationWatcher()
}
});
}
function allowGeolocation() {
navigator.geolocation.getCurrentPosition(() => {
console.log('success')
}, () => {
console.log('error')
});
checkGeolocationPermission()
registerGeolocationWatcher()
}
checkGeolocationPermission()
function sortStations(stationA, stationB) {
const nameA = stationA.mainSubStation.stationName.toUpperCase(); // ignore upper and lowercase
const nameB = stationB.mainSubStation.stationName.toUpperCase(); // ignore upper and lowercase
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
// names must be equal
return 0;
}
function sortStationsByDistance(stationA, stationB) {
const distanceA = stationA.distance; // ignore upper and lowercase
const distanceB = stationB.distance; // ignore upper and lowercase
if (distanceA < distanceB) {
return -1;
}
if (distanceA > distanceB) {
return 1;
}
// names must be equal
return 0;
}
function getType(line) {
const type = line.replace(/[^A-z]/g, "");
switch (type) {
case 'RE':
case 'RB':
case 'R':
case 'DB':
return 'R';
case 'S':
return 'S';
case 'U':
return 'U';
case 'A':
return 'A';
}
}
function getTypes(lines) {
const types = new Set();
for (const line of lines) {
types.add(getType(line))
}
return types;
}
function renderData(location = null) {
const ls = JSON.parse(localStorage.getItem("internal_data"));
if (!ls) return;
internalData.lastUpdate = ls['lastUpdate'];
internalData.stations = ls['stations'];
const osmData = JSON.parse(localStorage.getItem("osm_data"));
if (!internalData || !internalData.stations.length) {
console.error('No Data available!')
return;
}
if (
location !== null
&& (
location.length !== 2
|| typeof location[0] !== 'number'
|| typeof location[1] !== 'number'
)
) {
console.error('No valid location provided')
return;
}
document.querySelector('#updateInfo').classList.remove('hidden');
document.querySelector('#loadElevators').classList.remove('hidden');
document.querySelector('#filters').classList.remove('hidden');
document.querySelector('#initialLoad').classList.add('hidden');
const dateContainer = document.querySelector('#lastUpdated');
const oldDataWarning = document.querySelector('#oldDataWarning');
const lastUpdate = new Date(internalData.lastUpdate);
const now = new Date();
dateContainer.innerHTML = dateTimeStyle.format(lastUpdate);
oldDataWarning.classList.add('hidden');
if (now - lastUpdate > 86400 * 1000) {
const days = numberFormat.format((now - lastUpdate) / (86400 * 1000));
oldDataWarning.classList.remove('hidden');
oldDataWarning.innerHTML = `Daten ${days} Tag${days !== '1' ? 'e' : ''} alt!`;
}
const listContainer = document.querySelector('#stationList');
//clear list before update
listContainer.innerHTML = '';
let stations = [...internalData['stations']];
for (const stationIndex in stations) {
const station = stations[stationIndex];
station.id = stationIndex;
if (location !== null) {
if (station.hasOwnProperty('coordinates')) {
station.distance = distance(location, station.coordinates);
} else {
console.log('station has no position:', station.name);
}
}
}
stations = stations.sort(sortStationsByDistance);
for (const stationIndex in stations) {
const station = stations[stationIndex];
if (location !== null) {
if (station.hasOwnProperty('coordinates')) {
station.distance = distance(location, station.coordinates);
} else {
console.log('station has no position:', station.name);
}
}
let elevatorsTemplate = '';
let previewTemplate = '';
for (const elevator of station.elevators) {
const stateTemplate = `<span data-icon="${elevator.working || elevator.stateUnavailable ? 'elevator' : 'elevator-slash'}"
class="stateIcon size-xl ${elevator.working ? 'text-green' : elevator.stateUnavailable ? 'text-orange' : 'text-red'}"
role="img"
aria-label="${elevator.stateUnavailable
? 'Status nicht verfügbar.'
: elevator.working
? 'Funktionsfähig'
: 'Außer Betrieb'}">
</span>`;
let linesTemplate = '';
linesTemplate = `<div class="firstRow hiddenOnDesktop">${stateTemplate}`;
if (elevator.lines.length) {
linesTemplate = `<div class="firstRow">${stateTemplate}`;
linesTemplate += `<div class="lineList">Linien: `;
for (const line of elevator.lines) {
linesTemplate += `<span data-type="${line.type}" data-line="${line.line}" class="lineChip">${line.line}</span>`;
}
linesTemplate += '</div>';
}
linesTemplate += '</div>';
let levelsTemplate = '<ol class="levelList">';
for (const levelDescription of elevator.levels) {
levelsTemplate += `<li>${levelDescription}</li>`;
}
levelsTemplate += '</ol>';
let osmTemplate = '';
if (osmData) {
const node = osmData.nodes[elevator.osmNodeId]
if (node) {
osmTemplate = '<dl>';
osmTemplate += `<div>
<dt><span data-icon="location" class="size-l"></span>Link zur Karte</dt>
<dd>
<a href="https://www.openstreetmap.org/node/${elevator.osmNodeId}" target="_blank">
Auf Karte anzeigen
</a>
</dd>
</div>`;
if (node.tags.hasOwnProperty('description')) {
osmTemplate += `<div><dt><span data-icon="info" class="size-l"></span>Beschreibung</dt><dd>${node.tags['description']}</dd></div>`;
}
if (node.tags.hasOwnProperty('level')) {
osmTemplate += `<div><dt><span data-icon="up-down" class="size-l"></span>Ebenen</dt><dd>${node.tags['level'].split(';').sort().join(', ')}</dd></div>`;
}
if (node.tags.hasOwnProperty('wheelchair')) {
osmTemplate += `<div><dt><span data-icon="wheelchair" class="size-l"></span>Rollstühle</dt><dd>${node.tags['wheelchair'] === 'yes' ? 'Ja' : 'Nein'}</dd></div>`;
}
if (node.tags.hasOwnProperty('bicycle')) {
osmTemplate += `<div><dt><span data-icon="bicycle" class="size-l"></span>Fahrräder</dt><dd>${node.tags['bicycle'] === 'yes' ? 'Ja' : 'Nein'}</dd></div>`;
}
osmTemplate += '</dl>';
} else {
console.warn(`OSM Node not found (deleted). At:\t${station.name}\t${elevator.label} (NodeID: ${elevator.osmNodeId})`);
}
} else {
console.warn(`Elevator has no OSM Node id:\t${station.name}\t${elevator}`);
}
previewTemplate += `<span data-icon="${elevator.working || elevator.stateUnavailable ? 'elevator' : 'elevator-slash'}"
class="size-l ${elevator.working ? 'text-green' : elevator.stateUnavailable ? 'text-orange' : 'text-red'}"></span>`
elevatorsTemplate += `<li class="elevator">
${stateTemplate}
<div class="elevatorData">
${linesTemplate}
${elevator.instCause !== '' ? `<div class="elevatorInfo">${elevator.instCause}</div>` : ''}
${elevator.levels.length ? levelsTemplate : elevator.description}
<dl>
<div>
<dt><span data-icon="fit-width" class="size-l"></span>Durchgang</dt>
<dd>${elevator.dimensions.door} cm</dd>
</div>
<div>
<dt><span data-icon="width" class="size-l"></span>Breite</dt>
<dd>${elevator.dimensions.width} cm</dd>
</div>
<div>
<dt><span data-icon="length" class="size-l"></span>Länge</dt>
<dd>${elevator.dimensions.length} cm</dd>
</div>
<div>
<dt><span data-icon="braille" class="size-l"></span>Braille</dt>
<dd>${elevator.braille === -1 ? 'unbekannt' : elevator.braille ? `verfügbar` : 'nicht verfügbar'}</dd>
</div>
<div>
<dt><span data-icon="speaker" class="size-l"></span>Ansage</dt>
<dd>${elevator.speaker === -1 ? 'unbekannt' : elevator.speaker ? `verfügbar` : 'nicht verfügbar'}</dd>
</div>
</dl>
<!--<hr>-->
${osmTemplate ? `<div class="osm" data-nodeid="${elevator.osmNodeId}">
<div class="osmHeading">
<h4>Daten von OpenStreetMap</h4>
<button class="loadOSM size-s">
OSM Daten aktualisieren
<span data-icon="load" class="size-s spinner hidden"></span>
</button>
</div>
${osmTemplate}
</div>` : `<button class="loadOSM">
Zusätzliche Daten von OpenStreetMap abrufen
<span data-icon="load" class="size-s spinner hidden"></span>
</button>`}
</div>
</li>`;
}
const template = `<li class="station" id="station_${station.id}">
<div class="stationSummary">
<div class="symbol">
<div class="typeList">
${station.types.sort().map(t => `<span class="typeChip" data-type="${t}">${t}</span>`).join('')}
</div>
${typeof station.distance !== 'undefined' ? `<div class="distance"><b>${Math.round(station.distance / 100) / 10}</b><br>km</div>` : ''}
</div>
<div class="stationTitle">
<h3>${station.name}</h3>
<div class="elevator-preview" role="img"
aria-label="${station.state.working ? `${station.state.working} ${station.state.working > 1 ? 'Aufzüge sind' : 'Aufzug ist'} funktionstüchtig.` : ''}
${station.state.outOfOrder ? `${station.state.outOfOrder} ${station.state.outOfOrder > 1 ? 'Aufzüge sind' : 'Aufzug ist'} außer Betrieb.` : ''}
${station.state.unavailable ? `Bei ${station.state.unavailable} ${station.state.unavailable > 1 ? 'Aufzügen' : 'Aufzug'} ist der Funktionsstatus unbekannt.` : ''}">
${previewTemplate}
</div>
</div>
</div>
<details data-stationid="${station.id}" ${openStations.has(station.id) ? 'open' : ''}>
<summary>
<span>Details / Aufzüge anzeigen</span>
</summary>
${station.comment ? `<div class="comment"><p>${station.comment}</p></div>` : ''}
<ul class="elevatorList collapsed" aria-expanded="false" id="station_${station.id}_elevatorList">
${elevatorsTemplate}
</ul>
</details>
</li>`;
listContainer.insertAdjacentHTML('beforeend', template);
//immediate invocation
(function () {
listContainer.querySelectorAll(`#station_${station.id} .loadOSM`).forEach(e => {
e.addEventListener('click', (ev) => {
ev.target.querySelector('.spinner').classList.remove('hidden');
loadOsmData().then(() => {
ev.target.classList.add('hidden');
renderData();
});
})
})
}());
}
listContainer.insertAdjacentHTML('beforeend', `<li id="filterInfo">
<span id="filteredCount">0</span> von <span id="stationCount">0</span> Stationen durch Filter ausgeblendet.
</li>`);
listContainer.querySelectorAll(`details`).forEach(e => {
e.addEventListener("toggle", (event) => {
const stationId = event.target.dataset['stationid'];
if (event.target.open) {
openStations.add(stationId);
} else {
openStations.delete(stationId);
}
});
})
filterData();
}
document.querySelector('#loadElevators')
.addEventListener('click', (e) => {
e.target.querySelector('.spinner').classList.remove('hidden');
loadElevators().then(() => {
e.target.querySelector('.spinner').classList.add('hidden');
renderData();
});
})
document.querySelector('#initialLoad')
.addEventListener('click', (e) => {
e.target.querySelector('.spinner').classList.remove('hidden');
loadElevators().then(() => {
e.target.classList.add('hidden');
renderData();
});
})
document.querySelector('#loadOsm')
.addEventListener('click', (e) => {
e.target.querySelector('.spinner').classList.remove('hidden');
loadOsmData().then(() => {
e.target.querySelector('.spinner').classList.add('hidden');
renderData();
closeDialog('#dialog_osm');
});
})
document.querySelector('#stationsNearMe')
.addEventListener('click', async (e) => {
e.target.querySelector('.spinner').classList.remove('hidden');
if (!sortByDistance) {
if (JSON.parse(localStorage.getItem("osm_data")) === null) {
openDialog('#dialog_osm');
} else {
if (geolocationPermission !== 'granted') {
allowGeolocation();
} else {
// If geolocation is already set.
// If not the location watcher will re-render our data.
if (geolocation !== null) {
renderData(geolocation)
}
sortByDistance = e.target.ariaPressed = true;
}
}
} else {
sortByDistance = e.target.ariaPressed = false;
renderData();
}
e.target.querySelector('.spinner').classList.add('hidden');
})
function filterData() {
const searchString = document.querySelector('#searchStation').value;
const typeU = document.querySelector('button.typeChip[data-type="U"]');
const typeS = document.querySelector('button.typeChip[data-type="S"]');
const typeA = document.querySelector('button.typeChip[data-type="A"]');
const typeR = document.querySelector('button.typeChip[data-type="R"]');
const activeTypes = [];
if (typeU.dataset.pressed === 'true') activeTypes.push('U');
if (typeS.dataset.pressed === 'true') activeTypes.push('S');
if (typeA.dataset.pressed === 'true') activeTypes.push('A');
if (typeR.dataset.pressed === 'true') activeTypes.push('R');
const stationCount = internalData.stations.length;
let filteredStations = 0;
if (internalData) {
for (const stationIndex in internalData.stations) {
const matchesName = internalData.stations[stationIndex].name.toLowerCase().search(searchString.toLowerCase()) >= 0;
const matchesSearchTarget = internalData.stations[stationIndex].hasOwnProperty('searchTarget')
? internalData.stations[stationIndex].searchTarget.toLowerCase().search(searchString.toLowerCase()) >= 0
: false;
let matchesType = false;
internalData.stations[stationIndex].types.forEach(type => {
if (activeTypes.includes(type)) matchesType = true;
})
const filtered = !((matchesName || matchesSearchTarget) && matchesType);
document.querySelector(`#station_${stationIndex}`).classList.toggle('hidden', filtered);
if (filtered) filteredStations++;
}
document.querySelector('#stationCount').innerHTML = stationCount;
document.querySelector('#filteredCount').innerHTML = filteredStations;
}
}
document.querySelector('#searchStation').addEventListener('input', (e) => {
filterData();
})
document.querySelectorAll('button.typeChip').forEach(e => {
e.addEventListener('click', (event) => {
e.ariaPressed = e.dataset.pressed = e.dataset.pressed === 'true' ? 'false' : 'true';
filterData();
})
})
// data api version check
const check_internal = JSON.parse(localStorage.getItem("internal_data"));
const check_osm = JSON.parse(localStorage.getItem("osm_data"));
if (check_internal === null || check_internal.hasOwnProperty('api') && check_internal.api === minorVersion) {
if (check_osm === null || check_osm.hasOwnProperty('api') && check_osm.api === minorVersion) {
renderData();
} else {
console.log('osm_data: version mismatch')
localStorage.removeItem('osm_data');
}
} else {
console.log('internal_data: version mismatch')
localStorage.removeItem('internal_data');
}