Created
March 8, 2026 16:55
-
-
Save sagearbor/3a81bb44ff261b6d2bf4a10944453a03 to your computer and use it in GitHub Desktop.
Arbor Europe Trip 2026 — Route Planner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 · June 12 – 28 · 4 Routes to Explore · <span style="font-size:0.75rem;opacity:0.7">v3.1 — Updated Feb 26, 2026</span> <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 & 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: '© <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