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/package.json b/package.json
index e179fb94..9d2b2d73 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"dependencies": {
"chess.js": "^1.0.0-beta.6",
"eslint-plugin-tsdoc": "^0.2.17",
- "typescript": "^4.9.3",
+ "typescript": "5.5.4",
"vite-express": "^0.11.1"
},
"devDependencies": {
@@ -39,12 +39,12 @@
"nodemon": "^3.0.3",
"prettier": "^3.2.4",
"ts-node": "^10.9.1",
- "typedoc": "^0.23.0",
+ "typedoc": "^0.28.0",
"vite": "^4.5.5",
"vitest": "^3.0.8"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
},
- "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
+ "packageManager": "yarn@1.19.0+sha512.40b88ca23f991e8da44f5ef1d6dedeaceea0cd1fbdc526b9cfb2e67a2d6a60cd528f7ef088816febb910707fa792c86c3b47f4dc89970a57e410a5209ec32b79"
}
diff --git a/src/client/debug/debug.tsx b/src/client/debug/debug.tsx
index eeadd893..46342f4d 100644
--- a/src/client/debug/debug.tsx
+++ b/src/client/debug/debug.tsx
@@ -36,6 +36,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) {
@@ -49,6 +55,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 4c6d628b..6600abda 100644
--- a/src/client/debug/simulator.tsx
+++ b/src/client/debug/simulator.tsx
@@ -1,5 +1,13 @@
import { Card, Button, H1, Tooltip, Collapse } from "@blueprintjs/core";
-import { useEffect, useReducer, useRef, useState } from "react";
+import {
+ CSSProperties,
+ forwardRef,
+ type PropsWithChildren,
+ useEffect,
+ useReducer,
+ useRef,
+ useState,
+} from "react";
import { useNavigate } from "react-router-dom";
import { get, useSocket } from "../api";
import {
@@ -22,9 +30,11 @@ import {
} from "../check-dark-mode";
const tileSize = 60;
-const robotSize = tileSize / 2;
+export const robotSize = tileSize / 2;
const cellCount = 12;
+export type RobotState = { [robotId: string]: SimulatedRobotLocation };
+
/**
* Creates a robot simulator for testing robot commands
*
@@ -34,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 }
| {
@@ -150,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
@@ -336,25 +354,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..df5e0dc0
--- /dev/null
+++ b/src/client/editor/editor.tsx
@@ -0,0 +1,836 @@
+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 {
+ NonStartPointEvent,
+ 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 { Midpoint, SplinePointType } from "../../common/spline";
+import {
+ motion,
+ MotionValue,
+ useTransform,
+ useAnimate,
+ useMotionValueEvent,
+} from "motion/react";
+import { useShowfile } from "./showfile-state";
+import { Reorder } from "motion/react";
+import { getRobotStateAtTime } from "../../common/getRobotStateAtTime";
+import { MotionRobot } from "./motion-robot";
+import { TimelineLayerType } from "../../common/show";
+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..fd00b2d9
--- /dev/null
+++ b/src/client/editor/motion-robot.tsx
@@ -0,0 +1,90 @@
+import { CSSProperties, 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 { 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..1f97fda6
--- /dev/null
+++ b/src/client/editor/points.tsx
@@ -0,0 +1,109 @@
+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 { 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 ?
+ point.point
+ : point.endPoint;
+
+ 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..bd2f1921
--- /dev/null
+++ b/src/client/editor/showfile-state.ts
@@ -0,0 +1,783 @@
+// @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 {
+ createNewShowfile,
+ loadShowfileFromBinary,
+ StartPointEvent,
+ TimelineEventTypes,
+ GoToPointEvent,
+ TimelineLayerType,
+ NonStartPointEvent,
+ CHESSBOTS_SHOWFILE_MIME_TYPE,
+ CHESSBOTS_SHOWFILE_EXTENSION,
+ WaitEvent,
+ TurnEvent,
+} from "../../common/show";
+import {
+ TimelineDurationUpdateMode,
+ GridCursorMode,
+} from "../../common/show-interface-utils";
+import {
+ SplinePointType,
+ CubicBezier,
+ QuadraticBezier,
+ type Coords,
+ type Midpoint,
+} from "../../common/spline";
+import {
+ usePlayHead,
+ usePreventExitWithUnsavedChanges,
+ useStateWithTrackedHistory,
+} from "./hooks";
+
+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: newCoords },
+ };
+ 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: newCoords,
+ },
+ };
+ 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: newCoords,
+ },
+ };
+ 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: newCoords,
+ },
+ };
+ 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.toSpliced(
+ 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.toSpliced(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,
+ y: eventToUpdate.target.endPoint.y - 20,
+ },
+ controlPoint2: {
+ x: eventToUpdate.target.endPoint.x + 20,
+ y: eventToUpdate.target.endPoint.y + 20,
+ },
+ 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,
+ y: eventToUpdate.target.endPoint.y + 20,
+ },
+ };
+ } 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: 70,
+ },
+ },
+ 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.toSpliced(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, y: y - 10 },
+ endPoint: { x, y },
+ },
+ id: crypto.randomUUID(),
+ });
+ break;
+ case SplinePointType.CubicBezier:
+ newEvents.push({
+ type: TimelineEventTypes.GoToPointEvent,
+ durationMs: defaultEventDurationMs,
+ target: {
+ type: SplinePointType.CubicBezier,
+ endPoint: { x, y },
+ controlPoint: { x: x + 10, y: y + 10 },
+ controlPoint2: { x: x - 10, y: y - 10 },
+ },
+ 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.toSpliced(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..975229df
--- /dev/null
+++ b/src/client/editor/spline-editor.tsx
@@ -0,0 +1,184 @@
+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";
+
+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..e5571698
--- /dev/null
+++ b/src/client/editor/timeline.tsx
@@ -0,0 +1,285 @@
+import {
+ Card,
+ SectionCard,
+ Button,
+ Text,
+ ContextMenu,
+ Menu,
+ MenuItem,
+} from "@blueprintjs/core";
+import interact from "interactjs";
+import { useDragControls, Reorder } from "motion/react";
+import { forwardRef, PropsWithChildren, useEffect, useRef } from "react";
+import { 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 b282a82f..247a8ff7 100644
--- a/src/client/router.tsx
+++ b/src/client/router.tsx
@@ -7,6 +7,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([
{
@@ -41,4 +42,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..45460384
--- /dev/null
+++ b/src/common/getRobotStateAtTime.ts
@@ -0,0 +1,224 @@
+import { type TimelineLayerType, TimelineEventTypes } from "./show";
+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 = layer.startPoint.target.point;
+ 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 = target.endPoint;
+ 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 = target.controlPoint;
+ 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 = currentEvent.target.endPoint;
+ 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 = target.endPoint;
+
+ 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 = target.controlPoint;
+ 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 = currentEvent.target.point;
+ }
+ 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 eb6d8c8c..4e59c5af 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,27 +131,34 @@ export function createNewShowfile(): Showfile {
target: {
type: SplinePointType.StartPoint,
point: {
- x: 0,
- y: 70,
+ x: 20,
+ y: 140,
},
},
- durationMs: 7500,
- id: "4f21401d-07cf-434f-a73c-6482ab82f210",
+ durationMs: 3000,
+ id: crypto.randomUUID(),
},
remainingEvents: [
+ {
+ id: crypto.randomUUID(),
+ type: TimelineEventTypes.TurnEvent,
+ durationMs: 1750,
+ radians: 2 * Math.PI,
+ },
{
type: TimelineEventTypes.GoToPointEvent,
durationMs: 1000,
target: {
type: SplinePointType.QuadraticBezier,
endPoint: { x: 100, y: 100 },
+ controlPoint: { x: 60, y: 120 },
},
- id: "4f21401d-07cf-434f-a73c-6482ab82f211",
+ id: crypto.randomUUID(),
},
{
type: TimelineEventTypes.WaitEvent,
durationMs: 5000,
- id: "4f21401d-07cf-434f-a73c-6482ab82f212",
+ id: crypto.randomUUID(),
},
{
type: TimelineEventTypes.GoToPointEvent,
@@ -143,8 +170,12 @@ export function createNewShowfile(): Showfile {
x: 300,
y: 40,
},
+ controlPoint2: {
+ x: 310,
+ y: 60,
+ },
},
- id: "4f21401d-07cf-434f-a73c-6482ab82f213",
+ id: crypto.randomUUID(),
},
{
type: TimelineEventTypes.GoToPointEvent,
@@ -152,8 +183,9 @@ export function createNewShowfile(): Showfile {
target: {
type: SplinePointType.QuadraticBezier,
endPoint: { x: 70, y: 70 },
+ controlPoint: { x: 85, y: 90 },
},
- id: "4f21401d-07cf-434f-a73c-6482ab82f214",
+ id: crypto.randomUUID(),
},
],
},
@@ -162,30 +194,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 566703ad..04b54fc3 100644
--- a/src/common/spline.ts
+++ b/src/common/spline.ts
@@ -28,6 +28,7 @@ export type StartPointSchema = Static;
export const CubicBezierSchema = Record({
type: Literal(SplinePointType.CubicBezier),
controlPoint: CoordsSchema,
+ controlPoint2: CoordsSchema,
endPoint: CoordsSchema,
});
export type CubicBezier = Static;
@@ -35,6 +36,7 @@ export type CubicBezier = Static;
export const QuadraticBezierSchema = Record({
type: Literal(SplinePointType.QuadraticBezier),
endPoint: CoordsSchema,
+ controlPoint: CoordsSchema,
});
export type QuadraticBezier = Static;
@@ -67,9 +69,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}`;
}
}
@@ -91,3 +93,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/server/api/api.ts b/src/server/api/api.ts
index 2c1eb7f5..a059ce62 100644
--- a/src/server/api/api.ts
+++ b/src/server/api/api.ts
@@ -13,7 +13,7 @@ import {
SetRobotVariableMessage,
} from "../../common/message/robot-message";
-import { TCPServer } from "./tcp-interface";
+import { tcpServer } from "./managers";
import { Difficulty } from "../../common/client-types";
import { RegisterWebsocketMessage } from "../../common/message/message";
import { clientManager, robotManager, socketManager } from "./managers";
@@ -32,9 +32,19 @@ import { VirtualBotTunnel, virtualRobots } from "../simulator";
import { Position } from "../robot/position";
import { DEGREE } from "../../common/units";
import { PacketType } from "../utils/tcp-packet";
+import {
+ DriveCubicSplineCommand,
+ DriveQuadraticSplineCommand,
+ SpinRadiansCommand,
+} from "../command/move-command";
+import {
+ Command,
+ ParallelCommandGroup,
+ SequentialCommandGroup,
+} from "../command/command";
+import { ShowfileSchema, TimelineEventTypes } from "../../common/show";
+import { SplinePointType } from "../../common/spline";
-export const tcpServer: TCPServer | null =
- USE_VIRTUAL_ROBOTS ? null : new TCPServer();
export const executor = new CommandExecutor();
export let gameManager: GameManager | null = null;
@@ -206,6 +216,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 533e132b..70222183 100644
--- a/src/server/api/bot-server-config.json
+++ b/src/server/api/bot-server-config.json
@@ -1,10 +1,11 @@
{
"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:4d:f4:2a": "robot-4"
+ "80:65:99:4e:07:9c": "robot-5",
+ "80:65:99:4e:07:70": "robot-4"
},
"robot-1": {
"attributes": {},
@@ -15,7 +16,8 @@
"defaultPosition": {
"x": 2,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-2": {
"attributes": {},
@@ -26,7 +28,8 @@
"defaultPosition": {
"x": 3,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-3": {
"attributes": {},
@@ -37,7 +40,8 @@
"defaultPosition": {
"x": 4,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-4": {
"attributes": {},
@@ -48,7 +52,8 @@
"defaultPosition": {
"x": 5,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-5": {
"attributes": {},
@@ -59,7 +64,8 @@
"defaultPosition": {
"x": 6,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-6": {
"attributes": {},
@@ -70,7 +76,8 @@
"defaultPosition": {
"x": 7,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-7": {
"attributes": {},
@@ -81,7 +88,8 @@
"defaultPosition": {
"x": 8,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-8": {
"attributes": {},
@@ -92,7 +100,8 @@
"defaultPosition": {
"x": 9,
"y": 2
- }
+ },
+ "startHeading": 90
},
"robot-9": {
"attributes": {},
@@ -103,7 +112,8 @@
"defaultPosition": {
"x": 2,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-10": {
"attributes": {},
@@ -114,7 +124,8 @@
"defaultPosition": {
"x": 3,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-11": {
"attributes": {},
@@ -125,7 +136,8 @@
"defaultPosition": {
"x": 4,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-12": {
"attributes": {},
@@ -136,7 +148,8 @@
"defaultPosition": {
"x": 5,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-13": {
"attributes": {},
@@ -147,7 +160,8 @@
"defaultPosition": {
"x": 6,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-14": {
"attributes": {},
@@ -158,13 +172,11 @@
"defaultPosition": {
"x": 7,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-15": {
- "attributes": {
- "MOTOR_A_DRIVE_MULTIPLIER": 1.3,
- "MOTOR_B_DRIVE_MULTIPLIER": 2
- },
+ "attributes": {},
"homePosition": {
"x": 11,
"y": 3
@@ -172,7 +184,8 @@
"defaultPosition": {
"x": 8,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-16": {
"attributes": {},
@@ -183,7 +196,8 @@
"defaultPosition": {
"x": 9,
"y": 3
- }
+ },
+ "startHeading": 90
},
"robot-17": {
"attributes": {},
@@ -194,7 +208,8 @@
"defaultPosition": {
"x": 2,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-18": {
"attributes": {},
@@ -205,7 +220,8 @@
"defaultPosition": {
"x": 3,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-19": {
"attributes": {},
@@ -216,7 +232,8 @@
"defaultPosition": {
"x": 4,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-20": {
"attributes": {},
@@ -227,7 +244,8 @@
"defaultPosition": {
"x": 5,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-21": {
"attributes": {},
@@ -238,7 +256,8 @@
"defaultPosition": {
"x": 6,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-22": {
"attributes": {},
@@ -249,7 +268,8 @@
"defaultPosition": {
"x": 7,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-23": {
"attributes": {},
@@ -260,7 +280,8 @@
"defaultPosition": {
"x": 8,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-24": {
"attributes": {},
@@ -271,7 +292,8 @@
"defaultPosition": {
"x": 9,
"y": 8
- }
+ },
+ "startHeading": 270
},
"robot-25": {
"attributes": {},
@@ -282,7 +304,8 @@
"defaultPosition": {
"x": 2,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-26": {
"attributes": {},
@@ -293,7 +316,8 @@
"defaultPosition": {
"x": 3,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-27": {
"attributes": {},
@@ -304,7 +328,8 @@
"defaultPosition": {
"x": 4,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-28": {
"attributes": {},
@@ -315,7 +340,8 @@
"defaultPosition": {
"x": 5,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-29": {
"attributes": {},
@@ -326,7 +352,8 @@
"defaultPosition": {
"x": 6,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-30": {
"attributes": {},
@@ -337,7 +364,8 @@
"defaultPosition": {
"x": 7,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-31": {
"attributes": {},
@@ -348,7 +376,8 @@
"defaultPosition": {
"x": 8,
"y": 9
- }
+ },
+ "startHeading": 270
},
"robot-32": {
"attributes": {},
@@ -359,7 +388,8 @@
"defaultPosition": {
"x": 9,
"y": 9
- }
+ },
+ "startHeading": 270
},
"botConfigSchema": [
{
@@ -435,21 +465,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/managers.ts b/src/server/api/managers.ts
index 697ecb39..f644a9f0 100644
--- a/src/server/api/managers.ts
+++ b/src/server/api/managers.ts
@@ -7,9 +7,12 @@ import { ClientManager } from "./client-manager";
import { SocketManager } from "./socket-manager";
import { virtualRobots } from "../simulator";
import { USE_VIRTUAL_ROBOTS } from "../utils/env";
+import { TCPServer } from "./tcp-interface";
export const socketManager = new SocketManager({});
export const clientManager = new ClientManager(socketManager);
export const robotManager = new RobotManager(
USE_VIRTUAL_ROBOTS ? Array.from(virtualRobots.values()) : [],
);
+export const tcpServer: TCPServer | null =
+ USE_VIRTUAL_ROBOTS ? null : new TCPServer({}, robotManager);
diff --git a/src/server/api/tcp-interface.ts b/src/server/api/tcp-interface.ts
index fdd6d008..eb19b021 100644
--- a/src/server/api/tcp-interface.ts
+++ b/src/server/api/tcp-interface.ts
@@ -9,6 +9,7 @@ import {
} from "../utils/tcp-packet";
import { EventEmitter } from "@posva/event-emitter";
import { randomUUID } from "node:crypto";
+import { RobotManager } from "../robot/robot-manager";
type RobotEventEmitter = EventEmitter<{
actionComplete: {
@@ -296,10 +297,13 @@ export class TCPServer {
*
* @param connections - bot connections in a id:BotTunnel array
*/
- constructor(private connections: { [id: string]: BotTunnel } = {}) {
+ constructor(
+ private connections: { [id: string]: BotTunnel } = {},
+ private robotManager: RobotManager,
+ ) {
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(),
@@ -317,7 +321,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
@@ -336,6 +339,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 2859f1d9..488780bd 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 bb134489..b4680daa 100644
--- a/src/server/command/move-command.ts
+++ b/src/server/command/move-command.ts
@@ -83,6 +83,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.
@@ -182,6 +248,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-manager.ts b/src/server/robot/robot-manager.ts
index daabf1f4..a12fa759 100644
--- a/src/server/robot/robot-manager.ts
+++ b/src/server/robot/robot-manager.ts
@@ -1,5 +1,7 @@
import { GridIndices } from "./grid-indices";
import { Robot } from "./robot";
+import config from "../api/bot-server-config.json";
+import { DEGREE } from "../../common/units";
/**
* Stores robots. Provides utilities for finding them by position.
@@ -24,6 +26,15 @@ export class RobotManager {
this.indicesToIds.set(JSON.stringify(robot.defaultIndices), robot.id);
}
+ createRobotFromId(robotId: string) {
+ const robot = new Robot(
+ robotId,
+ config[robotId].homePosition,
+ config[robotId].defaultPosition,
+ config[robotId].startHeading * DEGREE,
+ );
+ this.addRobot(robot);
+ }
/**
* Retrieves a robot by id.
* Throws if no robot is found.
diff --git a/src/server/robot/robot.ts b/src/server/robot/robot.ts
index 50ddce0a..02230a5d 100644
--- a/src/server/robot/robot.ts
+++ b/src/server/robot/robot.ts
@@ -1,7 +1,7 @@
import { FULL_ROTATION, RADIAN, clampHeading } from "../../common/units";
import { Position, ZERO_POSITION } from "./position";
import { GridIndices } from "./grid-indices";
-import { tcpServer } from "../api/api";
+import { tcpServer } from "../api/managers";
import type { BotTunnel } from "../api/tcp-interface";
import { PacketType } from "../utils/tcp-packet";
@@ -112,6 +112,67 @@ export class Robot {
*/
public async sendDrivePacket(tileDistance: number): Promise {
const tunnel = this.getTunnel();
- await tunnel.send({ type: PacketType.DRIVE_TILES, tileDistance });
+ console.log(tileDistance);
+ await tunnel.send({ type: PacketType.DRIVE_TANK, left: 1, right: 1 });
+ }
+
+ /**
+ * 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 {
+ const tunnel = this.getTunnel();
+ await 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 {
+ const tunnel = this.getTunnel();
+ await 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 {
+ const tunnel = this.getTunnel();
+ await tunnel.send({
+ type: PacketType.DRIVE_QUADRATIC_SPLINE,
+ startPosition: startPosition,
+ controlPosition: controlPosition,
+ endPosition: endPosition,
+ timeDeltaMs: timeDeltaMs,
+ });
+ }
+
+ public async sendSpinPacket(
+ radians: number,
+ timeDeltaMs: number,
+ ): Promise {
+ const tunnel = this.getTunnel();
+ await 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 33abbd42..017a0186 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/tsconfig.json b/tsconfig.json
index c6c8c8d1..5982a28b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
- "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "lib": ["DOM", "DOM.Iterable", "ESNext", "ES2023"],
"module": "Node16",
"skipLibCheck": true,
diff --git a/yarn.lock b/yarn.lock
index 98ee2423..45fc1169 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"
@@ -541,6 +571,17 @@
resolved "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz"
integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==
+"@gerrit0/mini-shiki@^3.2.2":
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/@gerrit0/mini-shiki/-/mini-shiki-3.2.3.tgz#06aea4cf1097fb38a54b33e50fd6d8f6e7efa5af"
+ integrity sha512-yemSYr0Oiqk5NAQRfbD5DKUTlThiZw1MxTMx/YpQTg6m4QRJDtV2JTYSuNevgx1ayy/O7x+uwDjh3IgECGFY/Q==
+ dependencies:
+ "@shikijs/engine-oniguruma" "^3.2.2"
+ "@shikijs/langs" "^3.2.2"
+ "@shikijs/themes" "^3.2.2"
+ "@shikijs/types" "^3.2.2"
+ "@shikijs/vscode-textmate" "^10.0.2"
+
"@humanwhocodes/config-array@^0.11.14":
version "0.11.14"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz"
@@ -560,6 +601,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"
@@ -784,6 +830,41 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz#05f25dbc9981bee1ae6e713daab10397044a46ca"
integrity sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw==
+"@shikijs/engine-oniguruma@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.2.2.tgz#2a68e7be01960161615bcd6866a74f63c25578bc"
+ integrity sha512-vyXRnWVCSvokwbaUD/8uPn6Gqsf5Hv7XwcW4AgiU4Z2qwy19sdr6VGzMdheKKN58tJOOe5MIKiNb901bgcUXYQ==
+ dependencies:
+ "@shikijs/types" "3.2.2"
+ "@shikijs/vscode-textmate" "^10.0.2"
+
+"@shikijs/langs@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.2.2.tgz#b4db801a27e9e29191cac8766db610a048e66cdb"
+ integrity sha512-NY0Urg2dV9ETt3JIOWoMPuoDNwte3geLZ4M1nrPHbkDS8dWMpKcEwlqiEIGqtwZNmt5gKyWpR26ln2Bg2ecPgw==
+ dependencies:
+ "@shikijs/types" "3.2.2"
+
+"@shikijs/themes@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.2.2.tgz#56b9d6f6803296e9cb7233e25334897496d40d9d"
+ integrity sha512-Zuq4lgAxVKkb0FFdhHSdDkALuRpsj1so1JdihjKNQfgM78EHxV2JhO10qPsMrm01FkE3mDRTdF68wfmsqjt6HA==
+ dependencies:
+ "@shikijs/types" "3.2.2"
+
+"@shikijs/types@3.2.2", "@shikijs/types@^3.2.2":
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.2.2.tgz#3718364c46965b15b767a38ef663877f4f7e6fa9"
+ integrity sha512-a5TiHk7EH5Lso8sHcLHbVNNhWKP0Wi3yVnXnu73g86n3WoDgEra7n3KszyeCGuyoagspQ2fzvy4cpSc8pKhb0A==
+ dependencies:
+ "@shikijs/vscode-textmate" "^10.0.2"
+ "@types/hast" "^3.0.4"
+
+"@shikijs/vscode-textmate@^10.0.2":
+ version "10.0.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224"
+ integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==
+
"@tanstack/query-core@5.29.0":
version "5.29.0"
resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.29.0.tgz"
@@ -877,6 +958,13 @@
"@types/qs" "*"
"@types/serve-static" "*"
+"@types/hast@^3.0.4":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
"@types/http-errors@*":
version "2.0.4"
resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz"
@@ -959,6 +1047,11 @@
"@types/mime" "*"
"@types/node" "*"
+"@types/unist@*":
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
+ integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
+
"@types/ws@*", "@types/ws@^8.5.10":
version "8.5.10"
resolved "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz"
@@ -1165,11 +1258,6 @@ ansi-regex@^5.0.1:
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
-ansi-sequence-parser@^1.1.0:
- version "1.1.1"
- resolved "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz"
- integrity sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==
-
ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz"
@@ -1267,6 +1355,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"
@@ -1332,6 +1425,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"
@@ -1536,6 +1650,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.1.4:
version "1.1.4"
resolved "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz"
@@ -1555,6 +1674,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.3"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
+ integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
+
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz"
@@ -1624,6 +1748,11 @@ encodeurl@~2.0.0:
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
+entities@^4.4.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
es-define-property@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz"
@@ -1988,6 +2117,15 @@ forwarded@0.2.0:
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+framer-motion@^12.6.0:
+ version "12.6.0"
+ resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.6.0.tgz#0c41105069d64b790a0d1460b51e4221435cf982"
+ integrity sha512-91XLZ3VwDlXe9u2ABhTzYBiFQ/qdoiqyTiTCQDDJ4es5/5lzp76hdB+WG7gcNklcQlOmfDZQqVO48tqzY9Z/bQ==
+ dependencies:
+ motion-dom "^12.6.0"
+ motion-utils "^12.5.0"
+ tslib "^2.4.0"
+
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
@@ -2199,6 +2337,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"
+
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
@@ -2292,11 +2437,6 @@ json5@^2.2.3:
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
-jsonc-parser@^3.2.0:
- version "3.2.1"
- resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz"
- integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==
-
keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz"
@@ -2327,6 +2467,13 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz"
@@ -2409,10 +2556,22 @@ make-error@^1.1.1:
resolved "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
-marked@^4.2.12:
- version "4.3.0"
- resolved "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz"
- integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==
+markdown-it@^14.1.0:
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
media-typer@0.3.0:
version "0.3.0"
@@ -2466,20 +2625,33 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
-minimatch@^7.1.3:
- version "7.4.6"
- resolved "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz"
- integrity sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==
- dependencies:
- brace-expansion "^2.0.1"
-
-minimatch@^9.0.4:
+minimatch@^9.0.4, minimatch@^9.0.5:
version "9.0.5"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
+motion-dom@^12.6.0:
+ version "12.6.0"
+ resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.6.0.tgz#f9fb2499cd7908f7700f5f87295693e47e5a1b74"
+ integrity sha512-1s/+/V0ny/gfhocSSf0qhkspZK2da7jrwGw7xHzgiQPcimdHaPRcRCoJ3OxEZYBNzy3ma1ERUD+eUStk6a9pQw==
+ dependencies:
+ motion-utils "^12.5.0"
+
+motion-utils@^12.5.0:
+ version "12.5.0"
+ resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.5.0.tgz#0e42a6b8030327166ca0d0ea0d5b86d64c21e51d"
+ integrity sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==
+
+motion@^12.6.0:
+ version "12.6.0"
+ resolved "https://registry.yarnpkg.com/motion/-/motion-12.6.0.tgz#cbf1451e70c7b105e6d4da7f1fcabf6a9631452e"
+ integrity sha512-YbwQAeVOhQwTi6I8iA+e8TfsduoV0cbE+k9MDeonlDyN04uz5jiVWKeBR0nXgGyGICnmcTaKe/wsQbHkkM3EMw==
+ dependencies:
+ framer-motion "^12.6.0"
+ tslib "^2.4.0"
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -2523,6 +2695,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"
@@ -2793,6 +2972,11 @@ pstree.remy@^1.1.8:
resolved "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz"
integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
@@ -3153,16 +3337,6 @@ shell-quote@^1.8.1:
resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz"
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
-shiki@^0.14.1:
- version "0.14.7"
- resolved "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz"
- integrity sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==
- dependencies:
- ansi-sequence-parser "^1.1.0"
- jsonc-parser "^3.2.0"
- vscode-oniguruma "^1.7.0"
- vscode-textmate "^8.0.0"
-
side-channel@^1.0.6:
version "1.0.6"
resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz"
@@ -3337,6 +3511,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"
@@ -3357,20 +3536,26 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
-typedoc@^0.23.0:
- version "0.23.28"
- resolved "https://registry.npmjs.org/typedoc/-/typedoc-0.23.28.tgz"
- integrity sha512-9x1+hZWTHEQcGoP7qFmlo4unUoVJLB0H/8vfO/7wqTnZxg4kPuji9y3uRzEu0ZKez63OJAUmiGhUrtukC6Uj3w==
+typedoc@^0.28.0:
+ version "0.28.2"
+ resolved "https://registry.yarnpkg.com/typedoc/-/typedoc-0.28.2.tgz#342c42f2e4c0306c47a0a5e92592af9aa60e1a85"
+ integrity sha512-9Giuv+eppFKnJ0oi+vxqLM817b/IrIsEMYgy3jj6zdvppAfDqV3d6DXL2vXUg2TnlL62V48th25Zf/tcQKAJdg==
dependencies:
+ "@gerrit0/mini-shiki" "^3.2.2"
lunr "^2.3.9"
- marked "^4.2.12"
- minimatch "^7.1.3"
- shiki "^0.14.1"
+ markdown-it "^14.1.0"
+ minimatch "^9.0.5"
+ yaml "^2.7.1"
-typescript@^4.9.3:
- version "4.9.5"
- resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz"
- integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
+typescript@5.5.4:
+ version "5.5.4"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
+ integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
+
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
undefsafe@^2.0.5:
version "2.0.5"
@@ -3502,16 +3687,6 @@ vitest@^3.0.8:
vite-node "3.0.8"
why-is-node-running "^2.3.0"
-vscode-oniguruma@^1.7.0:
- version "1.7.0"
- resolved "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz"
- integrity sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==
-
-vscode-textmate@^8.0.0:
- version "8.0.0"
- resolved "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz"
- integrity sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==
-
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz"
@@ -3559,6 +3734,11 @@ yallist@^4.0.0:
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+yaml@^2.7.1:
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.1.tgz#44a247d1b88523855679ac7fa7cda6ed7e135cf6"
+ integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==
+
yn@3.1.1:
version "3.1.1"
resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"