astrion-website/demo/site-assets/site.js

451 lines
17 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.

/* 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 为每秒下落 pxparallax 为鼠标/滚动视差系数
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) {
// 2248 秒一颗,保持稀有
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 初始无 srcdata-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.htmlload 事件兜底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.jswindow.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);
});
}
})();