Files
Map-Jurnal/src/lib/world-map/JourneyView.svelte
2026-06-17 01:04:45 +09:00

411 lines
16 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { get } from 'svelte/store';
import { journals } from '../stores/entriesStore.svelte.js';
import airplaneImg from '../../assets/airplane-animation.png';
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
const HOME_CODE = '203';
const PLANE_SIZE = 26;
const HOME_COLOR = '#8b5cf6';
const VISITED_COLOR = '#22c55e';
const ARC_COLOR = '#666666';
const UNVISITED = '#ffffff';
const TERRITORY_PARENT = {
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
'850': '840', '876': '250',
};
function effId(d) { return TERRITORY_PARENT[d.id] || d.id; }
let frameEl;
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 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 raw = [];
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], 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 planeTransform(x, y, angle, flip) {
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
}
function delay(ms) {
return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); });
}
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 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 point = interp(t);
projection.rotate([-point[0], -point[1]]);
redrawBase();
if (t >= 1) { timer.stop(); resolve(); return true; }
});
});
}
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 => {
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 = 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 { 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();
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;
const revPts = [...pts].reverse();
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);
}
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() {
const myId = ++animId;
isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set();
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();
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.map(e => ({
countryName: e.location.country,
countryCode: nameToId[e.location.country] ?? null,
city: e.location.cities?.[0] ?? e.location.country,
transport: e.transport ?? 'flight',
date: e.date,
})).filter(t => t.countryCode);
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;
if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` });
await animateTrip(trip.countryCode, destFeature, trip.transport);
}
if (!isCancelled && myId === animId) {
isFinished = true; isPlaying = false;
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
setTimeout(() => close(), 2500);
} else if (myId === animId) { 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, height = frameEl.clientHeight;
setupProjection(width, height);
countriesData = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
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');
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);
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(); };
});
</script>
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
<div class="top-label">
{#if isFinished}Journey complete!{: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>
<style>
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
.journey-frame.globe-mode { background: #ffffff; }
.journey-frame :global(svg) { display: block; }
.top-label {
position: absolute; top: 16px; left: 16px; z-index: 10;
background: rgba(0,0,0,0.65); color: #fff;
font-family: var(--heading, sans-serif); font-size: 16px; font-weight: 600;
padding: 10px 24px; border-radius: 24px; white-space: nowrap;
letter-spacing: 0.04em; min-width: 200px; text-align: center;
}
.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: 14px; font-weight: 600;
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; font-family: inherit;
}
.control-btn:hover { background: #7c3aed; }
.control-btn:active { transform: scale(0.96); }
</style>