diff --git a/public/airplane.png b/public/airplane.png new file mode 100644 index 0000000..7fa84fe Binary files /dev/null and b/public/airplane.png differ diff --git a/public/plane.webp b/public/plane.webp deleted file mode 100644 index 6003f20..0000000 Binary files a/public/plane.webp and /dev/null differ diff --git a/public/profile.jpg b/public/profile.jpg deleted file mode 100644 index fe57389..0000000 Binary files a/public/profile.jpg and /dev/null differ diff --git a/src/lib/world-map/JourneyView.svelte b/src/lib/world-map/JourneyView.svelte index d19175b..1698c91 100644 --- a/src/lib/world-map/JourneyView.svelte +++ b/src/lib/world-map/JourneyView.svelte @@ -15,13 +15,16 @@ { countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' }, { countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' }, { 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 VISITED_COLOR = '#22c55e'; - const ARC_COLOR = '#000000'; + const ARC_COLOR = '#666666'; 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 TERRITORY_PARENT = { @@ -91,7 +94,11 @@ 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) => { const node = pathEl.node(); if (!node) { resolve(); return; } @@ -106,13 +113,14 @@ const offset = startOffset + (endOffset - startOffset) * t; pathEl.attr('stroke-dashoffset', offset); + if (maskEl) maskEl.attr('stroke-dashoffset', offset); const drawn = totalLength - offset; const clamped = Math.max(0, Math.min(drawn, totalLength)); try { const pt = node.getPointAtLength(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) { } @@ -244,7 +252,41 @@ }); } - function animateReprojectingArc(el, tip, geoPts, lineGen, duration) { + 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; @@ -271,8 +313,7 @@ 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); + tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1); } if (t >= 1) { @@ -301,42 +342,29 @@ const pts = computeArc(homeCentroid, destCentroid); if (pts.length < 2) return; - const lineGen = d3.line().curve(d3.curveBasis); - const pathData = lineGen(pts); - if (!pathData) return; + const outFlip = pts[pts.length - 1][0] < pts[0][0]; - function createArc(pathData) { - 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 = gAnim.append('path') - .attr('d', PLANE_PATH) - .attr('fill', PLANE_COLOR) - .attr('opacity', 0); - return { el, tip }; - } + 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); - let { el: outEl, tip: outTip } = createArc(pathData); - const outLen = outEl.node().getTotalLength(); - outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen); - - const homeDot = gAnim.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); + await animateIncrementalPath(outEl, outTip, pts, 2500, outFlip); if (isCancelled) return; outEl.remove(); outTip.remove(); - homeDot.remove(); const targetPath = countryPaths.filter(d => effId(d) === destCode); targetPath.transition().duration(500).attr('fill', VISITED_COLOR); @@ -350,24 +378,29 @@ if (isCancelled) return; 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 retFlip = revPts[revPts.length - 1][0] < revPts[0][0]; - const destDot = gAnim.append('circle') - .attr('r', 4) - .attr('fill', PLANE_COLOR) - .attr('cx', revPts[0][0]) - .attr('cy', revPts[0][1]) - .attr('opacity', 1); + 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); - await animateStroke(retEl, retTip, retLen, 0, 2200); + await animateIncrementalPath(retEl, retTip, revPts, 2200, retFlip); if (isCancelled) return; retEl.remove(); retTip.remove(); - destDot.remove(); await delay(300); } @@ -390,15 +423,23 @@ .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('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) + animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outFlipGlobe) ]); if (isCancelled) return; @@ -419,15 +460,23 @@ .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('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) + animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retFlipGlobe) ]); if (isCancelled) return;