night-trains-in-europ / index.html
ArthurZ's picture
ArthurZ HF Staff
<!DOCTYPE html>
32f3fa8 verified
<!DOCTYPE html>
<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) !important;
}
.leaflet-popup-content-wrapper {
background-color: var(--dark) !important;
color: var(--light) !important;
border-radius: 0.5rem !important;
}
.leaflet-popup-tip {
background-color: var(--dark) !important;
}
.leaflet-control-attribution {
background-color: rgba(15, 23, 42, 0.7) !important;
color: var(--muted) !important;
}
.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],
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: '&copy; <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>