updated animation
This commit is contained in:
BIN
public/airplane.png
Normal file
BIN
public/airplane.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user