From e9662754c4f9cf18ad4dfffaabcaaf4d7cffde53 Mon Sep 17 00:00:00 2001 From: Tomas Horsky Date: Mon, 15 Jun 2026 23:25:58 +0900 Subject: [PATCH 1/5] add globe animation --- src/App.svelte | 15 +- src/lib/world-map/JourneyView.svelte | 459 +++++++++++++++++++++------ 2 files changed, 374 insertions(+), 100 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index cf337df..1e5681f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -13,6 +13,7 @@ let journeyProgress = $state(null); let inDetail = $state(false); let pendingCountry = $state(''); + let journeyMode = $state('map'); function onNavigate(s) { screen = s; @@ -56,10 +57,10 @@
{#if journeyActive} - + journeyMode = m} /> {:else} - + {/if}
@@ -114,14 +115,14 @@ bottom: 24px; right: 24px; z-index: 10; - width: 44px; - height: 44px; - border-radius: 50%; + padding: 12px 28px; + border-radius: 24px; border: none; background: #8b5cf6; color: #fff; - font-size: 20px; - line-height: 1; + font-size: 15px; + font-weight: 600; + gap: 6px; cursor: pointer; display: flex; align-items: center; diff --git a/src/lib/world-map/JourneyView.svelte b/src/lib/world-map/JourneyView.svelte index ce8c7b7..d19175b 100644 --- a/src/lib/world-map/JourneyView.svelte +++ b/src/lib/world-map/JourneyView.svelte @@ -4,7 +4,7 @@ import { feature } from 'topojson-client'; import worldData from 'world-atlas/countries-50m.json'; - let { onclose, onprogress } = $props(); + let { onclose, onprogress, mode = 'map', onmodechange } = $props(); const HOME_CODE = '203'; @@ -40,18 +40,22 @@ } let frameEl; - let svg, g, pathFn, projection; + let svg, gBase, gCountries, gAnim, pathFn, projection; let countryPaths; let homeFeature; let featuresById = {}; + let countriesData = []; let isCancelled = false; let isPlaying = $state(false); let isFinished = $state(false); + let visitedCodes = new Set(); + let animId = 0; + let currentDateLabel = $state(''); - function fitProjection(proj, w, h) { - proj.fitSize([w, h], { type: 'Sphere' }); - const s = proj.scale() * 1.5; - proj.scale(s).translate([w / 2, h * 0.70]); + function formatDateLabel(dateStr) { + const d = new Date(dateStr); + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + return `${months[d.getMonth()]} ${d.getFullYear()}`; } function computeArc(p1, p2) { @@ -110,7 +114,6 @@ const angle = getAngleAtLength(node, clamped); tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1); } catch (e) { - // ignore SVG errors } if (t >= 1) { @@ -128,41 +131,200 @@ }); } + function setupProjection(width, height) { + if (mode === 'map') { + projection = d3.geoMercator(); + projection.fitSize([width, height], { type: 'Sphere' }); + const s = projection.scale() * 1.5; + projection.scale(s).translate([width / 2, height * 0.70]); + } else { + const size = Math.min(width, height) * 0.92; + projection = d3.geoOrthographic() + .rotate([0, 0]) + .fitSize([size, size], { type: 'Sphere' }) + .translate([width / 2, height / 2]); + } + pathFn = d3.geoPath().projection(projection); + } + + function renderMap() { + gBase.selectAll('*').remove(); + gCountries.selectAll('*').remove(); + gAnim.selectAll('*').remove(); + + const fillFn = d => { + const id = effId(d); + if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR; + if (visitedCodes.has(id)) return VISITED_COLOR; + return UNVISITED; + }; + + if (mode === 'globe') { + gBase.append('path') + .attr('class', 'sphere') + .datum({ type: 'Sphere' }) + .attr('d', pathFn) + .attr('fill', '#a4c8e0') + .attr('stroke', '#8b9bb0') + .attr('stroke-width', 1.5); + + } + + countryPaths = gCountries.selectAll('path') + .data(countriesData, d => effId(d)) + .join('path') + .attr('d', pathFn) + .attr('fill', fillFn) + .attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4') + .attr('stroke-width', mode === 'globe' ? 0.3 : 0.5); + + if (mode === 'map') { + renderMicrostates(); + } + } + + function renderMicrostates() { + gBase.selectAll('.micro-state-j').remove(); + const threshold = 4; + const fillFn = d => { + const id = effId(d); + if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR; + if (visitedCodes.has(id)) return VISITED_COLOR; + return UNVISITED; + }; + countryPaths.each(function (d) { + if (effId(d) !== d.id) return; + const { width, height } = this.getBBox(); + if (width < threshold && height < threshold) { + const [cx, cy] = pathFn.centroid(d); + gBase.append('circle') + .attr('class', 'micro-state-j') + .datum(d) + .attr('cx', cx) + .attr('cy', cy) + .attr('r', 2) + .attr('fill', fillFn(d)) + .attr('stroke', '#94a3b8') + .attr('stroke-width', 0.5); + } + }); + } + + function redrawBase() { + countryPaths.attr('d', pathFn); + if (mode === 'globe') { + gBase.select('.sphere').attr('d', pathFn); + } + } + + function rotateGlobeTo(lon, lat, duration = 1500) { + return new Promise((resolve) => { + if (isCancelled) { resolve(); return; } + + const current = projection.rotate(); + const from = [-current[0], -current[1]]; + const to = [lon, lat]; + + const interpolate = d3.geoInterpolate(from, to); + + const timer = d3.timer(elapsed => { + if (isCancelled) { timer.stop(); resolve(); return true; } + + const t = Math.min(elapsed / duration, 1); + const point = interpolate(t); + projection.rotate([-point[0], -point[1]]); + redrawBase(); + + if (t >= 1) { + timer.stop(); + resolve(); + return true; + } + }); + }); + } + + function animateReprojectingArc(el, tip, geoPts, lineGen, duration) { + return new Promise((resolve) => { + const steps = geoPts.length - 1; + + const timer = d3.timer(elapsed => { + if (isCancelled) { timer.stop(); resolve(); return true; } + + const t = Math.min(elapsed / duration, 1); + const count = Math.max(2, Math.floor(t * steps) + 1); + const visible = geoPts.slice(0, count); + const screenPts = []; + for (const p of visible) { + const pt = projection(p); + if (pt) screenPts.push(pt); + } + + if (screenPts.length >= 2) { + el.attr('d', lineGen(screenPts)); + } + + if (screenPts.length > 0) { + const last = screenPts[screenPts.length - 1]; + let angle = 0; + if (screenPts.length >= 2) { + const prev = screenPts[screenPts.length - 2]; + angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI; + } + tip.attr('transform', `translate(${last[0]},${last[1]}) rotate(${angle}) scale(1.4)`) + .attr('opacity', 1); + } + + if (t >= 1) { + timer.stop(); + resolve(); + return true; + } + }); + }); + } + async function animateTrip(destCode, destFeature) { if (!homeFeature || !destFeature) return; const homeCentroid = d3.geoCentroid(homeFeature); const destCentroid = d3.geoCentroid(destFeature); + if (mode === 'map') { + await animateMapTrip(homeCentroid, destCentroid, destCode); + } else { + await animateGlobeTrip(homeCentroid, destCentroid, destCode); + } + } + + async function animateMapTrip(homeCentroid, destCentroid, destCode) { const pts = computeArc(homeCentroid, destCentroid); if (pts.length < 2) return; const lineGen = d3.line().curve(d3.curveBasis); const pathData = lineGen(pts); - if (!pathData) return; function createArc(pathData) { - const el = g.append('path') + const el = gAnim.append('path') .attr('d', pathData) .attr('fill', 'none') .attr('stroke', ARC_COLOR) .attr('stroke-width', 2.5) .attr('stroke-opacity', 0.8) .attr('stroke-linecap', 'round'); - const tip = g.append('path') + const tip = gAnim.append('path') .attr('d', PLANE_PATH) .attr('fill', PLANE_COLOR) .attr('opacity', 0); return { el, tip }; } - // --- Outbound: home -> dest --- let { el: outEl, tip: outTip } = createArc(pathData); const outLen = outEl.node().getTotalLength(); outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen); - const homeDot = g.append('circle') + const homeDot = gAnim.append('circle') .attr('r', 4) .attr('fill', PLANE_COLOR) .attr('cx', pts[0][0]) @@ -176,10 +338,10 @@ outTip.remove(); homeDot.remove(); - // Color the destination country const targetPath = countryPaths.filter(d => effId(d) === destCode); targetPath.transition().duration(500).attr('fill', VISITED_COLOR); - g.selectAll('.micro-state-j') + visitedCodes.add(destCode); + gBase.selectAll('.micro-state-j') .filter(d => effId(d) === destCode) .transition().duration(500) .attr('fill', VISITED_COLOR); @@ -187,14 +349,13 @@ await delay(800); if (isCancelled) return; - // --- Return: dest -> home --- const revPts = [...pts].reverse(); const revData = d3.line().curve(d3.curveBasis)(revPts); let { el: retEl, tip: retTip } = createArc(revData); const retLen = retEl.node().getTotalLength(); retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen); - const destDot = g.append('circle') + const destDot = gAnim.append('circle') .attr('r', 4) .attr('fill', PLANE_COLOR) .attr('cx', revPts[0][0]) @@ -211,18 +372,104 @@ await delay(300); } + async function animateGlobeTrip(homeCentroid, destCentroid, destCode) { + const interp = d3.geoInterpolate(homeCentroid, destCentroid); + const steps = 80; + const geoPts = []; + for (let i = 0; i <= steps; i++) { + geoPts.push(interp(i / steps)); + } + + const dist = d3.geoDistance(homeCentroid, destCentroid); + const dur = Math.round(1500 + dist * 2500); + + const lineGen = d3.line().curve(d3.curveBasis); + + const outEl = gAnim.append('path') + .attr('fill', 'none') + .attr('stroke', ARC_COLOR) + .attr('stroke-width', 2.5) + .attr('stroke-opacity', 0.8) + .attr('stroke-linecap', 'round'); + const outTip = gAnim.append('path') + .attr('d', PLANE_PATH) + .attr('fill', PLANE_COLOR) + .attr('opacity', 0); + + await Promise.all([ + rotateGlobeTo(destCentroid[0], destCentroid[1], dur), + animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur) + ]); + if (isCancelled) return; + + outEl.remove(); + outTip.remove(); + + const targetPath = countryPaths.filter(d => effId(d) === destCode); + targetPath.transition().duration(500).attr('fill', VISITED_COLOR); + visitedCodes.add(destCode); + + await delay(600); + if (isCancelled) return; + + const revGeoPts = [...geoPts].reverse(); + + const retEl = gAnim.append('path') + .attr('fill', 'none') + .attr('stroke', ARC_COLOR) + .attr('stroke-width', 2.5) + .attr('stroke-opacity', 0.8) + .attr('stroke-linecap', 'round'); + const retTip = gAnim.append('path') + .attr('d', PLANE_PATH) + .attr('fill', PLANE_COLOR) + .attr('opacity', 0); + + await Promise.all([ + rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur), + animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur) + ]); + if (isCancelled) return; + + retEl.remove(); + retTip.remove(); + + await delay(300); + } + async function startJourney() { + const myId = ++animId; isPlaying = true; isFinished = false; isCancelled = false; + visitedCodes = new Set(); + + const width = frameEl.clientWidth; + const height = frameEl.clientHeight; + + svg.selectAll('*').remove(); + gBase = svg.append('g'); + gCountries = svg.append('g'); + gAnim = svg.append('g'); + + setupProjection(width, height); + + if (mode === 'globe' && homeFeature) { + const c = d3.geoCentroid(homeFeature); + projection.rotate([-c[0], -c[1]]); + pathFn = d3.geoPath().projection(projection); + } + + renderMap(); const trips = MOCK_TRIPS; const total = trips.length; for (let i = 0; i < total; i++) { - if (isCancelled) break; + if (isCancelled || myId !== animId) break; const trip = trips[i]; + currentDateLabel = formatDateLabel(trip.date); const destFeature = featuresById[trip.countryCode]; if (!destFeature) continue; @@ -232,11 +479,11 @@ await animateTrip(trip.countryCode, destFeature); } - if (!isCancelled) { + if (!isCancelled && myId === animId) { isFinished = true; isPlaying = false; if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' }); - } else { + } else if (myId === animId) { isPlaying = false; } } @@ -246,23 +493,37 @@ isPlaying = false; } + function replay() { + stopJourney(); + setTimeout(() => startJourney(), 100); + } + + function switchMode(target) { + if (target === mode) return; + onmodechange?.(target); + stopJourney(); + setTimeout(() => startJourney(), 100); + } + + function close() { + stopJourney(); + onclose?.(); + } + onMount(() => { const width = frameEl.clientWidth; const height = frameEl.clientHeight; - projection = d3.geoMercator(); - fitProjection(projection, width, height); + setupProjection(width, height); - pathFn = d3.geoPath().projection(projection); - - const countries = feature(worldData, worldData.objects.countries) + countriesData = feature(worldData, worldData.objects.countries) .features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010'); - countries.forEach(f => { + countriesData.forEach(f => { if (!f.id) f.id = 'XK'; }); - for (const f of countries) { + for (const f of countriesData) { featuresById[effId(f)] = f; } @@ -274,46 +535,32 @@ .attr('height', height) .style('cursor', 'default'); - g = svg.append('g'); + gBase = svg.append('g'); + gCountries = svg.append('g'); + gAnim = svg.append('g'); - countryPaths = g.selectAll('path') - .data(countries) - .join('path') - .attr('d', pathFn) - .attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED) - .attr('stroke', '#d4d4d4') - .attr('stroke-width', 0.5); - - function renderMicrostates() { - g.selectAll('.micro-state-j').remove(); - const threshold = 4; - countryPaths.each(function (d) { - if (effId(d) !== d.id) return; - const { width, height } = this.getBBox(); - if (width < threshold && height < threshold) { - const [cx, cy] = pathFn.centroid(d); - g.append('circle') - .attr('class', 'micro-state-j') - .datum(d) - .attr('cx', cx) - .attr('cy', cy) - .attr('r', 2) - .attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED) - .attr('stroke', '#94a3b8') - .attr('stroke-width', 0.5); - } - }); - } - - renderMicrostates(); + renderMap(); const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; svg.attr('width', width).attr('height', height); - fitProjection(projection, width, height); - countryPaths.attr('d', pathFn); - renderMicrostates(); + const prevProj = projection; + if (mode === 'map') { + projection = d3.geoMercator(); + projection.fitSize([width, height], { type: 'Sphere' }); + const s = projection.scale() * 1.5; + projection.scale(s).translate([width / 2, height * 0.70]); + } else { + const size = Math.min(width, height) * 0.92; + projection = d3.geoOrthographic() + .rotate(prevProj.rotate()) + .fitSize([size, size], { type: 'Sphere' }) + .translate([width / 2, height / 2]); + } + pathFn = d3.geoPath().projection(projection); + redrawBase(); + if (mode === 'map') renderMicrostates(); } }); @@ -329,11 +576,25 @@ }); -
- - {#if isFinished} -
Journey complete!
- {/if} +
+
+ {#if isFinished} + Journey complete! + {:else if currentDateLabel} + {currentDateLabel} + {/if} +
+
+ + + +
From 65248fd0822440c98de29b1f5e404af3e9a43d97 Mon Sep 17 00:00:00 2001 From: Tomas Horsky Date: Tue, 16 Jun 2026 01:39:59 +0900 Subject: [PATCH 2/5] updated adding jurney --- src/lib/shared/cities.js | 209 ++++++++++++++++++++++++++ src/lib/timeline/EditForm.svelte | 31 ++-- src/lib/timeline/JournalDetail.svelte | 14 +- src/lib/timeline/NewEntryForm.svelte | 131 +++++++++------- src/lib/timeline/TimelineView.svelte | 9 +- 5 files changed, 308 insertions(+), 86 deletions(-) create mode 100644 src/lib/shared/cities.js diff --git a/src/lib/shared/cities.js b/src/lib/shared/cities.js new file mode 100644 index 0000000..7ff380e --- /dev/null +++ b/src/lib/shared/cities.js @@ -0,0 +1,209 @@ +export const ALL_CITIES = { + 'Afghanistan': ['Kabul', 'Herat', 'Kandahar', 'Mazar-i-Sharif', 'Jalalabad'], + 'Albania': ['Tirana', 'Durrës', 'Vlorë', 'Shkodër', 'Sarandë'], + 'Algeria': ['Algiers', 'Oran', 'Constantine', 'Annaba', 'Tlemcen'], + 'Angola': ['Luanda', 'Huambo', 'Benguela', 'Lubango', 'Malanje'], + 'Argentina': ['Buenos Aires', 'Córdoba', 'Rosario', 'Mendoza', 'Bariloche', 'Salta', 'Ushuaia', 'Mar del Plata', 'Iguazú'], + 'Armenia': ['Yerevan', 'Gyumri', 'Vanadzor', 'Vagharshapat'], + 'Australia': ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Gold Coast', 'Cairns', 'Hobart', 'Darwin', 'Canberra', 'Newcastle'], + 'Austria': ['Vienna', 'Salzburg', 'Innsbruck', 'Graz', 'Linz', 'Hallstatt', 'Zell am See'], + 'Azerbaijan': ['Baku', 'Ganja', 'Sumqayit', 'Mingachevir', 'Nakhchivan'], + 'Bahamas': ['Nassau', 'Freeport', 'Marsh Harbour', 'George Town'], + 'Bahrain': ['Manama', 'Muharraq', 'Riffa', 'Hamad Town'], + 'Bangladesh': ['Dhaka', 'Chittagong', 'Sylhet', 'Cox\'s Bazar', 'Rajshahi', 'Khulna'], + 'Barbados': ['Bridgetown', 'Speightstown', 'Oistins', 'Holetown'], + 'Belarus': ['Minsk', 'Brest', 'Grodno', 'Vitebsk', 'Gomel'], + 'Belgium': ['Brussels', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Liège', 'Namur'], + 'Belize': ['Belize City', 'San Ignacio', 'Belmopan', 'Placencia', 'Caye Caulker'], + 'Benin': ['Porto-Novo', 'Cotonou', 'Parakou', 'Abomey'], + 'Bhutan': ['Thimphu', 'Paro', 'Punakha', 'Jakar'], + 'Bolivia': ['La Paz', 'Sucre', 'Santa Cruz', 'Cochabamba', 'Uyuni', 'Potosí'], + 'Bosnia and Herz.': ['Sarajevo', 'Mostar', 'Banja Luka', 'Tuzla', 'Zenica'], + 'Botswana': ['Gaborone', 'Maun', 'Kasane', 'Francistown'], + 'Brazil': ['Rio de Janeiro', 'São Paulo', 'Brasília', 'Salvador', 'Fortaleza', 'Recife', 'Porto Alegre', 'Curitiba', 'Manaus', 'Florianópolis', 'Belo Horizonte', 'Iguaçu Falls', 'Paraty', 'Bonito'], + 'Brunei': ['Bandar Seri Begawan', 'Kuala Belait', 'Seria', 'Tutong'], + 'Bulgaria': ['Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Bansko'], + 'Burkina Faso': ['Ouagadougou', 'Bobo-Dioulasso', 'Koudougou', 'Banfora'], + 'Burundi': ['Bujumbura', 'Gitega', 'Ngozi', 'Ruyigi'], + 'Cabo Verde': ['Praia', 'Mindelo', 'Santa Maria', 'Sal Rei'], + 'Cambodia': ['Phnom Penh', 'Siem Reap', 'Sihanoukville', 'Battambang', 'Kampot'], + 'Cameroon': ['Yaoundé', 'Douala', 'Bamenda', 'Garoua', 'Kribi'], + 'Canada': ['Toronto', 'Vancouver', 'Montreal', 'Calgary', 'Ottawa', 'Quebec City', 'Halifax', 'Whistler', 'Banff', 'Victoria', 'Edmonton'], + 'Chad': ['N\'Djamena', 'Moundou', 'Sarh', 'Abéché'], + 'Chile': ['Santiago', 'Valparaíso', 'Viña del Mar', 'Puerto Varas', 'San Pedro de Atacama', 'Punta Arenas', 'Easter Island (Rapa Nui)', 'Concepción', 'La Serena'], + 'China': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu', 'Hangzhou', 'Xi\'an', 'Kunming', 'Zhangjiajie', 'Guilin', 'Hong Kong', 'Macao', 'Lhasa', 'Suzhou', 'Nanjing', 'Chongqing', 'Wuhan', 'Harbin'], + 'Colombia': ['Bogotá', 'Medellín', 'Cartagena', 'Cali', 'Santa Marta', 'Bucaramanga', 'San Andrés', 'Leticia', 'Tayrona'], + 'Congo': ['Brazzaville', 'Pointe-Noire', 'Dolisie', 'Ouésso'], + 'Costa Rica': ['San José', 'Liberia', 'Puerto Viejo', 'La Fortuna', 'Monteverde', 'Manuel Antonio', 'Tamarindo'], + 'Croatia': ['Zagreb', 'Dubrovnik', 'Split', 'Zadar', 'Rovinj', 'Pula', 'Hvar', 'Šibenik', 'Trogir'], + 'Cuba': ['Havana', 'Varadero', 'Trinidad', 'Viñales', 'Santiago de Cuba', 'Cienfuegos'], + 'Curaçao': ['Willemstad', 'Westpunt', 'Sint Willibrordus'], + 'Cyprus': ['Nicosia', 'Limassol', 'Paphos', 'Larnaca', 'Ayia Napa'], + 'Czechia': ['Prague', 'Brno', 'Český Krumlov', 'Karlovy Vary', 'Plzeň', 'Olomouc', 'Ostrava', 'Liberec'], + 'Dem. Rep. Congo': ['Kinshasa', 'Lubumbashi', 'Goma', 'Bukavu', 'Kisangani'], + 'Denmark': ['Copenhagen', 'Aarhus', 'Odense', 'Aalborg', 'Ribe', 'Skagen', 'Bornholm', 'Møns Klint'], + 'Djibouti': ['Djibouti City', 'Tadjoura', 'Obock', 'Ali Sabieh'], + 'Dominican Rep.': ['Santo Domingo', 'Punta Cana', 'Puerto Plata', 'La Romana', 'Samaná', 'Sosúa'], + 'Ecuador': ['Quito', 'Guayaquil', 'Cuenca', 'Baños', 'Galápagos Islands', 'Otavalo', 'Montañita'], + 'Egypt': ['Cairo', 'Alexandria', 'Luxor', 'Aswan', 'Hurghada', 'Sharm el-Sheikh', 'Giza', 'Dahab'], + 'El Salvador': ['San Salvador', 'Santa Ana', 'San Miguel', 'La Libertad', 'Suchitoto'], + 'Eq. Guinea': ['Malabo', 'Bata', 'Ebebiyín'], + 'Eritrea': ['Asmara', 'Massawa', 'Keren', 'Assab'], + 'Estonia': ['Tallinn', 'Tartu', 'Pärnu', 'Kuressaare', 'Narva'], + 'Eswatini': ['Mbabane', 'Manzini', 'Big Bend', 'Mhlume'], + 'Ethiopia': ['Addis Ababa', 'Lalibela', 'Gondar', 'Axum', 'Bahir Dar', 'Harar'], + 'Faeroe Is.': ['Tórshavn', 'Klaksvík', 'Runavík', 'Vestmanna'], + 'Fiji': ['Suva', 'Nadi', 'Lautoka', 'Denarau', 'Coral Coast'], + 'Finland': ['Helsinki', 'Rovaniemi', 'Tampere', 'Turku', 'Levi', 'Savonlinna', 'Porvoo'], + 'France': ['Paris', 'Nice', 'Marseille', 'Lyon', 'Bordeaux', 'Toulouse', 'Strasbourg', 'Lille', 'Montpellier', 'Avignon', 'Arles', 'Cannes', 'Saint-Tropez', 'Annecy', 'Chamonix', 'Biarritz', 'Colmar'], + 'Gabon': ['Libreville', 'Port-Gentil', 'Franceville', 'Oyem'], + 'Gambia': ['Banjul', 'Serrekunda', 'Brikama', 'Bakau'], + 'Georgia': ['Tbilisi', 'Batumi', 'Kutaisi', 'Stepantsminda', 'Sighnaghi', 'Telavi', 'Mestia'], + 'Germany': ['Berlin', 'Munich', 'Hamburg', 'Frankfurt', 'Cologne', 'Stuttgart', 'Düsseldorf', 'Dresden', 'Leipzig', 'Nuremberg', 'Heidelberg', 'Freiburg', 'Hannover', 'Bremen', 'Bonn', 'Rothenburg ob der Tauber', 'Neuschwanstein'], + 'Ghana': ['Accra', 'Kumasi', 'Cape Coast', 'Tamale', 'Elmina', 'Takoradi'], + 'Greece': ['Athens', 'Santorini', 'Mykonos', 'Crete', 'Thessaloniki', 'Corfu', 'Rhodes', 'Naxos', 'Paros', 'Milos', 'Delphi', 'Meteora', 'Olympia', 'Zakynthos'], + 'Greenland': ['Nuuk', 'Ilulissat', 'Kangerlussuaq', 'Sisimiut'], + 'Grenada': ['St. George\'s', 'Gouyave', 'Grenville', 'Sauteurs'], + 'Guatemala': ['Guatemala City', 'Antigua', 'Lake Atitlán', 'Flores', 'Chichicastenango', 'Quetzaltenango', 'Semuc Champey'], + 'Guinea': ['Conakry', 'Kindia', 'Kankan', 'N\'Zérékoré', 'Labé'], + 'Guinea-Bissau': ['Bissau', 'Bafatá', 'Gabú', 'Cacheu'], + 'Guyana': ['Georgetown', 'Linden', 'New Amsterdam', 'Bartica'], + 'Haiti': ['Port-au-Prince', 'Cap-Haïtien', 'Jacmel', 'Les Cayes', 'Gonaïves'], + 'Honduras': ['Tegucigalpa', 'San Pedro Sula', 'La Ceiba', 'Roatán', 'Copán Ruinas'], + 'Hungary': ['Budapest', 'Debrecen', 'Szeged', 'Pécs', 'Eger', 'Siófok (Lake Balaton)', 'Visegrád', 'Hévíz'], + 'Iceland': ['Reykjavík', 'Akureyri', 'Vík', 'Höfn', 'Ísafjörður', 'Blue Lagoon', 'Thingvellir'], + 'India': ['Mumbai', 'Delhi', 'Jaipur', 'Agra', 'Varanasi', 'Goa', 'Kerala', 'Bangalore', 'Chennai', 'Kolkata', 'Hyderabad', 'Udaipur', 'Jaisalmer', 'Rishikesh', 'Darjeeling', 'Amritsar', 'Leh', 'Hampi', 'Mysore', 'Pondicherry'], + 'Indonesia': ['Bali (Denpasar)', 'Jakarta', 'Yogyakarta', 'Lombok', 'Komodo', 'Surabaya', 'Bandung', 'Medan', 'Makassar', 'Labuan Bajo', 'Raja Ampat', 'Gili Islands'], + 'Iran': ['Tehran', 'Isfahan', 'Shiraz', 'Mashhad', 'Tabriz', 'Yazd', 'Kashan'], + 'Iraq': ['Baghdad', 'Erbil', 'Basra', 'Najaf', 'Karbala', 'Sulaymaniyah'], + 'Ireland': ['Dublin', 'Galway', 'Cork', 'Killarney', 'Dingle', 'Cliffs of Moher', 'Kilkenny', 'Belfast (NI)', 'Ring of Kerry'], + 'Israel': ['Tel Aviv', 'Jerusalem', 'Haifa', 'Eilat', 'Dead Sea', 'Nazareth', 'Tiberias', 'Caesarea', 'Akko'], + 'Italy': ['Rome', 'Florence', 'Venice', 'Milan', 'Naples', 'Cinque Terre', 'Amalfi Coast', 'Positano', 'Capri', 'Verona', 'Bologna', 'Turin', 'Siena', 'Lake Como', 'Pisa', 'Palermo', 'Catania', 'Matera', 'Tuscany', 'Dolomites'], + 'Jamaica': ['Kingston', 'Montego Bay', 'Negril', 'Ocho Rios', 'Port Antonio'], + 'Japan': ['Tokyo', 'Osaka', 'Kyoto', 'Sapporo', 'Fukuoka', 'Hiroshima', 'Nara', 'Kanazawa', 'Nagoya', 'Yokohama', 'Kobe', 'Hakone', 'Nikko', 'Miyajima', 'Takayama', 'Okinawa', 'Kamakura', 'Fuji Five Lakes'], + 'Jordan': ['Amman', 'Petra', 'Wadi Rum', 'Dead Sea', 'Aqaba', 'Madaba', 'Jerash'], + 'Kazakhstan': ['Almaty', 'Nur-Sultan', 'Shymkent', 'Aktau', 'Karaganda'], + 'Kenya': ['Nairobi', 'Mombasa', 'Masai Mara', 'Diani Beach', 'Amboseli', 'Lake Nakuru', 'Tsavo', 'Nanyuki'], + 'Kiribati': ['Tarawa', 'Kiritimati (Christmas Island)'], + 'Kosovo': ['Pristina', 'Prizren', 'Peja', 'Gjakova', 'Mitrovica'], + 'Kuwait': ['Kuwait City', 'Salmiya', 'Hawally', 'Ahmadi', 'Jahra'], + 'Kyrgyzstan': ['Bishkek', 'Osh', 'Karakol', 'Jalal-Abad', 'Talas'], + 'Laos': ['Vientiane', 'Luang Prabang', 'Vang Vieng', 'Pakse', 'Savannakhet', 'Si Phan Don (4000 Islands)'], + 'Latvia': ['Riga', 'Jūrmala', 'Liepāja', 'Cēsis', 'Sigulda', 'Daugavpils'], + 'Lebanon': ['Beirut', 'Byblos', 'Baalbek', 'Tripoli', 'Sidon', 'Tyre', 'Jounieh'], + 'Lesotho': ['Maseru', 'Teyateyaneng', 'Mafeteng', 'Hlotse'], + 'Liberia': ['Monrovia', 'Buchanan', 'Ganta', 'Harper', 'Robertsport'], + 'Libya': ['Tripoli', 'Benghazi', 'Misrata', 'Sabratha', 'Leptis Magna'], + 'Liechtenstein': ['Vaduz', 'Schaan', 'Balzers', 'Triesenberg'], + 'Lithuania': ['Vilnius', 'Kaunas', 'Klaipėda', 'Šiauliai', 'Trakai', 'Palanga', 'Nida'], + 'Luxembourg': ['Luxembourg City', 'Echternach', 'Vianden', 'Ettelbruck'], + 'Madagascar': ['Antananarivo', 'Nosy Be', 'Morondava', 'Fianarantsoa', 'Isalo', 'Tôlanaro'], + 'Malawi': ['Lilongwe', 'Blantyre', 'Mzuzu', 'Lake Malawi', 'Zomba'], + 'Malaysia': ['Kuala Lumpur', 'Penang (George Town)', 'Langkawi', 'Borneo (Kota Kinabalu)', 'Malacca', 'Cameron Highlands', 'Johor Bahru', 'Kuching', 'Sipadan Island'], + 'Maldives': ['Malé', 'Ari Atoll', 'Baa Atoll', 'South Male Atoll', 'Addu City'], + 'Mali': ['Bamako', 'Timbuktu', 'Ségou', 'Mopti', 'Djenné'], + 'Malta': ['Valletta', 'Sliema', 'Gozo', 'Mellieħa', 'Mdina', 'St. Julian\'s', 'Comino'], + 'Marshall Is.': ['Majuro', 'Kwajalein', 'Ebeye'], + 'Mauritania': ['Nouakchott', 'Nouadhibou', 'Atar', 'Chinguetti', 'Ouadane'], + 'Mauritius': ['Port Louis', 'Grand Baie', 'Flic en Flac', 'Belle Mare', 'Le Morne', 'Chamarel'], + 'Mexico': ['Mexico City', 'Cancún', 'Playa del Carmen', 'Tulum', 'Guadalajara', 'Monterrey', 'Puerto Vallarta', 'Oaxaca', 'San Miguel de Allende', 'Mérida', 'Cabo San Lucas', 'Guanajuato', 'Chichen Itza', 'Palenque', 'Cuernavaca', 'Puebla'], + 'Micronesia': ['Palikir', 'Chuuk', 'Pohnpei', 'Yap', 'Kosrae'], + 'Moldova': ['Chișinău', 'Bălți', 'Tiraspol', 'Cahul', 'Orhei'], + 'Monaco': ['Monaco City', 'Monte Carlo', 'La Condamine', 'Fontvieille'], + 'Mongolia': ['Ulaanbaatar', 'Karakorum', 'Gobi Desert', 'Lake Khövsgöl', 'Altai', 'Erdenet'], + 'Montenegro': ['Podgorica', 'Kotor', 'Budva', 'Bar', 'Ulcinj', 'Žabljak', 'Perast'], + 'Morocco': ['Marrakech', 'Fes', 'Casablanca', 'Rabat', 'Tangier', 'Chefchaouen', 'Essaouira', 'Ouarzazate', 'Agadir', 'Meknes', 'Merzouga (Sahara)'], + 'Mozambique': ['Maputo', 'Beira', 'Tofo (Inhambane)', 'Vilankulo', 'Bazaruto Archipelago', 'Nampula'], + 'Myanmar': ['Yangon', 'Mandalay', 'Bagan', 'Inle Lake', 'Hpa-An', 'Ngapali Beach'], + 'Namibia': ['Windhoek', 'Swakopmund', 'Sossusvlei', 'Etosha National Park', 'Fish River Canyon', 'Walvis Bay'], + 'Nauru': ['Yaren', 'Boe', 'Aiwo'], + 'Nepal': ['Kathmandu', 'Pokhara', 'Chitwan', 'Lumbini', 'Everest Base Camp', 'Nagarkot', 'Bandipur'], + 'Netherlands': ['Amsterdam', 'Rotterdam', 'The Hague', 'Utrecht', 'Maastricht', 'Groningen', 'Leiden', 'Delft', 'Giethoorn', 'Haarlem', 'Zaanse Schans', 'Keukenhof'], + 'New Zealand': ['Auckland', 'Queenstown', 'Wellington', 'Christchurch', 'Rotorua', 'Milford Sound', 'Wanaka', 'Taupō', 'Dunedin', 'Tongariro', 'Abel Tasman', 'Bay of Islands'], + 'Nicaragua': ['Managua', 'Granada', 'León', 'San Juan del Sur', 'Ometepe Island', 'Corn Islands'], + 'Niger': ['Niamey', 'Agadez', 'Zinder', 'Maradi', 'Tahoua'], + 'Nigeria': ['Lagos', 'Abuja', 'Port Harcourt', 'Calabar', 'Ibadan', 'Kano', 'Enugu', 'Jos'], + 'North Korea': ['Pyongyang', 'Kaesong', 'Chongjin', 'Nampo', 'Wonsan'], + 'North Macedonia': ['Skopje', 'Ohrid', 'Bitola', 'Tetovo', 'Struga'], + 'Norway': ['Oslo', 'Bergen', 'Tromsø', 'Stavanger', 'Trondheim', 'Lofoten Islands', 'Geirangerfjord', 'Flåm', 'Alesund', 'Preikestolen', 'Nordkapp'], + 'Oman': ['Muscat', 'Salalah', 'Nizwa', 'Sur', 'Wahiba Sands', 'Khasab', 'Sohar'], + 'Pakistan': ['Islamabad', 'Karachi', 'Lahore', 'Hunza Valley', 'Skardu', 'Faisalabad', 'Multan', 'Swat Valley'], + 'Palau': ['Ngerulmud', 'Koror', 'Peleliu'], + 'Palestine': ['Ramallah', 'Bethlehem', 'Hebron', 'Nablus', 'Jericho', 'Gaza'], + 'Panama': ['Panama City', 'Bocas del Toro', 'Boquete', 'San Blas Islands', 'El Valle de Antón'], + 'Papua New Guinea': ['Port Moresby', 'Lae', 'Mount Hagen', 'Kokopo', 'Alotau', 'Tufi'], + 'Paraguay': ['Asunción', 'Ciudad del Este', 'Encarnación', 'San Bernardino', 'Filadelfia'], + 'Peru': ['Lima', 'Cusco', 'Arequipa', 'Machu Picchu', 'Sacred Valley', 'Lake Titicaca', 'Iquitos (Amazon)', 'Paracas', 'Huaraz', 'Nazca', 'Máncora', 'Trujillo'], + 'Philippines': ['Manila', 'Cebu', 'Palawan (El Nido)', 'Siargao', 'Boracay', 'Davao', 'Bohol (Panglao)', 'Banaue Rice Terraces', 'Coron', 'Baguio', 'Puerto Princesa'], + 'Poland': ['Warsaw', 'Kraków', 'Gdańsk', 'Wrocław', 'Poznań', 'Zakopane', 'Gdynia', 'Łódź', 'Toruń', 'Szczecin', 'Lublin', 'Malbork', 'Morskie Oko'], + 'Portugal': ['Lisbon', 'Porto', 'Algarve (Faro)', 'Sintra', 'Madeira', 'Coimbra', 'Azores', 'Braga', 'Évora', 'Cascais', 'Douro Valley'], + 'Puerto Rico': ['San Juan', 'Ponce', 'Mayagüez', 'Culebra', 'Vieques', 'Rincón'], + 'Qatar': ['Doha', 'Al Wakrah', 'Al Khor', 'Mesaieed', 'Katara'], + 'Romania': ['Bucharest', 'Cluj-Napoca', 'Brașov', 'Sibiu', 'Sighișoara', 'Timișoara', 'Iași', 'Constanța', 'Transfăgărășan', 'Mamaia'], + 'Russia': ['Moscow', 'Saint Petersburg', 'Moscow', 'Sochi', 'Vladivostok', 'Kazan', 'Novosibirsk', 'Yekaterinburg', 'Irkutsk', 'Lake Baikal', 'Murmansk', 'Kaliningrad', 'Kamchatka', 'Krasnodar', 'Nizhny Novgorod', 'Rostov-on-Don'], + 'Rwanda': ['Kigali', 'Butare', 'Gisenyi', 'Volcanoes National Park', 'Akagera', 'Nyungwe Forest'], + 'S. Sudan': ['Juba', 'Malakal', 'Wau', 'Bor', 'Yei'], + 'Samoa': ['Apia', 'Salelologa', 'Lalomanu', 'Safua'], + 'San Marino': ['San Marino City', 'Borgo Maggiore', 'Serravalle'], + 'São Tomé and Principe': ['São Tomé', 'Santo António', 'Neves'], + 'Saudi Arabia': ['Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'AlUla', 'Abha', 'Tabuk', 'Neom'], + 'Senegal': ['Dakar', 'Saint-Louis', 'Gorée Island', 'Sine-Saloum Delta', 'Pink Lake (Lac Rose)', 'Cap Skirring'], + 'Serbia': ['Belgrade', 'Novi Sad', 'Niš', 'Subotica', 'Kragujevac', 'Zlatibor', 'Kopaonik'], + 'Seychelles': ['Mahé (Victoria)', 'Praslin', 'La Digue', 'Silhouette Island'], + 'Sierra Leone': ['Freetown', 'Bo', 'Kenema', 'Makeni', 'Bunce Island'], + 'Singapore': ['Singapore'], + 'Slovakia': ['Bratislava', 'Košice', 'Tatras (High Tatras)', 'Banská Štiavnica', 'Levoča', 'Žilina', 'Poprad'], + 'Slovenia': ['Ljubljana', 'Lake Bled', 'Piran', 'Maribor', 'Postojna Cave', 'Triglav National Park', 'Celje'], + 'Solomon Is.': ['Honiara', 'Gizo', 'Auki', 'Munda'], + 'Somalia': ['Mogadishu', 'Hargeisa', 'Kismayo', 'Baidoa', 'Berbera'], + 'South Africa': ['Cape Town', 'Johannesburg', 'Durban', 'Kruger National Park', 'Garden Route', 'Cape Winelands (Stellenbosch)', 'Port Elizabeth', 'Hermanus', 'Blyde River Canyon', 'Drakensberg', 'Pretoria', 'Soweto', 'Knysna'], + 'South Korea': ['Seoul', 'Busan', 'Jeju Island', 'Gyeongju', 'Incheon', 'Daegu', 'Daejeon', 'Gwangju', 'Jeonju', 'Seoraksan National Park', 'Andong', 'Suwon', 'Sokcho', 'Pyeongchang'], + 'Spain': ['Barcelona', 'Madrid', 'Seville', 'Granada', 'Valencia', 'Bilbao', 'San Sebastián', 'Mallorca', 'Ibiza', 'Tenerife', 'Córdoba', 'Málaga', 'Santiago de Compostela', 'Toledo', 'Ronda', 'Salamanca', 'Marbella', 'Costa Brava', 'Alhambra', 'Picos de Europa'], + 'Sri Lanka': ['Colombo', 'Kandy', 'Galle', 'Sigiriya', 'Ella', 'Mirissa', 'Anuradhapura', 'Polonnaruwa', 'Nuwara Eliya', 'Yala National Park'], + 'St. Kitts and Nevis': ['Basseterre', 'Charlestown', 'Frigate Bay', 'Nevis'], + 'St. Lucia': ['Castries', 'Soufrière', 'Gros Islet', 'Vieux Fort', 'Marigot Bay'], + 'St. Pierre and Miquelon': ['Saint-Pierre', 'Miquelon'], + 'St. Vin. and Gren.': ['Kingstown', 'Bequia', 'Mustique', 'Canouan', 'Union Island'], + 'Sudan': ['Khartoum', 'Omdurman', 'Port Sudan', 'Kassala', 'Nyala'], + 'Suriname': ['Paramaribo', 'Lelydorp', 'Brokopondo', 'Nieuw Nickerie'], + 'Sweden': ['Stockholm', 'Gothenburg', 'Malmö', 'Kiruna', 'Visby', 'Uppsala', 'Lund', 'Abisko National Park', 'Icehotel (Jukkasjärvi)', 'Smögen'], + 'Switzerland': ['Zurich', 'Geneva', 'Lucerne', 'Zermatt', 'Interlaken', 'Lugano', 'Lausanne', 'Bern', 'Grindelwald', 'St. Moritz', 'Jungfraujoch', 'Montreux', 'Matterhorn Glacier Paradise'], + 'Syria': ['Damascus', 'Aleppo', 'Palmyra', 'Homs', 'Latakia', 'Maaloula'], + 'Taiwan': ['Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Taroko Gorge', 'Sun Moon Lake', 'Alishan', 'Jiufen', 'Kenting', 'Yilan', 'Taoyuan', 'Hualien'], + 'Tajikistan': ['Dushanbe', 'Khujand', 'Pamir Mountains', 'Khorog', 'Bokhtar'], + 'Tanzania': ['Dar es Salaam', 'Zanzibar City', 'Arusha', 'Serengeti National Park', 'Kilimanjaro', 'Ngorongoro Crater', 'Mwanza', 'Mbeya', 'Mafia Island', 'Lake Manyara', 'Selous'], + 'Thailand': ['Bangkok', 'Chiang Mai', 'Phuket', 'Krabi', 'Pattaya', 'Koh Samui', 'Koh Phi Phi', 'Koh Tao', 'Ayutthaya', 'Chiang Rai', 'Hua Hin', 'Khao Sok', 'Pai', 'Kanchanaburi', 'Sukhothai', 'Koh Lanta', 'Railay Beach', 'Erawan National Park'], + 'Timor-Leste': ['Dili', 'Baucau', 'Same', 'Atauro Island', 'Jaco Island'], + 'Togo': ['Lomé', 'Kpalimé', 'Sokodé', 'Kara', 'Aneho'], + 'Tonga': ['Nuku\'alofa', 'Neiafu', 'Pangai', 'Ha\'apai', 'Eua'], + 'Trinidad and Tobago': ['Port of Spain', 'San Fernando', 'Tobago (Scarborough)', 'Chaguanas', 'Maracas Bay'], + 'Tunisia': ['Tunis', 'Sousse', 'Hammamet', 'Djerba', 'Sfax', 'Carthage', 'Douz (Sahara)', 'Matmata', 'Bizerte', 'El Jem'], + 'Turkey': ['Istanbul', 'Cappadocia (Göreme)', 'Antalya', 'Izmir', 'Bodrum', 'Fethiye', 'Pamukkale', 'Ephesus', 'Marmaris', 'Alanya', 'Ankara', 'Trabzon', 'Kas', 'Olympos', 'Gallipoli', 'Konya', 'Mardin', 'Butterfly Valley'], + 'Turkmenistan': ['Ashgabat', 'Mary', 'Turkmenbashi', 'Dashoguz', 'Köneürgenç'], + 'Tuvalu': ['Funafuti', 'Nanumea', 'Nukulaelae'], + 'U.S. Virgin Is.': ['Charlotte Amalie', 'Christiansted', 'Frederiksted', 'Cruz Bay'], + 'Uganda': ['Kampala', 'Jinja', 'Murchison Falls', 'Queen Elizabeth National Park', 'Bwindi Impenetrable Forest', 'Kibale', 'Lake Bunyonyi', 'Entebbe'], + 'Ukraine': ['Kyiv', 'Lviv', 'Odesa', 'Kharkiv', 'Carpathian Mountains', 'Dnipro', 'Chernivtsi', 'Kamianets-Podilskyi', 'Zaporizhzhia', 'Lutsk'], + 'United Arab Emirates': ['Dubai', 'Abu Dhabi', 'Sharjah', 'Fujairah', 'Ras Al Khaimah', 'Ajman', 'Hatta'], + 'United Kingdom': ['London', 'Edinburgh', 'Bath', 'York', 'Liverpool', 'Manchester', 'Birmingham', 'Cambridge', 'Oxford', 'Brighton', 'Cornwall', 'Bristol', 'Cardiff', 'Glasgow', 'Inverness', 'Belfast', 'Lake District', 'Scottish Highlands', 'St. Ives', 'Canterbury', 'Dover', 'Stratford-upon-Avon'], + 'United States of America': ['New York', 'Los Angeles', 'Chicago', 'San Francisco', 'Las Vegas', 'Miami', 'Orlando', 'Washington DC', 'Boston', 'Seattle', 'Portland', 'Denver', 'New Orleans', 'Nashville', 'Austin', 'San Diego', 'Honolulu', 'Grand Canyon', 'Yellowstone', 'Yosemite', 'Houston', 'Dallas', 'Atlanta', 'Philadelphia', 'Phoenix', 'San Antonio', 'Savannah', 'Charleston', 'Santa Fe', 'Anchorage', 'Maui', 'Kauai', 'Moab', 'Portland (ME)', 'Asheville', 'Sedona', 'Napa Valley'], + 'Uruguay': ['Montevideo', 'Punta del Este', 'Colonia del Sacramento', 'Piriapolis', 'Cabo Polonio', 'Rocha'], + 'Uzbekistan': ['Tashkent', 'Samarkand', 'Bukhara', 'Khiva', 'Shakhrisabz', 'Fergana', 'Nukus', 'Termez'], + 'Vanuatu': ['Port Vila', 'Luganville', 'Tanna Island', 'Pentecost', 'Espiritu Santo'], + 'Vatican': ['Vatican City'], + 'Venezuela': ['Caracas', 'Angel Falls (Canaima)', 'Margarita Island', 'Los Roques', 'Mérida', 'Roraima', 'Maracaibo', 'Valencia'], + 'Vietnam': ['Hanoi', 'Ho Chi Minh City (Saigon)', 'Ha Long Bay', 'Hoi An', 'Da Nang', 'Hue', 'Nha Trang', 'Phong Nha', 'Da Lat', 'Sapa', 'Phu Quoc', 'Mui Ne', 'Con Dao', 'Mekong Delta (Can Tho)', 'Ninh Binh', 'Son Doong Cave'], + 'W. Sahara': ['Laayoune', 'Dakhla', 'Smara', 'Boujdour'], + 'Yemen': ['Sana\'a', 'Aden', 'Socotra', 'Taiz', 'Mukalla', 'Shibam'], + 'Zambia': ['Lusaka', 'Victoria Falls', 'Livingstone', 'South Luangwa National Park', 'Kitwe', 'Ndola', 'Kasama'], + 'Zimbabwe': ['Harare', 'Victoria Falls', 'Bulawayo', 'Hwange National Park', 'Mutare', 'Gweru', 'Masvingo (Great Zimbabwe)'], +}; + +/** + * Get curated city suggestions for a given country name. + * @param {string} countryName + * @returns {string[]} + */ +export function getCitiesForCountry(countryName) { + return ALL_CITIES[countryName] || []; +} diff --git a/src/lib/timeline/EditForm.svelte b/src/lib/timeline/EditForm.svelte index 46b990f..cef4076 100644 --- a/src/lib/timeline/EditForm.svelte +++ b/src/lib/timeline/EditForm.svelte @@ -1,7 +1,8 @@ @@ -240,7 +241,7 @@ align-items: center; justify-content: space-between; padding: 0 20px; - height: 52px; + height: 60px; flex-shrink: 0; border-bottom: 1px solid var(--border); background: var(--bg); @@ -255,8 +256,8 @@ .topbar-right { justify-content: flex-end; } .topbar-title { - font-size: 14px; - font-weight: 400; + font-size: 16px; + font-weight: 500; color: var(--text-h); } @@ -265,13 +266,13 @@ align-items: center; gap: 6px; font-family: var(--sans); - font-size: 13px; - font-weight: 300; + font-size: 15px; + font-weight: 400; color: var(--text); background: none; border: 1px solid transparent; - border-radius: 8px; - padding: 6px 12px; + border-radius: 10px; + padding: 8px 14px; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; white-space: nowrap; diff --git a/src/lib/timeline/JournalDetail.svelte b/src/lib/timeline/JournalDetail.svelte index 56458bd..2a303bd 100644 --- a/src/lib/timeline/JournalDetail.svelte +++ b/src/lib/timeline/JournalDetail.svelte @@ -1,5 +1,5 @@ @@ -161,17 +165,26 @@
{#if step === 1} + +

+ {#if country.trim()} + Journal your trip to {country}! + {:else} + Journal your trip! + {/if} +

+

Trip details

- + {#if errors.country}{errors.country}{/if}
- + {#if errors.cities}{errors.cities}{/if} {#if cities.length > 0} @@ -186,24 +199,24 @@
- + {#if errors.date}{errors.date}{/if}
- + {#if errors.days}{errors.days}{/if}
- +
{#each ['solo','friends','family'] as t} {/each}
@@ -211,13 +224,13 @@
- +
{#each transportOptions as opt} -
@@ -232,11 +245,16 @@ {:else} -

Your memories

+

+ Your memories{cities.length > 0 ? ` of ${cities.join(', ')}` : country.trim() ? ` of ${country}` : ''} +

+ {#if cities.length > 0 || country.trim()} +

{cities.join(', ')}{cities.length > 0 && country.trim() ? `, ${country}` : country.trim()}

+ {/if} {#each questions as q, i}
-

{q}

+

{q}{country.trim() ? ` in ${country}` : ''}

{/each} @@ -299,13 +317,13 @@ align-items: center; gap: 6px; font-family: var(--sans); - font-size: 13px; - font-weight: 300; + font-size: 15px; + font-weight: 400; color: var(--text); background: none; border: 1px solid transparent; - border-radius: 8px; - padding: 6px 10px; + border-radius: 10px; + padding: 8px 14px; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; } @@ -313,13 +331,13 @@ .save-btn { font-family: var(--sans); - font-size: 13px; - font-weight: 300; + font-size: 15px; + font-weight: 400; color: #fff; background: var(--accent); border: 1px solid var(--accent); - border-radius: 8px; - padding: 7px 14px; + border-radius: 10px; + padding: 8px 18px; cursor: pointer; transition: background 0.15s; white-space: nowrap; @@ -346,6 +364,14 @@ letter-spacing: -0.3px; margin: 0 0 2px; } + .page-headline { + font-size: 28px; + font-weight: 500; + color: var(--text-h); + letter-spacing: -0.5px; + margin: 0 0 4px; + } + .page-headline strong { font-weight: 600; } .step-sub { font-size: 13px; font-weight: 300; @@ -366,8 +392,9 @@ color: var(--text-sub); } .req { color: var(--accent); font-size: 11px; } + .kw { color: var(--accent); } - .ferr { font-size: 11px; color: #dc2626; } + .ferr { font-size: 13px; font-weight: 500; color: #dc2626; } .input { font-family: var(--sans); @@ -385,33 +412,22 @@ } .input:focus { border-color: var(--accent-border); } - .toggle-row { display: flex; gap: 8px; } + .toggle-row { display: flex; gap: 10px; flex-wrap: wrap; } .toggle-opt { - display: flex; align-items: center; gap: 6px; - font-size: 13px; font-weight: 300; color: var(--text); - padding: 7px 14px; border-radius: 8px; + display: flex; align-items: center; justify-content: center; gap: 8px; + font-size: 16px; font-weight: 400; color: var(--text); + padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border); - cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s; + cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s; background: var(--bg-subtle); + white-space: nowrap; } .toggle-opt input { display: none; } - .toggle-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); } + .toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + .toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); } - .transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } - .transport-opt { - display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 8px; aspect-ratio: 1; - border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle); - cursor: pointer; transition: border-color 0.15s, background 0.15s; - } - .transport-opt input { display: none; } - .transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); } - .transport-img { width: 60px; height: 60px; object-fit: contain; } - .transport-label { - font-size: 12px; font-weight: 300; color: var(--text-sub); - letter-spacing: 0.02em; - } - .transport-opt.active .transport-label { color: var(--accent); } + .transport-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } + .transport-img { width: 30px; height: 30px; object-fit: contain; flex-shrink: 0; } .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; } .tag { @@ -430,15 +446,16 @@ .q-card { display: flex; flex-direction: column; - gap: 10px; - background: var(--bg-subtle); - border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; + gap: 14px; + background: var(--bg); + border: 1.5px solid var(--accent-border); + border-radius: 16px; + padding: 28px; + box-shadow: 0 4px 20px rgba(0,0,0,0.06); } .q-text { - font-size: 14px; - font-weight: 400; + font-size: 20px; + font-weight: 500; color: var(--text-h); line-height: 1.5; margin: 0; @@ -446,10 +463,10 @@ } .q-input { font-family: var(--sans); - font-size: 13px; - font-weight: 300; + font-size: 16px; + font-weight: 400; color: var(--text-h); - background: var(--bg); + background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; diff --git a/src/lib/timeline/TimelineView.svelte b/src/lib/timeline/TimelineView.svelte index de7d0d3..624cdfa 100644 --- a/src/lib/timeline/TimelineView.svelte +++ b/src/lib/timeline/TimelineView.svelte @@ -1,6 +1,5 @@ -
- -
- {total} - countries visited -
+
+ -
+ {#if !collapsed} +
+

your statistics

- -
- {pct}% - of the world -
+
+
+ {pct}% +
+ {total} / {grandTotal} countries visited -
+
- -
- - {#if segments.length > 0} - {#each segments as seg} - hoveredSeg = seg} - onmouseleave={() => hoveredSeg = null}> - - - {/each} - - {:else} - - - {/if} - - -
- - {#if hoveredSeg} -
- {hoveredSeg.cont} - {counts[hoveredSeg.cont]} / {continentTotals[hoveredSeg.cont]} + by continent + {#each CONTINENTS as continent} + {@const contTotal = continentTotals[continent]} +
+ + {continent} + {counts[continent]}/{contTotal} + {#if visitedByContinent[continent]?.length > 0} +
+ {#each visitedByContinent[continent].slice(0, 10) as country} + {country} + {/each} + {#if visitedByContinent[continent].length > 10} + ... + {/if} +
+ {/if}
- {:else} - hover a slice - {/if} -
-
+ {/each} -
+
+ {#if segments.length > 0} + + {#each segments as seg} + + + {seg.cont} + + {/each} + + + {:else} + + + + + {/if} +
- -
- -
-
+
+ +
Contains all UN countries, Kosovo, Hong Kong and Taiwan
- All UN countries · Kosovo · HK · Taiwan -
+ {/if}
From 0a823948df615486b38595fbc93f3a45a331b939 Mon Sep 17 00:00:00 2001 From: Tomas Horsky Date: Tue, 16 Jun 2026 16:18:28 +0900 Subject: [PATCH 5/5] updated comunication with firebase --- src/lib/layout/selection.svelte.js | 16 +-------- src/lib/shared/types.js | 15 +++++++++ src/lib/stores/journalStore.js | 45 -------------------------- src/lib/timeline/EditForm.svelte | 2 +- src/lib/timeline/JournalDetail.svelte | 2 +- src/lib/timeline/JournalSummary.svelte | 2 +- src/lib/timeline/ShareCard.svelte | 2 +- src/lib/timeline/SharePreview.svelte | 2 +- src/lib/timeline/TimelineCard.svelte | 2 +- 9 files changed, 22 insertions(+), 66 deletions(-) create mode 100644 src/lib/shared/types.js delete mode 100644 src/lib/stores/journalStore.js diff --git a/src/lib/layout/selection.svelte.js b/src/lib/layout/selection.svelte.js index ee20752..cfdc6e2 100644 --- a/src/lib/layout/selection.svelte.js +++ b/src/lib/layout/selection.svelte.js @@ -1,5 +1,5 @@ import { db } from '../firebase.js'; -import { doc, onSnapshot, setDoc, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore'; +import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/firestore'; let selected = $state(new Set()); let totalCountries = $state(0); @@ -20,25 +20,12 @@ export function initSelectionListener(uid) { }); } -const visitedRef = doc(db, 'visited', 'countries'); - -onSnapshot(visitedRef, (snap) => { - if (snap.exists()) { - selected = new Set(snap.data().ids ?? []); - } -}); - -function persist() { - setDoc(visitedRef, { ids: [...selected] }); -} - export function toggle(id) { const was = selected.has(id); const next = new Set(selected); if (was) next.delete(id); else next.add(id); selected = next; - persist(); if (_uid) { const userRef = doc(db, 'users', _uid); if (was) updateDoc(userRef, { visitedCountries: arrayRemove(id) }); @@ -48,7 +35,6 @@ export function toggle(id) { export function clearAll() { selected = new Set(); - persist(); if (_uid) { const userRef = doc(db, 'users', _uid); updateDoc(userRef, { visitedCountries: [] }); diff --git a/src/lib/shared/types.js b/src/lib/shared/types.js new file mode 100644 index 0000000..3612192 --- /dev/null +++ b/src/lib/shared/types.js @@ -0,0 +1,15 @@ +/** + * @typedef {{ + * id: string, + * title: string, + * date: string, + * location: { country: string, cities: string[] }, + * photos: string[], + * transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk', + * tripType: 'solo' | 'friends' | 'family', + * days: number, + * memo: string + * }} JournalEntry + */ + +export {}; diff --git a/src/lib/stores/journalStore.js b/src/lib/stores/journalStore.js deleted file mode 100644 index de2aa6d..0000000 --- a/src/lib/stores/journalStore.js +++ /dev/null @@ -1,45 +0,0 @@ -import { writable } from 'svelte/store'; -import { db } from '../firebase.js'; -import { - collection, onSnapshot, addDoc, updateDoc, deleteDoc, doc, serverTimestamp -} from 'firebase/firestore'; - -/** - * @typedef {{ - * id: string, - * title: string, - * date: string, - * location: { country: string, cities: string[] }, - * photos: string[], - * transport: 'flight' | 'train' | 'bus' | 'car' | 'ship' | 'walk', - * tripType: 'solo' | 'friends' | 'family', - * days: number, - * memo: string - * }} JournalEntry - */ - -export const journals = writable(/** @type {JournalEntry[]} */([])); -export const journalsLoading = writable(true); - -const entriesRef = collection(db, 'entries'); - -onSnapshot(entriesRef, (snap) => { - journals.set(snap.docs.map(d => ({ id: d.id, ...d.data() }))); - journalsLoading.set(false); -}); - -/** @param {Omit} entry */ -export async function addJournal(entry) { - await addDoc(entriesRef, { ...entry, createdAt: serverTimestamp() }); -} - -/** @param {string} id */ -export async function removeJournal(id) { - await deleteDoc(doc(db, 'entries', id)); -} - -/** @param {JournalEntry} updated */ -export async function updateJournal(updated) { - const { id, ...data } = updated; - await updateDoc(doc(db, 'entries', id), data); -} diff --git a/src/lib/timeline/EditForm.svelte b/src/lib/timeline/EditForm.svelte index cef4076..19e5e9d 100644 --- a/src/lib/timeline/EditForm.svelte +++ b/src/lib/timeline/EditForm.svelte @@ -9,7 +9,7 @@ /** * entry = null → "new entry" mode * entry = {...} → "edit" mode - * @type {{ entry?: import('../stores/journalStore.js').JournalEntry | null, initialCountry?: string, onBack: () => void }} + * @type {{ entry?: import('../shared/types.js').JournalEntry | null, initialCountry?: string, onBack: () => void }} */ let { entry = null, initialCountry = '', onBack } = $props(); diff --git a/src/lib/timeline/JournalDetail.svelte b/src/lib/timeline/JournalDetail.svelte index 2a303bd..0a3bbaa 100644 --- a/src/lib/timeline/JournalDetail.svelte +++ b/src/lib/timeline/JournalDetail.svelte @@ -3,7 +3,7 @@ import { flagEmoji } from '../shared/countries.js'; import DeleteConfirm from './DeleteConfirm.svelte'; - /** @type {{ entry: import('../stores/journalStore.js').JournalEntry, onBack: () => void, onEdit: () => void }} */ + /** @type {{ entry: import('../shared/types.js').JournalEntry, onBack: () => void, onEdit: () => void }} */ let { entry, onBack, onEdit } = $props(); let showDeleteConfirm = $state(false); diff --git a/src/lib/timeline/JournalSummary.svelte b/src/lib/timeline/JournalSummary.svelte index 4ab1de8..1f213d1 100644 --- a/src/lib/timeline/JournalSummary.svelte +++ b/src/lib/timeline/JournalSummary.svelte @@ -1,5 +1,5 @@