summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 07:02:13 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 07:02:13 +0800
commite8eebe49407de8947bf509d5cf04d9080ee4edf7 (patch)
treecd7d5b1b2cb2efca2e19786041e49aa4b5e5b72e /src
parent13479cd194cd992c67805093e59f8b1dd1b5f3a2 (diff)
downloadsomhairles-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 'src')
-rw-r--r--src/SomhairlesDream.Frontend/App.fs287
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs3
2 files changed, 249 insertions, 41 deletions
diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs
index 549b695..b2bb8e6 100644
--- a/src/SomhairlesDream.Frontend/App.fs
+++ b/src/SomhairlesDream.Frontend/App.fs
@@ -54,13 +54,21 @@ let mutable replayIndex = 0
let mutable eventSource: EventSource option = None
let mutable currentProjectId = ""
let mutable currentRunId = ""
+/// Checkpoint artifact paths published by the currently selected run. Empty
+/// when no real history exists and the placeholder fallback is shown.
+let mutable historicalSteps: string array = [||]
+/// Index into historicalSteps of the user-pinned historical checkpoint.
+/// None means the viewport follows the latest published checkpoint.
+let mutable pinnedIndex: int option = None
/// Bumped on every new step request and on every invalidation; only the
/// response matching the latest generation may repaint the viewport.
let mutable loadGeneration = 0
let mutable pendingLoad: (int * LoadOwner) option = None
let mutable loadedStep: LoadOwner option = None
let mutable viewMode = FrameMode
-let mutable failedLoad: LoadOwner option = None
+/// Last failed step load plus whether it was a historical (replay) load, so
+/// the retry affordance re-requests the same owner in the same scope.
+let mutable failedLoad: (LoadOwner * bool) option = None
let mutable displayedNode: ThreeNode option = None
let mutable freezeRotation = false
@@ -252,9 +260,28 @@ let setNoteRetryable (retryable: bool) =
else
note.classList.remove("is-retryable")
+let updateFollowButton () =
+ if Array.length historicalSteps > 0 && pinnedIndex.IsSome then
+ (element "follow-button").removeAttribute("disabled")
+ setText "follow-button" "跟随最新"
+ else
+ (element "follow-button").setAttribute("disabled", "disabled")
+
+ if Array.length historicalSteps > 0 then
+ setText "follow-button" "跟随最新 · 已开启"
+ else
+ setText "follow-button" "跟随最新"
+
+let stopReplay () =
+ match replayTimer with
+ | Some handle ->
+ clearInterval handle
+ replayTimer <- None
+ | None -> ()
+
let showFrameMetadata (frame: ReplayFrame) =
- setText "mesh-label" frame.Label
- setText "timeline-current" (sprintf "V%02d · %s" frame.Version frame.Label)
+ setText "mesh-label" (sprintf "占位 · %s" frame.Label)
+ setText "timeline-current" (sprintf "V%02d · 占位 · %s" frame.Version frame.Label)
let setManifestLink (payload: SnapshotPayload) =
let link = element "artifact-manifest-link"
@@ -267,7 +294,7 @@ let setManifestLink (payload: SnapshotPayload) =
link.setAttribute("href", value)
link.classList.remove("is-hidden")
-let startStepLoad (view: ViewState) (owner: LoadOwner) =
+let startStepLoad (view: ViewState) (owner: LoadOwner) (historical: bool) =
let generation = loadGeneration + 1
loadGeneration <- generation
pendingLoad <- Some(generation, owner)
@@ -278,7 +305,11 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) =
let projectId = JS.encodeURIComponent owner.ProjectId
let runId = JS.encodeURIComponent owner.RunId
let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent owner.Path)
- setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path)
+
+ if historical then
+ setText "viewport-note" (sprintf "历史几何加载中 · %s" owner.Path)
+ else
+ setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path)
loadGLTF loader url
(fun gltf ->
@@ -290,18 +321,26 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) =
loadedStep <- Some owner
frameObject view gltf.scene
view.Renderer.render(view.Scene, view.Camera)
- setText "viewport-note" (sprintf "管线几何 · %s" owner.Path)
+
+ if historical then
+ setText "viewport-note" (sprintf "历史几何 · %s" owner.Path)
+ else
+ setText "viewport-note" (sprintf "管线几何 · %s" owner.Path)
else
disposeNodeResources gltf.scene)
(fun _ ->
if pendingLoad = Some(generation, owner) then
pendingLoad <- None
loadedStep <- None
- failedLoad <- Some owner
+ failedLoad <- Some(owner, historical)
setNoteRetryable true
- setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path))
-let loadStepArtifact (view: ViewState) (stepPath: string) =
+ if historical then
+ setText "viewport-note" (sprintf "历史几何加载失败 · %s" owner.Path)
+ else
+ setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path))
+
+let loadStepArtifact (view: ViewState) (stepPath: string) (historical: bool) =
let owner =
{ ProjectId = currentProjectId
RunId = currentRunId
@@ -309,12 +348,112 @@ let loadStepArtifact (view: ViewState) (stepPath: string) =
match pendingLoad with
| Some (_, pending) when pending = owner -> ()
- | _ -> startStepLoad view owner
+ | _ -> startStepLoad view owner historical
let retryFailedLoad () =
match viewState, failedLoad with
- | Some view, Some owner when owner.ProjectId = currentProjectId && owner.RunId = currentRunId ->
- loadStepArtifact view owner.Path
+ | Some view, Some (owner, historical) when owner.ProjectId = currentProjectId && owner.RunId = currentRunId ->
+ loadStepArtifact view owner.Path historical
+ | _ -> ()
+
+let applyHistoricalStep (index: int) (duringPlayback: bool) =
+ match viewState with
+ | Some view when index >= 0 && index < Array.length historicalSteps ->
+ pinnedIndex <- Some index
+ let path = historicalSteps[index]
+ let version = index + 1
+ setText "mesh-label" (sprintf "历史 · %s" path)
+ setText "timeline-current" (sprintf "V%02d · 历史回放 · %s" version path)
+ loadStepArtifact view path true
+
+ if duringPlayback then
+ setText "replay-state" (sprintf "历史回放中 · V%02d (%d/%d)" version version (Array.length historicalSteps))
+ else
+ setText "replay-state" (sprintf "历史回放 · 已固定 V%02d" version)
+ | _ -> ()
+
+let rec renderTimeline () =
+ let track = element "timeline-track"
+ clearChildren track
+
+ let line = document.createElement("span")
+ line.className <- "timeline-line"
+ line.setAttribute("aria-hidden", "true")
+ track.appendChild line |> ignore
+
+ if Array.length historicalSteps > 0 then
+ track.setAttribute("style", sprintf "grid-template-columns: repeat(%d, 1fr)" historicalSteps.Length)
+
+ historicalSteps
+ |> Array.iteri (fun i path ->
+ let active =
+ match pinnedIndex with
+ | Some pinned -> pinned = i
+ | None -> i = historicalSteps.Length - 1
+
+ track.appendChild (createStepNode (i + 1) path active true (Some i)) |> ignore)
+ else
+ track.setAttribute("style", "grid-template-columns: repeat(3, 1fr)")
+
+ frames
+ |> Array.iter (fun frame ->
+ track.appendChild
+ (createStepNode frame.Version (sprintf "占位 · %s" frame.Label) false (frame.Version < frames.Length) None)
+ |> ignore)
+
+and selectStep (index: int) =
+ if index >= 0 && index < Array.length historicalSteps then
+ stopReplay ()
+ applyHistoricalStep index false
+ updateFollowButton ()
+ renderTimeline ()
+
+and createStepNode (version: int) (label: string) (active: bool) (complete: bool) (onClickIndex: int option) =
+ let classes = ResizeArray [ "timeline-step" ]
+
+ if complete then classes.Add("timeline-step-complete")
+
+ if onClickIndex.IsSome then classes.Add("timeline-step-real")
+
+ if active then classes.Add("timeline-step-active")
+
+ let step = document.createElement("div")
+ step.className <- String.concat " " classes
+
+ let dot = document.createElement("span")
+ dot.className <- "timeline-dot"
+ dot.setAttribute("aria-hidden", "true")
+ step.appendChild dot |> ignore
+
+ let versionLabel = document.createElement("span")
+ versionLabel.className <- "timeline-version"
+ versionLabel.textContent <- sprintf "V%02d" version
+ step.appendChild versionLabel |> ignore
+
+ let text = document.createElement("span")
+ text.className <- "timeline-label"
+ text.textContent <- label
+ step.appendChild text |> ignore
+
+ match onClickIndex with
+ | Some index -> step.addEventListener("click", fun _ -> selectStep index)
+ | None -> ()
+
+ step
+
+let followLatestStep () =
+ match viewState with
+ | Some view when Array.length historicalSteps > 0 ->
+ stopReplay ()
+ pinnedIndex <- None
+ let total = Array.length historicalSteps
+ let latest = historicalSteps[total - 1]
+ loadStepArtifact view latest false
+ setText "mesh-label" latest
+ setText "timeline-current" (sprintf "V%02d · 实时跟随 · %s" total latest)
+ setText "replay-state" (sprintf "实时跟随 · 最新 V%02d" total)
+ updateFollowButton ()
+ renderTimeline ()
| _ -> ()
let updateState (payload: SnapshotPayload) =
@@ -335,6 +474,12 @@ let updateState (payload: SnapshotPayload) =
if currentProjectId <> payload.projectId || currentRunId <> payload.runId then
invalidateLoads ()
displayedNode <- None
+ stopReplay ()
+ pinnedIndex <- None
+ historicalSteps <- [||]
+ setText "replay-button" "占位演示"
+ setText "replay-state" "占位示例 · 未选择历史"
+ updateFollowButton ()
currentProjectId <- payload.projectId
currentRunId <- payload.runId
@@ -362,11 +507,35 @@ let updateState (payload: SnapshotPayload) =
if isNull (box payload.completedSteps) then [||] else payload.completedSteps
match viewState, completedSteps with
+ | Some view, [||] ->
+ if historicalSteps.Length > 0 then
+ stopReplay ()
+ historicalSteps <- [||]
+ pinnedIndex <- None
+ setText "replay-button" "占位演示"
+ renderTimeline ()
+ updateFollowButton ()
+
+ showFrameMetadata frame
+ renderFrame frame
| Some view, steps ->
- if steps.Length = 0 then
- showFrameMetadata frame
- renderFrame frame
- else
+ if steps <> historicalSteps then
+ if historicalSteps.Length = 0 then
+ stopReplay ()
+ setText "replay-button" "播放历史"
+
+ historicalSteps <- steps
+
+ match pinnedIndex with
+ | Some pinned when pinned >= steps.Length -> pinnedIndex <- None
+ | _ -> ()
+
+ renderTimeline ()
+ updateFollowButton ()
+
+ match pinnedIndex with
+ | Some _ -> ()
+ | None ->
let latest = steps[steps.Length - 1]
let owner =
{ ProjectId = payload.projectId
@@ -374,9 +543,11 @@ let updateState (payload: SnapshotPayload) =
Path = latest }
if loadedStep <> Some owner then
- loadStepArtifact view latest
+ loadStepArtifact view latest false
setText "mesh-label" latest
+ setText "timeline-current" (sprintf "V%02d · 实时跟随 · %s" steps.Length latest)
+ setText "replay-state" (sprintf "实时跟随 · 最新 V%02d" steps.Length)
| None, steps ->
if steps.Length > 0 then
setText "mesh-label" steps[steps.Length - 1]
@@ -386,36 +557,67 @@ let updateState (payload: SnapshotPayload) =
let updateStreamState message =
setText "stream-state" message
+let startPlaceholderPlayback () =
+ replayIndex <- 0
+ setText "replay-button" "暂停占位演示"
+ setText "replay-state" "占位演示中 · 未选择历史"
+
+ let tick () =
+ if replayIndex >= frames.Length then
+ stopReplay ()
+ setText "replay-button" "占位演示"
+ setText "replay-state" "占位演示完成 · 未选择历史"
+ else
+ let frame = frames[replayIndex]
+ showFrameMetadata frame
+ renderFrame frame
+ setText "replay-state" (sprintf "占位演示中 · V%02d %s" frame.Version frame.Label)
+ replayIndex <- replayIndex + 1
+
+ tick ()
+ replayTimer <- Some(setInterval tick 900)
+
+let startHistoricalPlayback () =
+ if Array.length historicalSteps > 0 then
+ setText "replay-button" "暂停历史回放"
+ applyHistoricalStep 0 true
+ updateFollowButton ()
+ renderTimeline ()
+ let total = Array.length historicalSteps
+
+ let tick () =
+ match pinnedIndex with
+ | Some pinned when pinned + 1 < total ->
+ applyHistoricalStep (pinned + 1) true
+ renderTimeline ()
+ | Some pinned ->
+ stopReplay ()
+ setText "replay-button" "播放历史"
+ setText "replay-state" (sprintf "历史回放完成 · 停在 V%02d" (pinned + 1))
+ | None ->
+ stopReplay ()
+ setText "replay-button" "播放历史"
+
+ replayTimer <- Some(setInterval tick 1500)
+
let toggleReplay () =
match replayTimer with
| Some handle ->
clearInterval handle
replayTimer <- None
- setText "replay-button" "重建演示"
- setText "replay-state" "演示已暂停"
- | None ->
- replayIndex <- 0
- setText "replay-button" "暂停重建"
- setText "replay-state" "重建中 · 几何逐帧替换"
-
- let tick () =
- if replayIndex >= frames.Length then
- match replayTimer with
- | Some handle -> clearInterval handle
- | None -> ()
-
- replayTimer <- None
- setText "replay-button" "重建演示"
- setText "replay-state" "重建完成 · 几何已更新"
- else
- let frame = frames[replayIndex]
- showFrameMetadata frame
- renderFrame frame
- setText "replay-state" (sprintf "重建中 · V%02d %s" frame.Version frame.Label)
- replayIndex <- replayIndex + 1
- tick ()
- replayTimer <- Some(setInterval tick 900)
+ if Array.length historicalSteps > 0 then
+ let current = Option.defaultValue 0 pinnedIndex
+ setText "replay-button" "播放历史"
+ setText "replay-state" (sprintf "历史回放 · 已暂停 · V%02d" (current + 1))
+ else
+ setText "replay-button" "占位演示"
+ setText "replay-state" "占位演示 · 已暂停"
+ | None ->
+ if Array.length historicalSteps > 0 then
+ startHistoricalPlayback ()
+ else
+ startPlaceholderPlayback ()
let disconnectFromServer () =
match eventSource with
@@ -494,9 +696,12 @@ let boot () =
fallbackView ()
(element "replay-button").addEventListener("click", fun _ -> toggleReplay ())
+ (element "follow-button").addEventListener("click", fun _ -> followLatestStep ())
(element "viewport-note").addEventListener("click", fun _ -> retryFailedLoad ())
(element "run-controls").addEventListener("submit", fun event ->
event.preventDefault()
startRun (selectedRun ()))
+ renderTimeline ()
+
boot ()
diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs
index 2822e33..e7c567d 100644
--- a/src/SomhairlesDream.Frontend/Bindings.fs
+++ b/src/SomhairlesDream.Frontend/Bindings.fs
@@ -103,6 +103,9 @@ let createVector3 () : ThreeVector = jsNative
[<Emit("window.SOMHAIRLES_VIEWER = { camera: $0, scene: $1, group: $2, canvas: $3 }")>]
let exposeViewerDebug (camera: ThreeCamera) (scene: ThreeNode) (group: ThreeNode) (canvas: obj) : unit = jsNative
+[<Emit("var child; while ((child = $0.firstChild)) { $0.removeChild(child); }")>]
+let clearChildren (node: obj) : unit = jsNative
+
[<Emit("new THREE.WebGLRenderer($0)")>]
let createRenderer (options: obj) : ThreeRenderer = jsNative