Atlas: Tile-Proxy + Leaflet lokal — DSGVO-Sanierung fuer Kartendienste

- Leaflet 1.9.4 (CSS + JS + 5 Marker-Images) lokal unter
  App/assets/vendor/leaflet/ statt via unpkg.com/Cloudflare
- Neuer PHP-Endpunkt App/php/tile-proxy.php mit Disk-Cache, liefert
  OSM- und CARTO-Kacheln serverseitig aus; Browser kontaktiert keinen
  Drittanbieter mehr
- Whitelist (osm, carto), Zoom 3-18, 30 Tage Browser-Cache, ETag +
  If-Modified-Since, sauberer User-Agent fuer OSM-Policy
- Cache-Pfad: APP_ROOT/data/tiles lokal, via .env TILE_CACHE_DIR
  auf Produktions-Volume /var/www/html/geograsim/data/tiles
- Heli (heli.php + heli-game.php), Logistik (logistik.php), Routes-
  Dev-Tool und admin-levels.html alle auf Proxy umgestellt
- Datenschutzerklaerung: alter Google-AdSense-Passus (falsch, nicht
  im Einsatz) entfernt, neuer Abschnitt zur serverseitigen
  Tile-Auslieferung mit Rechtsgrundlage Art. 6 Abs. 1 lit. f DSGVO
- .gitignore: App/data/tiles ausgeschlossen
- .env.example: TILE_CACHE_DIR + OpenAI/ElevenLabs dokumentiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 00:53:43 +02:00
parent 3885c83294
commit eac686ee1a
15 changed files with 1380 additions and 8 deletions
+3
View File
@@ -5,3 +5,6 @@ App/.env.local
App/.env.production
App/Don_t_Deploy/.env
.claude/
# Tile-Proxy-Cache (regenerierbare Kartenkacheln, nicht ins Git)
App/data/tiles/
+10
View File
@@ -4,3 +4,13 @@ DB_NAME=geograsim
DB_USER=root
DB_PASS=
DB_PORT=3306
# Tile-Proxy-Cache-Verzeichnis (optional; Default: APP_ROOT/data/tiles/)
# Produktion: persistentes Volume im Container, z.B.
# TILE_CACHE_DIR=/var/www/html/geograsim/data/tiles/
# OpenAI API Key — für DALL-E-Bildgenerierung (nur Dev-Zeit)
# OPENAI_API_KEY=sk-...
# ElevenLabs Sound Effects API — nur für generate-sounds.py (Dev-Zeit)
# ELEVENLABS_API_KEY=...
+4 -4
View File
@@ -6,7 +6,7 @@
<title>GeoGraSim — Administration</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
@@ -141,7 +141,7 @@
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="assets/vendor/leaflet/leaflet.js"></script>
<script>
var BASE = (window.__GGS__?.baseUrl) || '';
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
@@ -325,7 +325,7 @@ function openEdit(key) {
setTimeout(function() {
if (!editMap) {
editMap = L.map('editMap').setView([w.lat, w.lon], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:18}).addTo(editMap);
L.tileLayer('php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm',{attribution:'© OpenStreetMap',maxZoom:18}).addTo(editMap);
editMap.on('click',function(e){document.getElementById('e-lat').value=e.latlng.lat.toFixed(5);document.getElementById('e-lon').value=e.latlng.lng.toFixed(5);if(editMarker)editMap.removeLayer(editMarker);editMarker=L.marker(e.latlng).addTo(editMap);});
} else { editMap.setView([w.lat,w.lon],14); editMap.invalidateSize(); }
if(editMarker)editMap.removeLayer(editMarker);
@@ -336,7 +336,7 @@ function openAdd() {
document.getElementById('editTitle').textContent = 'Neuer Waypoint';
['e-key','e-name','e-lat','e-lon','e-info'].forEach(function(id){document.getElementById(id).value='';});
document.getElementById('editModal').classList.remove('hidden');
setTimeout(function(){if(!editMap){editMap=L.map('editMap').setView([47.3,10.5],9);L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM',maxZoom:18}).addTo(editMap);editMap.on('click',function(e){document.getElementById('e-lat').value=e.latlng.lat.toFixed(5);document.getElementById('e-lon').value=e.latlng.lng.toFixed(5);if(editMarker)editMap.removeLayer(editMarker);editMarker=L.marker(e.latlng).addTo(editMap);});}else{editMap.setView([47.3,10.5],9);editMap.invalidateSize();}if(editMarker){editMap.removeLayer(editMarker);editMarker=null;}},200);
setTimeout(function(){if(!editMap){editMap=L.map('editMap').setView([47.3,10.5],9);L.tileLayer('php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm',{attribution:'© OpenStreetMap',maxZoom:18}).addTo(editMap);editMap.on('click',function(e){document.getElementById('e-lat').value=e.latlng.lat.toFixed(5);document.getElementById('e-lon').value=e.latlng.lng.toFixed(5);if(editMarker)editMap.removeLayer(editMarker);editMarker=L.marker(e.latlng).addTo(editMap);});}else{editMap.setView([47.3,10.5],9);editMap.invalidateSize();}if(editMarker){editMap.removeLayer(editMarker);editMarker=null;}},200);
}
function closeEdit(){document.getElementById('editModal').classList.add('hidden');}
async function saveEdit() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
+21 -1
View File
@@ -9,6 +9,16 @@ require_once __DIR__ . '/../php/config/db.php';
$db = getDB();
$rows = $db->query("SELECT wp_key, name, lat, lon, wp_type, info FROM geo_waypoints")->fetchAll(PDO::FETCH_ASSOC);
// Facts pro Waypoint (geo + kids) — falls Tabelle existiert, sonst leer
$factsByKey = [];
try {
$factRows = $db->query("SELECT wp_key, kind, text_de FROM geo_waypoint_facts ORDER BY wp_key, kind, display_order")->fetchAll(PDO::FETCH_ASSOC);
foreach ($factRows as $f) {
$factsByKey[$f['wp_key']][$f['kind']][] = $f['text_de'];
}
} catch (Exception $e) { /* Tabelle fehlt → Script-freundlich weitermachen */ }
$factsJson = json_encode($factsByKey, JSON_UNESCAPED_UNICODE);
$wpJs = "{\n";
foreach ($rows as $w) {
$key = $w['wp_key'];
@@ -34,10 +44,20 @@ $html = file_get_contents(__DIR__ . '/../sims/heli/game.html');
// Placeholder ersetzen — HELI_BASE aus BASE_PATH ableiten (funktioniert lokal + produktiv)
$base = BASE_PATH . '/sims/heli/';
$html = str_replace('/*__DB_WAYPOINTS__*/',
"window.HELI_BASE = '$base';\nvar WP = $wpJs;\nvar wpLoaded = true;\nvar DB_POIS = $poisJs;", $html);
"window.HELI_BASE = '$base';\nvar WP = $wpJs;\nvar wpLoaded = true;\nvar DB_POIS = $poisJs;\nvar HELI_FACTS = $factsJson;", $html);
// Head-Asset-Pfade auf BASE_PATH umbiegen (relative ../../ greift unter /heli-game-Route falsch)
$html = str_replace('href="../../favicon-96x96.png"', 'href="' . BASE_PATH . '/favicon-96x96.png"', $html);
$html = str_replace('href="../../assets/fonts/inter.css"', 'href="' . BASE_PATH . '/assets/fonts/inter.css"', $html);
// Leaflet lokal + Tile-Proxy (DSGVO-sicher, kein Drittanbieter-Kontakt aus Browser)
$leafletCss = BASE_PATH . '/assets/vendor/leaflet/leaflet.css';
$leafletJs = BASE_PATH . '/assets/vendor/leaflet/leaflet.js';
$tileProxy = BASE_PATH . '/php/tile-proxy.php';
$html = strtr($html, [
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' => $leafletCss,
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' => $leafletJs,
"'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'" => "'{$tileProxy}?z={z}&x={x}&y={y}&p=osm'",
]);
echo $html;
+3 -3
View File
@@ -19,7 +19,7 @@ $wpJson = json_encode($waypoints, JSON_UNESCAPED_UNICODE);
<title>GeoGraSim — Helikopter-Übersichtskarte</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="<?= BASE_PATH ?>/assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
@@ -40,14 +40,14 @@ $wpJson = json_encode($waypoints, JSON_UNESCAPED_UNICODE);
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="<?= BASE_PATH ?>/assets/vendor/leaflet/leaflet.js"></script>
<script>
// Waypoints direkt aus der DB (PHP-injiziert)
var DB_WAYPOINTS = <?= $wpJson ?>;
var map = L.map('map', {zoomControl:false, maxBoundsViscosity:1.0, minZoom:7, maxZoom:15})
.setView([47.4, 13.2], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
L.tileLayer('<?= BASE_PATH ?>/php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm', {
attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom:18
}).addTo(map);
L.control.zoom({position:'topright'}).addTo(map);
+19
View File
@@ -51,4 +51,23 @@ window.LOGISTIK_LEVELS = {$levelsJson};
JS;
$html = str_replace('/*__LOGISTIK_INJECTION__*/', $injection, $html);
// Relative Asset-Pfade aus game.html auf BASE_PATH/LOGISTIK_BASE umbiegen
// (unter /App/logistik würden ../../ falsch aufgelöst — siehe heli-game.php).
$tileProxy = BASE_PATH . '/php/tile-proxy.php';
$html = strtr($html, [
'href="../../assets/css/design-system.css"' => 'href="' . BASE_PATH . '/assets/css/design-system.css"',
'href="../../assets/fonts/inter.css"' => 'href="' . BASE_PATH . '/assets/fonts/inter.css"',
'src="../../assets/img/bildLogo.png"' => 'src="' . BASE_PATH . '/assets/img/bildLogo.png"',
'src="../../assets/img/textlogo_geograsim.svg"' => 'src="' . BASE_PATH . '/assets/img/textlogo_geograsim.svg"',
'href="../../sim"' => 'href="' . BASE_PATH . '/sim"',
'href="../../"' => 'href="' . BASE_PATH . '/"',
'src="engine.js"' => 'src="' . $base . 'engine.js"',
'src="headless-runner.js"' => 'src="' . $base . 'headless-runner.js"',
// Leaflet lokal + Tile-Proxy (DSGVO-sicher)
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' => BASE_PATH . '/assets/vendor/leaflet/leaflet.css',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' => BASE_PATH . '/assets/vendor/leaflet/leaflet.js',
"'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'" => "'{$tileProxy}?z={z}&x={x}&y={y}&p=carto'",
]);
echo $html;
+151
View File
@@ -0,0 +1,151 @@
<?php
/**
* Tile-Proxy mit Disk-Cache — DSGVO-sicherer Ersatz für direkte
* Browser-Requests an OSM/CARTO-Tile-Server.
*
* Flow:
* Browser → tile-proxy.php (euer Server) → [Cache-Hit? ja: raus]
* → [Cache-Miss? Upstream holen, cachen, raus]
*
* Aufruf: /tile-proxy?z={z}&x={x}&y={y}&p={osm|carto}
*
* Konfiguration über .env:
* TILE_CACHE_DIR=/absoluter/pfad/zum/cache/ (optional;
* Default: APP_ROOT/data/tiles/)
*/
require_once __DIR__ . '/config/app.php';
// ============================================================
// Whitelist — was darf rein, was raus
// ============================================================
const PROVIDERS = [
'osm' => [
'label' => 'OpenStreetMap',
'url_pattern' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
'max_zoom' => 18,
],
'carto' => [
'label' => 'CARTO Positron',
'url_pattern' => 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
'max_zoom' => 18,
],
];
const MIN_ZOOM = 3;
const MAX_ZOOM = 18;
const CACHE_TTL_DAYS = 30; // Browser-Cache-Max-Age
const UPSTREAM_TIMEOUT = 10; // Sekunden
// ============================================================
// Parameter-Validierung
// ============================================================
$z = filter_input(INPUT_GET, 'z', FILTER_VALIDATE_INT, ['options' => ['min_range' => MIN_ZOOM, 'max_range' => MAX_ZOOM]]);
$x = filter_input(INPUT_GET, 'x', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1 << MAX_ZOOM]]);
$y = filter_input(INPUT_GET, 'y', FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 1 << MAX_ZOOM]]);
$p = filter_input(INPUT_GET, 'p', FILTER_CALLBACK, ['options' => fn($v) => is_string($v) && preg_match('/^[a-z]{1,16}$/', $v) ? $v : false]);
if ($z === null || $z === false || $x === false || $y === false || !$p || !isset(PROVIDERS[$p])) {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
exit("Ungültige Parameter. Erwartet: ?z=INT&x=INT&y=INT&p=osm|carto\n");
}
$xMax = (1 << $z) - 1;
if ($x < 0 || $x > $xMax || $y < 0 || $y > $xMax) {
http_response_code(400);
exit("Tile-Koordinaten außerhalb des Zoom-Bereichs.\n");
}
$providerConf = PROVIDERS[$p];
if ($z > $providerConf['max_zoom']) {
http_response_code(400);
exit("Zoom-Level {$z} wird von Provider {$p} nicht unterstützt.\n");
}
// ============================================================
// Cache-Pfad bestimmen
// ============================================================
$cacheDir = defined('TILE_CACHE_DIR')
? rtrim(TILE_CACHE_DIR, '/\\')
: rtrim(APP_ROOT, '/\\') . '/data/tiles';
$tileDir = $cacheDir . "/$p/$z/$x";
$tilePath = $tileDir . "/$y.png";
// ============================================================
// Cache-Hit: sofort ausliefern
// ============================================================
if (is_file($tilePath) && filesize($tilePath) > 0) {
_serveTile($tilePath);
exit;
}
// ============================================================
// Cache-Miss: upstream holen
// ============================================================
$upstreamUrl = strtr($providerConf['url_pattern'], [
'{z}' => $z, '{x}' => $x, '{y}' => $y,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => UPSTREAM_TIMEOUT,
'user_agent' => 'GeoGraSim/1.0 (+https://geograsim.at; educational tile cache)',
'header' => "Accept: image/png,image/*\r\n",
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($upstreamUrl, false, $ctx);
if ($data === false || strlen($data) < 100) {
http_response_code(502);
header('Content-Type: text/plain; charset=utf-8');
exit("Upstream nicht erreichbar: {$providerConf['label']}\n");
}
// ============================================================
// In Cache schreiben (best-effort, Fehler ignorieren)
// ============================================================
if (!is_dir($tileDir)) {
@mkdir($tileDir, 0775, true);
}
if (is_dir($tileDir) && is_writable($tileDir)) {
@file_put_contents($tilePath, $data, LOCK_EX);
}
// ============================================================
// An Browser ausliefern
// ============================================================
header('Content-Type: image/png');
header('Cache-Control: public, max-age=' . (CACHE_TTL_DAYS * 86400) . ', immutable');
header('X-Tile-Cache: MISS');
echo $data;
// ============================================================
// Helper: Tile aus Cache servieren
// ============================================================
function _serveTile(string $path): void {
$etag = '"' . md5_file($path) . '"';
$mtime = filemtime($path);
$lastMod = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
header('Content-Type: image/png');
header('Cache-Control: public, max-age=' . (CACHE_TTL_DAYS * 86400) . ', immutable');
header('ETag: ' . $etag);
header('Last-Modified: ' . $lastMod);
header('X-Tile-Cache: HIT');
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
$ifModSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '';
if ($ifNoneMatch === $etag || ($ifModSince && strtotime($ifModSince) >= $mtime)) {
http_response_code(304);
return;
}
header('Content-Length: ' . filesize($path));
readfile($path);
}
+502
View File
@@ -0,0 +1,502 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Heli-Routen Vorarlberg</title>
<link rel="icon" type="image/png" href="../../favicon-96x96.png">
<link rel="stylesheet" href="../../assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f7f6f3;height:100vh;display:flex;flex-direction:column}
.topbar{background:#fff;padding:.5rem 1rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);flex-shrink:0;flex-wrap:wrap}
.topbar h1{font-size:.9rem;font-weight:800;color:#1f4e5a}
.topbar select{padding:.3rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.75rem;font-family:inherit;font-weight:600;max-width:350px}
.topbar .info{font-size:.68rem;color:#8a8a8a;flex:1}
.badge{display:inline-block;font-size:.6rem;font-weight:700;padding:1px 6px;border-radius:4px;margin-left:4px}
.badge-easy{background:#dceadd;color:#3a6b3e}
.badge-medium{background:#f2eacc;color:#8a7020}
.badge-hard{background:#f2ddd8;color:#8a3a2a}
#map{flex:1}
.wp-marker{width:22px;height:22px;background:#fff;border:2px solid #1f4e5a;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:800;color:#1f4e5a;box-shadow:0 1px 4px rgba(0,0,0,.25)}
.wp-marker.start{background:#e8833a;border-color:#c06020;color:#fff;width:28px;height:28px;font-size:12px}
.wp-marker.end{background:#c85c4a;border-color:#a04030;color:#fff;width:28px;height:28px;font-size:12px}
.wp-label{font-family:'Inter',sans-serif;font-weight:700;font-size:9px;color:#1f4e5a;white-space:nowrap;background:rgba(255,255,255,.88);padding:1px 5px;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1)}
.route-desc{position:absolute;bottom:12px;left:12px;right:12px;background:rgba(255,255,255,.95);border-radius:10px;padding:10px 14px;font-size:.75rem;color:#333;box-shadow:0 4px 16px rgba(0,0,0,.12);z-index:1000;line-height:1.5;max-height:30vh;overflow-y:auto}
.route-desc h3{font-size:.85rem;font-weight:800;color:#1f4e5a;margin-bottom:4px}
.route-desc .meta{font-size:.65rem;color:#8a8a8a}
</style>
</head>
<body>
<div class="topbar">
<h1>🚁 Heli-Routen</h1>
<select id="region-select" onchange="filterRoutes()" style="padding:.3rem .5rem;border:1.5px solid rgba(0,0,0,.1);border-radius:6px;font-size:.75rem;font-family:inherit;font-weight:600">
<option value="all">Alle Regionen</option>
<option value="vlbg">Vorarlberg</option>
<option value="tirol">Tirol</option>
</select>
<select id="route-select" onchange="showRoute()"></select>
<div class="info" id="route-info">60 Routen · Vorarlberg + Tirol</div>
</div>
<div id="map" style="position:relative">
<div class="route-desc" id="route-desc"></div>
</div>
<script src="../../assets/vendor/leaflet/leaflet.js"></script>
<script>
var map = L.map('map', {zoomControl:false}).setView([47.2, 11.0], 9);
L.tileLayer('../../php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm', {
attribution:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom:16
}).addTo(map);
L.control.zoom({position:'topright'}).addTo(map);
L.control.scale({position:'bottomleft',metric:true,imperial:false}).addTo(map);
// Waypoints: echte Vorarlberger Orte
var WP = {
// Basen
nenzing: {lat:47.1828, lon:9.7072, name:'C8 Nenzing (ÖAMTC)'},
hohenems: {lat:47.3650, lon:9.6892, name:'Hohenems (Polizei)'},
// Rheintal
feldkirch: {lat:47.2376, lon:9.5982, name:'Feldkirch'},
rankweil: {lat:47.2725, lon:9.6450, name:'Rankweil'},
goetzis: {lat:47.3333, lon:9.6333, name:'Götzis'},
dornbirn: {lat:47.4136, lon:9.7424, name:'Dornbirn'},
bregenz: {lat:47.5026, lon:9.7473, name:'Bregenz'},
lustenau: {lat:47.4267, lon:9.6583, name:'Lustenau'},
// Walgau / Klostertal
bludenz: {lat:47.1546, lon:9.8222, name:'Bludenz'},
buers: {lat:47.1500, lon:9.8000, name:'Bürs'},
nüziders: {lat:47.1700, lon:9.8000, name:'Nüziders'},
innerbraz: {lat:47.1347, lon:9.9208, name:'Innerbraz'},
kloeserle: {lat:47.1283, lon:10.0750, name:'Klösterle'},
stuben: {lat:47.1300, lon:10.1900, name:'Stuben am Arlberg'},
langen: {lat:47.1283, lon:10.1167, name:'Langen am Arlberg'},
// Montafon
schruns: {lat:47.0800, lon:9.9167, name:'Schruns'},
tschagguns: {lat:47.0750, lon:9.9000, name:'Tschagguns'},
stgallenk: {lat:47.0500, lon:9.8833, name:'St. Gallenkirch'},
gaschurn: {lat:47.0167, lon:10.0167, name:'Gaschurn'},
partenen: {lat:47.0000, lon:10.0667, name:'Partenen'},
silvretta: {lat:46.9167, lon:10.0833, name:'Silvretta-Stausee'},
// Großes Walsertal
thueringen: {lat:47.2200, lon:9.7600, name:'Thüringen'},
zwischenwasser:{lat:47.2300, lon:9.8000, name:'Zwischenwasser'},
laterns: {lat:47.2500, lon:9.8700, name:'Laterns'},
damuels: {lat:47.2833, lon:9.8917, name:'Damüls'},
fontanella: {lat:47.2600, lon:9.9400, name:'Fontanella'},
sonntag: {lat:47.2300, lon:9.9200, name:'Sonntag'},
// Bregenzerwald
egg: {lat:47.4333, lon:9.8917, name:'Egg'},
bezau: {lat:47.3833, lon:9.9000, name:'Bezau'},
mellau: {lat:47.3500, lon:9.8833, name:'Mellau'},
au: {lat:47.3333, lon:9.9667, name:'Au'},
schoppernau:{lat:47.3167, lon:10.0167, name:'Schoppernau'},
warth: {lat:47.2533, lon:10.1883, name:'Warth'},
schroecken: {lat:47.2583, lon:10.0917, name:'Schröcken'},
lech: {lat:47.2083, lon:10.1417, name:'Lech am Arlberg'},
zuers: {lat:47.1700, lon:10.1583, name:'Zürs'},
// Brandnertal / Rätikon
brand: {lat:47.1000, lon:9.7333, name:'Brand'},
// Kleinwalsertal
riezlern: {lat:47.3583, lon:10.1917, name:'Riezlern'},
mittelberg: {lat:47.3333, lon:10.1500, name:'Mittelberg'},
// Pfänder / Bodensee
pfaender: {lat:47.5100, lon:9.7800, name:'Pfänder (1064 m)'},
// Kanisfluh
kanisfluh: {lat:47.3600, lon:9.9400, name:'Kanisfluh (2044 m)'},
// Spezielle Einsatzorte
damuels_ski:{lat:47.2900, lon:9.8950, name:'Skigebiet Damüls'},
lech_ski: {lat:47.2150, lon:10.1500, name:'Skigebiet Lech-Zürs'},
silvretta_bielerhöhe:{lat:46.9200, lon:10.0900, name:'Bielerhöhe'},
brandnergletscher:{lat:47.0800, lon:9.7500, name:'Brandner Gletscher'},
// Einsatzorte
verkehr_a14:{lat:47.2800, lon:9.6600, name:'Unfall A14 Rankweil'},
bergrettung_muels:{lat:47.2700, lon:9.8800, name:'Skiunfall Damüls-Mellau'},
vermisst_silvretta:{lat:46.9500, lon:10.0700, name:'Vermisste Person Silvretta'},
hochwasser_ill:{lat:47.1200, lon:9.8500, name:'Hochwasser Ill-Tal'},
// === TIROL ===
// Basen
innsbruck_c1:{lat:47.2600, lon:11.3439, name:'C1 Innsbruck (ÖAMTC)'},
zams_c5: {lat:47.1597, lon:10.5933, name:'C5 Zams (ÖAMTC)'},
kitzbuehel_c4:{lat:47.3801, lon:12.3580, name:'C4 Kitzbühel (ÖAMTC)'},
lienz_c7: {lat:46.8297, lon:12.7647, name:'C7 Lienz (ÖAMTC)'},
// Inntal
hall: {lat:47.2894, lon:11.5083, name:'Hall in Tirol'},
schwaz: {lat:47.3517, lon:11.7100, name:'Schwaz'},
woergl: {lat:47.4873, lon:12.0638, name:'Wörgl'},
kufstein: {lat:47.5836, lon:12.1647, name:'Kufstein'},
telfs: {lat:47.3128, lon:11.0697, name:'Telfs'},
imst: {lat:47.2450, lon:10.7397, name:'Imst'},
landeck: {lat:47.1397, lon:10.5669, name:'Landeck'},
reutte: {lat:47.4858, lon:10.7181, name:'Reutte'},
// Stubaital
fulpmes: {lat:47.1558, lon:11.3533, name:'Fulpmes'},
neustift: {lat:47.1083, lon:11.3117, name:'Neustift im Stubaital'},
stubaier_gl: {lat:46.9972, lon:11.1167, name:'Stubaier Gletscher'},
// Ötztal
oetz: {lat:47.2083, lon:10.8933, name:'Ötz'},
soelden: {lat:46.9667, lon:10.8750, name:'Sölden'},
obergurgl: {lat:46.8700, lon:11.0300, name:'Obergurgl'},
// Zillertal
jenbach: {lat:47.3917, lon:11.7750, name:'Jenbach'},
zell_ziller: {lat:47.2286, lon:11.8822, name:'Zell am Ziller'},
mayrhofen: {lat:47.1592, lon:11.8614, name:'Mayrhofen'},
hintertux: {lat:47.0833, lon:11.6500, name:'Hintertux'},
// Wipptal / Brenner
steinach: {lat:47.0886, lon:11.4672, name:'Steinach am Brenner'},
brenner_ort: {lat:47.0422, lon:11.5067, name:'Brenner'},
// Osttirol
matrei: {lat:47.0000, lon:12.5333, name:'Matrei in Osttirol'},
kals: {lat:47.0083, lon:12.6417, name:'Kals am Großglockner'},
sillian: {lat:46.7519, lon:12.4219, name:'Sillian'},
// Arlberg
st_anton: {lat:47.1286, lon:10.2653, name:'St. Anton am Arlberg'},
// Kitzbühel
kitzbuehel_ort:{lat:47.4464, lon:12.3922, name:'Kitzbühel'},
st_johann: {lat:47.3528, lon:12.4247, name:'St. Johann in Tirol'},
// Seefeld / Karwendel
seefeld: {lat:47.3300, lon:11.1900, name:'Seefeld'},
scharnitz: {lat:47.3889, lon:11.2647, name:'Scharnitz'},
// Einsatzorte Tirol
lawine_arlberg:{lat:47.1400, lon:10.2200, name:'Lawine St. Anton'},
skiunfall_soelden:{lat:46.9800, lon:10.8600, name:'Skiunfall Sölden'},
bergunfall_glockner:{lat:47.0750, lon:12.6950, name:'Bergunfall Großglockner'},
vermisst_karwendel:{lat:47.3600, lon:11.3900, name:'Vermisste Person Karwendel'},
verkehr_inntal:{lat:47.3000, lon:11.6000, name:'Unfall A12 Inntal'},
hochwasser_inn:{lat:47.2700, lon:10.8500, name:'Hochwasser Inn bei Imst'},
};
// === 30 ROUTEN ===
var ROUTES = [
// --- VON NENZING (C8) ---
{id:1, name:'Nenzing → Damüls (Skiunfall)', start:'nenzing', end:'bergrettung_muels', difficulty:'medium',
waypoints:['nenzing','thueringen','zwischenwasser','laterns','damuels','bergrettung_muels'],
desc:'Skiunfall im Gebiet Damüls-Mellau. Route über das Laternsertal, da Direktflug über den Grat bei Wind nicht möglich.', reason:'Gebirgsumgehung über Talroute'},
{id:2, name:'Nenzing → Lech (Lawinenunfall)', start:'nenzing', end:'lech', difficulty:'hard',
waypoints:['nenzing','bludenz','kloeserle','stuben','zuers','lech'],
desc:'Lawinenunfall bei Lech. Klassische Route durch das Klostertal und über den Arlberg.', reason:'Standardroute durch Klostertal'},
{id:3, name:'Nenzing → Silvretta (Vermisste)', start:'nenzing', end:'vermisst_silvretta', difficulty:'hard',
waypoints:['nenzing','bludenz','schruns','stgallenk','gaschurn','partenen','silvretta','vermisst_silvretta'],
desc:'Vermisste Wandergruppe im Silvrettagebiet. Langer Flug durch das gesamte Montafon bis zur Bielerhöhe.', reason:'Einzige Zugangsroute durch das enge Montafon'},
{id:4, name:'Nenzing → Bregenz (Seerettung)', start:'nenzing', end:'bregenz', difficulty:'easy',
waypoints:['nenzing','feldkirch','rankweil','goetzis','dornbirn','bregenz'],
desc:'Notfall am Bodensee. Einfacher Rheintal-Flug nach Norden.', reason:'Flaches Rheintal, keine Hindernisse'},
{id:5, name:'Nenzing → Brand (Bergrettung)', start:'nenzing', end:'brand', difficulty:'medium',
waypoints:['nenzing','buers','brand'],
desc:'Kletterunfall im Brandnertal (Rätikon). Kurzer, aber enger Talflug.', reason:'Kurze Distanz, enges Tal'},
{id:6, name:'Nenzing → Schoppernau (Verkehr)', start:'nenzing', end:'schoppernau', difficulty:'medium',
waypoints:['nenzing','feldkirch','rankweil','goetzis','egg','bezau','au','schoppernau'],
desc:'Schwerer Verkehrsunfall bei Schoppernau. Route über Rheintal und Bregenzerwald.', reason:'Umweg über Rheintal nötig — Direktflug über Gebirge bei Wetter nicht möglich'},
{id:7, name:'Nenzing → Gaschurn (Hochwasser)', start:'nenzing', end:'hochwasser_ill', difficulty:'medium',
waypoints:['nenzing','bludenz','schruns','tschagguns','stgallenk','hochwasser_ill'],
desc:'Hochwasser an der Ill. Evakuierung im vorderen Montafon.', reason:'Flusstal-Route'},
{id:8, name:'Nenzing → Warth (Skiunfall)', start:'nenzing', end:'warth', difficulty:'hard',
waypoints:['nenzing','bludenz','kloeserle','langen','stuben','zuers','lech','warth'],
desc:'Skiunfall bei Warth-Schröcken. Lange Route über Klostertal und Arlberg.', reason:'Einzige wettersichere Route über den Arlberg'},
{id:9, name:'Nenzing → A14 Rankweil (Verkehr)', start:'nenzing', end:'verkehr_a14', difficulty:'easy',
waypoints:['nenzing','thueringen','rankweil','verkehr_a14'],
desc:'Massenkarambolage auf der A14. Kürzester Weg.', reason:'Direktflug, minimale Distanz'},
{id:10, name:'Nenzing → Fontanella (Großes Walsertal)', start:'nenzing', end:'fontanella', difficulty:'hard',
waypoints:['nenzing','thueringen','zwischenwasser','sonntag','fontanella'],
desc:'Akuter Notfall im hinteren Großen Walsertal. Enges, winkeliges Tal.', reason:'Große Walsertal nur durch Taleingang erreichbar'},
{id:11, name:'Nenzing → Dornbirn (Industrieunfall)', start:'nenzing', end:'dornbirn', difficulty:'easy',
waypoints:['nenzing','feldkirch','rankweil','goetzis','hohenems','dornbirn'],
desc:'Chemieunfall in Dornbirn. Rheintal-Direktflug.', reason:'Flaches Gelände, gute Sicht'},
{id:12, name:'Nenzing → Kleinwalsertal', start:'nenzing', end:'mittelberg', difficulty:'hard',
waypoints:['nenzing','feldkirch','rankweil','goetzis','egg','bezau','au','schroecken','warth','riezlern','mittelberg'],
desc:'Notfall im Kleinwalsertal. Nur über den Bregenzerwald erreichbar (keine Straße nach Österreich!).', reason:'Kleinwalsertal ist nur von Deutschland oder per Luft erreichbar'},
{id:13, name:'Nenzing → Zürs (Piste)', start:'nenzing', end:'zuers', difficulty:'medium',
waypoints:['nenzing','bludenz','innerbraz','kloeserle','langen','stuben','zuers'],
desc:'Verletzung auf der Skipiste in Zürs. Route durch Klostertal.', reason:'Klassische Arlberg-Zufahrt'},
{id:14, name:'Nenzing → Pfänder (Wanderunfall)', start:'nenzing', end:'pfaender', difficulty:'easy',
waypoints:['nenzing','feldkirch','rankweil','goetzis','dornbirn','bregenz','pfaender'],
desc:'Wanderunfall am Pfänder über Bregenz. Einfacher Rheintal-Flug mit kurzem Anstieg.', reason:'Pfänder ist niedrig und gut erreichbar'},
{id:15, name:'Nenzing → Mellau (Canyoning)', start:'nenzing', end:'mellau', difficulty:'medium',
waypoints:['nenzing','rankweil','goetzis','egg','bezau','mellau'],
desc:'Canyoning-Unfall bei Mellau. Route über Bregenzerwald.', reason:'Bregenzerwald-Route'},
// --- VON HOHENEMS (Polizei) ---
{id:16, name:'Hohenems → Damüls (Skiunfall)', start:'hohenems', end:'bergrettung_muels', difficulty:'medium',
waypoints:['hohenems','goetzis','rankweil','zwischenwasser','laterns','damuels','bergrettung_muels'],
desc:'Polizeieinsatz nach Skiunfall Damüls. Route über Götzis und Laternsertal.', reason:'Umgehung des Gebirges über Talroute'},
{id:17, name:'Hohenems → Bregenz (Demonstration)', start:'hohenems', end:'bregenz', difficulty:'easy',
waypoints:['hohenems','dornbirn','bregenz'],
desc:'Polizeieinsatz bei Großveranstaltung in Bregenz. Kurzer Rheintal-Flug.', reason:'Minimale Distanz'},
{id:18, name:'Hohenems → Lech (Sucheinsatz)', start:'hohenems', end:'lech', difficulty:'hard',
waypoints:['hohenems','dornbirn','egg','bezau','au','schroecken','warth','lech'],
desc:'Vermisstensuche bei Lech. Route über den gesamten Bregenzerwald.', reason:'Bregenzerwald-Route vermeidet hohe Pässe'},
{id:19, name:'Hohenems → Schruns (Fahndung)', start:'hohenems', end:'schruns', difficulty:'medium',
waypoints:['hohenems','rankweil','nenzing','bludenz','schruns'],
desc:'Fahndungseinsatz in Schruns. Route über Rheintal und Walgau.', reason:'Standardroute ins Montafon'},
{id:20, name:'Hohenems → Feldkirch (Amtshilfe)', start:'hohenems', end:'feldkirch', difficulty:'easy',
waypoints:['hohenems','rankweil','feldkirch'],
desc:'Polizeieinsatz in Feldkirch. Sehr kurzer Flug.', reason:'Direkte Linie im Rheintal'},
{id:21, name:'Hohenems → Lustenau (Grenze)', start:'hohenems', end:'lustenau', difficulty:'easy',
waypoints:['hohenems','lustenau'],
desc:'Grenzeinsatz bei Lustenau (Schweizer Grenze). Kürzeste Route.', reason:'Nachbarort, Sichtflug'},
{id:22, name:'Hohenems → Egg (Bregenzerwald)', start:'hohenems', end:'egg', difficulty:'easy',
waypoints:['hohenems','dornbirn','egg'],
desc:'Einsatz im vorderen Bregenzerwald. Route über Dornbirn.', reason:'Einfache Talroute'},
{id:23, name:'Hohenems → Gaschurn (Großeinsatz)', start:'hohenems', end:'gaschurn', difficulty:'hard',
waypoints:['hohenems','rankweil','nenzing','bludenz','schruns','stgallenk','gaschurn'],
desc:'Großeinsatz im hinteren Montafon. Langer Flug durch mehrere Täler.', reason:'Montafon nur von Bludenz aus erreichbar'},
{id:24, name:'Hohenems → Brand (Kletterunfall)', start:'hohenems', end:'brand', difficulty:'medium',
waypoints:['hohenems','rankweil','nenzing','buers','brand'],
desc:'Kletterunfall im Brandnertal. Route über Walgau.', reason:'Brandnertal-Zugang über Bürs'},
{id:25, name:'Hohenems → Sonntag (Walsertal)', start:'hohenems', end:'sonntag', difficulty:'hard',
waypoints:['hohenems','rankweil','thueringen','zwischenwasser','sonntag'],
desc:'Notfall im Großen Walsertal. Enges Tal, schwieriger Zugang.', reason:'Talenge erfordert präzise Navigation'},
{id:26, name:'Hohenems → Bezau (Verkehr)', start:'hohenems', end:'bezau', difficulty:'easy',
waypoints:['hohenems','dornbirn','egg','bezau'],
desc:'Verkehrsunfall bei Bezau. Route über den vorderen Bregenzerwald.', reason:'Einfache Bregenzerwald-Route'},
{id:27, name:'Hohenems → Stuben (Arlberg)', start:'hohenems', end:'stuben', difficulty:'hard',
waypoints:['hohenems','rankweil','nenzing','bludenz','innerbraz','kloeserle','stuben'],
desc:'Einsatz am Arlberg. Langer Flug durch Walgau und Klostertal.', reason:'Arlberg nur über Klostertal erreichbar'},
{id:28, name:'Hohenems → Silvretta', start:'hohenems', end:'silvretta', difficulty:'hard',
waypoints:['hohenems','rankweil','nenzing','bludenz','schruns','gaschurn','partenen','silvretta'],
desc:'Bergunfall an der Silvretta. Längste Route — komplettes Montafon.', reason:'Silvretta nur über Montafon erreichbar'},
{id:29, name:'Hohenems → Kanisfluh (Sucheinsatz)', start:'hohenems', end:'kanisfluh', difficulty:'medium',
waypoints:['hohenems','dornbirn','egg','bezau','mellau','kanisfluh'],
desc:'Vermisstensuche am Kanisfluh. Route über den Bregenzerwald, dann Anflug zum Bergmassiv.', reason:'Kanisfluh ist isoliertes Massiv im Bregenzerwald'},
{id:30, name:'Hohenems → Riezlern (Kleinwalsertal)', start:'hohenems', end:'riezlern', difficulty:'hard',
waypoints:['hohenems','dornbirn','egg','bezau','au','schroecken','warth','riezlern'],
desc:'Einsatz im Kleinwalsertal. Das Tal ist nur von Deutschland oder per Luft erreichbar — eine der anspruchsvollsten Routen Vorarlbergs.', reason:'Funktionale Exklave — Luftweg einzige österreichische Verbindung', region:'vlbg'},
// === TIROL-ROUTEN (3160) ===
// Von C1 Innsbruck
{id:31, name:'Innsbruck → Sölden (Skiunfall)', start:'innsbruck_c1', end:'skiunfall_soelden', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','telfs','oetz','soelden','skiunfall_soelden'],
desc:'Skiunfall im Skigebiet Sölden. Route durch das Inntal und Ötztal.', reason:'Ötztal nur von Norden erreichbar'},
{id:32, name:'Innsbruck → Großglockner (Bergunfall)', start:'innsbruck_c1', end:'bergunfall_glockner', difficulty:'hard', region:'tirol',
waypoints:['innsbruck_c1','hall','schwaz','jenbach','woergl','kitzbuehel_ort','st_johann','matrei','kals','bergunfall_glockner'],
desc:'Kletterunfall am Großglockner. Längste Route — quer durch Tirol bis Osttirol.', reason:'Großglockner liegt an der Grenze Tirol/Kärnten, nur über Felbertauern oder Inntal erreichbar'},
{id:33, name:'Innsbruck → Stubaier Gletscher', start:'innsbruck_c1', end:'stubaier_gl', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','fulpmes','neustift','stubaier_gl'],
desc:'Notfall am Stubaier Gletscher. Kurzer, aber hochalpiner Flug.', reason:'Stubaital ist Sackgasse — ein Weg rein, ein Weg raus'},
{id:34, name:'Innsbruck → Karwendel (Vermisste)', start:'innsbruck_c1', end:'vermisst_karwendel', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','hall','scharnitz','vermisst_karwendel'],
desc:'Vermisstensuche im Karwendelgebirge. Unwegsames Gelände nördlich von Innsbruck.', reason:'Karwendel ist das größte Naturschutzgebiet der Nordalpen'},
{id:35, name:'Innsbruck → Kufstein (Verkehr)', start:'innsbruck_c1', end:'kufstein', difficulty:'easy', region:'tirol',
waypoints:['innsbruck_c1','hall','schwaz','jenbach','woergl','kufstein'],
desc:'Schwerer Verkehrsunfall auf der A12 bei Kufstein. Einfacher Inntal-Flug.', reason:'Flaches Inntal, gute Sicht'},
{id:36, name:'Innsbruck → Brenner', start:'innsbruck_c1', end:'brenner_ort', difficulty:'easy', region:'tirol',
waypoints:['innsbruck_c1','steinach','brenner_ort'],
desc:'Einsatz am Brennerpass. Kurzer Flug durchs Wipptal.', reason:'Direkte Talroute nach Süden'},
{id:37, name:'Innsbruck → Obergurgl', start:'innsbruck_c1', end:'obergurgl', difficulty:'hard', region:'tirol',
waypoints:['innsbruck_c1','telfs','oetz','soelden','obergurgl'],
desc:'Notfall in Obergurgl. Tief ins hintere Ötztal — hochalpin.', reason:'Obergurgl liegt auf 1.930 m, eine der höchsten Dauersiedlungen Österreichs'},
{id:38, name:'Innsbruck → Mayrhofen', start:'innsbruck_c1', end:'mayrhofen', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','hall','schwaz','jenbach','zell_ziller','mayrhofen'],
desc:'Canyoning-Unfall bei Mayrhofen. Route durch Inntal und Zillertal.', reason:'Zillertal-Zugang über Jenbach'},
{id:39, name:'Innsbruck → Hintertux', start:'innsbruck_c1', end:'hintertux', difficulty:'hard', region:'tirol',
waypoints:['innsbruck_c1','hall','schwaz','jenbach','zell_ziller','mayrhofen','hintertux'],
desc:'Gletscherunfall am Hintertuxer Gletscher. Langer Flug bis ans Talende.', reason:'Tuxertal ist Sackgasse, Gletscher auf 3.250 m'},
{id:40, name:'Innsbruck → Seefeld', start:'innsbruck_c1', end:'seefeld', difficulty:'easy', region:'tirol',
waypoints:['innsbruck_c1','seefeld'],
desc:'Wanderunfall am Seefelder Plateau. Kurzer Flug.', reason:'Seefeld liegt nur 20 km nordwestlich von Innsbruck'},
{id:41, name:'Innsbruck → St. Anton (Lawine)', start:'innsbruck_c1', end:'lawine_arlberg', difficulty:'hard', region:'tirol',
waypoints:['innsbruck_c1','telfs','imst','landeck','st_anton','lawine_arlberg'],
desc:'Lawinenabgang bei St. Anton. Langer Flug durch das gesamte Oberinntal.', reason:'Arlberg nur über Inntal-Route erreichbar'},
{id:42, name:'Innsbruck → Reutte', start:'innsbruck_c1', end:'reutte', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','telfs','imst','reutte'],
desc:'Notfall im Außerfern. Route über den Fernpass.', reason:'Außerfern ist vom Rest Tirols durch Gebirge getrennt'},
{id:43, name:'Innsbruck → Inntal A12 (Verkehr)', start:'innsbruck_c1', end:'verkehr_inntal', difficulty:'easy', region:'tirol',
waypoints:['innsbruck_c1','hall','verkehr_inntal'],
desc:'Massenkarambolage auf der A12. Sehr kurzer Einsatz.', reason:'Direktflug im Inntal'},
{id:44, name:'Innsbruck → Hochwasser Inn', start:'innsbruck_c1', end:'hochwasser_inn', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','telfs','imst','hochwasser_inn'],
desc:'Hochwasser am Inn bei Imst. Evakuierungseinsatz.', reason:'Inntal-Route, Fluss folgen'},
{id:45, name:'Innsbruck → Kitzbühel', start:'innsbruck_c1', end:'kitzbuehel_ort', difficulty:'medium', region:'tirol',
waypoints:['innsbruck_c1','hall','schwaz','jenbach','woergl','kitzbuehel_ort'],
desc:'Einsatz in Kitzbühel. Inntal-Route über Wörgl.', reason:'Kitzbühel liegt südlich des Inntals'},
// Von C5 Zams
{id:46, name:'Zams → St. Anton (Lawine)', start:'zams_c5', end:'lawine_arlberg', difficulty:'medium', region:'tirol',
waypoints:['zams_c5','landeck','st_anton','lawine_arlberg'],
desc:'Lawinenunfall bei St. Anton. Kurzer Flug von Zams über Landeck.', reason:'C5 ist die nächste Basis zum Arlberg'},
{id:47, name:'Zams → Sölden', start:'zams_c5', end:'soelden', difficulty:'medium', region:'tirol',
waypoints:['zams_c5','imst','oetz','soelden'],
desc:'Skiunfall in Sölden. Route über Imst ins Ötztal.', reason:'Ötztal-Zugang über Imst'},
{id:48, name:'Zams → Ischgl', start:'zams_c5', end:'sillian', difficulty:'hard', region:'tirol',
waypoints:['zams_c5','landeck','sillian'],
desc:'Notfall im Paznauntal. Enges Hochgebirgstal.', reason:'Paznauntal ist eine der engsten Talrouten Tirols'},
{id:49, name:'Zams → Reutte', start:'zams_c5', end:'reutte', difficulty:'medium', region:'tirol',
waypoints:['zams_c5','imst','reutte'],
desc:'Einsatz im Außerfern. Route über Imst und Fernpass.', reason:'Außerfern-Zugang'},
{id:50, name:'Zams → Innsbruck', start:'zams_c5', end:'innsbruck_c1', difficulty:'easy', region:'tirol',
waypoints:['zams_c5','imst','telfs','innsbruck_c1'],
desc:'Patiententransport nach Innsbruck. Inntal-Flug.', reason:'Inntal-Standardroute'},
// Von C4 Kitzbühel
{id:51, name:'Kitzbühel → Mayrhofen', start:'kitzbuehel_c4', end:'mayrhofen', difficulty:'medium', region:'tirol',
waypoints:['kitzbuehel_c4','woergl','jenbach','zell_ziller','mayrhofen'],
desc:'Bergunfall bei Mayrhofen. Route über das Inntal ins Zillertal.', reason:'Zillertal-Zugang über Jenbach'},
{id:52, name:'Kitzbühel → Kufstein', start:'kitzbuehel_c4', end:'kufstein', difficulty:'easy', region:'tirol',
waypoints:['kitzbuehel_c4','woergl','kufstein'],
desc:'Verkehrsunfall bei Kufstein. Kurzer Inntal-Flug.', reason:'Direkter Inntal-Flug'},
{id:53, name:'Kitzbühel → St. Johann', start:'kitzbuehel_c4', end:'st_johann', difficulty:'easy', region:'tirol',
waypoints:['kitzbuehel_c4','st_johann'],
desc:'Notfall in St. Johann. Kürzeste Route.', reason:'Nachbarort'},
{id:54, name:'Kitzbühel → Hintertux', start:'kitzbuehel_c4', end:'hintertux', difficulty:'hard', region:'tirol',
waypoints:['kitzbuehel_c4','woergl','jenbach','zell_ziller','mayrhofen','hintertux'],
desc:'Gletscherunfall Hintertux. Lange Route durchs gesamte Zillertal.', reason:'Tuxertal nur über Zillertal erreichbar'},
// Von C7 Lienz
{id:55, name:'Lienz → Großglockner', start:'lienz_c7', end:'bergunfall_glockner', difficulty:'hard', region:'tirol',
waypoints:['lienz_c7','matrei','kals','bergunfall_glockner'],
desc:'Kletterunfall am Großglockner. Route durch das Iseltal.', reason:'Kürzeste Route zum Glockner von Süden'},
{id:56, name:'Lienz → Sillian', start:'lienz_c7', end:'sillian', difficulty:'easy', region:'tirol',
waypoints:['lienz_c7','sillian'],
desc:'Einsatz bei Sillian nahe der italienischen Grenze. Kurzer Drautal-Flug.', reason:'Drautal-Route Richtung Osten'},
{id:57, name:'Lienz → Matrei', start:'lienz_c7', end:'matrei', difficulty:'easy', region:'tirol',
waypoints:['lienz_c7','matrei'],
desc:'Notfall in Matrei. Direkter Flug ins Iseltal.', reason:'Iseltal-Route'},
{id:58, name:'Lienz → Kals (Bergrettung)', start:'lienz_c7', end:'kals', difficulty:'medium', region:'tirol',
waypoints:['lienz_c7','matrei','kals'],
desc:'Bergrettung in Kals am Großglockner. Route über Matrei.', reason:'Kalser Tal nur über Matrei erreichbar'},
{id:59, name:'Lienz → Kitzbühel (Transport)', start:'lienz_c7', end:'kitzbuehel_ort', difficulty:'hard', region:'tirol',
waypoints:['lienz_c7','matrei','st_johann','kitzbuehel_ort'],
desc:'Patiententransport nach Kitzbühel. Route über den Felbertauern.', reason:'Felbertauernroute verbindet Ost- und Nordtirol'},
{id:60, name:'Lienz → Innsbruck (Intensivtransport)', start:'lienz_c7', end:'innsbruck_c1', difficulty:'hard', region:'tirol',
waypoints:['lienz_c7','matrei','st_johann','kitzbuehel_ort','woergl','jenbach','schwaz','hall','innsbruck_c1'],
desc:'Intensivtransport nach Innsbruck. Die längste Route — quer durch ganz Tirol.', reason:'Osttirol hat kein eigenes Traumazentrum'},
];
// Farben nach Schwierigkeit
var COLORS = {easy:'#5a8a5e', medium:'#c4a35a', hard:'#c85c4a'};
var routeLayers = [];
function buildSelect(region) {
var sel = document.getElementById('route-select');
sel.innerHTML = '';
var filtered = region === 'all' ? ROUTES : ROUTES.filter(function(r){return r.region===region;});
filtered.forEach(function(r) {
var opt = document.createElement('option');
opt.value = r.id;
var badge = r.difficulty === 'easy' ? '🟢' : r.difficulty === 'medium' ? '🟡' : '🔴';
opt.textContent = badge + ' #' + r.id + ' ' + r.name;
sel.appendChild(opt);
});
}
function showRoute() {
var id = parseInt(document.getElementById('route-select').value);
var r = ROUTES.find(function(x){return x.id===id;});
if (!r) return;
// Clear old
routeLayers.forEach(function(l){map.removeLayer(l);});
routeLayers = [];
// Build points
var latlngs = r.waypoints.map(function(wpId){
var wp = WP[wpId];
return wp ? [wp.lat, wp.lon] : null;
}).filter(Boolean);
// Fit map
if (latlngs.length > 0) map.fitBounds(L.latLngBounds(latlngs).pad(0.15));
// Draw line
var line = L.polyline(latlngs, {color:COLORS[r.difficulty], weight:4, opacity:0.8, dashArray:r.difficulty==='hard'?'8,6':null}).addTo(map);
routeLayers.push(line);
// Waypoint markers
r.waypoints.forEach(function(wpId, i) {
var wp = WP[wpId];
if (!wp) return;
var isStart = i === 0;
var isEnd = i === r.waypoints.length - 1;
var cls = isStart ? 'wp-marker start' : isEnd ? 'wp-marker end' : 'wp-marker';
var label = isStart ? '🚁' : isEnd ? '🎯' : (i);
var size = (isStart||isEnd) ? 28 : 22;
var m = L.marker([wp.lat, wp.lon], {
icon: L.divIcon({className:'', html:'<div class="'+cls+'">'+label+'</div>', iconSize:[size,size], iconAnchor:[size/2,size/2]}),
zIndexOffset: (isStart||isEnd) ? 1000 : 500
}).addTo(map).bindPopup('<strong>'+wp.name+'</strong><br><span style="font-size:10px;color:#8a8a8a">Waypoint '+(i+1)+' von '+r.waypoints.length+'</span>');
routeLayers.push(m);
// Label
var lbl = L.marker([wp.lat, wp.lon], {
icon: L.divIcon({className:'wp-label', html:wp.name, iconSize:[120,14], iconAnchor:[-14,7]}),
interactive:false
}).addTo(map);
routeLayers.push(lbl);
});
// Description
var diffLabel = r.difficulty==='easy'?'Einfach':r.difficulty==='medium'?'Mittel':'Schwierig';
var diffBadge = '<span class="badge badge-'+r.difficulty+'">'+diffLabel+'</span>';
document.getElementById('route-desc').innerHTML =
'<h3>#'+r.id+' '+r.name+' '+diffBadge+'</h3>'
+'<p>'+r.desc+'</p>'
+'<div class="meta">Grund für diese Route: <strong>'+r.reason+'</strong> · '+r.waypoints.length+' Wegpunkte</div>';
}
function filterRoutes() {
var region = document.getElementById('region-select').value;
buildSelect(region);
showRoute();
}
buildSelect('all');
showRoute();
</script>
</body>
</html>