nomadweb/js/main.js
2026-08-28 19:49:10 -05:00

777 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===== 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 = `
<div class="map-info-city">
<div class="city-emoji">${city.emoji}</div>
<h3>${city.name}</h3>
<span class="modal-tag">${city.tag}</span>
<p class="city-desc">${city.desc}</p>
<div class="map-info-stats">
<div class="map-stat"><strong>${city.cost}</strong>月生活费</div>
<div class="map-stat"><strong>${city.speed}</strong>网速</div>
<div class="map-stat"><strong>${city.temp}</strong>均温</div>
<div class="map-stat"><strong>⭐ ${city.rating}</strong>评分</div>
</div>
</div>
`;
});
});
}
/* ----- 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 = `
<div class="modal-hero" style="background:linear-gradient(135deg,hsl(${city.hue},60%,20%),hsl(${city.hue + 40},50%,30%))">
${city.emoji}
</div>
<div class="modal-body">
<h2>${city.name}</h2>
<span class="modal-tag">${city.tag}</span>
<p>${city.desc}</p>
<div class="modal-stats">
<div class="modal-stat"><span class="stat-emoji">💰</span><strong>${city.cost}</strong><span>月生活费</span></div>
<div class="modal-stat"><span class="stat-emoji">📶</span><strong>${city.speed}</strong><span>网速</span></div>
<div class="modal-stat"><span class="stat-emoji">🌡️</span><strong>${city.temp}</strong><span>均温</span></div>
</div>
<div class="modal-highlights">
<h4>✨ 亮点特色</h4>
<ul>${city.highlights.map((h) => `<li>${h}</li>`).join('')}</ul>
</div>
<p style="font-size:0.8rem;color:var(--text-muted)">👥 活跃游民: ${city.nomads} · ⭐ 评分: ${city.rating}</p>
</div>
`;
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' });
}
});
});