diff --git a/.vscode/launch.json b/.vscode/launch.json
index 158e0ca6..1f09e038 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,10 +4,17 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ "name": "Debug ChessBots Client",
+ "request": "launch",
+ "type": "chrome",
+ "url": "http://localhost:3000",
+ "webRoot": "${workspaceFolder}"
+ },
{
"type": "node",
"request": "launch",
- "name": "Debug ChessBot Server",
+ "name": "Debug ChessBots Server",
"runtimeExecutable": "ts-node",
"program": "${workspaceFolder}/src/server/main.ts",
"restart": true,
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9537b95b..8c9b7acb 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,6 +4,7 @@
"task.autoDetect": "off",
"cSpell.words": [
"blueprintjs",
+ "cbor",
"Chessbots",
"Premoves",
"runtypes",
diff --git a/docs/Home.md b/docs/Home.md
index 9ffbb1fc..c0ab582a 100644
--- a/docs/Home.md
+++ b/docs/Home.md
@@ -1,4 +1,4 @@
-Welcome to the ChessBot wiki!
+Welcome to the ChessBots wiki!
If you want to learn more about the project or get involved, this is the place to be!
@@ -10,17 +10,17 @@ Please have a look at the [Server Structure](Server-Structure) if you want a hig
If you want to want to start developing the server side, first check out our [Server Setup Guide](Server-Setup).
You can then read through the [Server Development Guide](Server-Development).
-## ChessBot Software
+## ChessBots Software
-The ChessBot software handles receiving movement commands from the server, and figuring out how to move the motors and read the sensors to comply with the command. The ChessBot uses an ESP32-S2 Mini as the microcontroller and transmitter/receiver. The onboard logic uses C++ for ESP-IDF.
+ChessBots software handles receiving movement commands from the server, and figuring out how to move the motors and read the sensors to comply with the command. The ChessBot uses an ESP32-S2 Mini as the microcontroller and transmitter/receiver. The onboard logic uses C++ for ESP-IDF.
Please have a look at the [ESP Structure](ESP-Structure) if you want a high level overview of its various aspects.
If you want to want to start developing the bot side, first check out our [ESP Setup Guide](ESP-Setup).
You can then read through the [ESP Development Guide](ESP-Development).
-## ChessBot Hardware
+## ChessBots Hardware
-A new PCB is being made for the ChessBots that will allow them to work easily with an ESP32-S2 Mini microcontroller.
+A PCB is being made for the ChessBots that will allow them to work easily with an ESP32-S2 Mini microcontroller.
## Resource Library
diff --git a/readme.md b/readme.md
index 83557066..db00eb40 100644
--- a/readme.md
+++ b/readme.md
@@ -1,4 +1,4 @@
-# Welcome to chessBots!
+# Welcome to ChessBots!
## Setup
diff --git a/src/client/debug/debug.tsx b/src/client/debug/debug.tsx
index 80cdc13d..f58f8105 100644
--- a/src/client/debug/debug.tsx
+++ b/src/client/debug/debug.tsx
@@ -37,6 +37,12 @@ export function Debug() {
fetchIds();
}, [setRobotIds]);
+ const danceConfigLoad = async () => {
+ const response = await get("/do-parallel");
+ if (!response.ok) {
+ console.warn("Failed to load dance config");
+ }
+ };
// create the select and move buttons
let body: ReactNode;
if (robotIds === undefined) {
@@ -50,6 +56,9 @@ export function Debug() {
selectedRobotId={selectedRobotId}
onRobotIdSelected={setSelectedRobotId}
/>
+
+
+
{selectedRobotId === undefined ? null : (
<>
diff --git a/src/client/debug/simulator.tsx b/src/client/debug/simulator.tsx
index e482773b..118e9038 100644
--- a/src/client/debug/simulator.tsx
+++ b/src/client/debug/simulator.tsx
@@ -7,7 +7,8 @@ import {
Tag,
CompoundTag,
} from "@blueprintjs/core";
-import { useEffect, useReducer, useRef, useState } from "react";
+import type { CSSProperties, PropsWithChildren } from "react";
+import { forwardRef, useEffect, useReducer, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { get, useSocket } from "../api";
import type {
@@ -16,7 +17,7 @@ import type {
} from "../../common/message/simulator-message";
import { SimulatorUpdateMessage } from "../../common/message/simulator-message";
import "./simulator.scss";
-import { clampHeading } from "../../common/units";
+import { clampHeading, GRID_CELL_PX } from "../../common/units";
import {
bgColor,
darkModeIcon,
@@ -28,10 +29,12 @@ import {
toggleUserSetting,
} from "../check-dark-mode";
-const tileSize = 60;
-const robotSize = tileSize / 2;
+const tileSize = GRID_CELL_PX;
+export const robotSize = tileSize / 2;
const cellCount = 12;
+export type RobotState = { [robotId: string]: SimulatedRobotLocation };
+
/**
* Creates a robot simulator for testing robot commands
*
@@ -41,8 +44,6 @@ const cellCount = 12;
export function Simulator() {
const navigate = useNavigate();
- type RobotState = { [robotId: string]: SimulatedRobotLocation };
-
type Action =
| { type: "SET_ALL_ROBOTS"; payload: RobotState }
| {
@@ -157,45 +158,7 @@ export function Simulator() {
-
- {new Array(cellCount * cellCount)
- .fill(undefined)
- .map((_, i) => {
- const row = Math.floor(i / cellCount);
- const col = i % cellCount;
- const isCenterCell =
- row >= 2 && row < 10 && col >= 2 && col < 10;
- return (
-
- );
- })}
- {/* TODO: implement onTopOfRobots */}
- {Object.entries(robotState).map(([robotId, pos]) => (
-
- ))}
-
+
{
await fetch(`/__open-in-editor?${params.toString()}`);
};
+// TODO: refactor out of debug since we use it in more than just simulator?
+export function RobotGrid({
+ robotState,
+ children,
+}: PropsWithChildren<{ robotState: RobotState }>) {
+ return (
+
+ {new Array(cellCount * cellCount).fill(undefined).map((_, i) => {
+ const row = Math.floor(i / cellCount);
+ const col = i % cellCount;
+ const isCenterCell =
+ row >= 2 && row < 10 && col >= 2 && col < 10;
+ return (
+
+ );
+ })}
+ {/* TODO: implement onTopOfRobots */}
+ {Object.entries(robotState).map(([robotId, pos]) => (
+
+ ))}
+ {children}
+
+ );
+}
+
/**
* the message log, used to show the commands sent to the robot
* @param props - the message and time
@@ -347,25 +358,31 @@ function LogEntry(props: { message: SimulatorUpdateMessage; ts: Date }) {
* @param props - the robot position and id
* @returns the robot icon scaled to the board
*/
-function Robot(props: {
- pos: SimulatedRobotLocation;
- robotId: string;
- onTopOfRobots: string[];
-}) {
+export const Robot = forwardRef<
+ HTMLDivElement,
+ {
+ pos: SimulatedRobotLocation;
+ robotId: string;
+ onTopOfRobots: string[];
+ style?: CSSProperties;
+ }
+>(function Robot({ pos, robotId, onTopOfRobots, style }, ref) {
return (
-
+
);
-}
+});
diff --git a/src/client/editor/editor.tsx b/src/client/editor/editor.tsx
new file mode 100644
index 00000000..fc9b2052
--- /dev/null
+++ b/src/client/editor/editor.tsx
@@ -0,0 +1,834 @@
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ EditableText,
+ Button,
+ H2,
+ Card,
+ ButtonGroup,
+ Tag,
+ useHotkeys,
+ type HotkeyConfig,
+ SegmentedControl,
+ Divider,
+ NumericInput,
+ Pre,
+ NonIdealState,
+ SectionCard,
+} from "@blueprintjs/core";
+import { RobotGrid, robotSize } from "../debug/simulator";
+import type { NonStartPointEvent, TimelineLayerType } from "../../common/show";
+import { NonStartPointEventSchema } from "../../common/show";
+import {
+ GridCursorMode,
+ millisToPixels,
+ pixelsToMillis,
+ RULER_TICK_GAP_PX,
+ TimelineDurationUpdateMode,
+} from "../../common/show-interface-utils";
+import {
+ Ruler,
+ TimelineLayer,
+ TimelineEvent,
+ ReorderableTimelineEvent,
+} from "./timeline";
+import type { Midpoint } from "../../common/spline";
+import { SplinePointType } from "../../common/spline";
+import type { MotionValue } from "motion/react";
+import {
+ motion,
+ useTransform,
+ useAnimate,
+ useMotionValueEvent,
+ Reorder,
+} from "motion/react";
+import { useShowfile } from "./showfile-state";
+import { getRobotStateAtTime } from "../../common/getRobotStateAtTime";
+import { MotionRobot } from "./motion-robot";
+import { SplineEditor } from "./spline-editor";
+import interact from "interactjs";
+import { Array as RArray } from "runtypes";
+import { post } from "../api";
+
+// Helper component to manage animation for a single robot
+function AnimatedRobotRenderer({
+ layer,
+ timestamp,
+ robotId,
+}: {
+ layer: TimelineLayerType;
+ timestamp: MotionValue;
+ robotId: string;
+}) {
+ const [scope, animate] = useAnimate();
+
+ // Get initial state to avoid undefined on first render
+ const initialState = getRobotStateAtTime(layer, timestamp.get());
+
+ useMotionValueEvent(timestamp, "change", (latestTimestamp) => {
+ const { position, headingRadians } = getRobotStateAtTime(
+ layer,
+ latestTimestamp,
+ );
+ const targetX = position.x - robotSize / 2;
+ const targetY = position.y - robotSize / 2;
+ const targetRotate = headingRadians * (180 / Math.PI);
+
+ // Animate using transform
+ if (scope.current) {
+ animate(
+ scope.current,
+ {
+ transform: `translateX(${targetX}px) translateY(${targetY}px) rotate(${targetRotate}deg)`,
+ },
+ { duration: 0, ease: "linear" },
+ );
+ }
+ });
+
+ // Calculate initial transform string
+ const initialX = initialState.position.x - robotSize / 2;
+ const initialY = initialState.position.y - robotSize / 2;
+ const initialRotate = initialState.headingRadians * (180 / Math.PI);
+ const initialTransform = `translateX(${initialX}px) translateY(${initialY}px) rotate(${initialRotate}deg)`;
+
+ return (
+
+ );
+}
+
+export function Editor() {
+ const {
+ show,
+ unsavedChanges,
+ loadAudioFromFile,
+ handleStartPointMove,
+ handlePointMove,
+ handleControlPointMove,
+ handleControlPoint2Move,
+ handleDeleteStartPoint,
+ handleDeletePoint,
+ handleSwitchPointType,
+ saveShowfile,
+ openShowfile,
+ undo,
+ redo,
+ editName,
+ addRobot,
+ currentTimestamp,
+ playing,
+ togglePlaying,
+ canUndo,
+ canRedo,
+ deleteLayer,
+ sequenceLengthMs,
+ updateTimelineEventOrders,
+ updateTimelineEventDurations,
+ setTimelineDurationUpdateMode,
+ timelineDurationUpdateMode,
+ gridCursorMode,
+ setGridCursorMode,
+ defaultPointType,
+ setDefaultPointType,
+ setDefaultEventDurationMs,
+ defaultEventDurationMs,
+ addPointToSelectedLayer,
+ setSelectedLayerIndex,
+ selectedLayerIndex,
+ removeAudio,
+ deleteTimelineEvent,
+ setTimestamp,
+ addWaitEventAtIndex,
+ addTurnEventAtIndex,
+ getLayerIndexFromEventId,
+ addBulkEventsToSelectedLayer,
+ selectedTimelineEventIndex,
+ setSelectedTimelineEventIndex,
+ } = useShowfile();
+
+ // TODO: fix viewport height / timeline height
+
+ const seekBarWidth = useTransform(() =>
+ millisToPixels(currentTimestamp.get()),
+ );
+
+ const hotkeys = useMemo(
+ () => [
+ {
+ combo: "mod+s",
+ group: "File",
+ global: true,
+ label: "Save",
+ onKeyDown: (e) => {
+ e.preventDefault();
+ saveShowfile();
+ },
+ },
+ {
+ combo: "mod+o",
+ group: "File",
+ global: true,
+ label: "Open...",
+ onKeyDown: (e) => {
+ e.preventDefault();
+ openShowfile();
+ },
+ },
+ {
+ combo: "mod+z",
+ group: "Edit",
+ global: true,
+ label: "Undo",
+ onKeyDown: (e) => {
+ e.preventDefault();
+ undo();
+ },
+ },
+ {
+ combo: "mod+y",
+ group: "Edit",
+ global: true,
+ label: "Redo",
+ onKeyDown: (e) => {
+ e.preventDefault();
+ redo();
+ },
+ },
+ {
+ combo: "space",
+ group: "Play/Pause",
+ global: true,
+ label: "Play/Pause",
+ onKeyDown: (e) => {
+ e.preventDefault();
+ togglePlaying();
+ },
+ },
+ {
+ combo: "mod+c",
+ group: "Editor",
+ global: true,
+ label: "Copy",
+ onKeyDown: async (e) => {
+ const selection = window.getSelection();
+ if (!selection) return;
+
+ const startEl =
+ selection?.anchorNode?.parentElement?.parentElement;
+ const endEl =
+ selection?.focusNode?.parentElement?.parentElement;
+ if (!startEl || !endEl) return;
+ console.log(startEl, endEl);
+
+ if (
+ startEl.parentNode?.parentNode?.parentNode !==
+ endEl.parentNode?.parentNode?.parentNode
+ ) {
+ console.warn(
+ "Selection is not within the same layer",
+ startEl.parentNode?.parentNode?.parentNode,
+ endEl.parentNode?.parentNode?.parentNode,
+ );
+ return;
+ }
+
+ const timelineEventIdRegex = /^timeline-event-(.+)$/;
+ const startElId = startEl.id;
+ const endElId = endEl.id;
+ const startEventId =
+ startElId.match(timelineEventIdRegex)?.[1];
+ const endEventId = endElId.match(timelineEventIdRegex)?.[1];
+ console.log(startEventId, endEventId);
+
+ if (!startEventId || !endEventId) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ console.log("WOO");
+
+ const layerIndex = getLayerIndexFromEventId(startEventId);
+ if (layerIndex === -1) {
+ console.warn("Layer not found");
+ return;
+ }
+
+ const layer = show.timeline[layerIndex];
+
+ const startIdx = layer.remainingEvents.findIndex(
+ (event) => event.id === startEventId,
+ );
+ const endIdx = layer.remainingEvents.findIndex(
+ (event) => event.id === endEventId,
+ );
+
+ if (startIdx === -1 || endIdx === -1) return;
+
+ const selectedEvents = layer.remainingEvents.slice(
+ startIdx,
+ endIdx + 1,
+ );
+ console.log(selectedEvents);
+
+ await navigator.clipboard.writeText(
+ JSON.stringify(selectedEvents),
+ );
+ },
+ },
+ {
+ combo: "mod+v",
+ group: "Editor",
+ global: true,
+ label: "Paste",
+ onKeyDown: async (e) => {
+ const pasteResult = await navigator.clipboard.readText();
+ if (!pasteResult) return;
+
+ let pastedEvents: unknown;
+ try {
+ pastedEvents = JSON.parse(pasteResult);
+ } catch (e) {
+ console.warn("Failed to parse pasted events", e);
+ return;
+ }
+
+ if (!Array.isArray(pastedEvents)) {
+ console.warn("Pasted events are not an array");
+ return;
+ }
+
+ const checkResult = RArray(
+ NonStartPointEventSchema,
+ ).validate(pastedEvents);
+ if (!checkResult.success) {
+ console.warn(
+ "Pasted events are not valid",
+ checkResult,
+ );
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ for (const event of checkResult.value) {
+ event.id = crypto.randomUUID();
+ }
+
+ addBulkEventsToSelectedLayer(checkResult.value);
+ },
+ },
+ ],
+ [
+ saveShowfile,
+ openShowfile,
+ undo,
+ redo,
+ togglePlaying,
+ getLayerIndexFromEventId,
+ show.timeline,
+ addBulkEventsToSelectedLayer,
+ ],
+ );
+
+ const { handleKeyDown, handleKeyUp } = useHotkeys(hotkeys);
+
+ const seekBarRef = useRef(null);
+
+ useEffect(() => {
+ if (!seekBarRef.current) return;
+ const el = seekBarRef.current;
+ interact(el).resizable({
+ edges: {
+ right: true,
+ },
+ listeners: {
+ move: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const newTimestamp = pixelsToMillis(event.rect.width);
+ setTimestamp(newTimestamp);
+ },
+ },
+ });
+ }, [setTimestamp]);
+
+ const handleSeekBarClick = useCallback(
+ (e: React.MouseEvent) => {
+ const { x: mouseX } = e.nativeEvent;
+ const seekBar = seekBarRef.current;
+ if (!seekBar) return;
+ const { x: seekBarStartX } = seekBar.getBoundingClientRect();
+ const newTimestamp = pixelsToMillis(mouseX - seekBarStartX);
+ setTimestamp(newTimestamp);
+ },
+ [setTimestamp],
+ );
+ return (
+
+
+
+
+
+
+ {unsavedChanges && (
+
+ Unsaved changes
+
+ )}
+
+
+
+
+
+
+
+
+ {show.audio && (
+
+ )}
+
+
+
+
+
+ {gridCursorMode === GridCursorMode.Pen && (
+ {
+ const { x: mouseX, y: mouseY } =
+ e.nativeEvent;
+ const {
+ left: gridOriginX,
+ top: gridOriginY,
+ } = e.currentTarget.getBoundingClientRect();
+ const x = mouseX - gridOriginX;
+ const y = mouseY - gridOriginY;
+ addPointToSelectedLayer(
+ x + robotSize / 4,
+ y + robotSize / 4,
+ );
+ }}
+ >
+ )}
+ {show.timeline.map((layer, index) => (
+
+
+ handleStartPointMove(index, coords)
+ }
+ onPointMove={(pointIdx, coords) =>
+ handlePointMove(index, pointIdx, coords)
+ }
+ onControlPointMove={(pointIdx, coords) =>
+ handleControlPointMove(
+ index,
+ pointIdx,
+ coords,
+ )
+ }
+ onControlPoint2Move={(pointIdx, coords) =>
+ handleControlPoint2Move(
+ index,
+ pointIdx,
+ coords,
+ )
+ }
+ onDeleteStartPoint={() =>
+ handleDeleteStartPoint(index)
+ }
+ onDeletePoint={(pointIdx) =>
+ handleDeletePoint(index, pointIdx)
+ }
+ onSwitchPointType={(pointIdx, newType) =>
+ handleSwitchPointType(
+ index,
+ pointIdx,
+ newType,
+ )
+ }
+ />
+
+ ))}
+ {show.timeline.map((layer, index) => (
+
+ ))}
+
+
+
+
+
+
+ {JSON.stringify(
+ {
+ ...show,
+ audio:
+ show.audio ?
+ {
+ data: "[binary data]",
+ mimeType:
+ show.audio.mimeType,
+ }
+ : undefined,
+ },
+ null,
+ 2,
+ )}
+
+
+
+
+
+ {selectedTimelineEventIndex !== null ?
+ Coming soon: event editor.
+ :
+ }
+
+
+
+
+
+
+ setTimelineDurationUpdateMode(
+ value as (typeof TimelineDurationUpdateMode)[keyof typeof TimelineDurationUpdateMode],
+ )
+ }
+ value={timelineDurationUpdateMode}
+ />
+
+ {gridCursorMode === GridCursorMode.Pen && (
+ <>
+
+ setDefaultPointType(
+ value as Midpoint["type"],
+ )
+ }
+ value={defaultPointType}
+ />
+
+ setDefaultEventDurationMs(value)
+ }
+ />
+ >
+ )}
+
+ setGridCursorMode(
+ value as (typeof GridCursorMode)[keyof typeof GridCursorMode],
+ )
+ }
+ value={gridCursorMode}
+ />
+
+
+ >
+ }
+ >
+
+
+
+ {
+ post("/do-big", { show: JSON.stringify(show) });
+ }}
+ />
+
+
+
+
+
+
+
+
+ {show.timeline.map(
+ ({ startPoint, remainingEvents }, layerIndex) => {
+ return (
+
deleteLayer(layerIndex)}
+ onActive={() =>
+ setSelectedLayerIndex(layerIndex)
+ }
+ active={selectedLayerIndex === layerIndex}
+ >
+
+
+ updateTimelineEventDurations(
+ layerIndex,
+ startPoint.id,
+ ms,
+ )
+ }
+ onAddWaitEvent={() => {
+ addWaitEventAtIndex(
+ layerIndex,
+ 0,
+ );
+ }}
+ onAddTurnEvent={() => {
+ addTurnEventAtIndex(
+ layerIndex,
+ 0,
+ );
+ }}
+ selected={
+ selectedTimelineEventIndex?.layerIndex ===
+ layerIndex &&
+ selectedTimelineEventIndex?.eventId ===
+ startPoint.id
+ }
+ onSelect={() => {
+ setSelectedTimelineEventIndex({
+ layerIndex,
+ eventId: startPoint.id,
+ });
+ }}
+ />
+ {
+ updateTimelineEventOrders(
+ layerIndex,
+ newIndices as NonStartPointEvent[],
+ );
+ }}
+ >
+ {remainingEvents.map(
+ (event, eventIndex) => {
+ return (
+
+ updateTimelineEventDurations(
+ layerIndex,
+ event.id,
+ ms,
+ )
+ }
+ onDelete={() =>
+ deleteTimelineEvent(
+ layerIndex,
+ event.id,
+ )
+ }
+ onAddWaitEvent={(
+ position,
+ ) =>
+ (
+ position ===
+ "before"
+ ) ?
+ addWaitEventAtIndex(
+ layerIndex,
+ eventIndex,
+ )
+ : addWaitEventAtIndex(
+ layerIndex,
+ eventIndex +
+ 1,
+ )
+ }
+ onAddTurnEvent={(
+ position,
+ ) =>
+ (
+ position ===
+ "before"
+ ) ?
+ addTurnEventAtIndex(
+ layerIndex,
+ eventIndex,
+ )
+ : addTurnEventAtIndex(
+ layerIndex,
+ eventIndex +
+ 1,
+ )
+ }
+ selected={
+ selectedTimelineEventIndex?.layerIndex ===
+ layerIndex &&
+ selectedTimelineEventIndex?.eventId ===
+ event.id
+ }
+ onSelect={() => {
+ setSelectedTimelineEventIndex(
+ {
+ layerIndex,
+ eventId:
+ event.id,
+ },
+ );
+ }}
+ />
+ );
+ },
+ )}
+
+
+
+ );
+ },
+ )}
+
+
+
+ );
+}
diff --git a/src/client/editor/hooks.ts b/src/client/editor/hooks.ts
new file mode 100644
index 00000000..80b39110
--- /dev/null
+++ b/src/client/editor/hooks.ts
@@ -0,0 +1,227 @@
+import { useMotionValue, useTime, useMotionValueEvent } from "motion/react";
+import {
+ type RefObject,
+ useEffect,
+ useState,
+ useReducer,
+ useCallback,
+ useMemo,
+} from "react";
+
+export function useDraggable(
+ elRef: RefObject,
+ updatePosition: (screenX: number, screenY: number) => void,
+) {
+ useEffect(() => {
+ const el = elRef.current;
+ if (!el) return;
+
+ // TODO: better name for these?
+ let pos1 = 0,
+ pos2 = 0,
+ pos3 = 0,
+ pos4 = 0;
+
+ const onMouseDown = (e) => {
+ e.preventDefault();
+ // get the mouse cursor position at startup:
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+ document.onmouseup = closeDragElement;
+ // call a fn whenever the cursor moves:
+ document.onmousemove = elementDrag;
+ };
+
+ const elementDrag = (e) => {
+ e.preventDefault();
+ pos1 = pos3 - e.clientX;
+ pos2 = pos4 - e.clientY;
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+ const x = el.offsetLeft - pos1;
+ const y = el.offsetTop - pos2;
+ updatePosition(x, y);
+ };
+
+ const closeDragElement = () => {
+ document.onmouseup = null;
+ document.onmousemove = null;
+ };
+
+ el.addEventListener("mousedown", onMouseDown);
+
+ return () => {
+ el.removeEventListener("mousedown", onMouseDown);
+ document.removeEventListener("mousemove", elementDrag);
+ document.removeEventListener("mouseup", closeDragElement);
+ };
+ }, [elRef, updatePosition]);
+}
+
+/*
+ * A hook that prevents the user from exiting the page with unsaved changes.
+ *
+ * The hook provides a boolean indicating if there are unsaved changes, and a function to set the unsaved changes state.
+ */
+export function usePreventExitWithUnsavedChanges() {
+ const [unsavedChanges, setUnsavedChanges] = useState(false);
+
+ useEffect(() => {
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (unsavedChanges) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
+
+ window.addEventListener("beforeunload", onBeforeUnload);
+
+ return () => {
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ };
+ }, [unsavedChanges]);
+
+ return [unsavedChanges, setUnsavedChanges] as const;
+}
+
+/*
+ * A hook that provides a state with a history of changes, and undo/redo functionality.
+ *
+ * The state is a simple object with a value and a history of values, and an index into the history.
+ * The value is the current state of the state, and the history is an array of all the previous states.
+ * The index is the index into the history of the current state.
+ *
+ * The hook also provides functions to set the value, undo, redo, and check if the state can be undone or redone.
+ *
+ * The hook also provides a callback to replace the current value with a new value, which is useful for the first time you load data since that shouldn't count as a tracked action.
+ */
+export function useStateWithTrackedHistory(initialValue: T) {
+ type Action =
+ | { type: "set"; value: T }
+ | { type: "undo" }
+ | { type: "redo" }
+ | { type: "replace"; value: T };
+ type State = {
+ history: T[];
+ index: number;
+ };
+ const [state, dispatch] = useReducer(
+ (state: State, action: Action): State => {
+ switch (action.type) {
+ case "set": {
+ // Truncate history after the current index if we've undone previously
+ const newHistory = state.history.slice(0, state.index + 1);
+ newHistory.push(action.value);
+ return {
+ history: newHistory,
+ index: newHistory.length - 1,
+ };
+ }
+ case "undo": {
+ if (state.index === 0) return state; // Cannot undo past the initial state
+ return {
+ ...state,
+ index: state.index - 1,
+ };
+ }
+ case "redo": {
+ // Cannot redo if already at the latest state
+ if (state.index === state.history.length - 1) return state;
+ return {
+ ...state,
+ index: state.index + 1,
+ };
+ }
+ case "replace": {
+ // Reset history with the new value
+ return {
+ history: [action.value],
+ index: 0,
+ };
+ }
+ }
+ },
+ // Initial state: history contains only the initial value at index 0
+ {
+ history: [initialValue],
+ index: 0,
+ },
+ );
+
+ const value = useMemo(
+ () => state.history[state.index],
+ [state.history, state.index],
+ );
+ const setValue = useCallback(
+ (newValue: T) => {
+ // Avoid adding identical consecutive states to history
+ if (newValue !== value) {
+ dispatch({ type: "set", value: newValue });
+ }
+ },
+ [value],
+ ); // Add value dependency to check against current value
+
+ const undo = useCallback(() => dispatch({ type: "undo" }), []);
+ const redo = useCallback(() => dispatch({ type: "redo" }), []);
+ const canUndo = useMemo(() => state.index > 0, [state.index]);
+ const canRedo = useMemo(
+ () => state.index < state.history.length - 1,
+ [state.index, state.history.length],
+ );
+ // Add a function to replace the state without affecting history tracking logic like 'set' does
+ const replaceState = useCallback((newValue: T) => {
+ dispatch({ type: "replace", value: newValue });
+ }, []);
+
+ return { value, setValue, canUndo, canRedo, undo, redo, replaceState }; // Expose replaceState if needed
+}
+
+export function usePlayHead(endDurationMs: number, startMs = 0) {
+ const [playing, setPlaying] = useState(false);
+ const [stoppedAtEnd, setStoppedAtEnd] = useState(false);
+ const currentTimestamp = useMotionValue(startMs);
+ const time = useTime();
+ const lastAccessedTime = useMotionValue(0);
+
+ const setTimestamp = (timestamp: number) => {
+ currentTimestamp.set(timestamp);
+ };
+
+ const togglePlaying = useCallback(() => {
+ setPlaying((p) => {
+ const newPlaying = !p;
+ if (newPlaying) {
+ if (stoppedAtEnd) {
+ currentTimestamp.set(0);
+ }
+ setStoppedAtEnd(false);
+ // When starting playback, initialize the lastAccessedTime
+ lastAccessedTime.set(time.get());
+ }
+ return newPlaying;
+ });
+ }, [stoppedAtEnd, lastAccessedTime, time, currentTimestamp]);
+
+ useMotionValueEvent(time, "change", (currentTime) => {
+ if (!playing) {
+ return;
+ }
+
+ const prevTime = lastAccessedTime.get();
+ const elapsedMs = currentTime - prevTime;
+
+ const newTimestamp = currentTimestamp.get() + elapsedMs;
+
+ if (newTimestamp >= endDurationMs) {
+ setPlaying(false);
+ setStoppedAtEnd(true);
+ } else {
+ currentTimestamp.set(newTimestamp);
+ }
+
+ lastAccessedTime.set(currentTime);
+ });
+
+ return { currentTimestamp, playing, togglePlaying, setTimestamp };
+}
diff --git a/src/client/editor/motion-robot.tsx b/src/client/editor/motion-robot.tsx
new file mode 100644
index 00000000..c4b591bc
--- /dev/null
+++ b/src/client/editor/motion-robot.tsx
@@ -0,0 +1,91 @@
+import type { CSSProperties } from "react";
+import { forwardRef } from "react";
+import { motion } from "framer-motion";
+import { Tooltip } from "@blueprintjs/core";
+import { robotSize } from "../debug/simulator"; // Reuse size
+import { robotColor, innerRobotColor } from "../check-dark-mode";
+import type { Coords } from "../../common/spline";
+
+// Simple representation for display, doesn't need full simulation data
+interface MotionRobotProps {
+ robotId: string;
+ position?: Coords; // Optional for initial render
+ style?: CSSProperties;
+ onTopOfRobotsCount?: number; // Simplified collision indication
+}
+
+/**
+ * A Framer Motion animated robot component for the editor grid.
+ * Uses x, y, rotate for positioning and animation.
+ */
+export const MotionRobot = forwardRef(
+ function MotionRobot(
+ { robotId, position, style, onTopOfRobotsCount = 0 },
+ ref,
+ ) {
+ // Convert position (if provided) to initial styles, but expect
+ // x, y, rotate to be controlled by useAnimate in the parent.
+ const initialStyle =
+ position ?
+ {
+ // Framer motion uses different origin - center based? Check docs.
+ // Let's assume x/y map directly for now and adjust if needed.
+ // The original used left/bottom. Motion x/y work from top/left.
+ x: `${position.x}px`,
+ y: `${position.y}px`,
+ // We apply rotation via the rotate transform property.
+ }
+ : {};
+
+ return (
+
+
+
+
+
+ );
+ },
+);
diff --git a/src/client/editor/points.tsx b/src/client/editor/points.tsx
new file mode 100644
index 00000000..613f134c
--- /dev/null
+++ b/src/client/editor/points.tsx
@@ -0,0 +1,113 @@
+import { ContextMenu, Menu, MenuItem, MenuDivider } from "@blueprintjs/core";
+import { useRef } from "react";
+import { type Point, SplinePointType, type Coords } from "../../common/spline";
+import { robotSize } from "../debug/simulator";
+import { GRID_CELL_PX } from "../../common/units";
+import { useDraggable } from "./hooks";
+
+export function SplinePoint({
+ point,
+ onMove,
+ onJumpToPoint,
+ onSwitchToQuadratic = undefined,
+ onSwitchToCubic = undefined,
+ onDelete = undefined,
+}: {
+ point: Point;
+ onMove: (x: number, y: number) => void;
+ onDelete?: () => void;
+ onSwitchToQuadratic?: () => void;
+ onSwitchToCubic?: () => void;
+ onJumpToPoint: () => void;
+}) {
+ // TODO: fix context menu positioning
+ const mainPointRef = useRef(null);
+ useDraggable(mainPointRef, (screenX, screenY) =>
+ onMove(screenX + robotSize / 4, screenY + robotSize / 4),
+ );
+
+ const mainPointColor =
+ point.type === SplinePointType.StartPoint ? "blue" : "black";
+ const mainCoords =
+ point.type === SplinePointType.StartPoint ?
+ { x: point.point.x * GRID_CELL_PX, y: point.point.y * GRID_CELL_PX }
+ : {
+ x: point.endPoint.x * GRID_CELL_PX,
+ y: point.endPoint.y * GRID_CELL_PX,
+ };
+
+ return (
+
+ {point.type === SplinePointType.CubicBezier && (
+
+ )}
+ {point.type === SplinePointType.QuadraticBezier && (
+
+ )}
+
+
+
+
+
+ }
+ >
+
+
+ );
+}
+export function SplineControlPoint({
+ point,
+ onMove,
+}: {
+ point: Coords;
+ onMove: (x: number, y: number) => void;
+}) {
+ const controlPointRef = useRef(null);
+ useDraggable(controlPointRef, (screenX, screenY) =>
+ onMove(screenX + robotSize / 4, screenY + robotSize / 4),
+ );
+
+ return (
+
+ );
+}
diff --git a/src/client/editor/showfile-state.ts b/src/client/editor/showfile-state.ts
new file mode 100644
index 00000000..d122531e
--- /dev/null
+++ b/src/client/editor/showfile-state.ts
@@ -0,0 +1,837 @@
+// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay
+import { encode as cborEncode } from "cbor-x";
+
+// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay
+import { fileOpen, fileSave } from "browser-fs-access";
+
+import { diff } from "deep-object-diff";
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
+import type {
+ StartPointEvent,
+ GoToPointEvent,
+ TimelineLayerType,
+ NonStartPointEvent,
+ WaitEvent,
+ TurnEvent,
+} from "../../common/show";
+import {
+ createNewShowfile,
+ loadShowfileFromBinary,
+ TimelineEventTypes,
+ CHESSBOTS_SHOWFILE_MIME_TYPE,
+ CHESSBOTS_SHOWFILE_EXTENSION,
+} from "../../common/show";
+import {
+ TimelineDurationUpdateMode,
+ GridCursorMode,
+} from "../../common/show-interface-utils";
+import { SplinePointType } from "../../common/spline";
+import type {
+ CubicBezier,
+ QuadraticBezier,
+ Coords,
+ Midpoint,
+} from "../../common/spline";
+import {
+ usePlayHead,
+ usePreventExitWithUnsavedChanges,
+ useStateWithTrackedHistory,
+} from "./hooks";
+import { GRID_CELL_PX } from "../../common/units";
+
+export function useShowfile() {
+ // used to store the initial showfile state before any changes were made in the editor, so we have something to compare against to see if there are unsaved changes
+ const [initialShow, setInitialShow] = useState(createNewShowfile());
+
+ // See comment on TimelineDurationUpdateMode declaration in src/common/show.ts for context
+ const [timelineDurationUpdateMode, setTimelineDurationUpdateMode] =
+ useState<
+ (typeof TimelineDurationUpdateMode)[keyof typeof TimelineDurationUpdateMode]
+ >(TimelineDurationUpdateMode.Ripple);
+
+ const [gridCursorMode, setGridCursorMode] = useState<
+ (typeof GridCursorMode)[keyof typeof GridCursorMode]
+ >(GridCursorMode.Pen);
+
+ const [defaultPointType, setDefaultPointType] = useState(
+ SplinePointType.QuadraticBezier,
+ );
+
+ const [defaultEventDurationMs, setDefaultEventDurationMs] = useState(3750);
+
+ const [selectedLayerIndex, setSelectedLayerIndex] = useState(0);
+ const [selectedTimelineEventIndex, setSelectedTimelineEventIndex] =
+ useState<{ layerIndex: number; eventId: string } | null>(null);
+
+ // used to store the handle to the file system file that the showfile is currently loaded from,
+ // so we can save to the same file we've already opened. not all the browsers' file system API
+ // implementations support this - Chrome does, Safari doesn't, not sure about Firefox.
+ const [fsHandle, setFsHandle] = useState(null);
+
+ const [unsavedChanges, setUnsavedChanges] =
+ usePreventExitWithUnsavedChanges();
+
+ const {
+ value: show,
+ setValue: setShow,
+ canUndo,
+ canRedo,
+ undo,
+ redo,
+ } = useStateWithTrackedHistory(initialShow);
+
+ // update the unsaved changes state whenever the showfile changes
+ useEffect(() => {
+ const result = diff(initialShow, show);
+ const differenceBetweenSavedShowAndCurrentShow =
+ Object.keys(result).length === 0;
+ if (differenceBetweenSavedShowAndCurrentShow) {
+ setUnsavedChanges(false);
+ } else {
+ setUnsavedChanges(true);
+ }
+ }, [initialShow, show, setUnsavedChanges]);
+
+ // handles opening a showfile from the file system, parsing it, and loading it into the editor
+ const openShowfile = useCallback(async () => {
+ const blob = await fileOpen({
+ mimeTypes: [CHESSBOTS_SHOWFILE_MIME_TYPE],
+ extensions: [CHESSBOTS_SHOWFILE_EXTENSION],
+ description: "Chess Bots Showfile",
+ });
+
+ const show = loadShowfileFromBinary(
+ new Uint8Array(await blob.arrayBuffer()),
+ );
+ if (!show) return;
+
+ setInitialShow(show);
+ setShow(show);
+ if (blob.handle) setFsHandle(blob.handle);
+ }, [setShow]);
+
+ // handles encoding the showfile as a CBOR and saving it to the file system
+ const saveShowfile = useCallback(async () => {
+ const blob = new Blob([cborEncode(show)], {
+ type: CHESSBOTS_SHOWFILE_MIME_TYPE,
+ });
+ await fileSave(
+ blob,
+ {
+ mimeTypes: [CHESSBOTS_SHOWFILE_MIME_TYPE],
+ extensions: [CHESSBOTS_SHOWFILE_EXTENSION],
+ fileName: show.name + CHESSBOTS_SHOWFILE_EXTENSION,
+ },
+ fsHandle,
+ );
+
+ setInitialShow(show);
+ }, [fsHandle, show]);
+
+ // handles loading an audio file from the file system, and adding it to the showfile
+ const loadAudioFromFile = useCallback(async () => {
+ const blob = await fileOpen({
+ mimeTypes: ["audio/mpeg", "audio/wav"],
+ extensions: [".mp3", ".wav"],
+ });
+ const audio = new Uint8Array(await blob.arrayBuffer());
+ setShow({
+ ...show,
+ audio: {
+ data: audio,
+ mimeType: blob.type,
+ },
+ });
+ }, [setShow, show]);
+
+ const handleStartPointMove = useCallback(
+ (layerIndex: number, newCoords: Coords) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (layer) {
+ const { startPoint, remainingEvents } = layer;
+ const newStartPointEvent: StartPointEvent = {
+ ...startPoint,
+ target: {
+ ...startPoint.target,
+ point: {
+ x: newCoords.x / GRID_CELL_PX,
+ y: newCoords.y / GRID_CELL_PX,
+ },
+ },
+ };
+ newTimeline[layerIndex] = {
+ startPoint: newStartPointEvent,
+ remainingEvents,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ }
+ },
+ [show, setShow],
+ );
+
+ const handlePointMove = useCallback(
+ (layerIndex: number, pointIndex: number, newCoords: Coords) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+ const eventToUpdate = events[pointIndex];
+
+ if (
+ eventToUpdate &&
+ eventToUpdate.type === TimelineEventTypes.GoToPointEvent
+ ) {
+ events[pointIndex] = {
+ ...eventToUpdate,
+ target: {
+ ...eventToUpdate.target,
+ endPoint: {
+ x: newCoords.x / GRID_CELL_PX,
+ y: newCoords.y / GRID_CELL_PX,
+ },
+ },
+ };
+ newTimeline[layerIndex] = {
+ startPoint,
+ remainingEvents: events,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ }
+ },
+ [show, setShow],
+ );
+
+ const handleControlPointMove = useCallback(
+ (layerIndex: number, pointIndex: number, newCoords: Coords) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+ const eventToUpdate = events[pointIndex];
+
+ if (eventToUpdate?.type === TimelineEventTypes.GoToPointEvent) {
+ events[pointIndex] = {
+ ...eventToUpdate,
+ target: {
+ ...eventToUpdate.target,
+ controlPoint: {
+ x: newCoords.x / GRID_CELL_PX,
+ y: newCoords.y / GRID_CELL_PX,
+ },
+ },
+ };
+ newTimeline[layerIndex] = {
+ startPoint,
+ remainingEvents: events,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ }
+ },
+ [show, setShow],
+ );
+ const handleControlPoint2Move = useCallback(
+ (layerIndex: number, pointIndex: number, newCoords: Coords) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+ const eventToUpdate = events[pointIndex];
+
+ if (
+ eventToUpdate?.type === TimelineEventTypes.GoToPointEvent &&
+ eventToUpdate.target.type === SplinePointType.CubicBezier
+ ) {
+ events[pointIndex] = {
+ ...eventToUpdate,
+ target: {
+ ...eventToUpdate.target,
+ controlPoint2: {
+ x: newCoords.x / GRID_CELL_PX,
+ y: newCoords.y / GRID_CELL_PX,
+ },
+ },
+ };
+ newTimeline[layerIndex] = {
+ startPoint,
+ remainingEvents: events,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ }
+ },
+ [show, setShow],
+ );
+ const handleDeleteStartPoint = useCallback(
+ (layerIndex: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+
+ const { remainingEvents } = layer;
+
+ // Promote the first GoToPoint event (if any) to be the new start point
+ const firstGoToPointIndex = remainingEvents.findIndex(
+ (e) => e.type === TimelineEventTypes.GoToPointEvent,
+ );
+
+ if (firstGoToPointIndex !== -1) {
+ const firstGoToPointEvent = remainingEvents[
+ firstGoToPointIndex
+ ] as GoToPointEvent;
+ const newStartPointEvent: StartPointEvent = {
+ id: crypto.randomUUID(),
+ type: TimelineEventTypes.StartPointEvent,
+ durationMs: firstGoToPointEvent.durationMs,
+ target: {
+ type: SplinePointType.StartPoint,
+ point: firstGoToPointEvent.target.endPoint,
+ },
+ };
+ const newRemainingEvents = [
+ ...remainingEvents.slice(0, firstGoToPointIndex),
+ ...remainingEvents.slice(firstGoToPointIndex + 1),
+ ];
+ newTimeline[layerIndex] = {
+ startPoint: newStartPointEvent,
+ remainingEvents: newRemainingEvents,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ } else {
+ console.warn(
+ "Tried to delete a start point with no subsequent GoTo points. Consider deleting layer instead.",
+ );
+ }
+ },
+ [show, setShow],
+ );
+
+ const handleDeletePoint = useCallback(
+ (layerIndex: number, pointIndex: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) {
+ return;
+ }
+ const { startPoint, remainingEvents } = layer;
+ const events = [
+ ...remainingEvents.slice(0, pointIndex),
+ ...remainingEvents.slice(pointIndex + 1),
+ ]; // Remove the event
+ newTimeline[layerIndex] = { startPoint, remainingEvents: events };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, setShow],
+ );
+
+ const handleSwitchPointType = useCallback(
+ (
+ layerIndex: number,
+ pointIndex: number,
+ newType:
+ | SplinePointType.QuadraticBezier
+ | SplinePointType.CubicBezier,
+ ) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+
+ const eventToUpdate = events[pointIndex];
+
+ if (eventToUpdate?.type !== TimelineEventTypes.GoToPointEvent) {
+ console.warn(
+ "Tried to switch point type on non-GoToPointEvent",
+ );
+ return;
+ }
+ let newTarget: CubicBezier | QuadraticBezier;
+ if (
+ newType === SplinePointType.CubicBezier &&
+ eventToUpdate.target.type === SplinePointType.QuadraticBezier
+ ) {
+ newTarget = {
+ type: SplinePointType.CubicBezier,
+ controlPoint: {
+ x:
+ (eventToUpdate.target.endPoint.x - 20) /
+ GRID_CELL_PX,
+ y:
+ (eventToUpdate.target.endPoint.y - 20) /
+ GRID_CELL_PX,
+ },
+ controlPoint2: {
+ x:
+ (eventToUpdate.target.endPoint.x + 20) /
+ GRID_CELL_PX,
+ y:
+ (eventToUpdate.target.endPoint.y + 20) /
+ GRID_CELL_PX,
+ },
+ endPoint: eventToUpdate.target.endPoint,
+ };
+ } else if (
+ newType === SplinePointType.QuadraticBezier &&
+ eventToUpdate.target.type === SplinePointType.CubicBezier
+ ) {
+ newTarget = {
+ type: SplinePointType.QuadraticBezier,
+ endPoint: eventToUpdate.target.endPoint,
+ controlPoint: {
+ x:
+ (eventToUpdate.target.endPoint.x + 20) /
+ GRID_CELL_PX,
+ y:
+ (eventToUpdate.target.endPoint.y + 20) /
+ GRID_CELL_PX,
+ },
+ };
+ } else {
+ console.warn("Tried to switch point type with invalid type");
+ return;
+ }
+
+ events[pointIndex] = {
+ ...eventToUpdate,
+ target: newTarget,
+ };
+ newTimeline[layerIndex] = { startPoint, remainingEvents: events };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, setShow],
+ );
+
+ const editName = useCallback(
+ (value: string) => setShow({ ...show, name: value }),
+ [show, setShow],
+ );
+
+ const addRobot = useCallback(() => {
+ const newLayer: TimelineLayerType = {
+ startPoint: {
+ id: crypto.randomUUID(),
+ type: TimelineEventTypes.StartPointEvent,
+ target: {
+ type: SplinePointType.StartPoint,
+ point: {
+ x: 0,
+ y: 10,
+ },
+ },
+ durationMs: defaultEventDurationMs,
+ },
+ remainingEvents: [],
+ };
+ const layers = [...show.timeline, newLayer];
+ setShow({
+ ...show,
+ timeline: layers,
+ });
+
+ setSelectedLayerIndex(layers.length - 1);
+ }, [show, setShow, defaultEventDurationMs]);
+
+ // Calculate sequenceLengthMs dynamically
+ const sequenceLengthMs = useMemo(() => {
+ let maxDuration = 0;
+ show.timeline.forEach((layer) => {
+ let currentLayerDuration = 0;
+ // Add start point duration first if it exists
+ if (layer.startPoint) {
+ currentLayerDuration += layer.startPoint.durationMs;
+ }
+ // Add remaining events durations
+ layer.remainingEvents.forEach((event) => {
+ currentLayerDuration += event.durationMs;
+ });
+ maxDuration = Math.max(maxDuration, currentLayerDuration);
+ });
+ // Use the max duration across all layers.
+ // Provide a default minimum duration if there are no events.
+ return maxDuration > 0 ? maxDuration : 10000; // Default to 10s
+ }, [show.timeline]);
+
+ // TODO: continue adding comments to code below this line
+ const { currentTimestamp, playing, togglePlaying, setTimestamp } =
+ usePlayHead(sequenceLengthMs);
+
+ const startShow = useEffect(() => {});
+
+ const { audio } = show;
+ const audioRef = useRef(new Audio());
+ useEffect(() => {
+ if (!audio) return;
+
+ audioRef.current.src = URL.createObjectURL(
+ new Blob([audio.data], {
+ type: audio.mimeType,
+ }),
+ );
+ audioRef.current.load();
+ }, [audio]);
+
+ useEffect(() => {
+ if (!audioRef.current) return;
+
+ if (audioRef.current.readyState !== 4) {
+ return;
+ }
+
+ if (playing && audioRef.current.paused) {
+ audioRef.current.currentTime = Math.min(
+ currentTimestamp.get() / 1000,
+ audioRef.current.duration,
+ );
+ audioRef.current.play();
+ }
+
+ if (!playing && !audioRef.current.paused) {
+ audioRef.current.pause();
+ }
+ }, [playing, currentTimestamp]);
+
+ const deleteLayer = useCallback(
+ (i: number) =>
+ setShow({
+ ...show,
+ timeline: [
+ ...show.timeline.slice(0, i),
+ ...show.timeline.slice(i + 1),
+ ],
+ }),
+ [show, setShow],
+ );
+
+ const updateTimelineEventOrders = useCallback(
+ (layerIndex: number, newList: NonStartPointEvent[]) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint } = layer;
+ newTimeline[layerIndex] = { startPoint, remainingEvents: newList };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, setShow],
+ );
+
+ const updateTimelineEventDurations = useCallback(
+ (layerIndex: number, eventId: string, deltaMs: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) {
+ console.log("Layer not found");
+ return;
+ }
+
+ const { startPoint, remainingEvents } = layer;
+
+ const updatedEventList = [startPoint, ...remainingEvents];
+ const eventToUpdateIndex = updatedEventList.findIndex(
+ (event) => event.id === eventId,
+ );
+ if (eventToUpdateIndex === -1) {
+ console.log("Event not found", updatedEventList, layer);
+ return;
+ }
+
+ const shouldUpdateSubsequentEventDuration =
+ timelineDurationUpdateMode ===
+ TimelineDurationUpdateMode.Rolling &&
+ eventToUpdateIndex < updatedEventList.length - 1;
+
+ const eventToUpdate = updatedEventList[eventToUpdateIndex];
+ const newDurationMs = eventToUpdate.durationMs + deltaMs;
+ eventToUpdate.durationMs = newDurationMs;
+ updatedEventList[eventToUpdateIndex] = eventToUpdate;
+
+ if (shouldUpdateSubsequentEventDuration) {
+ const subsequentEventIndex = eventToUpdateIndex + 1;
+ const subsequentEvent = updatedEventList[subsequentEventIndex];
+ const newSubsequentEventDurationMs =
+ subsequentEvent.durationMs - deltaMs;
+ subsequentEvent.durationMs = newSubsequentEventDurationMs;
+ updatedEventList[subsequentEventIndex] = subsequentEvent;
+ }
+
+ newTimeline[layerIndex] = {
+ startPoint: updatedEventList[0] as StartPointEvent,
+ remainingEvents: updatedEventList.slice(
+ 1,
+ ) as NonStartPointEvent[],
+ };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, setShow, timelineDurationUpdateMode],
+ );
+
+ const addPointToSelectedLayer = useCallback(
+ (x: number, y: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[selectedLayerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const newEvents = [...remainingEvents];
+ switch (defaultPointType) {
+ case SplinePointType.QuadraticBezier:
+ newEvents.push({
+ type: TimelineEventTypes.GoToPointEvent,
+ durationMs: defaultEventDurationMs,
+ target: {
+ type: SplinePointType.QuadraticBezier,
+ controlPoint: {
+ x: (x - 10) / GRID_CELL_PX,
+ y: (y - 10) / GRID_CELL_PX,
+ },
+ endPoint: {
+ x: x / GRID_CELL_PX,
+ y: y / GRID_CELL_PX,
+ },
+ },
+ id: crypto.randomUUID(),
+ });
+ break;
+ case SplinePointType.CubicBezier:
+ newEvents.push({
+ type: TimelineEventTypes.GoToPointEvent,
+ durationMs: defaultEventDurationMs,
+ target: {
+ type: SplinePointType.CubicBezier,
+ endPoint: {
+ x: x / GRID_CELL_PX,
+ y: y / GRID_CELL_PX,
+ },
+ controlPoint: {
+ x: (x + 10) / GRID_CELL_PX,
+ y: (y + 10) / GRID_CELL_PX,
+ },
+ controlPoint2: {
+ x: (x - 10) / GRID_CELL_PX,
+ y: (y - 10) / GRID_CELL_PX,
+ },
+ },
+ id: crypto.randomUUID(),
+ });
+ break;
+ default:
+ console.warn("Tried to add point with invalid point type");
+ return;
+ }
+
+ newTimeline[selectedLayerIndex] = {
+ startPoint,
+ remainingEvents: newEvents,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [
+ show,
+ setShow,
+ selectedLayerIndex,
+ defaultPointType,
+ defaultEventDurationMs,
+ ],
+ );
+
+ const removeAudio = useCallback(() => {
+ setShow({
+ ...show,
+ audio: undefined,
+ });
+ }, [show, setShow]);
+
+ const deleteTimelineEvent = useCallback(
+ (layerIndex: number, eventId: string) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) {
+ return;
+ }
+ const { startPoint, remainingEvents } = layer;
+ const eventIndex = remainingEvents.findIndex(
+ (event) => event.id === eventId,
+ );
+ const events = [
+ ...remainingEvents.slice(0, eventIndex),
+ ...remainingEvents.slice(eventIndex + 1),
+ ]; // Remove the event
+ newTimeline[layerIndex] = { startPoint, remainingEvents: events };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, setShow],
+ );
+
+ const addWaitEventAtIndex = useCallback(
+ (layerIndex: number, eventIndex: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+ const eventToAdd: WaitEvent = {
+ id: crypto.randomUUID(),
+ type: TimelineEventTypes.WaitEvent,
+ durationMs: defaultEventDurationMs,
+ };
+ events.splice(eventIndex, 0, eventToAdd);
+ newTimeline[layerIndex] = {
+ startPoint,
+ remainingEvents: events,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, defaultEventDurationMs, setShow],
+ );
+
+ const addTurnEventAtIndex = useCallback(
+ (layerIndex: number, eventIndex: number) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[layerIndex];
+ if (!layer) return;
+ const { startPoint, remainingEvents } = layer;
+ const events = [...remainingEvents];
+ const eventToAdd: TurnEvent = {
+ id: crypto.randomUUID(),
+ type: TimelineEventTypes.TurnEvent,
+ durationMs: defaultEventDurationMs,
+ radians: 2 * Math.PI,
+ };
+ events.splice(eventIndex, 0, eventToAdd);
+ newTimeline[layerIndex] = {
+ startPoint,
+ remainingEvents: events,
+ };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, defaultEventDurationMs, setShow],
+ );
+
+ const getLayerIndexFromEventId = useCallback(
+ (eventId: string) => {
+ const layerIndex = show.timeline.findIndex((layer) =>
+ layer.remainingEvents.find((event) => event.id === eventId),
+ );
+ return layerIndex;
+ },
+ [show],
+ );
+
+ const addBulkEventsToSelectedLayer = useCallback(
+ (events: NonStartPointEvent[]) => {
+ const newTimeline = [...show.timeline];
+ const layer = newTimeline[selectedLayerIndex];
+ if (!layer) return;
+ const { startPoint } = layer;
+ newTimeline[selectedLayerIndex] = {
+ startPoint,
+ remainingEvents: [...layer.remainingEvents, ...events],
+ };
+ setShow({ ...show, timeline: newTimeline });
+ },
+ [show, selectedLayerIndex, setShow],
+ );
+
+ const showfileApi = useMemo(
+ () => ({
+ updateTimelineEventOrders,
+ show,
+ unsavedChanges,
+ loadAudioFromFile,
+ handleStartPointMove,
+ handlePointMove,
+ handleControlPointMove,
+ handleControlPoint2Move,
+ handleDeleteStartPoint,
+ handleDeletePoint,
+ handleSwitchPointType,
+ saveShowfile,
+ openShowfile,
+ editName,
+ undo,
+ redo,
+ addRobot,
+ currentTimestamp,
+ playing,
+ togglePlaying,
+ startShow,
+ deleteLayer,
+ canRedo,
+ canUndo,
+ sequenceLengthMs,
+ timelineDurationUpdateMode,
+ setTimelineDurationUpdateMode,
+ updateTimelineEventDurations,
+ gridCursorMode,
+ setGridCursorMode,
+ defaultPointType,
+ setDefaultPointType,
+ setDefaultEventDurationMs,
+ defaultEventDurationMs,
+ addPointToSelectedLayer,
+ setSelectedLayerIndex,
+ selectedLayerIndex,
+ removeAudio,
+ deleteTimelineEvent,
+ setTimestamp,
+ addWaitEventAtIndex,
+ addTurnEventAtIndex,
+ getLayerIndexFromEventId,
+ addBulkEventsToSelectedLayer,
+ selectedTimelineEventIndex,
+ setSelectedTimelineEventIndex,
+ }),
+ [
+ updateTimelineEventOrders,
+ show,
+ unsavedChanges,
+ loadAudioFromFile,
+ handleStartPointMove,
+ handlePointMove,
+ handleControlPointMove,
+ handleControlPoint2Move,
+ handleDeleteStartPoint,
+ handleDeletePoint,
+ handleSwitchPointType,
+ saveShowfile,
+ openShowfile,
+ editName,
+ undo,
+ redo,
+ addRobot,
+ currentTimestamp,
+ playing,
+ togglePlaying,
+ startShow,
+ deleteLayer,
+ canRedo,
+ canUndo,
+ sequenceLengthMs,
+ timelineDurationUpdateMode,
+ setTimelineDurationUpdateMode,
+ updateTimelineEventDurations,
+ gridCursorMode,
+ setGridCursorMode,
+ defaultPointType,
+ setDefaultPointType,
+ setDefaultEventDurationMs,
+ defaultEventDurationMs,
+ addPointToSelectedLayer,
+ setSelectedLayerIndex,
+ selectedLayerIndex,
+ removeAudio,
+ deleteTimelineEvent,
+ setTimestamp,
+ addWaitEventAtIndex,
+ addTurnEventAtIndex,
+ getLayerIndexFromEventId,
+ addBulkEventsToSelectedLayer,
+ selectedTimelineEventIndex,
+ setSelectedTimelineEventIndex,
+ ],
+ );
+
+ return showfileApi;
+}
diff --git a/src/client/editor/spline-editor.tsx b/src/client/editor/spline-editor.tsx
new file mode 100644
index 00000000..0897cbf1
--- /dev/null
+++ b/src/client/editor/spline-editor.tsx
@@ -0,0 +1,186 @@
+import { useMemo, useCallback, Fragment } from "react";
+import {
+ type Coords,
+ SplinePointType,
+ splineToSvgDrawAttribute,
+} from "../../common/spline";
+import { SplinePoint, SplineControlPoint } from "./points";
+import {
+ type TimelineLayerType,
+ timelineLayerToSpline,
+ TimelineEventTypes,
+} from "../../common/show";
+import { GRID_CELL_PX } from "../../common/units";
+
+interface SplineEditorProps {
+ layer: TimelineLayerType;
+ onStartPointMove: (newCoords: Coords) => void;
+ onPointMove: (pointIndex: number, newCoords: Coords) => void;
+ onControlPointMove: (pointIndex: number, newCoords: Coords) => void;
+ onControlPoint2Move: (pointIndex: number, newCoords: Coords) => void;
+ onDeleteStartPoint: () => void;
+ onDeletePoint: (pointIndex: number) => void;
+ onSwitchPointType: (
+ pointIndex: number,
+ newType: SplinePointType.QuadraticBezier | SplinePointType.CubicBezier,
+ ) => void;
+}
+
+export function SplineEditor({
+ layer,
+ onStartPointMove,
+ onPointMove,
+ onControlPointMove,
+ onControlPoint2Move,
+ onDeleteStartPoint,
+ onDeletePoint,
+ onSwitchPointType,
+}: SplineEditorProps) {
+ const { remainingEvents } = layer;
+ const spline = useMemo(() => timelineLayerToSpline(layer), [layer]);
+
+ const path = useMemo(() => splineToSvgDrawAttribute(spline), [spline]);
+
+ const getOriginalEventIndex = useCallback(
+ (goToPointIndex: number): number => {
+ let count = 0;
+ for (let i = 0; i < remainingEvents.length; i++) {
+ if (
+ remainingEvents[i].type ===
+ TimelineEventTypes.GoToPointEvent
+ ) {
+ if (count === goToPointIndex) {
+ return i;
+ }
+ count++;
+ }
+ }
+ return -1;
+ },
+ [remainingEvents],
+ );
+
+ return (
+ <>
+
+
+
+ 0 ? onDeleteStartPoint : undefined
+ }
+ onMove={(x, y) => onStartPointMove({ x, y })}
+ onJumpToPoint={() => {
+ // TODO: i don't like that we're touching elements by ids and mucking w dom outside of react - see if theres a way to refactor this in a more idiomatic react way
+ const el = document.getElementById(
+ `timeline-event-${layer.startPoint.id}`,
+ );
+ navigateAndIdentifyTimelineElement(el as HTMLElement);
+ }}
+ />
+ {spline.points.map((point, index) => (
+
+
+ onDeletePoint(getOriginalEventIndex(index))
+ }
+ onMove={(x, y) =>
+ onPointMove(getOriginalEventIndex(index), { x, y })
+ }
+ onSwitchToQuadratic={() =>
+ onSwitchPointType(
+ getOriginalEventIndex(index),
+ SplinePointType.QuadraticBezier,
+ )
+ }
+ onSwitchToCubic={() =>
+ onSwitchPointType(
+ getOriginalEventIndex(index),
+ SplinePointType.CubicBezier,
+ )
+ }
+ onJumpToPoint={() => {
+ const originalIndex = getOriginalEventIndex(index);
+ const el = document.getElementById(
+ `timeline-event-${layer.remainingEvents[originalIndex].id}`,
+ );
+ if (!el) return;
+ navigateAndIdentifyTimelineElement(el);
+ }}
+ />
+ {/* TODO: add line between control point and end point */}
+ {point.type === SplinePointType.CubicBezier && (
+ <>
+
+ onControlPointMove(
+ getOriginalEventIndex(index),
+ { x, y },
+ )
+ }
+ />
+
+ onControlPoint2Move(
+ getOriginalEventIndex(index),
+ { x, y },
+ )
+ }
+ />
+ >
+ )}
+ {point.type === SplinePointType.QuadraticBezier && (
+
+ onControlPointMove(
+ getOriginalEventIndex(index),
+ { x, y },
+ )
+ }
+ />
+ )}
+
+ ))}
+ >
+ );
+}
+
+function navigateAndIdentifyTimelineElement(element: HTMLElement) {
+ element.scrollIntoView({
+ inline: "start",
+ block: "end",
+ });
+ const originalBackgroundColor = element.style.backgroundColor;
+ const flashColor = "black";
+ element.style.backgroundColor = flashColor;
+ setTimeout(() => {
+ element.style.backgroundColor = originalBackgroundColor;
+ }, 100);
+ setTimeout(() => {
+ element.style.backgroundColor = flashColor;
+ }, 200);
+ setTimeout(() => {
+ element.style.backgroundColor = originalBackgroundColor;
+ }, 300);
+ setTimeout(() => {
+ element.style.backgroundColor = flashColor;
+ }, 400);
+ setTimeout(() => {
+ element.style.backgroundColor = originalBackgroundColor;
+ }, 500);
+}
diff --git a/src/client/editor/timeline.tsx b/src/client/editor/timeline.tsx
new file mode 100644
index 00000000..1eaa9b8e
--- /dev/null
+++ b/src/client/editor/timeline.tsx
@@ -0,0 +1,286 @@
+import {
+ Card,
+ SectionCard,
+ Button,
+ Text,
+ ContextMenu,
+ Menu,
+ MenuItem,
+} from "@blueprintjs/core";
+import interact from "interactjs";
+import { useDragControls, Reorder } from "motion/react";
+import type { PropsWithChildren } from "react";
+import { forwardRef, useEffect, useRef } from "react";
+import type { TimelineEvents } from "../../common/show";
+import {
+ EVENT_TYPE_TO_COLOR,
+ millisToPixels,
+ RULER_TICK_GAP_PX,
+ RULER_TICK_INTERVAL_MS,
+ RULER_EXTRA_TICK_COUNT,
+ pixelsToMillis,
+} from "../../common/show-interface-utils";
+
+export function ReorderableTimelineEvent({
+ event,
+ onDurationChange,
+ onDelete,
+ onAddWaitEvent,
+ onAddTurnEvent,
+ onSelect,
+ selected,
+}: PropsWithChildren<{
+ event: TimelineEvents;
+ onDurationChange: (ms: number) => void;
+ onDelete?: () => void;
+ onAddWaitEvent?: (position: "before" | "after") => void;
+ onAddTurnEvent?: (position: "before" | "after") => void;
+ onSelect: () => void;
+ selected: boolean;
+}>) {
+ const controls = useDragControls();
+
+ return (
+
+ controls.start(e)}
+ onDurationChange={onDurationChange}
+ onDelete={onDelete}
+ onAddWaitEvent={onAddWaitEvent}
+ onAddTurnEvent={onAddTurnEvent}
+ onSelect={onSelect}
+ selected={selected}
+ />
+
+ );
+}
+
+export function TimelineEvent(props: {
+ event: TimelineEvents;
+ onPointerDownOnDragHandle?: (
+ event: React.PointerEvent,
+ ) => void;
+ onDurationChange?: (deltaMs: number) => void;
+ onDelete?: () => void;
+ onAddWaitEvent?: (position: "before" | "after") => void;
+ onAddTurnEvent?: (position: "before" | "after") => void;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const ref = useRef(null);
+ const {
+ event,
+ onPointerDownOnDragHandle,
+ onDurationChange,
+ onDelete,
+ onAddWaitEvent,
+ onAddTurnEvent,
+ onSelect,
+ selected,
+ } = props;
+ useEffect(() => {
+ if (!onDurationChange) return;
+ if (!ref.current) return;
+ const el = ref.current;
+
+ interact(el).resizable({
+ edges: {
+ right: true,
+ },
+ listeners: {
+ move: function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ onDurationChange(
+ Math.floor(pixelsToMillis(event.deltaRect.width)),
+ );
+ },
+ },
+ });
+ }, [event.type, onDurationChange]);
+
+ return (
+
+ {onDelete && (
+
+ )}
+ {onAddWaitEvent && (
+ <>
+ onAddWaitEvent("after")}
+ />
+ onAddWaitEvent("before")}
+ />
+ >
+ )}
+ {onAddTurnEvent && (
+ <>
+ onAddTurnEvent("after")}
+ />
+ onAddTurnEvent("before")}
+ />
+ >
+ )}
+
+ }
+ >
+
+
+ {event.type}
+
+ {onPointerDownOnDragHandle && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+export const TimelineLayer = forwardRef<
+ HTMLDivElement,
+ PropsWithChildren<{
+ title: string;
+ onDelete?: () => void;
+ onActive?: () => void;
+ active?: boolean;
+ }>
+>(function TimelineLayer({ title, children, onDelete, onActive, active }, ref) {
+ // TODO: add borders between columns. v low priority
+ // https://codepen.io/Kevin-Geary/pen/BavwqYX
+ // https://www.youtube.com/watch?v=EQYft7JPKto
+ return (
+
+
+
+ {title}{" "}
+ {onActive && (
+
+ )}
+
+ {onDelete && (
+
+ )}
+
+
+ {children}
+
+ );
+});
+
+export function Ruler({ sequenceLengthMs }: { sequenceLengthMs: number }) {
+ return (
+
+
+ {new Array(
+ Math.round(sequenceLengthMs / RULER_TICK_INTERVAL_MS) +
+ RULER_EXTRA_TICK_COUNT,
+ )
+ .fill(1)
+ .map((_, i) => {
+ // 1000ms interval (every 4 ticks if RULER_TICK_INTERVAL_MS is 250)
+ const isMajorTick =
+ i % (1000 / RULER_TICK_INTERVAL_MS) === 0;
+
+ // 500ms interval
+ const isSecondaryTick =
+ i % (1000 / RULER_TICK_INTERVAL_MS) === 5;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/client/package.json b/src/client/package.json
index ff946077..06963ff4 100644
--- a/src/client/package.json
+++ b/src/client/package.json
@@ -6,6 +6,11 @@
"@blueprintjs/icons": "^5.20.0",
"@blueprintjs/select": "^5.3.18",
"@tanstack/react-query": "^5.29.0",
+ "browser-fs-access": "^0.35.0",
+ "cbor-x": "^1.6.0",
+ "deep-object-diff": "^1.1.9",
+ "interactjs": "^1.10.27",
+ "motion": "^12.6.0",
"react": "^18.2.0",
"react-chessboard": "^4.5.0",
"react-dom": "^18.2.0",
diff --git a/src/client/router.tsx b/src/client/router.tsx
index 7571293d..ce38aa04 100644
--- a/src/client/router.tsx
+++ b/src/client/router.tsx
@@ -6,6 +6,7 @@ import { Lobby } from "./setup/lobby";
import { Home } from "./home";
import { Debug2 } from "./debug/debug2";
import { Simulator } from "./debug/simulator";
+import { Editor } from "./editor/editor";
export const router = createBrowserRouter([
{
@@ -36,4 +37,8 @@ export const router = createBrowserRouter([
path: "/game",
element: ,
},
+ {
+ path: "/editor",
+ element: ,
+ },
]);
diff --git a/src/common/getRobotStateAtTime.ts b/src/common/getRobotStateAtTime.ts
new file mode 100644
index 00000000..7aa6b04e
--- /dev/null
+++ b/src/common/getRobotStateAtTime.ts
@@ -0,0 +1,246 @@
+import { type TimelineLayerType, TimelineEventTypes } from "./show";
+import { GRID_CELL_PX } from "./units";
+import {
+ type Coords,
+ SplinePointType,
+ evaluateQuadraticBezier,
+ evaluateCubicBezier,
+ derivativeQuadraticBezier,
+ derivativeCubicBezier,
+ reflectPoint,
+} from "./spline";
+
+export interface RobotState {
+ position: Coords;
+ headingRadians: number;
+}
+
+/**
+ * Calculates the robot's position and heading at a specific timestamp within its timeline layer.
+ *
+ * @param layer - The timeline layer for the robot.
+ * @param timestampMs - The timestamp in milliseconds.
+ * @returns The robot's state (position and heading) at the given time.
+ */
+export function getRobotStateAtTime(
+ layer: TimelineLayerType,
+ timestampMs: number,
+): RobotState {
+ let currentTimeMs = 0;
+ let previousPoint: Coords = {
+ x: layer.startPoint.target.point.x * GRID_CELL_PX,
+ y: layer.startPoint.target.point.y * GRID_CELL_PX,
+ };
+ let previousHeadingRad = 0; // Default initial heading
+ let previousMidpointType: SplinePointType | null = null; // Track type of previous segment
+ let previousActualCP1: Coords | null = null;
+ let previousActualCP2: Coords | null = null; // Only relevant if previous was Cubic
+
+ const allEvents = [layer.startPoint, ...layer.remainingEvents]; // Combine for easier iteration
+
+ for (let i = 0; i < allEvents.length; i++) {
+ const currentEvent = allEvents[i];
+ const eventStartTimeMs = currentTimeMs;
+ const eventEndTimeMs = currentTimeMs + currentEvent.durationMs;
+
+ if (timestampMs >= eventStartTimeMs && timestampMs < eventEndTimeMs) {
+ // Timestamp falls within this event
+ const timeIntoEvent = timestampMs - eventStartTimeMs;
+ const progress =
+ currentEvent.durationMs > 0 ?
+ timeIntoEvent / currentEvent.durationMs
+ : 1; // Avoid division by zero
+
+ if (currentEvent.type === TimelineEventTypes.StartPointEvent) {
+ // Robot waits at the start point during its initial duration
+ return {
+ position: previousPoint,
+ headingRadians: previousHeadingRad,
+ };
+ }
+
+ if (currentEvent.type === TimelineEventTypes.WaitEvent) {
+ // Robot waits at the end of the previous movement
+ return {
+ position: previousPoint,
+ headingRadians: previousHeadingRad,
+ };
+ }
+
+ if (currentEvent.type === TimelineEventTypes.GoToPointEvent) {
+ const target = currentEvent.target;
+ const startPos = previousPoint;
+ const endPos = {
+ x: target.endPoint.x * GRID_CELL_PX,
+ y: target.endPoint.y * GRID_CELL_PX,
+ };
+ let p1: Coords;
+ let derivativeFunc: (t: number) => Coords;
+ let evaluateFunc: (t: number) => Coords;
+
+ if (target.type === SplinePointType.QuadraticBezier) {
+ if (
+ previousMidpointType ===
+ SplinePointType.QuadraticBezier &&
+ previousActualCP1
+ ) {
+ p1 = reflectPoint(previousActualCP1, startPos);
+ } else if (
+ previousMidpointType === SplinePointType.CubicBezier &&
+ previousActualCP2
+ ) {
+ p1 = reflectPoint(previousActualCP2, startPos);
+ } else {
+ p1 = startPos;
+ }
+ evaluateFunc = (t) =>
+ evaluateQuadraticBezier(startPos, p1, endPos, t);
+ derivativeFunc = (t) =>
+ derivativeQuadraticBezier(startPos, p1, endPos, t);
+ } else {
+ // Cubic Bezier
+ const controlPoint2 = {
+ x: target.controlPoint.x * GRID_CELL_PX,
+ y: target.controlPoint.y * GRID_CELL_PX,
+ };
+ if (
+ previousMidpointType === SplinePointType.CubicBezier &&
+ previousActualCP2
+ ) {
+ p1 = reflectPoint(previousActualCP2, startPos);
+ } else {
+ p1 = startPos;
+ }
+ evaluateFunc = (t) =>
+ evaluateCubicBezier(
+ startPos,
+ p1,
+ controlPoint2,
+ endPos,
+ t,
+ );
+ derivativeFunc = (t) =>
+ derivativeCubicBezier(
+ startPos,
+ p1,
+ controlPoint2,
+ endPos,
+ t,
+ );
+ }
+
+ const currentPosition = evaluateFunc(progress);
+ const derivative = derivativeFunc(progress);
+ const currentHeading = Math.atan2(derivative.y, derivative.x);
+
+ return {
+ position: currentPosition,
+ headingRadians:
+ isNaN(currentHeading) ? previousHeadingRad : (
+ currentHeading
+ ),
+ };
+ }
+
+ if (currentEvent.type === TimelineEventTypes.TurnEvent) {
+ const radians = currentEvent.radians;
+ const currentHeading = previousHeadingRad + radians * progress;
+
+ return {
+ position: previousPoint,
+ headingRadians: currentHeading,
+ };
+ }
+ }
+
+ // Update state for the next iteration
+ currentTimeMs = eventEndTimeMs;
+ if (currentEvent.type === TimelineEventTypes.GoToPointEvent) {
+ previousPoint = {
+ x: currentEvent.target.endPoint.x * GRID_CELL_PX,
+ y: currentEvent.target.endPoint.y * GRID_CELL_PX,
+ };
+ previousMidpointType = currentEvent.target.type;
+
+ let final_p1: Coords | null = null;
+ let final_p2: Coords | null = null;
+ let derivativeFunc_final: (t: number) => Coords;
+ const target = currentEvent.target;
+
+ const _startPos_deriv = previousPoint;
+ const endPos = {
+ x: target.endPoint.x * GRID_CELL_PX,
+ y: target.endPoint.y * GRID_CELL_PX,
+ };
+
+ if (target.type === SplinePointType.QuadraticBezier) {
+ if (
+ previousMidpointType === SplinePointType.QuadraticBezier &&
+ previousActualCP1
+ ) {
+ final_p1 = reflectPoint(previousActualCP1, _startPos_deriv);
+ } else if (
+ previousMidpointType === SplinePointType.CubicBezier &&
+ previousActualCP2
+ ) {
+ final_p1 = reflectPoint(previousActualCP2, _startPos_deriv);
+ } else {
+ final_p1 = _startPos_deriv;
+ }
+ previousActualCP1 = final_p1;
+ previousActualCP2 = null;
+ derivativeFunc_final = (t) =>
+ derivativeQuadraticBezier(
+ _startPos_deriv,
+ final_p1!,
+ endPos,
+ t,
+ );
+ } else {
+ const controlPoint2 = {
+ x: target.controlPoint.x * GRID_CELL_PX,
+ y: target.controlPoint.y * GRID_CELL_PX,
+ };
+ if (
+ previousMidpointType === SplinePointType.CubicBezier &&
+ previousActualCP2
+ ) {
+ final_p1 = reflectPoint(previousActualCP2, _startPos_deriv);
+ } else {
+ final_p1 = _startPos_deriv;
+ }
+ final_p2 = controlPoint2;
+ previousActualCP1 = final_p1;
+ previousActualCP2 = final_p2;
+ derivativeFunc_final = (t) =>
+ derivativeCubicBezier(
+ _startPos_deriv,
+ final_p1!,
+ final_p2!,
+ endPos,
+ t,
+ );
+ }
+ const finalDerivative = derivativeFunc_final(1);
+ const finalHeading = Math.atan2(
+ finalDerivative.y,
+ finalDerivative.x,
+ );
+ previousHeadingRad =
+ isNaN(finalHeading) ? previousHeadingRad : finalHeading;
+ } else {
+ if (currentEvent.type === TimelineEventTypes.StartPointEvent) {
+ previousPoint = {
+ x: currentEvent.target.point.x,
+ y: currentEvent.target.point.y,
+ };
+ }
+ previousMidpointType = null;
+ previousActualCP1 = null;
+ previousActualCP2 = null;
+ }
+ }
+
+ // If timestamp is beyond the last event, stay at the final position and heading
+ return { position: previousPoint, headingRadians: previousHeadingRad };
+}
diff --git a/src/common/show-interface-utils.ts b/src/common/show-interface-utils.ts
new file mode 100644
index 00000000..56f1b55e
--- /dev/null
+++ b/src/common/show-interface-utils.ts
@@ -0,0 +1,63 @@
+import { Colors } from "@blueprintjs/colors";
+import { TimelineEventTypes } from "./show";
+
+export const EVENT_TYPE_TO_COLOR: Record<
+ (typeof TimelineEventTypes)[keyof typeof TimelineEventTypes],
+ (typeof Colors)[keyof typeof Colors]
+> = {
+ [TimelineEventTypes.GoToPointEvent]: Colors.BLUE2,
+ [TimelineEventTypes.WaitEvent]: Colors.GRAY2,
+ [TimelineEventTypes.StartPointEvent]: Colors.GREEN2,
+ [TimelineEventTypes.TurnEvent]: Colors.ORANGE2,
+};
+/*
+ * The timeline duration update mode determines how the duration of timeline events
+ * is updated when the user edits a timeline event's duration.
+ *
+ * Being in ripple edit mode means that editing the duration of an event has a
+ * ripple effect on ALL the other events in the same layer, shifting
+ * all the subsequent event start times by the same amount (so only
+ * one event's duration is actually changing).
+ *
+ * Being in rolling edit mode mean that editing the duration of an event also affects the
+ * duration of the event that immediately follows it in the same layer, such
+ * that adjusting the duration of this event doesn't shift the start timestamp
+ * of the subsequent events in the same layer.
+ */
+
+export const TimelineDurationUpdateMode = {
+ Rolling: "rolling",
+ Ripple: "ripple",
+} as const;
+/*
+ * The grid cursor mode determines the behavior of the cursor when on the grid.
+ *
+ * Being in cursor mode means that the cursor is a normal cursor, and the user
+ * can click and drag points that already exist on the grid. The user is not able
+ * to add new points to the grid.
+ *
+ * Being in pen mode means that the user is able to add new points to the grid
+ * by clicking on the locations where they want to add points. The user is still
+ * able to drag existing points on the grid as in cursor mode.
+ */
+
+export const GridCursorMode = {
+ Cursor: "cursor",
+ Pen: "pen",
+} as const;
+
+export const RULER_TICK_INTERVAL_MS = 100;
+// TODO: make ruler tick size configurable so we can zoom. relatively low priority. would be nice if gestures could be supported too
+export const RULER_TICK_GAP_PX = 12;
+
+export const RULER_EXTRA_TICK_COUNT = Math.round(
+ window.innerWidth / 4 / RULER_TICK_GAP_PX,
+);
+
+export function millisToPixels(millis: number): number {
+ return (millis / RULER_TICK_INTERVAL_MS) * RULER_TICK_GAP_PX;
+}
+
+export function pixelsToMillis(pixels: number): number {
+ return (pixels / RULER_TICK_GAP_PX) * RULER_TICK_INTERVAL_MS;
+}
diff --git a/src/common/show.ts b/src/common/show.ts
index f3f37c7b..7f8cc605 100644
--- a/src/common/show.ts
+++ b/src/common/show.ts
@@ -1,3 +1,6 @@
+// @ts-expect-error: chessbots client is a CommonJS module, but this library is a ES Module, so we need to tell TypeScript that it's okay
+import { decode as cborDecode } from "cbor-x";
+
import {
Number,
String,
@@ -15,16 +18,17 @@ import {
SplinePointType,
StartPointSchema,
} from "./spline";
-import { Colors } from "@blueprintjs/core";
+import { Uint32 } from "../server/utils/tcp-packet";
export const TimelineEventTypes = {
GoToPointEvent: "goto_point",
WaitEvent: "wait",
StartPointEvent: "start_point",
+ TurnEvent: "turn",
} as const;
export const GoToPointEventSchema = RuntypesRecord({
- durationMs: Number,
+ durationMs: Uint32,
target: MidpointSchema,
type: Literal(TimelineEventTypes.GoToPointEvent),
id: String,
@@ -32,7 +36,7 @@ export const GoToPointEventSchema = RuntypesRecord({
export type GoToPointEvent = Static;
const WaitEventSchema = RuntypesRecord({
- durationMs: Number,
+ durationMs: Uint32,
type: Literal(TimelineEventTypes.WaitEvent),
id: String,
});
@@ -41,20 +45,36 @@ export type WaitEvent = Static;
const StartPointEventSchema = RuntypesRecord({
type: Literal(TimelineEventTypes.StartPointEvent),
target: StartPointSchema,
- durationMs: Number,
+ durationMs: Uint32,
id: String,
});
export type StartPointEvent = Static;
-const NonStartPointEventSchema = Union(GoToPointEventSchema, WaitEventSchema);
+const TurnEventSchema = RuntypesRecord({
+ type: Literal(TimelineEventTypes.TurnEvent),
+ radians: Number,
+ durationMs: Uint32,
+ id: String,
+});
+export type TurnEvent = Static;
+
+export const NonStartPointEventSchema = Union(
+ GoToPointEventSchema,
+ WaitEventSchema,
+ TurnEventSchema,
+);
export type NonStartPointEvent = Static;
const TimelineLayerSchema = RuntypesRecord({
startPoint: StartPointEventSchema,
remainingEvents: Array(NonStartPointEventSchema),
});
-export type TimelineLayer = Static;
-export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent;
+export type TimelineLayerType = Static;
+export type TimelineEvents =
+ | GoToPointEvent
+ | WaitEvent
+ | StartPointEvent
+ | TurnEvent;
/**
* The showfile schema.
@@ -66,7 +86,7 @@ export type TimelineEvents = GoToPointEvent | WaitEvent | StartPointEvent;
*/
export const ShowfileSchema = RuntypesRecord({
// Be sure to increment the schema version number when making breaking changes to the showfile schema.
- $chessbots_show_schema_version: Literal(3),
+ $chessbots_show_schema_version: Literal(4),
// The timeline is an array of timeline 'layers'. A layer consists of an array that includes all the events for one robot.
timeline: Array(TimelineLayerSchema),
audio: Optional(
@@ -84,7 +104,7 @@ export type Showfile = Static;
* @param layer - the timeline layer to convert.
* @returns - the spline representation of the timeline layer.
*/
-export function timelineLayerToSpline(layer: TimelineLayer): Spline {
+export function timelineLayerToSpline(layer: TimelineLayerType): Spline {
const { startPoint, remainingEvents } = layer;
return {
start: startPoint.target,
@@ -103,7 +123,7 @@ export function timelineLayerToSpline(layer: TimelineLayer): Spline {
*/
export function createNewShowfile(): Showfile {
return {
- $chessbots_show_schema_version: 3,
+ $chessbots_show_schema_version: 4,
timeline: [
{
startPoint: {
@@ -111,49 +131,24 @@ export function createNewShowfile(): Showfile {
target: {
type: SplinePointType.StartPoint,
point: {
- x: 0,
- y: 70,
+ x: 2,
+ y: 2,
},
},
- durationMs: 7500,
- id: "4f21401d-07cf-434f-a73c-6482ab82f210",
+ durationMs: 3000,
+ id: crypto.randomUUID(),
},
remainingEvents: [
- {
- type: TimelineEventTypes.GoToPointEvent,
- durationMs: 1000,
- target: {
- type: SplinePointType.QuadraticBezier,
- endPoint: { x: 100, y: 100 },
- },
- id: "4f21401d-07cf-434f-a73c-6482ab82f211",
- },
- {
- type: TimelineEventTypes.WaitEvent,
- durationMs: 5000,
- id: "4f21401d-07cf-434f-a73c-6482ab82f212",
- },
{
type: TimelineEventTypes.GoToPointEvent,
durationMs: 1000,
target: {
type: SplinePointType.CubicBezier,
- endPoint: { x: 315, y: 50 },
- controlPoint: {
- x: 300,
- y: 40,
- },
+ endPoint: { x: 3, y: 3 },
+ controlPoint: { x: 2.5, y: 1 },
+ controlPoint2: { x: 2.5, y: 3 },
},
- id: "4f21401d-07cf-434f-a73c-6482ab82f213",
- },
- {
- type: TimelineEventTypes.GoToPointEvent,
- durationMs: 1000,
- target: {
- type: SplinePointType.QuadraticBezier,
- endPoint: { x: 70, y: 70 },
- },
- id: "4f21401d-07cf-434f-a73c-6482ab82f214",
+ id: crypto.randomUUID(),
},
],
},
@@ -162,30 +157,23 @@ export function createNewShowfile(): Showfile {
};
}
-export const EVENT_TYPE_TO_COLOR: Record<
- (typeof TimelineEventTypes)[keyof typeof TimelineEventTypes],
- (typeof Colors)[keyof typeof Colors]
-> = {
- [TimelineEventTypes.GoToPointEvent]: Colors.BLUE2,
- [TimelineEventTypes.WaitEvent]: Colors.GRAY2,
- [TimelineEventTypes.StartPointEvent]: Colors.GREEN2,
-};
+export const CHESSBOTS_SHOWFILE_MIME_TYPE = "application/chessbots-showfile";
+export const CHESSBOTS_SHOWFILE_EXTENSION = ".cbor";
-/**
- * The timeline duration update mode determines how the duration of timeline events
- * is updated when the user edits a timeline event's duration.
- *
- * Being in ripple edit mode means that editing the duration of an event has a
- * ripple effect on ALL the other events in the same layer, shifting
- * all the subsequent event start times by the same amount (so only
- * one event's duration is actually changing).
- *
- * Being in rolling edit mode mean that editing the duration of an event also affects the
- * duration of the event that immediately follows it in the same layer, such
- * that adjusting the duration of this event doesn't shift the start timestamp
- * of the subsequent events in the same layer.
- */
-export const TimelineDurationUpdateMode = {
- Rolling: "rolling",
- Ripple: "ripple",
-} as const;
+export const loadShowfileFromBinary = (binary: Buffer | Uint8Array) => {
+ let decodedCborData: unknown | null = null;
+
+ try {
+ decodedCborData = cborDecode(binary);
+ } catch (e) {
+ return null;
+ }
+
+ const result = ShowfileSchema.validate(decodedCborData);
+
+ if (result.success) {
+ return result.value;
+ }
+
+ return null;
+};
diff --git a/src/common/spline.ts b/src/common/spline.ts
index 5f0ae474..9f95f267 100644
--- a/src/common/spline.ts
+++ b/src/common/spline.ts
@@ -22,6 +22,7 @@ export type StartPointSchema = Static;
export const CubicBezierSchema = Record({
type: Literal(SplinePointType.CubicBezier),
controlPoint: CoordsSchema,
+ controlPoint2: CoordsSchema,
endPoint: CoordsSchema,
});
export type CubicBezier = Static;
@@ -29,6 +30,7 @@ export type CubicBezier = Static;
export const QuadraticBezierSchema = Record({
type: Literal(SplinePointType.QuadraticBezier),
endPoint: CoordsSchema,
+ controlPoint: CoordsSchema,
});
export type QuadraticBezier = Static;
@@ -61,9 +63,9 @@ function pointToSvgPathCommand(point: Point): string {
case SplinePointType.StartPoint:
return `M${point.point.x},${point.point.y}`;
case SplinePointType.CubicBezier:
- return `S${point.controlPoint.x},${point.controlPoint.y} ${point.endPoint.x},${point.endPoint.y}`;
+ return `C${point.controlPoint.x},${point.controlPoint.y} ${point.controlPoint2.x},${point.controlPoint2.y} ${point.endPoint.x},${point.endPoint.y}`;
case SplinePointType.QuadraticBezier:
- return `T${point.endPoint.x},${point.endPoint.y}`;
+ return `Q${point.controlPoint.x},${point.controlPoint.y} ${point.endPoint.x},${point.endPoint.y}`;
}
}
@@ -85,3 +87,78 @@ export function splineToSvgDrawAttribute(spline: Spline): string {
}
return path;
}
+
+export function evaluateQuadraticBezier(
+ p0: Coords,
+ p1: Coords,
+ p2: Coords,
+ t: number,
+): Coords {
+ const mt = 1 - t;
+ const x = mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x;
+ const y = mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y;
+ return { x, y };
+}
+
+export function evaluateCubicBezier(
+ p0: Coords,
+ p1: Coords,
+ p2: Coords,
+ p3: Coords,
+ t: number,
+): Coords {
+ const mt = 1 - t;
+ const mt2 = mt * mt;
+ const t2 = t * t;
+ const x =
+ mt2 * mt * p0.x +
+ 3 * mt2 * t * p1.x +
+ 3 * mt * t2 * p2.x +
+ t2 * t * p3.x;
+ const y =
+ mt2 * mt * p0.y +
+ 3 * mt2 * t * p1.y +
+ 3 * mt * t2 * p2.y +
+ t2 * t * p3.y;
+ return { x, y };
+}
+
+export function derivativeQuadraticBezier(
+ p0: Coords,
+ p1: Coords,
+ p2: Coords,
+ t: number,
+): Coords {
+ // Derivative: 2(1-t)(p1-p0) + 2t(p2-p1)
+ const x = 2 * (1 - t) * (p1.x - p0.x) + 2 * t * (p2.x - p1.x);
+ const y = 2 * (1 - t) * (p1.y - p0.y) + 2 * t * (p2.y - p1.y);
+ return { x, y };
+}
+
+export function derivativeCubicBezier(
+ p0: Coords,
+ p1: Coords,
+ p2: Coords,
+ p3: Coords,
+ t: number,
+): Coords {
+ // Derivative: 3(1-t)^2(p1-p0) + 6(1-t)t(p2-p1) + 3t^2(p3-p2)
+ const mt = 1 - t;
+ const x =
+ 3 * mt * mt * (p1.x - p0.x) +
+ 6 * mt * t * (p2.x - p1.x) +
+ 3 * t * t * (p3.x - p2.x);
+ const y =
+ 3 * mt * mt * (p1.y - p0.y) +
+ 6 * mt * t * (p2.y - p1.y) +
+ 3 * t * t * (p3.y - p2.y);
+ return { x, y };
+}
+
+/** Reflect point p over pivot */
+export function reflectPoint(p: Coords, pivot: Coords): Coords {
+ return {
+ x: pivot.x + (pivot.x - p.x),
+ y: pivot.y + (pivot.y - p.y),
+ };
+}
diff --git a/src/common/units.ts b/src/common/units.ts
index 5be89ab4..9a7db6ff 100644
--- a/src/common/units.ts
+++ b/src/common/units.ts
@@ -19,3 +19,9 @@ export function clampHeading(radians: number): number {
// Final mod results in [0, 360)
return ((radians % FULL_ROTATION) + FULL_ROTATION) % FULL_ROTATION;
}
+
+/**
+ * Size of one grid cell in pixels for UI/editor rendering.
+ * Used to convert between board grid coordinates and pixel coordinates.
+ */
+export const GRID_CELL_PX = 60;
diff --git a/src/server/api/api.ts b/src/server/api/api.ts
index 8a1bc47b..ef382121 100644
--- a/src/server/api/api.ts
+++ b/src/server/api/api.ts
@@ -37,14 +37,27 @@ import { VirtualBotTunnel, VirtualRobot } from "../simulator";
import { Position } from "../robot/position";
import { DEGREE } from "../../common/units";
import { PacketType } from "../utils/tcp-packet";
+import { ShowfileSchema, TimelineEventTypes } from "../../common/show";
+import { SplinePointType } from "../../common/spline";
+import type { Command } from "../command/command";
+import {
+ SequentialCommandGroup,
+ ParallelCommandGroup,
+} from "../command/command";
+import {
+ DriveQuadraticSplineCommand,
+ DriveCubicSplineCommand,
+ SpinRadiansCommand,
+} from "../command/move-command";
import { GridIndices } from "../robot/grid-indices";
-import puzzles, { type PuzzleComponents } from "./puzzles";
import {
- moveAllRobotsToDefaultPositions,
moveAllRobotsHomeToDefaultOptimized,
+ moveAllRobotsToDefaultPositions,
} from "../robot/path-materializer";
-import { robotManager } from "../robot/robot-manager";
+import type { PuzzleComponents } from "./puzzles";
+import puzzles from "./puzzles";
import { tcpServer } from "./tcp-interface";
+import { robotManager } from "../robot/robot-manager";
import { executor } from "../command/executor";
/**
@@ -342,6 +355,157 @@ apiRouter.get("/do-smth", async (_, res) => {
res.send({ message: "success" });
});
+apiRouter.get("/do-parallel", async (_, res) => {
+ console.log("Starting parallel command group");
+ const robotsEntries = Array.from(robotManager.idsToRobots.entries());
+ console.log(robotsEntries);
+ const commands: Command[] = [];
+ for (const [, robot] of robotsEntries) {
+ console.log("Moving robot " + robot.id);
+ // await robot.sendDrivePacket(1);
+ commands.push(
+ new SequentialCommandGroup([
+ new DriveQuadraticSplineCommand(
+ robot.id,
+ { x: 0, y: 0 },
+ { x: 3, y: 3 },
+ { x: 0, y: 3 },
+ 4000,
+ ),
+ new DriveQuadraticSplineCommand(
+ robot.id,
+ { x: 0, y: 0 },
+ { x: 0, y: 0 },
+ { x: 0, y: 0 },
+ 30,
+ ),
+ ]),
+ );
+ }
+ const start = Date.now();
+ console.log("Starting command group");
+ await new ParallelCommandGroup(commands).execute();
+ const time = Date.now() - start;
+ console.log("Finished command group in " + time + "ms");
+
+ res.send({ message: "success", timeMs: time });
+});
+
+apiRouter.post("/do-big", async (req, res) => {
+ console.log("Parsing show");
+
+ const validateResult = ShowfileSchema.validate(
+ JSON.parse(req.query.show as string),
+ );
+
+ if (!validateResult.success) {
+ res.status(400).json({ error: "Showfile is invalid" });
+ console.log("Show parsing failed");
+ return;
+ }
+
+ const show = validateResult.value;
+
+ const connectedRobotIds = Array.from(robotManager.idsToRobots.keys());
+
+ if (connectedRobotIds.length < show.timeline.length) {
+ const r = {
+ error: `Not enough robots connected. Got ${connectedRobotIds.length}, expected ${show.timeline.length}`,
+ };
+ res.status(400).json(r);
+ console.log(r);
+ return;
+ }
+
+ const commandGroupsForAllRobots: Command[] = [];
+ for (
+ let timelineLayerIndex = 0;
+ timelineLayerIndex < show.timeline.length;
+ timelineLayerIndex++
+ ) {
+ // TODO: make a way to map robot ids to timeline layers, so we can choose which physical robot to use for each timeline layer
+ const robotId = connectedRobotIds[timelineLayerIndex];
+ const layer = show.timeline[timelineLayerIndex];
+ let start = layer.startPoint.target.point;
+ const sequentialCommandsForCurrentRobot: Command[] = [];
+ for (
+ let eventIndex = 0;
+ eventIndex < layer.remainingEvents.length;
+ eventIndex++
+ ) {
+ const event = layer.remainingEvents[eventIndex];
+ if (event.type === TimelineEventTypes.GoToPointEvent) {
+ if (event.target.type === SplinePointType.QuadraticBezier) {
+ sequentialCommandsForCurrentRobot.push(
+ new DriveQuadraticSplineCommand(
+ robotId,
+ start,
+ event.target.endPoint,
+ event.target.controlPoint,
+ event.durationMs,
+ ),
+ );
+ } else if (event.target.type === SplinePointType.CubicBezier) {
+ sequentialCommandsForCurrentRobot.push(
+ new DriveCubicSplineCommand(
+ robotId,
+ start,
+ event.target.endPoint,
+ event.target.controlPoint,
+ event.target.controlPoint2,
+ event.durationMs,
+ ),
+ );
+ }
+ start = event.target.endPoint;
+ } else if (event.type === TimelineEventTypes.WaitEvent) {
+ sequentialCommandsForCurrentRobot.push(
+ new DriveQuadraticSplineCommand(
+ robotId,
+ start,
+ start,
+ start,
+ event.durationMs,
+ ),
+ );
+ } else if (event.type === TimelineEventTypes.TurnEvent) {
+ sequentialCommandsForCurrentRobot.push(
+ new SpinRadiansCommand(
+ robotId,
+ event.radians,
+ event.durationMs,
+ ),
+ );
+ }
+ }
+ if (sequentialCommandsForCurrentRobot.length === 0) {
+ console.warn("No commands found for robot " + robotId);
+ continue;
+ }
+
+ // adding this command which tells the robot to start and stop moving at the same place as a scuffed stop command.
+ // otherwise the robot will keep moving at the speed of the last command it was given.
+ sequentialCommandsForCurrentRobot.push(
+ new DriveQuadraticSplineCommand(
+ connectedRobotIds[timelineLayerIndex],
+ start,
+ start,
+ start,
+ 30,
+ ),
+ );
+ commandGroupsForAllRobots.push(
+ new SequentialCommandGroup(sequentialCommandsForCurrentRobot),
+ );
+ }
+ const start = Date.now();
+ console.log("Executing commands");
+ await new ParallelCommandGroup(commandGroupsForAllRobots).execute();
+ const timeMs = Date.now() - start;
+ console.log("Command execution completed", { timeMs });
+ res.send({ message: "success", timeMs });
+});
+
/**
* get the current state of the virtual robots for the simulator
*/
diff --git a/src/server/api/bot-server-config.json b/src/server/api/bot-server-config.json
index c943b7f2..01624aef 100644
--- a/src/server/api/bot-server-config.json
+++ b/src/server/api/bot-server-config.json
@@ -1,7 +1,7 @@
{
"tcpServerPort": 3001,
"bots": {
- "80:65:99:4d:f3:f0": "robot-15",
+ "80:65:99:4d:f3:e8": "robot-15",
"c0:4e:30:4b:68:76": "robot-11",
"80:65:99:4d:f3:9a": "robot-3",
"80:65:99:4e:07:b0": "robot-4",
@@ -471,21 +471,6 @@
"name": "WHEEL_DIAMETER_INCHES",
"type": "float",
"default_value": 4.375
- },
- {
- "name": "MOTOR_A_DRIVE_MULTIPLIER",
- "type": "float",
- "default_value": -1.0
- },
- {
- "name": "MOTOR_B_DRIVE_MULTIPLIER",
- "type": "float",
- "default_value": 1.0
- },
- {
- "name": "ENCODER_MULTIPLIER",
- "type": "float",
- "default_value": -12000.0
}
]
}
diff --git a/src/server/api/tcp-interface.ts b/src/server/api/tcp-interface.ts
index f32f5d1b..66f97e67 100644
--- a/src/server/api/tcp-interface.ts
+++ b/src/server/api/tcp-interface.ts
@@ -192,7 +192,7 @@ export class TCPServer {
) {
this.server = net.createServer();
this.server.on("connection", this.handleConnection.bind(this));
- this.server.listen(config["tcpServerPort"], () => {
+ this.server.listen(config["tcpServerPort"], "0.0.0.0", () => {
console.log(
"TCP bot server listening to %j",
this.server.address(),
@@ -210,7 +210,6 @@ export class TCPServer {
private handleConnection(socket: net.Socket) {
const remoteAddress = socket.remoteAddress + ":" + socket.remotePort;
console.log("New client connection from %s", remoteAddress);
-
socket.setNoDelay(true);
// create a new bot tunnel for the connection
@@ -229,6 +228,9 @@ export class TCPServer {
config["bots"][mac] = id;
} else {
id = config["bots"][mac];
+ if (!(id in this.robotManager.idsToRobots)) {
+ this.robotManager.createRobotFromId(id);
+ }
console.log("Found address ID: " + id);
}
tunnel.id = id;
diff --git a/src/server/command/command.ts b/src/server/command/command.ts
index 4923ab99..e955bada 100644
--- a/src/server/command/command.ts
+++ b/src/server/command/command.ts
@@ -20,6 +20,13 @@ export interface Command {
* @param next - The command which is run next.
*/
then(next: Command): SequentialCommandGroup;
+
+ /**
+ * Decorates the command with a "time point" - if the command finishes in less than the
+ * given duration, it will wait until this number of seconds has elapsed before continuing.
+ * @param seconds - The number of seconds that the command will execute for, at minimum.
+ */
+ withTimePoint(seconds: number): Command;
}
/**
@@ -43,6 +50,10 @@ export abstract class CommandBase implements Command {
return new SequentialCommandGroup([this, next]);
}
+ public withTimePoint(seconds: number): Command {
+ return new ParallelCommandGroup([this, new WaitCommand(seconds)]);
+ }
+
public get requirements(): Set {
return this._requirements;
}
@@ -68,6 +79,20 @@ export abstract class RobotCommand extends CommandBase {
}
}
+/**
+ * A command that waits for a given number of seconds.
+ */
+export class WaitCommand extends CommandBase {
+ constructor(public readonly durationSec: number) {
+ super();
+ }
+ public async execute(): Promise {
+ return new Promise((resolve) =>
+ setTimeout(resolve, this.durationSec * 1000),
+ );
+ }
+}
+
/**
* A type of command which groups other commands and runs them together.
*/
diff --git a/src/server/command/move-command.ts b/src/server/command/move-command.ts
index f64c90d9..2aded6f1 100644
--- a/src/server/command/move-command.ts
+++ b/src/server/command/move-command.ts
@@ -84,6 +84,72 @@ export class RotateToStartCommand extends RobotCommand {
}
}
+export class DriveCubicSplineCommand extends RobotCommand {
+ constructor(
+ robotId: string,
+ public startPosition: { x: number; y: number },
+ public endPosition: { x: number; y: number },
+ public controlPositionA: { x: number; y: number },
+ public controlPositionB: { x: number; y: number },
+ public timeDeltaMs: number,
+ ) {
+ super(robotId);
+ }
+
+ public async execute(): Promise {
+ const robot = robotManager.getRobot(this.robotId);
+ return robot.sendDriveCubicPacket(
+ this.startPosition,
+ this.endPosition,
+ this.controlPositionA,
+ this.controlPositionB,
+ this.timeDeltaMs,
+ );
+ }
+}
+
+export class SpinRadiansCommand extends RobotCommand {
+ constructor(
+ robotId: string,
+ public radians: number,
+ public timeDeltaMs: number,
+ ) {
+ super(robotId);
+ }
+ public async execute(): Promise {
+ const robot = robotManager.getRobot(this.robotId);
+ return robot.sendSpinPacket(this.radians, this.timeDeltaMs);
+ }
+}
+
+export class DriveQuadraticSplineCommand extends RobotCommand {
+ constructor(
+ robotId: string,
+ public startPosition: { x: number; y: number },
+ public endPosition: { x: number; y: number },
+ public controlPosition: { x: number; y: number },
+ public timeDeltaMs: number,
+ ) {
+ super(robotId);
+ }
+ public async execute(): Promise {
+ const robot = robotManager.getRobot(this.robotId);
+ return robot.sendDriveQuadraticPacket(
+ this.startPosition,
+ this.endPosition,
+ this.controlPosition,
+ this.timeDeltaMs,
+ );
+ }
+}
+
+export class StopCommand extends RobotCommand {
+ public async execute(): Promise {
+ const robot = robotManager.getRobot(this.robotId);
+ return robot.sendDrivePacket(0);
+ }
+}
+
/**
* Drives a robot for a distance equal to a number of tiles. Distance
* may be negative, indicating the robot drives backwards.
@@ -174,6 +240,27 @@ export class AbsoluteMoveCommand extends MoveCommand {
}
}
+export class DriveTicksCommand
+ extends RobotCommand
+ implements Reversible
+{
+ constructor(
+ robotId: string,
+ public ticksDistance: number,
+ ) {
+ super(robotId);
+ }
+
+ public async execute(): Promise {
+ const robot = robotManager.getRobot(this.robotId);
+ return robot.sendDriveTicksPacket(this.ticksDistance);
+ }
+
+ public reverse(): DriveTicksCommand {
+ return new DriveTicksCommand(this.robotId, -this.ticksDistance);
+ }
+}
+
/**
* Moves a robot to a global location. Implements Reversible through a
* position supplier to return to the previous position.
diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts
index fc6a7b27..7540ec82 100644
--- a/src/server/robot/robot.ts
+++ b/src/server/robot/robot.ts
@@ -1,6 +1,6 @@
import { FULL_ROTATION, RADIAN, clampHeading } from "../../common/units";
import { Position } from "./position";
-import { type GridIndices } from "./grid-indices";
+import type { GridIndices } from "./grid-indices";
import { PacketType } from "../utils/tcp-packet";
import { type BotTunnel } from "../api/bot-tunnel";
@@ -130,4 +130,60 @@ export class Robot {
);
await this.tunnel!.send({ type: PacketType.DRIVE_TILES, tileDistance });
}
+
+ /**
+ * Send a packet to the robot indicating distance to drive, in ticks. Returns a promise that finishes when the
+ * robot finishes the action.
+ *
+ * @param distanceTicks - The distance to drive forward or backwards by, in ticks.
+ */
+ public async sendDriveTicksPacket(distanceTicks: number): Promise {
+ await this.tunnel!.send({
+ type: PacketType.DRIVE_TICKS,
+ tickDistance: distanceTicks,
+ });
+ }
+
+ public async sendDriveCubicPacket(
+ startPosition: { x: number; y: number },
+ endPosition: { x: number; y: number },
+ controlPositionA: { x: number; y: number },
+ controlPositionB: { x: number; y: number },
+ timeDeltaMs: number,
+ ): Promise {
+ await this.tunnel!.send({
+ type: PacketType.DRIVE_CUBIC_SPLINE,
+ startPosition: startPosition,
+ endPosition: endPosition,
+ controlPositionA: controlPositionA,
+ controlPositionB: controlPositionB,
+ timeDeltaMs: timeDeltaMs,
+ });
+ }
+
+ public async sendDriveQuadraticPacket(
+ startPosition: { x: number; y: number },
+ endPosition: { x: number; y: number },
+ controlPosition: { x: number; y: number },
+ timeDeltaMs: number,
+ ): Promise {
+ await this.tunnel!.send({
+ type: PacketType.DRIVE_QUADRATIC_SPLINE,
+ startPosition: startPosition,
+ controlPosition: controlPosition,
+ endPosition: endPosition,
+ timeDeltaMs: timeDeltaMs,
+ });
+ }
+
+ public async sendSpinPacket(
+ radians: number,
+ timeDeltaMs: number,
+ ): Promise {
+ await this.tunnel!.send({
+ type: PacketType.SPIN_RADIANS,
+ radians: radians,
+ timeDeltaMs: timeDeltaMs,
+ });
+ }
}
diff --git a/src/server/utils/tcp-packet.ts b/src/server/utils/tcp-packet.ts
index b751b88c..30228c1f 100644
--- a/src/server/utils/tcp-packet.ts
+++ b/src/server/utils/tcp-packet.ts
@@ -18,10 +18,14 @@ export enum PacketType {
SET_VAR = "SET_VAR",
TURN_BY_ANGLE = "TURN_BY_ANGLE",
DRIVE_TILES = "DRIVE_TILES",
+ DRIVE_TICKS = "DRIVE_TICKS",
ACTION_SUCCESS = "ACTION_SUCCESS",
ACTION_FAIL = "ACTION_FAIL",
DRIVE_TANK = "DRIVE_TANK",
ESTOP = "ESTOP",
+ DRIVE_CUBIC_SPLINE = "DRIVE_CUBIC_SPLINE",
+ DRIVE_QUADRATIC_SPLINE = "DRIVE_QUADRATIC_SPLINE",
+ SPIN_RADIANS = "SPIN_RADIANS",
}
const Float = NumberType.withConstraint((n) => Number.isFinite(n), {
@@ -30,12 +34,17 @@ const Float = NumberType.withConstraint((n) => Number.isFinite(n), {
const Int32 = Float.withConstraint((n) => Number.isSafeInteger(n), {
name: "int32",
});
-const Uint32 = Int32.withConstraint((n) => n >= 0, { name: "uint32" });
+export const Uint32 = Int32.withConstraint((n) => n >= 0, { name: "uint32" });
const VarId = Uint32;
const MotorPower = Float.withConstraint((n) => -1 <= n && n <= 1, {
name: "motor_power",
});
+const Position = Record({
+ x: Float,
+ y: Float,
+});
+
// MUST be kept in sync with chessBotArduino/include/packet.h PacketType
export const SERVER_PROTOCOL_VERSION = 1;
@@ -126,6 +135,12 @@ export const DRIVE_TILES_SCHEMA = Record({
type: Literal(PacketType.DRIVE_TILES),
tileDistance: Float,
});
+
+export const DRIVE_TICKS_SCHEMA = Record({
+ type: Literal(PacketType.DRIVE_TICKS),
+ tickDistance: Int32,
+});
+
/** success message */
export const ACTION_SUCCESS_SCHEMA = Record({
type: Literal(PacketType.ACTION_SUCCESS),
@@ -145,6 +160,30 @@ export const DRIVE_TANK_SCHEMA = Record({
left: MotorPower,
right: MotorPower,
});
+
+export const DRIVE_CUBIC_SPLINE_SCHEMA = Record({
+ type: Literal(PacketType.DRIVE_CUBIC_SPLINE),
+ startPosition: Position,
+ endPosition: Position,
+ controlPositionA: Position,
+ controlPositionB: Position,
+ timeDeltaMs: Uint32,
+});
+
+export const DRIVE_QUADRATIC_SPLINE_SCHEMA = Record({
+ type: Literal(PacketType.DRIVE_QUADRATIC_SPLINE),
+ startPosition: Position,
+ endPosition: Position,
+ controlPosition: Position,
+ timeDeltaMs: Uint32,
+});
+
+export const SPIN_RADIANS_SCHEMA = Record({
+ type: Literal(PacketType.SPIN_RADIANS),
+ radians: Float,
+ timeDeltaMs: Uint32,
+});
+
export const ESTOP_SCHEMA = Record({ type: Literal(PacketType.ESTOP) });
export const Packet = Union(
@@ -157,10 +196,14 @@ export const Packet = Union(
SET_VAR_SCHEMA,
TURN_BY_ANGLE_SCHEMA,
DRIVE_TILES_SCHEMA,
+ DRIVE_TICKS_SCHEMA,
ACTION_SUCCESS_SCHEMA,
ACTION_FAIL_SCHEMA,
DRIVE_TANK_SCHEMA,
ESTOP_SCHEMA,
+ DRIVE_CUBIC_SPLINE_SCHEMA,
+ DRIVE_QUADRATIC_SPLINE_SCHEMA,
+ SPIN_RADIANS_SCHEMA,
);
export type Packet = Static;
diff --git a/yarn.lock b/yarn.lock
index 0a63d216..bec055a1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -262,6 +262,36 @@
classnames "^2.3.1"
tslib "~2.6.2"
+"@cbor-extract/cbor-extract-darwin-arm64@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz#8d65cb861a99622e1b4a268e2d522d2ec6137338"
+ integrity sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==
+
+"@cbor-extract/cbor-extract-darwin-x64@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz#9fbec199c888c5ec485a1839f4fad0485ab6c40a"
+ integrity sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==
+
+"@cbor-extract/cbor-extract-linux-arm64@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz#bf77e0db4a1d2200a5aa072e02210d5043e953ae"
+ integrity sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==
+
+"@cbor-extract/cbor-extract-linux-arm@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz#491335037eb8533ed8e21b139c59f6df04e39709"
+ integrity sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==
+
+"@cbor-extract/cbor-extract-linux-x64@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz#672574485ccd24759bf8fb8eab9dbca517d35b97"
+ integrity sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==
+
+"@cbor-extract/cbor-extract-win32-x64@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz#4b3f07af047f984c082de34b116e765cb9af975f"
+ integrity sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==
+
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz"
@@ -560,6 +590,11 @@
resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz"
integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==
+"@interactjs/types@1.10.27":
+ version "1.10.27"
+ resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.27.tgz#10afd71cef2498e2b5192cf0d46f937d8ceb767f"
+ integrity sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==
+
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
version "0.3.3"
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz"
@@ -1357,6 +1392,11 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
+browser-fs-access@^0.35.0:
+ version "0.35.0"
+ resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.35.0.tgz#1661996659c79d388d93408ca7cb8072510ee8bc"
+ integrity sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==
+
browserslist@^4.22.2:
version "4.23.0"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz"
@@ -1448,6 +1488,27 @@ capital-case@^1.0.4:
tslib "^2.0.3"
upper-case-first "^2.0.2"
+cbor-extract@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/cbor-extract/-/cbor-extract-2.2.0.tgz#cee78e630cbeae3918d1e2e58e0cebaf3a3be840"
+ integrity sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==
+ dependencies:
+ node-gyp-build-optional-packages "5.1.1"
+ optionalDependencies:
+ "@cbor-extract/cbor-extract-darwin-arm64" "2.2.0"
+ "@cbor-extract/cbor-extract-darwin-x64" "2.2.0"
+ "@cbor-extract/cbor-extract-linux-arm" "2.2.0"
+ "@cbor-extract/cbor-extract-linux-arm64" "2.2.0"
+ "@cbor-extract/cbor-extract-linux-x64" "2.2.0"
+ "@cbor-extract/cbor-extract-win32-x64" "2.2.0"
+
+cbor-x@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.6.0.tgz#89c35d2d805efc30e09a28349425cc05d57aacd7"
+ integrity sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==
+ optionalDependencies:
+ cbor-extract "^2.2.0"
+
chai@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05"
@@ -1686,6 +1747,11 @@ deep-is@^0.1.3:
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+deep-object-diff@^1.1.9:
+ version "1.1.9"
+ resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
+ integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
+
define-data-property@^1.0.1, define-data-property@^1.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz"
@@ -1714,6 +1780,11 @@ destroy@1.2.0:
resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+detect-libc@^2.0.1:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8"
+ integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==
+
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz"
@@ -2309,6 +2380,15 @@ forwarded@0.2.0:
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+framer-motion@^12.23.12:
+ version "12.23.12"
+ resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.23.12.tgz#80cf6fd7c111073a0c558e336c85ca36cca80d3d"
+ integrity sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==
+ dependencies:
+ motion-dom "^12.23.12"
+ motion-utils "^12.23.6"
+ tslib "^2.4.0"
+
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
@@ -2607,6 +2687,13 @@ inherits@2, inherits@2.0.4:
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+interactjs@^1.10.27:
+ version "1.10.27"
+ resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.27.tgz#16499aba4987a5ccfdaddca7d1ba7bb1118e14d0"
+ integrity sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==
+ dependencies:
+ "@interactjs/types" "1.10.27"
+
internal-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961"
@@ -3084,6 +3171,26 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+motion-dom@^12.23.12:
+ version "12.23.12"
+ resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.23.12.tgz#87974046e7e61bc4932f36d35e8eab6bb6f3e434"
+ integrity sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==
+ dependencies:
+ motion-utils "^12.23.6"
+
+motion-utils@^12.23.6:
+ version "12.23.6"
+ resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.23.6.tgz#fafef80b4ea85122dd0d6c599a0c63d72881f312"
+ integrity sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==
+
+motion@^12.6.0:
+ version "12.23.12"
+ resolved "https://registry.yarnpkg.com/motion/-/motion-12.23.12.tgz#9f3552636c1e94911eee42e6f85db8f60db83c19"
+ integrity sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==
+ dependencies:
+ framer-motion "^12.23.12"
+ tslib "^2.4.0"
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -3127,6 +3234,13 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
+node-gyp-build-optional-packages@5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz#52b143b9dd77b7669073cbfe39e3f4118bfc603c"
+ integrity sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==
+ dependencies:
+ detect-libc "^2.0.1"
+
node-gyp-build@^4.3.0:
version "4.8.2"
resolved "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz"
@@ -4188,6 +4302,11 @@ tslib@^2.0.0, tslib@^2.0.3, tslib@~2.6.2:
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tslib@^2.4.0:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"