/* Astrion 官网主页交互 * 1) 多层视差星空(无连线、无光晕:实心星点 + 闪烁 + 极慢漂移 + 鼠标/滚动视差 + 偶发流星) * 顶栏 Astrion 标题 = 星空开关:点击切换开/关(关 = 停 rAF + 隐藏画布,移动端卡顿排查用) * 2) 顶栏滚动态 * 3) FAQ 手风琴平滑展开(details + 高度动画) * 4) 演示区 iframe 等比缩放(内部固定 1400×860 逻辑尺寸) * 5) 挂载品牌眼睛(眨眼 + 鼠标追踪) */ (function () { 'use strict'; var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* 站点语言(第 12 节文案替换与动画模块共用):localStorage 优先 → 浏览器语言(zh 开头中文,否则英文)。 提前到这里:动画初始化(第 6-9 节)需要先用 setLocale 注入语言。 */ function detectSiteLocale() { try { var stored = localStorage.getItem('astrion_site_locale'); if (stored === 'zh-CN' || stored === 'en-US') return stored; } catch (e) {} var nav = (navigator.language || '').toLowerCase(); return nav.indexOf('zh') === 0 ? 'zh-CN' : 'en-US'; } var siteLocale = detectSiteLocale(); /* ═══════════ 1) 星空 ═══════════ */ var canvas = document.getElementById('starfield'); var ctx = canvas.getContext('2d'); var W = 0; var H = 0; var DPR = Math.min(window.devicePixelRatio || 1, 2); // 层:远 → 近。count 按屏幕面积缩放,drift 为每秒下落 px,parallax 为鼠标/滚动视差系数 var LAYERS = [ { density: 1 / 5200, sizeMin: 0.4, sizeMax: 0.9, drift: 1.2, parallax: 6, twinkle: 0.35, alpha: 0.55 }, { density: 1 / 9800, sizeMin: 0.7, sizeMax: 1.3, drift: 2.6, parallax: 14, twinkle: 0.55, alpha: 0.75 }, { density: 1 / 21000, sizeMin: 1.1, sizeMax: 1.9, drift: 4.5, parallax: 26, twinkle: 0.8, alpha: 1.0 } ]; var stars = []; var meteors = []; var nextMeteorAt = 0; function resize() { W = window.innerWidth; H = window.innerHeight; canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR); canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); seed(); if (reduceMotion) draw(0, 0, 0); } function seed() { stars = []; var area = W * H; for (var li = 0; li < LAYERS.length; li++) { var L = LAYERS[li]; var n = Math.max(12, Math.round(area * L.density)); for (var i = 0; i < n; i++) { stars.push({ layer: li, x: Math.random() * W, y: Math.random() * H, r: L.sizeMin + Math.random() * (L.sizeMax - L.sizeMin), // 闪烁相位与速度(部分星几乎不闪,画面才稳) phase: Math.random() * Math.PI * 2, speed: 0.25 + Math.random() * 0.9, amp: 0.25 + Math.random() * 0.75 }); } } } // 鼠标视差目标值 var mouseTx = 0; var mouseTy = 0; var mouseX = 0; var mouseY = 0; window.addEventListener('mousemove', function (e) { mouseTx = e.clientX / W - 0.5; mouseTy = e.clientY / H - 0.5; }, { passive: true }); function scheduleMeteor(now) { // 22~48 秒一颗,保持稀有 nextMeteorAt = now + 22000 + Math.random() * 26000; } function spawnMeteor() { var fromLeft = Math.random() < 0.5; var startX = fromLeft ? -40 : W * (0.35 + Math.random() * 0.65); var startY = Math.random() * H * 0.4; var angle = fromLeft ? 0.32 + Math.random() * 0.2 : Math.PI - (0.32 + Math.random() * 0.2); var speed = 380 + Math.random() * 240; meteors.push({ x: startX, y: startY, vx: Math.cos(angle) * speed, vy: Math.abs(Math.sin(angle)) * speed, life: 0, ttl: 0.7 + Math.random() * 0.35 }); } var lastTs = 0; var rafId = 0; // 当前 rAF 句柄(星空开关用) var starsEnabled = true; function tick(ts) { if (!starsEnabled) return; // 开关关闭:不再排下一帧,循环自然终止 if (document.hidden) { lastTs = ts; rafId = requestAnimationFrame(tick); return; } var dt = Math.min((ts - lastTs) / 1000 || 0, 0.05); lastTs = ts; var now = ts; // 鼠标视差缓动 mouseX += (mouseTx - mouseX) * 0.04; mouseY += (mouseTy - mouseY) * 0.04; if (now >= nextMeteorAt) { spawnMeteor(); scheduleMeteor(now); } draw(dt, ts, window.scrollY || 0); // 流星生命推进 for (var i = meteors.length - 1; i >= 0; i--) { var m = meteors[i]; m.life += dt; m.x += m.vx * dt; m.y += m.vy * dt; if (m.life > m.ttl || m.y > H + 60) meteors.splice(i, 1); } rafId = requestAnimationFrame(tick); } function draw(dt, ts, scrollY) { ctx.clearRect(0, 0, W, H); var t = ts / 1000; for (var i = 0; i < stars.length; i++) { var s = stars[i]; var L = LAYERS[s.layer]; if (dt) { s.y += L.drift * dt; if (s.y > H + 4) { s.y = -4; s.x = Math.random() * W; } } // 视差:滚动让星野整体上移(近处更明显),鼠标让层间错动 var px = s.x + mouseX * L.parallax * 2; var py = s.y - (scrollY * L.parallax * 0.012) + mouseY * L.parallax; // 纵向包裹,保证滚动后星点仍铺满 var wrappedY = ((py % (H + 8)) + (H + 8)) % (H + 8) - 4; var tw = 1 - L.twinkle * 0.5 + L.twinkle * 0.5 * Math.sin(t * s.speed + s.phase) * s.amp; var a = L.alpha * Math.max(0.15, Math.min(1, tw)); ctx.globalAlpha = a; ctx.fillStyle = '#dfe6f5'; ctx.fillRect(px, wrappedY, s.r, s.r); } // 流星:短细线段,头部略亮(线性渐变线条,不是光晕) for (var j = 0; j < meteors.length; j++) { var m = meteors[j]; var fade = 1 - m.life / m.ttl; var tailX = m.x - m.vx * 0.14; var tailY = m.y - m.vy * 0.14; var grad = ctx.createLinearGradient(tailX, tailY, m.x, m.y); grad.addColorStop(0, 'rgba(223,230,245,0)'); grad.addColorStop(1, 'rgba(223,230,245,' + (0.6 * fade).toFixed(3) + ')'); ctx.globalAlpha = 1; ctx.strokeStyle = grad; ctx.lineWidth = 1.1; ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(m.x, m.y); ctx.stroke(); } ctx.globalAlpha = 1; } resize(); window.addEventListener('resize', resize, { passive: true }); if (reduceMotion) { draw(0, 0, 0); // 静态星图 } else { scheduleMeteor(performance.now() + 6000); // 首颗稍提前,证明存在 rafId = requestAnimationFrame(tick); // 滚动时重绘由 rAF 持续驱动,无需 scroll 监听 } // 顶栏标题 = 星空开关(移动端卡顿排查用):点击切换开/关。 // 关闭必须「停 rAF 循环 + 隐藏画布」同时做——只藏画布仍每帧渲染,对比测试就失去意义。 function setStarsEnabled(on) { starsEnabled = on; canvas.style.display = on ? '' : 'none'; var btn = document.getElementById('brandToggle'); if (btn) { btn.classList.toggle('stars-off', !on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); // aria-pressed 语义 = 星空开启 } if (on) { if (!reduceMotion && !rafId) { lastTs = 0; rafId = requestAnimationFrame(tick); } } else if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } } var brandToggle = document.getElementById('brandToggle'); if (brandToggle) { brandToggle.addEventListener('click', function () { setStarsEnabled(!starsEnabled); }); } /* ═══════════ 2) 顶栏滚动态 ═══════════ */ var topbar = document.getElementById('topbar'); function onScroll() { topbar.classList.toggle('scrolled', (window.scrollY || 0) > 24); } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); /* ═══════════ 3) FAQ 平滑手风琴 ═══════════ */ var faqItems = document.querySelectorAll('.faq-item'); faqItems.forEach(function (item) { var summary = item.querySelector('.faq-q'); var panel = item.querySelector('.faq-a'); summary.addEventListener('click', function (e) { e.preventDefault(); if (reduceMotion) { item.open = !item.open; return; } if (item.open) { // 收起:先固定高度再过渡到 0。open 属性必须等动画结束才移除(否则内容瞬消、动画播不了), // 但图标由 [open] 驱动会因此延迟变回 +——点击瞬间加 closing 类让图标立即切回(与展开对称)。 item.classList.add('closing'); var h0 = panel.offsetHeight; panel.style.height = h0 + 'px'; requestAnimationFrame(function () { panel.style.transition = 'height 0.22s ease'; panel.style.height = '0px'; }); panel.addEventListener('transitionend', function done() { panel.removeEventListener('transitionend', done); panel.style.cssText = ''; item.open = false; item.classList.remove('closing'); }); } else { item.classList.remove('closing'); item.open = true; var h1 = panel.offsetHeight; panel.style.height = '0px'; requestAnimationFrame(function () { panel.style.transition = 'height 0.24s ease'; panel.style.height = h1 + 'px'; }); panel.addEventListener('transitionend', function done() { panel.removeEventListener('transitionend', done); panel.style.cssText = ''; }); } }); }); /* ═══════════ 4) 演示区等比缩放 + 严格懒加载 ═══════════ */ var frame = document.getElementById('demoFrame'); var iframe = document.getElementById('demoIframe'); var preview = document.getElementById('demoPreview'); var previewBtn = document.getElementById('demoPreviewBtn'); var DEMO_W = 1400; var DEMO_H = 860; // 手动预览(2026-09-04 起,取代原 IntersectionObserver 严格懒加载):演示壳较重(main.js 2.1MB), // 连「接近视口自动注入」都不做——点「点击预览」才注入 src 开始渲染(卡顿排查期可彻底排除演示区变量)。 if (iframe && iframe.dataset.src && previewBtn) { previewBtn.addEventListener('click', function () { var dict = (window.SITE_I18N && window.SITE_I18N[siteLocale]) || (window.SITE_I18N && window.SITE_I18N['zh-CN']) || {}; previewBtn.textContent = dict['demo.previewLoading'] || '加载中…'; previewBtn.disabled = true; iframe.src = iframe.dataset.src; delete iframe.dataset.src; }, { once: true }); } // 防白闪:iframe 子文档整页导航(新建对话跳 /new)时,浏览器会把 iframe 区域画成不透明白, // 与子文档 CSS 无关(逐帧实测证实:子文档计算背景全程深色,屏幕仍出现全白帧);且新文档首帧 // 在 theme.ts 写 data-theme 前会命中产品首屏回退浅色 #faf9f5(#app 无条件 background 所致)。 // 对策:导航期间隐藏 iframe(露出 demo-frame 深色面板底),子文档等 data-theme 落地且首帧绘制后 // postMessage 通知显示(demo.html);load 事件兜底(load 时 module 脚本已执行,data-theme 已在)。 if (iframe) { var markDemoFrameReady = function () { iframe.classList.add('demo-frame-ready'); if (preview) preview.classList.add('demo-preview--hidden'); // 手动预览:子文档就绪后撤掉占位层 }; window.addEventListener('message', function (e) { if (e.origin === location.origin && e.data === 'astrion-demo-frame-ready') markDemoFrameReady(); }); iframe.addEventListener('load', function () { // 每次新文档 load 后重挂导航监听(window 对象随导航重建)。 // beforeunload 早于 pagehide(网络请求发起前就触发),给父页留出一个 SW 往返的时间隐藏, // 实测只靠 pagehide 会漏掉浏览器画白的前 2 帧。两个都挂,幂等。 try { var hide = function () { iframe.classList.remove('demo-frame-ready'); }; iframe.contentWindow.addEventListener('beforeunload', hide); iframe.contentWindow.addEventListener('pagehide', hide); } catch (e) {} markDemoFrameReady(); // 兜底:postMessage 丢失时 load 也要显示 }); } function fitDemo() { var available = frame.clientWidth; var scale = Math.min(1, available / DEMO_W); iframe.style.transform = 'scale(' + scale + ')'; frame.style.height = Math.round(DEMO_H * scale) + 'px'; } if ('ResizeObserver' in window) { new ResizeObserver(fitDemo).observe(frame); } window.addEventListener('resize', fitDemo, { passive: true }); fitDemo(); /* ═══════════ 5) 品牌眼睛 ═══════════ */ if (window.AstrionAvatar) { AstrionAvatar.mount(document.getElementById('heroMascot'), { mode: 'idle', blink: true, track: true }); } /* 动画模块语言注入:init 前先 setLocale(语言切换时第 12 节会再次 setLocale 触发重建续播) */ ['AnimModes', 'AnimSandbox', 'AnimRecords', 'AnimVersion'].forEach(function (name) { if (window[name] && window[name].setLocale) window[name].setLocale(siteLocale); }); /* ═══════════ 6) 运行模式动画(功能 01 行) ═══════════ */ if (window.AnimModes && document.getElementById('animModes')) { AnimModes.init(document.getElementById('animModes')); } /* ═══════════ 7) 沙箱与网络授权动画(功能 02 行) ═══════════ */ if (window.AnimSandbox && document.getElementById('animSandbox')) { AnimSandbox.init(document.getElementById('animSandbox')); } /* ═══════════ 8) 修改记录动画(功能 03 行) ═══════════ */ if (window.AnimRecords && document.getElementById('animRecords')) { AnimRecords.init(document.getElementById('animRecords')); } /* ═══════════ 9) 版本控制动画(功能 04 行) ═══════════ */ if (window.AnimVersion && document.getElementById('animVersion')) { AnimVersion.init(document.getElementById('animVersion')); } /* ═══════════ 10) hero 区 git clone 复制 ═══════════ */ var heroClone = document.getElementById('heroClone'); if (heroClone) { var heroCloneTimer = null; heroClone.addEventListener('click', function () { var cmd = 'git clone https://github.com/JOJO6618/astrion.git'; var done = function () { heroClone.classList.add('copied'); clearTimeout(heroCloneTimer); heroCloneTimer = setTimeout(function () { heroClone.classList.remove('copied'); }, 1600); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(cmd).then(done).catch(function () {}); } }); } /* ═══════════ 11) 联系我 邮箱复制 ═══════════ */ var contactCopy = document.getElementById('contactCopy'); if (contactCopy) { var contactCopyTimer = null; contactCopy.addEventListener('click', function () { var mail = 'yao884417@outlook.com'; var done = function () { contactCopy.classList.add('copied'); clearTimeout(contactCopyTimer); contactCopyTimer = setTimeout(function () { contactCopy.classList.remove('copied'); }, 1600); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(mail).then(done).catch(function () {}); } }); } /* ═══════════ 12) 站点多语言(主页 zh-CN / en-US) ═══════════ * siteLocale 已在 IIFE 顶部检测(动画初始化要用);本脚本在 body 末尾同步执行, * 首帧绘制前完成替换,不会闪源语言。 * 字典在 i18n.js(window.SITE_I18N);标记约定: * data-i18n → textContent / data-i18n-html → innerHTML / data-i18n-aria → aria-label / * data-i18n-attr-title → title 属性;meta[name=description] 用 meta.desc 键。 */ var SITE_I18N = window.SITE_I18N || {}; var langToggle = document.getElementById('langToggle'); function applySiteLocale(locale) { var dict = SITE_I18N[locale]; if (!dict) return; document.documentElement.setAttribute('lang', locale); var i, nodes, key; nodes = document.querySelectorAll('[data-i18n]'); for (i = 0; i < nodes.length; i++) { key = nodes[i].getAttribute('data-i18n'); if (dict[key] != null) nodes[i].textContent = dict[key]; } nodes = document.querySelectorAll('[data-i18n-html]'); for (i = 0; i < nodes.length; i++) { key = nodes[i].getAttribute('data-i18n-html'); if (dict[key] != null) nodes[i].innerHTML = dict[key]; } nodes = document.querySelectorAll('[data-i18n-aria]'); for (i = 0; i < nodes.length; i++) { key = nodes[i].getAttribute('data-i18n-aria'); if (dict[key] != null) nodes[i].setAttribute('aria-label', dict[key]); } nodes = document.querySelectorAll('[data-i18n-attr-title]'); for (i = 0; i < nodes.length; i++) { key = nodes[i].getAttribute('data-i18n-attr-title'); if (dict[key] != null) nodes[i].setAttribute('title', dict[key]); } var metaDesc = document.querySelector('meta[name="description"]'); if (metaDesc && dict['meta.desc'] != null) { metaDesc.setAttribute('content', dict['meta.desc']); } if (langToggle) { langToggle.textContent = locale === 'zh-CN' ? 'English' : '中文'; langToggle.setAttribute('aria-label', locale === 'zh-CN' ? 'Switch to English' : '切换为中文'); } /* 动画模块联动:setLocale 内部重建 DOM 并续播当前场景(详见各 anim-*.js) */ ['AnimModes', 'AnimSandbox', 'AnimRecords', 'AnimVersion'].forEach(function (name) { if (window[name] && window[name].setLocale) window[name].setLocale(locale); }); } if (siteLocale !== 'zh-CN') applySiteLocale(siteLocale); if (langToggle) { langToggle.textContent = siteLocale === 'zh-CN' ? 'English' : '中文'; langToggle.addEventListener('click', function () { siteLocale = siteLocale === 'zh-CN' ? 'en-US' : 'zh-CN'; try { localStorage.setItem('astrion_site_locale', siteLocale); } catch (e) {} applySiteLocale(siteLocale); }); } })();