diff --git a/src/lib/world-map/JourneyView.svelte b/src/lib/world-map/JourneyView.svelte index cb5d735..ba708b3 100644 --- a/src/lib/world-map/JourneyView.svelte +++ b/src/lib/world-map/JourneyView.svelte @@ -12,7 +12,7 @@ import shipImg from '../../assets/ship.png'; import walkImg from '../../assets/walk.png'; - let { onclose, onprogress } = $props(); + let { onclose, onprogress, mode = 'map', onmodechange } = $props(); const HOME_CODE = '203'; @@ -24,12 +24,11 @@ ship: shipImg, walk: walkImg, }; + const PLANE_SIZE = 28; const HOME_COLOR = '#8b5cf6'; const VISITED_COLOR = '#22c55e'; - const ARC_COLOR = '#000000'; - 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 ARC_COLOR = '#666666'; const UNVISITED = '#ffffff'; const TERRITORY_PARENT = { @@ -43,393 +42,386 @@ '850': '840', '876': '250', }; - function effId(d) { - return TERRITORY_PARENT[d.id] || d.id; - } + function effId(d) { return TERRITORY_PARENT[d.id] || d.id; } 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) { const interp = d3.geoInterpolate(p1, p2); - const steps = 80; const raw = []; - - for (let i = 0; i <= steps; i++) { - const t = i / steps; - const geo = interp(t); - const pt = projection(geo); + for (let i = 0; i <= 80; i++) { + const t = i / 80; + const pt = projection(interp(t)); if (!pt) continue; raw.push({ t, x: pt[0], y: pt[1] }); } - if (raw.length < 2) return []; - - const first = raw[0]; - const last = raw[raw.length - 1]; - const dx = last.x - first.x; - const dy = last.y - first.y; - const dist = Math.sqrt(dx * dx + dy * dy); + const first = raw[0], last = raw[raw.length - 1]; + const dist = Math.sqrt((last.x-first.x)**2 + (last.y-first.y)**2); const arcH = Math.max(40, Math.min(200, dist * 0.22)); - return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]); } - function getAngleAtLength(node, len) { - const d = 0.5; - const total = node.getTotalLength(); - const p1 = node.getPointAtLength(Math.max(0, len - d)); - const p2 = node.getPointAtLength(Math.min(total, len + d)); - return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI; + function planeTransform(x, y, angle, flip) { + return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`; } - function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) { - return new Promise((resolve) => { - const node = pathEl.node(); - if (!node) { resolve(); return; } - const totalLength = node.getTotalLength(); + function delay(ms) { + return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); }); + } - if (totalLength === 0) { resolve(); return; } + 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); + } - d3.timer(elapsed => { - if (isCancelled) { resolve(); return true; } + 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 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 < 4 && height < 4) { + 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 interp = d3.geoInterpolate([-current[0], -current[1]], [lon, lat]); + const timer = d3.timer(elapsed => { + if (isCancelled) { timer.stop(); resolve(); return true; } const t = Math.min(elapsed / duration, 1); - const offset = startOffset + (endOffset - startOffset) * t; - - pathEl.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); - } catch (e) { - // ignore SVG errors - } - - if (t >= 1) { - resolve(); - return true; - } + const point = interp(t); + projection.rotate([-point[0], -point[1]]); + redrawBase(); + if (t >= 1) { timer.stop(); resolve(); return true; } }); }); } - function delay(ms) { + function createArcEl(iconSrc) { + const el = 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 tip = gAnim.append('image') + .attr('href', iconSrc).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); + return { el, tip }; + } + + function animateIncrementalPath(el, tip, pts, duration, flip = false) { return new Promise(resolve => { - if (isCancelled) { resolve(); return; } - const id = setTimeout(resolve, ms); + 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 * (pts.length - 1)) + 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 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 * (geoPts.length - 1)) + 1); + const screenPts = geoPts.slice(0, count).map(p => projection(p)).filter(Boolean); + 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, transport = 'flight') { if (!homeFeature || !destFeature) return; - + const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg; const homeCentroid = d3.geoCentroid(homeFeature); const destCentroid = d3.geoCentroid(destFeature); + if (mode === 'map') { + await animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc); + } else { + await animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc); + } + } + async function animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc) { 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 iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg; - const iconSize = 28; - const iconHalf = iconSize / 2; - - function createArc(pathData) { - 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('image') - .attr('href', iconSrc) - .attr('width', iconSize) - .attr('height', iconSize) - .attr('x', -iconHalf) - .attr('y', -iconHalf) - .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); + const { el: outEl, tip: outTip } = createArcEl(iconSrc); + await animateIncrementalPath(outEl, outTip, pts, 2500, pts[pts.length-1][0] < pts[0][0]); if (isCancelled) return; - - outEl.remove(); - 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') - .filter(d => effId(d) === destCode) - .transition().duration(500) - .attr('fill', VISITED_COLOR); - + outEl.remove(); outTip.remove(); + countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR); + visitedCodes.add(destCode); + gBase.selectAll('.micro-state-j').filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR); 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') - .attr('r', 4) - .attr('fill', PLANE_COLOR) - .attr('cx', revPts[0][0]) - .attr('cy', revPts[0][1]) - .attr('opacity', 1); - - await animateStroke(retEl, retTip, retLen, 0, 2200); + const { el: retEl, tip: retTip } = createArcEl(iconSrc); + await animateIncrementalPath(retEl, retTip, revPts, 2200, revPts[revPts.length-1][0] < revPts[0][0]); if (isCancelled) return; + retEl.remove(); retTip.remove(); + await delay(300); + } - retEl.remove(); - retTip.remove(); - destDot.remove(); - + async function animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc) { + const interp = d3.geoInterpolate(homeCentroid, destCentroid); + const geoPts = Array.from({ length: 81 }, (_, i) => interp(i / 80)); + const dur = Math.round(1500 + d3.geoDistance(homeCentroid, destCentroid) * 2500); + const lineGen = d3.line().curve(d3.curveBasis); + const { el: outEl, tip: outTip } = createArcEl(iconSrc); + const outGcs = geoPts.map(p => projection(p)).filter(Boolean); + await Promise.all([ + rotateGlobeTo(destCentroid[0], destCentroid[1], dur), + animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outGcs.length >= 2 && outGcs[outGcs.length-1][0] < outGcs[0][0]), + ]); + if (isCancelled) return; + outEl.remove(); outTip.remove(); + countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR); + visitedCodes.add(destCode); + await delay(600); + if (isCancelled) return; + const revGeoPts = [...geoPts].reverse(); + const { el: retEl, tip: retTip } = createArcEl(iconSrc); + const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean); + await Promise.all([ + rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur), + animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retGcs.length >= 2 && retGcs[retGcs.length-1][0] < retGcs[0][0]), + ]); + if (isCancelled) return; + retEl.remove(); retTip.remove(); await delay(300); } async function startJourney() { - isPlaying = true; - isFinished = false; - isCancelled = false; + const myId = ++animId; + isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set(); - // Build name → numeric ID map from loaded features - const nameToId = {}; - for (const [id, feat] of Object.entries(featuresById)) { - if (feat.properties?.name) nameToId[feat.properties.name] = id; + const width = frameEl.clientWidth, 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(); - // Use real journal entries, sorted by date + const nameToId = Object.fromEntries(Object.entries(featuresById).filter(([,f]) => f.properties?.name).map(([id, f]) => [f.properties.name, id])); const entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date)); const trips = entries.length > 0 ? entries.map(e => ({ countryName: e.location.country, countryCode: nameToId[e.location.country] ?? null, - city: e.location.cities[0] ?? e.location.country, + city: e.location.cities?.[0] ?? e.location.country, transport: e.transport ?? 'flight', + date: e.date, })).filter(t => t.countryCode) : [ - { countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight' }, - { countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight' }, - { countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight' }, - { countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight' }, - { countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight' }, - { countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight' }, + { countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' }, + { countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' }, + { countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' }, + { countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' }, + { countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' }, + { countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' }, ]; - const total = trips.length; - - for (let i = 0; i < total; i++) { - if (isCancelled) break; - + for (let i = 0; i < trips.length; i++) { + if (isCancelled || myId !== animId) break; const trip = trips[i]; + if (trip.date) currentDateLabel = formatDateLabel(trip.date); const destFeature = featuresById[trip.countryCode]; if (!destFeature) continue; - - const label = `${trip.city}, ${trip.countryName}`; - if (onprogress) onprogress({ index: i + 1, total, label }); - + if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` }); await animateTrip(trip.countryCode, destFeature, trip.transport); } - if (!isCancelled) { - isFinished = true; - isPlaying = false; + if (!isCancelled && myId === animId) { + isFinished = true; isPlaying = false; if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' }); - } else { - isPlaying = false; - } + } else if (myId === animId) { isPlaying = false; } } - function stopJourney() { - isCancelled = true; - isPlaying = false; + function stopJourney() { isCancelled = true; 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; + const width = frameEl.clientWidth, height = frameEl.clientHeight; + setupProjection(width, height); - projection = d3.geoMercator(); - fitProjection(projection, 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 => { - if (!f.id) f.id = 'XK'; - }); - - for (const f of countries) { - featuresById[effId(f)] = f; - } - + countriesData.forEach(f => { if (!f.id) f.id = 'XK'; }); + for (const f of countriesData) featuresById[effId(f)] = f; homeFeature = featuresById[HOME_CODE]; - svg = d3.select(frameEl) - .append('svg') - .attr('width', width) - .attr('height', height) - .style('cursor', 'default'); - - g = 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(); + svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height).style('cursor', 'default'); + gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g'); + 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 prevRotate = mode === 'globe' ? projection.rotate() : null; + if (mode === 'map') { + projection = d3.geoMercator(); + projection.fitSize([width, height], { type: 'Sphere' }); + projection.scale(projection.scale() * 1.5).translate([width / 2, height * 0.70]); + } else { + const size = Math.min(width, height) * 0.92; + projection = d3.geoOrthographic().rotate(prevRotate).fitSize([size, size], { type: 'Sphere' }).translate([width / 2, height / 2]); + } + pathFn = d3.geoPath().projection(projection); + redrawBase(); + if (mode === 'map') renderMicrostates(); } }); - observer.observe(frameEl); - startJourney(); - - return () => { - stopJourney(); - observer.disconnect(); - if (svg) svg.remove(); - }; + return () => { stopJourney(); observer.disconnect(); if (svg) svg.remove(); }; }); -
- - {#if isFinished} -
Journey complete!
- {/if} +
+
+ {#if isFinished}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if} +
+
+ + + +