Spaces:
Running
Running
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Europe Night Trains (10h+)</title> | |
| <link | |
| rel="stylesheet" | |
| href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" | |
| integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" | |
| crossorigin="" | |
| /> | |
| <style> | |
| :root { --bg:#0b0f14; --panel:#10161f; --muted:#9fb0c3; --accent:#56b0ff; } | |
| * { box-sizing: border-box; } | |
| html, body { height:100%; margin:0; background:var(--bg); color:#e6edf3; font:14px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji","Segoe UI Emoji"; } | |
| /* Ensure the map actually gets a concrete height via the grid parent */ | |
| #app { display:grid; grid-template-columns: 320px 1fr; grid-template-rows: 56px 1fr; height:100vh; min-height:0; } | |
| header { grid-column:1 / span 2; display:flex; align-items:center; gap:.75rem; padding:12px 16px; background:var(--panel); border-bottom:1px solid #1b2532; } | |
| header h1 { font-size:16px; margin:0; font-weight:600; letter-spacing:.2px; } | |
| header .sub { color:var(--muted); font-weight:400; } | |
| #sidebar { background:var(--panel); border-right:1px solid #1b2532; overflow:auto; min-height:0; } | |
| #map { width:100%; height:100%; min-height:0; } | |
| .group { padding:10px 12px; border-bottom:1px solid #192330; } | |
| .group h3 { margin:0 0 8px 0; font-size:12px; color:#87a3bd; text-transform:uppercase; letter-spacing:.12em; } | |
| .route { display:flex; align-items:center; gap:.5rem; padding:8px; border-radius:10px; cursor:pointer; transition:opacity .15s ease, background .15s ease; } | |
| .route:hover { background:#0f1722; } | |
| .route[aria-pressed="false"] { opacity:0.45; } | |
| .dot { width:10px; height:10px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 2px rgba(86,176,255,.2); flex:0 0 10px; } | |
| .citypair { display:flex; flex-direction:column; line-height:1.15; } | |
| .citypair strong { font-size:13px; } | |
| .meta { font-size:12px; color:var(--muted); } | |
| .controls { display:flex; gap:.5rem; padding:8px 12px; position:sticky; top:0; background:linear-gradient(var(--panel), var(--panel)); border-bottom:1px solid #1b2532; z-index:5; } | |
| .btn { padding:6px 10px; border-radius:10px; background:#0f1722; border:1px solid #1b2532; color:#cfe3f6; cursor:pointer; } | |
| .btn:hover { background:#122033; } | |
| .legend { position:absolute; right:12px; bottom:12px; background:var(--panel); border:1px solid #1b2532; padding:8px 10px; border-radius:12px; color:#cfe3f6; font-size:12px; } | |
| .legend .swatch { display:inline-block; width:12px; height:3px; background:var(--accent); margin:0 6px 0 0; vertical-align:middle; border-radius:2px; } | |
| .leaflet-container { background:#0a0e13; } | |
| a, .leaflet-popup-content a { color:#9bd1ff; } | |
| @media (max-width: 880px) { | |
| #app { grid-template-columns: 1fr; grid-template-rows: 56px 220px 1fr; } | |
| #sidebar { grid-row: 2; border-right: none; border-bottom:1px solid #1b2532; } | |
| #map { grid-row: 3; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <header> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18" stroke="#56b0ff" stroke-width="1.6" stroke-linecap="round"/></svg> | |
| <h1>Europe Night Trains <span class="sub">(10h+ start → terminus)</span></h1> | |
| </header> | |
| <aside id="sidebar" aria-label="Routes sidebar"> | |
| <div class="controls"> | |
| <button class="btn" id="showAll" type="button" title="Show all routes (S)">Show all</button> | |
| <button class="btn" id="hideAll" type="button" title="Hide all routes (H)">Hide all</button> | |
| <button class="btn" id="fitAll" type="button" title="Fit to Europe (F)">Fit to Europe</button> | |
| </div> | |
| <div class="group" id="list"></div> | |
| </aside> | |
| <main id="map" aria-label="Map of Europe with night train routes"></main> | |
| </div> | |
| <div class="legend" role="note"><span class="swatch"></span> Night train route (start → terminus)</div> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> | |
| <script> | |
| // --- City coordinates (approximate) --- | |
| const CITIES = { | |
| Amsterdam:[52.379, 4.9], | |
| Vienna:[48.208, 16.373], | |
| Innsbruck:[47.269, 11.404], | |
| Zürich:[47.376, 8.541], | |
| Brussels:[50.847, 4.357], | |
| Berlin:[52.52, 13.405], | |
| Prague:[50.075, 14.437], | |
| Paris:[48.857, 2.351], | |
| Nice:[43.703, 7.266], | |
| "Latour-de-Carol/Enveitg":[42.453, 1.918], | |
| Briançon:[44.897, 6.643], | |
| Stockholm:[59.33, 18.06], | |
| Narvik:[68.438, 17.427], | |
| Helsinki:[60.171, 24.941], | |
| Rovaniemi:[66.503, 25.728], | |
| Kolari:[67.35, 23.78], | |
| Hamburg:[53.55, 9.993], | |
| Rome:[41.902, 12.496], | |
| Munich:[48.137, 11.575], | |
| Zagreb:[45.815, 15.981], | |
| Budapest:[47.497, 19.04], | |
| Bucharest:[44.426, 26.102], | |
| London:[51.507, -0.128], | |
| Inverness:[57.477, -4.224], | |
| "Fort William":[56.82, -5.105] | |
| }; | |
| // --- Routes ≥10h (start → terminus) --- | |
| const ROUTES = [ | |
| {from:"Amsterdam", to:"Vienna", op:"Nightjet"}, | |
| {from:"Amsterdam", to:"Innsbruck", op:"Nightjet"}, | |
| {from:"Amsterdam", to:"Zürich", op:"Nightjet"}, | |
| {from:"Brussels", to:"Berlin", op:"European Sleeper"}, | |
| {from:"Brussels", to:"Prague", op:"European Sleeper"}, | |
| {from:"Paris", to:"Berlin", op:"Nightjet"}, | |
| {from:"Paris", to:"Nice", op:"Intercités de Nuit"}, | |
| {from:"Paris", to:"Latour-de-Carol/Enveitg", op:"Intercités de Nuit"}, | |
| {from:"Paris", to:"Briançon", op:"Intercités de Nuit"}, | |
| {from:"Stockholm", to:"Berlin", op:"SJ EuroNight"}, | |
| {from:"Stockholm", to:"Narvik", op:"Vy/SJ"}, | |
| {from:"Helsinki", to:"Rovaniemi", op:"VR"}, | |
| {from:"Helsinki", to:"Kolari", op:"VR"}, | |
| {from:"Hamburg", to:"Vienna", op:"Nightjet"}, | |
| {from:"Hamburg", to:"Innsbruck", op:"Nightjet"}, | |
| {from:"Vienna", to:"Rome", op:"Nightjet"}, | |
| {from:"Munich", to:"Rome", op:"Nightjet"}, | |
| {from:"Zürich", to:"Prague", op:"EN Canopus"}, | |
| {from:"Zürich", to:"Zagreb", op:"EN Lisinski"}, | |
| {from:"Budapest", to:"Bucharest", op:"EN Dacia"}, | |
| {from:"Vienna", to:"Bucharest", op:"EN Dacia"}, | |
| {from:"London", to:"Inverness", op:"Caledonian Sleeper"}, | |
| {from:"London", to:"Fort William", op:"Caledonian Sleeper"} | |
| ]; | |
| // --- Map init --- | |
| const map = L.map('map', { zoomControl: true, scrollWheelZoom: true, preferCanvas:true }).setView([51.2, 10], 5); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| maxZoom: 10, | |
| attribution: '© OpenStreetMap contributors' | |
| }).addTo(map); | |
| // --- Draw cities --- | |
| const cityMarkers = {}; | |
| Object.entries(CITIES).forEach(([name, [lat, lng]]) => { | |
| const m = L.circleMarker([lat, lng], { radius:5, weight:1, color:'#56b0ff', fillColor:'#56b0ff', fillOpacity:0.85 }) | |
| .bindTooltip(name, { permanent:false, direction:'top', offset:[0,-2]}) | |
| .addTo(map); | |
| cityMarkers[name] = m; | |
| }); | |
| // --- Route layers + rows registry --- | |
| const registry = []; // { layer, data, row } | |
| const makePopup = (r) => `<strong>${r.from} → ${r.to}</strong><br><span style="color:#9fb0c3">${r.op}</span>`; | |
| ROUTES.forEach((r) => { | |
| const a = CITIES[r.from], b = CITIES[r.to]; | |
| if(!a || !b) return; | |
| const layer = L.polyline([a, b], { weight:3, opacity:0.9, color:'#56b0ff', lineCap:'round' }) | |
| .bindPopup(makePopup(r)) | |
| .addTo(map); | |
| registry.push({ layer, data: r, row: null }); | |
| }); | |
| function fitAllVisible(){ | |
| const visible = registry.filter(o => map.hasLayer(o.layer)); | |
| if(visible.length === 0){ map.setView([51.2,10], 4); return; } | |
| const group = L.featureGroup(visible.map(o => o.layer)); | |
| map.fitBounds(group.getBounds().pad(0.15)); | |
| } | |
| fitAllVisible(); | |
| // --- Sidebar list --- | |
| const list = document.getElementById('list'); | |
| function addRouteRow(entry){ | |
| const { data, layer } = entry; | |
| const row = document.createElement('button'); | |
| row.type = 'button'; | |
| row.className = 'route'; | |
| row.setAttribute('aria-pressed', 'true'); | |
| row.innerHTML = `<span class="dot" aria-hidden="true"></span><div class="citypair"><strong>${data.from} → ${data.to}</strong><span class="meta">${data.op}</span></div>`; | |
| row.addEventListener('click', () => { | |
| const isVisible = map.hasLayer(layer); | |
| if(isVisible){ | |
| layer.closePopup(); | |
| map.removeLayer(layer); | |
| row.setAttribute('aria-pressed','false'); | |
| }else{ | |
| layer.addTo(map); | |
| row.setAttribute('aria-pressed','true'); | |
| const bounds = L.latLngBounds([CITIES[data.from], CITIES[data.to]]).pad(0.25); | |
| map.fitBounds(bounds); | |
| layer.openPopup(); | |
| } | |
| }); | |
| list.appendChild(row); | |
| entry.row = row; | |
| } | |
| registry.forEach(addRouteRow); | |
| // --- Buttons --- | |
| document.getElementById('showAll').onclick = () => { | |
| registry.forEach(o => { if(!map.hasLayer(o.layer)) o.layer.addTo(map); o.row?.setAttribute('aria-pressed','true'); }); | |
| fitAllVisible(); | |
| }; | |
| document.getElementById('hideAll').onclick = () => { | |
| registry.forEach(o => { if(map.hasLayer(o.layer)) { o.layer.closePopup(); map.removeLayer(o.layer); } o.row?.setAttribute('aria-pressed','false'); }); | |
| }; | |
| document.getElementById('fitAll').onclick = fitAllVisible; | |
| // --- Keyboard UX --- | |
| document.addEventListener('keydown', (e) => { | |
| const key = e.key.toLowerCase(); | |
| if(key === 'f') fitAllVisible(); | |
| if(key === 'h') document.getElementById('hideAll').click(); | |
| if(key === 's') document.getElementById('showAll').click(); | |
| }); | |
| // Resize observer to keep map sized correctly if layout changes | |
| new ResizeObserver(() => { map.invalidateSize(); }).observe(document.getElementById('app')); | |
| </script> | |
| </body> | |
| </html> | |
| make it fancy and work |