/* ===== 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 = ` `; 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' }); } }); });