Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Europe Night Trains Explorer</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet"> | |
| <script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""> | |
| <script src="https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.globe.min.js"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| :root { | |
| --primary: #3b82f6; | |
| --primary-light: #93c5fd; | |
| --primary-dark: #1d4ed8; | |
| --dark: #0f172a; | |
| --darker: #020617; | |
| --light: #f8fafc; | |
| --muted: #94a3b8; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--darker); | |
| color: var(--light); | |
| } | |
| .route-card { | |
| transition: all 0.3s ease; | |
| border-left: 3px solid var(--primary); | |
| } | |
| .route-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); | |
| } | |
| .route-card.active { | |
| background-color: rgba(59, 130, 246, 0.1); | |
| } | |
| .map-container { | |
| height: 100%; | |
| border-radius: 0.75rem; | |
| overflow: hidden; | |
| box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); | |
| } | |
| .leaflet-container { | |
| background-color: var(--dark) ; | |
| } | |
| .leaflet-popup-content-wrapper { | |
| background-color: var(--dark) ; | |
| color: var(--light) ; | |
| border-radius: 0.5rem ; | |
| } | |
| .leaflet-popup-tip { | |
| background-color: var(--dark) ; | |
| } | |
| .leaflet-control-attribution { | |
| background-color: rgba(15, 23, 42, 0.7) ; | |
| color: var(--muted) ; | |
| } | |
| .vanta-bg { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: -1; | |
| opacity: 0.15; | |
| } | |
| .gradient-text { | |
| background: linear-gradient(90deg, var(--primary), var(--primary-light)); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| color: transparent; | |
| } | |
| .scrollbar-hide::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .scrollbar-hide { | |
| -ms-overflow-style: none; | |
| scrollbar-width: none; | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen"> | |
| <div id="vanta-bg" class="vanta-bg"></div> | |
| <div class="container mx-auto px-4 py-8"> | |
| <header class="mb-12 text-center" data-aos="fade-down"> | |
| <h1 class="text-4xl md:text-5xl font-bold mb-2 gradient-text">Europe Night Trains</h1> | |
| <p class="text-lg text-gray-400 max-w-2xl mx-auto">Explore the network of overnight train routes across Europe (10+ hour journeys)</p> | |
| </header> | |
| <div class="flex flex-col lg:flex-row gap-8" data-aos="fade-up"> | |
| <!-- Sidebar --> | |
| <div class="w-full lg:w-1/3 bg-slate-900/50 backdrop-blur-md rounded-xl border border-slate-800/50 overflow-hidden"> | |
| <div class="p-6 border-b border-slate-800/50"> | |
| <div class="flex items-center gap-4"> | |
| <div class="flex-1"> | |
| <h2 class="text-xl font-semibold text-white">Routes</h2> | |
| <p class="text-sm text-gray-400">Click to highlight on map</p> | |
| </div> | |
| <div class="flex gap-2"> | |
| <button id="showAll" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition flex items-center gap-1"> | |
| <i data-feather="eye" class="w-4 h-4"></i> All | |
| </button> | |
| <button id="hideAll" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition flex items-center gap-1"> | |
| <i data-feather="eye-off" class="w-4 h-4"></i> None | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="overflow-y-auto h-[500px] lg:h-[calc(100vh-250px)] scrollbar-hide" id="routesList"> | |
| <!-- Routes will be inserted here by JavaScript --> | |
| </div> | |
| </div> | |
| <!-- Map --> | |
| <div class="w-full lg:w-2/3"> | |
| <div class="map-container h-[500px] lg:h-[calc(100vh-250px)]"> | |
| <div id="map" class="h-full w-full"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer class="mt-12 text-center text-gray-500 text-sm" data-aos="fade-up"> | |
| <p>Data sourced from various European rail operators • Last updated: {current_date}</p> | |
| <p class="mt-2">Made with <i data-feather="heart" class="w-4 h-4 inline text-red-500"></i> for train enthusiasts</p> | |
| </footer> | |
| </div> | |
| <script> | |
| // Initialize animations | |
| AOS.init({ | |
| duration: 800, | |
| once: true | |
| }); | |
| // Initialize Vanta.js background | |
| VANTA.GLOBE({ | |
| el: "#vanta-bg", | |
| mouseControls: true, | |
| touchControls: true, | |
| gyroControls: false, | |
| minHeight: 200.00, | |
| minWidth: 200.00, | |
| scale: 1.00, | |
| scaleMobile: 1.00, | |
| color: "#3b82f6", | |
| backgroundColor: "#020617", | |
| size: 0.8 | |
| }); | |
| // City coordinates | |
| 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 data | |
| const ROUTES = [ | |
| { from: "Amsterdam", to: "Vienna", op: "Nightjet", duration: "14h 30m" }, | |
| { from: "Amsterdam", to: "Innsbruck", op: "Nightjet", duration: "12h 45m" }, | |
| { from: "Amsterdam", to: "Zürich", op: "Nightjet", duration: "11h 50m" }, | |
| { from: "Brussels", to: "Berlin", op: "European Sleeper", duration: "11h 30m" }, | |
| { from: "Brussels", to: "Prague", op: "European Sleeper", duration: "14h 20m" }, | |
| { from: "Paris", to: "Berlin", op: "Nightjet", duration: "13h 15m" }, | |
| { from: "Paris", to: "Nice", op: "Intercités de Nuit", duration: "10h 45m" }, | |
| { from: "Paris", to: "Latour-de-Carol/Enveitg", op: "Intercités de Nuit", duration: "11h 10m" }, | |
| { from: "Paris", to: "Briançon", op: "Intercités de Nuit", duration: "10h 30m" }, | |
| { from: "Stockholm", to: "Berlin", op: "SJ EuroNight", duration: "16h 20m" }, | |
| { from: "Stockholm", to: "Narvik", op: "Vy/SJ", duration: "18h 15m" }, | |
| { from: "Helsinki", to: "Rovaniemi", op: "VR", duration: "10h 30m" }, | |
| { from: "Helsinki", to: "Kolari", op: "VR", duration: "12h 45m" }, | |
| { from: "Hamburg", to: "Vienna", op: "Nightjet", duration: "11h 55m" }, | |
| { from: "Hamburg", to: "Innsbruck", op: "Nightjet", duration: "10h 45m" }, | |
| { from: "Vienna", to: "Rome", op: "Nightjet", duration: "13h 40m" }, | |
| { from: "Munich", to: "Rome", op: "Nightjet", duration: "11h 30m" }, | |
| { from: "Zürich", to: "Prague", op: "EN Canopus", duration: "12h 15m" }, | |
| { from: "Zürich", to: "Zagreb", op: "EN Lisinski", duration: "14h 20m" }, | |
| { from: "Budapest", to: "Bucharest", op: "EN Dacia", duration: "16h 10m" }, | |
| { from: "Vienna", to: "Bucharest", op: "EN Dacia", duration: "17h 30m" }, | |
| { from: "London", to: "Inverness", op: "Caledonian Sleeper", duration: "11h 45m" }, | |
| { from: "London", to: "Fort William", op: "Caledonian Sleeper", duration: "13h 30m" } | |
| ]; | |
| // Initialize map | |
| const map = L.map('map', { | |
| zoomControl: true, | |
| scrollWheelZoom: true, | |
| preferCanvas: true | |
| }).setView([51.2, 10], 5); | |
| // Add tile layer | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| maxZoom: 10, | |
| attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' | |
| }).addTo(map); | |
| // Add city markers | |
| const cityMarkers = {}; | |
| Object.entries(CITIES).forEach(([name, [lat, lng]]) => { | |
| const marker = L.circleMarker([lat, lng], { | |
| radius: 6, | |
| weight: 1, | |
| color: '#3b82f6', | |
| fillColor: '#3b82f6', | |
| fillOpacity: 0.9 | |
| }).bindTooltip(name, { | |
| permanent: false, | |
| direction: 'top', | |
| offset: [0, -6], | |
| className: 'custom-tooltip' | |
| }).addTo(map); | |
| cityMarkers[name] = marker; | |
| }); | |
| // Route layers registry | |
| const routeRegistry = []; | |
| const makePopup = (r) => ` | |
| <div class="p-2"> | |
| <h3 class="font-bold text-blue-400">${r.from} → ${r.to}</h3> | |
| <p class="text-sm text-gray-300">Operator: ${r.op}</p> | |
| <p class="text-sm text-gray-300">Duration: ${r.duration}</p> | |
| </div> | |
| `; | |
| // Create route polylines | |
| ROUTES.forEach((route) => { | |
| const fromCoords = CITIES[route.from]; | |
| const toCoords = CITIES[route.to]; | |
| if (!fromCoords || !toCoords) return; | |
| const polyline = L.polyline([fromCoords, toCoords], { | |
| weight: 4, | |
| opacity: 0.9, | |
| color: '#3b82f6', | |
| lineCap: 'round', | |
| dashArray: route.op.includes('Nightjet') ? null : '10, 10' | |
| }).bindPopup(makePopup(route)); | |
| routeRegistry.push({ | |
| layer: polyline, | |
| data: route, | |
| element: null | |
| }); | |
| polyline.addTo(map); | |
| }); | |
| // Fit map to all visible layers | |
| function fitAllVisible() { | |
| const visibleLayers = routeRegistry.filter(o => map.hasLayer(o.layer)); | |
| if (visibleLayers.length === 0) { | |
| map.setView([51.2, 10], 4); | |
| return; | |
| } | |
| const group = L.featureGroup(visibleLayers.map(o => o.layer)); | |
| map.fitBounds(group.getBounds().pad(0.15)); | |
| } | |
| // Create route list in sidebar | |
| const routesList = document.getElementById('routesList'); | |
| function createRouteElement(entry) { | |
| const { data, layer } = entry; | |
| const element = document.createElement('div'); | |
| element.className = 'route-card p-4 border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer transition'; | |
| element.innerHTML = ` | |
| <div class="flex items-start gap-3"> | |
| <div class="mt-1 w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div> | |
| <div class="flex-1"> | |
| <h3 class="font-medium text-white">${data.from} → ${data.to}</h3> | |
| <div class="flex items-center justify-between mt-1"> | |
| <span class="text-sm text-gray-400">${data.op}</span> | |
| <span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-1 rounded-full">${data.duration}</span> | |
| </div> | |
| </div> | |
| <i data-feather="chevron-right" class="text-gray-500 w-5 h-5"></i> | |
| </div> | |
| `; | |
| element.addEventListener('click', () => { | |
| // Toggle route visibility | |
| if (map.hasLayer(layer)) { | |
| layer.closePopup(); | |
| map.removeLayer(layer); | |
| element.classList.remove('active'); | |
| } else { | |
| layer.addTo(map); | |
| element.classList.add('active'); | |
| const bounds = L.latLngBounds([CITIES[data.from], CITIES[data.to]]).pad(0.25); | |
| map.fitBounds(bounds); | |
| layer.openPopup(); | |
| } | |
| }); | |
| routesList.appendChild(element); | |
| entry.element = element; | |
| } | |
| // Initialize route list | |
| routeRegistry.forEach(createRouteElement); | |
| // Button event handlers | |
| document.getElementById('showAll').addEventListener('click', () => { | |
| routeRegistry.forEach(entry => { | |
| if (!map.hasLayer(entry.layer)) { | |
| entry.layer.addTo(map); | |
| } | |
| if (entry.element) { | |
| entry.element.classList.add('active'); | |
| } | |
| }); | |
| fitAllVisible(); | |
| }); | |
| document.getElementById('hideAll').addEventListener('click', () => { | |
| routeRegistry.forEach(entry => { | |
| if (map.hasLayer(entry.layer)) { | |
| entry.layer.closePopup(); | |
| map.removeLayer(entry.layer); | |
| } | |
| if (entry.element) { | |
| entry.element.classList.remove('active'); | |
| } | |
| }); | |
| }); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| const key = e.key.toLowerCase(); | |
| if (key === 'a') document.getElementById('showAll').click(); | |
| if (key === 'n') document.getElementById('hideAll').click(); | |
| if (key === 'f') fitAllVisible(); | |
| }); | |
| // Handle responsive resizing | |
| new ResizeObserver(() => { | |
| map.invalidateSize(); | |
| }).observe(document.getElementById('map')); | |
| // Initialize Feather icons | |
| feather.replace(); | |
| // Set current date in footer | |
| const currentDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); | |
| document.querySelector('footer p:first-child').textContent = | |
| document.querySelector('footer p:first-child').textContent.replace('{current_date}', currentDate); | |
| </script> | |
| </body> | |
| </html> | |