Spaces:
Running
<!DOCTYPE html>
Browse files<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/[email protected]/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/[email protected]/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 - Initial Deployment
- README.md +7 -5
- index.html +393 -18
- prompts.txt +217 -0
|
@@ -1,10 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
+
title: night-trains-in-europ
|
| 3 |
+
emoji: 🐳
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite
|
| 10 |
---
|
| 11 |
|
| 12 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
@@ -1,19 +1,394 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Europe Night Trains Explorer</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link href="https://unpkg.com/[email protected]/dist/aos.css" rel="stylesheet">
|
| 9 |
+
<script src="https://unpkg.com/[email protected]/dist/aos.js"></script>
|
| 10 |
+
<script src="https://unpkg.com/[email protected]/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
| 11 |
+
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.globe.min.js"></script>
|
| 13 |
+
<script src="https://unpkg.com/feather-icons"></script>
|
| 14 |
+
<style>
|
| 15 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 16 |
+
|
| 17 |
+
:root {
|
| 18 |
+
--primary: #3b82f6;
|
| 19 |
+
--primary-light: #93c5fd;
|
| 20 |
+
--primary-dark: #1d4ed8;
|
| 21 |
+
--dark: #0f172a;
|
| 22 |
+
--darker: #020617;
|
| 23 |
+
--light: #f8fafc;
|
| 24 |
+
--muted: #94a3b8;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
body {
|
| 28 |
+
font-family: 'Inter', sans-serif;
|
| 29 |
+
background-color: var(--darker);
|
| 30 |
+
color: var(--light);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.route-card {
|
| 34 |
+
transition: all 0.3s ease;
|
| 35 |
+
border-left: 3px solid var(--primary);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.route-card:hover {
|
| 39 |
+
transform: translateY(-2px);
|
| 40 |
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.route-card.active {
|
| 44 |
+
background-color: rgba(59, 130, 246, 0.1);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.map-container {
|
| 48 |
+
height: 100%;
|
| 49 |
+
border-radius: 0.75rem;
|
| 50 |
+
overflow: hidden;
|
| 51 |
+
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.leaflet-container {
|
| 55 |
+
background-color: var(--dark) !important;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.leaflet-popup-content-wrapper {
|
| 59 |
+
background-color: var(--dark) !important;
|
| 60 |
+
color: var(--light) !important;
|
| 61 |
+
border-radius: 0.5rem !important;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.leaflet-popup-tip {
|
| 65 |
+
background-color: var(--dark) !important;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.leaflet-control-attribution {
|
| 69 |
+
background-color: rgba(15, 23, 42, 0.7) !important;
|
| 70 |
+
color: var(--muted) !important;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.vanta-bg {
|
| 74 |
+
position: fixed;
|
| 75 |
+
top: 0;
|
| 76 |
+
left: 0;
|
| 77 |
+
width: 100%;
|
| 78 |
+
height: 100%;
|
| 79 |
+
z-index: -1;
|
| 80 |
+
opacity: 0.15;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.gradient-text {
|
| 84 |
+
background: linear-gradient(90deg, var(--primary), var(--primary-light));
|
| 85 |
+
-webkit-background-clip: text;
|
| 86 |
+
background-clip: text;
|
| 87 |
+
color: transparent;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.scrollbar-hide::-webkit-scrollbar {
|
| 91 |
+
display: none;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.scrollbar-hide {
|
| 95 |
+
-ms-overflow-style: none;
|
| 96 |
+
scrollbar-width: none;
|
| 97 |
+
}
|
| 98 |
+
</style>
|
| 99 |
+
</head>
|
| 100 |
+
<body class="min-h-screen">
|
| 101 |
+
<div id="vanta-bg" class="vanta-bg"></div>
|
| 102 |
+
|
| 103 |
+
<div class="container mx-auto px-4 py-8">
|
| 104 |
+
<header class="mb-12 text-center" data-aos="fade-down">
|
| 105 |
+
<h1 class="text-4xl md:text-5xl font-bold mb-2 gradient-text">Europe Night Trains</h1>
|
| 106 |
+
<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>
|
| 107 |
+
</header>
|
| 108 |
+
|
| 109 |
+
<div class="flex flex-col lg:flex-row gap-8" data-aos="fade-up">
|
| 110 |
+
<!-- Sidebar -->
|
| 111 |
+
<div class="w-full lg:w-1/3 bg-slate-900/50 backdrop-blur-md rounded-xl border border-slate-800/50 overflow-hidden">
|
| 112 |
+
<div class="p-6 border-b border-slate-800/50">
|
| 113 |
+
<div class="flex items-center gap-4">
|
| 114 |
+
<div class="flex-1">
|
| 115 |
+
<h2 class="text-xl font-semibold text-white">Routes</h2>
|
| 116 |
+
<p class="text-sm text-gray-400">Click to highlight on map</p>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="flex gap-2">
|
| 119 |
+
<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">
|
| 120 |
+
<i data-feather="eye" class="w-4 h-4"></i> All
|
| 121 |
+
</button>
|
| 122 |
+
<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">
|
| 123 |
+
<i data-feather="eye-off" class="w-4 h-4"></i> None
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div class="overflow-y-auto h-[500px] lg:h-[calc(100vh-250px)] scrollbar-hide" id="routesList">
|
| 130 |
+
<!-- Routes will be inserted here by JavaScript -->
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<!-- Map -->
|
| 135 |
+
<div class="w-full lg:w-2/3">
|
| 136 |
+
<div class="map-container h-[500px] lg:h-[calc(100vh-250px)]">
|
| 137 |
+
<div id="map" class="h-full w-full"></div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<footer class="mt-12 text-center text-gray-500 text-sm" data-aos="fade-up">
|
| 143 |
+
<p>Data sourced from various European rail operators • Last updated: {current_date}</p>
|
| 144 |
+
<p class="mt-2">Made with <i data-feather="heart" class="w-4 h-4 inline text-red-500"></i> for train enthusiasts</p>
|
| 145 |
+
</footer>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<script>
|
| 149 |
+
// Initialize animations
|
| 150 |
+
AOS.init({
|
| 151 |
+
duration: 800,
|
| 152 |
+
once: true
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
// Initialize Vanta.js background
|
| 156 |
+
VANTA.GLOBE({
|
| 157 |
+
el: "#vanta-bg",
|
| 158 |
+
mouseControls: true,
|
| 159 |
+
touchControls: true,
|
| 160 |
+
gyroControls: false,
|
| 161 |
+
minHeight: 200.00,
|
| 162 |
+
minWidth: 200.00,
|
| 163 |
+
scale: 1.00,
|
| 164 |
+
scaleMobile: 1.00,
|
| 165 |
+
color: "#3b82f6",
|
| 166 |
+
backgroundColor: "#020617",
|
| 167 |
+
size: 0.8
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// City coordinates
|
| 171 |
+
const CITIES = {
|
| 172 |
+
Amsterdam: [52.379, 4.9],
|
| 173 |
+
Vienna: [48.208, 16.373],
|
| 174 |
+
Innsbruck: [47.269, 11.404],
|
| 175 |
+
Zürich: [47.376, 8.541],
|
| 176 |
+
Brussels: [50.847, 4.357],
|
| 177 |
+
Berlin: [52.52, 13.405],
|
| 178 |
+
Prague: [50.075, 14.437],
|
| 179 |
+
Paris: [48.857, 2.351],
|
| 180 |
+
Nice: [43.703, 7.266],
|
| 181 |
+
"Latour-de-Carol/Enveitg": [42.453, 1.918],
|
| 182 |
+
Briançon: [44.897, 6.643],
|
| 183 |
+
Stockholm: [59.33, 18.06],
|
| 184 |
+
Narvik: [68.438, 17.427],
|
| 185 |
+
Helsinki: [60.171, 24.941],
|
| 186 |
+
Rovaniemi: [66.503, 25.728],
|
| 187 |
+
Kolari: [67.35, 23.78],
|
| 188 |
+
Hamburg: [53.55, 9.993],
|
| 189 |
+
Rome: [41.902, 12.496],
|
| 190 |
+
Munich: [48.137, 11.575],
|
| 191 |
+
Zagreb: [45.815, 15.981],
|
| 192 |
+
Budapest: [47.497, 19.04],
|
| 193 |
+
Bucharest: [44.426, 26.102],
|
| 194 |
+
London: [51.507, -0.128],
|
| 195 |
+
Inverness: [57.477, -4.224],
|
| 196 |
+
"Fort William": [56.82, -5.105]
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
// Routes data
|
| 200 |
+
const ROUTES = [
|
| 201 |
+
{ from: "Amsterdam", to: "Vienna", op: "Nightjet", duration: "14h 30m" },
|
| 202 |
+
{ from: "Amsterdam", to: "Innsbruck", op: "Nightjet", duration: "12h 45m" },
|
| 203 |
+
{ from: "Amsterdam", to: "Zürich", op: "Nightjet", duration: "11h 50m" },
|
| 204 |
+
{ from: "Brussels", to: "Berlin", op: "European Sleeper", duration: "11h 30m" },
|
| 205 |
+
{ from: "Brussels", to: "Prague", op: "European Sleeper", duration: "14h 20m" },
|
| 206 |
+
{ from: "Paris", to: "Berlin", op: "Nightjet", duration: "13h 15m" },
|
| 207 |
+
{ from: "Paris", to: "Nice", op: "Intercités de Nuit", duration: "10h 45m" },
|
| 208 |
+
{ from: "Paris", to: "Latour-de-Carol/Enveitg", op: "Intercités de Nuit", duration: "11h 10m" },
|
| 209 |
+
{ from: "Paris", to: "Briançon", op: "Intercités de Nuit", duration: "10h 30m" },
|
| 210 |
+
{ from: "Stockholm", to: "Berlin", op: "SJ EuroNight", duration: "16h 20m" },
|
| 211 |
+
{ from: "Stockholm", to: "Narvik", op: "Vy/SJ", duration: "18h 15m" },
|
| 212 |
+
{ from: "Helsinki", to: "Rovaniemi", op: "VR", duration: "10h 30m" },
|
| 213 |
+
{ from: "Helsinki", to: "Kolari", op: "VR", duration: "12h 45m" },
|
| 214 |
+
{ from: "Hamburg", to: "Vienna", op: "Nightjet", duration: "11h 55m" },
|
| 215 |
+
{ from: "Hamburg", to: "Innsbruck", op: "Nightjet", duration: "10h 45m" },
|
| 216 |
+
{ from: "Vienna", to: "Rome", op: "Nightjet", duration: "13h 40m" },
|
| 217 |
+
{ from: "Munich", to: "Rome", op: "Nightjet", duration: "11h 30m" },
|
| 218 |
+
{ from: "Zürich", to: "Prague", op: "EN Canopus", duration: "12h 15m" },
|
| 219 |
+
{ from: "Zürich", to: "Zagreb", op: "EN Lisinski", duration: "14h 20m" },
|
| 220 |
+
{ from: "Budapest", to: "Bucharest", op: "EN Dacia", duration: "16h 10m" },
|
| 221 |
+
{ from: "Vienna", to: "Bucharest", op: "EN Dacia", duration: "17h 30m" },
|
| 222 |
+
{ from: "London", to: "Inverness", op: "Caledonian Sleeper", duration: "11h 45m" },
|
| 223 |
+
{ from: "London", to: "Fort William", op: "Caledonian Sleeper", duration: "13h 30m" }
|
| 224 |
+
];
|
| 225 |
+
|
| 226 |
+
// Initialize map
|
| 227 |
+
const map = L.map('map', {
|
| 228 |
+
zoomControl: true,
|
| 229 |
+
scrollWheelZoom: true,
|
| 230 |
+
preferCanvas: true
|
| 231 |
+
}).setView([51.2, 10], 5);
|
| 232 |
+
|
| 233 |
+
// Add tile layer
|
| 234 |
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
| 235 |
+
maxZoom: 10,
|
| 236 |
+
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
| 237 |
+
}).addTo(map);
|
| 238 |
+
|
| 239 |
+
// Add city markers
|
| 240 |
+
const cityMarkers = {};
|
| 241 |
+
Object.entries(CITIES).forEach(([name, [lat, lng]]) => {
|
| 242 |
+
const marker = L.circleMarker([lat, lng], {
|
| 243 |
+
radius: 6,
|
| 244 |
+
weight: 1,
|
| 245 |
+
color: '#3b82f6',
|
| 246 |
+
fillColor: '#3b82f6',
|
| 247 |
+
fillOpacity: 0.9
|
| 248 |
+
}).bindTooltip(name, {
|
| 249 |
+
permanent: false,
|
| 250 |
+
direction: 'top',
|
| 251 |
+
offset: [0, -6],
|
| 252 |
+
className: 'custom-tooltip'
|
| 253 |
+
}).addTo(map);
|
| 254 |
+
|
| 255 |
+
cityMarkers[name] = marker;
|
| 256 |
+
});
|
| 257 |
+
|
| 258 |
+
// Route layers registry
|
| 259 |
+
const routeRegistry = [];
|
| 260 |
+
const makePopup = (r) => `
|
| 261 |
+
<div class="p-2">
|
| 262 |
+
<h3 class="font-bold text-blue-400">${r.from} → ${r.to}</h3>
|
| 263 |
+
<p class="text-sm text-gray-300">Operator: ${r.op}</p>
|
| 264 |
+
<p class="text-sm text-gray-300">Duration: ${r.duration}</p>
|
| 265 |
+
</div>
|
| 266 |
+
`;
|
| 267 |
+
|
| 268 |
+
// Create route polylines
|
| 269 |
+
ROUTES.forEach((route) => {
|
| 270 |
+
const fromCoords = CITIES[route.from];
|
| 271 |
+
const toCoords = CITIES[route.to];
|
| 272 |
+
|
| 273 |
+
if (!fromCoords || !toCoords) return;
|
| 274 |
+
|
| 275 |
+
const polyline = L.polyline([fromCoords, toCoords], {
|
| 276 |
+
weight: 4,
|
| 277 |
+
opacity: 0.9,
|
| 278 |
+
color: '#3b82f6',
|
| 279 |
+
lineCap: 'round',
|
| 280 |
+
dashArray: route.op.includes('Nightjet') ? null : '10, 10'
|
| 281 |
+
}).bindPopup(makePopup(route));
|
| 282 |
+
|
| 283 |
+
routeRegistry.push({
|
| 284 |
+
layer: polyline,
|
| 285 |
+
data: route,
|
| 286 |
+
element: null
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
polyline.addTo(map);
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
// Fit map to all visible layers
|
| 293 |
+
function fitAllVisible() {
|
| 294 |
+
const visibleLayers = routeRegistry.filter(o => map.hasLayer(o.layer));
|
| 295 |
+
if (visibleLayers.length === 0) {
|
| 296 |
+
map.setView([51.2, 10], 4);
|
| 297 |
+
return;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
const group = L.featureGroup(visibleLayers.map(o => o.layer));
|
| 301 |
+
map.fitBounds(group.getBounds().pad(0.15));
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Create route list in sidebar
|
| 305 |
+
const routesList = document.getElementById('routesList');
|
| 306 |
+
|
| 307 |
+
function createRouteElement(entry) {
|
| 308 |
+
const { data, layer } = entry;
|
| 309 |
+
const element = document.createElement('div');
|
| 310 |
+
element.className = 'route-card p-4 border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer transition';
|
| 311 |
+
element.innerHTML = `
|
| 312 |
+
<div class="flex items-start gap-3">
|
| 313 |
+
<div class="mt-1 w-3 h-3 rounded-full bg-blue-500 flex-shrink-0"></div>
|
| 314 |
+
<div class="flex-1">
|
| 315 |
+
<h3 class="font-medium text-white">${data.from} → ${data.to}</h3>
|
| 316 |
+
<div class="flex items-center justify-between mt-1">
|
| 317 |
+
<span class="text-sm text-gray-400">${data.op}</span>
|
| 318 |
+
<span class="text-xs bg-blue-900/50 text-blue-300 px-2 py-1 rounded-full">${data.duration}</span>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
<i data-feather="chevron-right" class="text-gray-500 w-5 h-5"></i>
|
| 322 |
+
</div>
|
| 323 |
+
`;
|
| 324 |
+
|
| 325 |
+
element.addEventListener('click', () => {
|
| 326 |
+
// Toggle route visibility
|
| 327 |
+
if (map.hasLayer(layer)) {
|
| 328 |
+
layer.closePopup();
|
| 329 |
+
map.removeLayer(layer);
|
| 330 |
+
element.classList.remove('active');
|
| 331 |
+
} else {
|
| 332 |
+
layer.addTo(map);
|
| 333 |
+
element.classList.add('active');
|
| 334 |
+
const bounds = L.latLngBounds([CITIES[data.from], CITIES[data.to]]).pad(0.25);
|
| 335 |
+
map.fitBounds(bounds);
|
| 336 |
+
layer.openPopup();
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
routesList.appendChild(element);
|
| 341 |
+
entry.element = element;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// Initialize route list
|
| 345 |
+
routeRegistry.forEach(createRouteElement);
|
| 346 |
+
|
| 347 |
+
// Button event handlers
|
| 348 |
+
document.getElementById('showAll').addEventListener('click', () => {
|
| 349 |
+
routeRegistry.forEach(entry => {
|
| 350 |
+
if (!map.hasLayer(entry.layer)) {
|
| 351 |
+
entry.layer.addTo(map);
|
| 352 |
+
}
|
| 353 |
+
if (entry.element) {
|
| 354 |
+
entry.element.classList.add('active');
|
| 355 |
+
}
|
| 356 |
+
});
|
| 357 |
+
fitAllVisible();
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
document.getElementById('hideAll').addEventListener('click', () => {
|
| 361 |
+
routeRegistry.forEach(entry => {
|
| 362 |
+
if (map.hasLayer(entry.layer)) {
|
| 363 |
+
entry.layer.closePopup();
|
| 364 |
+
map.removeLayer(entry.layer);
|
| 365 |
+
}
|
| 366 |
+
if (entry.element) {
|
| 367 |
+
entry.element.classList.remove('active');
|
| 368 |
+
}
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
|
| 372 |
+
// Keyboard shortcuts
|
| 373 |
+
document.addEventListener('keydown', (e) => {
|
| 374 |
+
const key = e.key.toLowerCase();
|
| 375 |
+
if (key === 'a') document.getElementById('showAll').click();
|
| 376 |
+
if (key === 'n') document.getElementById('hideAll').click();
|
| 377 |
+
if (key === 'f') fitAllVisible();
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
// Handle responsive resizing
|
| 381 |
+
new ResizeObserver(() => {
|
| 382 |
+
map.invalidateSize();
|
| 383 |
+
}).observe(document.getElementById('map'));
|
| 384 |
+
|
| 385 |
+
// Initialize Feather icons
|
| 386 |
+
feather.replace();
|
| 387 |
+
|
| 388 |
+
// Set current date in footer
|
| 389 |
+
const currentDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
| 390 |
+
document.querySelector('footer p:first-child').textContent =
|
| 391 |
+
document.querySelector('footer p:first-child').textContent.replace('{current_date}', currentDate);
|
| 392 |
+
</script>
|
| 393 |
+
</body>
|
| 394 |
</html>
|
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Europe Night Trains (10h+)</title>
|
| 7 |
+
<link
|
| 8 |
+
rel="stylesheet"
|
| 9 |
+
href="https://unpkg.com/[email protected]/dist/leaflet.css"
|
| 10 |
+
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
| 11 |
+
crossorigin=""
|
| 12 |
+
/>
|
| 13 |
+
<style>
|
| 14 |
+
:root { --bg:#0b0f14; --panel:#10161f; --muted:#9fb0c3; --accent:#56b0ff; }
|
| 15 |
+
* { box-sizing: border-box; }
|
| 16 |
+
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"; }
|
| 17 |
+
/* Ensure the map actually gets a concrete height via the grid parent */
|
| 18 |
+
#app { display:grid; grid-template-columns: 320px 1fr; grid-template-rows: 56px 1fr; height:100vh; min-height:0; }
|
| 19 |
+
header { grid-column:1 / span 2; display:flex; align-items:center; gap:.75rem; padding:12px 16px; background:var(--panel); border-bottom:1px solid #1b2532; }
|
| 20 |
+
header h1 { font-size:16px; margin:0; font-weight:600; letter-spacing:.2px; }
|
| 21 |
+
header .sub { color:var(--muted); font-weight:400; }
|
| 22 |
+
#sidebar { background:var(--panel); border-right:1px solid #1b2532; overflow:auto; min-height:0; }
|
| 23 |
+
#map { width:100%; height:100%; min-height:0; }
|
| 24 |
+
.group { padding:10px 12px; border-bottom:1px solid #192330; }
|
| 25 |
+
.group h3 { margin:0 0 8px 0; font-size:12px; color:#87a3bd; text-transform:uppercase; letter-spacing:.12em; }
|
| 26 |
+
.route { display:flex; align-items:center; gap:.5rem; padding:8px; border-radius:10px; cursor:pointer; transition:opacity .15s ease, background .15s ease; }
|
| 27 |
+
.route:hover { background:#0f1722; }
|
| 28 |
+
.route[aria-pressed="false"] { opacity:0.45; }
|
| 29 |
+
.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; }
|
| 30 |
+
.citypair { display:flex; flex-direction:column; line-height:1.15; }
|
| 31 |
+
.citypair strong { font-size:13px; }
|
| 32 |
+
.meta { font-size:12px; color:var(--muted); }
|
| 33 |
+
.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; }
|
| 34 |
+
.btn { padding:6px 10px; border-radius:10px; background:#0f1722; border:1px solid #1b2532; color:#cfe3f6; cursor:pointer; }
|
| 35 |
+
.btn:hover { background:#122033; }
|
| 36 |
+
.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; }
|
| 37 |
+
.legend .swatch { display:inline-block; width:12px; height:3px; background:var(--accent); margin:0 6px 0 0; vertical-align:middle; border-radius:2px; }
|
| 38 |
+
.leaflet-container { background:#0a0e13; }
|
| 39 |
+
a, .leaflet-popup-content a { color:#9bd1ff; }
|
| 40 |
+
@media (max-width: 880px) {
|
| 41 |
+
#app { grid-template-columns: 1fr; grid-template-rows: 56px 220px 1fr; }
|
| 42 |
+
#sidebar { grid-row: 2; border-right: none; border-bottom:1px solid #1b2532; }
|
| 43 |
+
#map { grid-row: 3; }
|
| 44 |
+
}
|
| 45 |
+
</style>
|
| 46 |
+
</head>
|
| 47 |
+
<body>
|
| 48 |
+
<div id="app">
|
| 49 |
+
<header>
|
| 50 |
+
<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>
|
| 51 |
+
<h1>Europe Night Trains <span class="sub">(10h+ start → terminus)</span></h1>
|
| 52 |
+
</header>
|
| 53 |
+
<aside id="sidebar" aria-label="Routes sidebar">
|
| 54 |
+
<div class="controls">
|
| 55 |
+
<button class="btn" id="showAll" type="button" title="Show all routes (S)">Show all</button>
|
| 56 |
+
<button class="btn" id="hideAll" type="button" title="Hide all routes (H)">Hide all</button>
|
| 57 |
+
<button class="btn" id="fitAll" type="button" title="Fit to Europe (F)">Fit to Europe</button>
|
| 58 |
+
</div>
|
| 59 |
+
<div class="group" id="list"></div>
|
| 60 |
+
</aside>
|
| 61 |
+
<main id="map" aria-label="Map of Europe with night train routes"></main>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="legend" role="note"><span class="swatch"></span> Night train route (start → terminus)</div>
|
| 65 |
+
|
| 66 |
+
<script src="https://unpkg.com/[email protected]/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
| 67 |
+
<script>
|
| 68 |
+
// --- City coordinates (approximate) ---
|
| 69 |
+
const CITIES = {
|
| 70 |
+
Amsterdam:[52.379, 4.9],
|
| 71 |
+
Vienna:[48.208, 16.373],
|
| 72 |
+
Innsbruck:[47.269, 11.404],
|
| 73 |
+
Zürich:[47.376, 8.541],
|
| 74 |
+
Brussels:[50.847, 4.357],
|
| 75 |
+
Berlin:[52.52, 13.405],
|
| 76 |
+
Prague:[50.075, 14.437],
|
| 77 |
+
Paris:[48.857, 2.351],
|
| 78 |
+
Nice:[43.703, 7.266],
|
| 79 |
+
"Latour-de-Carol/Enveitg":[42.453, 1.918],
|
| 80 |
+
Briançon:[44.897, 6.643],
|
| 81 |
+
Stockholm:[59.33, 18.06],
|
| 82 |
+
Narvik:[68.438, 17.427],
|
| 83 |
+
Helsinki:[60.171, 24.941],
|
| 84 |
+
Rovaniemi:[66.503, 25.728],
|
| 85 |
+
Kolari:[67.35, 23.78],
|
| 86 |
+
Hamburg:[53.55, 9.993],
|
| 87 |
+
Rome:[41.902, 12.496],
|
| 88 |
+
Munich:[48.137, 11.575],
|
| 89 |
+
Zagreb:[45.815, 15.981],
|
| 90 |
+
Budapest:[47.497, 19.04],
|
| 91 |
+
Bucharest:[44.426, 26.102],
|
| 92 |
+
London:[51.507, -0.128],
|
| 93 |
+
Inverness:[57.477, -4.224],
|
| 94 |
+
"Fort William":[56.82, -5.105]
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// --- Routes ≥10h (start → terminus) ---
|
| 98 |
+
const ROUTES = [
|
| 99 |
+
{from:"Amsterdam", to:"Vienna", op:"Nightjet"},
|
| 100 |
+
{from:"Amsterdam", to:"Innsbruck", op:"Nightjet"},
|
| 101 |
+
{from:"Amsterdam", to:"Zürich", op:"Nightjet"},
|
| 102 |
+
{from:"Brussels", to:"Berlin", op:"European Sleeper"},
|
| 103 |
+
{from:"Brussels", to:"Prague", op:"European Sleeper"},
|
| 104 |
+
{from:"Paris", to:"Berlin", op:"Nightjet"},
|
| 105 |
+
{from:"Paris", to:"Nice", op:"Intercités de Nuit"},
|
| 106 |
+
{from:"Paris", to:"Latour-de-Carol/Enveitg", op:"Intercités de Nuit"},
|
| 107 |
+
{from:"Paris", to:"Briançon", op:"Intercités de Nuit"},
|
| 108 |
+
{from:"Stockholm", to:"Berlin", op:"SJ EuroNight"},
|
| 109 |
+
{from:"Stockholm", to:"Narvik", op:"Vy/SJ"},
|
| 110 |
+
{from:"Helsinki", to:"Rovaniemi", op:"VR"},
|
| 111 |
+
{from:"Helsinki", to:"Kolari", op:"VR"},
|
| 112 |
+
{from:"Hamburg", to:"Vienna", op:"Nightjet"},
|
| 113 |
+
{from:"Hamburg", to:"Innsbruck", op:"Nightjet"},
|
| 114 |
+
{from:"Vienna", to:"Rome", op:"Nightjet"},
|
| 115 |
+
{from:"Munich", to:"Rome", op:"Nightjet"},
|
| 116 |
+
{from:"Zürich", to:"Prague", op:"EN Canopus"},
|
| 117 |
+
{from:"Zürich", to:"Zagreb", op:"EN Lisinski"},
|
| 118 |
+
{from:"Budapest", to:"Bucharest", op:"EN Dacia"},
|
| 119 |
+
{from:"Vienna", to:"Bucharest", op:"EN Dacia"},
|
| 120 |
+
{from:"London", to:"Inverness", op:"Caledonian Sleeper"},
|
| 121 |
+
{from:"London", to:"Fort William", op:"Caledonian Sleeper"}
|
| 122 |
+
];
|
| 123 |
+
|
| 124 |
+
// --- Map init ---
|
| 125 |
+
const map = L.map('map', { zoomControl: true, scrollWheelZoom: true, preferCanvas:true }).setView([51.2, 10], 5);
|
| 126 |
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
| 127 |
+
maxZoom: 10,
|
| 128 |
+
attribution: '© OpenStreetMap contributors'
|
| 129 |
+
}).addTo(map);
|
| 130 |
+
|
| 131 |
+
// --- Draw cities ---
|
| 132 |
+
const cityMarkers = {};
|
| 133 |
+
Object.entries(CITIES).forEach(([name, [lat, lng]]) => {
|
| 134 |
+
const m = L.circleMarker([lat, lng], { radius:5, weight:1, color:'#56b0ff', fillColor:'#56b0ff', fillOpacity:0.85 })
|
| 135 |
+
.bindTooltip(name, { permanent:false, direction:'top', offset:[0,-2]})
|
| 136 |
+
.addTo(map);
|
| 137 |
+
cityMarkers[name] = m;
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
// --- Route layers + rows registry ---
|
| 141 |
+
const registry = []; // { layer, data, row }
|
| 142 |
+
const makePopup = (r) => `<strong>${r.from} → ${r.to}</strong><br><span style="color:#9fb0c3">${r.op}</span>`;
|
| 143 |
+
|
| 144 |
+
ROUTES.forEach((r) => {
|
| 145 |
+
const a = CITIES[r.from], b = CITIES[r.to];
|
| 146 |
+
if(!a || !b) return;
|
| 147 |
+
const layer = L.polyline([a, b], { weight:3, opacity:0.9, color:'#56b0ff', lineCap:'round' })
|
| 148 |
+
.bindPopup(makePopup(r))
|
| 149 |
+
.addTo(map);
|
| 150 |
+
registry.push({ layer, data: r, row: null });
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
+
function fitAllVisible(){
|
| 154 |
+
const visible = registry.filter(o => map.hasLayer(o.layer));
|
| 155 |
+
if(visible.length === 0){ map.setView([51.2,10], 4); return; }
|
| 156 |
+
const group = L.featureGroup(visible.map(o => o.layer));
|
| 157 |
+
map.fitBounds(group.getBounds().pad(0.15));
|
| 158 |
+
}
|
| 159 |
+
fitAllVisible();
|
| 160 |
+
|
| 161 |
+
// --- Sidebar list ---
|
| 162 |
+
const list = document.getElementById('list');
|
| 163 |
+
|
| 164 |
+
function addRouteRow(entry){
|
| 165 |
+
const { data, layer } = entry;
|
| 166 |
+
const row = document.createElement('button');
|
| 167 |
+
row.type = 'button';
|
| 168 |
+
row.className = 'route';
|
| 169 |
+
row.setAttribute('aria-pressed', 'true');
|
| 170 |
+
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>`;
|
| 171 |
+
|
| 172 |
+
row.addEventListener('click', () => {
|
| 173 |
+
const isVisible = map.hasLayer(layer);
|
| 174 |
+
if(isVisible){
|
| 175 |
+
layer.closePopup();
|
| 176 |
+
map.removeLayer(layer);
|
| 177 |
+
row.setAttribute('aria-pressed','false');
|
| 178 |
+
}else{
|
| 179 |
+
layer.addTo(map);
|
| 180 |
+
row.setAttribute('aria-pressed','true');
|
| 181 |
+
const bounds = L.latLngBounds([CITIES[data.from], CITIES[data.to]]).pad(0.25);
|
| 182 |
+
map.fitBounds(bounds);
|
| 183 |
+
layer.openPopup();
|
| 184 |
+
}
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
list.appendChild(row);
|
| 188 |
+
entry.row = row;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
registry.forEach(addRouteRow);
|
| 192 |
+
|
| 193 |
+
// --- Buttons ---
|
| 194 |
+
document.getElementById('showAll').onclick = () => {
|
| 195 |
+
registry.forEach(o => { if(!map.hasLayer(o.layer)) o.layer.addTo(map); o.row?.setAttribute('aria-pressed','true'); });
|
| 196 |
+
fitAllVisible();
|
| 197 |
+
};
|
| 198 |
+
document.getElementById('hideAll').onclick = () => {
|
| 199 |
+
registry.forEach(o => { if(map.hasLayer(o.layer)) { o.layer.closePopup(); map.removeLayer(o.layer); } o.row?.setAttribute('aria-pressed','false'); });
|
| 200 |
+
};
|
| 201 |
+
document.getElementById('fitAll').onclick = fitAllVisible;
|
| 202 |
+
|
| 203 |
+
// --- Keyboard UX ---
|
| 204 |
+
document.addEventListener('keydown', (e) => {
|
| 205 |
+
const key = e.key.toLowerCase();
|
| 206 |
+
if(key === 'f') fitAllVisible();
|
| 207 |
+
if(key === 'h') document.getElementById('hideAll').click();
|
| 208 |
+
if(key === 's') document.getElementById('showAll').click();
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
// Resize observer to keep map sized correctly if layout changes
|
| 212 |
+
new ResizeObserver(() => { map.invalidateSize(); }).observe(document.getElementById('app'));
|
| 213 |
+
</script>
|
| 214 |
+
</body>
|
| 215 |
+
</html>
|
| 216 |
+
|
| 217 |
+
make it fancy and work
|