LeRobot-Arena / src /lib /components /interface /overlay /ManualControlSheet.svelte
blanchon's picture
Mostly UI Update
18b0fa5
<script lang="ts">
import * as Sheet from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import * as Alert from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { toast } from "svelte-sonner";
import type { Robot } from "$lib/robot/Robot.svelte";
import { Separator } from "@/components/ui/separator";
interface Props {
open: boolean;
robot: Robot | null;
}
let { open = $bindable(), robot }: Props = $props();
async function calibrateRobot() {
if (!robot) return;
try {
await robot.calibrateRobot();
} catch (err) {
toast.error("Calibration Failed", {
description: `Failed to calibrate: ${err}`
});
console.error(err);
}
}
async function moveToRest() {
if (!robot) return;
try {
await robot.moveToRestPosition();
} catch (err) {
toast.error("Movement Failed", {
description: `Failed to move to rest: ${err}`
});
console.error(err);
}
}
function clearCalibration() {
if (!robot) return;
robot.clearCalibration();
}
</script>
<Sheet.Root bind:open>
<Sheet.Content
trapFocus={false}
side="right"
class="w-80 gap-0 border-l border-slate-600 bg-gradient-to-b from-slate-700 to-slate-800 p-0 text-white sm:w-96"
>
<!-- Header -->
<Sheet.Header class="border-b border-slate-600 bg-slate-700/80 p-6 backdrop-blur-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="icon-[mdi--tune] size-6 text-purple-400"></span>
<div>
<Sheet.Title class="text-xl font-semibold text-slate-100">Manual Control</Sheet.Title>
<p class="mt-1 text-sm text-slate-400">Direct robot joint manipulation</p>
</div>
</div>
</div>
</Sheet.Header>
{#if robot}
<!-- Content -->
<div
class="scrollbar-thin scrollbar-track-slate-700 scrollbar-thumb-slate-500 flex-1 overflow-y-auto px-4"
>
<div class="space-y-6 py-4">
<!-- Calibration Section (for USB slaves) -->
{#if robot.connectedSlaves.some((slave) => slave.name.includes("USB"))}
<div class="space-y-4">
<div class="mb-3 flex items-center gap-3">
<span class="icon-[mdi--crosshairs-gps] size-5 text-purple-400"></span>
<h3 class="text-lg font-medium text-slate-100">USB Robot Calibration</h3>
{#if robot.isCalibrated}
<Badge variant="default" class="ml-auto bg-green-600 text-xs">
<span class="icon-[mdi--check] mr-1 size-3"></span>
OK
</Badge>
{:else}
<Badge variant="destructive" class="ml-auto text-xs">
<span class="icon-[mdi--alert] mr-1 size-3"></span>
Required
</Badge>
{/if}
</div>
{#if !robot.isCalibrated}
<div class="space-y-4">
<div class="space-y-1 text-xs text-purple-300/70">
<p>1. Position robot to match rest pose</p>
<p>2. Click Calibrate when ready</p>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onclick={moveToRest}
class="h-8 flex-1 border-slate-600 text-xs text-slate-200 hover:bg-slate-700"
>
<span class="icon-[mdi--human-handsup] mr-1 size-3"></span>
Rest Pose
</Button>
<Button
variant="default"
size="sm"
onclick={calibrateRobot}
class="h-8 flex-1 bg-green-600 text-xs hover:bg-green-700"
>
<span class="icon-[mdi--crosshairs-gps] mr-1 size-3"></span>
Calibrate
</Button>
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-green-300">
<span class="icon-[mdi--check-circle] size-3"></span>
Calibrated at {robot.calibrationState.calibrationTime?.toLocaleTimeString()}
</div>
<Button
variant="outline"
size="sm"
onclick={clearCalibration}
class="h-8 w-full border-slate-600 text-xs text-slate-200 hover:bg-slate-700"
>
<span class="icon-[mdi--refresh] mr-1 size-3"></span>
Clear Calibration
</Button>
</div>
{/if}
</div>
<Separator class="bg-slate-600" />
{/if}
<!-- Manual Joint Controls -->
{#if robot.manualControlEnabled}
<div class="space-y-4">
<div class="mb-3 flex items-center gap-3">
<span class="icon-[lucide--rotate-3d] size-5 text-purple-400"></span>
<h3 class="text-lg font-medium text-slate-100">Joint Controls</h3>
<Badge variant="default" class="ml-auto bg-purple-600 text-xs">
{robot.activeJoints.length}
</Badge>
</div>
<p class="text-xs text-slate-400">
Each joint can be moved independently using sliders. Values show virtual position
(degrees) and real position in parentheses when available.
</p>
{#if robot.activeJoints.length === 0}
<p class="py-4 text-center text-xs text-slate-500 italic">No active joints</p>
{:else}
<div class="space-y-3">
{#each robot.activeJoints as joint (joint.name)}
{@const lower =
joint.urdfJoint.limit?.lower != undefined
? (joint.urdfJoint.limit.lower * 180) / Math.PI
: -180}
{@const upper =
joint.urdfJoint.limit?.upper != undefined
? (joint.urdfJoint.limit.upper * 180) / Math.PI
: 180}
<div class="space-y-2 rounded-lg border border-slate-600 bg-slate-800/50 p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-200">{joint.name}</span>
<div class="flex items-center gap-2 text-xs">
<span class="font-mono text-purple-400"
>{joint.virtualValue.toFixed(0)}°</span
>
{#if joint.realValue !== undefined}
<span class="font-mono text-green-400"
>({joint.realValue.toFixed(0)}°)</span
>
{/if}
</div>
</div>
<div class="space-y-1">
<input
type="range"
min={lower}
max={upper}
step="0.001"
value={joint.virtualValue}
oninput={(e) => {
const val = parseFloat((e.target as HTMLInputElement).value);
robot.updateJointValue(joint.name, val);
}}
class="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-600"
/>
<div class="flex justify-between text-xs text-slate-500">
<span>{lower.toFixed(0)}°</span>
<span>{upper.toFixed(0)}°</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="space-y-4">
<div class="mb-3 flex items-center gap-3">
<span class="icon-[mdi--gamepad-variant] size-5 text-purple-400"></span>
<h3 class="text-lg font-medium text-slate-100">Master Control Active</h3>
</div>
<Alert.Root class="border-purple-500/30 bg-purple-500/10">
<span class="icon-[mdi--gamepad-variant] size-4"></span>
<Alert.Title class="text-sm text-purple-200">Master Control Active</Alert.Title>
<Alert.Description class="text-xs text-purple-300">
Robot controlled by: <strong>{robot.controlState.masterName}</strong><br />
Disconnect master to enable manual control.
</Alert.Description>
</Alert.Root>
</div>
{/if}
</div>
</div>
{/if}
</Sheet.Content>
</Sheet.Root>
<style>
/* Slider styling (classic <input type="range">) */
.slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #a855f7;
cursor: pointer;
border: 2px solid #1e293b;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.15s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #9333ea;
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #a855f7;
cursor: pointer;
border: 2px solid #1e293b;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.15s ease;
}
.slider::-moz-range-track {
height: 6px;
background: #374151;
border-radius: 3px;
border: none;
}
.slider:focus {
box-shadow: 0 0 0 2px rgba(168, 85, 247, 0.5);
}
</style>