Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/components/overlay/FloatingPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@
import { Icon } from '@viamrobotics/prime-core'
import * as floatingPanel from '@zag-js/floating-panel'
import { normalizeProps, useMachine } from '@zag-js/svelte'
import { untrack } from 'svelte'

interface Props {
title?: string
defaultSize?: { width: number; height: number }
/**
* Resizes an already-open panel, for content that changes shape at runtime. `defaultSize` is
* zag's uncontrolled initial value and is ignored after mount, and `api.setSize` shares the
* resize-drag's action so it no-ops outside a gesture — driving the machine's controlled
* `size` is what actually moves the panel. Pass a stable object reference: the size is
* re-applied whenever it changes, discarding a manual resize.
*/
size?: { width: number; height: number }
minSize?: { width: number; height: number }
defaultPosition?: { x: number; y: number }
exitable?: boolean
Expand All @@ -29,6 +38,7 @@
let {
title = '',
defaultSize = { width: 700, height: 500 },
size,
defaultPosition,
exitable = true,
resizable = false,
Expand All @@ -43,9 +53,21 @@
const { dom } = useThrelte()

const id = $props.id()

// Stays undefined unless the caller opted in, which leaves the machine uncontrolled and every
// other panel behaving exactly as before.
let currentSize = $state(untrack(() => size))

// Not derived state: `size` is a push from the caller and a resize drag is a push from the
// machine, so whichever moved last wins — an assignment, not a computation.
$effect(() => {
if (size) currentSize = size
})

const floatingPanelService = useMachine(floatingPanel.machine, () => ({
id,
defaultSize,
size: currentSize,
defaultPosition: defaultPosition ?? {
x: dom.clientWidth / 2 - defaultSize.width / 2 + dom.clientLeft,
y: dom.clientHeight / 2 - defaultSize.width / 2 + dom.clientTop,
Expand All @@ -56,6 +78,11 @@
persistRect,
open: isOpen,
...props,
onSizeChange: (details: floatingPanel.SizeChangeDetails) => {
// Controlled panels have to adopt the drag's own result, or a resize snaps straight back.
if (currentSize) currentSize = details.size
props.onSizeChange?.(details)
},
}))

const api = $derived(floatingPanel.connect(floatingPanelService, normalizeProps))
Expand Down
2 changes: 2 additions & 0 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { useEnvironment } from '$lib/hooks/useEnvironment.svelte'

import { provideIKInspection } from './inspect-ik/useIKInspection.svelte'
import MotionPlanReplayerUI from './MotionPlanReplayerUI.svelte'
import { type ResolvePlanSnapshots } from './plan-dropper'
import { type PlanEntry, provideMotionPlanReplayer } from './useMotionPlanReplayer.svelte'
Expand All @@ -20,6 +21,7 @@
const { plans, children, resolvePlanSnapshots }: Props = $props()

provideMotionPlanReplayer(untrack(() => plans))
provideIKInspection()

const environment = useEnvironment()
</script>
Expand Down
160 changes: 6 additions & 154 deletions src/lib/plugins/MotionPlanReplayer/MotionPlanReplayerScrubber.svelte
Original file line number Diff line number Diff line change
@@ -1,160 +1,12 @@
<script lang="ts">
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Pause,
Play,
} from 'lucide-svelte'

import Scrubber from './Scrubber.svelte'
import { useMotionPlanReplayer } from './useMotionPlanReplayer.svelte'

const ctx = useMotionPlanReplayer()

const STEP_INTERVAL_MS = 100

let isPlaying = $state(false)

const lastStepIdx = $derived(Math.max(0, ctx.totalSteps - 1))
const atEnd = $derived(ctx.currentStep >= lastStepIdx)

const pause = () => {
isPlaying = false
}

const play = () => {
if (ctx.totalSteps <= 0) return
isPlaying = true
}

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 (!isPlaying) return

const intervalId = setInterval(() => {
if (ctx.currentStep >= lastStepIdx) {
pause()
return
}
ctx.setStep(ctx.currentStep + 1)
}, STEP_INTERVAL_MS)

return () => clearInterval(intervalId)
})

$effect(() => {
if (ctx.totalSteps <= 0 && isPlaying) pause()
})
</script>

{#if ctx.totalSteps > 0}
<div class="border-light flex flex-col gap-2 border-t pt-2">
<input
class="scrubber w-full"
type="range"
min="0"
max={lastStepIdx}
value={ctx.currentStep}
oninput={(e) => seek(Number((e.currentTarget as HTMLInputElement).value))}
/>

<div class="flex items-center gap-1">
<button
type="button"
class="border-light text-subtle-1 hover:bg-light flex h-6 w-6 shrink-0 items-center justify-center rounded border disabled:opacity-40"
onclick={() => seek(0)}
disabled={ctx.currentStep <= 0}
aria-label="Jump to start"
title="Jump to start"><ChevronsLeft size={12} /></button
>

<button
type="button"
class="border-light text-subtle-1 hover:bg-light flex h-6 w-6 shrink-0 items-center justify-center rounded border disabled:opacity-40"
onclick={() => stepOnce('prev')}
disabled={ctx.currentStep <= 0}
aria-label="Previous step"
title="Previous step"><ChevronLeft size={12} /></button
>

<button
type="button"
class="border-light text-subtle-1 hover:bg-light flex h-6 w-6 shrink-0 items-center justify-center rounded border"
onclick={togglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>{#if isPlaying}<Pause size={12} />{:else}<Play size={12} />{/if}</button
>

<button
type="button"
class="border-light text-subtle-1 hover:bg-light flex h-6 w-6 shrink-0 items-center justify-center rounded border disabled:opacity-40"
onclick={() => stepOnce('next')}
disabled={atEnd}
aria-label="Next step"
title="Next step"><ChevronRight size={12} /></button
>

<button
type="button"
class="border-light text-subtle-1 hover:bg-light flex h-6 w-6 shrink-0 items-center justify-center rounded border disabled:opacity-40"
onclick={() => seek(lastStepIdx)}
disabled={atEnd}
aria-label="Jump to end"
title="Jump to end"><ChevronsRight size={12} /></button
>

<span class="text-subtle-1 font-roboto-mono ml-auto whitespace-nowrap tabular-nums">
{ctx.currentStep + 1} / {ctx.totalSteps}
</span>
</div>
</div>
{/if}

<style>
.scrubber {
appearance: none;
-webkit-appearance: none;
height: 4px;
border-radius: 2px;
background: var(--color-gray-4, #d7d7d9);
cursor: pointer;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-gray-8, #333438);
border: none;
cursor: pointer;
}
.scrubber::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-gray-8, #333438);
border: none;
cursor: pointer;
}
</style>
<Scrubber
currentStep={ctx.currentStep}
totalSteps={ctx.totalSteps}
onseek={(index) => ctx.setStep(index)}
/>
Loading
Loading