Skip to content

Instantly share code, notes, and snippets.

@sagearbor
Created March 8, 2026 16:55
Show Gist options
  • Select an option

  • Save sagearbor/3a81bb44ff261b6d2bf4a10944453a03 to your computer and use it in GitHub Desktop.

Select an option

Save sagearbor/3a81bb44ff261b6d2bf4a10944453a03 to your computer and use it in GitHub Desktop.
Arbor Europe Trip 2026 — Route Planner
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🇪🇺 Arbor Family Europe 2026 — Paris to Amsterdam</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Helvetica, Arial, sans-serif; background: #1a1a2e; color: #eee; }
header {
padding: 18px 24px;
background: linear-gradient(135deg, #16213e 0%, #0f3460 100%);
display: flex; align-items: center; justify-content: space-between;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}
header h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: 0.02em; }
header p { font-size: 0.85rem; color: #aac; margin-top: 2px; }
.container { display: flex; height: calc(100vh - 72px); }
#map { flex: 1; }
.sidebar {
width: 320px; background: #16213e; overflow-y: auto;
padding: 16px; border-left: 1px solid #0f3460;
}
.route-toggle {
margin-bottom: 10px; padding: 12px 14px;
border-radius: 10px; cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
display: flex; align-items: center; gap: 10px;
user-select: none;
}
.route-toggle:hover { filter: brightness(1.1); }
.route-toggle.active { border-color: white; }
.route-toggle .dot {
width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
}
.route-toggle .name { font-weight: 700; font-size: 0.9rem; line-height: 1.3; }
.route-toggle .sub { font-size: 0.75rem; color: #aaa; margin-top: 2px; }
.route-toggle .cost { font-size: 0.8rem; color: #7ec8e3; margin-top: 3px; }
.section-title {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em;
color: #778; margin: 18px 0 8px;
}
.city-card {
background: #0f3460; border-radius: 8px; padding: 10px 12px;
margin-bottom: 8px; cursor: pointer;
border: 1px solid #1a4080;
transition: background 0.15s;
}
.city-card:hover { background: #1a4a80; }
.city-card .city-name { font-weight: 700; font-size: 0.9rem; }
.city-card .city-hotel { font-size: 0.78rem; color: #aac; margin-top: 2px; }
.city-card .city-cost { font-size: 0.78rem; color: #7ec8e3; margin-top: 2px; }
#city-detail {
display: none; background: #0f3460; border-radius: 10px;
padding: 16px; margin-top: 12px; border: 1px solid #1a4080;
}
#city-detail h3 { font-size: 1.1rem; margin-bottom: 6px; }
#city-detail .label { font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.08em; color: #778; margin-top: 10px; margin-bottom: 2px; }
#city-detail .val { font-size: 0.88rem; line-height: 1.4; }
#city-detail a {
display: inline-block; margin-top: 10px; padding: 7px 14px;
background: #e74c3c; color: white; border-radius: 6px; text-decoration: none;
font-size: 0.82rem; font-weight: 600;
}
#city-detail a:hover { background: #c0392b; }
.gmaps-btn {
display: inline-block; margin-top: 6px; margin-left: 8px;
padding: 7px 14px;
background: #4285F4; color: white; border-radius: 6px;
text-decoration: none; font-size: 0.82rem; font-weight: 600;
}
.gmaps-btn:hover { background: #3367d6; }
.legend {
position: absolute; bottom: 20px; left: 20px; z-index: 1000;
background: rgba(22,33,62,0.92); border-radius: 10px;
padding: 12px 16px; font-size: 0.82rem;
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
}
.legend-item { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
.legend-line {
width: 24px; height: 4px; border-radius: 2px;
}
@media (max-width: 700px) {
.sidebar { width: 100%; max-height: 40vh; }
.container { flex-direction: column-reverse; }
}
</style>
</head>
<body>
<header>
<div>
<h1>🇪🇺 Arbor Family — Europe 2026</h1>
<p>Paris → Amsterdam &nbsp;·&nbsp; June 12 – 28 &nbsp;·&nbsp; 4 Routes to Explore &nbsp;·&nbsp; <span style="font-size:0.75rem;opacity:0.7">v3.1 — Updated Feb 26, 2026</span> &nbsp;<a href="https://docs.google.com/spreadsheets/d/1JO4WnIqgtBaTGggeCspQUcnvfiBG6oXsDHZ8n6GKy3k" target="_blank" style="font-size:0.75rem;color:#8cf;opacity:0.85;text-decoration:none" title="Open detailed Google Sheet">📊 Full details ↗</a></p>
</div>
<div style="font-size:1.8rem">✈️</div>
</header>
<div class="container">
<div id="map"></div>
<div class="sidebar">
<div class="section-title">Choose a Route</div>
<div id="route-toggles"></div>
<div class="section-title">Cities on Selected Route</div>
<div id="city-list"></div>
<div id="city-detail"></div>
<div style="margin-top:16px;padding-top:12px;border-top:1px solid #334">
<a href="https://docs.google.com/spreadsheets/d/1JO4WnIqgtBaTGggeCspQUcnvfiBG6oXsDHZ8n6GKy3k" target="_blank"
style="display:block;text-align:center;padding:8px 12px;background:#1a3a5c;color:#8cf;
border-radius:6px;text-decoration:none;font-size:0.82rem;border:1px solid #2a5a8c">
📊 Full Route Details &amp; Costs →
</a>
</div>
</div>
</div>
<div class="legend" id="legend"></div>
<script>
const MAP_VERSION = "v3.1"; const MAP_UPDATED = "Feb 26, 2026";
const TRANSPORT_COSTS = {"Route 1 – 🏔️ Alpine Arc": {"Paris": {"cost": 0, "note": "Starting city"}, "Interlaken": {"cost": 135, "note": "TGV Lyria Paris→Basel + SBB Basel→Interlaken. Book 60+ days ahead. Day trips extra (~$115 Jungfraujoch, ~$18 Lauterbrunnen)."}, "Lucerne": {"cost": 35, "note": "SBB Interlaken→Lucerne (~2h, scenic lake route)"}, "Cologne": {"cost": 95, "note": "SBB + DB ICE Lucerne→Basel→Cologne (~4.5h total)"}, "Amsterdam": {"cost": 35, "note": "DB/NS ICE Cologne→Amsterdam (~2.5h). Compulsory reservation required Jun–Aug."}}, "Route 2 – 🏰 Rhine Valley": {"Paris": {"cost": 0, "note": "Starting city"}, "Strasbourg": {"cost": 40, "note": "TGV INOUI Paris Gare de l'Est→Strasbourg (~2h). Book early for ~$40."}, "Heidelberg": {"cost": 30, "note": "Regional: Strasbourg→Karlsruhe→Heidelberg (~1.5h)"}, "Bacharach": {"cost": 25, "note": "Regional: Heidelberg→Mannheim→Bacharach (~1.5h, scenic Rhine valley)"}, "Cologne": {"cost": 30, "note": "Regional RE: Bacharach→Koblenz→Cologne (~2h). Stop at Koblenz Deutsches Eck if time allows."}, "Amsterdam": {"cost": 35, "note": "DB/NS ICE Cologne→Amsterdam (~2.5h). Compulsory reservation required Jun–Aug."}}, "Route 3 – ⛰️ Alps & Prague": {"Paris": {"cost": 0, "note": "Departure city — already staying here."}, "Interlaken": {"cost": 90, "note": "TGV Paris Gare de Lyon → Basel (~3h, ~€50) + Swiss train Basel → Interlaken Ost (~2h, ~€35). Book Swiss rail ahead. ~5h total."}, "Prague": {"cost": 120, "note": "SBB Interlaken → Zürich (~2h) + ICE Zürich → Munich (~3.5h) + EC Munich → Prague (~4h). ~9.5h total, ~€80–120. Long day but all high-speed."}, "Amsterdam": {"cost": 90, "note": "EC Prague → Frankfurt (~4h) + ICE Frankfurt → Cologne (~2h) + IC Cologne → Amsterdam (~2.5h). ~9h total. Book seats ahead for ICE/EC in summer."}}, "Route 4 – 🌍 Iberian Arc & Morocco": {"Paris": {"cost": 0, "note": "Starting city"}, "Barcelona": {"cost": 80, "note": "TGV INOUI Paris Gare de Lyon→Barcelona Sants (~6.5h). Book 60+ days ahead."}, "Granada": {"cost": 100, "note": "Renfe AVE Barcelona→Madrid (~2.5h) + AVE/Alvia Madrid→Granada (~3.5h). Transit through Madrid — same day, no overnight. From Granada: bus to Algeciras (~3h, ~€15) then ferry to Tangier — skip Málaga overnight."}, "Tangier": {"cost": 65, "note": "CTM bus Málaga→Algeciras (~2h, ~€15) + Balearia/FRS ferry Algeciras→Tangier Ville (~35 min, ~€50). Dramatic strait crossing into Africa."}, "Chefchaouen": {"cost": 10, "note": "CTM bus Tangier→Chefchaouen (~3h). Bus detour off main ONCF train line — worth every minute."}, "Fez": {"cost": 15, "note": "CTM bus Chefchaouen→Fez (~4h). Or ONCF train: Chefchaouen→Tangier→Fez (~5h total)."}, "Amsterdam": {"cost": 185, "note": "CTM bus Chefchaouen→Algeciras (~3h back to Tangier + 35 min ferry + 2h bus to Málaga airport) + ✈️ Vueling/Ryanair Málaga→Amsterdam direct (~3h, ~€90–110). ~6h travel day. No overnight in Málaga needed."}}};
const ROUTES = [{"name": "Route 1 – 🏔️ Alpine Arc", "color": "#e74c3c", "cities": [{"name": "Paris", "country": "France", "hotel": "Hôtel des Grands Boulevards", "cost": 320, "highlights": "Eiffel Tower, Seine cruise, Louvre", "booking_url": "https://www.booking.com/hotel/fr/des-grands-boulevards.html", "coords": [48.8566, 2.3522]}, {"name": "Interlaken", "country": "Switzerland", "hotel": "Hotel Royal St Georges – MGallery", "cost": 220, "highlights": "Höheweg promenade, Lake Thun boat cruise, Harder Kulm funicular", "booking_url": "https://www.booking.com/hotel/ch/royal-st-georges.html", "coords": [46.6863, 7.8632]}, {"name": "Lucerne", "country": "Switzerland", "hotel": "Cascada Boutique Hotel", "cost": 250, "highlights": "Chapel Bridge (Kapellbrücke), Lion Monument, Lake Lucerne, Old Town", "booking_url": "https://www.booking.com/hotel/ch/cascada-boutique.html", "coords": [47.0502, 8.3093]}, {"name": "Cologne", "country": "Germany", "hotel": "Hopper Hotel et cetera", "cost": 185, "highlights": "Cologne Cathedral (Dom), Chocolate Museum, Rhine promenade, Old Town Altstadt", "booking_url": "https://www.booking.com/hotel/de/hopper-et-cetera.html", "coords": [50.9333, 6.95]}, {"name": "Amsterdam", "country": "Netherlands", "hotel": "The Hoxton Amsterdam", "cost": 280, "highlights": "Canal cruise, Rijksmuseum, Jordaan district, evening canal stroll", "booking_url": "https://www.booking.com/hotel/nl/the-hoxton-amsterdam.html", "coords": [52.3676, 4.9041]}], "latlngs": [[48.8566, 2.3522], [46.6863, 7.8632], [47.0502, 8.3093], [50.9333, 6.95], [52.3676, 4.9041]]}, {"name": "Route 2 – 🏰 Rhine Valley", "color": "#3498db", "cities": [{"name": "Paris", "country": "France", "hotel": "Hôtel des Grands Boulevards", "cost": 320, "highlights": "Eiffel Tower, Seine cruise, Louvre", "booking_url": "https://www.booking.com/hotel/fr/des-grands-boulevards.html", "coords": [48.8566, 2.3522]}, {"name": "Strasbourg", "country": "France", "hotel": "Cour du Corbeau – MGallery", "cost": 210, "highlights": "Grande Île (UNESCO), Cathédrale Notre-Dame, Petite France district", "booking_url": "https://www.booking.com/hotel/fr/cour-du-corbeau.html", "coords": [48.5734, 7.7521]}, {"name": "Heidelberg", "country": "Germany", "hotel": "Hotel Zum Ritter St. Georg", "cost": 185, "highlights": "Heidelberg Castle ruins, Philosophenweg (Philosopher's Walk), Neckar River", "booking_url": "https://www.booking.com/hotel/de/zum-ritter-heidelberg.html", "coords": [49.3988, 8.6724]}, {"name": "Bacharach", "country": "Germany", "hotel": "Rhein Hotel Bacharach", "cost": 130, "highlights": "Rhine Gorge (UNESCO), Stahleck Castle, village wine taverns, KD Rhine cruise", "booking_url": "https://www.booking.com/hotel/de/rhein-hotel-bacharach.html", "coords": [50.0577, 7.7699]}, {"name": "Cologne", "country": "Germany", "hotel": "Boutique Hotel Domspitzen", "cost": 185, "highlights": "Cologne Cathedral, Chocolate Museum, Altstadt, Rhine promenade", "booking_url": "https://www.booking.com/hotel/de/boutique-003-koln-amdom.html", "coords": [50.9333, 6.95]}, {"name": "Amsterdam", "country": "Netherlands", "hotel": "The Hoxton Amsterdam", "cost": 280, "highlights": "Rijksmuseum, Van Gogh Museum, canal cruise, Jordaan", "booking_url": "https://www.booking.com/hotel/nl/the-hoxton-amsterdam.html", "coords": [52.3676, 4.9041]}], "latlngs": [[48.8566, 2.3522], [48.5734, 7.7521], [49.3988, 8.6724], [50.0577, 7.7699], [50.9333, 6.95], [52.3676, 4.9041]]}, {"name": "Route 3 – ⛰️ Alps & Prague", "color": "#9b59b6", "cities": [{"name": "Paris", "country": "France", "nights": 0, "hotel": "", "cost": 0, "highlights": "Departure point — already staying here with family. Depart morning for Interlaken.", "booking_url": "", "coords": [48.8566, 2.3522]}, {"name": "Interlaken", "country": "Switzerland", "nights": 2, "hotel": "Hotel Metropole Interlaken", "cost": 220, "highlights": "Jungfraujoch ‘Top of Europe’ (cogwheel train, book ahead — one of the world’s great rides), Lake Thun & Lake Brienz boat cruises, Harder Kulm funicular for panoramic Alps views, Lauterbrunnen valley with 72 waterfalls (30 min by train), paragliding above the valley.", "booking_url": "https://www.booking.com/hotel/ch/metropole-interlaken.html", "coords": [46.6863, 7.8632]}, {"name": "Prague", "country": "Czech Republic", "nights": 2, "hotel": "Hotel Josef Prague", "cost": 195, "highlights": "Charles Bridge at sunrise (before the crowds). Old Town Square & the Astronomical Clock. Prague Castle & St Vítus Cathedral (largest castle complex in the world). Josefov Jewish Quarter. Best craft beer in Europe — dark lagers and pilsners in old cellar pubs. Vltava river cruise at dusk.", "booking_url": "https://www.booking.com/hotel/cz/josef-prague.html", "coords": [50.0755, 14.4378]}, {"name": "Amsterdam", "country": "Netherlands", "nights": 1, "hotel": "The Hoxton Amsterdam", "cost": 280, "highlights": "Arrive from Prague (long train day — deserve a nice dinner). Canal cruise, Rijksmuseum, Jordaan district. Flight home next morning.", "booking_url": "https://www.booking.com/hotel/nl/the-hoxton-amsterdam.html", "coords": [52.3676, 4.9041]}], "latlngs": [[48.8566, 2.3522], [47.5596, 7.5886], [46.6863, 7.8632], [47.3769, 8.5417], [48.1351, 11.582], [50.0755, 14.4378], [50.1109, 8.6821], [50.9333, 6.95], [52.3676, 4.9041]]}, {"name": "Route 4 – 🌍 Iberian Arc & Morocco", "color": "#f39c12", "cities": [{"name": "Paris", "country": "France", "nights": 1, "hotel": "Hôtel des Grands Boulevards", "cost": 320, "highlights": "Departure city. Seine stroll, Le Marais dinner, Eiffel Tower light show.", "booking_url": "https://www.booking.com/hotel/fr/des-grands-boulevards.html", "coords": [48.8566, 2.3522]}, {"name": "Barcelona", "country": "Spain", "nights": 2, "hotel": "Casa Camper Barcelona", "cost": 250, "highlights": "Sagrada Família (book interior tickets ahead), Park Güell, Gothic Quarter, Barceloneta beach, La Boqueria market, tapas in Gràcia.", "booking_url": "https://www.booking.com/hotel/es/casacamper.html", "coords": [41.3851, 2.1734]}, {"name": "Granada", "country": "Spain", "nights": 1, "hotel": "Hotel Casa Morisca", "cost": 220, "highlights": "Alhambra palace & Generalife gardens (book months ahead — timed entry, strictly limited). Evening Albaicín walk, rooftop views of Alhambra lit up at night. Free tapas with every drink — city-wide tradition.", "booking_url": "https://www.booking.com/hotel/es/casamorisca.html", "coords": [37.1773, -3.5986]}, {"name": "Tangier", "country": "Morocco", "nights": 1, "hotel": "El Minzah Hotel", "cost": 125, "highlights": "Ferry crossing the Strait of Gibraltar — Africa on the horizon, dramatic entry. Tangier Kasbah & medina, Petit Socco, Café Hafa (cliffside Atlantic views, Rolling Stones haunt). Gateway to Morocco.", "booking_url": "https://www.booking.com/hotel/ma/el-minzah.html", "coords": [35.7673, -5.7998]}, {"name": "Chefchaouen", "country": "Morocco", "nights": 1, "hotel": "Lina Ryad & Spa", "cost": 210, "highlights": "The Blue City — every alley painted blue & white. Ras el-Maa waterfall, Plaza Uta el-Hammam for mint tea, sunset hike to Spanish mosque hilltop. Bus detour (~3h from Tangier) but unmissable. Can condense to 1 night or skip to save time.", "booking_url": "https://www.booking.com/hotel/ma/lina-ryad-spa.html", "coords": [35.1688, -5.2692]}, {"name": "Amsterdam", "country": "Netherlands", "nights": 0, "hotel": "The Hoxton Amsterdam", "cost": 0, "highlights": "Fly in from Málaga via overnight Nador→Almería ferry + train to Málaga. Canal walk, Rijksmuseum, Jordaan district. End of the road.", "booking_url": "https://www.booking.com/hotel/nl/the-hoxton-amsterdam.html", "coords": [52.3676, 4.9041]}], "latlngs": [[48.8566, 2.3522], [41.3851, 2.1734], [37.1773, -3.5986], [36.7213, -4.4213], [35.7673, -5.7998], [35.1688, -5.2692], [34.0531, -4.9998], [36.7213, -4.4213], [52.3676, 4.9041]]}];
const map = L.map('map').setView([44.0, 1.5], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd', maxZoom: 19
}).addTo(map);
let activeRouteIdx = 0;
const layers = [];
const markerGroups = [];
function makeIcon(color) {
return L.divIcon({
className: '',
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:${color};border:2.5px solid white;
box-shadow:0 0 6px rgba(0,0,0,0.5);">
</div>`,
iconSize: [14, 14], iconAnchor: [7, 7]
});
}
function makeStartIcon() {
return L.divIcon({
className: '',
html: `<div style="font-size:22px;line-height:1;">🗼</div>`,
iconSize: [26, 26], iconAnchor: [13, 13]
});
}
function makeEndIcon() {
return L.divIcon({
className: '',
html: `<div style="font-size:22px;line-height:1;">🌷</div>`,
iconSize: [26, 26], iconAnchor: [13, 13]
});
}
ROUTES.forEach((route, idx) => {
const dash = idx === 0 ? null : (idx === 1 ? '10,6' : (idx === 2 ? '4,8' : '8,4,2,4'));
const line = L.polyline(route.latlngs, {
color: route.color, weight: 4, opacity: 0.9, dashArray: dash
}).addTo(map);
line._origDash = dash;
// Wide invisible hit area so tapping/clicking the route is easy on mobile
const hitLine = L.polyline(route.latlngs, {
color: 'transparent', weight: 24, opacity: 0, interactive: true
}).addTo(map);
hitLine.on('click', () => setActiveRoute(idx));
line.on('click', () => setActiveRoute(idx));
const group = L.layerGroup();
route.cities.forEach((city, ci) => {
let icon;
if (city.name === 'Paris') icon = makeStartIcon();
else if (city.name === 'Amsterdam') icon = makeEndIcon();
else icon = makeIcon(route.color);
const marker = L.marker(city.coords, { icon })
.bindTooltip(`<b>${city.name}</b><br>${route.name.split('–')[1].trim()}`, {
permanent: false, direction: 'top', offset: [0, -10]
})
.on('click', () => showCityDetail(city, route.color, route.name));
group.addLayer(marker);
});
layers.push({ line, group });
markerGroups.push(group);
});
function setActiveRoute(idx) {
activeRouteIdx = idx;
layers.forEach((l, i) => {
if (i === idx) {
l.line.setStyle({ opacity: 1, weight: 6, dashArray: null });
l.group.eachLayer(m => { const el = m.getElement(); if (el) el.style.opacity = '1'; });
l.group.addTo(map);
} else {
l.line.setStyle({ opacity: 0.55, weight: 3, dashArray: l.line._origDash });
l.group.eachLayer(m => { const el = m.getElement(); if (el) el.style.opacity = '0.45'; });
l.group.addTo(map);
}
});
// Update toggles
document.querySelectorAll('.route-toggle').forEach((el, i) => {
el.classList.toggle('active', i === idx);
if (i === idx) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
// Fit bounds
map.fitBounds(layers[idx].line.getBounds(), { padding: [40, 40] });
// Update city list
renderCityList(idx);
document.getElementById('city-detail').style.display = 'none';
}
function renderCityList(idx) {
const route = ROUTES[idx];
const el = document.getElementById('city-list');
el.innerHTML = route.cities.map(city => `
<div class="city-card" onclick='showCityDetail(${JSON.stringify(city)}, "${route.color}", "${route.name}")'>
<div class="city-name">${city.name}, ${city.country}</div>
<div class="city-hotel">${city.hotel !== '—' ? city.hotel : 'Last day / departure'}</div>
${city.cost > 0 ? `<div class="city-cost">~$${city.cost}/night</div>` : ''}
</div>
`).join('');
}
function showCityDetail(city, color, routeName) {
const el = document.getElementById('city-detail');
const gmapsUrl = `https://www.google.com/maps/search/${encodeURIComponent(city.name + ' ' + city.country)}`;
const t = (TRANSPORT_COSTS[routeName] || {})[city.name];
el.style.display = 'block';
el.innerHTML = `
<h3 style="color:${color}">${city.name} <span style="color:#aaa;font-weight:400;font-size:0.85rem">${city.country}</span></h3>
${city.hotel !== '—' ? `
<div class="label">Hotel</div>
<div class="val">${city.hotel}</div>
<div class="label">Lodging / night</div>
<div class="val" style="color:#7ec8e3">~$${city.cost}</div>
` : ''}
${t && t.cost > 0 ? `
<div class="label">🚆 Getting Here</div>
<div class="val" style="color:#f39c12">~$${t.cost} / person</div>
<div class="val" style="font-size:0.78rem;color:#aaa;margin-top:2px;line-height:1.4">${t.note}</div>
` : ''}
<div class="label">Must-See</div>
<div class="val">${city.highlights}</div>
<div style="margin-top:12px">
${city.booking_url ? `<a href="${city.booking_url}" target="_blank">🏨 Book Hotel</a>` : ''}
<a href="${gmapsUrl}" target="_blank" class="gmaps-btn">📍 Google Maps</a>
</div>
`;
// Pan map to city
if (city.coords) map.panTo(city.coords, { animate: true });
}
// Build toggles
const togglesEl = document.getElementById('route-toggles');
ROUTES.forEach((route, idx) => {
// Lodging totals account for multi-night stays (cities deduplicated for map, not for cost)
const LODGING_TOTALS = {
"Route 1 \u2013 \ud83c\udfd4\ufe0f Alpine Arc": 2200, // Paris×2 + Interlaken×3 + Lucerne×1 + Cologne×2 + Amsterdam×1
"Route 2 \u2013 \ud83c\udff0 Rhine Valley": 2025, // Paris×2 + Strasbourg×2 + Heidelberg×1 + Bacharach×1 + Cologne×2 + Amsterdam×1
"Route 3 – ⛰️ Alps & Prague": 1110, // Interlaken×2=440 + Prague×2=390 + Amsterdam×1=280
"Route 4 – 🌍 Iberian Arc & Morocco": 1335, // Barcelona×2=500 + Granada×1=220 + Tangier×1=125 + Chefchaouen×1=210 + Amsterdam×1=280
};
const TRANSIT_STATS = {
"Route 1 – 🏔️ Alpine Arc": { avgNights: 1.8, trainH: 15.5, busH: 0, ferryH: 0, flightH: 0, totalH: 15.5 },
"Route 2 – 🏰 Rhine Valley": { avgNights: 1.5, trainH: 9.5, busH: 0, ferryH: 0, flightH: 0, totalH: 9.5 },
"Route 3 – ⛰️ Alps & Prague": { avgNights: 1.7, trainH: 23.5, busH: 0, ferryH: 0, flightH: 0, totalH: 23.5 },
"Route 4 – 🌍 Iberian Arc & Morocco": { avgNights: 1.2, trainH: 12.5, busH: 11, ferryH: 1, flightH: 3, totalH: 27.5 },
};
const lodging = LODGING_TOTALS[route.name] || route.cities.reduce((s, c) => s + c.cost, 0);
const transport = Object.values(TRANSPORT_COSTS[route.name] || {}).reduce((s, t) => s + (t.cost||0), 0);
const totalPP = lodging + transport;
const div = document.createElement('div');
div.className = 'route-toggle';
div.style.background = route.color + '22';
div.innerHTML = `
<div class="dot" style="background:${route.color}"></div>
<div>
<div class="name">${route.name}</div>
<div class="sub">${route.cities.map(c=>c.name).join(' → ')}</div>
<div class="cost" style="font-size:0.9rem;font-weight:700;color:white">~$${(totalPP*4).toLocaleString()} total (family of 4)</div>
<div class="cost" style="font-size:0.72rem;color:#aaa">$${totalPP.toLocaleString()}/person · lodging $${lodging.toLocaleString()} + transport $${transport.toLocaleString()}</div>
<div class="cost" style="font-size:0.72rem;color:#9db;margin-top:2px">${(() => {
const s = TRANSIT_STATS[route.name] || {};
const parts = [];
if (s.trainH) parts.push('🚂 ' + s.trainH + 'h train');
if (s.busH) parts.push('🚌 ' + s.busH + 'h bus');
if (s.ferryH) parts.push('⛴️ ' + s.ferryH + 'h ferry');
if (s.flightH) parts.push('✈️ ' + s.flightH + 'h flight');
return 'Avg stay ' + (s.avgNights||'?') + 'n/city · ' + parts.join(' · ') + ' · ⏱ ' + (s.totalH||'?') + 'h transit total';
})()}</div>
</div>
`;
div.addEventListener('click', () => setActiveRoute(idx));
togglesEl.appendChild(div);
});
// Build legend
const legendEl = document.getElementById('legend');
legendEl.innerHTML = ROUTES.map((r, i) => `
<div class="legend-item">
<div class="legend-line" style="background:${r.color};${i===1?'background:repeating-linear-gradient(90deg,'+r.color+' 0,'+r.color+' 6px,transparent 6px,transparent 10px)':i===2?'background:repeating-linear-gradient(90deg,'+r.color+' 0,'+r.color+' 4px,transparent 4px,transparent 8px)':''}"></div>
<span>${r.name.split('–')[1]?.trim()}</span>
</div>
`).join('');
// Initialize — fit ALL routes in view so Morocco route is visible from the start
layers.forEach(l => { l.line.addTo(map); l.group.addTo(map); });
const allBounds = layers.reduce((b, l) => b.extend(l.line.getBounds()), L.latLngBounds());
map.fitBounds(allBounds, { padding: [30, 30] });
setActiveRoute(0);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment