astrion-website/demo/site-assets/anim-records.js
JOJO 64d0a377dc feat: 04版本控制动画 + 演示区5条真实对话mock + 文档同步沙箱重构
- 主页第4行特色动画(版本控制)及 01/02/03 动画迭代
- 演示区对话 mock 全部换为真实对话转换:日常使用(汽车对比新/整理周会速记)、
  StarRaid(弹幕调研/像素飞机设计)、开发实战(猫咪咖啡店多智能体)
- 子智能体 activity、消息截图媒体、文件卡片资源(docx/pdf/html)随站点发布
- sw.js: file/content 二进制 MIME、/api/conversations/media 路由、
  模型列表扩为 K3/V4-Flash/MiniMax-M3/GLM-5.2、CACHE v5
- content/ 同步 8-31 沙箱重构文档更新
2026-09-03 07:47:39 +08:00

900 lines
41 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/* 修改记录动画 —— 可复用模块(主页 feature 行 / anim3-demo 页共用)
* AnimRecords.init(container):在容器内建 DOM 并自动播放;
* 三轮收盒后自动轮播三张卡的 diff 详情,用户按下卡片暂停轮播 5 秒。
*
* 舞台固定 540×380 逻辑尺寸按容器等比缩放居中transform scale——
* 所有内部坐标不随容器变化getBoundingClientRect 读数是缩放后的视口值,
* 统一除以 curScale 换算回舞台本地坐标。
*/
window.AnimRecords = (function () {
'use strict';
var LOGICAL_W = 540, LOGICAL_H = 380;
function init(container) {
container.innerHTML =
'<div class="anim3-window">' +
'<div class="anim3-stage" id="anim3Stage">' +
'<div class="writer" id="writer"><span class="writer-avatar" id="writerAvatar"></span></div>' +
'<div class="pen-pos" id="penPos">' +
'<svg class="writer-pen" id="writerPen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path fill="var(--bg-1)" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />' +
'<path d="m15 5 4 4" />' +
'<circle id="penNibRef" class="pen-ref" cx="2.65" cy="21.02" r="0.7" />' +
'<circle id="penEraserRef" class="pen-ref" cx="19.2" cy="4.8" r="0.7" />' +
'</svg>' +
'</div>' +
'<div class="paper3d" id="paper"><div class="paper-sheet">' +
'<div class="pline" data-w="82"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="64"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="71"><i class="pline-end"></i></div>' +
'<div class="pline" data-w="58"><i class="pline-end"></i></div>' +
'</div></div>' +
'<svg class="inbox-part box-back is-shown" id="boxBack" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M2 12 L5.45 5.11 A2 2 0 0 1 7.24 4 H16.76 a2 2 0 0 1 1.79 1.11 L22 12" />' +
'</svg>' +
'<svg class="inbox-part box-front is-shown" id="boxFront" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M2 12 h6 l2 3 h4 l2 -3 h6 v6 a2 2 0 0 1 -2 2 H4 a2 2 0 0 1 -2 -2 z" />' +
'</svg>' +
'<div class="diff-view" id="diffView">' +
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">theme: \'dark\'</span></div>' +
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">theme: \'classic\'</span></div>' +
'<div class="dline is-del"><span class="dsign">-</span><span class="dtext">fontSize: 13, lineHeight: 1.4</span></div>' +
'<div class="dline is-add"><span class="dsign">+</span><span class="dtext">fontSize: 14, lineHeight: 1.6</span></div>' +
'</div>' +
'</div>' +
'</div>';
var stage = document.getElementById('anim3Stage');
var paper = document.getElementById('paper');
var penPos = document.getElementById('penPos');
var pen = document.getElementById('writerPen');
var boxBack = document.getElementById('boxBack');
var boxFront = document.getElementById('boxFront');
var diffView = document.getElementById('diffView');
var lines = Array.prototype.slice.call(paper.querySelectorAll('.pline'));
var dlines = Array.prototype.slice.call(diffView.querySelectorAll('.dline'));
// 挂载六边形眼睛(眨眼 + 追踪)
if (window.AstrionAvatar) {
AstrionAvatar.mount(document.getElementById('writerAvatar'), { mode: 'idle', blink: true, track: true });
}
/* 等比缩放:舞台固定 540×380 逻辑尺寸,按容器 min(w/540, h/380) 缩放居中。
垂直方向按内容中心CONTENT_CY对齐容器中心内容包围盒为 diff 顶 0 ~ 盒底 262
中心 131 ≠ 舞台中心 190直接居中会让整体视觉偏上 */
var CONTENT_CY = 131;
var curScale = 1;
function updateScale() {
var w = container.clientWidth, h = container.clientHeight;
curScale = Math.min(w / LOGICAL_W, h / LOGICAL_H) || 1;
stage.style.top = 'calc(50% + ' + ((LOGICAL_H / 2 - CONTENT_CY) * curScale).toFixed(1) + 'px)';
stage.style.transform = 'translate(-50%, -50%) scale(' + curScale + ')';
}
if (window.ResizeObserver) new ResizeObserver(updateScale).observe(container);
window.addEventListener('resize', updateScale);
/* 视口 rect → 舞台本地坐标stage 被 scale差值统一除 curScale。
sr 一律现场重测:擦写/飞行序列长达数百 ms外部缓存的旧 sr 在页面滚动后已过期,
与调用方刚实测的 rect 相差一个滚动量——文字的起点终点就会跟着页面滚动歪掉 */
function toLocal(rect, sr) {
sr = stageRect();
return {
left: (rect.left - sr.left) / curScale,
top: (rect.top - sr.top) / curScale,
width: rect.width / curScale,
height: rect.height / curScale
};
}
var gen = 0;
/* 笔尖/橡皮的精确位置不再手调像素:
* SVG 内放了两个隐形参考点penNibRef / penEraserRef
* 运行时 getBoundingClientRect 实测其渲染位置做补偿——
* 无论笔怎么旋转/摇摆,作用点都精确钉在目标上。 */
var nibRef = document.getElementById('penNibRef');
var eraserRef = document.getElementById('penEraserRef');
var penTx = 0, penTy = 0;
/* 笔的初始位(被智能体拿着):笔尖所在点,笔身斜跨六边形。
与 04 同款手持偏移writer 盒左上 (412,26) + (46,58) */
var PEN_HOME = { x: 458, y: 84 };
/* 所有计时都基于 rAF 累计「可见时间」:后台标签页 rAF 暂停,整个动画流程随之冻结,
恢复可见时从冻结点继续——setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
async function step(ms, myGen) {
await rafWait(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls) {
var e = document.createElement(tag);
if (cls) e.className = cls;
return e;
}
function stageRect() { return stage.getBoundingClientRect(); }
/* 缓动 */
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
/* rAF 逐帧驱动,进度按可见时间累计(后台冻结,恢复不跳变);返回 false/取消时提前结束 */
function tween(ms, fn, myGen) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
if (myGen !== gen) { resolve(); return; }
acc += Math.min(now - last, 100);
last = now;
var p = Math.min(acc / ms, 1);
fn(p);
if (p >= 1) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function setPen(x, y) {
penTx = x; penTy = y;
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
}
/* 手动微调css px数据对准后仍有轻微视觉偏差时在这里调用户肉眼验收 */
var NIB_TWEAK = { x: 0, y: -1.8 };
var ERASER_TWEAK = { x: 0, y: 0 };
/* 把参考点精确移到舞台坐标 (x, y):实测当前位置,差值补偿到外层 translate一次收敛 */
function refTo(refEl, x, y, sr) {
sr = stageRect(); /* 现场重测,同 toLocal */
var r = refEl.getBoundingClientRect();
var cx = (r.left + r.width / 2 - sr.left) / curScale;
var cy = (r.top + r.height / 2 - sr.top) / curScale;
setPen(penTx + (x - cx), penTy + (y - cy));
}
function nibTo(x, y, sr) { refTo(nibRef, x + NIB_TWEAK.x, y + NIB_TWEAK.y, sr); }
function eraserTo(x, y, sr) { refTo(eraserRef, x + ERASER_TWEAK.x, y + ERASER_TWEAK.y, sr); }
/* 行右端进度点的舞台坐标:倾斜线的包围盒中心≠真实右端点,
* 用线内右端锚点(.pline-end的实测位置兼容 3D 透视 + rotZ 倾斜 */
function lineTip(line, sr) {
sr = stageRect(); /* 现场重测,同 toLocal */
var r = line.querySelector('.pline-end').getBoundingClientRect();
return { x: (r.left + r.width / 2 - sr.left) / curScale, y: (r.top + r.height / 2 - sr.top) / curScale };
}
function nibToLineTip(line, sr) {
var t = lineTip(line, sr);
nibTo(t.x, t.y, sr);
}
/* 三轮内容Dockerfile / Python / TS不全是一种代码
纸上横线长度 ≈ 对应行字符数(~2.3%/字符)——线长与内容长短一致;
file/tone收盒前渐变覆盖的文件卡红/黄/绿,露出一截即可分辨)。 */
var ROUNDS = [
{
file: 'Docker',
paperW: [34, 26, 59, 44],
edits: [
{ newW: 26, del: 'EXPOSE 8080', add: 'EXPOSE 8091' },
/* 最长行 26 字符≈237px 宽diff 左缘保持在 stage 内(>15px否则左缘描边被 stage 裁掉 */
{ newW: 53, del: 'CMD ["python", "server.py"]', add: 'CMD ["python", "main.py"]' }
]
},
{
file: 'api.py',
paperW: [56, 35, 46, 30],
edits: [
{ newW: 35, del: 'MAX_RETRIES = 3', add: 'MAX_RETRIES = 5' },
{ newW: 46, del: 'request_timeout = 30', add: 'request_timeout = 60' }
]
},
{
file: 'theme.ts',
paperW: [46, 30, 66, 58],
edits: [
{ newW: 37, del: "theme: 'dark'", add: "theme: 'classic'" },
{ newW: 66, del: 'fontSize: 13, lineHeight: 1.4', add: 'fontSize: 14, lineHeight: 1.6' }
]
}
];
/* 盒内纸堆叠透视(按盒子 SVG 侧边斜率 dx/dy=0.5 推导:每向深处 1px 两侧共收 1px
每往里一张纸,顶边抬高 ~10px、宽度按侧边角度同步收窄、只露出顶部一截。
topY/bottomY 为 stage 坐标wRatio 为相对盒子图标宽的比例。 */
/* 等比透视:三张文件本身差不多高,随深度每层缩 ~8%(与宽度收敛同比率);
底边都落在盒口附近194/183/172——每张收进去的瞬间都是真的插进盒子不悬空
顶边各露 5px底边落差 11px - 高度缩 6px ≈ 5px */
/* 等比透视:三张文件本身差不多高,随深度略缩;底边都落在盒口附近——
每张收进去的瞬间都是真的插进盒子,不悬空;顶边各露 5px。
(坐标基于盒子下移 50px 后:盒口 y=224 */
var FILED_SPECS = [
{ topY: 182, bottomY: 234, wRatio: 0.66 }, /* 最前:高 52最宽、最低、贴前壁 */
{ topY: 177, bottomY: 223, wRatio: 0.608 }, /* 中间:高 46 */
{ topY: 172, bottomY: 212, wRatio: 0.556 } /* 最里:高 40底边在盒口线上 */
];
/* diff 行拆成单字符(初始透明占位),擦/写时字母逐个从笔处飘入落位。
落位的 ghost 会转正替换占位符成为流内正式字符reset 时重建占位 */
var dlineTexts = [];
function setDiffTexts(r) {
var e = ROUNDS[r].edits;
dlineTexts = [e[0].del, e[0].add, e[1].del, e[1].add];
}
setDiffTexts(0);
var dlineChars = dlines.map(function () { return []; });
function buildDlineChars() {
dlines.forEach(function (d, di) {
var t = d.querySelector('.dtext');
t.textContent = '';
dlineChars[di] = [];
for (var i = 0; i < dlineTexts[di].length; i++) {
var c = el('span', 'dch');
/* 空格用不换行空格:普通空格 span 会塌缩成 0 宽/高度异常,落点歪斜 */
c.textContent = dlineTexts[di][i] === ' ' ? ' ' : dlineTexts[di][i];
t.appendChild(c);
dlineChars[di].push(c);
}
});
}
buildDlineChars();
/* 一个字母从舞台 (x0,y0)(笔的擦/写位置)飘到 diff 行目标字符位。
落位后转正替换占位符成为流内正式字符——genie 收纸时文字随纸一起变形;
首个落位字符触发行容器(红/绿底色)淡入:底色与文字同步,不先于文字出现 */
function flyChar(x0, y0, chSpan, sr, myGen) {
sr = stageRect(); /* 现场重测,同 toLocal与 to 同刻,滚动不歪 */
var to = chSpan.getBoundingClientRect();
var g = el('span', 'dch-ghost');
g.textContent = chSpan.textContent;
g.style.color = getComputedStyle(chSpan).color;
g.style.transform = 'translate(' + (x0 - 3).toFixed(1) + 'px,' + (y0 - 8).toFixed(1) + 'px)';
stage.appendChild(g);
void g.offsetWidth; /* 起始位定格后再设终点,保证过渡触发 */
g.style.transform = 'translate(' + ((to.left - sr.left) / curScale).toFixed(1) + 'px,' + ((to.top - sr.top) / curScale).toFixed(1) + 'px)';
g.addEventListener('transitionend', function handler() {
g.removeEventListener('transitionend', handler);
if (myGen !== gen) { g.parentNode && g.parentNode.removeChild(g); return; }
/* 飞行过渡真正结束后转正:清掉行内样式、换成流内字符 class、替换占位符同帧完成无跳变 */
g.style.cssText = '';
g.className = 'dch is-lit';
var row = chSpan.parentNode; /* .dtext */
var dline = row.parentNode; /* .dline */
row.replaceChild(g, chSpan);
/* 第一个字符到位,该行的红绿背景与 +/- 符号即浮现(后续字符陆续落位在已有底色的行里) */
dline.classList.add('is-shown');
});
}
function applyWidths() {
lines.forEach(function (l) {
l.style.width = l.dataset.w + '%';
});
}
function reset() {
gen += 1;
disableBrowse();
pen.classList.remove('is-writing');
penPos.classList.remove('is-live');
lines.forEach(function (l) {
l.classList.remove('is-new');
l.style.opacity = '';
});
lines.forEach(function (l, i) { l.dataset.w = ROUNDS[0].paperW[i]; });
applyWidths();
setDiffTexts(0);
diffView.classList.add('no-fade'); /* 重播重置同样禁用过渡,防红绿块淡出闪现 */
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
Array.prototype.slice.call(stage.querySelectorAll('.dch-ghost')).forEach(function (g) { g.parentNode.removeChild(g); });
buildDlineChars(); /* 落位字符已转正为流内文本,重播前重建透明占位 */
Array.prototype.slice.call(stage.querySelectorAll('.file-flight')).forEach(function (g) { g.parentNode.removeChild(g); });
diffView.style.clipPath = '';
diffView.style.transform = '';
diffView.style.transformOrigin = '';
diffView.style.opacity = '';
diffView.style.visibility = '';
diffView.classList.remove('is-sheet');
void diffView.offsetWidth;
diffView.classList.remove('no-fade');
/* 收件箱始终显示,不在重置时隐藏 */
pen.classList.remove('is-erasing');
pen.style.transform = '';
penPos.classList.add('is-live'); /* 重置瞬移,不起飞行动画 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算:让 transform 在 transition:none 下定格,避免移除 is-live 后触发回退过渡 */
penPos.classList.remove('is-live');
}
/* 改写一行:飞向行右端同时倒转笔换到橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 边转回边滑到书写位 → 新行从笔尖长出 */
async function rewriteLine(line, newW, delDline, addDline, myGen) {
var sr = stageRect();
var oldW = parseFloat(line.dataset.w);
// 1) 飞行倒笔一步到位:飞向行右端的同时顺时针转 90° + 滑移换端,到达时橡皮恰好按到线上
// (擦除姿态为「左上—右下」:橡皮在右下接触线,笔身朝左上,与写字的「左下—右上」镜像区分)
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate
// (同步块内连续 setPen/transform 不触发渲染,借此算出精确的起止 translate
pen.classList.add('is-erasing');
penPos.classList.add('is-live');
var t0 = lineTip(line, sr);
var fromX = penTx, fromY = penTy;
pen.style.transform = 'rotate(90deg)';
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
var toX = penTx, toY = penTy;
pen.style.transform = '';
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
await tween(540, function (p) {
var e = easeInOut(p);
pen.style.transform = 'rotate(' + (90 * e).toFixed(1) + 'deg)';
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
}, myGen);
// 3) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
var delChars = dlineChars[dlines.indexOf(delDline)];
var dN = delChars.length;
var nextDel = dN - 1;
var eraseMs = Math.round(oldW * 7);
await tween(eraseMs, function (p) {
line.style.width = (oldW * (1 - easeInOut(p))) + '%';
var wob = Math.sin(p * Math.PI * 4) * 7; // 两个来回±7°
pen.style.transform = 'rotate(' + (90 + wob).toFixed(1) + 'deg)';
var t = lineTip(line, sr);
eraserTo(t.x, t.y, sr);
while (nextDel >= 0 && p >= (dN - nextDel) / (dN + 1)) {
flyChar(t.x, t.y, delChars[nextDel], sr, myGen);
nextDel--;
}
}, myGen);
// 5) 回正:翻回 0° 与换端回笔尖融合为一步(线宽已 0右端即行首到位即开写
// 转回 0° 后移除 is-erasingorigin 切回笔尖无跳变
var home = lineTip(line, sr);
var backFromX = penTx, backFromY = penTy;
pen.style.transform = '';
nibTo(home.x, home.y, sr);
var backToX = penTx, backToY = penTy;
pen.style.transform = 'rotate(90deg)';
setPen(backFromX, backFromY);
await tween(300, function (p) {
var e = easeInOut(p);
pen.style.transform = 'rotate(' + (90 * (1 - e)).toFixed(1) + 'deg)';
setPen(backFromX + (backToX - backFromX) * e, backFromY + (backToY - backFromY) * e);
}, myGen);
pen.classList.remove('is-erasing');
pen.style.transform = '';
// 6) 写上绿色新内容笔尖跟随右端is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
pen.classList.add('is-writing');
var addChars = dlineChars[dlines.indexOf(addDline)];
var aN = addChars.length;
var nextAdd = 0;
var writeMs = Math.round(newW * 7); // 时长与行长成正比(等速书写)
await tween(writeMs, function (p) {
line.style.width = (newW * easeOut(p)) + '%';
nibToLineTip(line, sr);
while (nextAdd < aN && p >= (nextAdd + 0.5) / aN) {
var t2 = lineTip(line, sr);
flyChar(t2.x, t2.y, addChars[nextAdd], sr, myGen);
nextAdd++;
}
}, myGen);
pen.classList.remove('is-writing');
}
/* genie 收盒 · 裁切式:文件卡片始终是同一个 DOM 元素,不切片、不缩放内容——
每帧把 S 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 pathfill 纸色+描边跟随),
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
文字随轮廓中心移动、矢量缩小字号;终态轮廓恰好是插在盒口的小矩形。
三轮播完后动画可逆点击盒内卡片按原路径倒出查看genieRun dir=-1。 */
/* genie 时序:塞入/倒出共用。833/533= 原 1000/640 再提 1.2 倍速,相位结构不变) */
var GENIE_TOTAL = 833;
var GENIE_SLICE = 533;
var GENIE_MAXDELAY = GENIE_TOTAL - GENIE_SLICE;
/* genie 几何参数:收盒时算一次缓存到 flight._geom倒出时原样复用 */
function computeGenieGeom(flight, r) {
var sr = stageRect();
var br = toLocal(boxBack.getBoundingClientRect(), sr);
var px = +flight.dataset.px, py = +flight.dataset.py;
var W = +flight.dataset.w, H = +flight.dataset.h;
/* 盒口中心stage 坐标):图标 y=12/24 处为盒口前缘线 */
var mouthX = br.left + br.width / 2;
/* 收盒顺序 3→2→1第一轮收最里面最后一轮收最前后收的挡先收的 */
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
var sinkY = spec.bottomY - (py + H);
return {
W: W, H: H, sinkY: sinkY,
centerDx: mouthX - (px + W / 2),
minS: (br.width * spec.wRatio) / W,
compressH: spec.topY - py - sinkY
};
}
/* genie 单帧:按相位 p0=封面原位1=盒口终态)画 S 曲线轮廓、定位随动文字。
正放收盒与倒放倒出共用(倒放只是 p 从 1 走回 0返回轮廓包围盒。 */
function genieFrame(flight, g, p) {
var shape = flight._shapeEl;
var name = flight._nameEl;
var stat = flight._statEl;
var W = g.W, H = g.H, sinkY = g.sinkY, centerDx = g.centerDx;
var minS = g.minS, compressH = g.compressH;
/* 交错相位运动:底部先动先快,顶部延迟;侧边 72 段采样≈1.6px/段)平滑无锯齿 */
var N = 72;
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
var now = p * GENIE_TOTAL;
var L = [], R = [];
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (var i = 0; i < N; i++) {
var v = (i + 0.5) / N;
var start = (1 - v) * GENIE_MAXDELAY;
var q = Math.min(Math.max((now - start) / GENIE_SLICE, 0), 1);
var e = easeInOut(q);
var sx = 1 - (1 - minS) * e;
var ty = (sinkY + (1 - v) * compressH) * e;
var tx = centerDx * e;
var lw = W * sx;
var lx = (W - lw) / 2 + tx, rx = lx + lw;
var yt = i * H / N + ty, yb = (i + 1) * H / N + ty;
L.push([lx, yt], [lx, yb]);
R.push([rx, yt], [rx, yb]);
if (lx < minX) minX = lx;
if (rx > maxX) maxX = rx;
if (yt < minY) minY = yt;
if (yb > maxY) maxY = yb;
}
/* 拼圆角多边形:四角 Q 贝塞尔(控制点=角点),角区两侧各截 RR 长——
顶/底边内缩 RR侧边从 ±RR 处起笔(落在角区内的端部小段跳过/截断)。
这样 Q 跨距 RR×RR等效圆角与端点 roundedRectD 的 r=3.5 圆弧视觉一致;
早前版本侧边只跨一个切片H/N≈1.7pxQ 被压成 ~1px 微圆角≈直角,
倒放到位切换 roundedRectD 时圆角瞬间跳变bug。 */
var f = function (n) { return n.toFixed(1); };
var lt = L[0], rt = R[0];
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
var RRe = Math.min(RR, (rt[0] - lt[0]) / 2, (rbN[0] - lbN[0]) / 2, (lbN[1] - lt[1]) / 2);
var rtIn = lt[1] + RRe, rbIn = rbN[1] - RRe; /* 右侧:上角区下沿 / 下角区上沿 */
var ltIn = lt[1] + RRe, lbIn = lbN[1] - RRe; /* 左侧:上角区下沿 / 下角区上沿 */
var d = 'M' + f(lt[0] + RRe) + ',' + f(lt[1]);
d += ' L' + f(rt[0] - RRe) + ',' + f(rt[1]);
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rt[0]) + ',' + f(rtIn); /* 右上 */
for (var i = 0; i < N; i++) {
var u2 = R[i * 2], d2 = R[i * 2 + 1];
if (d2[1] <= rtIn || u2[1] >= rbIn) continue; /* 整段落在角区内 */
var ax = u2[0], ay = u2[1], bx = d2[0], by = d2[1];
if (ay < rtIn) { var s = (rtIn - ay) / (by - ay); ax += (bx - ax) * s; ay = rtIn; }
if (by > rbIn) { var s2 = (rbIn - ay) / (by - ay); bx = ax + (bx - ax) * s2; by = rbIn; }
d += ' L' + f(ax) + ',' + f(ay);
d += ' L' + f(bx) + ',' + f(by);
}
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RRe) + ',' + f(rbN[1]); /* 右下 */
d += ' L' + f(lbN[0] + RRe) + ',' + f(lbN[1]); /* 底边 */
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(lbN[0]) + ',' + f(lbIn); /* 左下 */
for (var j = N - 1; j >= 0; j--) {
var dn = L[j * 2 + 1], up = L[j * 2];
if (dn[1] <= ltIn || up[1] >= lbIn) continue; /* 整段落在角区内 */
var cx2 = dn[0], cy2 = dn[1], dx2 = up[0], dy2 = up[1];
if (cy2 > lbIn) { var s3 = (lbIn - dy2) / (cy2 - dy2); cx2 = dx2 + (cx2 - dx2) * s3; cy2 = lbIn; }
if (dy2 < ltIn) { var s4 = (ltIn - cy2) / (dy2 - cy2); dx2 = cx2 + (dx2 - cx2) * s4; dy2 = ltIn; }
d += ' L' + f(cx2) + ',' + f(cy2);
d += ' L' + f(dx2) + ',' + f(dy2);
}
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RRe) + ',' + f(lt[1]); /* 左上 */
d += ' Z';
shape.setAttribute('d', d);
/* 文字随轮廓中心移动、矢量缩小字号。
font-size 必须内联 stylepresentation attribute 优先级低于 CSS 规则,会被压住 */
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
var sc = (maxX - minX) / W;
/* 字号双保险随收窄等比缩且不超过卡片宽能放下的上限mono 字宽 ≈0.62em */
var fsFit = (maxX - minX) * 0.88 / (name.textContent.length * 0.62);
var fs1 = Math.min(Math.max(15 * sc, 7), fsFit);
var fs2 = Math.max(11 * sc, 7);
name.setAttribute('x', cx);
name.setAttribute('y', cy - 2);
name.style.fontSize = fs1.toFixed(1) + 'px';
stat.setAttribute('x', cx);
stat.setAttribute('y', cy + fs2 + 2);
stat.style.fontSize = fs2.toFixed(1) + 'px';
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
/* genie 正放(收盒 dir=1/ 倒放(倒出 dir=-1同一套帧函数双向驱动 */
async function genieRun(myGen, flight, dir) {
var g = flight._geom;
var lastBox = null;
await tween(GENIE_TOTAL, function (p) {
lastBox = genieFrame(flight, g, dir > 0 ? p : 1 - p);
}, myGen);
/* 端点定格为精确圆角矩形(收盒=盒口堆叠小卡;倒出=完整封面),消除折线近似 */
if (dir > 0) {
flight._shapeEl.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
flight._lastBox = lastBox;
} else {
flight._shapeEl.setAttribute('d', roundedRectD(0, 0, g.W, g.H, 3.5));
}
}
async function genieIntoBox(myGen, r, flight) {
flight._geom = computeGenieGeom(flight, r);
var g = flight._geom;
flight.style.height = (g.H + g.sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
await genieRun(myGen, flight, 1);
}
/* SVG 工具SVG 元素必须用 createElementNS 创建createElement 建的不渲染) */
var SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, cls) {
var e = document.createElementNS(SVG_NS, tag);
if (cls) e.setAttribute('class', cls);
return e;
}
/* 圆角矩形 path初始封面与终态卡片用genie 期间为直边多边形) */
function roundedRectD(x, y, w, h, r) {
return 'M' + (x + r) + ',' + y +
' L' + (x + w - r) + ',' + y +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w) + ',' + (y + r) +
' L' + (x + w) + ',' + (y + h - r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w - r) + ',' + (y + h) +
' L' + (x + r) + ',' + (y + h) +
' A' + r + ',' + r + ' 0 0 1 ' + x + ',' + (y + h - r) +
' L' + x + ',' + (y + r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + r) + ',' + y + ' Z';
}
/* 收盒前文件卡片SVGpath 纸色+描边text 文件名+统计)渐变出现,
diff 内容直接消失;卡片是 SVG 单元素——描边跟随 path 轮廓S 曲线斜边也有) */
async function showFileCover(r, myGen) {
var sr = stageRect();
var pr = toLocal(diffView.getBoundingClientRect(), sr);
var px = pr.left, py = pr.top;
var W = pr.width, H = pr.height;
var flight = svgEl('svg', 'file-flight');
flight.style.left = px + 'px';
flight.style.top = py + 'px';
flight.style.width = W + 'px';
flight.style.height = H + 'px';
/* 创建即设层级(先收最里 21后收在前封面期就必须在 diffView(z20) 之上 */
flight.style.zIndex = String(21 + r);
flight.dataset.px = px; flight.dataset.py = py;
flight.dataset.w = W; flight.dataset.h = H;
var shape = svgEl('path', 'file-flight-shape');
shape.setAttribute('d', roundedRectD(0, 0, W, H, 3.5));
var name = svgEl('text', 'file-cover-name');
name.setAttribute('x', W / 2);
name.setAttribute('y', H / 2 - 3);
name.setAttribute('text-anchor', 'middle');
name.textContent = ROUNDS[r].file;
var stat = svgEl('text', 'file-cover-stat');
stat.setAttribute('x', W / 2);
stat.setAttribute('y', H / 2 + 14);
stat.setAttribute('text-anchor', 'middle');
var add = svgEl('tspan', 'add'); add.textContent = '+2';
var del = svgEl('tspan', 'del'); del.textContent = ' -2';
stat.appendChild(add);
stat.appendChild(del);
flight.appendChild(shape);
flight.appendChild(name);
flight.appendChild(stat);
flight._shapeEl = shape; flight._nameEl = name; flight._statEl = stat; /* genie 帧渲染复用,免每帧 querySelector */
stage.appendChild(flight);
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式getComputedStyle 才强制样式计算,定格 opacity 0 */
flight.classList.add('is-on');
await step(800, myGen); /* 等封面渐变动画完全结束730ms + 余量) */
diffView.style.visibility = 'hidden'; /* 封面完全出现后,背后的 diff 瞬间消失 */
return flight;
}
/* 下轮 diff 内容就位no-fade 禁用过渡——移除 is-shown 与恢复显示若同帧,
红绿底色会以 0.3s 淡出形式闪一下(有底色无文字);禁用后瞬时消失,无残影 */
function resetDiffForRound(r) {
setDiffTexts(r);
diffView.classList.add('no-fade');
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
diffView.classList.remove('is-sheet');
buildDlineChars();
diffView.style.visibility = '';
diffView.style.opacity = ''; /* 恢复渐隐前的可见no-fade 下瞬间生效,无闪) */
void diffView.offsetWidth; /* 强制样式计算:无过渡状态定格后再恢复过渡 */
diffView.classList.remove('no-fade');
}
/* 纸上复现新一轮 4 行内容(长短不一):旧行淡出 → 换行宽 → 淡入 */
async function refillPaper(r, myGen) {
paper.classList.add('is-refilling');
await step(300, myGen);
lines.forEach(function (l, i) {
l.classList.remove('is-new');
l.dataset.w = ROUNDS[r].paperW[i];
});
applyWidths();
paper.classList.remove('is-refilling');
await step(400, myGen);
}
/* ───── 三轮收盒后:盒内卡片可交互 ─────
hover 微微向上抬+向右倾(选中感);点击倒放 genie 倒出卡片,内容渐变为该轮 diff
再点(或点空白)正放收回——可反复查看。热区是独立 divSVG 卡片本体
pointer-events 为 none且三层卡只露出顶边一截热区按各卡露出顶边条带定位
重叠区归层级更高的(更靠前的卡),与视觉所见一致。 */
var flights = [];
var hotspots = [];
var replayDiffEl = null;
var browse = { active: false, opened: -1, busy: false };
/* 热区底边统一延伸到盒口前缘略下(前缘 214 + 凹口上段):
最前卡的可见区本就一直延伸到这里;三张卡热区互相重叠的部分由 z-index 自然归更靠前的卡,与视觉一致 */
var CARD_HOT_BOTTOM = 220;
function enableBrowse() {
browse.active = true;
flights.forEach(function (flight, r) {
if (!flight || !flight._lastBox) return;
var lb = flight._lastBox;
var hot = el('div', 'card-hotspot');
var topPx = +flight.dataset.py + lb.y;
hot.style.left = (+flight.dataset.px + lb.x) + 'px';
hot.style.top = topPx + 'px';
hot.style.width = lb.w + 'px';
hot.style.height = (CARD_HOT_BOTTOM - topPx) + 'px';
hot.style.zIndex = String(31 + r);
hot.addEventListener('mouseenter', function () {
if (browse.busy || browse.opened === r) return;
flight.classList.add('is-hover');
});
hot.addEventListener('mouseleave', function () {
flight.classList.remove('is-hover');
});
hot.addEventListener('click', function (ev) {
ev.stopPropagation();
pauseAutoplay(); /* 按下卡片:暂停轮播 5 秒,点哪张看哪张 */
onCardClick(r);
});
stage.appendChild(hot);
hotspots.push(hot);
});
/* 收盒完成,停 2 秒让观众看清堆叠结果,再自动开始轮播 */
var g = gen;
rafWait(2000).then(function () { if (browse.active && g === gen) startAutoplay(); });
}
function disableBrowse() {
stopAutoplay();
browse.active = false;
browse.opened = -1;
browse.busy = false;
pendingCardClick = -1;
hotspots.forEach(function (h) { h.parentNode && h.parentNode.removeChild(h); });
hotspots = [];
flights = [];
removeReplayDiff();
}
async function onCardClick(r) {
/* 点已展开的卡:无操作(轮播中先停轮播,走到这保持展开) */
if (browse.opened === r && !autoplay.on) return;
/* 切换动画进行中点击:记录意图,当前动画完成后执行(不被 busy 吞掉) */
if (browse.busy) { pendingCardClick = r; return; }
browse.busy = true;
pendingCardClick = -1;
try {
/* 已有展开卡:收回与倒出同时进行(一个塞回、一个倒出,视觉上是交换而非先后)。
opened 先占位新卡,两路动画各自驱动各自的 flight互不干扰 */
var prev = browse.opened;
browse.opened = r;
var tasks = [openCardInner(r)];
if (prev >= 0) tasks.push(closeCardInner(prev));
await Promise.all(tasks);
} catch (e) {
if (e && e.message !== 'cancelled') throw e;
}
browse.busy = false;
if (pendingCardClick >= 0) {
var p = pendingCardClick;
pendingCardClick = -1;
onCardClick(p);
}
}
/* 倒出卡片:倒放 genie最后 350ms 封面文字淡出、该轮 diff 逐行淡入(并行收尾) */
async function openCardInner(r) {
var myGen = gen;
var flight = flights[r];
browse.opened = r;
flight.classList.remove('is-hover');
if (hotspots[r]) hotspots[r].style.display = 'none';
var pouring = genieRun(myGen, flight, -1);
await step(GENIE_TOTAL - 350, myGen);
flight.classList.add('is-bare');
showReplayDiff(r);
await pouring;
}
/* 收回卡片 rdiff 快速淡出 + 封面文字淡回,随后正放 genie 回盒。
只负责收回本身,不碰 browse.opened由调用方管理支持收回/倒出并行) */
async function closeCardInner(r) {
var myGen = gen;
var flight = flights[r];
if (replayDiffEl) {
var d = replayDiffEl;
replayDiffEl = null;
d.classList.add('is-closing');
setTimeout(function () { d.parentNode && d.parentNode.removeChild(d); }, 260);
}
flight.classList.remove('is-bare');
await step(160, myGen);
await genieRun(myGen, flight, 1);
if (hotspots[r]) hotspots[r].style.display = '';
}
/* 展开后的 diff 层:位置同 diffView卡片倒出落点四行逐行淡入点击它即收回 */
function showReplayDiff(r) {
removeReplayDiff();
var e = ROUNDS[r].edits;
var rows = [
{ t: e[0].del, c: 'is-del', s: '-' },
{ t: e[0].add, c: 'is-add', s: '+' },
{ t: e[1].del, c: 'is-del', s: '-' },
{ t: e[1].add, c: 'is-add', s: '+' }
];
var d = el('div', 'replay-diff');
rows.forEach(function (row, i) {
var line = el('div', 'dline is-shown ' + row.c);
line.style.transitionDelay = (i * 70) + 'ms';
var sign = el('span', 'dsign'); sign.textContent = row.s;
var text = el('span', 'dtext'); text.textContent = row.t;
line.appendChild(sign);
line.appendChild(text);
d.appendChild(line);
});
d.addEventListener('click', function (ev) {
ev.stopPropagation();
closeOpenedCard();
});
stage.appendChild(d);
getComputedStyle(d).opacity; /* 强制样式计算:起始 opacity 0 定格后再开过渡 */
d.classList.add('is-on');
replayDiffEl = d;
}
function removeReplayDiff() {
if (replayDiffEl) { replayDiffEl.parentNode && replayDiffEl.parentNode.removeChild(replayDiffEl); replayDiffEl = null; }
}
function closeOpenedCard() {
pauseAutoplay(); /* 用户手动收回卡片:同样暂停轮播 5 秒 */
if (browse.busy || browse.opened < 0) return;
browse.busy = true;
var r = browse.opened;
browse.opened = -1;
closeCardInner(r).then(function () { browse.busy = false; }, function (err) {
browse.busy = false;
if (err && err.message !== 'cancelled') throw err;
});
}
/* ───── 自动轮播:收盒完成后自动开始,循环倒出/收回三张卡的详情;
用户按下卡片(或手动收回)暂停 5 秒,之后自动恢复 ───── */
var AUTOPLAY_DWELL = 4000; /* 每次切换间隔(倒出 833ms + diff 淡入 + 阅读停留) */
var AUTOPLAY_PAUSE_MS = 5000; /* 用户操作后暂停轮播的时长 */
var autoplay = { on: false, timer: null, resumeTimer: null, idx: -1 };
var pendingCardClick = -1;
function startAutoplay() {
if (!browse.active || autoplay.on) return;
autoplay.on = true;
autoplay.idx = browse.opened; /* 从当前展开卡的下一张开始;无展开则从第 1 张开始 */
autoplayTick();
}
/* 彻底停止(重播/卸载时):连恢复计时一起清。
timer 是标识对象而非 setTimeout id——轮播间隔由 rafWait 驱动,回调比对引用失效即放弃 */
function stopAutoplay() {
autoplay.on = false;
autoplay.timer = null;
if (autoplay.resumeTimer) { clearTimeout(autoplay.resumeTimer); autoplay.resumeTimer = null; }
}
/* 暂停 5 秒用户操作时期间轮播不动作5 秒后自动恢复 */
function pauseAutoplay() {
autoplay.on = false;
autoplay.timer = null;
if (autoplay.resumeTimer) clearTimeout(autoplay.resumeTimer);
autoplay.resumeTimer = setTimeout(function () {
autoplay.resumeTimer = null;
if (browse.active) startAutoplay();
}, AUTOPLAY_PAUSE_MS);
}
function autoplayTick() {
if (!autoplay.on) return;
autoplay.idx = (autoplay.idx + 1) % flights.length;
onCardClick(autoplay.idx);
var t = autoplay.timer = {};
rafWait(AUTOPLAY_DWELL).then(function () {
if (autoplay.on && autoplay.timer === t) autoplayTick();
});
}
async function play() {
var myGen = ++gen;
try {
for (var r = 0; r < ROUNDS.length; r++) {
// 0) 首轮等待开场(后续轮的纸面换新已并入上一轮收盒期间并行完成,无需等待)
if (r === 0) {
await step(650, myGen);
}
// 1) 笔飞到纸上,先后改写第 2、3 行;擦旧行时红行 - 飘出,写新行时绿行 + 飘出diff 实时构建)
await rewriteLine(lines[1], ROUNDS[r].edits[0].newW, dlines[0], dlines[1], myGen);
await rewriteLine(lines[2], ROUNDS[r].edits[1].newW, dlines[2], dlines[3], myGen);
// 2) 笔飞回智能体右侧(不再空等:飞行 500ms 与封面渐现 520ms 并行重叠)
penPos.classList.remove('is-live');
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
// 4) 文件卡片渐变出现diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
var flight = await showFileCover(r, myGen);
flights[r] = flight;
if (r < ROUNDS.length - 1) {
// 5) 收盒与下一轮内容浮现同时进行diff 区在封面下瞬时重建(不可见),
// 纸面淡出旧行、淡入新行——卡片插进盒子时,下一轮的内容已就位
resetDiffForRound(r + 1);
var collecting = genieIntoBox(myGen, r, flight);
await refillPaper(r + 1, myGen);
await collecting;
} else {
await genieIntoBox(myGen, r, flight);
// 6) 末轮三张卡全部收盒后进入可查看交互hover 微倾/点击倒出)
enableBrowse();
}
}
} catch (e) {
if (e && e.message !== 'cancelled') throw e;
}
}
/* stage 空白点击只承担「收回展开卡」(轮播由 pauseAutoplay 暂停) */
stage.addEventListener('click', function () {
if (browse.active && browse.opened >= 0) closeOpenedCard();
});
updateScale();
applyWidths();
penPos.classList.add('is-live'); /* 初始就位瞬移 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
penPos.classList.remove('is-live');
/* 进入视口才开始播放:页面打开时用户可能还没滚到功能区,避免错过开场 */
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
play();
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init };
})();