3 Commits

Author SHA1 Message Date
d09946161f Merge branch 'feature/timeline' 2026-06-16 13:14:49 +09:00
87993ae9c6 updated animation 2026-06-16 12:36:37 +09:00
e9662754c4 add globe animation 2026-06-15 23:25:58 +09:00
5 changed files with 465 additions and 142 deletions

BIN
public/airplane.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -13,6 +13,7 @@
let journeyProgress = $state(null); let journeyProgress = $state(null);
let inDetail = $state(false); let inDetail = $state(false);
let pendingCountry = $state(''); let pendingCountry = $state('');
let journeyMode = $state('map');
function onNavigate(s) { function onNavigate(s) {
screen = s; screen = s;
@@ -56,10 +57,10 @@
<div class="worldmap-page"> <div class="worldmap-page">
<div class="map-area"> <div class="map-area">
{#if journeyActive} {#if journeyActive}
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} /> <JourneyView onclose={endJourney} onprogress={onJourneyProgress} mode={journeyMode} onmodechange={(m) => journeyMode = m} />
{:else} {:else}
<WorldMap onCountryClick={handleCountryClick} /> <WorldMap onCountryClick={handleCountryClick} />
<button class="journey-play-btn" onclick={startJourney}></button> <button class="journey-play-btn" onclick={startJourney}> Replay My Trips</button>
{/if} {/if}
</div> </div>
<StatsPanel /> <StatsPanel />
@@ -115,14 +116,14 @@
bottom: 24px; bottom: 24px;
right: 24px; right: 24px;
z-index: 10; z-index: 10;
width: 44px; padding: 12px 28px;
height: 44px; border-radius: 24px;
border-radius: 50%;
border: none; border: none;
background: #8b5cf6; background: #8b5cf6;
color: #fff; color: #fff;
font-size: 20px; font-size: 15px;
line-height: 1; font-weight: 600;
gap: 6px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -4,7 +4,7 @@
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json'; import worldData from 'world-atlas/countries-50m.json';
let { onclose, onprogress } = $props(); let { onclose, onprogress, mode = 'map', onmodechange } = $props();
const HOME_CODE = '203'; const HOME_CODE = '203';
@@ -15,13 +15,16 @@
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' }, { countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' }, { countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' }, { countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
{ countryName: 'Kenya', countryCode: '404', date: '2021-11-10', city: 'Nairobi' },
{ countryName: 'South Africa', countryCode: '710', date: '2026-02-05', city: 'Cape Town' },
]; ];
const HOME_COLOR = '#8b5cf6'; const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e'; const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#000000'; const ARC_COLOR = '#666666';
const PLANE_COLOR = '#7c3aed'; const PLANE_COLOR = '#7c3aed';
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z'; const PLANE_IMG = '/airplane.png';
const PLANE_SIZE = 28;
const UNVISITED = '#ffffff'; const UNVISITED = '#ffffff';
const TERRITORY_PARENT = { const TERRITORY_PARENT = {
@@ -40,18 +43,22 @@
} }
let frameEl; let frameEl;
let svg, g, pathFn, projection; let svg, gBase, gCountries, gAnim, pathFn, projection;
let countryPaths; let countryPaths;
let homeFeature; let homeFeature;
let featuresById = {}; let featuresById = {};
let countriesData = [];
let isCancelled = false; let isCancelled = false;
let isPlaying = $state(false); let isPlaying = $state(false);
let isFinished = $state(false); let isFinished = $state(false);
let visitedCodes = new Set();
let animId = 0;
let currentDateLabel = $state('');
function fitProjection(proj, w, h) { function formatDateLabel(dateStr) {
proj.fitSize([w, h], { type: 'Sphere' }); const d = new Date(dateStr);
const s = proj.scale() * 1.5; const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
proj.scale(s).translate([w / 2, h * 0.70]); return `${months[d.getMonth()]} ${d.getFullYear()}`;
} }
function computeArc(p1, p2) { function computeArc(p1, p2) {
@@ -87,7 +94,11 @@
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI; return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
} }
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) { function planeTransform(x, y, angle, flip) {
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
}
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration, flip = false, maskEl = null) {
return new Promise((resolve) => { return new Promise((resolve) => {
const node = pathEl.node(); const node = pathEl.node();
if (!node) { resolve(); return; } if (!node) { resolve(); return; }
@@ -102,15 +113,15 @@
const offset = startOffset + (endOffset - startOffset) * t; const offset = startOffset + (endOffset - startOffset) * t;
pathEl.attr('stroke-dashoffset', offset); pathEl.attr('stroke-dashoffset', offset);
if (maskEl) maskEl.attr('stroke-dashoffset', offset);
const drawn = totalLength - offset; const drawn = totalLength - offset;
const clamped = Math.max(0, Math.min(drawn, totalLength)); const clamped = Math.max(0, Math.min(drawn, totalLength));
try { try {
const pt = node.getPointAtLength(clamped); const pt = node.getPointAtLength(clamped);
const angle = getAngleAtLength(node, clamped); const angle = getAngleAtLength(node, clamped);
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1); tipEl.attr('transform', planeTransform(pt.x, pt.y, angle, flip)).attr('opacity', 1);
} catch (e) { } catch (e) {
// ignore SVG errors
} }
if (t >= 1) { if (t >= 1) {
@@ -128,58 +139,237 @@
}); });
} }
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 animateIncrementalPath(el, tip, pts, duration, flip = false) {
return new Promise((resolve) => {
const steps = pts.length - 1;
const lineGen = d3.line().curve(d3.curveBasis);
d3.timer(elapsed => {
if (isCancelled) { resolve(); return true; }
const t = Math.min(elapsed / duration, 1);
const count = Math.max(2, Math.floor(t * steps) + 1);
const visible = pts.slice(0, count);
if (visible.length >= 2) {
el.attr('d', lineGen(visible));
}
if (visible.length > 0) {
const last = visible[visible.length - 1];
let angle = 0;
if (visible.length >= 2) {
const prev = visible[visible.length - 2];
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
}
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) {
resolve();
return true;
}
});
});
}
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
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', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
}
if (t >= 1) {
timer.stop();
resolve();
return true;
}
});
});
}
async function animateTrip(destCode, destFeature) { async function animateTrip(destCode, destFeature) {
if (!homeFeature || !destFeature) return; if (!homeFeature || !destFeature) return;
const homeCentroid = d3.geoCentroid(homeFeature); const homeCentroid = d3.geoCentroid(homeFeature);
const destCentroid = d3.geoCentroid(destFeature); 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); const pts = computeArc(homeCentroid, destCentroid);
if (pts.length < 2) return; if (pts.length < 2) return;
const lineGen = d3.line().curve(d3.curveBasis); const outFlip = pts[pts.length - 1][0] < pts[0][0];
const pathData = lineGen(pts);
if (!pathData) return; 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')
.attr('stroke-dasharray', '10, 6');
const outTip = gAnim.append('image')
.attr('href', PLANE_IMG)
.attr('width', PLANE_SIZE)
.attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2)
.attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet')
.attr('opacity', 0);
function createArc(pathData) { await animateIncrementalPath(outEl, outTip, pts, 2500, outFlip);
const el = g.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')
.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')
.attr('r', 4)
.attr('fill', PLANE_COLOR)
.attr('cx', pts[0][0])
.attr('cy', pts[0][1])
.attr('opacity', 1);
await animateStroke(outEl, outTip, outLen, 0, 2500);
if (isCancelled) return; if (isCancelled) return;
outEl.remove(); outEl.remove();
outTip.remove(); outTip.remove();
homeDot.remove();
// Color the destination country
const targetPath = countryPaths.filter(d => effId(d) === destCode); const targetPath = countryPaths.filter(d => effId(d) === destCode);
targetPath.transition().duration(500).attr('fill', VISITED_COLOR); 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) .filter(d => effId(d) === destCode)
.transition().duration(500) .transition().duration(500)
.attr('fill', VISITED_COLOR); .attr('fill', VISITED_COLOR);
@@ -187,42 +377,148 @@
await delay(800); await delay(800);
if (isCancelled) return; if (isCancelled) return;
// --- Return: dest -> home ---
const revPts = [...pts].reverse(); const revPts = [...pts].reverse();
const revData = d3.line().curve(d3.curveBasis)(revPts); const retFlip = revPts[revPts.length - 1][0] < revPts[0][0];
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 retEl = gAnim.append('path')
.attr('r', 4) .attr('fill', 'none')
.attr('fill', PLANE_COLOR) .attr('stroke', ARC_COLOR)
.attr('cx', revPts[0][0]) .attr('stroke-width', 2.5)
.attr('cy', revPts[0][1]) .attr('stroke-opacity', 0.8)
.attr('opacity', 1); .attr('stroke-linecap', 'round')
.attr('stroke-dasharray', '10, 6');
const retTip = gAnim.append('image')
.attr('href', PLANE_IMG)
.attr('width', PLANE_SIZE)
.attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2)
.attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet')
.attr('opacity', 0);
await animateStroke(retEl, retTip, retLen, 0, 2200); await animateIncrementalPath(retEl, retTip, revPts, 2200, retFlip);
if (isCancelled) return;
retEl.remove();
retTip.remove();
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')
.attr('stroke-dasharray', '10, 6');
const outTip = gAnim.append('image')
.attr('href', PLANE_IMG)
.attr('width', PLANE_SIZE)
.attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2)
.attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet')
.attr('opacity', 0);
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
const outFlipGlobe = outGcs.length >= 2 && outGcs[outGcs.length - 1][0] < outGcs[0][0];
await Promise.all([
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outFlipGlobe)
]);
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')
.attr('stroke-dasharray', '10, 6');
const retTip = gAnim.append('image')
.attr('href', PLANE_IMG)
.attr('width', PLANE_SIZE)
.attr('height', PLANE_SIZE)
.attr('x', -PLANE_SIZE / 2)
.attr('y', -PLANE_SIZE / 2)
.attr('preserveAspectRatio', 'xMidYMid meet')
.attr('opacity', 0);
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
const retFlipGlobe = retGcs.length >= 2 && retGcs[retGcs.length - 1][0] < retGcs[0][0];
await Promise.all([
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retFlipGlobe)
]);
if (isCancelled) return; if (isCancelled) return;
retEl.remove(); retEl.remove();
retTip.remove(); retTip.remove();
destDot.remove();
await delay(300); await delay(300);
} }
async function startJourney() { async function startJourney() {
const myId = ++animId;
isPlaying = true; isPlaying = true;
isFinished = false; isFinished = false;
isCancelled = 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 trips = MOCK_TRIPS;
const total = trips.length; const total = trips.length;
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
if (isCancelled) break; if (isCancelled || myId !== animId) break;
const trip = trips[i]; const trip = trips[i];
currentDateLabel = formatDateLabel(trip.date);
const destFeature = featuresById[trip.countryCode]; const destFeature = featuresById[trip.countryCode];
if (!destFeature) continue; if (!destFeature) continue;
@@ -232,11 +528,11 @@
await animateTrip(trip.countryCode, destFeature); await animateTrip(trip.countryCode, destFeature);
} }
if (!isCancelled) { if (!isCancelled && myId === animId) {
isFinished = true; isFinished = true;
isPlaying = false; isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' }); if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
} else { } else if (myId === animId) {
isPlaying = false; isPlaying = false;
} }
} }
@@ -246,23 +542,37 @@
isPlaying = false; 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(() => { onMount(() => {
const width = frameEl.clientWidth; const width = frameEl.clientWidth;
const height = frameEl.clientHeight; const height = frameEl.clientHeight;
projection = d3.geoMercator(); setupProjection(width, height);
fitProjection(projection, width, height);
pathFn = d3.geoPath().projection(projection); countriesData = feature(worldData, worldData.objects.countries)
const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010'); .features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => { countriesData.forEach(f => {
if (!f.id) f.id = 'XK'; if (!f.id) f.id = 'XK';
}); });
for (const f of countries) { for (const f of countriesData) {
featuresById[effId(f)] = f; featuresById[effId(f)] = f;
} }
@@ -274,46 +584,32 @@
.attr('height', height) .attr('height', height)
.style('cursor', 'default'); .style('cursor', 'default');
g = svg.append('g'); gBase = svg.append('g');
gCountries = svg.append('g');
gAnim = svg.append('g');
countryPaths = g.selectAll('path') renderMap();
.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();
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
for (const entry of entries) { for (const entry of entries) {
const { width, height } = entry.contentRect; const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height); svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height); const prevProj = projection;
countryPaths.attr('d', pathFn); if (mode === 'map') {
renderMicrostates(); 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 +625,25 @@
}); });
</script> </script>
<div bind:this={frameEl} class="journey-frame"> <div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button> <div class="top-label">
{#if isFinished} {#if isFinished}
<div class="done-badge">Journey complete!</div> Journey complete!
{/if} {:else if currentDateLabel}
{currentDateLabel}
{/if}
</div>
<div class="control-bar">
<button class="control-btn" onclick={replay}>
⟳ Replay
</button>
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
{mode === 'map' ? 'Globe animation' : 'Map animation'}
</button>
<button class="control-btn" onclick={close}>
✕ Back to Journaling
</button>
</div>
</div> </div>
<style> <style>
@@ -345,47 +655,59 @@
background: #a4c8e0; background: #a4c8e0;
} }
.journey-frame.globe-mode {
background: #ffffff;
}
.journey-frame :global(svg) { .journey-frame :global(svg) {
display: block; display: block;
} }
.close-btn { .top-label {
position: absolute; position: absolute;
top: 12px; top: 16px;
right: 12px; left: 16px;
z-index: 10;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.55);
color: #fff;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.close-btn:hover {
background: rgba(0,0,0,0.75);
}
.done-badge {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10; z-index: 10;
background: rgba(0,0,0,0.65); background: rgba(0,0,0,0.65);
color: #fff; color: #fff;
font-family: var(--heading, sans-serif); font-family: var(--heading, sans-serif);
font-size: 16px; font-size: 16px;
font-weight: 600;
padding: 10px 24px; padding: 10px 24px;
border-radius: 24px; border-radius: 24px;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.04em; letter-spacing: 0.04em;
min-width: 200px;
text-align: center;
box-sizing: border-box;
}
.control-bar {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.control-btn {
padding: 10px 24px;
border: none;
border-radius: 24px;
background: #8b5cf6;
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
white-space: nowrap;
font-family: inherit;
}
.control-btn:hover {
background: #7c3aed;
} }
</style> </style>