777 lines
24 KiB
JavaScript
777 lines
24 KiB
JavaScript
/* ===== 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' });
|
||
}
|
||
});
|
||
});
|