import { ArchiveScene } from "/src/scene.ts";
import { selectionWave as baselineWave } from "./baseline-motion.ts";
import { selectionWave as currentWave } from "/src/motion.ts";
import { columnFiles } from "/src/data.ts";
import { wrap } from "/src/archive-loop.ts";
const $ = (s) => document.querySelector(s),
duration = 192,
dt = 1 / 60,
startTime = 8;
const scenes = [
new ArchiveScene($("#before .scene"), currentWave, false),
new ArchiveScene($("#after .scene"), currentWave, true),
];
const draws = scenes.map((s) => s.composer.render.bind(s.composer));
// Seek by simulating at 60 Hz; only the requested frame is drawn.
scenes.forEach((s) => {
s.composer.render = () => {};
});
const fields = [
"lift",
"rail",
"shoulder",
"laneFocus",
"columnCamera",
"selectedCell",
"coordinateOrigin",
"pulses",
"pendingPulse",
"rotation",
"targetRotation",
"returnY",
"detail",
"targetDetail",
"reveal",
"targetReveal",
"last",
"clock",
"idleGain",
"pulseGain",
"lastInteraction",
"scanTime",
"scanBlend",
"selectedSlot",
"looping",
"canInspect",
"clearance",
];
let saved = [],
frame = 0,
index = 0,
events = [],
samples = [],
maxFrame = 0,
playing = false,
ready = false,
lastWall = 0,
accumulator = 0;
const files = columnFiles(2);
const schedules = {
next: [[1, 1]],
previous: [[1, -1]],
repeat: [
[1, 1],
[19, 1],
[37, 1],
[55, 1],
],
reverse: [
[1, 1],
[25, -1],
],
};
function snapshot(s) {
return {
state: Object.fromEntries(fields.map((k) => [k, structuredClone(s[k])])),
camera: s.camera.position.clone(),
aim: s.cameraAim.clone(),
fov: s.camera.fov,
};
}
function reset() {
scenes.forEach((s, i) => {
s.setMode("hidden");
for (const key of fields) s[key] = structuredClone(saved[i].state[key]);
s.camera.position.copy(saved[i].camera);
s.cameraAim.copy(saved[i].aim);
s.camera.fov = saved[i].fov;
s.camera.lookAt(s.cameraAim);
s.camera.updateProjectionMatrix();
s.camera.updateMatrixWorld();
s.drawLabel(0);
s.pointer.set(0, 0);
});
frame = 0;
index = 0;
}
function advance() {
frame++;
const action = events.find(([f]) => f === frame);
if (action) {
const direction = action[1];
index = files[wrap(files.indexOf(index) + direction, files.length)];
scenes.forEach((s) => s.select(index, { axis: "row", direction }));
}
scenes.forEach((s) => s.update(startTime + frame * dt));
}
function setPlaying(value) {
playing = value;
accumulator = 0;
lastWall = 0;
$("#play").textContent = value ? "暂停" : "同步播放";
}
function seek(target) {
target = Math.max(0, Math.min(duration, target));
if (target <= frame) reset();
while (frame < target) advance();
// The first frame is the checkpoint, prior to the first input.
if (target === 0) {
scenes.forEach((s) => s.update(startTime));
reset();
}
render();
}
function render() {
scenes.forEach((s, i) => {
draws[i]();
const [x, y] = s.projectCard(-2.5, 3.7),
view = $(["#before", "#after"][i]);
const zoom = $("#zoom").checked ? 1.7 : 1;
const px = (x / 1920 - 0.34) * zoom + 0.34,
py = (y / 1080 - 0.3) * zoom + 0.3;
const marker = view.querySelector(".marker");
marker.style.left = `${px * 100}%`;
marker.style.top = `${py * 100}%`;
$(["#before-phase", "#after-phase"][i]).textContent = s.pendingPulse
? "档案抬起中"
: s.pulses.length
? "波浪传播中"
: "就位";
});
$("#timeline").value = String(frame);
$("#stamp").textContent =
`${(frame * dt).toFixed(2)} / ${(duration * dt).toFixed(2)} 秒`;
const delta = scenes[1].model.position.y - scenes[0].model.position.y;
const replayError = Math.max(
Math.abs(scenes[0].model.position.y - samples[frame].before),
Math.abs(scenes[1].model.position.y - samples[frame].after),
);
$("#check").textContent =
replayError < 1e-7 ? "同步回放校验通过" : "回放校验异常";
$("#check").dataset.replayError = String(replayError);
$("#difference").textContent =
`${delta >= 0 ? "+" : ""}${delta.toFixed(3)} · ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%`;
const max = samples[maxFrame].after - samples[maxFrame].before;
$("#explanation").textContent =
Math.abs(delta) < 0.001
? `此刻两版几乎重合。最大高度差出现在 ${(maxFrame * dt).toFixed(2)} 秒,为卡片高度的 ${((Math.abs(max) / 3.7) * 100).toFixed(1)}%。`
: `右侧选中档案比左侧${delta >= 0 ? "高" : "低"} ${Math.abs(delta).toFixed(3)} 个场景单位,占卡片高度的 ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%。观察右侧先抬起档案,再带动周围起伏的顺序。`;
const x = 56 + (frame / duration) * 1044;
$("#cursor").setAttribute("x1", x);
$("#cursor").setAttribute("x2", x);
}
function chart() {
// The zero checkpoint still owns the old file. Plot from the first input so
// changing ownership is not misrepresented as a downward motion of one file.
const origin = samples[1].before,
values = samples
.slice(1)
.flatMap((p) => [p.before - origin, p.after - origin]);
const min = Math.floor((Math.min(...values) - 0.03) * 10) / 10,
max = Math.ceil((Math.max(...values) + 0.03) * 10) / 10;
const x = (f) => 56 + (f / duration) * 1044,
y = (v) => 154 - ((v - origin - min) / (max - min)) * 130;
const path = (key) =>
samples
.slice(1)
.map(
(p, f) =>
`${f ? "L" : "M"}${x(f + 1).toFixed(2)},${y(p[key]).toFixed(2)}`,
)
.join(" ");
let markup = "";
for (let i = 0; i <= 4; i++) {
const v = min + ((max - min) * i) / 4,
py = y(v + origin);
markup += `${v.toFixed(2)}`;
}
for (let i = 0; i <= duration; i += 30)
markup += `${(i * dt).toFixed(1)}s`;
for (const [f] of events)
markup += ``;
markup += ``;
$("#chart").innerHTML = markup;
}
function prepare() {
setPlaying(false);
events = schedules[$("#scenario").value];
scenes[0].selectionPulse =
$("#baseline").value === "original" ? baselineWave : currentWave;
reset();
// The checkpoint has already been rendered into the selected model.
scenes.forEach((s) => s.update(startTime));
reset();
samples = [
{ before: scenes[0].model.position.y, after: scenes[1].model.position.y },
];
while (frame < duration) {
advance();
samples.push({
before: scenes[0].model.position.y,
after: scenes[1].model.position.y,
});
}
maxFrame = samples.reduce(
(best, p, f) =>
Math.abs(p.after - p.before) >
Math.abs(samples[best].after - samples[best].before)
? f
: best,
0,
);
chart();
seek(0);
}
$("#play").onclick = () => {
if (frame === duration) seek(0);
setPlaying(!playing);
};
$("#restart").onclick = () => {
seek(0);
setPlaying(true);
};
$("#prev").onclick = () => {
setPlaying(false);
seek(frame - 1);
};
$("#next").onclick = () => {
setPlaying(false);
seek(frame + 1);
};
$("#peak").onclick = () => {
setPlaying(false);
seek(maxFrame);
};
$("#timeline").oninput = (e) => {
setPlaying(false);
seek(Number(e.target.value));
};
$("#scenario").onchange = () => {
if (ready) {
prepare();
setPlaying(true);
}
};
$("#baseline").onchange = () => {
if (ready) {
prepare();
setPlaying(true);
}
};
$("#zoom").onchange = () => {
document
.querySelectorAll(".view")
.forEach((v) => v.classList.toggle("zoomed", $("#zoom").checked));
if (ready) render();
};
$("#chart").onclick = (e) => {
if (!ready) return;
const rect = $("#chart").getBoundingClientRect();
setPlaying(false);
seek(
Math.round(
((((e.clientX - rect.left) / rect.width) * 1120 - 56) / 1044) * duration,
),
);
};
function resize() {
scenes.forEach((s) => {
s.resize();
// These are real-size comparison panels, not the scaled 1920px app stage.
const ratio = Math.min(devicePixelRatio, 1.5);
s.renderer.setPixelRatio(ratio);
s.composer.setPixelRatio(ratio);
});
if (ready) render();
}
addEventListener("resize", resize);
document.addEventListener("keydown", (e) => {
if (!ready || ["INPUT", "SELECT", "BUTTON", "A"].includes(e.target.tagName))
return;
if (e.code === "Space") {
e.preventDefault();
$("#play").click();
}
if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
e.preventDefault();
setPlaying(false);
seek(frame + (e.code === "ArrowLeft" ? -1 : 1));
}
});
function tick(wall) {
if (playing && ready && !document.hidden) {
if (lastWall)
accumulator +=
Math.min((wall - lastWall) / 1000, 0.1) * Number($("#speed").value);
const count = Math.floor(accumulator / dt);
if (count) {
accumulator -= count * dt;
let target = frame + count;
if (target > duration) {
if ($("#loop").checked) {
reset();
target %= duration;
} else {
target = duration;
setPlaying(false);
}
}
seek(target);
}
}
lastWall = wall;
requestAnimationFrame(tick);
}
try {
await document.fonts.ready;
await Promise.all(scenes.map((s) => s.load()));
scenes.forEach((s) => {
s.setMode("archive");
s.select(0);
for (let f = 1; f <= 480; f++) s.update(f * dt);
});
// Start both runs with the exact same resting state and no residual pulses.
scenes.forEach((s) => {
s.pulses = [];
s.idleGain = 0;
s.lastInteraction = startTime;
s.update(startTime);
});
saved = scenes.map(snapshot);
resize();
ready = true;
document
.querySelectorAll(".transport button,#timeline")
.forEach((el) => (el.disabled = false));
prepare();
setPlaying(true);
requestAnimationFrame(tick);
} catch (error) {
$("#explanation").textContent = `场景加载失败:${error.message}`;
$("#play").textContent = "加载失败";
console.error(error);
}