From e9662754c4f9cf18ad4dfffaabcaaf4d7cffde53 Mon Sep 17 00:00:00 2001 From: Tomas Horsky Date: Mon, 15 Jun 2026 23:25:58 +0900 Subject: [PATCH] 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} +
+
+ + + +