-
Notifications
You must be signed in to change notification settings - Fork 1
SCOPE: Motion plan replay plugin #782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 16 commits
5920b5c
3a81882
ea40eae
d8782fa
caa9ccd
4334197
cb7336f
b7e96a8
98e83ee
2c26251
b941f27
6ad0e89
db5ab73
6ee653c
1304ee1
9445896
7930ad6
90625d7
36ffdf1
21d810f
111fafe
d3be9d2
f3f70cb
58cd153
e87fe24
c08a58c
abe76c0
8fdb6b9
5631b12
cc87cee
fa999e5
31f4afe
e13263d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| <script lang="ts"> | ||
| import { untrack } from 'svelte' | ||
|
|
||
| import MotionPlanReplayerScrubber from './MotionPlanReplayerScrubber.svelte' | ||
| import MotionPlanReplayerUI from './MotionPlanReplayerUI.svelte' | ||
| import { type PlanEntry, provideMotionPlanReplayer } from './useMotionPlanReplayer.svelte' | ||
|
|
||
| interface Props { | ||
| /** Pass plans to use app-embedded mode (props-driven, no drag-and-drop UI). Omit for standalone mode. */ | ||
| plans?: PlanEntry[] | ||
| } | ||
|
|
||
| const { plans }: Props = $props() | ||
|
|
||
| // `plans` is intentionally read once at setup time — initial entries only. | ||
| // The context manages its own reactive plan list after that. | ||
| provideMotionPlanReplayer(untrack(() => plans)) | ||
| </script> | ||
|
|
||
| {#if plans === undefined} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it would be cool if drag and drop just updated the plans array and then we could use the ui for both cases
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Designed a more explicit hook here (f3f70cb) which calls the same addPlan hook from app vs. standalone. |
||
| <MotionPlanReplayerUI /> | ||
| {/if} | ||
| <MotionPlanReplayerScrubber /> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| <script lang="ts"> | ||
| import { Portal } from '@threlte/extras' | ||
|
|
||
| import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte' | ||
|
|
||
| const STEP_INTERVAL_MS = 100 | ||
|
|
||
| const ctx = useMotionPlanReplayer() | ||
|
|
||
| let isPlaying = $state(false) | ||
| let intervalId: ReturnType<typeof setInterval> | undefined | ||
|
|
||
| const lastStepIdx = $derived(Math.max(0, ctx.totalSteps - 1)) | ||
| const atEnd = $derived(ctx.currentStep >= lastStepIdx) | ||
|
|
||
| const pause = () => { | ||
| isPlaying = false | ||
| if (intervalId !== undefined) { | ||
| clearInterval(intervalId) | ||
| intervalId = undefined | ||
| } | ||
| } | ||
|
|
||
| const play = () => { | ||
| if (isPlaying || ctx.totalSteps <= 0) return | ||
| isPlaying = true | ||
| intervalId = setInterval(() => { | ||
|
sucrammal marked this conversation as resolved.
Outdated
|
||
| if (ctx.currentStep >= lastStepIdx) { | ||
| pause() | ||
| return | ||
| } | ||
| ctx.setStep(ctx.currentStep + 1) | ||
| }, STEP_INTERVAL_MS) | ||
| } | ||
|
|
||
| const togglePlay = () => { | ||
| if (isPlaying) { | ||
| pause() | ||
| return | ||
| } | ||
| if (atEnd && ctx.totalSteps > 0) ctx.setStep(0) | ||
| play() | ||
| } | ||
|
|
||
| const seek = (index: number) => { | ||
| pause() | ||
| ctx.setStep(index) | ||
| } | ||
|
|
||
| const stepOnce = (direction: 'prev' | 'next') => { | ||
| pause() | ||
| ctx.setStep(direction === 'next' ? ctx.currentStep + 1 : ctx.currentStep - 1) | ||
| } | ||
|
|
||
| $effect(() => { | ||
| if (ctx.totalSteps <= 0 && isPlaying) pause() | ||
| }) | ||
| $effect(() => () => pause()) | ||
| $effect(() => { | ||
| document.body.classList.toggle('has-scrubber', ctx.totalSteps > 0) | ||
| return () => document.body.classList.remove('has-scrubber') | ||
| }) | ||
| </script> | ||
|
|
||
| <Portal id="dom"> | ||
| {#if ctx.totalSteps > 0} | ||
| <div | ||
| class="pointer-events-auto fixed bottom-4 left-1/2 z-[10000] flex w-[min(640px,calc(100vw-2rem))] -translate-x-1/2 items-center gap-3 rounded bg-zinc-900/85 px-3 py-2 text-xs text-white" | ||
| > | ||
| <!-- Exit replay --> | ||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none" | ||
| onclick={ctx.clearActivePlan} | ||
| aria-label="Exit replay" | ||
| title="Exit replay">×</button | ||
| > | ||
|
|
||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40" | ||
| onclick={togglePlay} | ||
| disabled={ctx.totalSteps <= 0} | ||
| aria-label={isPlaying ? 'Pause' : 'Play'} | ||
| title={isPlaying ? 'Pause' : 'Play'}>{isPlaying ? '⏸' : '▶'}</button | ||
| > | ||
|
|
||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40" | ||
| onclick={() => seek(0)} | ||
| disabled={ctx.currentStep <= 0} | ||
| aria-label="Jump to start" | ||
| title="Jump to start">«</button | ||
| > | ||
|
|
||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40" | ||
| onclick={() => stepOnce('prev')} | ||
| disabled={ctx.currentStep <= 0} | ||
| aria-label="Previous step" | ||
| title="Previous step">‹</button | ||
| > | ||
|
|
||
| <input | ||
| class="scrubber grow" | ||
| type="range" | ||
| min="0" | ||
| max={lastStepIdx} | ||
| value={ctx.currentStep} | ||
| oninput={(e) => seek(Number((e.currentTarget as HTMLInputElement).value))} | ||
| /> | ||
|
|
||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40" | ||
| onclick={() => stepOnce('next')} | ||
| disabled={atEnd} | ||
| aria-label="Next step" | ||
| title="Next step">›</button | ||
| > | ||
|
|
||
| <button | ||
| type="button" | ||
| class="flex h-7 w-7 shrink-0 items-center justify-center rounded border border-zinc-600 text-sm leading-none disabled:opacity-40" | ||
| onclick={() => seek(lastStepIdx)} | ||
| disabled={atEnd} | ||
| aria-label="Jump to end" | ||
| title="Jump to end">»</button | ||
| > | ||
|
|
||
| <span class="whitespace-nowrap tabular-nums">{ctx.currentStep + 1} / {ctx.totalSteps}</span> | ||
| </div> | ||
| {/if} | ||
| </Portal> | ||
|
|
||
| <style> | ||
| .scrubber { | ||
| appearance: none; | ||
| -webkit-appearance: none; | ||
| height: 4px; | ||
| border-radius: 2px; | ||
| background: #52525b; | ||
| cursor: pointer; | ||
| accent-color: #ffffff; | ||
| } | ||
| .scrubber::-webkit-slider-thumb { | ||
| -webkit-appearance: none; | ||
| appearance: none; | ||
| width: 14px; | ||
| height: 14px; | ||
| border-radius: 50%; | ||
| background: #ffffff; | ||
| border: none; | ||
| cursor: pointer; | ||
| } | ||
| .scrubber::-moz-range-thumb { | ||
| width: 14px; | ||
| height: 14px; | ||
| border-radius: 50%; | ||
| background: #ffffff; | ||
| border: none; | ||
| cursor: pointer; | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| <script lang="ts"> | ||
| import { Portal } from '@threlte/extras' | ||
| import { ToastVariant, useToast } from '@viamrobotics/prime-core' | ||
|
|
||
| import type { PlanFileDropSuccess } from '$lib/components/FileDrop/file-dropper' | ||
|
|
||
| import DashboardButton from '$lib/components/overlay/dashboard/Button.svelte' | ||
| import FloatingPanel from '$lib/components/overlay/FloatingPanel.svelte' | ||
|
|
||
| import { truncate } from './plan-dropper' | ||
| import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte' | ||
|
|
||
| const ctx = useMotionPlanReplayer() | ||
| const toast = useToast() | ||
|
|
||
| let isOpen = $state(false) | ||
|
|
||
| $effect(() => { | ||
| const handle = (e: Event) => { | ||
| const { name, content, snapshots } = (e as CustomEvent<PlanFileDropSuccess>).detail | ||
| if (ctx.plans.some((p) => p.name === name)) { | ||
| toast({ message: `"${truncate(name, 24)}" already loaded.`, variant: ToastVariant.Warning }) | ||
| return | ||
| } | ||
| ctx.addPlan(name, content, snapshots) | ||
| isOpen = true | ||
| } | ||
| window.addEventListener('viam:plan-loaded', handle) | ||
|
sucrammal marked this conversation as resolved.
Outdated
|
||
| return () => window.removeEventListener('viam:plan-loaded', handle) | ||
| }) | ||
| </script> | ||
|
|
||
| <Portal id="dashboard"> | ||
| <fieldset> | ||
| <DashboardButton | ||
| active={isOpen} | ||
| icon="play-circle-outline" | ||
| description="Motion Plan Replayer" | ||
| onclick={() => (isOpen = !isOpen)} | ||
| /> | ||
| </fieldset> | ||
| </Portal> | ||
|
|
||
| <Portal id="dom"> | ||
| <FloatingPanel | ||
| bind:isOpen | ||
| title="Motion Plan Replayer" | ||
| defaultSize={{ width: 320, height: 260 }} | ||
| > | ||
| <div class="flex h-full flex-col gap-1 p-2 text-xs"> | ||
| {#if ctx.plans.length === 0} | ||
| <div class="flex grow items-center justify-center text-center text-gray-400"> | ||
| Drop a plan JSON file onto the canvas | ||
| </div> | ||
| {/if} | ||
|
|
||
| {#each ctx.plans as plan, i (plan.name)} | ||
| {@const isActive = ctx.activePlanIndex === i} | ||
| <div | ||
| class={[ | ||
| 'flex cursor-pointer items-center gap-1 rounded px-2 py-1', | ||
| isActive ? 'bg-blue-100 font-medium' : 'hover:bg-gray-100', | ||
| ]} | ||
| role="button" | ||
| tabindex="0" | ||
| onclick={() => (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))} | ||
| onkeydown={(e) => | ||
| e.key === 'Enter' && (isActive ? ctx.clearActivePlan() : ctx.selectPlan(i))} | ||
| > | ||
| <span class="mr-1 text-gray-400">{isActive ? '●' : '○'}</span> | ||
| <span class="grow truncate">{plan.name}</span> | ||
|
|
||
| <button | ||
| type="button" | ||
| class="ml-1 rounded px-1 text-gray-400 hover:text-red-500" | ||
| onclick={(e) => { | ||
| e.stopPropagation() | ||
| ctx.removePlan(i) | ||
| }} | ||
| aria-label="Remove plan" | ||
| title="Remove plan">×</button | ||
| > | ||
| </div> | ||
|
|
||
| {#if plan.status === 'error'} | ||
| <div class="pl-5 text-[10px] text-red-600">{plan.error}</div> | ||
| {/if} | ||
| {#if plan.status === 'no-trajectory'} | ||
| <div class="pl-5 text-[10px] text-yellow-600">No trajectory — nothing to replay</div> | ||
| {/if} | ||
| {/each} | ||
| </div> | ||
| </FloatingPanel> | ||
| </Portal> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
todo: for final impl we probably should look into how to do this with component placements instead of the app css