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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/user-guides/dojo.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,18 @@ Dojo is split into two primary environments depending on your current needs:

---

## Plot Notes

The trial viewer supports freeform notes anchored to specific points on a plot.

- **Add a note:** Middle-click anywhere on the plot area. The Notes panel opens with the cursor's data coordinates pre-filled.
- **Edit or delete:** Open the Notes panel (the speech-bubble icon in the sidebar) and use the edit/delete controls next to each note.
- **Jump to a note:** Click the note's coordinates in the list to zoom the plot to that location.
- **Close the panel:** Left-click anywhere outside the Notes panel, or press ++esc++.

Notes are persisted in the trial's saved configuration and are included when sharing a view via the share URL.

---

!!! success
Dojo is now live! Now that you have the interface running, move on to the **Monitor** tool to learn how to track your job's progress in real-time.
24 changes: 22 additions & 2 deletions docs/user-guides/mosaic.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Adjust the visual style. Switch between Lines, Markers, or both. Change interpol

### Notes Editor

Click anywhere on the plot to drop a persistent note. These are saved into the JSON config and help you flag specific events (like "Impact Start" or "Sensor Saturation"). The editor allows you to go back and edit the note's label. Typos happen!
**Middle-click** anywhere on the plot to drop a persistent note at the exact data coordinates of your cursor. Notes are saved into the JSON config and help you flag specific events (like "Impact Start" or "Sensor Saturation"). The editor allows you to go back and edit the note's label. Press ++esc++ to cancel an in-progress note or close the panel.

???+ tip "Tip: Clickable Buttons"
You can click on a note in the editor to "focus" on the note!
Expand All @@ -71,18 +71,38 @@ Click anywhere on the plot to drop a persistent note. These are saved into the J

### Shapes Editor

Add various shapes to the plot. After selecting an option in the menu, your mouse becomes a placement tool. Clicking on the plot configures the placement of the shape.
Add various shapes to the plot. After selecting an option in the menu, your mouse becomes a placement tool. Clicking on the plot configures the placement of the shape. Press ++esc++ at any time to cancel placement.

- **V-Line:** Draws a vertical line on the plot. The abscissa of the mouse determines the abscissa of the line.
- **H-Line:** Draws a horizontal line on the plot. The ordinate of the mouse determines the ordinate of the line.
- **Rect:** Draws a rectangle on the plot. The abscissa and ordinate of the mouse determines one of the corners of the rectangle. Likewise, the second click determines the other corner.

Each new shape is automatically assigned the next color in the preset palette so overlapping shapes remain visually distinct. The shape editor includes a full color picker (identical to the signal color picker) for precise color control.

<figure align="center">
<img src="../../assets/user-guides/light-shapes-editor.jpg#only-light" alt="Shapes editor menu" style="width: 60%; height: auto;">
<img src="../../assets/user-guides/dark-shapes-editor.jpg#only-dark" alt="Shapes editor menu" style="width: 60%; height: auto;">
<figcaption>The menu for <b>shapes editor</b> is active and in editing mode. A shape's label and color is being edited. The plot area shows a vertical (yellow) and horizontal (red) line and a rectangle (purple).</figcaption>
</figure>

### Saved Profiles

Profiles save your complete plot configuration (selected signals, axes, display settings, zoom, annotations, and shapes) under a named key so you can restore a view instantly.

Profiles are stored globally in `~/.mujoco-mojo/profiles/` and are available across all workdirs, making it easy to share a standard analysis setup between projects.

**Creating a profile**

Type a name in the save box and press **Save** or ++enter++. To organize profiles into folders use a `folder/name` path, for example `arm-reach/baseline`. Folders are created automatically and the input field offers Tab-completion for existing folder names.

**Loading a profile**

Click **Load** next to any profile in the list. If the profile references signals, axes, or reference frames that do not exist in the current trial it will be rejected with an error toast rather than silently applying an invalid configuration. Profiles that are incompatible with the current trial are marked with a warning icon in the list.

**Searching profiles**

A search box filters the profile list by name (including folder path) as you type.

### Undo/Redo

Every change to the plot configuration is tracked. Use the standard shortcuts (++ctrl+z++ / ++ctrl+y++ or ++cmd+z++ / ++cmd+shift+z++) to step through your edits.
Expand Down
176 changes: 176 additions & 0 deletions scripts/gen_ts_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""
Generate TypeScript types from Pydantic models in plot_config.py.

Run from the repo root:

python scripts/gen_ts_models.py

Writes:
src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/plot-config.generated.ts
"""

from __future__ import annotations

import sys
import textwrap
from pathlib import Path

ROOT = Path(__file__).parent.parent
TS_OUT = (
ROOT
/ "src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib"
/ "plot-config.generated.ts"
)

# ---------------------------------------------------------------------------
# JSON Schema → TypeScript converter
# ---------------------------------------------------------------------------


def _ref_name(ref: str) -> str:
"""'#/$defs/FooBar' → 'FooBar'"""
return ref.rsplit("/", 1)[-1]


def _schema_to_ts(node: dict, defs: dict) -> str:
"""Recursively convert a JSON Schema node to a TypeScript type string."""
if "$ref" in node:
return _ref_name(node["$ref"])

if "anyOf" in node:
parts = [_schema_to_ts(s, defs) for s in node["anyOf"]]
return " | ".join(parts)

if "enum" in node:
return " | ".join(f'"{v}"' for v in node["enum"])

t = node.get("type")

if t == "string":
return "string"
if t in ("number", "integer"):
return "number"
if t == "boolean":
return "boolean"
if t == "null":
return "null"

if t == "array":
if "prefixItems" in node:
items = [_schema_to_ts(s, defs) for s in node["prefixItems"]]
return f"[{', '.join(items)}]"
if "items" in node:
return f"{_schema_to_ts(node['items'], defs)}[]"
return "unknown[]"

if t == "object":
add_props = node.get("additionalProperties")
props = node.get("properties", {})
required = set(node.get("required", []))

if add_props and not props:
# Pure dict / Record
if add_props is True or add_props == {}:
return "Record<string, unknown>"
return f"Record<string, {_schema_to_ts(add_props, defs)}>"

# Inline object (rare — models surface as $defs, not inline)
lines = _props_to_ts_lines(props, required, defs)
if add_props is True:
lines.append(" [key: string]: unknown;")
body = "\n".join(lines)
return "{\n" + body + "\n}"

return "unknown"


def _props_to_ts_lines(
props: dict,
required: set[str],
defs: dict,
) -> list[str]:
"""Return one ' field?: Type;' string per property."""
lines: list[str] = []
for name, schema in props.items():
ts_type = _schema_to_ts(schema, defs)
opt = "" if name in required else "?"
lines.append(f" {name}{opt}: {ts_type};")
return lines


# ---------------------------------------------------------------------------
# $defs → top-level TypeScript declarations
# ---------------------------------------------------------------------------


def _def_to_ts(name: str, schema: dict, defs: dict) -> str:
"""Convert one $defs entry to a TypeScript type or interface block."""
# StrEnum / Literal → export type Name = "a" | "b";
if "enum" in schema:
values = " | ".join(f'"{v}"' for v in schema["enum"])
return f"export type {name} = {values};\n"

# BaseModel → export interface Name { ... }
if schema.get("type") == "object":
props = schema.get("properties", {})
required = set(schema.get("required", []))
lines = _props_to_ts_lines(props, required, defs)
if schema.get("additionalProperties") is True:
lines.append(" [key: string]: unknown;")
body = "\n".join(lines) if lines else " [key: string]: unknown;"
return f"export interface {name} {{\n{body}\n}}\n"

return f"// Unhandled $def: {name}\n"


def _top_level_interface(schema: dict, defs: dict) -> str:
"""Generate the top-level model (PlotConfig) as an interface."""
name = schema.get("title", "PlotConfig")
desc = schema.get("description", "")
props = schema.get("properties", {})
required = set(schema.get("required", []))
lines = _props_to_ts_lines(props, required, defs)
body = "\n".join(lines)
comment = f"/** {desc} */\n" if desc else ""
return f"{comment}export interface {name} {{\n{body}\n}}\n"


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> None:
# Import inside main so sys.path manipulation is localised
sys.path.insert(0, str(ROOT / "src"))
from mujoco_mojo.utils.layers.dojo.plot_config import PlotConfig

schema = PlotConfig.model_json_schema()
defs: dict = schema.get("$defs", {})

blocks: list[str] = []

# Emit each $def in definition order
for def_name, def_schema in defs.items():
blocks.append(_def_to_ts(def_name, def_schema, defs))

# Emit the top-level PlotConfig interface
blocks.append(_top_level_interface(schema, defs))

header = textwrap.dedent("""\
// ============================================================
// AUTO-GENERATED - do not edit manually.
// Source: src/mujoco_mojo/utils/layers/dojo/plot_config.py
// Regenerate: python scripts/gen_ts_models.py
// ============================================================

""")

output = header + "\n".join(blocks)
TS_OUT.parent.mkdir(parents=True, exist_ok=True)
TS_OUT.write_text(output, encoding="utf-8")
print(f"Written → {TS_OUT.relative_to(ROOT)}")


if __name__ == "__main__":
main()
152 changes: 152 additions & 0 deletions src/mujoco_mojo/utils/layers/dojo/plot_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""
Pydantic models for the Dojo trial-viewer PlotConfig.

These are the single source of truth for both server-side validation
(profile save/load) and the generated TypeScript types in
``lib/plot-config.generated.ts``.

To regenerate TypeScript types after changing this file:

python scripts/gen_ts_models.py
"""

from __future__ import annotations

from enum import StrEnum
from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field

# ---------------------------------------------------------------------------
# String-enum types (generate named TypeScript union types)
# ---------------------------------------------------------------------------


class DashStyle(StrEnum):
solid = "solid"
dash = "dash"
dot = "dot"
dashdot = "dashdot"


class MarkerSymbol(StrEnum):
none = "none"
circle = "circle"
square = "square"
diamond = "diamond"
cross = "cross"


class GridMode(StrEnum):
none = "none"
major = "major"
all = "all"


class LineMode(StrEnum):
lines = "lines"
markers = "markers"
lines_and_markers = "lines+markers"


class InterpMode(StrEnum):
linear = "linear"
spline = "spline"
hv = "hv"
vh = "vh"
hvh = "hvh"
vhv = "vhv"


class HoverMode(StrEnum):
x_unified = "x unified"
y_unified = "y unified"
closest = "closest"
x = "x"
y = "y"
none = "none"


class LegendPos(StrEnum):
bottom = "bottom"
right = "right"
hidden = "hidden"


class ScaleType(StrEnum):
linear = "linear"
log = "log"


class ShapeType(StrEnum):
vline = "vline"
hline = "hline"
rect = "rect"


# ---------------------------------------------------------------------------
# Composite models
# ---------------------------------------------------------------------------


class FilterEntry(BaseModel):
"""A single filter step applied to a signal. Extra keys are preserved."""

model_config = ConfigDict(extra="allow")

type: str
enabled: bool = True


class YAxisConfig(BaseModel):
label: str
color: str
width: float
opacity: float
filters: list[FilterEntry]
dash: DashStyle
marker: MarkerSymbol


class Annotation(BaseModel):
x: float
y: float
text: str


class Shape(BaseModel):
type: ShapeType
x0: float
x1: float | None = None
y0: float | None = None
y1: float | None = None
color: str
dash: DashStyle | None = None
label: str


class PlotConfig(BaseModel):
"""Complete serialisable state of a trial-viewer plot."""

xAxis: str
yAxes: dict[str, YAxisConfig]
refFrame: str | None
grid: GridMode
linemode: LineMode
interp: InterpMode
hover: HoverMode
title: str
xAxisTitle: str
yAxisTitle: str
showSpike: bool
legendPos: LegendPos
rangeX: Annotated[tuple[float, float], Field()] | None
rangeY: Annotated[tuple[float, float], Field()] | None
xScale: ScaleType
yScale: ScaleType
xLogBase: float | None = None
yLogBase: float | None = None
vsEnabled: bool
vsRange: Annotated[tuple[float, float], Field()]
annotations: list[Annotation]
shapes: list[Shape]
Loading
Loading