diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 07:02:13 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 07:02:13 +0800 |
| commit | e8eebe49407de8947bf509d5cf04d9080ee4edf7 (patch) | |
| tree | cd7d5b1b2cb2efca2e19786041e49aa4b5e5b72e /public | |
| parent | 13479cd194cd992c67805093e59f8b1dd1b5f3a2 (diff) | |
| download | somhairles-dream-fsharp-e8eebe49407de8947bf509d5cf04d9080ee4edf7.tar.gz | |
Real historical replay: timeline pins load run's own checkpoint GLBs
- renderTimeline rebuilds #timeline-track after each snapshot and wires
click-to-pin on completed checkpoints (kept placeholder nodes otherwise)
- selection loads the pinned run's own steps/<n>-*.glb (generation/
ownership guards apply), pins survive live snapshots, 跟随最新 restores
live scope, replay pauses/scrubs through 已完成 versions
- placeholder fallback now clearly labeled (占位…) only before any run
- acceptance: historical_replay_regression (9 checks) pins mid-run,
asserts no auto-follow strays, late-older-response precedence, served
checkpoint sha256 vs CLI reference, and viewport geometry change
- fix: timeline-track id missing in index.html crashed renderTimeline
(null firstChild), leaving static placeholder nodes non-clickable
- driver: 71/71 checks, zero console/page errors
Diffstat (limited to 'public')
| -rw-r--r-- | public/App.js | 382 | ||||
| -rw-r--r-- | public/index.html | 17 | ||||
| -rw-r--r-- | public/styles.css | 30 |
3 files changed, 367 insertions, 62 deletions
diff --git a/public/App.js b/public/App.js index 70d2377..ab4ae59 100644 --- a/public/App.js +++ b/public/App.js @@ -1,9 +1,9 @@ import { Union, Record } from "./fable_modules/fable-library-js.4.24.0/Types.js"; import { union_type, class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js"; import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js"; -import { equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; -import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; -import { isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js"; +import { defaultOf, equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; +import { equalsWith, tryFind, iterateIndexed, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; +import { join, isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js"; import { MeshData_triangleIndices, MeshData_positionData } from "./SomhairlesDream.Shared/Domain.js"; import { min, max } from "./fable_modules/fable-library-js.4.24.0/Double.js"; import { defaultArg } from "./fable_modules/fable-library-js.4.24.0/Option.js"; @@ -84,6 +84,10 @@ export let currentProjectId = createAtom(""); export let currentRunId = createAtom(""); +export let historicalSteps = createAtom([]); + +export let pinnedIndex = createAtom(undefined); + export let loadGeneration = createAtom(0); export let pendingLoad = createAtom(undefined); @@ -333,9 +337,34 @@ export function setNoteRetryable(retryable) { } } +export function updateFollowButton() { + if ((historicalSteps().length > 0) && (pinnedIndex() != null)) { + element("follow-button").removeAttribute("disabled"); + setText("follow-button", "跟随最新"); + } + else { + element("follow-button").setAttribute("disabled", "disabled"); + if (historicalSteps().length > 0) { + setText("follow-button", "跟随最新 · 已开启"); + } + else { + setText("follow-button", "跟随最新"); + } + } +} + +export function stopReplay() { + if (replayTimer() == null) { + } + else { + clearInterval(replayTimer()); + replayTimer(undefined); + } +} + export function showFrameMetadata(frame) { - setText("mesh-label", frame.Label); - setText("timeline-current", toText(printf("V%02d · %s"))(frame.Version)(frame.Label)); + setText("mesh-label", toText(printf("占位 · %s"))(frame.Label)); + setText("timeline-current", toText(printf("V%02d · 占位 · %s"))(frame.Version)(frame.Label)); } export function setManifestLink(payload) { @@ -351,7 +380,7 @@ export function setManifestLink(payload) { } } -export function startStepLoad(view, owner) { +export function startStepLoad(view, owner, historical) { const generation = (loadGeneration() + 1) | 0; loadGeneration(generation); pendingLoad([generation, owner]); @@ -364,7 +393,12 @@ export function startStepLoad(view, owner) { let url; const arg_2 = encodeURIComponent(owner.Path); url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s"))(projectId)(runId)(arg_2); - setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何加载中 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path)); + } loader.load(url, ((gltf) => { if (equals(pendingLoad(), [generation, owner])) { pendingLoad(undefined); @@ -374,7 +408,12 @@ export function startStepLoad(view, owner) { loadedStep(owner); frameObject(view, gltf.scene); view.Renderer.render(view.Scene, view.Camera); - setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path)); + } } else { gltf.scene.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); }); @@ -383,14 +422,19 @@ export function startStepLoad(view, owner) { if (equals(pendingLoad(), [generation, owner])) { pendingLoad(undefined); loadedStep(undefined); - failedLoad(owner); + failedLoad([owner, historical]); setNoteRetryable(true); - setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path)); + if (historical) { + setText("viewport-note", toText(printf("历史几何加载失败 · %s"))(owner.Path)); + } + else { + setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path)); + } } })); } -export function loadStepArtifact(view, stepPath) { +export function loadStepArtifact(view, stepPath, historical) { const owner = new LoadOwner(currentProjectId(), currentRunId(), stepPath); let matchResult, pending_1; if (pendingLoad() != null) { @@ -410,22 +454,23 @@ export function loadStepArtifact(view, stepPath) { break; } case 1: { - startStepLoad(view, owner); + startStepLoad(view, owner, historical); break; } } } export function retryFailedLoad() { - let view, owner; + let view, owner, historical; const matchValue = viewState(); const matchValue_1 = failedLoad(); - let matchResult, owner_1, view_1; + let matchResult, historical_1, owner_1, view_1; if (matchValue != null) { if (matchValue_1 != null) { - if ((view = matchValue, (owner = matchValue_1, (owner.ProjectId === currentProjectId()) && (owner.RunId === currentRunId())))) { + if ((view = matchValue, (owner = matchValue_1[0], (historical = matchValue_1[1], (owner.ProjectId === currentProjectId()) && (owner.RunId === currentRunId()))))) { matchResult = 0; - owner_1 = matchValue_1; + historical_1 = matchValue_1[1]; + owner_1 = matchValue_1[0]; view_1 = matchValue; } else { @@ -441,7 +486,147 @@ export function retryFailedLoad() { } switch (matchResult) { case 0: { - loadStepArtifact(view_1, owner_1.Path); + loadStepArtifact(view_1, owner_1.Path, historical_1); + break; + } + case 1: { + break; + } + } +} + +export function applyHistoricalStep(index, duringPlayback) { + let arg_5, view; + let matchResult, view_1; + if (viewState() != null) { + if ((view = viewState(), (index >= 0) && (index < historicalSteps().length))) { + matchResult = 0; + view_1 = viewState(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + pinnedIndex(index); + const path = item(index, historicalSteps()); + const version = (index + 1) | 0; + setText("mesh-label", toText(printf("历史 · %s"))(path)); + setText("timeline-current", toText(printf("V%02d · 历史回放 · %s"))(version)(path)); + loadStepArtifact(view_1, path, true); + if (duringPlayback) { + setText("replay-state", (arg_5 = (historicalSteps().length | 0), toText(printf("历史回放中 · V%02d (%d/%d)"))(version)(version)(arg_5))); + } + else { + setText("replay-state", toText(printf("历史回放 · 已固定 V%02d"))(version)); + } + break; + } + case 1: { + break; + } + } +} + +export function renderTimeline() { + let arg; + const track = element("timeline-track"); + var child; while ((child = track.firstChild)) { track.removeChild(child); }; + const line = document.createElement("span"); + line.className = "timeline-line"; + line.setAttribute("aria-hidden", "true"); + track.appendChild(line); + if (historicalSteps().length > 0) { + track.setAttribute("style", (arg = (historicalSteps().length | 0), toText(printf("grid-template-columns: repeat(%d, 1fr)"))(arg))); + iterateIndexed((i, path) => { + const active = (pinnedIndex() == null) ? (i === (historicalSteps().length - 1)) : (pinnedIndex() === i); + track.appendChild(createStepNode(i + 1, path, active, true, i)); + }, historicalSteps()); + } + else { + track.setAttribute("style", "grid-template-columns: repeat(3, 1fr)"); + frames.forEach((frame) => { + track.appendChild(createStepNode(frame.Version, toText(printf("占位 · %s"))(frame.Label), false, frame.Version < frames.length, undefined)); + }); + } +} + +export function selectStep(index) { + if ((index >= 0) && (index < historicalSteps().length)) { + stopReplay(); + applyHistoricalStep(index, false); + updateFollowButton(); + renderTimeline(); + } +} + +export function createStepNode(version, label, active, complete, onClickIndex) { + const classes = ["timeline-step"]; + if (complete) { + void (classes.push("timeline-step-complete")); + } + if (onClickIndex != null) { + void (classes.push("timeline-step-real")); + } + if (active) { + void (classes.push("timeline-step-active")); + } + const step = document.createElement("div"); + step.className = join(" ", classes); + const dot = document.createElement("span"); + dot.className = "timeline-dot"; + dot.setAttribute("aria-hidden", "true"); + step.appendChild(dot); + const versionLabel = document.createElement("span"); + versionLabel.className = "timeline-version"; + versionLabel.textContent = toText(printf("V%02d"))(version); + step.appendChild(versionLabel); + const text = document.createElement("span"); + text.className = "timeline-label"; + text.textContent = label; + step.appendChild(text); + if (onClickIndex == null) { + } + else { + const index = onClickIndex | 0; + step.addEventListener("click", (_arg) => { + selectStep(index); + }); + } + return step; +} + +export function followLatestStep() { + let view; + let matchResult, view_1; + if (viewState() != null) { + if ((view = viewState(), historicalSteps().length > 0)) { + matchResult = 0; + view_1 = viewState(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + stopReplay(); + pinnedIndex(undefined); + const total = historicalSteps().length | 0; + const latest = item(total - 1, historicalSteps()); + loadStepArtifact(view_1, latest, false); + setText("mesh-label", latest); + setText("timeline-current", toText(printf("V%02d · 实时跟随 · %s"))(total)(latest)); + setText("replay-state", toText(printf("实时跟随 · 最新 V%02d"))(total)); + updateFollowButton(); + renderTimeline(); break; } case 1: { @@ -451,7 +636,7 @@ export function retryFailedLoad() { } export function updateState(payload) { - let arg, arg_3; + let arg, arg_3, testExpr, arg_5, arg_7; setStatusClass(statusName(payload)); setText("status-label", statusLabel(payload)); setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message); @@ -460,6 +645,12 @@ export function updateState(payload) { if ((currentProjectId() !== payload.projectId) ? true : (currentRunId() !== payload.runId)) { invalidateLoads(); displayedNode(undefined); + stopReplay(); + pinnedIndex(undefined); + historicalSteps([]); + setText("replay-button", "占位演示"); + setText("replay-state", "占位示例 · 未选择历史"); + updateFollowButton(); } currentProjectId(payload.projectId); currentRunId(payload.runId); @@ -475,28 +666,65 @@ export function updateState(payload) { const completedSteps = (payload.completedSteps == null) ? [] : payload.completedSteps; const matchValue = viewState(); if (matchValue == null) { - const steps_1 = completedSteps; - if (steps_1.length > 0) { - setText("mesh-label", item(steps_1.length - 1, steps_1)); + if (completedSteps.length > 0) { + setText("mesh-label", item(completedSteps.length - 1, completedSteps)); } else { showFrameMetadata(frame); } } - else { + else if ((testExpr = completedSteps, !equalsWith((x, y) => (x === y), testExpr, defaultOf()) && (testExpr.length === 0))) { const view = matchValue; - const steps = completedSteps; - if (steps.length === 0) { - showFrameMetadata(frame); - renderFrame(frame); + if (historicalSteps().length > 0) { + stopReplay(); + historicalSteps([]); + pinnedIndex(undefined); + setText("replay-button", "占位演示"); + renderTimeline(); + updateFollowButton(); } - else { - const latest = item(steps.length - 1, steps); + showFrameMetadata(frame); + renderFrame(frame); + } + else { + const view_1 = matchValue; + if (!equalsWith((x_1, y_1) => (x_1 === y_1), completedSteps, historicalSteps())) { + if (historicalSteps().length === 0) { + stopReplay(); + setText("replay-button", "播放历史"); + } + historicalSteps(completedSteps); + let matchResult, pinned_1; + if (pinnedIndex() != null) { + if (pinnedIndex() >= completedSteps.length) { + matchResult = 0; + pinned_1 = pinnedIndex(); + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + pinnedIndex(undefined); + break; + } + } + renderTimeline(); + updateFollowButton(); + } + if (pinnedIndex() == null) { + const latest = item(completedSteps.length - 1, completedSteps); const owner = new LoadOwner(payload.projectId, payload.runId, latest); if (!equals(loadedStep(), owner)) { - loadStepArtifact(view, latest); + loadStepArtifact(view_1, latest, false); } setText("mesh-label", latest); + setText("timeline-current", (arg_5 = (completedSteps.length | 0), toText(printf("V%02d · 实时跟随 · %s"))(arg_5)(latest))); + setText("replay-state", (arg_7 = (completedSteps.length | 0), toText(printf("实时跟随 · 最新 V%02d"))(arg_7))); } } } @@ -505,38 +733,78 @@ export function updateStreamState(message) { setText("stream-state", message); } -export function toggleReplay() { - if (replayTimer() == null) { - replayIndex(0); - setText("replay-button", "暂停重建"); - setText("replay-state", "重建中 · 几何逐帧替换"); - const tick = () => { - if (replayIndex() >= frames.length) { - if (replayTimer() == null) { - } - else { - clearInterval(replayTimer()); - } - replayTimer(undefined); - setText("replay-button", "重建演示"); - setText("replay-state", "重建完成 · 几何已更新"); +export function startPlaceholderPlayback() { + replayIndex(0); + setText("replay-button", "暂停占位演示"); + setText("replay-state", "占位演示中 · 未选择历史"); + const tick = () => { + if (replayIndex() >= frames.length) { + stopReplay(); + setText("replay-button", "占位演示"); + setText("replay-state", "占位演示完成 · 未选择历史"); + } + else { + const frame = item(replayIndex(), frames); + showFrameMetadata(frame); + renderFrame(frame); + setText("replay-state", toText(printf("占位演示中 · V%02d %s"))(frame.Version)(frame.Label)); + replayIndex(replayIndex() + 1); + } + }; + tick(); + replayTimer(setInterval(tick, 900)); +} + +export function startHistoricalPlayback() { + if (historicalSteps().length > 0) { + setText("replay-button", "暂停历史回放"); + applyHistoricalStep(0, true); + updateFollowButton(); + renderTimeline(); + const total = historicalSteps().length | 0; + replayTimer(setInterval(() => { + let arg; + if (pinnedIndex() == null) { + stopReplay(); + setText("replay-button", "播放历史"); + } + else if ((pinnedIndex() + 1) < total) { + const pinned_1 = pinnedIndex() | 0; + applyHistoricalStep(pinned_1 + 1, true); + renderTimeline(); } else { - const frame = item(replayIndex(), frames); - showFrameMetadata(frame); - renderFrame(frame); - setText("replay-state", toText(printf("重建中 · V%02d %s"))(frame.Version)(frame.Label)); - replayIndex(replayIndex() + 1); + const pinned_2 = pinnedIndex() | 0; + stopReplay(); + setText("replay-button", "播放历史"); + setText("replay-state", (arg = ((pinned_2 + 1) | 0), toText(printf("历史回放完成 · 停在 V%02d"))(arg))); } - }; - tick(); - replayTimer(setInterval(tick, 900)); + }, 1500)); + } +} + +export function toggleReplay() { + let arg; + if (replayTimer() == null) { + if (historicalSteps().length > 0) { + startHistoricalPlayback(); + } + else { + startPlaceholderPlayback(); + } } else { clearInterval(replayTimer()); replayTimer(undefined); - setText("replay-button", "重建演示"); - setText("replay-state", "演示已暂停"); + if (historicalSteps().length > 0) { + const current = defaultArg(pinnedIndex(), 0) | 0; + setText("replay-button", "播放历史"); + setText("replay-state", (arg = ((current + 1) | 0), toText(printf("历史回放 · 已暂停 · V%02d"))(arg))); + } + else { + setText("replay-button", "占位演示"); + setText("replay-state", "占位演示 · 已暂停"); + } } } @@ -624,13 +892,17 @@ export function boot() { element("replay-button").addEventListener("click", (_arg) => { toggleReplay(); }); - element("viewport-note").addEventListener("click", (_arg_1) => { + element("follow-button").addEventListener("click", (_arg_1) => { + followLatestStep(); + }); + element("viewport-note").addEventListener("click", (_arg_2) => { retryFailedLoad(); }); element("run-controls").addEventListener("submit", (event) => { event.preventDefault(); startRun(selectedRun()); }); + renderTimeline(); } boot(); diff --git a/public/index.html b/public/index.html index 2acf08e..71e9e62 100644 --- a/public/index.html +++ b/public/index.html @@ -162,29 +162,32 @@ <span class="eyebrow">RECONSTRUCTION TIMELINE</span> <h2 id="timeline-title">几何版本轨迹</h2> </div> - <span id="replay-state" class="replay-state">可回放 · 三个确定性快照</span> + <span id="replay-state" class="replay-state">占位示例 · 未选择历史</span> </div> - <div class="timeline-track" aria-label="几何版本"> + <div class="timeline-track" id="timeline-track" aria-label="几何版本"> <span class="timeline-line" aria-hidden="true"></span> <div class="timeline-step timeline-step-complete"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V01</span> - <span class="timeline-label">重建起点</span> + <span class="timeline-label">占位 · 重建起点</span> </div> <div class="timeline-step timeline-step-complete"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V02</span> - <span class="timeline-label">主承力骨架</span> + <span class="timeline-label">占位 · 主承力骨架</span> </div> <div class="timeline-step"> <span class="timeline-dot" aria-hidden="true"></span> <span class="timeline-version">V03</span> - <span class="timeline-label">舱段重构</span> + <span class="timeline-label">占位 · 舱段重构</span> </div> </div> <div class="timeline-footer"> - <span id="timeline-current">V01 · 重建起点</span> - <button id="replay-button" class="replay-button" type="button">重建演示</button> + <span id="timeline-current">V01 · 占位 · 重建起点</span> + <div class="timeline-actions"> + <button id="follow-button" class="replay-button replay-button-ghost" type="button" disabled>跟随最新</button> + <button id="replay-button" class="replay-button" type="button">占位演示</button> + </div> </div> </section> diff --git a/public/styles.css b/public/styles.css index 6346df5..a925e58 100644 --- a/public/styles.css +++ b/public/styles.css @@ -540,6 +540,31 @@ button:focus-visible { background: var(--gold); } +.timeline-step-real { + cursor: pointer; +} + +.timeline-step-real:hover .timeline-version { + color: var(--ivory); +} + +.timeline-step-active .timeline-dot { + border-color: var(--ivory); + box-shadow: 0 0 0 3px rgba(215, 169, 72, 0.35); +} + +#follow-button[disabled] { + border-color: var(--line-bright); + color: var(--muted); + cursor: default; + opacity: 0.45; +} + +#follow-button[disabled]:hover { + background: transparent; + color: var(--muted); +} + .timeline-version { color: var(--gold-soft); font-size: 0.68rem; @@ -557,6 +582,11 @@ button:focus-visible { font-size: 0.68rem; } +.timeline-actions { + display: flex; + gap: 10px; +} + .replay-button { min-height: 44px; padding: 8px 16px; |
