add jurney animation
This commit is contained in:
BIN
public/plane.webp
Normal file
BIN
public/plane.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -4,15 +4,32 @@
|
|||||||
import CountryPicker from './lib/auth/CountryPicker.svelte';
|
import CountryPicker from './lib/auth/CountryPicker.svelte';
|
||||||
import Layout from './lib/layout/Layout.svelte';
|
import Layout from './lib/layout/Layout.svelte';
|
||||||
import WorldMap from './lib/world-map/WorldMap.svelte';
|
import WorldMap from './lib/world-map/WorldMap.svelte';
|
||||||
|
import JourneyView from './lib/world-map/JourneyView.svelte';
|
||||||
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
||||||
import TimelineView from './lib/timeline/TimelineView.svelte';
|
import TimelineView from './lib/timeline/TimelineView.svelte';
|
||||||
|
|
||||||
let screen = $state('worldmap');
|
let screen = $state('worldmap');
|
||||||
|
let journeyActive = $state(false);
|
||||||
|
let journeyProgress = $state(null);
|
||||||
|
|
||||||
function onNavigate(s) {
|
function onNavigate(s) {
|
||||||
screen = s;
|
screen = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startJourney() {
|
||||||
|
journeyActive = true;
|
||||||
|
journeyProgress = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endJourney() {
|
||||||
|
journeyActive = false;
|
||||||
|
journeyProgress = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJourneyProgress(p) {
|
||||||
|
journeyProgress = p;
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
initAuth();
|
initAuth();
|
||||||
});
|
});
|
||||||
@@ -30,7 +47,14 @@
|
|||||||
<Layout {screen} {onNavigate}>
|
<Layout {screen} {onNavigate}>
|
||||||
{#if screen === 'worldmap'}
|
{#if screen === 'worldmap'}
|
||||||
<div class="worldmap-page">
|
<div class="worldmap-page">
|
||||||
<div class="map-area"><WorldMap /></div>
|
<div class="map-area">
|
||||||
|
{#if journeyActive}
|
||||||
|
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
|
||||||
|
{:else}
|
||||||
|
<WorldMap />
|
||||||
|
<button class="journey-play-btn" onclick={startJourney}>▶</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -71,5 +95,36 @@
|
|||||||
.map-area {
|
.map-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-play-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.4);
|
||||||
|
transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-play-btn:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-play-btn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
391
src/lib/world-map/JourneyView.svelte
Normal file
391
src/lib/world-map/JourneyView.svelte
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { feature } from 'topojson-client';
|
||||||
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
|
let { onclose, onprogress } = $props();
|
||||||
|
|
||||||
|
const HOME_CODE = '203';
|
||||||
|
|
||||||
|
const MOCK_TRIPS = [
|
||||||
|
{ countryName: 'Japan', countryCode: '392', date: '2024-03-15', city: 'Tokyo' },
|
||||||
|
{ countryName: 'France', countryCode: '191', date: '2024-06-20', city: 'Paris' },
|
||||||
|
{ countryName: 'Spain', countryCode: '724', date: '2024-09-10', city: 'Barcelona' },
|
||||||
|
{ 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOME_COLOR = '#8b5cf6';
|
||||||
|
const VISITED_COLOR = '#22c55e';
|
||||||
|
const ARC_COLOR = '#000000';
|
||||||
|
const PLANE_COLOR = '#000000';
|
||||||
|
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 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, g, pathFn, projection;
|
||||||
|
let countryPaths;
|
||||||
|
let homeFeature;
|
||||||
|
let featuresById = {};
|
||||||
|
let isCancelled = false;
|
||||||
|
let isPlaying = $state(false);
|
||||||
|
let isFinished = $state(false);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
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 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 animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const node = pathEl.node();
|
||||||
|
if (!node) { resolve(); return; }
|
||||||
|
const totalLength = node.getTotalLength();
|
||||||
|
|
||||||
|
if (totalLength === 0) { resolve(); return; }
|
||||||
|
|
||||||
|
d3.timer(elapsed => {
|
||||||
|
if (isCancelled) { 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})`).attr('opacity', 1);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore SVG errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
resolve();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (isCancelled) { resolve(); return; }
|
||||||
|
const id = setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateTrip(destCode, destFeature) {
|
||||||
|
if (!homeFeature || !destFeature) return;
|
||||||
|
|
||||||
|
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||||
|
const destCentroid = d3.geoCentroid(destFeature);
|
||||||
|
|
||||||
|
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')
|
||||||
|
.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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
retEl.remove();
|
||||||
|
retTip.remove();
|
||||||
|
destDot.remove();
|
||||||
|
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startJourney() {
|
||||||
|
isPlaying = true;
|
||||||
|
isFinished = false;
|
||||||
|
isCancelled = false;
|
||||||
|
|
||||||
|
const trips = MOCK_TRIPS;
|
||||||
|
const total = trips.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
if (isCancelled) break;
|
||||||
|
|
||||||
|
const trip = trips[i];
|
||||||
|
const destFeature = featuresById[trip.countryCode];
|
||||||
|
if (!destFeature) continue;
|
||||||
|
|
||||||
|
const label = `${trip.city}, ${trip.countryName}`;
|
||||||
|
if (onprogress) onprogress({ index: i + 1, total, label });
|
||||||
|
|
||||||
|
await animateTrip(trip.countryCode, destFeature);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
isFinished = true;
|
||||||
|
isPlaying = false;
|
||||||
|
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
||||||
|
} else {
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopJourney() {
|
||||||
|
isCancelled = true;
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const width = frameEl.clientWidth;
|
||||||
|
const height = frameEl.clientHeight;
|
||||||
|
|
||||||
|
projection = d3.geoMercator();
|
||||||
|
fitProjection(projection, width, height);
|
||||||
|
|
||||||
|
pathFn = d3.geoPath().projection(projection);
|
||||||
|
|
||||||
|
const countries = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(frameEl);
|
||||||
|
|
||||||
|
startJourney();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopJourney();
|
||||||
|
observer.disconnect();
|
||||||
|
if (svg) svg.remove();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={frameEl} class="journey-frame">
|
||||||
|
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
|
||||||
|
{#if isFinished}
|
||||||
|
<div class="done-badge">Journey complete!</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.journey-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #a4c8e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journey-frame :global(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
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;
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--heading, sans-serif);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateAllFills() {
|
function updateAllFills() {
|
||||||
if (!_paths || !_g) return;
|
|
||||||
const sel = getSelected();
|
const sel = getSelected();
|
||||||
const hc = getHomeCountryCode();
|
const hc = getHomeCountryCode();
|
||||||
|
if (!_paths || !_g) return;
|
||||||
_paths.attr('fill', d => countryColor(d, sel, hc));
|
_paths.attr('fill', d => countryColor(d, sel, hc));
|
||||||
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user