553 lines
25 KiB
JavaScript
553 lines
25 KiB
JavaScript
/* 修改记录动画 —— 智能体持笔在 3D 倾斜纸上改写内容,
|
||
* 笔尖始终对准线条进度点(逐帧同步),笔以笔尖为圆心摇摆;
|
||
* 纸摊平后绿行抽出展开为红绿 diff。自动播放,点击场景重播。
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
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 });
|
||
}
|
||
|
||
var gen = 0;
|
||
|
||
/* 笔尖/橡皮的精确位置不再手调像素:
|
||
* SVG 内放了两个隐形参考点(penNibRef / penEraserRef),
|
||
* 运行时 getBoundingClientRect 实测其渲染位置做补偿——
|
||
* 无论笔怎么旋转/摇摆,作用点都精确钉在目标上。 */
|
||
var nibRef = document.getElementById('penNibRef');
|
||
var eraserRef = document.getElementById('penEraserRef');
|
||
var penTx = 0, penTy = 0;
|
||
|
||
/* 笔的初始位(被智能体拿着):笔尖所在点,笔身斜跨六边形 */
|
||
var PEN_HOME = { x: 456, y: 94 };
|
||
|
||
function sleep(ms) {
|
||
return new Promise(function (r) { setTimeout(r, ms); });
|
||
}
|
||
|
||
async function step(ms, myGen) {
|
||
await sleep(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 t0 = performance.now();
|
||
function tick(now) {
|
||
if (myGen !== gen) { resolve(); return; }
|
||
var p = Math.min((now - t0) / 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) {
|
||
var r = refEl.getBoundingClientRect();
|
||
var cx = r.left + r.width / 2 - sr.left;
|
||
var cy = r.top + r.height / 2 - sr.top;
|
||
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) {
|
||
var r = line.querySelector('.pline-end').getBoundingClientRect();
|
||
return { x: r.left + r.width / 2 - sr.left, y: r.top + r.height / 2 - sr.top };
|
||
}
|
||
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) {
|
||
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).toFixed(1) + 'px,' + (to.top - sr.top).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;
|
||
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');
|
||
}
|
||
|
||
/* 改写一行:笔尖飞到旧行末端 → 翻转 180° 露出橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 翻回行首 → 绿行从笔尖长出 */
|
||
async function rewriteLine(line, newW, delDline, addDline, myGen) {
|
||
var sr = stageRect();
|
||
var oldW = parseFloat(line.dataset.w);
|
||
|
||
// 1) 飞到该行旧内容末端(笔尖对准线右端,过渡飞行)
|
||
penPos.classList.remove('is-live');
|
||
nibToLineTip(line, sr);
|
||
await step(430, myGen);
|
||
|
||
// 2) 倒笔:先平滑平移换端(笔尖→橡皮,JS tween 驱动,无瞬移),再绕橡皮翻转 180°
|
||
// (同步块内连续 setPen 不触发渲染,借此算出平移的精确起止 translate)
|
||
pen.classList.add('is-erasing');
|
||
penPos.classList.add('is-live');
|
||
var t0 = lineTip(line, sr);
|
||
var fromX = penTx, fromY = penTy;
|
||
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
|
||
var toX = penTx, toY = penTy;
|
||
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
|
||
await tween(280, function (p) {
|
||
var e = easeInOut(p);
|
||
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
|
||
}, myGen);
|
||
|
||
// 3) 翻转 180°:绕橡皮平滑转动,期间橡皮始终钉在线右端
|
||
await tween(300, function (p) {
|
||
pen.style.transform = 'rotate(' + (easeInOut(p) * 180).toFixed(1) + 'deg)';
|
||
var t = lineTip(line, sr);
|
||
eraserTo(t.x, t.y, sr);
|
||
}, myGen);
|
||
|
||
// 4) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
|
||
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
|
||
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(' + (180 + 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°:origin 仍在笔尾,橡皮钉在行首(线宽已 0,右端即行首);
|
||
// 转回 0° 后移除 is-erasing,origin 切回笔尖无跳变
|
||
var home = lineTip(line, sr);
|
||
await tween(280, function (p) {
|
||
pen.style.transform = 'rotate(' + (180 * (1 - easeInOut(p))).toFixed(1) + 'deg)';
|
||
eraserTo(home.x, home.y, sr);
|
||
}, myGen);
|
||
pen.classList.remove('is-erasing');
|
||
pen.style.transform = '';
|
||
|
||
// 6) 写上绿色新内容:笔尖跟随右端,is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
|
||
penPos.classList.remove('is-live');
|
||
nibTo(home.x, home.y, sr);
|
||
await step(320, myGen);
|
||
/* 写入行不变色(修改记录靠 diff 区表达,纸面行保持同色) */
|
||
pen.classList.add('is-writing');
|
||
penPos.classList.add('is-live');
|
||
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 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 clip-path 多边形,
|
||
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
|
||
body(底色+边框+文字)每帧定位到轮廓包围盒,文字随收窄矢量缩小字号;
|
||
终态轮廓恰好是插在盒口的小矩形,元素原地成为堆叠的一张,无替换无跳变。 */
|
||
async function genieIntoBox(myGen, r, flight) {
|
||
var shape = flight.querySelector('.file-flight-shape');
|
||
var name = flight.querySelector('.file-cover-name');
|
||
var stat = flight.querySelector('.file-cover-stat');
|
||
var sr = stageRect();
|
||
var br = boxBack.getBoundingClientRect();
|
||
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 - sr.left;
|
||
/* 收盒顺序 3→2→1:第一轮收最里面,最后一轮收最前(后收的挡先收的) */
|
||
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
|
||
var sinkY = spec.bottomY - (py + H);
|
||
var centerDx = mouthX - (px + W / 2);
|
||
var minS = (br.width * spec.wRatio) / W;
|
||
var compressH = spec.topY - py - sinkY;
|
||
flight.style.height = (H + sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
|
||
|
||
/* 交错相位运动(同一套数学):底部先动先快,顶部延迟——
|
||
每帧把轮廓直接画成 path(fill 纸色 + stroke 描边跟随 S 曲线) */
|
||
/* 侧边采样 72 段(≈1.6px/段):折线足够密,S 曲线侧边平滑无锯齿 */
|
||
var N = 72;
|
||
var totalMs = 1500;
|
||
var sliceMs = 950;
|
||
var maxDelay = totalMs - sliceMs;
|
||
var lastBox = { x: 0, y: 0, w: W, h: H };
|
||
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
|
||
await tween(totalMs, function (p) {
|
||
var now = p * totalMs;
|
||
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) * maxDelay;
|
||
var q = Math.min(Math.max((now - start) / sliceMs, 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;
|
||
}
|
||
/* 拼圆角多边形:顶/底边内缩 RR,四角 Q 贝塞尔过渡(控制点=角点);
|
||
侧边仍是 72 段平滑折线 */
|
||
var f = function (n) { return n.toFixed(1); };
|
||
var lt = L[0], lb0 = L[1], rt = R[0], rb0 = R[1];
|
||
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
|
||
var d = 'M' + f(lt[0] + RR) + ',' + f(lt[1]);
|
||
d += ' L' + f(rt[0] - RR) + ',' + f(rt[1]);
|
||
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rb0[0]) + ',' + f(rb0[1]); /* 右上 */
|
||
for (var i = 1; i < N - 1; i++) {
|
||
d += ' L' + f(R[i * 2][0]) + ',' + f(R[i * 2][1]);
|
||
d += ' L' + f(R[i * 2 + 1][0]) + ',' + f(R[i * 2 + 1][1]);
|
||
}
|
||
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RR) + ',' + f(rbN[1]); /* 右下 */
|
||
d += ' L' + f(lbN[0] + RR) + ',' + f(lbN[1]); /* 底边 */
|
||
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(L[(N - 1) * 2][0]) + ',' + f(L[(N - 1) * 2][1]); /* 左下 */
|
||
for (var j = N - 2; j >= 1; j--) {
|
||
d += ' L' + f(L[j * 2 + 1][0]) + ',' + f(L[j * 2 + 1][1]);
|
||
d += ' L' + f(L[j * 2][0]) + ',' + f(L[j * 2][1]);
|
||
}
|
||
d += ' L' + f(lb0[0]) + ',' + f(lb0[1]); /* 段0左下 */
|
||
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RR) + ',' + f(lt[1]) + ' 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';
|
||
lastBox.x = minX; lastBox.y = minY; lastBox.w = maxX - minX; lastBox.h = maxY - minY;
|
||
}, myGen);
|
||
/* 终态:换回圆角矩形 path(描边+圆角完整),元素留在盒口成为堆叠的一张 */
|
||
shape.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
|
||
}
|
||
|
||
/* 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 = diffView.getBoundingClientRect();
|
||
var px = pr.left - sr.left, py = pr.top - sr.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);
|
||
stage.appendChild(flight);
|
||
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式;getComputedStyle 才强制样式计算,定格 opacity 0 */
|
||
flight.classList.add('is-on');
|
||
await step(520, myGen); /* 等封面渐变动画完全结束 */
|
||
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);
|
||
}
|
||
|
||
async function play() {
|
||
var myGen = ++gen;
|
||
try {
|
||
for (var r = 0; r < ROUNDS.length; r++) {
|
||
// 0) 首轮等待开场;后续轮先展示盒内堆叠片刻,再在纸上复现新内容
|
||
if (r > 0) {
|
||
await step(520, myGen);
|
||
await refillPaper(r, myGen);
|
||
} else {
|
||
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) 笔飞回智能体右侧
|
||
penPos.classList.remove('is-live');
|
||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||
await step(500, myGen);
|
||
|
||
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
|
||
|
||
// 4) 文件卡片渐变出现(diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
|
||
var flight = await showFileCover(r, myGen);
|
||
await genieIntoBox(myGen, r, flight);
|
||
|
||
// 5) 为下一轮重建 diff 区(末轮保留收盒终态)
|
||
if (r < ROUNDS.length - 1) resetDiffForRound(r + 1);
|
||
}
|
||
} catch (e) {
|
||
if (e && e.message !== 'cancelled') throw e;
|
||
}
|
||
}
|
||
|
||
stage.addEventListener('click', function () {
|
||
reset();
|
||
play();
|
||
});
|
||
|
||
applyWidths();
|
||
penPos.classList.add('is-live'); /* 初始就位瞬移 */
|
||
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
|
||
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
|
||
penPos.classList.remove('is-live');
|
||
play();
|
||
})();
|