diff --git a/docs/user-guides/getting-started.md b/docs/user-guides/getting-started.md index 4d7f42cd..adfdf961 100644 --- a/docs/user-guides/getting-started.md +++ b/docs/user-guides/getting-started.md @@ -307,7 +307,7 @@ MuJoCo does not currently ship first-party Python typing stubs. To enable proper --- !!! tip "Tip: Scaffold a New Project" - Before diving into the generate script, run `mujoco-mojo init` in a new directory to get a working project skeleton — `simulation.py`, `run.sh`, and `reloaded.sh` — pre-wired and ready to edit. + Before diving into the generate script, run `mujoco-mojo init` in a new directory to get a working project skeleton (`simulation.py`, `run.sh`, and `reloaded.sh`) pre-wired and ready to edit. ```bash linenums="0" mujoco-mojo init diff --git a/docs/user-guides/runtime-script.md b/docs/user-guides/runtime-script.md index a6aeb1ce..b9d11cb2 100644 --- a/docs/user-guides/runtime-script.md +++ b/docs/user-guides/runtime-script.md @@ -49,7 +49,7 @@ Mojo provides high-level force abstractions like `PointToPointForce`, which auto --- - **6-DOF force and torque**. The "multi-tool" of loads—defines full spatial dynamics in either a relative frame or local to a site. + **6-DOF force and torque**. Defines full spatial dynamics in either a relative frame or local to a site. > Think of it as a `VectorForce` and `VectorTorque` combined into one robust plugin. diff --git a/src/mujoco_mojo/utils/dataframe.py b/src/mujoco_mojo/utils/dataframe.py index 8c790a8a..dcc9b220 100644 --- a/src/mujoco_mojo/utils/dataframe.py +++ b/src/mujoco_mojo/utils/dataframe.py @@ -231,18 +231,22 @@ def select_flex(self, name: FlexName) -> MojoDataFrame: self._df.select(pl.col(rf"^{SignalCategory.DEFORMABLES}/{name}/.*$")) ) - def _get_base_map(self) -> dict[str, set[str]]: + def _get_base_map( + self, extra_columns: list[str] | None = None + ) -> dict[str, set[str]]: """ Internal helper to group suffixes by their common prefixes. Example: - 'Bodies/racket:xvelr:x', 'Bodies/racket:xvelr:y' -> {'Bodies/racket:xvelr': {'x', 'y'}} + 'Bodies/racket/xvelr:x', 'Bodies/racket/xvelr:y' -> {'Bodies/racket/xvelr': {'x', 'y'}} + + extra_columns are included in discovery but are not expected to exist in self._df. """ base_map: dict[str, set[str]] = {} - for c in self._df.columns: + for c in list(self._df.columns) + (extra_columns or []): if ":" in c: - prefix, suffix = c.rsplit(":") # we may have :ke_trans, or ke_rot, etc. + prefix, suffix = c.rsplit(":", 1) base_map.setdefault(prefix, set()).add(suffix) return base_map @@ -280,12 +284,17 @@ def quaternion_columns(self) -> list[str]: expanded.extend([f"{base}:w", f"{base}:x", f"{base}:y", f"{base}:z"]) return expanded - def get_manifest(self) -> ColumnManifest: - """Returns the structured manifest used by the frontend.""" + def get_manifest(self, extra_columns: list[str] | None = None) -> ColumnManifest: + """Returns the structured manifest used by the frontend. extra_columns are appended to 'all' and included in rotatable/quat discovery.""" + bm = self._get_base_map(extra_columns) return { - "all": self._df.columns, - "rotatable_vectors": sorted(list(self.rotatable_bases)), - "available_quats": sorted(list(self.quaternion_bases)), + "all": list(self._df.columns) + (extra_columns or []), + "rotatable_vectors": sorted( + b for b, s in bm.items() if {"x", "y", "z"}.issubset(s) and "w" not in s + ), + "available_quats": sorted( + b for b, s in bm.items() if {"w", "x", "y", "z"}.issubset(s) + ), } def with_rotation(self, quat_base: str, invert: bool = True) -> MojoDataFrame: diff --git a/src/mujoco_mojo/utils/filters/__init__.py b/src/mujoco_mojo/utils/filters/__init__.py index 0c517d7e..9dee1704 100644 --- a/src/mujoco_mojo/utils/filters/__init__.py +++ b/src/mujoco_mojo/utils/filters/__init__.py @@ -2,18 +2,26 @@ AbsoluteValueFilter, AnyFilter, ClipFilter, + ComparisonFilter, DeadbandFilter, DerivativeFilter, + ExpFilter, FilterType, HighPassFilter, IntegralFilter, + LogFilter, LowPassFilter, MedianFilter, NormalizeFilter, + PowerFilter, RollingMeanFilter, + RotationFilter, + RoundFilter, SavitzkyGolayFilter, ScaleFilter, + SignFilter, TaringFilter, + TrigFilter, UnitFilter, WrapFilter, filter_adapter, @@ -23,18 +31,26 @@ "AbsoluteValueFilter", "AnyFilter", "ClipFilter", + "ComparisonFilter", "DeadbandFilter", "DerivativeFilter", + "ExpFilter", "FilterType", "HighPassFilter", "IntegralFilter", + "LogFilter", "LowPassFilter", "MedianFilter", "NormalizeFilter", + "PowerFilter", "RollingMeanFilter", + "RotationFilter", + "RoundFilter", "SavitzkyGolayFilter", "ScaleFilter", + "SignFilter", "TaringFilter", + "TrigFilter", "UnitFilter", "WrapFilter", "filter_adapter", diff --git a/src/mujoco_mojo/utils/filters/filters.py b/src/mujoco_mojo/utils/filters/filters.py index 4a880bb7..82817980 100644 --- a/src/mujoco_mojo/utils/filters/filters.py +++ b/src/mujoco_mojo/utils/filters/filters.py @@ -1,32 +1,42 @@ from __future__ import annotations +import math from abc import ABC, abstractmethod from enum import StrEnum -from typing import Annotated, Literal, Self +from typing import Annotated, ClassVar, Literal, Self import numpy as np import pint import polars as pl from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator from scipy.signal import savgol_filter +from scipy.spatial.transform import Rotation as R __all__ = [ "UNIT_GROUPS", "AbsoluteValueFilter", "AnyFilter", "ClipFilter", + "ComparisonFilter", "DeadbandFilter", "DerivativeFilter", + "ExpFilter", "FilterType", "HighPassFilter", "IntegralFilter", + "LogFilter", "LowPassFilter", "MedianFilter", "NormalizeFilter", + "PowerFilter", "RollingMeanFilter", + "RotationFilter", + "RoundFilter", "SavitzkyGolayFilter", "ScaleFilter", + "SignFilter", "TaringFilter", + "TrigFilter", "UnitFilter", "WrapFilter", "filter_adapter", @@ -49,12 +59,21 @@ class FilterType(StrEnum): NORMALIZE = "normalize" SAVITZKY_GOLAY = "savitzky_golay" UNIT = "unit" + ROTATION = "rotation" + LOG = "log" + EXP = "exp" + POWER = "power" + ROUND = "round" + TRIG = "trig" + SIGN = "sign" + COMPARISON = "comparison" class BaseFilter(ABC, BaseModel): """Base class for all data transformations.""" model_config = ConfigDict(extra="forbid") + category: ClassVar[str] = "Misc" @abstractmethod def apply(self, expr: pl.Expr) -> pl.Expr: @@ -74,6 +93,8 @@ def apply_with_context( class ScaleFilter(BaseFilter): """Applies a linear transformation: (value * factor) + offset.""" + category: ClassVar[str] = "Arithmetic" + type: Literal[FilterType.SCALE] = FilterType.SCALE """The discriminator type for Pydantic.""" @@ -90,6 +111,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class AbsoluteValueFilter(BaseFilter): """Rectifies the signal by taking the magnitude of every sample.""" + category: ClassVar[str] = "Arithmetic" + type: Literal[FilterType.ABSOLUTE_VALUE] = FilterType.ABSOLUTE_VALUE """The discriminator type for Pydantic.""" @@ -103,6 +126,8 @@ class DerivativeFilter(BaseFilter): Useful for deriving velocity from position or acceleration from velocity. """ + category: ClassVar[str] = "Calculus" + type: Literal[FilterType.DERIVATIVE] = FilterType.DERIVATIVE """The discriminator type for Pydantic.""" @@ -133,6 +158,8 @@ class IntegralFilter(BaseFilter): Useful for deriving position from velocity or calculating energy. """ + category: ClassVar[str] = "Calculus" + type: Literal[FilterType.INTEGRAL] = FilterType.INTEGRAL """The discriminator type for Pydantic.""" @@ -162,6 +189,8 @@ class LowPassFilter(BaseFilter): Effective for removing high-frequency noise while introducing slight phase lag. """ + category: ClassVar[str] = "Smoothing" + type: Literal[FilterType.LOW_PASS] = FilterType.LOW_PASS """The discriminator type for Pydantic.""" @@ -178,6 +207,8 @@ class HighPassFilter(BaseFilter): Implemented as the complement of the Exponential Moving Average. """ + category: ClassVar[str] = "Smoothing" + type: Literal[FilterType.HIGH_PASS] = FilterType.HIGH_PASS """The discriminator type for Pydantic.""" @@ -192,6 +223,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class ClipFilter(BaseFilter): """Clamps the signal values within a specified range.""" + category: ClassVar[str] = "Bounding" + type: Literal[FilterType.CLIP] = FilterType.CLIP """The discriminator type for Pydantic.""" @@ -217,6 +250,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class RollingMeanFilter(BaseFilter): """Applies a sliding window average to the signal.""" + category: ClassVar[str] = "Smoothing" + type: Literal[FilterType.ROLLING_MEAN] = FilterType.ROLLING_MEAN """The discriminator type for Pydantic.""" @@ -233,6 +268,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class TaringFilter(BaseFilter): """Offsets the entire signal so that the first sample is zero.""" + category: ClassVar[str] = "Bounding" + type: Literal[FilterType.TARING] = FilterType.TARING """The discriminator type for Pydantic.""" @@ -243,6 +280,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class DeadbandFilter(BaseFilter): """Suppresses noise around zero by forcing values below a threshold to zero.""" + category: ClassVar[str] = "Bounding" + type: Literal[FilterType.DEADBAND] = FilterType.DEADBAND """The discriminator type for Pydantic.""" @@ -259,6 +298,8 @@ class WrapFilter(BaseFilter): Ensures continuity when a signal crosses the upper or lower boundary. """ + category: ClassVar[str] = "Bounding" + type: Literal[FilterType.WRAP] = FilterType.WRAP """The discriminator type for Pydantic.""" @@ -285,6 +326,8 @@ class MedianFilter(BaseFilter): Highly effective for removing impulse noise (spikes) without blurring edges. """ + category: ClassVar[str] = "Smoothing" + type: Literal[FilterType.MEDIAN] = FilterType.MEDIAN """The discriminator type for Pydantic.""" @@ -298,6 +341,8 @@ def apply(self, expr: pl.Expr) -> pl.Expr: class NormalizeFilter(BaseFilter): """Rescales the signal to the range [0, 1].""" + category: ClassVar[str] = "Bounding" + type: Literal[FilterType.NORMALIZE] = FilterType.NORMALIZE """The discriminator type for Pydantic.""" @@ -311,6 +356,8 @@ class SavitzkyGolayFilter(BaseFilter): Preserves signal features (like peaks and transients) better than a simple moving average. """ + category: ClassVar[str] = "Smoothing" + type: Literal[FilterType.SAVITZKY_GOLAY] = FilterType.SAVITZKY_GOLAY """The discriminator type for Pydantic.""" @@ -346,7 +393,7 @@ def apply(self, expr: pl.Expr) -> pl.Expr: pass # --------------------------------------------------------------------------- -# Unit groups — single source of truth for both the frontend smart dropdown +# Unit groups - single source of truth for both the frontend smart dropdown # and the SignalUnit type annotation on UnitFilter. To add a new unit, # add it here; SignalUnit is derived automatically. Verify that pint can # parse any new string via `ureg.parse_units(...)` before committing. @@ -377,7 +424,7 @@ def apply(self, expr: pl.Expr) -> pl.Expr: ("Misc.", ["dimensionless", "pct", "count", "bit"]), ] -# Derived automatically — Literal[tuple_of_strings] is equivalent to Literal["a", "b", ...] +# Derived automatically - Literal[tuple_of_strings] is equivalent to Literal["a", "b", ...] # in Python 3.9+ because x[a, b] and x[(a, b)] make the same __getitem__ call. _ALL_UNITS: tuple[str, ...] = tuple(u for _, us in UNIT_GROUPS for u in us) SignalUnit = Literal[_ALL_UNITS] | str @@ -431,6 +478,232 @@ def apply(self, expr: pl.Expr) -> pl.Expr: return (expr * m) + b +class RotationFilter(BaseFilter): + """Rotates a 3D vector component into a reference frame using a quaternion column.""" + + type: Literal[FilterType.ROTATION] = FilterType.ROTATION + """The discriminator type for Pydantic.""" + + quat_col: str = Field("", json_schema_extra={"ui_type": "col"}) + """Base name of the quaternion column group (e.g. 'Bodies/hand/xquat').""" + + invert: bool = True + """If True, transforms world-to-local (invert the quaternion rotation).""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + return expr + + def apply_with_context( + self, series: pl.Series, df: pl.DataFrame + ) -> pl.Series | None: + name = series.name + suffix = name.rsplit(":", 1)[-1] if ":" in name else "" + if suffix not in ("x", "y", "z") or not self.quat_col: + return None + base = name.rsplit(":", 1)[0] + x_col, y_col, z_col = f"{base}:x", f"{base}:y", f"{base}:z" + if not all(c in df.columns for c in (x_col, y_col, z_col)): + return None + # scipy expects (x, y, z, w) column order + q_cols = [f"{self.quat_col}:{k}" for k in ("x", "y", "z", "w")] + if not all(c in df.columns for c in q_cols): + return None + transformer = R.from_quat(df.select(q_cols).to_numpy()) + if self.invert: + transformer = transformer.inv() + v_rot = transformer.apply(df.select([x_col, y_col, z_col]).to_numpy()) + return pl.Series(name=name, values=v_rot[:, {"x": 0, "y": 1, "z": 2}[suffix]]) + + +class LogFilter(BaseFilter): + """Applies a logarithm to the signal. Defaults to natural log (base e).""" + + category: ClassVar[str] = "Arithmetic" + + type: Literal[FilterType.LOG] = FilterType.LOG + """The discriminator type for Pydantic.""" + + base: float = Field(default=math.e, gt=0) + """Logarithm base. Use e (2.718...) for natural log, 2 for log2, 10 for log10.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + return expr.log(base=self.base) + + +class ExpFilter(BaseFilter): + """Raises a base to the power of each signal sample. Defaults to e^x.""" + + category: ClassVar[str] = "Arithmetic" + + type: Literal[FilterType.EXP] = FilterType.EXP + """The discriminator type for Pydantic.""" + + base: float = Field(default=math.e, gt=0) + """The base to exponentiate. Use e (2.718...) for the natural exponential.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + if abs(self.base - math.e) < 1e-12: + return expr.exp() + return expr.map_batches( + lambda s: pl.Series(np.power(self.base, s.fill_null(0).to_numpy())), + return_dtype=pl.Float64, + ) + + +class PowerFilter(BaseFilter): + """Raises each signal sample to a fixed exponent. Supports fractional exponents (e.g. 0.5 for sqrt).""" + + category: ClassVar[str] = "Arithmetic" + + type: Literal[FilterType.POWER] = FilterType.POWER + """The discriminator type for Pydantic.""" + + exponent: float = Field(default=2.0) + """The power to raise each sample to. Use 0.5 for square root, 1/3 for cube root.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + return expr.pow(self.exponent) + + +class RoundFilter(BaseFilter): + """Quantizes the signal to a fixed number of decimal places.""" + + category: ClassVar[str] = "Bounding" + + type: Literal[FilterType.ROUND] = FilterType.ROUND + """The discriminator type for Pydantic.""" + + method: Literal["round", "floor", "ceil"] = Field( + default="round", + json_schema_extra={"ui_type": "select"}, + ) + """Rounding method: round (nearest), floor (toward -inf), or ceil (toward +inf).""" + + decimals: int = Field(default=0, ge=0) + """Number of decimal places to round to (only used when method is 'round').""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + if self.method == "floor": + return expr.floor() + if self.method == "ceil": + return expr.ceil() + return expr.round(self.decimals) + + +_TRIG_FUNCS = Literal[ + "sin", + "cos", + "tan", + "asin", + "acos", + "atan", + "sinh", + "cosh", + "tanh", + "degrees", + "radians", +] + + +class TrigFilter(BaseFilter): + """Applies a trigonometric or angle-conversion function to the signal.""" + + category: ClassVar[str] = "Trigonometry" + + type: Literal[FilterType.TRIG] = FilterType.TRIG + """The discriminator type for Pydantic.""" + + func: _TRIG_FUNCS = Field( + default="sin", + json_schema_extra={"ui_type": "select"}, + ) + """The trig function to apply.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + match self.func: + case "sin": + return expr.sin() + case "cos": + return expr.cos() + case "tan": + return expr.tan() + case "asin": + return expr.arcsin() + case "acos": + return expr.arccos() + case "atan": + return expr.arctan() + case "sinh": + return expr.sinh() + case "cosh": + return expr.cosh() + case "tanh": + return expr.tanh() + case "degrees": + return expr * (180.0 / math.pi) + case "radians": + return expr * (math.pi / 180.0) + case _: + return expr + + +class SignFilter(BaseFilter): + """Returns the sign of each sample: 1 for positive, -1 for negative, 0 for zero.""" + + category: ClassVar[str] = "Comparison" + + type: Literal[FilterType.SIGN] = FilterType.SIGN + """The discriminator type for Pydantic.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + return ( + pl.when(expr > 0) + .then(pl.lit(1.0)) + .when(expr < 0) + .then(pl.lit(-1.0)) + .otherwise(pl.lit(0.0)) + ) + + +_COMPARISON_OPS = Literal["gt", "gte", "lt", "lte", "eq", "neq"] + + +class ComparisonFilter(BaseFilter): + """Compares each sample against a threshold, returning 1.0 (true) or 0.0 (false).""" + + category: ClassVar[str] = "Comparison" + + type: Literal[FilterType.COMPARISON] = FilterType.COMPARISON + """The discriminator type for Pydantic.""" + + operator: _COMPARISON_OPS = Field( + default="gt", + json_schema_extra={"ui_type": "select"}, + ) + """Comparison operator: gt (>), gte (>=), lt (<), lte (<=), eq (==), neq (!=).""" + + threshold: float = 0.0 + """The value to compare each sample against.""" + + def apply(self, expr: pl.Expr) -> pl.Expr: + match self.operator: + case "gt": + cond = expr > self.threshold + case "gte": + cond = expr >= self.threshold + case "lt": + cond = expr < self.threshold + case "lte": + cond = expr <= self.threshold + case "eq": + cond = expr == self.threshold + case "neq": + cond = expr != self.threshold + case _: + return expr + return pl.when(cond).then(pl.lit(1.0)).otherwise(pl.lit(0.0)) + + AnyFilter = Annotated[ ScaleFilter | AbsoluteValueFilter @@ -446,7 +719,15 @@ def apply(self, expr: pl.Expr) -> pl.Expr: | UnitFilter | MedianFilter | NormalizeFilter - | WrapFilter, + | WrapFilter + | RotationFilter + | LogFilter + | ExpFilter + | PowerFilter + | RoundFilter + | TrigFilter + | SignFilter + | ComparisonFilter, Field(discriminator="type"), ] diff --git a/src/mujoco_mojo/utils/layers/cli.py b/src/mujoco_mojo/utils/layers/cli.py index 988b1ef3..d050ada7 100644 --- a/src/mujoco_mojo/utils/layers/cli.py +++ b/src/mujoco_mojo/utils/layers/cli.py @@ -1064,6 +1064,7 @@ def run_dojo( Panel( f"""[bold green]MuJoCo Mojo Dojo is Live![/bold green]\n\n{connection_info}\n\n[yellow]Press CTRL+C to stop[/yellow]""", border_style="cyan", + expand=False, title="Mojo Dojo", subtitle=f"Workers: {n_proc}", ) diff --git a/src/mujoco_mojo/utils/layers/dojo/lab_executor.py b/src/mujoco_mojo/utils/layers/dojo/lab_executor.py new file mode 100644 index 00000000..8d3e1ea9 --- /dev/null +++ b/src/mujoco_mojo/utils/layers/dojo/lab_executor.py @@ -0,0 +1,290 @@ +""" +Lab graph executor. + +Takes a LiteGraph-serialised graph (from graph.serialize() in the browser) +and executes it against a Polars DataFrame, returning the output series keyed +by their Signal Out labels. + +LiteGraph link format: + links: [[link_id, from_node_id, from_slot, to_node_id, to_slot, type], ...] +""" + +from __future__ import annotations + +from collections import defaultdict, deque +from typing import Any + +import polars as pl + +from mujoco_mojo.utils.filters.filters import ( + AbsoluteValueFilter, + ClipFilter, + ComparisonFilter, + DeadbandFilter, + ExpFilter, + HighPassFilter, + LogFilter, + LowPassFilter, + MedianFilter, + NormalizeFilter, + PowerFilter, + RollingMeanFilter, + RoundFilter, + SavitzkyGolayFilter, + ScaleFilter, + SignFilter, + TaringFilter, + TrigFilter, + WrapFilter, +) + +# Map node type string → filter class (single-input filters only) +_FILTER_MAP = { + "low_pass": LowPassFilter, + "high_pass": HighPassFilter, + "scale": ScaleFilter, + "rolling_mean": RollingMeanFilter, + "median": MedianFilter, + "savitzky_golay": SavitzkyGolayFilter, + "clip": ClipFilter, + "deadband": DeadbandFilter, + "wrap": WrapFilter, + "taring": TaringFilter, + "normalize": NormalizeFilter, + "absolute_value": AbsoluteValueFilter, + "log": LogFilter, + "exp": ExpFilter, + "power": PowerFilter, + "round": RoundFilter, + "trig": TrigFilter, + "sign": SignFilter, + "comparison": ComparisonFilter, +} + + +class LabExecutor: + """ + Execute a LiteGraph filter graph against a Polars DataFrame. + + Usage:: + + executor = LabExecutor(graph_dict) + outputs = executor.execute(df) # {label: pl.Series} + """ + + def __init__(self, graph: dict[str, Any]) -> None: + self.nodes: dict[int, dict] = {n["id"]: n for n in graph.get("nodes", [])} + # links keyed by link_id → [link_id, from_node, from_slot, to_node, to_slot, type] + self.links: dict[int, list] = {lnk[0]: lnk for lnk in graph.get("links", [])} + + # ── public helpers ──────────────────────────────────────────────────────── + + @staticmethod + def _bare_type(node: dict) -> str: + """Strip the LiteGraph category prefix, e.g. 'Signal/signal_in' → 'signal_in'.""" + return node.get("type", "").split("/")[-1] + + @property + def signal_in_columns(self) -> list[str]: + """Column names used by all Signal In nodes - used for validation.""" + return [ + n.get("properties", {}).get("column", "") + for n in self.nodes.values() + if self._bare_type(n) == "signal_in" + ] + + @property + def output_labels(self) -> list[str]: + """Labels produced by all Signal Out nodes.""" + return [ + n.get("properties", {}).get("label") or f"out_{n['id']}" + for n in self.nodes.values() + if self._bare_type(n) == "signal_out" + ] + + def execute(self, df: pl.DataFrame) -> dict[str, pl.Series]: + """Run the graph and return {output_label: series}.""" + # slot_data[node_id][slot_index] = computed series + slot_data: dict[int, dict[int, pl.Series]] = defaultdict(dict) + + for nid in self._topo_sort(): + node = self.nodes[nid] + ntype = self._bare_type(node) + props = node.get("properties", {}) + + if ntype == "signal_in": + col = props.get("column", "") + series = ( + df[col].cast(pl.Float64) + if col in df.columns + else pl.Series(name=col, values=[0.0] * len(df)) + ) + slot_data[nid][0] = series + + elif ntype == "constant": + value = float(props.get("value", 0.0)) + slot_data[nid][0] = pl.Series( + name="const", values=[value] * len(df), dtype=pl.Float64 + ) + + elif ntype == "signal_out": + signal = self._get_input(node, 0, slot_data) + if signal is not None: + slot_data[nid][0] = signal + + else: + signal = self._get_input(node, 0, slot_data) + if signal is None: + continue + wrt = self._get_input(node, 1, slot_data) + slot_data[nid][0] = self._apply(ntype, props, signal, wrt, df) + + # Collect Signal Out results + outputs: dict[str, pl.Series] = {} + for nid, node in self.nodes.items(): + if self._bare_type(node) == "signal_out": + series = slot_data.get(nid, {}).get(0) + if series is not None: + label = node.get("properties", {}).get("label") or f"out_{nid}" + outputs[label] = series + return outputs + + # ── internals ───────────────────────────────────────────────────────────── + + def _topo_sort(self) -> list[int]: + in_degree: dict[int, int] = {nid: 0 for nid in self.nodes} + adj: dict[int, list[int]] = defaultdict(list) + for _, from_node, _, to_node, _, *_ in self.links.values(): + adj[from_node].append(to_node) + in_degree[to_node] += 1 + queue = deque(nid for nid, deg in in_degree.items() if deg == 0) + order: list[int] = [] + while queue: + nid = queue.popleft() + order.append(nid) + for nxt in adj[nid]: + in_degree[nxt] -= 1 + if in_degree[nxt] == 0: + queue.append(nxt) + return order + + def _get_input( + self, + node: dict, + slot: int, + slot_data: dict[int, dict[int, pl.Series]], + ) -> pl.Series | None: + inputs = node.get("inputs", []) + if slot >= len(inputs): + return None + link_id = inputs[slot].get("link") + if link_id is None or link_id not in self.links: + return None + lnk = self.links[link_id] + from_node, from_slot = lnk[1], lnk[2] + return slot_data.get(from_node, {}).get(from_slot) + + def _apply( + self, + ntype: str, + props: dict, + signal: pl.Series, + wrt: pl.Series | None, + df: pl.DataFrame, + ) -> pl.Series: + # Individual trig nodes (one node per function, no combo widget) + _TRIG_FNS = { + "sin", + "cos", + "tan", + "asin", + "acos", + "atan", + "sinh", + "cosh", + "tanh", + "degrees", + "radians", + } + if ntype in _TRIG_FNS: + filt = TrigFilter(func=ntype) # type: ignore[arg-type] + tmp = pl.DataFrame({"_s": signal.cast(pl.Float64)}) + return tmp.with_columns(filt.apply(pl.col("_s")).alias("_s"))["_s"] + + # Individual comparison nodes - signal vs signal, returns 1.0/0.0 + _CMP_OPS = {"gt", "gte", "lt", "lte", "eq", "neq"} + if ntype in _CMP_OPS: + if wrt is None: + return signal + a = signal.cast(pl.Float64) + b = wrt.cast(pl.Float64) + if ntype == "gt": + return (a > b).cast(pl.Float64) + if ntype == "gte": + return (a >= b).cast(pl.Float64) + if ntype == "lt": + return (a < b).cast(pl.Float64) + if ntype == "lte": + return (a <= b).cast(pl.Float64) + if ntype == "eq": + return (a == b).cast(pl.Float64) + return (a != b).cast(pl.Float64) + + # Two-input arithmetic (signal a op signal b) + if ntype in ("add", "subtract", "multiply", "divide"): + if wrt is None: + return signal + a = signal.cast(pl.Float64) + b = wrt.cast(pl.Float64) + if ntype == "add": + return a + b + if ntype == "subtract": + return a - b + if ntype == "multiply": + return a * b + # divide: propagate zero denominator as null (will become NaN in JSON) + tmp = pl.DataFrame({"_a": a, "_b": b}) + return tmp.select( + pl.when(pl.col("_b") != 0) + .then(pl.col("_a") / pl.col("_b")) + .otherwise(None) + .cast(pl.Float64) + ).to_series() + + # Derivative and Integral handle wrt directly + if ntype == "derivative": + if wrt is not None: + dx = ( + wrt.cast(pl.Float64) + .diff() + .fill_null(strategy="forward") + .fill_null(1) + ) + return signal.cast(pl.Float64).diff().fill_null(0) / dx + dt = float(props.get("dt", 0.001)) or 0.001 + return signal.cast(pl.Float64).diff().fill_null(0) / dt + + if ntype == "integral": + if wrt is not None: + dx = wrt.cast(pl.Float64).diff().fill_null(0) + return (signal.cast(pl.Float64) * dx).cum_sum() + dt = float(props.get("dt", 0.001)) or 0.001 + return signal.cast(pl.Float64).cum_sum() * dt + + cls = _FILTER_MAP.get(ntype) + if cls is None: + return signal + + # Strip None values so Pydantic uses field defaults + clean = {k: v for k, v in props.items() if v is not None} + try: + filt = cls(**clean) + except Exception: + return signal + + tmp = pl.DataFrame({"_s": signal.cast(pl.Float64)}) + ctx = filt.apply_with_context(tmp["_s"], df) + if ctx is not None: + return ctx + tmp = tmp.with_columns(filt.apply(pl.col("_s")).alias("_s")) + return tmp["_s"] diff --git a/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py b/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py index 03105e6f..a4113c14 100644 --- a/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py +++ b/src/mujoco_mojo/utils/layers/dojo/routers/mosaic.py @@ -12,9 +12,11 @@ from fastapi.responses import HTMLResponse from mujoco_mojo.utils.dataframe import ColumnManifest, MojoDataFrame +from mujoco_mojo.utils.defaults import TIME_COLUMN_NAME as _TIME_COLUMN_NAME from mujoco_mojo.utils.filters.filters import UNIT_GROUPS as _UNIT_GROUPS from mujoco_mojo.utils.filters.filters import AnyFilter as _AnyFilter from mujoco_mojo.utils.filters.filters import FilterType as _FilterType +from mujoco_mojo.utils.filters.filters import RotationFilter as _RotationFilter from mujoco_mojo.utils.filters.filters import filter_adapter as _filter_adapter from mujoco_mojo.utils.log import get_logger @@ -218,6 +220,8 @@ async def get_filter_schema(): def _infer_type(prop: dict) -> str: if prop.get("ui_type") == "col": return "col" + if prop.get("ui_type") == "select": + return "select" if "anyOf" in prop: non_null = [s for s in prop["anyOf"] if s.get("type") != "null"] prop = non_null[0] if non_null else {} @@ -254,6 +258,8 @@ def _infer_type(prop: dict) -> str: default = round(float(default), 8) p: dict = {"name": name, "type": _infer_type(prop), "default": default} + if p["type"] == "select" and "enum" in prop: + p["options"] = prop["enum"] if "minimum" in prop_clean: p["min"] = prop_clean["minimum"] if "maximum" in prop_clean: @@ -271,6 +277,7 @@ def _infer_type(prop: dict) -> str: "type": type_val, "label": type_val.replace("_", " ").title(), "description": description, + "category": cls.category, "params": params, } if type_val == "unit": @@ -395,10 +402,186 @@ async def delete_profile(name: str): return {"deleted": path.relative_to(d).with_suffix("").as_posix()} +# --------------------------------------------------------------------------- +# Lab · filter graph configs stored under ~/.mujoco-mojo/lab/ +# --------------------------------------------------------------------------- + +_LAB_PREFIX = "Lab" # Virtual column category shown in the Y-axis selector +_LAB_MAX_BYTES = 1024 * 1024 # 1 MB + + +def _get_lab_dir() -> Path: + d: Path = Path.home() / ".mujoco-mojo" / "lab" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _sanitize_lab_name(name: str) -> str: + """ + Return a filesystem-safe relative path from the user-supplied lab name. + + Supports folder separators, e.g. 'robotics/arm_reach/baseline'. + Each segment is sanitized independently; empty segments are dropped. + """ + name = name.strip()[:256] + segments = [s.strip() for s in name.split("/") if s.strip()] + safe: list[str] = [] + for seg in segments: + seg = re.sub(r"[^\w\s\-]", "", seg) + seg = re.sub(r"\s+", "_", seg) + seg = re.sub(r"_+", "_", seg).strip("_") + if seg: + safe.append(seg[:64]) + return "/".join(safe) or "lab" + + +def _resolve_lab_path(name: str) -> Path: + d = _get_lab_dir() + path = (d / f"{_sanitize_lab_name(name)}.json").resolve() + if not path.is_relative_to(d.resolve()): + raise HTTPException(status_code=400, detail="Invalid lab name") + return path + + +def _lab_meta(path: Path, d: Path) -> dict: + """Parse a saved lab file and return metadata for the API.""" + from mujoco_mojo.utils.layers.dojo.lab_executor import LabExecutor + + try: + graph = json.loads(path.read_text(encoding="utf-8")) + exc = LabExecutor(graph) + return { + "name": path.relative_to(d).with_suffix("").as_posix(), + "modified": int(path.stat().st_mtime * 1000), + "signal_in_columns": exc.signal_in_columns, + "outputs": exc.output_labels, + } + except Exception: + return { + "name": path.relative_to(d).with_suffix("").as_posix(), + "modified": int(path.stat().st_mtime * 1000), + "signal_in_columns": [], + "outputs": [], + } + + +@router.get("/api/lab") +async def list_labs(): + """List all saved lab graphs with their input column requirements and output labels.""" + d = _get_lab_dir() + return [ + _lab_meta(f, d) + for f in sorted( + d.rglob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True + ) + ] + + +@router.get("/api/lab/{name:path}") +async def get_lab(name: str): + """Return the raw LiteGraph JSON for a saved lab.""" + path = _resolve_lab_path(name) + if not path.exists(): + raise HTTPException(status_code=404, detail="Lab not found") + return json.loads(path.read_text(encoding="utf-8")) + + +@router.post("/api/lab/{name:path}") +async def save_lab(name: str, request: Request): + """Save a LiteGraph graph JSON as a named lab.""" + cl = request.headers.get("content-length") + if cl and int(cl) > _LAB_MAX_BYTES: + raise HTTPException(status_code=413, detail="Lab payload too large") + body = await request.json() + path = _resolve_lab_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(body), encoding="utf-8") + d = _get_lab_dir() + return {"name": path.relative_to(d).with_suffix("").as_posix()} + + +@router.delete("/api/lab/{name:path}") +async def delete_lab(name: str): + """Delete a saved lab.""" + path = _resolve_lab_path(name) + if not path.exists(): + raise HTTPException(status_code=404, detail="Lab not found") + path.unlink() + d = _get_lab_dir() + # Remove empty parent directories up to (but not including) the lab root. + parent = path.parent + while parent != d and parent.is_dir() and not any(parent.iterdir()): + parent.rmdir() + parent = parent.parent + return {"deleted": path.relative_to(d).with_suffix("").as_posix()} + + +def _lab_dir_mtime() -> float: + """Max mtime of any file in the lab directory; used as a cache key.""" + d = _get_lab_dir() + try: + mtimes = [f.stat().st_mtime for f in d.rglob("*.json")] + return max(mtimes) if mtimes else 0.0 + except Exception: + return 0.0 + + +@lru_cache(maxsize=32) +def _valid_lab_columns_cached(parquet_cols: frozenset, lab_mtime: float) -> list: + """ + BFS to find all valid lab virtual column names for a given parquet column set. + + Mirrors the frontend loadLabSchemas logic. Cached by column frozenset + lab dir mtime. + Returns a list of 'Lab/{name}/{output}' strings. + """ + from mujoco_mojo.utils.layers.dojo.lab_executor import LabExecutor + + d = _get_lab_dir() + labs = [] + for f in sorted(d.rglob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True): + try: + graph = json.loads(f.read_text(encoding="utf-8")) + exc = LabExecutor(graph) + labs.append( + { + "name": f.relative_to(d).with_suffix("").as_posix(), + "si_cols": set(exc.signal_in_columns), + "outputs": exc.output_labels, + } + ) + except Exception: + pass + + available: set = set(parquet_cols) + valid_cols: list = [] + processed: set = set() + changed = True + while changed: + changed = False + for lab in labs: + name = lab["name"] + if name in processed: + continue + if lab["si_cols"].issubset(available): + processed.add(name) + changed = True + for out in lab["outputs"]: + col = f"{_LAB_PREFIX}/{name}/{out}" + valid_cols.append(col) + available.add(col) + return valid_cols + + +@lru_cache(maxsize=128) +def _get_mojo_df(path_str: str, mtime: float) -> MojoDataFrame: + """Zero-row MojoDataFrame for schema queries, cached by path and mtime.""" + return MojoDataFrame.from_metadata(path_str) + + @lru_cache(maxsize=128) def _get_column_manifest(path_str: str, mtime: float) -> ColumnManifest: """Retrieves all column names from the table schema.""" - return MojoDataFrame.from_metadata(path_str).mojo.get_manifest() + return _get_mojo_df(path_str, mtime).mojo.get_manifest() @lru_cache(maxsize=2048) @@ -453,12 +636,22 @@ async def get_trial_data( mtime = db_path.stat().st_mtime db_path_str = str(db_path) - # get the manifest of ALL columns in the df - column_manifest = _get_column_manifest(db_path_str, mtime) + # Parquet-only manifest (cached), then augment with valid lab virtual columns. + parquet_manifest = _get_column_manifest(db_path_str, mtime) + lab_mtime = _lab_dir_mtime() + lab_extra = _valid_lab_columns_cached(frozenset(parquet_manifest["all"]), lab_mtime) + column_manifest = ( + _get_mojo_df(db_path_str, mtime).mojo.get_manifest( + extra_columns=list(lab_extra) + ) + if lab_extra + else parquet_manifest + ) try: requested = cols.split(",") if cols else [] - available_cols = set(column_manifest["all"]) + # available_cols covers only parquet columns for actual fetch logic + available_cols = set(parquet_manifest["all"]) # determine columns to request fetch_targets = [c for c in requested if c in available_cols] @@ -474,6 +667,89 @@ async def get_trial_data( if q in available_cols and q not in fetch_targets: fetch_targets.append(q) + # ── Collect lab SI columns before building the df ───────────────────── + # Lab virtual columns are computed from the graph, not read from parquet. + # We load executors now so we can add their parquet SI deps to fetch_targets. + lab_cols = [c for c in requested if c.startswith(f"{_LAB_PREFIX}/")] + from collections import defaultdict as _dd + + by_lab: dict[str, list[tuple[str, str]]] = _dd(list) + lab_executors: dict = {} + + if lab_cols: + from mujoco_mojo.utils.layers.dojo.lab_executor import LabExecutor + + for col in lab_cols: + parts = col.split("/", 2) + if len(parts) == 3: + _, lab_name, output_label = parts + by_lab[lab_name].append((col, output_label)) + + # Load executors transitively so chained labs have their deps available. + to_load = list(by_lab.keys()) + while to_load: + name = to_load.pop() + if name in lab_executors: + continue + lab_path = _resolve_lab_path(name) + if not lab_path.exists(): + continue + try: + g = json.loads(lab_path.read_text(encoding="utf-8")) + lab_executors[name] = LabExecutor(g) + for si_col in lab_executors[name].signal_in_columns: + if si_col.startswith(f"{_LAB_PREFIX}/"): + dep_parts = si_col.split("/", 2) + if ( + len(dep_parts) == 3 + and dep_parts[1] not in lab_executors + ): + to_load.append(dep_parts[1]) + except Exception: + pass + + # Add parquet SI columns and the time column to fetch_targets. + extra_si: set[str] = set() + for executor in lab_executors.values(): + for si_col in executor.signal_in_columns: + if si_col in available_cols: + extra_si.add(si_col) + if _TIME_COLUMN_NAME in available_cols: + extra_si.add(_TIME_COLUMN_NAME) + existing_targets = set(fetch_targets) + fetch_targets.extend(c for c in extra_si if c not in existing_targets) + + # ── Pre-flight: ensure RotationFilter dependencies are fetched ──────── + # Parse filters early (errors are tolerated; full parse happens again below). + if filters: + try: + _preflight = _filter_adapter.validate_python(json.loads(filters)) + _existing = set(fetch_targets) + for _col, _flist in _preflight.items(): + for _f in _flist: + if not isinstance(_f, _RotationFilter) or not _f.quat_col: + continue + # Sibling vector components for the column being rotated + if ( + _col.endswith(":x") + or _col.endswith(":y") + or _col.endswith(":z") + ): + _base = _col.rsplit(":", 1)[0] + for _comp in ("x", "y", "z"): + _sib = f"{_base}:{_comp}" + if _sib in available_cols and _sib not in _existing: + fetch_targets.append(_sib) + _existing.add(_sib) + # Quaternion components + for _comp in ("w", "x", "y", "z"): + _qc = f"{_f.quat_col}:{_comp}" + if _qc in available_cols and _qc not in _existing: + fetch_targets.append(_qc) + _existing.add(_qc) + except Exception: + pass + # early exit for no found columns if not fetch_targets: return {"columns": column_manifest, "data": {}} @@ -488,7 +764,7 @@ async def get_trial_data( # rotate from world to rotate_by frame df = df.mojo.with_rotation(quat_base=rotate_by, invert=True) - # parse validated filter stacks (col_name → list[AnyFilter]) + # parse validated filter stacks (col_name -> list[AnyFilter]) col_filters: dict = {} filter_errors: list[str] = [] if filters: @@ -498,9 +774,78 @@ async def get_trial_data( logger.warning(f"Could not parse filters for {trial_id}: {e}") filter_errors.append(_format_filter_error(e)) - # build response data, applying per-column filters where present data: dict = {} + + # ── Execute lab virtual columns (multi-pass for chained labs) ────────── + if lab_cols and lab_executors: + exec_df = df + lab_cols_set = set(lab_cols) + + # For transitive deps not directly requested, expose all their outputs + # so downstream labs can use them as inputs via exec_df. + all_by_lab: dict[str, list[tuple[str, str]]] = dict(by_lab) + for lab_name, executor in lab_executors.items(): + if lab_name not in all_by_lab: + all_by_lab[lab_name] = [ + (f"{_LAB_PREFIX}/{lab_name}/{out}", out) + for out in executor.output_labels + ] + + remaining = dict(all_by_lab) + for _ in range(len(remaining) + 1): + if not remaining: + break + progress = False + for lab_name in list(remaining.keys()): + if lab_name not in lab_executors: + del remaining[lab_name] + progress = True + continue + executor = lab_executors[lab_name] + # Wait until all lab-virtual SI deps are present in exec_df. + if any( + c not in exec_df.columns + for c in executor.signal_in_columns + if c.startswith(f"{_LAB_PREFIX}/") + ): + continue + try: + outputs = executor.execute(exec_df) + new_series: list[pl.Series] = [] + for full_col, output_label in remaining[lab_name]: + if output_label in outputs: + s = outputs[output_label].rename(full_col) + new_series.append(s) + if full_col in lab_cols_set: + data[full_col] = s.to_list() + if new_series: + exec_df = MojoDataFrame.from_pl(exec_df.hstack(new_series)) + del remaining[lab_name] + progress = True + except Exception as exc: + logger.warning(f"Lab '{lab_name}' execution failed: {exc}") + del remaining[lab_name] + progress = True + if not progress: + break + + # ── build response data, applying per-column filters where present ──── for col in requested: + if col.startswith(f"{_LAB_PREFIX}/"): + # Lab output already in data; apply any stacked filters on top. + filter_list = col_filters.get(col) + if filter_list and col in data: + series = pl.Series(name=col, values=data[col], dtype=pl.Float64) + for f in filter_list: + ctx = f.apply_with_context(series, df) + if ctx is not None: + series = ctx + else: + tmp = pl.DataFrame({col: series}) + tmp = tmp.with_columns(f.apply(pl.col(col)).alias(col)) + series = tmp[col] + data[col] = series.to_list() + continue if col not in df.columns: continue series = df[col] diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/base.html b/src/mujoco_mojo/utils/layers/dojo/templates/base.html index 0f8e3094..69e01128 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/base.html +++ b/src/mujoco_mojo/utils/layers/dojo/templates/base.html @@ -21,14 +21,6 @@ - - - @@ -101,11 +93,6 @@ } } - + + +
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/main.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/main.js index c991af78..d3a10a40 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/main.js +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/main.js @@ -30,7 +30,11 @@ isAutoRefresh: localStorage.getItem("mojo_auto") !== "false", isConnected: false, _wasConnected: null, - globalToast: { show: false, message: "", type: "info" }, + globalToast: { + show: false, + message: "", + type: "info" + }, isSyncing: false, syncProgress: 0, secondsSinceUpdate: 0, @@ -114,7 +118,7 @@ } if (connected === this._wasConnected) return; this._wasConnected = connected; - const message = connected ? "Server connection restored." : "Server connection lost."; + const message = connected ? "Server connection restored" : "Server connection lost"; const type = connected ? "success" : "error"; this.toast(message, type); this.addNotification(message, type); @@ -154,7 +158,9 @@ }, startLoadingMessages() { if (this.loadingInterval) return; - this.loadingIndex = Math.floor(Math.random() * this.loadingPhrases.length); + this.loadingIndex = Math.floor( + Math.random() * this.loadingPhrases.length + ); this.showPhrase = true; this.loadingInterval = setInterval(() => { this.showPhrase = false; @@ -203,7 +209,8 @@ try { const data = JSON.parse(event.data); if (data.type === "start") this.startSync(); - if (data.type === "progress" && data.value !== void 0) this.setSyncProgress(data.value); + if (data.type === "progress" && data.value !== void 0) + this.setSyncProgress(data.value); if (data.type === "final") { this.endSync(Date.now(), data.status?.is_complete ?? false); window.dispatchEvent( @@ -211,7 +218,10 @@ ); } } catch (err) { - console.warn("[Mojo Sync] Received invalid payload.", { raw: event.data, error: err }); + console.warn("[Mojo Sync] Received invalid payload.", { + raw: event.data, + error: err + }); } }; this.source.onerror = () => { @@ -260,10 +270,13 @@ notifTick: Date.now(), _saveNotifications() { try { - localStorage.setItem("mojo_notif", JSON.stringify({ - n: this.notifications, - u: this.unreadCount - })); + localStorage.setItem( + "mojo_notif", + JSON.stringify({ + n: this.notifications, + u: this.unreadCount + }) + ); } catch { } }, @@ -300,7 +313,9 @@ const store = Alpine.store("dojo"); setInterval(() => { if (store.lastUpdate) { - store.secondsSinceUpdate = Math.floor((Date.now() - store.lastUpdate) / 1e3); + store.secondsSinceUpdate = Math.floor( + (Date.now() - store.lastUpdate) / 1e3 + ); } }, 1e3); setInterval(() => { diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/monitor.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/monitor.js index 64dcdd36..0c00e15f 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/monitor.js +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/monitor.js @@ -24,7 +24,10 @@ const resp = await fetch("/monitor/api/status"); const data = await resp.json(); if (data && !data.error) { - Alpine.store("dojo").updateSync(Date.now(), data.is_complete); + Alpine.store("dojo").updateSync( + Date.now(), + data.is_complete + ); this.handleDataUpdate(data); } } catch (e) { @@ -32,13 +35,20 @@ } finally { const store = Alpine.store("dojo"); store.startGlobalSync(); - store.setPageReady(true, this.status.is_complete); + store.setPageReady( + true, + this.status.is_complete + ); } }, handleDataUpdate(data) { const wasInit = this.hasInitialData; const prev = { ...this.prevStatus }; - this.prevStatus = { n_done: data.n_done, n_success: data.n_success, n_failed: data.n_failed }; + this.prevStatus = { + n_done: data.n_done, + n_success: data.n_success, + n_failed: data.n_failed + }; this.status = data; this.hasInitialData = true; this.refreshStats(); @@ -52,10 +62,13 @@ if (newDone === 1) { const failed = newFailed === 1; const trialId = failed ? data.failure_tns.at(-1) : data.success_tns.at(-1); - store.addNotification(`Trial ${trialId} ${failed ? "failed" : "succeeded"}`, failed ? "error" : "success"); + store.addNotification( + `Trial ${trialId} ${failed ? "failed" : "succeeded"}`, + failed ? "error" : "success" + ); } else { store.addNotification( - `${newDone} trials done \u2014 ${newDone - newFailed} ok, ${newFailed} failed`, + `${newDone} trials done - ${newDone - newFailed} ok, ${newFailed} failed`, newFailed > 0 ? "error" : "success" ); } @@ -121,7 +134,7 @@ this.hasCelebrated = true; const store = Alpine.store("dojo"); store.addNotification( - `Job complete in ${this.status.elapsed} \u2014 ${this.status.n_success} succeeded, ${this.status.n_failed} failed`, + `Job complete in ${this.status.elapsed} - ${this.status.n_success} succeeded, ${this.status.n_failed} failed`, this.status.n_failed > 0 ? "error" : "success" ); const theme = this.getHolidayTheme(); @@ -144,24 +157,47 @@ const m = now.getMonth(); const d = now.getDate(); if (m === 11 && d === 31 || m === 0 && d <= 2) { - return { name: "New Year", emojis: ["\u{1F386}", "\u2728", "\u{1F942}"], colors: ["#ffcc00", "#ffffff"] }; + return { + name: "New Year", + emojis: ["\u{1F386}", "\u2728", "\u{1F942}"], + colors: ["#ffcc00", "#ffffff"] + }; } if (m === 2 && d === 14) { return { name: "Pi Day", emojis: ["\u03C0", "\u{1F967}"], colors: ["#ff9900"] }; } if (m === 2 && d === 17) { - return { name: "St. Patrick's Day", emojis: ["\u{1F340}", "\u{1F308}"], colors: ["#22c55e", "#166534"] }; + return { + name: "St. Patrick's Day", + emojis: ["\u{1F340}", "\u{1F308}"], + colors: ["#22c55e", "#166534"] + }; } if (m === 4 && d === 4) { - return { name: "May the 4th", emojis: ["\u2694\uFE0F", "\u{1F30C}", "\u2728"], colors: ["#FFE81F", "#2dd4bf"] }; + return { + name: "May the 4th", + emojis: ["\u2694\uFE0F", "\u{1F30C}", "\u2728"], + colors: ["#FFE81F", "#2dd4bf"] + }; } if (m === 9 && d >= 25 || m === 10 && d === 1) { - return { name: "Halloween", emojis: ["\u{1F383}", "\u{1F47B}", "\u{1F987}"], colors: ["#ff6600", "#9437ff"] }; + return { + name: "Halloween", + emojis: ["\u{1F383}", "\u{1F47B}", "\u{1F987}"], + colors: ["#ff6600", "#9437ff"] + }; } if (m === 11 || m === 0 && d <= 15) { - return { name: "Winter Snow", emojis: ["\u2744\uFE0F", "\u26C4", "\u{1F328}\uFE0F"], isSnow: true }; + return { + name: "Winter Snow", + emojis: ["\u2744\uFE0F", "\u26C4", "\u{1F328}\uFE0F"], + isSnow: true + }; } - return { name: "Standard Mojo", colors: ["#06b6d4", "#3b82f6", "#22c55e"] }; + return { + name: "Standard Mojo", + colors: ["#06b6d4", "#3b82f6", "#22c55e"] + }; }, fireConfetti(theme = { name: "Standard Mojo" }) { const themeName = theme.name ?? "Standard Mojo"; @@ -188,22 +224,37 @@ scalar: isSpecial ? 5 : 1, gravity: isSnow ? 0.4 : isSpecial ? 0.4 : 1.2 }; - const interval = setInterval(() => { - const timeLeft = animationEnd - Date.now(); - if (timeLeft <= 0) { - clearInterval(interval); - return; - } - if (isSnow) { - confetti({ ...defaults, particleCount: 1, startVelocity: 0, drift: (Math.random() - 0.5) * 1.5, origin: { x: Math.random(), y: -0.2 } }); - } else { - const countMultiplier = isSpecial ? 40 : 150; - const particleCount = countMultiplier * (timeLeft / duration); - for (let i = 0; i < 3; i++) { - confetti({ ...defaults, particleCount: Math.ceil(particleCount / 3), spread: isSpecial ? 90 : 360, startVelocity: isSpecial ? 15 : 45, origin: { x: Math.random(), y: Math.random() - 0.2 } }); + const interval = setInterval( + () => { + const timeLeft = animationEnd - Date.now(); + if (timeLeft <= 0) { + clearInterval(interval); + return; } - } - }, isSpecial ? 400 : 250); + if (isSnow) { + confetti({ + ...defaults, + particleCount: 1, + startVelocity: 0, + drift: (Math.random() - 0.5) * 1.5, + origin: { x: Math.random(), y: -0.2 } + }); + } else { + const countMultiplier = isSpecial ? 40 : 150; + const particleCount = countMultiplier * (timeLeft / duration); + for (let i = 0; i < 3; i++) { + confetti({ + ...defaults, + particleCount: Math.ceil(particleCount / 3), + spread: isSpecial ? 90 : 360, + startVelocity: isSpecial ? 15 : 45, + origin: { x: Math.random(), y: Math.random() - 0.2 } + }); + } + } + }, + isSpecial ? 400 : 250 + ); } }; } diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/trial-viewer.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/trial-viewer.js index 01145f4d..dccb8622 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/js/trial-viewer.js +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/js/trial-viewer.js @@ -116,9 +116,14 @@ annotations: [], shapes: [] }; + var _cm = { + editor: null, + updating: false, + debounce: null + }; function trialViewer(trialId, externalUrl) { const self = { - // Alpine magic (injected at runtime — declared here for TS) + // Alpine magic (injected at runtime - declared here for TS) ...null, // --- BASE STATE --- trialId, @@ -155,7 +160,7 @@ ], // Toast (shared mixin) ...createToastMixin(), - // Options — exposed so templates can use opts.lineMode, opts.interpLabel(...), etc. + // Options - exposed so templates can use opts.lineMode, opts.interpLabel(...), etc. opts: OPTIONS, // --- PLOT CONFIGURATION --- config: JSON.parse(JSON.stringify(DEFAULT_CONFIG)), @@ -197,6 +202,20 @@ annotationsOpen: false, annDraft: null, annEditIndex: null, + // --- FILTER LAB --- + labOpen: localStorage.getItem("mojo:lab:open") === "1", + labGraph: (() => { + try { + const s = localStorage.getItem("mojo:lab:draft"); + return s ? JSON.parse(s) : null; + } catch { + return null; + } + })(), + labName: localStorage.getItem("mojo:lab:name") ?? "", + nodePickingColumn: null, + nodeColSearch: "", + labSchemas: [], // --- SHAPES --- shapesOpen: false, placementMode: null, @@ -275,10 +294,12 @@ const colParams = new URLSearchParams(); if (requiredCols.length > 0) colParams.append("cols", requiredCols.join(",")); - if (this.config.refFrame) - colParams.append("rotate_by", this.config.refFrame); const filtersPayload = {}; - const toActiveFilters = (filters) => filters.filter((f) => f.enabled !== false).map((f) => Object.fromEntries(Object.entries(f).filter(([k]) => k !== "enabled"))); + const toActiveFilters = (filters) => filters.filter((f) => f.enabled !== false).map( + (f) => Object.fromEntries( + Object.entries(f).filter(([k]) => k !== "enabled") + ) + ); for (const col of requiredCols) { const yConfig = this.config.yAxes[col]; if (yConfig?.filters && yConfig.filters.length > 0) { @@ -358,7 +379,10 @@ if (currentId !== this.discoveryId) return; const start = Math.min(this.vsDraft.range[0], this.vsDraft.range[1]); const end = Math.max(this.vsDraft.range[0], this.vsDraft.range[1]); - const activeCols = [this.config.xAxis.col, ...Object.keys(this.config.yAxes)]; + const activeCols = [ + this.config.xAxis.col, + ...Object.keys(this.config.yAxes) + ]; const draftIds = this.allTrials.filter((id) => { const n = parseInt(id.split("_").pop() ?? ""); return n >= start && n <= end && id !== this.trialId; @@ -583,6 +607,7 @@ this.columns = response.columns.all.sort(); this.rotateableVectors = response.columns.rotatable_vectors ?? []; this.data = response.data; + void this.loadLabSchemas(); const params = new URLSearchParams(window.location.search); const shared = params.get("v"); if (shared) { @@ -595,6 +620,12 @@ this.vsDraft.enabled = this.config.vsEnabled; this.vsDraft.range = [...this.config.vsRange]; } + if (this.config.refFrame) { + const hasRotation = Object.values(this.config.yAxes).some( + (y) => y.filters.some((f) => f.type === "rotation") + ); + if (!hasRotation) this.applyRefFrame(this.config.refFrame); + } void this.$nextTick(() => { this.pushHistory(); }); @@ -693,47 +724,101 @@ Alpine.store("dojo").startGlobalSync(); Alpine.store("dojo").setPageReady(true); } - window.addEventListener("keydown", (e) => { - if (e.repeat) return; - const tag = e.target.tagName; - if (e.key === "/" && !["INPUT", "TEXTAREA"].includes(tag)) { - e.preventDefault(); - document.querySelector('input[type="number"]')?.focus(); - } - if (e.key === "Escape") { - const anyOpen = !!(this.placementMode || this.annotationsOpen || this.shapesOpen || this.xMenuOpen || this.yMenuOpen || this.refFrameMenuOpen || this.settingsOpen || this.downloadOpen || this.editorOpen || this.profilesOpen || this.vsMenuOpen || Alpine.store("dojo").overlayCount > 0 || ["INPUT", "TEXTAREA"].includes(tag)); - if (["INPUT", "TEXTAREA"].includes(tag)) - e.target.blur(); - this.placementMode = null; - this.rectStart = null; - this.cancelAnnDraft(); - this.cancelShapeDraft(); - this.annotationsOpen = false; - this.shapesOpen = false; - this.xMenuOpen = this.yMenuOpen = this.refFrameMenuOpen = false; - this.settingsOpen = this.downloadOpen = this.editorOpen = false; - this.profilesOpen = this.vsMenuOpen = false; - this.profileSearch = ""; - window.dispatchEvent(new CustomEvent("mojo:escape")); - if (anyOpen) e.stopImmediatePropagation(); - } - if (["INPUT", "TEXTAREA"].includes(tag)) return; - if (e.key === "ArrowLeft") document.getElementById("nav-prev")?.click(); - if (e.key === "ArrowRight") - document.getElementById("nav-next")?.click(); - const isZ = e.key.toLowerCase() === "z"; - const isY = e.key.toLowerCase() === "y"; - const cmdOrCtrl = e.metaKey || e.ctrlKey; - if (cmdOrCtrl && isZ) { - e.preventDefault(); - if (e.shiftKey) this.redo(); - else this.undo(); - } - if (cmdOrCtrl && isY) { - e.preventDefault(); - this.redo(); - } - }, { capture: true }); + window.addEventListener( + "keydown", + (e) => { + if (e.repeat) return; + const targetEl = e.target; + const tag = targetEl.tagName; + const isTextInput = ["INPUT", "TEXTAREA", "SELECT"].includes(tag) || targetEl.isContentEditable; + if (e.key === "/" && !isTextInput) { + e.preventDefault(); + document.querySelector( + 'input[type="number"]' + )?.focus(); + } + if (e.key === "Escape") { + const anyOpen = !!(this.placementMode || this.annotationsOpen || this.shapesOpen || this.xMenuOpen || this.yMenuOpen || this.refFrameMenuOpen || this.settingsOpen || this.downloadOpen || this.editorOpen || this.profilesOpen || this.vsMenuOpen || this.labOpen || Alpine.store("dojo").overlayCount > 0 || isTextInput); + if (isTextInput) targetEl.blur(); + this.placementMode = null; + this.rectStart = null; + this.cancelAnnDraft(); + this.cancelShapeDraft(); + this.annotationsOpen = false; + this.shapesOpen = false; + this.xMenuOpen = this.yMenuOpen = this.refFrameMenuOpen = false; + this.settingsOpen = this.downloadOpen = this.editorOpen = false; + this.profilesOpen = this.vsMenuOpen = false; + this.profileSearch = ""; + this.labOpen = false; + window.dispatchEvent(new CustomEvent("mojo:escape")); + if (anyOpen) e.stopImmediatePropagation(); + } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + if (!isTextInput) { + if (this.labOpen) { + const el = document.getElementById( + "lab-name-input" + ); + if (el) { + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + } + } else { + this.profilesOpen = true; + void this.loadProfiles(); + void this.$nextTick(() => { + const el = document.getElementById( + "profile-name-input" + ); + if (el) { + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + } + }); + } + } + } + if ((e.metaKey || e.ctrlKey) && !isTextInput) { + if (e.key === "ArrowLeft") { + e.preventDefault(); + document.getElementById("nav-prev")?.click(); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + document.getElementById("nav-next")?.click(); + } + } + if (isTextInput) return; + const isZ = e.key.toLowerCase() === "z"; + const isY = e.key.toLowerCase() === "y"; + const cmdOrCtrl = e.metaKey || e.ctrlKey; + if (cmdOrCtrl && isZ) { + e.preventDefault(); + if (this.labOpen) { + e.shiftKey ? window.mojoLabRedo?.() : window.mojoLabUndo?.(); + } else { + if (e.shiftKey) this.redo(); + else this.undo(); + } + } + if (cmdOrCtrl && isY) { + e.preventDefault(); + if (this.labOpen) window.mojoLabRedo?.(); + else this.redo(); + } + if (this.labOpen && cmdOrCtrl && e.shiftKey) { + if (e.key.toLowerCase() === "a") { + e.preventDefault(); + window.mojoLabArrange?.(); + } else if (e.key.toLowerCase() === "f") { + e.preventDefault(); + window.mojoLabFitView?.(); + } + } + }, + { capture: true } + ); const resp = await fetch("/mosaic/api/trials"); const data = await resp.json(); this.allTrials = data.trials ?? []; @@ -749,39 +834,23 @@ this.$watch("vsDraft.range", () => { if (this.discoveryTimeout) clearTimeout(this.discoveryTimeout); this.discoveryTimeout = setTimeout(() => { - if (this.vsDraft.enabled) { - console.debug( - "Predictive Sync: User adjusted range, starting hydration..." - ); - void this.startBackgroundDiscovery(); - } + if (this.vsDraft.enabled) void this.startBackgroundDiscovery(); }, 500); }); - this.$watch( - "config.refFrame", - async (newValue, oldValue) => { - console.debug( - `[Mojo] Frame Change: ${oldValue ?? "world"} -> ${newValue ?? "world"}` - ); - this.notify(`Frame: ${newValue || "world"}`, "info"); - this.discoveryId++; - this.data = {}; - this.vsDatasets = {}; - const initialCols = [ - this.config.xAxis.col, - ...Object.keys(this.config.yAxes) - ]; - const response = await this.fetchTrialData(this.trialId, initialCols); - this.columns = response.columns.all.sort(); - this.rotateableVectors = response.columns.rotatable_vectors ?? []; - this.data = response.data; - void this.startBackgroundDiscovery(); - if (this.config.vsEnabled) await this.syncVsRange(); - this.saveAndRender(); - } - ); + this.$watch("config.refFrame", (newValue, oldValue) => { + if (newValue === oldValue) return; + this.notify(`Frame: ${newValue || "world"}`, "info"); + this.discoveryId++; + this.applyRefFrame(newValue); + }); this.$watch("config", async (value, oldValue) => { - if (!this.isEditingRaw) this.configRaw = JSON.stringify(value, null, 4); + if (!this.isEditingRaw) { + this.configRaw = JSON.stringify(value, null, 4); + try { + localStorage.removeItem("mojo:config:raw-draft"); + } catch { + } + } if (this.config.vsEnabled && oldValue?.vsEnabled && (value.xAxis.col !== oldValue?.xAxis?.col || Object.keys(value.yAxes).length !== Object.keys(oldValue.yAxes ?? {}).length)) { await this.syncVsRange(); } @@ -823,7 +892,8 @@ this.saveAndRender(); }); void this.startBackgroundDiscovery(); - this.configRaw = JSON.stringify(this.config, null, 4); + this.configRaw = localStorage.getItem("mojo:config:raw-draft") ?? JSON.stringify(this.config, null, 4); + this.updateFromRaw(); }, // ----------------------------------------------------------------------- // VS (comparison) mode @@ -845,7 +915,10 @@ try { const start = Math.min(this.vsDraft.range[0], this.vsDraft.range[1]); const end = Math.max(this.vsDraft.range[0], this.vsDraft.range[1]); - let activeCols = [this.config.xAxis.col, ...Object.keys(this.config.yAxes)]; + let activeCols = [ + this.config.xAxis.col, + ...Object.keys(this.config.yAxes) + ]; if (this.config.refFrame) { const families = /* @__PURE__ */ new Set(); Object.keys(this.config.yAxes).forEach((col) => { @@ -899,9 +972,39 @@ handleVsToggle() { if (!this.vsDraft.enabled) { this.config.vsEnabled = false; + this.vsDatasets = {}; this.renderPlot(); } }, + setVsPreset(delta) { + const cur = parseInt(this.trialId.split("_").pop() ?? "0"); + this.vsDraft.range = [cur - delta, cur + delta]; + }, + setVsAll() { + const nums = this.allTrials.map((t) => parseInt(t.split("_").pop() ?? "")).filter((n) => !isNaN(n)); + if (!nums.length) return; + this.vsDraft.range = [Math.min(...nums), Math.max(...nums)]; + }, + isVsPreset(delta) { + const cur = parseInt(this.trialId.split("_").pop() ?? "0"); + const [a, b] = this.vsDraft.range; + return Math.min(a, b) === cur - delta && Math.max(a, b) === cur + delta; + }, + isVsAll() { + const nums = this.allTrials.map((t) => parseInt(t.split("_").pop() ?? "")).filter((n) => !isNaN(n)); + if (!nums.length) return false; + const [a, b] = this.vsDraft.range; + return Math.min(a, b) === Math.min(...nums) && Math.max(a, b) === Math.max(...nums); + }, + vsInRangeCount() { + const lo = Math.min(this.vsDraft.range[0], this.vsDraft.range[1]); + const hi = Math.max(this.vsDraft.range[0], this.vsDraft.range[1]); + const cur = parseInt(this.trialId.split("_").pop() ?? ""); + return this.allTrials.filter((id) => { + const n = parseInt(id.split("_").pop() ?? ""); + return n >= lo && n <= hi && n !== cur; + }).length; + }, // ----------------------------------------------------------------------- // Column filtering & search // ----------------------------------------------------------------------- @@ -916,7 +1019,7 @@ }, getFilteredCols(field) { if (!this.columns || !Array.isArray(this.columns)) return []; - const base = field === "x" ? this.columns : this.selectableYColumns; + const base = field === "x" || field === "nodeCol" ? this.columns : this.selectableYColumns; const search = this[field + "Search"] ?? ""; if (!search) return this.smartSort([...base]); try { @@ -962,7 +1065,7 @@ self2[key] = (pathPart ?? "") + (suffixPart ? ":" + suffixPart : ""); }, getSegmentsAtDepth(field, depth) { - const base = field === "x" ? this.columns : this.selectableYColumns; + const base = field === "x" || field === "nodeCol" ? this.columns : this.selectableYColumns; const search = this[field + "Search"] ?? ""; const pathSearch = search.split(":")[0] ?? ""; const parts = pathSearch.split("/").filter((p) => p !== ""); @@ -977,7 +1080,7 @@ return this.smartSort([.../* @__PURE__ */ new Set([...selected, ...segments])]); }, getAvailableSuffixes(field) { - const base = field === "x" ? this.columns : this.selectableYColumns; + const base = field === "x" || field === "nodeCol" ? this.columns : this.selectableYColumns; const search = this[field + "Search"] ?? ""; const [pathPart = "", suffixPart = ""] = search.split(":"); const selected = (suffixPart ?? "").replace(/[()]/g, "").split("|").filter(Boolean).map((s) => ":" + s); @@ -1037,20 +1140,42 @@ }, validateConfig(cfg) { const errors = []; - if (cfg.xAxis?.col && !this.columns.includes(cfg.xAxis.col)) - errors.push(`X-Axis "${cfg.xAxis.col}" not found in telemetry.`); + const labsNoted = /* @__PURE__ */ new Set(); + const schemasLoaded = this.labSchemas.length > 0; + const checkCol = (col, label) => { + if (col.startsWith("Lab/")) { + if (!schemasLoaded) return; + const labName = col.slice(4).split("/")[0]; + if (labsNoted.has(labName)) return; + const lab = this.labSchemas.find((l) => l.name === labName); + if (!lab) { + labsNoted.add(labName); + errors.push(`Lab "${labName}" not found.`); + } else if (lab.missing.length > 0) { + labsNoted.add(labName); + errors.push( + `Lab "${labName}" requires: ${lab.signal_in_columns.join(", ")}; missing: ${lab.missing.join(", ")}.` + ); + } + } else if (!this.columns.includes(col)) { + errors.push(`${label} "${col}" not found in telemetry.`); + } + }; + if (cfg.xAxis?.col) checkCol(cfg.xAxis.col, "X-Axis"); if (typeof cfg.yAxes !== "object" || Array.isArray(cfg.yAxes)) { errors.push("yAxes must be a hashmap."); } else { - Object.keys(cfg.yAxes).forEach((y) => { - if (!this.columns.includes(y)) errors.push(`Y-Axis "${y}" missing.`); - }); + Object.keys(cfg.yAxes).forEach((y) => checkCol(y, "Y-Axis")); } if (cfg.vsRange && cfg.vsRange[0] > cfg.vsRange[1]) errors.push("Comparison range start cannot be greater than end."); return errors; }, updateFromRaw() { + try { + localStorage.setItem("mojo:config:raw-draft", this.configRaw); + } catch { + } try { const parsed = JSON.parse(this.configRaw); this.isValidJson = true; @@ -1058,8 +1183,13 @@ this.configErrors = this.validateConfig(parsed); this.isValidConfig = this.configErrors.length === 0; if (this.isValidConfig) { + const prevRefFrame = this.config.refFrame ?? null; this.isEditingRaw = true; this.config = { ...this.config, ...parsed }; + const nextRefFrame = this.config.refFrame ?? null; + if (nextRefFrame !== prevRefFrame) { + this.applyRefFrame(nextRefFrame); + } void this.$nextTick(() => { this.isEditingRaw = false; }); @@ -1141,6 +1271,163 @@ copyRawConfig() { void this.copyToClipboard(this.configRaw, "JSON Config copied!"); }, + initCodeMirror(hostEl) { + if (!hostEl || typeof CM === "undefined" || _cm.editor) return; + const { + EditorView, + basicSetup, + json, + jsonParseLinter, + oneDarkHighlightStyle, + EditorState, + Compartment, + linter, + lintGutter, + syntaxHighlighting, + defaultHighlightStyle + } = CM; + const self2 = this; + const savedH = localStorage.getItem("mojo:json-editor:height"); + if (savedH) hostEl.style.height = savedH; + const darkTheme = EditorView.theme({ + "&": { backgroundColor: "#020617", color: "#cbd5e1", height: "100%" }, + ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "0.875rem", lineHeight: "1.625" }, + ".cm-content": { padding: "1rem", caretColor: "#06b6d4" }, + ".cm-cursor": { borderLeftColor: "#06b6d4" }, + ".cm-gutters": { backgroundColor: "#0f172a", color: "#475569", borderRight: "1px solid #1e293b" }, + ".cm-activeLineGutter": { backgroundColor: "rgba(15,23,42,0.6)" }, + ".cm-activeLine": { backgroundColor: "rgba(15,23,42,0.4)" }, + ".cm-selectionBackground": { backgroundColor: "#1e293b !important" }, + "&.cm-focused .cm-selectionBackground": { backgroundColor: "#1e293b !important" }, + ".cm-matchingBracket": { color: "#22d3ee", fontWeight: "bold" }, + ".cm-tooltip": { backgroundColor: "#1e293b", border: "1px solid #334155", color: "#cbd5e1" }, + ".cm-panels": { backgroundColor: "#0f172a", borderColor: "#1e293b", color: "#cbd5e1" }, + ".cm-searchMatch": { backgroundColor: "rgba(34,211,238,0.18)" }, + ".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "rgba(34,211,238,0.35)" }, + ".cm-lintRange-error": { backgroundImage: "none", textDecoration: "underline wavy #ef4444 1.5px", textUnderlineOffset: "3px" }, + ".cm-lintRange-warning": { backgroundImage: "none", textDecoration: "underline wavy #f59e0b 1.5px", textUnderlineOffset: "3px" }, + ".cm-diagnostic-error": { borderLeft: "3px solid #ef4444" } + }, { dark: true }); + const lightTheme = EditorView.theme({ + "&": { backgroundColor: "#ffffff", color: "#0f172a", height: "100%" }, + ".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: "0.875rem", lineHeight: "1.625" }, + ".cm-content": { padding: "1rem", caretColor: "#0891b2" }, + ".cm-cursor": { borderLeftColor: "#0891b2" }, + ".cm-gutters": { backgroundColor: "#f8fafc", color: "#94a3b8", borderRight: "1px solid #e2e8f0" }, + ".cm-activeLineGutter": { backgroundColor: "rgba(241,245,249,0.6)" }, + ".cm-activeLine": { backgroundColor: "rgba(241,245,249,0.5)" }, + ".cm-selectionBackground": { backgroundColor: "#e2e8f0 !important" }, + "&.cm-focused .cm-selectionBackground": { backgroundColor: "#e2e8f0 !important" }, + ".cm-matchingBracket": { color: "#0891b2", fontWeight: "bold" }, + ".cm-tooltip": { backgroundColor: "#f8fafc", border: "1px solid #e2e8f0", color: "#0f172a" }, + ".cm-panels": { backgroundColor: "#f8fafc", borderColor: "#e2e8f0" }, + ".cm-searchMatch": { backgroundColor: "rgba(8,145,178,0.15)" }, + ".cm-searchMatch.cm-searchMatch-selected": { backgroundColor: "rgba(8,145,178,0.3)" }, + ".cm-lintRange-error": { backgroundImage: "none", textDecoration: "underline wavy #ef4444 1.5px", textUnderlineOffset: "3px" }, + ".cm-lintRange-warning": { backgroundImage: "none", textDecoration: "underline wavy #f59e0b 1.5px", textUnderlineOffset: "3px" }, + ".cm-diagnostic-error": { borderLeft: "3px solid #ef4444" } + }, { dark: false }); + const isDark = () => document.documentElement.classList.contains("dark"); + const themeComp = new Compartment(); + const highlightComp = new Compartment(); + const makeTheme = (dark) => dark ? darkTheme : lightTheme; + const makeHighlight = (dark) => syntaxHighlighting(dark ? oneDarkHighlightStyle : defaultHighlightStyle); + const startState = EditorState.create({ + doc: this.configRaw, + extensions: [ + basicSetup, + json(), + lintGutter(), + linter(jsonParseLinter()), + themeComp.of(makeTheme(isDark())), + highlightComp.of(makeHighlight(isDark())), + EditorView.updateListener.of((update) => { + if (update.docChanged && !_cm.updating) { + const text = update.state.doc.toString(); + _cm.updating = true; + self2.configRaw = text; + if (_cm.debounce !== null) clearTimeout(_cm.debounce); + _cm.debounce = setTimeout(() => { + self2.updateFromRaw(); + _cm.debounce = null; + }, 500); + _cm.updating = false; + } + }) + ] + }); + _cm.editor = new EditorView({ state: startState, parent: hostEl }); + const handle = document.createElement("div"); + handle.style.cssText = "height:14px;cursor:ns-resize;display:flex;align-items:center;justify-content:center;flex-shrink:0;"; + const grip = document.createElement("div"); + grip.style.cssText = "width:36px;height:4px;border-radius:2px;background:#334155;transition:background 150ms,width 150ms;pointer-events:none;"; + handle.appendChild(grip); + handle.addEventListener("mouseenter", () => { + grip.style.background = "#06b6d4"; + grip.style.width = "52px"; + }); + handle.addEventListener("mouseleave", () => { + grip.style.background = "#334155"; + grip.style.width = "36px"; + }); + hostEl.insertAdjacentElement("afterend", handle); + handle.addEventListener("mousedown", (e) => { + const startY = e.clientY; + const startH = hostEl.offsetHeight; + let prevY = startY; + document.body.style.userSelect = "none"; + document.body.style.cursor = "ns-resize"; + const onMove = (ev) => { + const dy = ev.clientY - prevY; + prevY = ev.clientY; + const newH = Math.max(128, startH + (ev.clientY - startY)); + hostEl.style.height = newH + "px"; + if (dy > 0) window.scrollBy(0, dy); + }; + const onUp = () => { + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + try { + localStorage.setItem("mojo:json-editor:height", hostEl.style.height); + } catch { + } + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + e.preventDefault(); + }); + handle.addEventListener("dblclick", () => { + const scroller = hostEl.querySelector(".cm-scroller"); + if (scroller) { + hostEl.style.height = scroller.scrollHeight + "px"; + try { + localStorage.setItem("mojo:json-editor:height", hostEl.style.height); + } catch { + } + } + }); + new MutationObserver(() => { + const dark = isDark(); + _cm.editor?.dispatch({ + effects: [ + themeComp.reconfigure(makeTheme(dark)), + highlightComp.reconfigure(makeHighlight(dark)) + ] + }); + }).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + this.$watch("configRaw", (val) => { + if (!_cm.updating && _cm.editor) { + const current = _cm.editor.state.doc.toString(); + if (current !== val) { + _cm.updating = true; + _cm.editor.dispatch({ changes: { from: 0, to: current.length, insert: val } }); + _cm.updating = false; + } + } + }); + }, resetConfig() { if (confirm( "Reset plot to factory defaults? This will clear your current view." @@ -1225,7 +1512,10 @@ }, downloadCSV() { if (!this.data || Object.keys(this.config.yAxes).length === 0) return; - const activeCols = [this.config.xAxis.col, ...Object.keys(this.config.yAxes)]; + const activeCols = [ + this.config.xAxis.col, + ...Object.keys(this.config.yAxes) + ]; const rowCount = this.data[this.config.xAxis.col]?.length ?? 0; let csv = activeCols.join(",") + "\n"; for (let i = 0; i < rowCount; i++) { @@ -1286,12 +1576,13 @@ this.config.yAxes = rest; } else { const nextIndex = Object.keys(this.config.yAxes).length; + const initFilters = this.config.refFrame ? [{ type: "rotation", quat_col: this.config.refFrame, invert: true, enabled: true }] : []; this.config.yAxes[col] = { color: this.getSignalColor(nextIndex), label: "", width: 3, opacity: 1, - filters: [], + filters: initFilters, dash: "solid", marker: "none" }; @@ -1305,6 +1596,38 @@ this.configRaw = JSON.stringify(this.config, null, 4); this.notify("Signals Cleared", "info"); }, + applyRefFrame(frame) { + for (const col of Object.keys(this.config.yAxes)) { + const yConfig = this.config.yAxes[col]; + if (!yConfig) continue; + if (frame) { + const newEntry = { + type: "rotation", + quat_col: frame, + invert: true, + enabled: true + }; + const idx = yConfig.filters.findIndex((f) => f.type === "rotation"); + if (idx >= 0) { + yConfig.filters = [ + ...yConfig.filters.slice(0, idx), + newEntry, + ...yConfig.filters.slice(idx + 1) + ]; + } else { + yConfig.filters = [newEntry, ...yConfig.filters]; + } + } else { + const idx = yConfig.filters.findIndex((f) => f.type === "rotation"); + if (idx >= 0) { + yConfig.filters = [ + ...yConfig.filters.slice(0, idx), + ...yConfig.filters.slice(idx + 1) + ]; + } + } + } + }, warpToTrial() { if (this.warpId === null || this.warpId === void 0 || this.warpId === "") return; @@ -1332,6 +1655,15 @@ getFilterSchema(filterType) { return this.filterSchemas.find((s) => s.type === filterType); }, + get groupedFilterSchemas() { + const ORDER = ["Smoothing", "Arithmetic", "Trigonometry", "Calculus", "Comparison", "Bounding", "Misc"]; + const groups = {}; + for (const s of this.filterSchemas) { + const cat = s.category ?? "Misc"; + (groups[cat] ?? (groups[cat] = [])).push(s); + } + return ORDER.filter((c) => groups[c]?.length).map((c) => ({ category: c, schemas: groups[c] })); + }, evalMathExpr(expr) { const s = String(expr ?? "").trim(); if (!s) return null; @@ -1419,6 +1751,8 @@ const schema = this.filterSchemas.find((s) => s.type === filterType); if (!schema) return; if (!temp.filters) temp.filters = []; + if (filterType === "rotation" && temp.filters.some((f) => f.type === "rotation")) + return; const entry = { type: filterType, enabled: true }; for (const p of schema.params) { entry[p.name] = p.default; @@ -1454,6 +1788,116 @@ _profileUrl(name) { return `/mosaic/api/profiles/${name.split("/").map(encodeURIComponent).join("/")}`; }, + // ── Lab ────────────────────────────────────────────────────────────────── + relTime(ms) { + const diff = Date.now() - ms; + const min = Math.floor(diff / 6e4); + if (min < 1) return "just now"; + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return d === 1 ? "yesterday" : `${d}d ago`; + }, + async loadLabSchemas() { + try { + const resp = await fetch("/mosaic/api/lab"); + if (!resp.ok) return; + const all = await resp.json(); + const schemas = all.map((lab) => ({ + ...lab, + signal_in_columns: [...new Set(lab.signal_in_columns)].sort(), + outputs: [...new Set(lab.outputs)].sort() + })); + const baseColumns = this.columns.filter((c) => !c.startsWith("Lab/")); + const available = new Set(baseColumns); + const validLabs = /* @__PURE__ */ new Set(); + let changed = true; + while (changed) { + changed = false; + for (const lab of schemas) { + if (validLabs.has(lab.name)) continue; + if (lab.signal_in_columns.every((c) => available.has(c))) { + validLabs.add(lab.name); + lab.outputs.forEach((o) => available.add(`Lab/${lab.name}/${o}`)); + changed = true; + } + } + } + this.labSchemas = schemas.map((lab) => ({ + ...lab, + missing: lab.signal_in_columns.filter((c) => !available.has(c)), + valid: validLabs.has(lab.name) + })); + this.columns = [...available].sort(); + } catch { + } + }, + async refreshLabValidation() { + await this.loadLabSchemas(); + void this.loadProfiles(); + }, + async saveLabGraph(name, graph) { + const trimmed = name.trim(); + if (!trimmed) return; + const exists = this.labSchemas.some((s) => s.name === trimmed); + if (exists && !confirm(`Overwrite "${trimmed}"?`)) return; + const safePath = trimmed.split("/").map(encodeURIComponent).join("/"); + try { + const resp = await fetch(`/mosaic/api/lab/${safePath}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(graph) + }); + if (!resp.ok) { + const detail = await resp.text().catch(() => resp.statusText); + this.notify(`Save failed: ${detail}`, "error"); + return; + } + localStorage.setItem("mojo:lab:name", trimmed); + this.notify(`Lab "${trimmed}" saved`, "success"); + await this.refreshLabValidation(); + const labPrefix = `Lab/${trimmed}/`; + const staleCols = Object.keys(this.data ?? {}).filter( + (c) => c.startsWith(labPrefix) + ); + if (staleCols.length > 0) { + const fresh = { ...this.data ?? {} }; + staleCols.forEach((c) => delete fresh[c]); + this.data = fresh; + const activeCols = staleCols.filter( + (c) => c in this.config.yAxes || c === this.config.xAxis?.col + ); + if (activeCols.length > 0) { + try { + const refetch = await this.fetchTrialData(this.trialId, activeCols); + this.data = { ...this.data ?? {}, ...refetch.data }; + this.saveAndRender(); + } catch { + } + } + } + } catch (err) { + this.notify(`Save failed: ${String(err)}`, "error"); + } + }, + selectNodeColumn(col) { + if (this.nodePickingColumn !== null) { + if (typeof window.mojoLabSelectNodeColumn === "function") { + window.mojoLabSelectNodeColumn(this.nodePickingColumn, col); + } + } + this.nodePickingColumn = null; + this.nodeColSearch = ""; + }, + async deleteLabGraph(name) { + const safePath = name.split("/").map(encodeURIComponent).join("/"); + await fetch(`/mosaic/api/lab/${safePath}`, { + method: "DELETE" + }); + this.notify(`Lab "${name}" deleted`, "info"); + await this.refreshLabValidation(); + }, // ----------------------------------------------------------------------- async loadProfiles() { try { @@ -1541,6 +1985,22 @@ } this.config = { ...this.config, ...loaded }; this.notify(`Profile "${name}" loaded`, "success"); + const needed = []; + if (loaded.xAxis?.col && !this.data?.[loaded.xAxis.col]) + needed.push(loaded.xAxis.col); + for (const col of Object.keys(loaded.yAxes ?? {})) { + if (!this.data?.[col]) needed.push(col); + } + if (needed.length > 0) { + const fetched = await this.fetchTrialData(this.trialId, needed); + this.data = { ...this.data ?? {}, ...fetched.data }; + } + void this.$nextTick(() => { + this.configErrors = this.validateConfig(this.config); + this.isValidConfig = this.configErrors.length === 0; + this.isValidJson = true; + this.saveAndRender(); + }); } catch (e) { this.notify( `Failed to load "${name}": ${e.message}`, diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/build.mjs b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/build.mjs index 493fa5be..6d2765b8 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/build.mjs +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/build.mjs @@ -13,11 +13,24 @@ const entries = [ { in: 'trial-viewer.ts', out: 'trial-viewer.js' }, ]; +const vendorDir = join(__dirname, '..', 'vendored'); + const isWatch = process.argv.includes('--watch'); +const cmBundleOptions = { + entryPoints: [join(srcDir, 'cm-bundle.ts')], + outfile: join(vendorDir, 'codemirror.bundle.js'), + bundle: true, + minify: true, + target: 'es2020', + format: /** @type {'iife'} */ ('iife'), + globalName: 'CM', + platform: 'browser', +}; + if (isWatch) { - const contexts = await Promise.all( - entries.map(({ in: inFile, out: outFile }) => + const contexts = await Promise.all([ + ...entries.map(({ in: inFile, out: outFile }) => esbuild.context({ entryPoints: [join(srcDir, inFile)], outfile: join(outDir, outFile), @@ -28,12 +41,13 @@ if (isWatch) { platform: 'browser', }), ), - ); + esbuild.context(cmBundleOptions), + ]); await Promise.all(contexts.map((ctx) => ctx.watch())); console.log('Watching for changes...'); } else { - await Promise.all( - entries.map(async ({ in: inFile, out: outFile }) => { + await Promise.all([ + ...entries.map(async ({ in: inFile, out: outFile }) => { await esbuild.build({ entryPoints: [join(srcDir, inFile)], outfile: join(outDir, outFile), @@ -45,5 +59,8 @@ if (isWatch) { }); console.log(`✓ src/${inFile} → js/${outFile}`); }), - ); + esbuild.build(cmBundleOptions).then(() => { + console.log('✓ src/cm-bundle.ts → vendored/codemirror.bundle.js'); + }), + ]); } diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package-lock.json b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package-lock.json index f3c26246..32ec744a 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package-lock.json +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package-lock.json @@ -5,6 +5,11 @@ "packages": { "": { "name": "dojo-static", + "dependencies": { + "@codemirror/lang-json": "^6.0.2", + "@codemirror/theme-one-dark": "^6.1.3", + "codemirror": "^6.0.2" + }, "devDependencies": { "@types/plotly.js": "^2.35.0", "alpinejs": "^3.14.0", @@ -12,6 +17,109 @@ "typescript": "^5.8.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -454,6 +562,47 @@ "node": ">=18" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@types/plotly.js": { "version": "2.35.14", "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.14.tgz", @@ -488,6 +637,27 @@ "@vue/reactivity": "~3.1.1" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -530,6 +700,12 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -543,6 +719,12 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" } } } diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package.json b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package.json index 85dc6e98..22f4b21a 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package.json +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/package.json @@ -8,9 +8,14 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "esbuild": "^0.25.0", - "typescript": "^5.8.0", + "@types/plotly.js": "^2.35.0", "alpinejs": "^3.14.0", - "@types/plotly.js": "^2.35.0" + "esbuild": "^0.25.0", + "typescript": "^5.8.0" + }, + "dependencies": { + "@codemirror/lang-json": "^6.0.2", + "@codemirror/theme-one-dark": "^6.1.3", + "codemirror": "^6.0.2" } } diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/cm-bundle.ts b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/cm-bundle.ts new file mode 100644 index 00000000..b7b615b9 --- /dev/null +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/cm-bundle.ts @@ -0,0 +1,7 @@ +// CodeMirror 6 bundle — exported as window.CM +export { EditorView, basicSetup } from "codemirror"; +export { json, jsonParseLinter } from "@codemirror/lang-json"; +export { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"; +export { EditorState, Compartment } from "@codemirror/state"; +export { linter, lintGutter } from "@codemirror/lint"; +export { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language"; diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/options.ts b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/options.ts index b4478080..d9c277d7 100644 --- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/options.ts +++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/options.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Option arrays — single source of truth for every select/dropdown in the UI. +// Option arrays - single source of truth for every select/dropdown in the UI. // Each array is `as const` so element types narrow to their literal values. // // Named types (DashStyle, GridMode, …) are generated from plot_config.py @@ -69,7 +69,7 @@ export const LEGEND_POS_OPTIONS: LegendPos[] = ["bottom", "right", "hidden"]; export const SCALE_OPTIONS: ScaleType[] = ["linear", "log"]; // --------------------------------------------------------------------------- -// Label-lookup helpers — derive the display string for a current config value. +// Label-lookup helpers - derive the display string for a current config value. // --------------------------------------------------------------------------- function labelOfe;){let c=!0,f=!1;if(!h||a>r[h-1].to){let m=U[a-1];m!=l&&(c=!1,f=m==16)}let u=!c&&l==1?[]:null,d=c?i:i+1,p=a;e:for(;;)if(h&&p==r[h-1].to){if(f)break e;let m=r[--h];if(!c)for(let g=m.from,b=h;;){if(g==e)break e;if(b&&r[b-1].to==g)g=r[--b].from;else{if(U[g-1]==l)break e;break}}if(u)u.push(m);else{m.toU.length;)U[U.length]=256;let i=[],s=e==Ft?0:1;return Yr(n,s,s,t,0,n.length,i),i}function Nh(n){return[new We(0,n,0)]}var Fh="";function Gd(n,e,t,i,s){var r;let o=i.head-n.from,l=We.find(e,o,(r=i.bidiLevel)!==null&&r!==void 0?r:-1,i.assoc),a=e[l],h=a.side(s,t);if(o==h){let u=l+=s?1:-1;if(u<0||u>=e.length)return null;a=e[l=u],o=a.side(!s,t),h=a.side(s,t)}let c=ee(n.text,o,a.forward(s,t));(c 1)){if(w.bottom0&&l>0&&n.charCodeAt(o-1)==e.charCodeAt(l-1);)o--,l--;if(i=="end"){let a=Math.max(0,r-Math.min(o,l));t-=o+a-r}if(othis.left.length)return this.balanced(this.left,this.right.replace(e-s,t-s,i));let r=[];e>0&&this.decomposeLeft(e,r);let o=r.length;for(let l of i)r.push(l);if(e>0&&oh(r,o-1),t=pe)break;Re>G&&I(Math.max(oe,G),v==null&&oe<=B,Math.min(Re,pe),S==null&&Re>=q,Xe.dir)}if(G=Se.to+1,G>=pe)break}return j.length==0&&I(B,v==null,q,S==null,n.textDirection),{top:E,bottom:W,horizontal:j}}function D(v,S){let C=l.top+(S?v.top:v.bottom);return{top:C,bottom:C,horizontal:[]}}}function Jp(n,e){return n.constructor==e.constructor&&n.eq(e)}var To=class{constructor(e,t){this.view=e,this.layer=t,this.drawn=[],this.scaleX=1,this.scaleY=1,this.measureReq={read:this.measure.bind(this),write:this.draw.bind(this)},this.dom=e.scrollDOM.appendChild(document.createElement("div")),this.dom.classList.add("cm-layer"),t.above&&this.dom.classList.add("cm-layer-above"),t.class&&this.dom.classList.add(t.class),this.scale(),this.dom.setAttribute("aria-hidden","true"),this.setOrder(e.state),e.requestMeasure(this.measureReq),t.mount&&t.mount(this.dom,e)}update(e){e.startState.facet(jn)!=e.state.facet(jn)&&this.setOrder(e.state),(this.layer.update(e,this.dom)||e.geometryChanged)&&(this.scale(),e.view.requestMeasure(this.measureReq))}docViewUpdate(e){this.layer.updateOnDocViewUpdate!==!1&&e.requestMeasure(this.measureReq)}setOrder(e){let t=0,i=e.facet(jn);for(;t
S&&(S=D?E.top-g-2-p:E.bottom+p+2);if(this.position=="absolute"?(c.style.top=(S-n.parent.top)/r+"px",wh(c,(k-n.parent.left)/s)):(c.style.top=S/r+"px",wh(c,k/s)),d){let E=f.left+(w?b.x:-b.x)-(k+14-7);d.style.left=E/s+"px"}h.overlap!==!0&&o.push({left:k,top:S,right:C,bottom:S+g}),c.classList.toggle("cm-tooltip-above",D),c.classList.toggle("cm-tooltip-below",!D),h.positioned&&h.positioned(n.space)}}maybeMeasure(){if(this.manager.tooltips.length&&(this.view.inView&&this.view.requestMeasure(this.measureReq),this.inView!=this.view.inView&&(this.inView=this.view.inView,!this.inView)))for(let n of this.manager.tooltipViews)n.dom.style.top=zn}},{eventObservers:{scroll(){this.maybeMeasure()}}});function wh(n,e){let t=parseInt(n.style.left,10);(isNaN(t)||Math.abs(e-t)>1)&&(n.style.left=e+"px")}var ym=T.baseTheme({".cm-tooltip":{zIndex:500,boxSizing:"border-box"},"&light .cm-tooltip":{border:"1px solid #bbb",backgroundColor:"#f5f5f5"},"&light .cm-tooltip-section:not(:first-child)":{borderTop:"1px solid #bbb"},"&dark .cm-tooltip":{backgroundColor:"#333338",color:"white"},".cm-tooltip-arrow":{height:"7px",width:"14px",position:"absolute",zIndex:-1,overflow:"hidden","&:before, &:after":{content:"''",position:"absolute",width:0,height:0,borderLeft:"7px solid transparent",borderRight:"7px solid transparent"},".cm-tooltip-above &":{bottom:"-7px","&:before":{borderTop:"7px solid #bbb"},"&:after":{borderTop:"7px solid #f5f5f5",bottom:"1px"}},".cm-tooltip-below &":{top:"-7px","&:before":{borderBottom:"7px solid #bbb"},"&:after":{borderBottom:"7px solid #f5f5f5",top:"1px"}}},"&dark .cm-tooltip .cm-tooltip-arrow":{"&:before":{borderTopColor:"#333338",borderBottomColor:"#333338"},"&:after":{borderTopColor:"transparent",borderBottomColor:"transparent"}}}),xm={x:0,y:0},bi=A.define({enables:[Uo,ym]}),as=A.define({combine:n=>n.reduce((e,t)=>e.concat(t),[])}),hs=class n{static create(e){return new n(e)}constructor(e){this.view=e,this.mounted=!1,this.dom=document.createElement("div"),this.dom.classList.add("cm-tooltip-hover"),this.manager=new ls(e,as,(t,i)=>this.createHostedView(t,i),t=>t.dom.remove())}createHostedView(e,t){let i=e.create(this.view);return i.dom.classList.add("cm-tooltip-section"),this.dom.insertBefore(i.dom,t?t.dom.nextSibling:this.dom.firstChild),this.mounted&&i.mount&&i.mount(this.view),i}mount(e){for(let t of this.manager.tooltipViews)t.mount&&t.mount(e);this.mounted=!0}positioned(e){for(let t of this.manager.tooltipViews)t.positioned&&t.positioned(e)}update(e){this.manager.update(e)}destroy(){var e;for(let t of this.manager.tooltipViews)(e=t.destroy)===null||e===void 0||e.call(t)}passProp(e){let t;for(let i of this.manager.tooltipViews){let s=i[e];if(s!==void 0){if(t===void 0)t=s;else if(t!==s)return}}return t}get offset(){return this.passProp("offset")}get getCoords(){return this.passProp("getCoords")}get overlap(){return this.passProp("overlap")}get resize(){return this.passProp("resize")}},wm=bi.compute([as],n=>{let e=n.facet(as);return e.length===0?null:{pos:Math.min(...e.map(t=>t.pos)),end:Math.max(...e.map(t=>{var i;return(i=t.end)!==null&&i!==void 0?i:t.pos})),create:hs.create,above:e[0].above,arrow:e.some(t=>t.arrow)}}),Oc=A.define(),Lo=class{constructor(e,t,i,s,r,o){this.view=e,this.source=t,this.field=i,this.locked=s,this.setHover=r,this.hoverTime=o,this.hoverTimeout=-1,this.restartTimeout=-1,this.pending=null,this.lastMove={x:0,y:0,target:e.dom,time:0},this.checkHover=this.checkHover.bind(this),e.dom.addEventListener("mouseleave",this.mouseleave=this.mouseleave.bind(this)),e.dom.addEventListener("mousemove",this.mousemove=this.mousemove.bind(this))}update(e){this.pending&&(this.pending=null,clearTimeout(this.restartTimeout),this.restartTimeout=setTimeout(()=>this.startHover(),20))}get active(){return this.view.state.field(this.field)}checkHover(){if(this.hoverTimeout=-1,this.active.length)return;let e=Date.now()-this.lastMove.time;e=e&&a<=t}function Dc(n,e={}){let t=R.define(),i=new WeakMap,s=X.define({create(){return[]},update(o,l){let a=i.get(o);if(o.length&&(e.hideOnChange&&(l.docChanged||l.selection)?o=[]:a&&a(l)?o=[]:e.hideOn&&(o=o.filter(h=>!e.hideOn(l,h)))),l.docChanged&&o.length){let h=[];for(let c of o){let f=l.changes.mapPos(c.pos,-1,le.TrackDel);if(f!=null){let u=Object.assign(Object.create(null),c);u.pos=f,u.end!=null&&(u.end=l.changes.mapPos(u.end)),h.push(u)}}o=h}for(let h of l.effects)h.is(t)&&(o=h.value,a=void 0),(h.is(Sm)&&!h.value||h.value==s)&&(o=[]);return o.length&&a&&i.set(o,a),o},provide:o=>as.from(o)}),r=Y.define(o=>new Lo(o,n,s,i,t,e.hoverTime||300));return{active:s,extension:[s,r,Oc.of(r),wm]}}function Pc(n,e,t,i={}){var s;let r=n.state.facet(Oc).map(o=>n.plugin(o)).filter(o=>!!o);if(i.tooltip&&i.tooltip.active){let o=r.find(l=>l.field==i.tooltip.active);o&&(r=[o])}for(let o of r)o.activateHover(n,e,t,(s=i.until)!==null&&s!==void 0?s:(()=>!1))}function Go(n,e){let t=n.plugin(Uo);if(!t)return null;let i=t.manager.tooltips.indexOf(e);return i<0?null:t.manager.tooltipViews[i]}var Sm=R.define();var kh=A.define({combine(n){let e,t;for(let i of n)e=e||i.topContainer,t=t||i.bottomContainer;return{topContainer:e,bottomContainer:t}}});function Qi(n,e){let t=n.plugin(Bc),i=t?t.specs.indexOf(e):-1;return i>-1?t.panels[i]:null}var Bc=Y.fromClass(class{constructor(n){this.input=n.state.facet(Ht),this.specs=this.input.filter(t=>t),this.panels=this.specs.map(t=>t(n));let e=n.state.facet(kh);this.top=new li(n,!0,e.topContainer),this.bottom=new li(n,!1,e.bottomContainer),this.top.sync(this.panels.filter(t=>t.top)),this.bottom.sync(this.panels.filter(t=>!t.top));for(let t of this.panels)t.dom.classList.add("cm-panel"),t.mount&&t.mount()}update(n){let e=n.state.facet(kh);this.top.container!=e.topContainer&&(this.top.sync([]),this.top=new li(n.view,!0,e.topContainer)),this.bottom.container!=e.bottomContainer&&(this.bottom.sync([]),this.bottom=new li(n.view,!1,e.bottomContainer)),this.top.syncClasses(),this.bottom.syncClasses();let t=n.state.facet(Ht);if(t!=this.input){let i=t.filter(a=>a),s=[],r=[],o=[],l=[];for(let a of i){let h=this.specs.indexOf(a),c;h<0?(c=a(n.view),l.push(c)):(c=this.panels[h],c.update&&c.update(n)),s.push(c),(c.top?r:o).push(c)}this.specs=i,this.panels=s,this.top.sync(r),this.bottom.sync(o);for(let a of l)a.dom.classList.add("cm-panel"),a.mount&&a.mount()}else for(let i of this.panels)i.update&&i.update(n)}destroy(){this.top.sync([]),this.bottom.sync([])}},{provide:n=>T.scrollMargins.of(e=>{let t=e.plugin(n);return t&&{top:t.top.scrollMargin(),bottom:t.bottom.scrollMargin()}})}),li=class{constructor(e,t,i){this.view=e,this.top=t,this.container=i,this.dom=void 0,this.classes="",this.panels=[],this.syncClasses()}sync(e){for(let t of this.panels)t.destroy&&e.indexOf(t)<0&&t.destroy();this.panels=e,this.syncDOM()}syncDOM(){if(this.panels.length==0){this.dom&&(this.dom.remove(),this.dom=void 0);return}if(!this.dom){this.dom=document.createElement("div"),this.dom.className=this.top?"cm-panels cm-panels-top":"cm-panels cm-panels-bottom",this.dom.style[this.top?"top":"bottom"]="0";let t=this.container||this.view.dom;t.insertBefore(this.dom,this.top?t.firstChild:null)}let e=this.dom.firstChild;for(let t of this.panels)if(t.dom.parentNode==this.dom){for(;e!=t.dom;)e=vh(e);e=e.nextSibling}else this.dom.insertBefore(t.dom,e);for(;e;)e=vh(e)}scrollMargin(){return!this.dom||this.container?0:Math.max(0,this.top?this.dom.getBoundingClientRect().bottom-Math.max(0,this.view.scrollDOM.getBoundingClientRect().top):Math.min(innerHeight,this.view.scrollDOM.getBoundingClientRect().bottom)-this.dom.getBoundingClientRect().top)}syncClasses(){if(!(!this.container||this.classes==this.view.themeClasses)){for(let e of this.classes.split(" "))e&&this.container.classList.remove(e);for(let e of(this.classes=this.view.themeClasses).split(" "))e&&this.container.classList.add(e)}}};function vh(n){let e=n.nextSibling;return n.remove(),e}var Ht=A.define({enables:Bc});function Rc(n,e){let t,i=new Promise(o=>t=o),s=o=>Cm(o,e,t);n.state.field(Hr,!1)?n.dispatch({effects:Lc.of(s)}):n.dispatch({effects:R.appendConfig.of(Hr.init(()=>[s]))});let r=Ec.of(s);return{close:r,result:i.then(o=>((n.win.queueMicrotask||(a=>n.win.setTimeout(a,10)))(()=>{n.state.field(Hr).indexOf(s)>-1&&n.dispatch({effects:r})}),o))}}var Hr=X.define({create(){return[]},update(n,e){for(let t of e.effects)t.is(Lc)?n=[t.value].concat(n):t.is(Ec)&&(n=n.filter(i=>i!=t.value));return n},provide:n=>Ht.computeN([n],e=>e.field(n))}),Lc=R.define(),Ec=R.define();function Cm(n,e,t){let i=e.content?e.content(n,()=>o(null)):null;if(!i){if(i=H("form"),e.input){let l=H("input",e.input);/^(text|password|number|email|tel|url)$/.test(l.type)&&l.classList.add("cm-textfield"),l.name||(l.name="input"),i.appendChild(H("label",(e.label||"")+": ",l))}else i.appendChild(document.createTextNode(e.label||""));i.appendChild(document.createTextNode(" ")),i.appendChild(H("button",{class:"cm-button",type:"submit"},e.submitLabel||"OK"))}let s=i.nodeName=="FORM"?[i]:i.querySelectorAll("form");for(let l=0;l=o?4:0,Se=C.start;for(C.next();C.pos>G;){if(C.size<0)if(C.size==-3||C.size==-4)pe+=4;else break e;else C.id>=o&&(pe+=4);C.next()}W=Se,E+=V,j+=pe}return(S<0||E==v)&&(B.size=E,B.start=W,B.skip=j),B.size>4?B:void 0}function b(v,S,C){let{id:E,start:W,end:j,size:I}=l;if(l.next(),I>=0&&E
=c)break;S+=C}if(k==D+1){if(S>c){let C=p[D];d(C.children,C.positions,0,C.children.length,m[D]+w);continue}f.push(p[D])}else{let C=m[k-1]+p[k-1].length-v;f.push(el(n,p,m,D,k,v,C,null,a))}u.push(v+w-r)}}return d(e,t,i,s,0),(l||a)(f,u,o)}var zt=class n{constructor(e,t,i,s,r=!1,o=!1){this.from=e,this.to=t,this.tree=i,this.offset=s,this.open=(r?1:0)|(o?2:0)}get openStart(){return(this.open&1)>0}get openEnd(){return(this.open&2)>0}static addTree(e,t=[],i=!1){let s=[new n(0,e.length,e,0,!1,i)];for(let r of t)r.to>e.length&&s.push(r);return s}static applyChanges(e,t,i=128){if(!t.length)return e;let s=[],r=1,o=e.length?e[0]:null;for(let l=0,a=0,h=0;;l++){let c=l