diff options
Diffstat (limited to 'src/SomhairlesDream.Frontend')
| -rw-r--r-- | src/SomhairlesDream.Frontend/App.fs | 287 | ||||
| -rw-r--r-- | src/SomhairlesDream.Frontend/Bindings.fs | 3 |
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 |
