const internalData = { lastUpdate: 0, stations: [], }; let geolocationPermission = false; let geolocation = null; const openStations = new Set(); let sortByDistance = false; const version = '0.5.2' const minorVersion = version.split('.').splice(0, 2).join('.'); const numberFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 }); 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], }, ] Object.defineProperty(String.prototype, 'capitalize', { value: function () { return this.charAt(0).toUpperCase() + this.toLowerCase().slice(1); }, enumerable: false }); 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 })); } const dateTimeStyle = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'medium', timeZone: 'Europe/Berlin', }) 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 = ` `; let linesTemplate = ''; linesTemplate = `
${stateTemplate}`; if (elevator.lines.length) { linesTemplate = `
${stateTemplate}`; linesTemplate += `
Linien: `; for (const line of elevator.lines) { linesTemplate += `${line.line}`; } linesTemplate += '
'; } linesTemplate += '
'; let levelsTemplate = '
    '; for (const levelDescription of elevator.levels) { levelsTemplate += `
  1. ${levelDescription}
  2. `; } levelsTemplate += '
'; let osmTemplate = ''; if (osmData) { const node = osmData.nodes[elevator.osmNodeId] if (node) { osmTemplate = '
'; osmTemplate += `
Link zur Karte
Auf Karte anzeigen
`; if (node.tags.hasOwnProperty('description')) { osmTemplate += `
Beschreibung
${node.tags['description']}
`; } if (node.tags.hasOwnProperty('level')) { osmTemplate += `
Ebenen
${node.tags['level'].split(';').sort().join(', ')}
`; } if (node.tags.hasOwnProperty('wheelchair')) { osmTemplate += `
Rollstühle
${node.tags['wheelchair'] === 'yes' ? 'Ja' : 'Nein'}
`; } if (node.tags.hasOwnProperty('bicycle')) { osmTemplate += `
Fahrräder
${node.tags['bicycle'] === 'yes' ? 'Ja' : 'Nein'}
`; } osmTemplate += '
'; } 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 += `` elevatorsTemplate += `
  • ${stateTemplate}
    ${linesTemplate} ${elevator.instCause !== '' ? `
    ${elevator.instCause}
    ` : ''} ${elevator.levels.length ? levelsTemplate : elevator.description}
    Durchgang
    ${elevator.dimensions.door} cm
    Breite
    ${elevator.dimensions.width} cm
    Länge
    ${elevator.dimensions.length} cm
    Braille
    ${elevator.braille === -1 ? 'unbekannt' : elevator.braille ? `verfügbar` : 'nicht verfügbar'}
    Ansage
    ${elevator.speaker === -1 ? 'unbekannt' : elevator.speaker ? `verfügbar` : 'nicht verfügbar'}
    ${osmTemplate ? `

    Daten von OpenStreetMap

    ${osmTemplate}
    ` : ``}
  • `; } const template = `
  • ${station.types.sort().map(t => `${t}`).join('')}
    ${typeof station.distance !== 'undefined' ? `
    ${Math.round(station.distance / 100) / 10}
    km
    ` : ''}

    ${station.name}

    Details / Aufzüge anzeigen ${station.comment ? `

    ${station.comment}

    ` : ''}
  • `; 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', `
  • 0 von 0 Stationen durch Filter ausgeblendet.
  • `); 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(); }) }) function openDialog(selector) { document.querySelector('body').classList.add('has-dialog') document.querySelector('#dialog_layer').classList.add('active') document.querySelector(selector).classList.remove('hidden') } function closeDialog(selector) { document.querySelector('body').classList.remove('has-dialog') document.querySelector('#dialog_layer').classList.remove('active') document.querySelector(selector).classList.add('hidden') } // set version document.querySelector('#version').innerHTML = `v${version}`; // 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'); }