451 lines
17 KiB
JavaScript
451 lines
17 KiB
JavaScript
/* Astrion 官网主页交互
|
||
* 1) 多层视差星空(无连线、无光晕:实心星点 + 闪烁 + 极慢漂移 + 鼠标/滚动视差 + 偶发流星)
|
||
* 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;
|
||
|
||
function tick(ts) {
|
||
if (document.hidden) {
|
||
lastTs = ts;
|
||
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);
|
||
}
|
||
|
||
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); // 首颗稍提前,证明存在
|
||
requestAnimationFrame(tick);
|
||
// 滚动时重绘由 rAF 持续驱动,无需 scroll 监听
|
||
}
|
||
|
||
/* ═══════════ 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 DEMO_W = 1400;
|
||
var DEMO_H = 860;
|
||
|
||
// 严格懒加载:iframe 初始无 src(data-src 占位),接近视口才注入——loading=lazy 的
|
||
// 浏览器预载阈值过宽(几千 px 就开载),演示壳 main.js 2.1MB 不值得提前加载。
|
||
if (iframe && iframe.dataset.src) {
|
||
if ('IntersectionObserver' in window) {
|
||
var demoIO = new IntersectionObserver(function (entries) {
|
||
if (entries[0].isIntersecting) {
|
||
iframe.src = iframe.dataset.src;
|
||
delete iframe.dataset.src;
|
||
demoIO.disconnect();
|
||
}
|
||
}, { rootMargin: '600px' });
|
||
demoIO.observe(iframe);
|
||
} else {
|
||
iframe.src = iframe.dataset.src; // 兜底:无 IO 直接加载
|
||
}
|
||
}
|
||
|
||
// 防白闪: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'); };
|
||
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);
|
||
});
|
||
}
|
||
})();
|