/* ===== NomadFlow - Main JavaScript ===== */
document.addEventListener('DOMContentLoaded', () => {
initLoader();
initParticles();
initNavbar();
initThemeToggle();
initMobileMenu();
initScrollReveal();
initCounters();
initTiltEffect();
initCharts();
initSubscribeForm();
initTypewriter();
initMapPins();
initDestFilters();
initDestModal();
initFAQ();
initBackToTop();
initNavHighlight();
});
/* ----- Loader ----- */
function initLoader() {
const loader = document.getElementById('loader');
if (!loader) return;
window.addEventListener('load', () => {
setTimeout(() => loader.classList.add('hidden'), 1800);
});
setTimeout(() => loader.classList.add('hidden'), 3000);
}
/* ----- Typewriter ----- */
function initTypewriter() {
const el = document.getElementById('typewriter');
if (!el) return;
const phrases = [
'数字游民不是逃离生活,而是用科技重新定义生活。',
'在巴厘岛写代码,在里斯本开会议,在清迈看日落 ☀️',
'世界那么大,你的办公室可以在任何地方 🌍',
'远程工作 + 全球旅居 = 无限可能 ✨',
];
let phraseIdx = 0;
let charIdx = 0;
let deleting = false;
function type() {
const current = phrases[phraseIdx];
if (!deleting) {
el.textContent = current.substring(0, charIdx + 1);
charIdx++;
if (charIdx === current.length) {
deleting = true;
setTimeout(type, 2500);
return;
}
} else {
el.textContent = current.substring(0, charIdx - 1);
charIdx--;
if (charIdx === 0) {
deleting = false;
phraseIdx = (phraseIdx + 1) % phrases.length;
}
}
setTimeout(type, deleting ? 30 : 60);
}
setTimeout(type, 2000);
}
/* ----- City Data ----- */
const CITY_DATA = {
bali: {
name: '巴厘岛, 印尼', emoji: '🏝️', tag: '东南亚 · 热带天堂',
desc: '乌布的数字游民社区闻名全球,稻田间的 Co-working Space 和瑜伽文化让这里成为游民圣地。',
cost: '¥4,500', speed: '85Mbps', temp: '28°C', rating: '9.2',
nomads: '12,000+', highlights: ['🏄 冲浪与海滩生活', '🧘 瑜伽冥想文化', '💰 东南亚性价比之王', '🌴 热带气候全年温暖'],
hue: 170,
},
lisbon: {
name: '里斯本, 葡萄牙', emoji: '🌊', tag: '欧洲 · 海滨明珠',
desc: 'D7 签证友好,阳光海岸与悠久历史的完美融合,欧洲数字游民的首选基地。',
cost: '¥9,000', speed: '120Mbps', temp: '22°C', rating: '9.5',
nomads: '8,500+', highlights: ['📋 D7 签证门槛低', '☀️ 300天阳光', '🎵 Fado 音乐文化', '🚋 复古有轨电车'],
hue: 220,
},
chiangmai: {
name: '清迈, 泰国', emoji: '🏔️', tag: '东南亚 · 文化古城',
desc: '数字游民大本营,咖啡文化与夜市生活的天堂,全球性价比最高的游民城市。',
cost: '¥3,800', speed: '95Mbps', temp: '30°C', rating: '9.4',
nomads: '15,000+', highlights: ['☕ 咖啡馆文化浓厚', '🏮 夜市与寺庙', '💰 月生活费最低', '🤝 游民社区最活跃'],
hue: 45,
},
mexico: {
name: '墨西哥城, 墨西哥', emoji: '🌃', tag: '拉美 · 活力之都',
desc: '艺术、美食与科技交织,时区便利对接北美市场,拉美最具活力的游民城市。',
cost: '¥6,500', speed: '75Mbps', temp: '18°C', rating: '8.8',
nomads: '5,200+', highlights: ['🎨 街头艺术天堂', '🌮 世界美食之都', '🕐 北美时区友好', '💃 丰富夜生活'],
hue: 300,
},
barcelona: {
name: '巴塞罗那, 西班牙', emoji: '🏖️', tag: '欧洲 · 地中海',
desc: '高迪建筑与创业生态并存,Nomad Visa 政策领先,地中海生活的理想之选。',
cost: '¥10,500', speed: '150Mbps', temp: '20°C', rating: '9.1',
nomads: '6,800+', highlights: ['🏛️ 高迪建筑奇迹', '🏖️ 地中海海滩', '📋 Nomad Visa 便利', '🍷 美食与夜生活'],
hue: 130,
},
tokyo: {
name: '东京, 日本', emoji: '🗼', tag: '亚洲 · 现代都市',
desc: '极致效率与安全,适合追求高品质生活的远程工作者,亚洲科技之都。',
cost: '¥12,000', speed: '200Mbps', temp: '15°C', rating: '8.6',
nomads: '4,100+', highlights: ['🚄 极致公共交通', '🛡️ 全球最安全城市', '📶 网速亚洲第一', '🍣 美食文化巅峰'],
hue: 10,
},
};
/* ----- Map Pins ----- */
function initMapPins() {
const pins = document.querySelectorAll('.map-pin');
const panel = document.getElementById('map-info-panel');
if (!pins.length || !panel) return;
pins.forEach((pin) => {
pin.addEventListener('click', () => {
const cityId = pin.dataset.city;
const city = CITY_DATA[cityId];
if (!city) return;
pins.forEach((p) => p.classList.remove('active'));
pin.classList.add('active');
panel.innerHTML = `
${city.emoji}
${city.name}
${city.tag}
${city.desc}
${city.cost}月生活费
${city.speed}网速
${city.temp}均温
⭐ ${city.rating}评分
`;
});
});
}
/* ----- Destination Filters ----- */
function initDestFilters() {
const grid = document.getElementById('dest-grid');
const search = document.getElementById('dest-search');
const sort = document.getElementById('dest-sort');
const empty = document.getElementById('dest-empty');
const filterBtns = document.querySelectorAll('.filter-btn');
if (!grid) return;
let currentFilter = 'all';
function getCards() {
return [...grid.querySelectorAll('.dest-card')];
}
function applyFilters() {
const query = (search?.value || '').toLowerCase();
const cards = getCards();
let visible = 0;
cards.forEach((card) => {
const name = card.querySelector('h3')?.textContent.toLowerCase() || '';
const region = card.dataset.region;
const matchFilter = currentFilter === 'all' || region === currentFilter;
const matchSearch = !query || name.includes(query);
const show = matchFilter && matchSearch;
card.classList.toggle('hidden-card', !show);
if (show) visible++;
});
if (empty) empty.hidden = visible > 0;
}
function applySort() {
const cards = getCards();
const sortVal = sort?.value || 'rating';
cards.sort((a, b) => {
switch (sortVal) {
case 'cost-asc': return +a.dataset.cost - +b.dataset.cost;
case 'cost-desc': return +b.dataset.cost - +a.dataset.cost;
case 'speed': return +b.dataset.speed - +a.dataset.speed;
default: return +b.dataset.rating - +a.dataset.rating;
}
});
cards.forEach((card) => grid.appendChild(card));
}
filterBtns.forEach((btn) => {
btn.addEventListener('click', () => {
filterBtns.forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
applyFilters();
});
});
search?.addEventListener('input', applyFilters);
sort?.addEventListener('change', () => { applySort(); applyFilters(); });
}
/* ----- Destination Modal ----- */
function initDestModal() {
const overlay = document.getElementById('modal-overlay');
const content = document.getElementById('modal-content');
const closeBtn = document.getElementById('modal-close');
const cards = document.querySelectorAll('.dest-card');
if (!overlay || !content) return;
function openModal(cityId) {
const city = CITY_DATA[cityId];
if (!city) return;
content.innerHTML = `
${city.emoji}
${city.name}
${city.tag}
${city.desc}
💰${city.cost}月生活费
📶${city.speed}网速
🌡️${city.temp}均温
✨ 亮点特色
${city.highlights.map((h) => `- ${h}
`).join('')}
👥 活跃游民: ${city.nomads} · ⭐ 评分: ${city.rating}
`;
overlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeModal() {
overlay.classList.remove('open');
document.body.style.overflow = '';
}
cards.forEach((card) => {
card.addEventListener('click', () => openModal(card.dataset.id));
});
closeBtn?.addEventListener('click', closeModal);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
}
/* ----- FAQ Accordion ----- */
function initFAQ() {
const items = document.querySelectorAll('.faq-item');
items.forEach((item) => {
const btn = item.querySelector('.faq-question');
btn?.addEventListener('click', () => {
const wasActive = item.classList.contains('active');
items.forEach((i) => i.classList.remove('active'));
if (!wasActive) item.classList.add('active');
});
});
}
/* ----- Back to Top ----- */
function initBackToTop() {
const btn = document.getElementById('back-to-top');
if (!btn) return;
window.addEventListener('scroll', () => {
btn.classList.toggle('visible', window.scrollY > 600);
}, { passive: true });
btn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
/* ----- Nav Section Highlight ----- */
function initNavHighlight() {
const links = document.querySelectorAll('.nav-links a[data-section]');
const sections = [...links].map((l) => document.getElementById(l.dataset.section)).filter(Boolean);
if (!sections.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
links.forEach((l) => l.classList.toggle('active', l.dataset.section === id));
}
});
},
{ threshold: 0.3, rootMargin: '-80px 0px -50% 0px' }
);
sections.forEach((s) => observer.observe(s));
}
function initParticles() {
const canvas = document.getElementById('particle-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let particles = [];
let animId;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
function createParticles() {
const count = Math.min(Math.floor(window.innerWidth / 12), 120);
particles = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 2 + 0.5,
speedX: (Math.random() - 0.5) * 0.3,
speedY: (Math.random() - 0.5) * 0.3,
opacity: Math.random() * 0.5 + 0.1,
hue: [0, 45, 170, 280][Math.floor(Math.random() * 4)],
}));
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p, i) => {
p.x += p.speedX;
p.y += p.speedY;
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 70%, 60%, ${p.opacity})`;
ctx.fill();
particles.slice(i + 1).forEach((p2) => {
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 120) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `hsla(${p.hue}, 70%, 60%, ${0.08 * (1 - dist / 120)})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
});
});
animId = requestAnimationFrame(draw);
}
resize();
createParticles();
draw();
window.addEventListener('resize', () => {
resize();
createParticles();
});
}
/* ----- Navbar Scroll Effect ----- */
function initNavbar() {
const navbar = document.getElementById('navbar');
if (!navbar) return;
const onScroll = () => {
navbar.classList.toggle('scrolled', window.scrollY > 50);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
/* ----- Theme Toggle ----- */
function initThemeToggle() {
const btn = document.getElementById('theme-toggle');
if (!btn) return;
const saved = localStorage.getItem('nomad-theme');
if (saved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
btn.querySelector('.theme-icon').textContent = '☀️';
}
btn.addEventListener('click', () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
if (isLight) {
document.documentElement.removeAttribute('data-theme');
btn.querySelector('.theme-icon').textContent = '🌙';
localStorage.setItem('nomad-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
btn.querySelector('.theme-icon').textContent = '☀️';
localStorage.setItem('nomad-theme', 'light');
}
updateChartThemes();
});
}
/* ----- Mobile Menu ----- */
function initMobileMenu() {
const btn = document.getElementById('mobile-menu-btn');
const links = document.querySelector('.nav-links');
if (!btn || !links) return;
btn.addEventListener('click', () => {
links.classList.toggle('open');
btn.classList.toggle('active');
});
links.querySelectorAll('a').forEach((a) => {
a.addEventListener('click', () => {
links.classList.remove('open');
btn.classList.remove('active');
});
});
}
/* ----- Scroll Reveal ----- */
function initScrollReveal() {
const reveals = document.querySelectorAll('.reveal');
if (!reveals.length) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry, i) => {
if (entry.isIntersecting) {
const delay = entry.target.dataset.delay || 0;
setTimeout(() => {
entry.target.classList.add('visible');
}, delay);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
reveals.forEach((el, i) => {
el.style.transitionDelay = `${(i % 6) * 0.1}s`;
observer.observe(el);
});
}
/* ----- Animated Counters ----- */
function initCounters() {
const counters = document.querySelectorAll('.stat-number');
if (!counters.length) return;
const animate = (el) => {
const target = parseInt(el.dataset.target, 10);
const duration = 2000;
const start = performance.now();
const step = (now) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.floor(eased * target);
if (progress < 1) requestAnimationFrame(step);
else el.textContent = target;
};
requestAnimationFrame(step);
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
animate(entry.target);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.5 }
);
counters.forEach((c) => observer.observe(c));
}
/* ----- 3D Tilt Effect ----- */
function initTiltEffect() {
const cards = document.querySelectorAll('[data-tilt]');
cards.forEach((card) => {
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -8;
const rotateY = ((x - centerX) / centerX) * 8;
card.style.transform = `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateY(-8px)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
}
/* ----- Charts ----- */
let chartInstances = {};
function getChartColors() {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
return {
text: isLight ? '#475569' : '#94a3b8',
grid: isLight ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.06)',
accent: ['#FF6B6B', '#FFE66D', '#4ECDC4', '#A78BFA', '#F472B6', '#60A5FA'],
};
}
function initCharts() {
if (typeof Chart === 'undefined') return;
Chart.defaults.font.family = "'Outfit', 'Noto Sans SC', sans-serif";
Chart.defaults.color = getChartColors().text;
initCostChart();
initSpeedChart();
initGrowthChart();
initRadarChart();
}
function initCostChart() {
const ctx = document.getElementById('costChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.cost = new Chart(ctx, {
type: 'bar',
data: {
labels: ['清迈 🇹🇭', '巴厘岛 🇮🇩', '墨西哥城 🇲🇽', '里斯本 🇵🇹', '巴塞罗那 🇪🇸', '东京 🇯🇵'],
datasets: [{
label: '月生活费 (千元人民币)',
data: [3.8, 4.5, 6.5, 9.0, 10.5, 12.0],
backgroundColor: colors.accent.map((c) => c + '99'),
borderColor: colors.accent,
borderWidth: 2,
borderRadius: 8,
borderSkipped: false,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2000, easing: 'easeOutQuart' },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: (ctx) => ` ¥${ctx.parsed.y.toFixed(1)},000/月`,
},
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: colors.grid },
ticks: { callback: (v) => `¥${v}k` },
},
x: { grid: { display: false } },
},
},
});
}
function initSpeedChart() {
const ctx = document.getElementById('speedChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.speed = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['东京 200', '巴塞罗那 150', '里斯本 120', '清迈 95', '巴厘岛 85', '墨西哥城 75'],
datasets: [{
data: [200, 150, 120, 95, 85, 75],
backgroundColor: colors.accent,
borderColor: 'transparent',
borderWidth: 0,
hoverOffset: 12,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
animation: { animateRotate: true, duration: 2000 },
plugins: {
legend: {
position: 'right',
labels: { padding: 12, usePointStyle: true, pointStyle: 'circle' },
},
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.parsed} Mbps` },
},
},
},
});
}
function initGrowthChart() {
const ctx = document.getElementById('growthChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.growth = new Chart(ctx, {
type: 'line',
data: {
labels: ['2019', '2020', '2021', '2022', '2023', '2024', '2025', '2026'],
datasets: [{
label: '全球数字游民 (百万人)',
data: [7.3, 10.9, 15.5, 24.0, 28.5, 31.2, 33.8, 35.6],
borderColor: colors.accent[2],
backgroundColor: colors.accent[2] + '20',
fill: true,
tension: 0.4,
pointRadius: 5,
pointBackgroundColor: colors.accent[2],
pointBorderColor: '#0a0e17',
pointBorderWidth: 2,
pointHoverRadius: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2500, easing: 'easeOutQuart' },
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (ctx) => ` ${ctx.parsed.y}M 人` },
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: colors.grid },
ticks: { callback: (v) => `${v}M` },
},
x: { grid: { display: false } },
},
},
});
}
function initRadarChart() {
const ctx = document.getElementById('radarChart');
if (!ctx) return;
const colors = getChartColors();
chartInstances.radar = new Chart(ctx, {
type: 'radar',
data: {
labels: ['生活成本 💰', '网络速度 📶', '安全性 🛡️', '社群活跃 🤝', '气候环境 🌤️', '签证便利 📋'],
datasets: [{
label: '清迈',
data: [95, 80, 85, 95, 70, 90],
borderColor: colors.accent[2],
backgroundColor: colors.accent[2] + '30',
pointBackgroundColor: colors.accent[2],
}, {
label: '里斯本',
data: [60, 90, 92, 80, 85, 95],
borderColor: colors.accent[0],
backgroundColor: colors.accent[0] + '30',
pointBackgroundColor: colors.accent[0],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 2000 },
scales: {
r: {
beginAtZero: true,
max: 100,
grid: { color: colors.grid },
angleLines: { color: colors.grid },
pointLabels: { font: { size: 11 } },
ticks: { display: false },
},
},
plugins: {
legend: {
position: 'bottom',
labels: { padding: 16, usePointStyle: true },
},
},
},
});
}
function updateChartThemes() {
const colors = getChartColors();
Chart.defaults.color = colors.text;
Object.values(chartInstances).forEach((chart) => {
if (!chart) return;
if (chart.options.scales?.y) chart.options.scales.y.grid.color = colors.grid;
if (chart.options.scales?.x) chart.options.scales.x.grid = { color: colors.grid, display: chart.options.scales.x.grid?.display ?? false };
if (chart.options.scales?.r) {
chart.options.scales.r.grid.color = colors.grid;
chart.options.scales.r.angleLines.color = colors.grid;
}
chart.update();
});
}
/* ----- Subscribe Form ----- */
function initSubscribeForm() {
const form = document.getElementById('subscribe-form');
const toast = document.getElementById('toast');
if (!form || !toast) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
const input = form.querySelector('input');
if (!input.value) return;
toast.classList.add('show');
input.value = '';
setTimeout(() => toast.classList.remove('show'), 4000);
});
}
/* ----- Smooth anchor highlight ----- */
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
const target = document.querySelector(anchor.getAttribute('href'));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth' });
}
});
});