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}
+
+
+
+
+
+