merge globe 3D view with transport icons and real journal data
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
import shipImg from '../../assets/ship.png';
|
import shipImg from '../../assets/ship.png';
|
||||||
import walkImg from '../../assets/walk.png';
|
import walkImg from '../../assets/walk.png';
|
||||||
|
|
||||||
let { onclose, onprogress } = $props();
|
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||||
|
|
||||||
const HOME_CODE = '203';
|
const HOME_CODE = '203';
|
||||||
|
|
||||||
@@ -24,12 +24,11 @@
|
|||||||
ship: shipImg,
|
ship: shipImg,
|
||||||
walk: walkImg,
|
walk: walkImg,
|
||||||
};
|
};
|
||||||
|
const PLANE_SIZE = 28;
|
||||||
|
|
||||||
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_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 UNVISITED = '#ffffff';
|
||||||
|
|
||||||
const TERRITORY_PARENT = {
|
const TERRITORY_PARENT = {
|
||||||
@@ -43,393 +42,386 @@
|
|||||||
'850': '840', '876': '250',
|
'850': '840', '876': '250',
|
||||||
};
|
};
|
||||||
|
|
||||||
function effId(d) {
|
function effId(d) { return TERRITORY_PARENT[d.id] || d.id; }
|
||||||
return TERRITORY_PARENT[d.id] || d.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
const interp = d3.geoInterpolate(p1, p2);
|
const interp = d3.geoInterpolate(p1, p2);
|
||||||
const steps = 80;
|
|
||||||
const raw = [];
|
const raw = [];
|
||||||
|
for (let i = 0; i <= 80; i++) {
|
||||||
for (let i = 0; i <= steps; i++) {
|
const t = i / 80;
|
||||||
const t = i / steps;
|
const pt = projection(interp(t));
|
||||||
const geo = interp(t);
|
|
||||||
const pt = projection(geo);
|
|
||||||
if (!pt) continue;
|
if (!pt) continue;
|
||||||
raw.push({ t, x: pt[0], y: pt[1] });
|
raw.push({ t, x: pt[0], y: pt[1] });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.length < 2) return [];
|
if (raw.length < 2) return [];
|
||||||
|
const first = raw[0], last = raw[raw.length - 1];
|
||||||
const first = raw[0];
|
const dist = Math.sqrt((last.x-first.x)**2 + (last.y-first.y)**2);
|
||||||
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));
|
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)]);
|
return raw.map(p => [p.x, p.y - arcH * Math.sin(Math.PI * p.t)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAngleAtLength(node, len) {
|
function planeTransform(x, y, angle, flip) {
|
||||||
const d = 0.5;
|
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
|
||||||
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) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => {
|
return new Promise(resolve => { if (isCancelled) { resolve(); return; } setTimeout(resolve, ms); });
|
||||||
const node = pathEl.node();
|
}
|
||||||
if (!node) { resolve(); return; }
|
|
||||||
const totalLength = node.getTotalLength();
|
|
||||||
|
|
||||||
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 => {
|
function renderMap() {
|
||||||
if (isCancelled) { resolve(); return true; }
|
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 t = Math.min(elapsed / duration, 1);
|
||||||
const offset = startOffset + (endOffset - startOffset) * t;
|
const point = interp(t);
|
||||||
|
projection.rotate([-point[0], -point[1]]);
|
||||||
pathEl.attr('stroke-dashoffset', offset);
|
redrawBase();
|
||||||
|
if (t >= 1) { timer.stop(); resolve(); return true; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 => {
|
return new Promise(resolve => {
|
||||||
if (isCancelled) { resolve(); return; }
|
const lineGen = d3.line().curve(d3.curveBasis);
|
||||||
const id = setTimeout(resolve, ms);
|
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') {
|
async function animateTrip(destCode, destFeature, transport = 'flight') {
|
||||||
if (!homeFeature || !destFeature) return;
|
if (!homeFeature || !destFeature) return;
|
||||||
|
const iconSrc = TRANSPORT_IMG[transport] ?? airplaneImg;
|
||||||
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, iconSrc);
|
||||||
|
} else {
|
||||||
|
await animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateMapTrip(homeCentroid, destCentroid, destCode, iconSrc) {
|
||||||
const pts = computeArc(homeCentroid, destCentroid);
|
const pts = computeArc(homeCentroid, destCentroid);
|
||||||
if (pts.length < 2) return;
|
if (pts.length < 2) return;
|
||||||
|
const { el: outEl, tip: outTip } = createArcEl(iconSrc);
|
||||||
const lineGen = d3.line().curve(d3.curveBasis);
|
await animateIncrementalPath(outEl, outTip, pts, 2500, pts[pts.length-1][0] < pts[0][0]);
|
||||||
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);
|
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
outEl.remove(); outTip.remove();
|
||||||
outEl.remove();
|
countryPaths.filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
outTip.remove();
|
visitedCodes.add(destCode);
|
||||||
homeDot.remove();
|
gBase.selectAll('.micro-state-j').filter(d => effId(d) === destCode).transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
|
|
||||||
// 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);
|
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 { el: retEl, tip: retTip } = createArcEl(iconSrc);
|
||||||
let { el: retEl, tip: retTip } = createArc(revData);
|
await animateIncrementalPath(retEl, retTip, revPts, 2200, revPts[revPts.length-1][0] < revPts[0][0]);
|
||||||
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;
|
if (isCancelled) return;
|
||||||
|
retEl.remove(); retTip.remove();
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
|
||||||
retEl.remove();
|
async function animateGlobeTrip(homeCentroid, destCentroid, destCode, iconSrc) {
|
||||||
retTip.remove();
|
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
|
||||||
destDot.remove();
|
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);
|
await delay(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startJourney() {
|
async function startJourney() {
|
||||||
isPlaying = true;
|
const myId = ++animId;
|
||||||
isFinished = false;
|
isPlaying = true; isFinished = false; isCancelled = false; visitedCodes = new Set();
|
||||||
isCancelled = false;
|
|
||||||
|
|
||||||
// Build name → numeric ID map from loaded features
|
const width = frameEl.clientWidth, height = frameEl.clientHeight;
|
||||||
const nameToId = {};
|
svg.selectAll('*').remove();
|
||||||
for (const [id, feat] of Object.entries(featuresById)) {
|
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
|
||||||
if (feat.properties?.name) nameToId[feat.properties.name] = id;
|
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 entries = get(journals).slice().sort((a, b) => a.date.localeCompare(b.date));
|
||||||
const trips = entries.length > 0
|
const trips = entries.length > 0
|
||||||
? entries.map(e => ({
|
? entries.map(e => ({
|
||||||
countryName: e.location.country,
|
countryName: e.location.country,
|
||||||
countryCode: nameToId[e.location.country] ?? null,
|
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',
|
transport: e.transport ?? 'flight',
|
||||||
|
date: e.date,
|
||||||
})).filter(t => t.countryCode)
|
})).filter(t => t.countryCode)
|
||||||
: [
|
: [
|
||||||
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight' },
|
{ countryName: 'Japan', countryCode: '392', city: 'Tokyo', transport: 'flight', date: '2024-03-15' },
|
||||||
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight' },
|
{ countryName: 'France', countryCode: '250', city: 'Paris', transport: 'flight', date: '2024-06-20' },
|
||||||
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight' },
|
{ countryName: 'Spain', countryCode: '724', city: 'Barcelona', transport: 'flight', date: '2024-09-10' },
|
||||||
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight' },
|
{ countryName: 'United States of America', countryCode: '840', city: 'New York', transport: 'flight', date: '2025-01-05' },
|
||||||
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight' },
|
{ countryName: 'Thailand', countryCode: '764', city: 'Bangkok', transport: 'flight', date: '2025-04-18' },
|
||||||
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight' },
|
{ countryName: 'Australia', countryCode: '036', city: 'Sydney', transport: 'flight', date: '2025-08-22' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = trips.length;
|
for (let i = 0; i < trips.length; i++) {
|
||||||
|
if (isCancelled || myId !== animId) break;
|
||||||
for (let i = 0; i < total; i++) {
|
|
||||||
if (isCancelled) break;
|
|
||||||
|
|
||||||
const trip = trips[i];
|
const trip = trips[i];
|
||||||
|
if (trip.date) currentDateLabel = formatDateLabel(trip.date);
|
||||||
const destFeature = featuresById[trip.countryCode];
|
const destFeature = featuresById[trip.countryCode];
|
||||||
if (!destFeature) continue;
|
if (!destFeature) continue;
|
||||||
|
if (onprogress) onprogress({ index: i + 1, total: trips.length, label: `${trip.city}, ${trip.countryName}` });
|
||||||
const label = `${trip.city}, ${trip.countryName}`;
|
|
||||||
if (onprogress) onprogress({ index: i + 1, total, label });
|
|
||||||
|
|
||||||
await animateTrip(trip.countryCode, destFeature, trip.transport);
|
await animateTrip(trip.countryCode, destFeature, trip.transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopJourney() {
|
function stopJourney() { isCancelled = true; isPlaying = false; }
|
||||||
isCancelled = true;
|
function replay() { stopJourney(); setTimeout(() => startJourney(), 100); }
|
||||||
isPlaying = false;
|
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, height = frameEl.clientHeight;
|
||||||
const height = frameEl.clientHeight;
|
setupProjection(width, height);
|
||||||
|
|
||||||
projection = d3.geoMercator();
|
countriesData = feature(worldData, worldData.objects.countries)
|
||||||
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');
|
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
||||||
|
countriesData.forEach(f => { if (!f.id) f.id = 'XK'; });
|
||||||
countries.forEach(f => {
|
for (const f of countriesData) featuresById[effId(f)] = f;
|
||||||
if (!f.id) f.id = 'XK';
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const f of countries) {
|
|
||||||
featuresById[effId(f)] = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
homeFeature = featuresById[HOME_CODE];
|
homeFeature = featuresById[HOME_CODE];
|
||||||
|
|
||||||
svg = d3.select(frameEl)
|
svg = d3.select(frameEl).append('svg').attr('width', width).attr('height', height).style('cursor', 'default');
|
||||||
.append('svg')
|
gBase = svg.append('g'); gCountries = svg.append('g'); gAnim = svg.append('g');
|
||||||
.attr('width', width)
|
renderMap();
|
||||||
.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) => {
|
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 prevRotate = mode === 'globe' ? projection.rotate() : null;
|
||||||
countryPaths.attr('d', pathFn);
|
if (mode === 'map') {
|
||||||
renderMicrostates();
|
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);
|
observer.observe(frameEl);
|
||||||
|
|
||||||
startJourney();
|
startJourney();
|
||||||
|
return () => { stopJourney(); observer.disconnect(); if (svg) svg.remove(); };
|
||||||
return () => {
|
|
||||||
stopJourney();
|
|
||||||
observer.disconnect();
|
|
||||||
if (svg) svg.remove();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</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}Journey complete!{:else if currentDateLabel}{currentDateLabel}{/if}
|
||||||
<div class="done-badge">Journey complete!</div>
|
</div>
|
||||||
{/if}
|
<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 view' : '🗺 Map view'}
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick={close}>✕ Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.journey-frame {
|
.journey-frame { width: 100%; height: 100%; overflow: hidden; position: relative; background: #a4c8e0; }
|
||||||
width: 100%;
|
.journey-frame.globe-mode { background: #ffffff; }
|
||||||
height: 100%;
|
.journey-frame :global(svg) { display: block; }
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
.top-label {
|
||||||
background: #a4c8e0;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.journey-frame :global(svg) {
|
.control-bar {
|
||||||
display: block;
|
position: absolute; bottom: 24px; right: 24px; z-index: 10;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.control-btn {
|
||||||
position: absolute;
|
padding: 10px 24px; border: none; border-radius: 24px;
|
||||||
bottom: 24px;
|
background: #8b5cf6; color: #fff; font-size: 14px; font-weight: 600;
|
||||||
right: 24px;
|
cursor: pointer; transition: background 0.15s ease; white-space: nowrap; font-family: inherit;
|
||||||
z-index: 10;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: #8b5cf6;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: #7c3aed;
|
|
||||||
box-shadow: 0 4px 18px rgba(139, 92, 246, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:active {
|
|
||||||
transform: scale(0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
.control-btn:hover { background: #7c3aed; }
|
||||||
|
.control-btn:active { transform: scale(0.96); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user