/* 修改记录动画 —— 可复用模块(主页 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;
/* 站点多语言:locale 由 setLocale 注入(默认 zh-CN);T(zh, en) 就地双语。
切换语言后 setLocale 重新 init 重建 DOM 并从头播放动画(本模块无场景选择,
始终完整播放三轮,新 init 的 IntersectionObserver 进入视口即触发 play 从头播放,
不会停在空白;本模块动画内容均为代码 diff,无 UI 文案,T 暂供未来扩展文案使用)。
gen 为模块级:重新 init 时递增作废旧闭包里的未完成序列,避免旧动画继续跑。 */
var locale = 'zh-CN';
var lastContainer = null;
var gen = 0;
function T(zh, en) { return locale === 'en-US' ? en : zh; }
function setLocale(l) {
if (l !== 'zh-CN' && l !== 'en-US') return;
if (l === locale) return;
locale = l;
if (lastContainer) init(lastContainer);
}
function init(container) {
lastContainer = container;
container.innerHTML =
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
-theme: \'dark\'
' +
'
+theme: \'classic\'
' +
'
-fontSize: 13, lineHeight: 1.4
' +
'
+fontSize: 14, lineHeight: 1.6
' +
'
' +
'
' +
'
';
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
};
}
/* 笔尖/橡皮的精确位置不再手调像素:
* 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-erasing,origin 切回笔尖无跳变
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 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 path(fill 纸色+描边跟随),
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
文字随轮廓中心移动、矢量缩小字号;终态轮廓恰好是插在盒口的小矩形。
三轮播完后动画可逆:点击盒内卡片按原路径倒出查看(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 单帧:按相位 p(0=封面原位,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.7px),Q 被压成 ~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 必须内联 style:presentation 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';
}
/* 收盒前:文件卡片(SVG:path 纸色+描边,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;
再点(或点空白)正放收回——可反复查看。热区是独立 div:SVG 卡片本体
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;
}
/* 收回卡片 r:diff 快速淡出 + 封面文字淡回,随后正放 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, setLocale: setLocale };
})();