+
+
+
+
+
+
+
+
+
@@ -439,7 +478,7 @@
@@ -428,6 +676,7 @@
open: false,
coords: { top: 0, left: 0, width: 0 }
}"
+ x-init="$watch('open', v => $store.dojo.overlayCount = Math.max(0, $store.dojo.overlayCount + (v ? 1 : -1)))"
@mojo:escape.window="open = false">
{% from 'partials/trial_viewer/_macros.html' import color_picker %}
- {{ color_picker('temp.color', 'seriesPicker', 'Signal Color', on_change='isDirty = true') }}
+ {{ color_picker('temp.color', 'seriesPicker', 'Color', on_change='isDirty = true') }}
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 67970ee6..c991af78 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
@@ -22,7 +22,8 @@
document.addEventListener("alpine:init", () => {
Alpine.store("dojo", {
isPageReady: false,
- isFullscreen: false,
+ isFullscreen: localStorage.getItem("mojo_fullscreen") === "true",
+ overlayCount: 0,
loadStartTime: Date.now(),
isComplete: false,
isMuted: localStorage.getItem("mojo_muted") !== "false",
@@ -173,6 +174,14 @@
this.loadingInterval = null;
}
},
+ toggleFullscreen() {
+ this.isFullscreen = !this.isFullscreen;
+ localStorage.setItem("mojo_fullscreen", String(this.isFullscreen));
+ },
+ exitFullscreen() {
+ this.isFullscreen = false;
+ localStorage.setItem("mojo_fullscreen", "false");
+ },
toggleMute() {
this.isMuted = !this.isMuted;
localStorage.setItem("mojo_muted", this.isMuted.toString());
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 fad3210b..01145f4d 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
@@ -2,7 +2,13 @@
(() => {
// src/lib/options.ts
var DASH_OPTIONS = ["solid", "dash", "dot", "dashdot"];
- var MARKER_OPTIONS = ["none", "circle", "square", "diamond", "cross"];
+ var MARKER_OPTIONS = [
+ "none",
+ "circle",
+ "square",
+ "diamond",
+ "cross"
+ ];
var GRID_OPTIONS = ["none", "major", "all"];
var LINE_MODE_OPTIONS = [
{ label: "Lines", value: "lines" },
@@ -67,7 +73,19 @@
// src/trial-viewer.ts
var tw = {
- slate: { 50: "#f8fafc", 100: "#f1f5f9", 200: "#e2e8f0", 300: "#cbd5e1", 400: "#94a3b8", 500: "#64748b", 600: "#475569", 700: "#334155", 800: "#1e293b", 900: "#0f172a", 950: "#020617" },
+ slate: {
+ 50: "#f8fafc",
+ 100: "#f1f5f9",
+ 200: "#e2e8f0",
+ 300: "#cbd5e1",
+ 400: "#94a3b8",
+ 500: "#64748b",
+ 600: "#475569",
+ 700: "#334155",
+ 800: "#1e293b",
+ 900: "#0f172a",
+ 950: "#020617"
+ },
cyan: { 400: "#22d3ee", 500: "#06b6d4", 600: "#0891b2" },
emerald: { 500: "#10b981" },
blue: { 500: "#3b82f6" },
@@ -76,7 +94,7 @@
rose: { 500: "#ef4444" }
};
var DEFAULT_CONFIG = {
- xAxis: "time",
+ xAxis: { col: "time", filters: [] },
yAxes: {},
refFrame: null,
grid: "all",
@@ -92,6 +110,7 @@
rangeY: null,
xScale: "linear",
yScale: "linear",
+ plotType: "cartesian",
vsEnabled: false,
vsRange: [0, 10],
annotations: [],
@@ -110,6 +129,7 @@
isMac: /Mac|iPhone|iPod|iPad/.test(navigator.platform),
data: null,
errorState: null,
+ _renderedPlotType: null,
// --- UI / MENU STATES ---
theme: "dark",
xMenuOpen: false,
@@ -125,7 +145,14 @@
columns: [],
rotateableVectors: [],
discoveryId: 0,
- plotColors: [tw.cyan[500], tw.emerald[500], tw.blue[500], tw.violet[500], tw.amber[500], tw.rose[500]],
+ plotColors: [
+ tw.cyan[500],
+ tw.emerald[500],
+ tw.blue[500],
+ tw.violet[500],
+ tw.amber[500],
+ tw.rose[500]
+ ],
// Toast (shared mixin)
...createToastMixin(),
// Options — exposed so templates can use opts.lineMode, opts.interpLabel(...), etc.
@@ -149,6 +176,7 @@
// tracks the last filter fingerprint that was fetched for each col; used to detect
// real filter changes without relying on Alpine.js's (unreliable) oldValue deep clone
filterFingerprints: {},
+ xAxisFilterFingerprint: "[]",
// deduplicates filter error toasts so VS mode (N parallel fetches) shows each error once
_shownFilterErrors: /* @__PURE__ */ new Set(),
// in-progress signal editor edits that survive closing/reopening the panel
@@ -192,7 +220,9 @@
if (this.historyIndex > 0) {
this.isUndoing = true;
this.historyIndex--;
- this.config = JSON.parse(this.historyStack[this.historyIndex] ?? "{}");
+ this.config = JSON.parse(
+ this.historyStack[this.historyIndex] ?? "{}"
+ );
this.persistHistory();
void this.$nextTick(() => {
this.isUndoing = false;
@@ -204,7 +234,9 @@
if (this.historyIndex < this.historyStack.length - 1) {
this.isUndoing = true;
this.historyIndex++;
- this.config = JSON.parse(this.historyStack[this.historyIndex] ?? "{}");
+ this.config = JSON.parse(
+ this.historyStack[this.historyIndex] ?? "{}"
+ );
this.persistHistory();
void this.$nextTick(() => {
this.isUndoing = false;
@@ -213,7 +245,10 @@
}
},
persistHistory() {
- localStorage.setItem("mojo_mosaic_history", JSON.stringify({ stack: this.historyStack, index: this.historyIndex }));
+ localStorage.setItem(
+ "mojo_mosaic_history",
+ JSON.stringify({ stack: this.historyStack, index: this.historyIndex })
+ );
},
shiftY(index, direction, isWarp = false) {
const keys = Object.keys(this.config.yAxes);
@@ -238,15 +273,25 @@
async fetchTrialData(id, requiredCols = []) {
let url = `/mosaic/${id}/data`;
const colParams = new URLSearchParams();
- if (requiredCols.length > 0) colParams.append("cols", requiredCols.join(","));
- if (this.config.refFrame) colParams.append("rotate_by", this.config.refFrame);
+ 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")));
for (const col of requiredCols) {
const yConfig = this.config.yAxes[col];
if (yConfig?.filters && yConfig.filters.length > 0) {
- const active = yConfig.filters.filter((f) => f.enabled !== false).map((f) => Object.fromEntries(Object.entries(f).filter(([k]) => k !== "enabled")));
+ const active = toActiveFilters(yConfig.filters);
if (active.length > 0) filtersPayload[col] = active;
}
+ if (col === this.config.xAxis.col) {
+ const xFilters = this.config.xAxis?.filters ?? [];
+ if (xFilters.length > 0) {
+ const active = toActiveFilters(xFilters);
+ if (active.length > 0) filtersPayload[col] = active;
+ }
+ }
}
if (Object.keys(filtersPayload).length > 0) {
colParams.append("filters", JSON.stringify(filtersPayload));
@@ -261,7 +306,10 @@
if (!this._shownFilterErrors.has(msg)) {
this._shownFilterErrors.add(msg);
this.notify(msg, "error");
- setTimeout(() => this._shownFilterErrors.delete(msg), 5e3);
+ setTimeout(
+ () => this._shownFilterErrors.delete(msg),
+ 5e3
+ );
}
});
}
@@ -276,13 +324,19 @@
try {
const resp = await this.fetchTrialData(id, chunk);
if (isVsDataset) {
- this.vsDatasets[id] = { ...this.vsDatasets[id] ?? {}, ...resp.data };
+ this.vsDatasets[id] = {
+ ...this.vsDatasets[id] ?? {},
+ ...resp.data
+ };
this.vsDatasets = { ...this.vsDatasets };
} else {
this.data = { ...this.data ?? {}, ...resp.data };
}
- if (Object.keys(this.config.yAxes).some((y) => chunk.includes(y))) this.renderPlot();
- console.debug(`Dojo Hydration [${label}]: ${i + chunk.length}/${columnList.length}`);
+ if (Object.keys(this.config.yAxes).some((y) => chunk.includes(y)))
+ this.renderPlot();
+ console.debug(
+ `Dojo Hydration [${label}]: ${i + chunk.length}/${columnList.length}`
+ );
} catch (e) {
console.warn(`Hydration failed for ${id}`, e);
}
@@ -290,12 +344,21 @@
},
async startBackgroundDiscovery() {
const currentId = ++this.discoveryId;
- const pendingCols = this.columns.filter((c) => !Object.prototype.hasOwnProperty.call(this.data ?? {}, c));
- if (pendingCols.length > 0) await this.trickleFetch(this.trialId, pendingCols, "Current", false, currentId);
+ const pendingCols = this.columns.filter(
+ (c) => !Object.prototype.hasOwnProperty.call(this.data ?? {}, c)
+ );
+ if (pendingCols.length > 0)
+ await this.trickleFetch(
+ this.trialId,
+ pendingCols,
+ "Current",
+ false,
+ currentId
+ );
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, ...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;
@@ -303,8 +366,17 @@
for (const id of draftIds) {
if (currentId !== this.discoveryId) return;
const existing = this.vsDatasets[id];
- const needsFetch = !existing || activeCols.some((c) => !Object.prototype.hasOwnProperty.call(existing, c));
- if (needsFetch) await this.trickleFetch(id, activeCols, `Draft ${id}`, true, currentId);
+ const needsFetch = !existing || activeCols.some(
+ (c) => !Object.prototype.hasOwnProperty.call(existing, c)
+ );
+ if (needsFetch)
+ await this.trickleFetch(
+ id,
+ activeCols,
+ `Draft ${id}`,
+ true,
+ currentId
+ );
}
},
// -----------------------------------------------------------------------
@@ -328,13 +400,27 @@
if (this.placementMode === "vline") {
newShape = { type: "vline", x0: pt.x, color: defaultColor, label: "" };
} else if (this.placementMode === "hline") {
- newShape = { type: "hline", x0: pt.x, y0: pt.y, color: defaultColor, label: "" };
+ newShape = {
+ type: "hline",
+ x0: pt.x,
+ y0: pt.y,
+ color: defaultColor,
+ label: ""
+ };
} else if (this.placementMode === "rect") {
if (!this.rectStart) {
this.rectStart = { x: pt.x, y: pt.y };
return true;
}
- newShape = { type: "rect", x0: this.rectStart.x, x1: pt.x, y0: this.rectStart.y, y1: pt.y, color: defaultColor, label: "" };
+ newShape = {
+ type: "rect",
+ x0: this.rectStart.x,
+ x1: pt.x,
+ y0: this.rectStart.y,
+ y1: pt.y,
+ color: defaultColor,
+ label: ""
+ };
this.rectStart = null;
}
if (newShape) {
@@ -381,7 +467,9 @@
startAnnEdit(index) {
this.annEditIndex = index;
this.annDraft = { ...this.config.annotations[index] };
- void this.$nextTick(() => this.$refs["annInput"]?.focus());
+ void this.$nextTick(
+ () => this.$refs["annInput"]?.focus()
+ );
},
cancelAnnDraft() {
this.annDraft = null;
@@ -390,7 +478,7 @@
jumpToAnnotation(ann) {
const el = document.getElementById("plot-area");
if (!el || !this.data) return;
- const xValues = this.data[this.config.xAxis] ?? [];
+ const xValues = this.data[this.config.xAxis.col] ?? [];
const xMin = xValues[0] ?? 0;
const xMax = xValues[xValues.length - 1] ?? 100;
const xSpan = (xMax - xMin) * 0.1;
@@ -403,12 +491,23 @@
newRangeX[0] -= newRangeX[1] - xMax;
newRangeX[1] = xMax;
}
- const fullY = this.calculatePaddedRange(Object.keys(this.config.yAxes), false);
+ const fullY = this.calculatePaddedRange(
+ Object.keys(this.config.yAxes),
+ false
+ );
const ySpan = Math.abs(fullY[1] - fullY[0]) * 0.2;
- const newRangeY = [ann.y - ySpan / 2, ann.y + ySpan / 2];
+ const newRangeY = [
+ ann.y - ySpan / 2,
+ ann.y + ySpan / 2
+ ];
this.config.rangeX = newRangeX;
this.config.rangeY = newRangeY;
- void Plotly.relayout(el, { "xaxis.range": newRangeX, "yaxis.range": newRangeY, "xaxis.autorange": false, "yaxis.autorange": false });
+ void Plotly.relayout(el, {
+ "xaxis.range": newRangeX,
+ "yaxis.range": newRangeY,
+ "xaxis.autorange": false,
+ "yaxis.autorange": false
+ });
this.saveAndRender();
},
deleteAnnotation(index) {
@@ -450,7 +549,8 @@
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.attributeName === "class")) {
this.theme = document.documentElement.classList.contains("dark") ? "dark" : "light";
- if (this.data && Object.keys(this.config.yAxes).length > 0) this.renderPlot();
+ if (this.data && Object.keys(this.config.yAxes).length > 0)
+ this.renderPlot();
}
});
observer.observe(document.documentElement, { attributes: true });
@@ -464,7 +564,10 @@
const statusResp = await fetch("/monitor/api/status");
const statusData = await statusResp.json();
if (statusData && !statusData.error) {
- Alpine.store("dojo").updateSync(Date.now(), statusData.is_complete);
+ Alpine.store("dojo").updateSync(
+ Date.now(),
+ statusData.is_complete
+ );
const match = statusData.padding_style.match(/\d+/);
this.paddingLen = match ? parseInt(match[0]) : 2;
}
@@ -472,7 +575,10 @@
console.warn("Dojo offline", e);
}
try {
- const initialCols = [this.config.xAxis, ...Object.keys(this.config.yAxes)];
+ 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 ?? [];
@@ -508,39 +614,57 @@
return;
}
if (event["xaxis.range[0]"] !== void 0) {
- this.config.rangeX = [event["xaxis.range[0]"], event["xaxis.range[1]"]];
+ this.config.rangeX = [
+ event["xaxis.range[0]"],
+ event["xaxis.range[1]"]
+ ];
}
if (event["yaxis.range[0]"] !== void 0) {
- this.config.rangeY = [event["yaxis.range[0]"], event["yaxis.range[1]"]];
+ this.config.rangeY = [
+ event["yaxis.range[0]"],
+ event["yaxis.range[1]"]
+ ];
}
});
plotEl.addEventListener("click", (e) => {
if (!this.placementMode) return;
const target = e.target;
- if (!target.classList.contains("nsewdrag") && !target.classList.contains("drag")) return;
+ if (!target.classList.contains("nsewdrag") && !target.classList.contains("drag"))
+ return;
const rect = plotEl.getBoundingClientRect();
const fullLayout = plotEl._fullLayout;
if (!fullLayout) return;
this.handlePlotClickForShapes({
- x: fullLayout.xaxis.p2l(e.clientX - rect.left - fullLayout.margin.l),
- y: fullLayout.yaxis.p2l(e.clientY - rect.top - fullLayout.margin.t)
+ x: fullLayout.xaxis.p2l(
+ e.clientX - rect.left - fullLayout.margin.l
+ ),
+ y: fullLayout.yaxis.p2l(
+ e.clientY - rect.top - fullLayout.margin.t
+ )
});
});
document.addEventListener("mousedown", (e) => {
if (e.button !== 1) return;
const rect = plotEl.getBoundingClientRect();
- if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) return;
+ if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom)
+ return;
e.preventDefault();
const fullLayout = plotEl._fullLayout;
if (!fullLayout) return;
- const xVal = fullLayout.xaxis.p2l(e.clientX - rect.left - fullLayout.margin.l);
- const yVal = fullLayout.yaxis.p2l(e.clientY - rect.top - fullLayout.margin.t);
+ const xVal = fullLayout.xaxis.p2l(
+ e.clientX - rect.left - fullLayout.margin.l
+ );
+ const yVal = fullLayout.yaxis.p2l(
+ e.clientY - rect.top - fullLayout.margin.t
+ );
setTimeout(() => {
this.annDraft = { x: xVal, y: yVal, text: "" };
this.annEditIndex = null;
this.annotationsOpen = true;
void this.$nextTick(() => {
- document.querySelector('[x-ref="annInput"]')?.focus();
+ document.querySelector(
+ '[x-ref="annInput"]'
+ )?.focus();
});
}, 0);
});
@@ -577,7 +701,9 @@
document.querySelector('input[type="number"]')?.focus();
}
if (e.key === "Escape") {
- if (["INPUT", "TEXTAREA"].includes(tag)) e.target.blur();
+ 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();
@@ -589,10 +715,12 @@
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();
+ 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;
@@ -605,7 +733,7 @@
e.preventDefault();
this.redo();
}
- });
+ }, { capture: true });
const resp = await fetch("/mosaic/api/trials");
const data = await resp.json();
this.allTrials = data.trials ?? [];
@@ -622,43 +750,73 @@
if (this.discoveryTimeout) clearTimeout(this.discoveryTimeout);
this.discoveryTimeout = setTimeout(() => {
if (this.vsDraft.enabled) {
- console.debug("Predictive Sync: User adjusted range, starting hydration...");
+ console.debug(
+ "Predictive Sync: User adjusted range, starting hydration..."
+ );
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, ...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",
+ 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", async (value, oldValue) => {
if (!this.isEditingRaw) this.configRaw = JSON.stringify(value, null, 4);
- if (this.config.vsEnabled && oldValue?.vsEnabled && (value.xAxis !== oldValue.xAxis || Object.keys(value.yAxes).length !== Object.keys(oldValue.yAxes ?? {}).length)) {
+ 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();
}
this.pushHistory();
const changedFilterCols = Object.keys(value.yAxes).filter((col) => {
- const current = JSON.stringify((value.yAxes[col]?.filters ?? []).filter((f) => f.enabled !== false));
+ const current = JSON.stringify(
+ (value.yAxes[col]?.filters ?? []).filter(
+ (f) => f.enabled !== false
+ )
+ );
return current !== (this.filterFingerprints[col] ?? "[]");
});
- if (changedFilterCols.length > 0) {
+ const xFilterCurrent = JSON.stringify(
+ (value.xAxis?.filters ?? []).filter((f) => f.enabled !== false)
+ );
+ const xFilterChanged = xFilterCurrent !== this.xAxisFilterFingerprint;
+ if (xFilterChanged) {
+ this.xAxisFilterFingerprint = xFilterCurrent;
+ if (this.data) delete this.data[value.xAxis.col];
+ }
+ const colsToRefetch = [
+ ...xFilterChanged ? [value.xAxis.col] : [],
+ ...changedFilterCols
+ ];
+ if (colsToRefetch.length > 0) {
changedFilterCols.forEach((col) => {
- this.filterFingerprints[col] = JSON.stringify((value.yAxes[col]?.filters ?? []).filter((f) => f.enabled !== false));
+ this.filterFingerprints[col] = JSON.stringify(
+ (value.yAxes[col]?.filters ?? []).filter(
+ (f) => f.enabled !== false
+ )
+ );
if (this.data) delete this.data[col];
});
this.vsDatasets = {};
- const resp2 = await this.fetchTrialData(this.trialId, changedFilterCols);
+ const resp2 = await this.fetchTrialData(this.trialId, colsToRefetch);
this.data = { ...this.data ?? {}, ...resp2.data };
if (this.config.vsEnabled) await this.syncVsRange();
}
@@ -687,14 +845,22 @@
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, ...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) => {
- if (col.includes(":")) families.add(col.substring(0, col.lastIndexOf(":")));
+ if (col.includes(":"))
+ families.add(col.substring(0, col.lastIndexOf(":")));
});
- families.forEach((fam) => activeCols.push(`${fam}:x`, `${fam}:y`, `${fam}:z`));
- activeCols.push(`${this.config.refFrame}:w`, `${this.config.refFrame}:x`, `${this.config.refFrame}:y`, `${this.config.refFrame}:z`);
+ families.forEach(
+ (fam) => activeCols.push(`${fam}:x`, `${fam}:y`, `${fam}:z`)
+ );
+ activeCols.push(
+ `${this.config.refFrame}:w`,
+ `${this.config.refFrame}:x`,
+ `${this.config.refFrame}:y`,
+ `${this.config.refFrame}:z`
+ );
}
activeCols = [...new Set(activeCols)];
const currentNum = parseInt(this.trialId.split("_").pop() ?? "");
@@ -702,19 +868,29 @@
const n = parseInt(id.split("_").pop() ?? "");
return n >= start && n <= end && n !== currentNum;
});
- await Promise.all(targetIds.map(async (id) => {
- const existing = this.vsDatasets[id];
- const needsFetch = !existing || activeCols.some((col) => !Object.prototype.hasOwnProperty.call(existing, col)) || this.config.refFrame !== null;
- if (needsFetch) {
- const response = await this.fetchTrialData(id, activeCols);
- this.vsDatasets[id] = { ...this.vsDatasets[id] ?? {}, ...response.data };
- }
- }));
+ await Promise.all(
+ targetIds.map(async (id) => {
+ const existing = this.vsDatasets[id];
+ const needsFetch = !existing || activeCols.some(
+ (col) => !Object.prototype.hasOwnProperty.call(existing, col)
+ ) || this.config.refFrame !== null;
+ if (needsFetch) {
+ const response = await this.fetchTrialData(id, activeCols);
+ this.vsDatasets[id] = {
+ ...this.vsDatasets[id] ?? {},
+ ...response.data
+ };
+ }
+ })
+ );
this.vsDatasets = { ...this.vsDatasets };
this.config.vsRange = [start, end];
this.config.vsEnabled = true;
if (targetIds.length > 0) {
- this.notify(`Comparing ${targetIds.length} trial${targetIds.length === 1 ? "" : "s"}`, "info");
+ this.notify(
+ `Comparing ${targetIds.length} trial${targetIds.length === 1 ? "" : "s"}`,
+ "info"
+ );
}
} finally {
this.vsLoading = false;
@@ -751,7 +927,9 @@
const query = new RegExp(pattern, "i");
return this.smartSort(base.filter((c) => query.test(c)));
} catch {
- return this.smartSort(base.filter((c) => c.toLowerCase().includes(search.toLowerCase())));
+ return this.smartSort(
+ base.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
+ );
}
},
toggleRegexSegment(field, segment, depth) {
@@ -775,7 +953,9 @@
}
pathPart = parts.join("/");
if (pathPart && pathPart.toLowerCase() !== "time") {
- const isFolder = this.columns.some((c) => c.toLowerCase().startsWith(pathPart.toLowerCase() + "/"));
+ const isFolder = this.columns.some(
+ (c) => c.toLowerCase().startsWith(pathPart.toLowerCase() + "/")
+ );
if (isFolder) pathPart += "/";
}
}
@@ -801,7 +981,10 @@
const search = this[field + "Search"] ?? "";
const [pathPart = "", suffixPart = ""] = search.split(":");
const selected = (suffixPart ?? "").replace(/[()]/g, "").split("|").filter(Boolean).map((s) => ":" + s);
- const pathRegex = new RegExp("^" + (pathPart ?? "").replace(/\//g, "\\/?"), "i");
+ const pathRegex = new RegExp(
+ "^" + (pathPart ?? "").replace(/\//g, "\\/?"),
+ "i"
+ );
const matches = base.filter((c) => pathRegex.test(c));
const available = matches.map((c) => c.includes(":") ? ":" + c.split(":").pop() : null).filter(Boolean);
return this.smartSort([.../* @__PURE__ */ new Set([...selected, ...available])]);
@@ -833,24 +1016,29 @@
if (!this.configRaw) return "";
let html = this.configRaw.replace(/&/g, "&").replace(//g, ">");
const regex = /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?|[\[\]{},])|(\S+)/g;
- return html.replace(regex, (match, _token, _i1, _i2, _i3, garbage) => {
- if (garbage) return `
${garbage}`;
- let cls = "text-slate-500 dark:text-slate-400";
- if (/^"/.test(match)) {
- cls = /:$/.test(match) ? "text-cyan-600 dark:text-cyan-300" : "text-emerald-600 dark:text-emerald-400";
- } else if (/true|false/.test(match)) {
- cls = "text-violet-600 dark:text-violet-400";
- } else if (/null/.test(match)) {
- cls = "text-rose-500";
- } else if (/-?\d/.test(match)) {
- cls = "text-amber-600 dark:text-amber-500";
+ return html.replace(
+ regex,
+ (match, _token, _i1, _i2, _i3, garbage) => {
+ if (garbage)
+ return `
${garbage}`;
+ let cls = "text-slate-500 dark:text-slate-400";
+ if (/^"/.test(match)) {
+ cls = /:$/.test(match) ? "text-cyan-600 dark:text-cyan-300" : "text-emerald-600 dark:text-emerald-400";
+ } else if (/true|false/.test(match)) {
+ cls = "text-violet-600 dark:text-violet-400";
+ } else if (/null/.test(match)) {
+ cls = "text-rose-500";
+ } else if (/-?\d/.test(match)) {
+ cls = "text-amber-600 dark:text-amber-500";
+ }
+ return `
${match}`;
}
- return `
${match}`;
- });
+ );
},
validateConfig(cfg) {
const errors = [];
- if (!this.columns.includes(cfg.xAxis)) errors.push(`X-Axis "${cfg.xAxis}" not found in telemetry.`);
+ if (cfg.xAxis?.col && !this.columns.includes(cfg.xAxis.col))
+ errors.push(`X-Axis "${cfg.xAxis.col}" not found in telemetry.`);
if (typeof cfg.yAxes !== "object" || Array.isArray(cfg.yAxes)) {
errors.push("yAxes must be a hashmap.");
} else {
@@ -858,7 +1046,8 @@
if (!this.columns.includes(y)) errors.push(`Y-Axis "${y}" missing.`);
});
}
- if (cfg.vsRange && cfg.vsRange[0] > cfg.vsRange[1]) errors.push("Comparison range start cannot be greater than end.");
+ if (cfg.vsRange && cfg.vsRange[0] > cfg.vsRange[1])
+ errors.push("Comparison range start cannot be greater than end.");
return errors;
},
updateFromRaw() {
@@ -894,7 +1083,7 @@
console.error("Stored config corrupt");
}
} else {
- if (this.columns.includes("time")) this.config.xAxis = "time";
+ if (this.columns.includes("time")) this.config.xAxis.col = "time";
}
const savedHistory = localStorage.getItem("mojo_mosaic_history");
if (savedHistory) {
@@ -937,9 +1126,14 @@
// -----------------------------------------------------------------------
copyShareLink() {
try {
- const encoded = LZString.compressToEncodedURIComponent(JSON.stringify(this.config));
+ const encoded = LZString.compressToEncodedURIComponent(
+ JSON.stringify(this.config)
+ );
const shareBase = this.externalUrl + window.location.pathname;
- void this.copyToClipboard(`${shareBase}?v=${encoded}`, "Shareable link copied!");
+ void this.copyToClipboard(
+ `${shareBase}?v=${encoded}`,
+ "Shareable link copied!"
+ );
} catch {
this.notify("Link generation failed", "error");
}
@@ -948,10 +1142,12 @@
void this.copyToClipboard(this.configRaw, "JSON Config copied!");
},
resetConfig() {
- if (confirm("Reset plot to factory defaults? This will clear your current view.")) {
+ if (confirm(
+ "Reset plot to factory defaults? This will clear your current view."
+ )) {
localStorage.removeItem("mojo_mosaic_config");
this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
- if (this.columns.includes("time")) this.config.xAxis = "time";
+ if (this.columns.includes("time")) this.config.xAxis.col = "time";
this.notify("Settings Reset", "info");
this.configRaw = JSON.stringify(this.config, null, 4);
}
@@ -989,20 +1185,37 @@
const bgColor = isDark ? tw.slate[800] : "#ffffff";
const resW = Math.round(1280 * scale);
const resH = Math.round(720 * scale);
- this.notify(`Exporting ${resW}x${resH} ${format.toUpperCase()}...`, "info");
+ this.notify(
+ `Exporting ${resW}x${resH} ${format.toUpperCase()}...`,
+ "info"
+ );
try {
const origPaper = el.layout.paper_bgcolor;
const origPlot = el.layout.plot_bgcolor;
- await Plotly.relayout(el, { paper_bgcolor: bgColor, plot_bgcolor: bgColor });
- const dataUrl = await Plotly.toImage(el, { format: plotlyFormat, width: 1280, height: 720, scale });
- await Plotly.relayout(el, { paper_bgcolor: origPaper, plot_bgcolor: origPlot });
+ await Plotly.relayout(el, {
+ paper_bgcolor: bgColor,
+ plot_bgcolor: bgColor
+ });
+ const dataUrl = await Plotly.toImage(el, {
+ format: plotlyFormat,
+ width: 1280,
+ height: 720,
+ scale
+ });
+ await Plotly.relayout(el, {
+ paper_bgcolor: origPaper,
+ plot_bgcolor: origPlot
+ });
const link = document.createElement("a");
link.href = dataUrl;
link.download = `${this.trialId}_${resW}p.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
- this.notify(`${format.toUpperCase()} saved (${resW}\xD7${resH})`, "success");
+ this.notify(
+ `${format.toUpperCase()} saved (${resW}\xD7${resH})`,
+ "success"
+ );
} catch (e) {
console.error("Export failed", e);
this.notify("Export failed", "error");
@@ -1012,14 +1225,16 @@
},
downloadCSV() {
if (!this.data || Object.keys(this.config.yAxes).length === 0) return;
- const activeCols = [this.config.xAxis, ...Object.keys(this.config.yAxes)];
- const rowCount = this.data[this.config.xAxis]?.length ?? 0;
+ 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++) {
csv += activeCols.map((col) => this.data[col]?.[i] ?? "").join(",") + "\n";
}
const link = document.createElement("a");
- link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
+ link.href = URL.createObjectURL(
+ new Blob([csv], { type: "text/csv;charset=utf-8;" })
+ );
link.setAttribute("download", `${this.trialId}_filtered.csv`);
document.body.appendChild(link);
link.click();
@@ -1029,7 +1244,11 @@
},
downloadJSON() {
const link = document.createElement("a");
- link.href = URL.createObjectURL(new Blob([JSON.stringify(this.config, null, 4)], { type: "application/json" }));
+ link.href = URL.createObjectURL(
+ new Blob([JSON.stringify(this.config, null, 4)], {
+ type: "application/json"
+ })
+ );
link.setAttribute("download", `${this.trialId}_config.json`);
document.body.appendChild(link);
link.click();
@@ -1046,7 +1265,9 @@
const reader = new FileReader();
reader.onload = (event) => {
try {
- const imported = JSON.parse(event.target?.result);
+ const imported = JSON.parse(
+ event.target?.result
+ );
this.config = { ...this.config, ...imported };
this.notify("Configuration restored!", "success");
this.configRaw = JSON.stringify(this.config, null, 4);
@@ -1085,7 +1306,8 @@
this.notify("Signals Cleared", "info");
},
warpToTrial() {
- if (this.warpId === null || this.warpId === void 0 || this.warpId === "") return;
+ if (this.warpId === null || this.warpId === void 0 || this.warpId === "")
+ return;
const paddedNum = String(this.warpId).padStart(this.paddingLen, "0");
window.location.href = `/mosaic/trial_${paddedNum}`;
},
@@ -1110,6 +1332,68 @@
getFilterSchema(filterType) {
return this.filterSchemas.find((s) => s.type === filterType);
},
+ evalMathExpr(expr) {
+ const s = String(expr ?? "").trim();
+ if (!s) return null;
+ const n = Number(s);
+ if (!isNaN(n)) return n;
+ try {
+ const fn = new Function(
+ "pi",
+ "e",
+ "sin",
+ "cos",
+ "tan",
+ "asin",
+ "acos",
+ "atan",
+ "atan2",
+ "sqrt",
+ "cbrt",
+ "log",
+ "log2",
+ "log10",
+ "abs",
+ "floor",
+ "ceil",
+ "round",
+ "sign",
+ "pow",
+ "exp",
+ "max",
+ "min",
+ `"use strict"; return (${s})`
+ );
+ const result = fn(
+ Math.PI,
+ Math.E,
+ Math.sin,
+ Math.cos,
+ Math.tan,
+ Math.asin,
+ Math.acos,
+ Math.atan,
+ Math.atan2,
+ Math.sqrt,
+ Math.cbrt,
+ Math.log,
+ Math.log2,
+ Math.log10,
+ Math.abs,
+ Math.floor,
+ Math.ceil,
+ Math.round,
+ Math.sign,
+ Math.pow,
+ Math.exp,
+ Math.max,
+ Math.min
+ );
+ if (typeof result === "number" && isFinite(result)) return result;
+ } catch {
+ }
+ return null;
+ },
getUnitOptions(groups, fromUnit) {
if (!groups) return [];
if (!fromUnit) return groups;
@@ -1119,11 +1403,14 @@
getFilterSummary(entry) {
const schema = this.filterSchemas.find((s) => s.type === entry.type);
if (!schema || schema.params.length === 0) return "";
- if (entry.type === "unit") return `${entry["from_unit"] ?? "?"} \u2192 ${entry["to_unit"] ?? "?"}`;
+ if (entry.type === "unit")
+ return `${entry["from_unit"] ?? "?"} \u2192 ${entry["to_unit"] ?? "?"}`;
const parts = schema.params.filter((p) => entry[p.name] != null).map((p) => {
const val = entry[p.name];
- if (typeof val === "boolean") return `${p.name}=${val ? "on" : "off"}`;
- if (typeof val === "number") return `${p.name}=${parseFloat(val.toFixed(4))}`;
+ if (typeof val === "boolean")
+ return `${p.name}=${val ? "on" : "off"}`;
+ if (typeof val === "number")
+ return `${p.name}=${parseFloat(val.toFixed(4))}`;
return `${p.name}=${val}`;
});
return parts.slice(0, 3).join(", ");
@@ -1155,7 +1442,9 @@
},
duplicateFilterInTemp(temp, index) {
if (!temp.filters?.[index]) return;
- const copy = JSON.parse(JSON.stringify(temp.filters[index]));
+ const copy = JSON.parse(
+ JSON.stringify(temp.filters[index])
+ );
temp.filters.splice(index + 1, 0, copy);
},
// -----------------------------------------------------------------------
@@ -1171,23 +1460,29 @@
const resp = await fetch("/mosaic/api/profiles");
this.profiles = await resp.json();
const colSet = new Set(this.columns);
- const frames = new Set(this.columns.filter((c) => c.endsWith(":w")).map((c) => c.replace(":w", "")));
+ const frames = new Set(
+ this.columns.filter((c) => c.endsWith(":w")).map((c) => c.replace(":w", ""))
+ );
const warnings = {};
- await Promise.all(this.profiles.map(async (p) => {
- try {
- const pr = await fetch(this._profileUrl(p.name));
- if (!pr.ok) return;
- const cfg = await pr.json();
- const w = [];
- if (cfg.xAxis && !colSet.has(cfg.xAxis)) w.push(`x-axis "${cfg.xAxis}"`);
- for (const key of Object.keys(cfg.yAxes ?? {})) {
- if (!colSet.has(key)) w.push(`"${key}"`);
+ await Promise.all(
+ this.profiles.map(async (p) => {
+ try {
+ const pr = await fetch(this._profileUrl(p.name));
+ if (!pr.ok) return;
+ const cfg = await pr.json();
+ const w = [];
+ if (cfg.xAxis?.col && !colSet.has(cfg.xAxis.col))
+ w.push(`x-axis "${cfg.xAxis.col}"`);
+ for (const key of Object.keys(cfg.yAxes ?? {})) {
+ if (!colSet.has(key)) w.push(`"${key}"`);
+ }
+ if (cfg.refFrame && !frames.has(cfg.refFrame))
+ w.push(`frame "${cfg.refFrame}"`);
+ if (w.length) warnings[p.name] = w;
+ } catch {
}
- if (cfg.refFrame && !frames.has(cfg.refFrame)) w.push(`frame "${cfg.refFrame}"`);
- if (w.length) warnings[p.name] = w;
- } catch {
- }
- }));
+ })
+ );
this.profileWarnings = warnings;
} catch (e) {
console.warn("[mojo] Failed to load profiles", e);
@@ -1200,7 +1495,9 @@
return;
}
const normalise = (s) => s.toLowerCase().replace(/\s+/g, "_");
- const existing = this.profiles.find((p) => normalise(p.name) === normalise(name));
+ const existing = this.profiles.find(
+ (p) => normalise(p.name) === normalise(name)
+ );
if (existing && !confirm(`Overwrite profile "${existing.name}"?`)) return;
try {
const resp = await fetch(this._profileUrl(name), {
@@ -1226,20 +1523,29 @@
}
const loaded = await resp.json();
const colSet = new Set(this.columns);
- const frames = new Set(this.columns.filter((c) => c.endsWith(":w")).map((c) => c.replace(":w", "")));
+ const frames = new Set(
+ this.columns.filter((c) => c.endsWith(":w")).map((c) => c.replace(":w", ""))
+ );
const missing = [];
- if (loaded.xAxis && !colSet.has(loaded.xAxis)) missing.push(`x-axis "${loaded.xAxis}"`);
+ if (loaded.xAxis?.col && !colSet.has(loaded.xAxis.col))
+ missing.push(`x-axis "${loaded.xAxis.col}"`);
for (const key of Object.keys(loaded.yAxes ?? {})) {
if (!colSet.has(key)) missing.push(`signal "${key}"`);
}
- if (loaded.refFrame && !frames.has(loaded.refFrame)) missing.push(`frame "${loaded.refFrame}"`);
+ if (loaded.refFrame && !frames.has(loaded.refFrame))
+ missing.push(`frame "${loaded.refFrame}"`);
if (missing.length) {
- throw new Error(`references columns not in this trial: ${missing.join(", ")}`);
+ throw new Error(
+ `references columns not in this trial: ${missing.join(", ")}`
+ );
}
this.config = { ...this.config, ...loaded };
this.notify(`Profile "${name}" loaded`, "success");
} catch (e) {
- this.notify(`Failed to load "${name}": ${e.message}`, "error");
+ this.notify(
+ `Failed to load "${name}": ${e.message}`,
+ "error"
+ );
}
},
async deleteProfile(name) {
@@ -1311,27 +1617,60 @@
const isHoverDisabled = this.config.hover === "none";
const showX = this.config.showSpike && !isHoverDisabled && (this.config.hover.includes("x") || this.config.hover === "closest");
const showY = this.config.showSpike && !isHoverDisabled && (this.config.hover.includes("y") || this.config.hover === "closest");
+ const isPolar = this.config.plotType === "polar";
const yKeys = Object.keys(this.config.yAxes);
let traces = yKeys.map((key, i) => {
const p = this.getYProps(key, i);
if (!this.data[p.name]) return null;
+ const lineStyle = {
+ width: p.width,
+ color: p.color,
+ shape: this.config.interp,
+ dash: p.dash
+ };
+ if (isPolar) {
+ return {
+ r: this.data[p.name],
+ theta: this.data[this.config.xAxis.col],
+ name: p.label,
+ mode: this.config.linemode,
+ type: "scatterpolar",
+ line: lineStyle,
+ marker: { size: 6, symbol: p.marker },
+ opacity: p.opacity,
+ hoverlabel: {
+ namelength: -1,
+ bgcolor: tooltipBg,
+ bordercolor: tooltipBorder,
+ font: { family: "monospace", size: 12, color: tooltipFont }
+ },
+ hovertemplate: `
${key}\u03B8: %{theta:.4f}
r: %{r:.4f}
`
+ };
+ }
return {
- x: this.data[this.config.xAxis],
+ x: this.data[this.config.xAxis.col],
y: this.data[p.name],
name: p.label,
mode: this.config.linemode,
type: "scatter",
- line: { width: p.width, color: p.color, shape: this.config.interp, dash: p.dash },
+ line: lineStyle,
marker: { size: 6, symbol: p.marker },
opacity: p.opacity,
- hoverlabel: { namelength: -1, bgcolor: tooltipBg, bordercolor: tooltipBorder, font: { family: "monospace", size: 12, color: tooltipFont } },
+ hoverlabel: {
+ namelength: -1,
+ bgcolor: tooltipBg,
+ bordercolor: tooltipBorder,
+ font: { family: "monospace", size: 12, color: tooltipFont }
+ },
hovertemplate: `
${key}%{x}: %{y:.4f}
`
};
}).filter((t) => t !== null);
if (this.config.vsEnabled) {
const [start, end] = this.config.vsRange;
const legendTracker = /* @__PURE__ */ new Set();
- const sortedVsIds = Object.keys(this.vsDatasets).sort((a, b) => parseInt(a.split("_").pop() ?? "0") - parseInt(b.split("_").pop() ?? "0"));
+ const sortedVsIds = Object.keys(this.vsDatasets).sort(
+ (a, b) => parseInt(a.split("_").pop() ?? "0") - parseInt(b.split("_").pop() ?? "0")
+ );
sortedVsIds.forEach((vsId) => {
const n = parseInt(vsId.split("_").pop() ?? "");
if (n < start || n > end || vsId === this.trialId) return;
@@ -1341,15 +1680,34 @@
const p = this.getYProps(key, i);
if (!dataset[p.name]) return null;
const isFirst = !legendTracker.has(key);
- const t = {
- x: dataset[this.config.xAxis],
+ const lineStyle = {
+ width: 1,
+ color: p.color,
+ shape: this.config.interp,
+ dash: "dot"
+ };
+ const t = isPolar ? {
+ r: dataset[p.name],
+ theta: dataset[this.config.xAxis.col],
+ name: `${p.label} (
vs.)`,
+ legendgroup: `group_${key}`,
+ showlegend: isFirst,
+ mode: this.config.linemode,
+ type: "scatterpolar",
+ line: lineStyle,
+ opacity: 0.35,
+ marker: { size: 4, symbol: p.marker },
+ hoverlabel: { namelength: -1 },
+ hovertemplate: `
${key} (#${n})
\u03B8: %{theta:.4f}
r: %{r:.4f}
`
+ } : {
+ x: dataset[this.config.xAxis.col],
y: dataset[p.name],
name: `${p.label} (
vs.)`,
legendgroup: `group_${key}`,
showlegend: isFirst,
mode: this.config.linemode,
type: "scatter",
- line: { width: 1, color: p.color, shape: this.config.interp, dash: "dot" },
+ line: lineStyle,
opacity: 0.35,
marker: { size: 4, symbol: p.marker },
hoverlabel: { namelength: -1 },
@@ -1365,7 +1723,10 @@
type: this.config.xScale ?? "linear",
...this.config.rangeX ? {
autorange: false,
- range: this.config.xScale === "log" ? [Math.log10(Math.max(1e-6, this.config.rangeX[0])), Math.log10(Math.max(1e-6, this.config.rangeX[1]))] : this.config.rangeX
+ range: this.config.xScale === "log" ? [
+ Math.log10(Math.max(1e-6, this.config.rangeX[0])),
+ Math.log10(Math.max(1e-6, this.config.rangeX[1]))
+ ] : this.config.rangeX
} : { autorange: true },
dtick: this.config.xScale === "log" && this.config.xLogBase ? Math.log10(this.config.xLogBase) : void 0,
gridcolor: majorGrid,
@@ -1373,7 +1734,10 @@
minor: { showgrid: this.config.grid === "all", gridcolor: minorGrid },
zeroline: false,
tickfont: { color: textColor, size: 14 },
- title: { text: this.config.xAxisTitle || this.config.xAxis, font: { size: 14, color: textColor, family: "monospace" } },
+ title: {
+ text: this.config.xAxisTitle || this.config.xAxis.col,
+ font: { size: 14, color: textColor, family: "monospace" }
+ },
showspikes: showX,
spikemode: "across",
spikelinecolor: spikeColor,
@@ -1384,7 +1748,10 @@
type: this.config.yScale ?? "linear",
...this.config.rangeY ? {
autorange: false,
- range: this.config.yScale === "log" ? [Math.log10(Math.max(1e-6, this.config.rangeY[0])), Math.log10(Math.max(1e-6, this.config.rangeY[1]))] : this.config.rangeY
+ range: this.config.yScale === "log" ? [
+ Math.log10(Math.max(1e-6, this.config.rangeY[0])),
+ Math.log10(Math.max(1e-6, this.config.rangeY[1]))
+ ] : this.config.rangeY
} : { autorange: true },
dtick: this.config.yScale === "log" && this.config.yLogBase ? Math.log10(this.config.yLogBase) : void 0,
gridcolor: majorGrid,
@@ -1392,25 +1759,83 @@
minor: { showgrid: this.config.grid === "all", gridcolor: minorGrid },
zeroline: false,
tickfont: { color: textColor, size: 14 },
- title: { text: this.config.yAxisTitle + frameLabel, font: { size: 14, color: textColor, family: "monospace" } },
+ title: {
+ text: this.config.yAxisTitle + frameLabel,
+ font: { size: 14, color: textColor, family: "monospace" }
+ },
showspikes: showY,
spikemode: "across",
spikelinecolor: spikeColor,
spikethickness: -2
};
+ const polarLayout = isPolar ? {
+ polar: {
+ bgcolor: "rgba(0,0,0,0)",
+ radialaxis: {
+ color: textColor,
+ gridcolor: majorGrid,
+ tickfont: { color: textColor, size: 14, family: "monospace" },
+ title: {
+ text: this.config.yAxisTitle || "r",
+ font: { size: 14, color: textColor, family: "monospace" }
+ }
+ },
+ angularaxis: {
+ color: textColor,
+ gridcolor: majorGrid,
+ tickfont: { color: textColor, size: 14, family: "monospace" },
+ title: {
+ text: this.config.xAxisTitle || this.config.xAxis.col,
+ font: { size: 14, color: textColor, family: "monospace" }
+ }
+ }
+ }
+ } : { xaxis: xAxisObj, yaxis: yAxisObj };
const layout = {
- uirevision: `${this.trialId}_${this.config.xAxis}_${Object.keys(this.config.yAxes).join("_")}`,
- title: this.config.title ? { text: this.config.title, font: { family: "monospace", size: 16, color: isDark ? tw.slate[200] : tw.slate[800], weight: "bold" }, x: 0, xanchor: "left" } : null,
+ uirevision: `${this.trialId}_${this.config.xAxis.col}_${Object.keys(this.config.yAxes).join("_")}_${this.config.plotType}`,
+ title: this.config.title ? {
+ text: this.config.title,
+ font: {
+ family: "monospace",
+ size: 16,
+ color: isDark ? tw.slate[200] : tw.slate[800],
+ weight: "bold"
+ },
+ x: 0,
+ xanchor: "left"
+ } : null,
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
- margin: { t: this.config.title ? 60 : 30, r: this.config.legendPos === "right" ? 150 : 30, b: this.config.legendPos === "bottom" ? 80 : 50, l: this.config.yAxisTitle ? 80 : 60 },
+ margin: {
+ t: this.config.title ? 60 : 30,
+ r: this.config.legendPos === "right" ? 150 : 30,
+ b: this.config.legendPos === "bottom" ? 80 : 50,
+ l: this.config.yAxisTitle ? 80 : 60
+ },
hovermode: isHoverDisabled ? false : this.config.hover,
- hoverlabel: { bgcolor: tooltipBg, bordercolor: tooltipBorder, font: { family: "monospace", size: 12, color: tooltipFont }, align: "left" },
+ hoverlabel: {
+ bgcolor: tooltipBg,
+ bordercolor: tooltipBorder,
+ font: { family: "monospace", size: 12, color: tooltipFont },
+ align: "left"
+ },
showlegend: this.config.legendPos !== "hidden",
- legend: this.config.legendPos === "right" ? { orientation: "v", x: 1.02, y: 1, font: { family: "monospace", size: 14, color: textColor }, groupclick: "togglegroup" } : { orientation: "h", y: -0.2, x: 0.5, xanchor: "center", font: { family: "monospace", size: 14, color: textColor }, groupclick: "togglegroup" },
- xaxis: xAxisObj,
- yaxis: yAxisObj,
- annotations: [
+ legend: this.config.legendPos === "right" ? {
+ orientation: "v",
+ x: 1.02,
+ y: 1,
+ font: { family: "monospace", size: 14, color: textColor },
+ groupclick: "togglegroup"
+ } : {
+ orientation: "h",
+ y: -0.2,
+ x: 0.5,
+ xanchor: "center",
+ font: { family: "monospace", size: 14, color: textColor },
+ groupclick: "togglegroup"
+ },
+ ...polarLayout,
+ annotations: isPolar ? [] : [
...(this.config.annotations ?? []).map((ann) => ({
x: ann.x,
y: ann.y,
@@ -1419,7 +1844,11 @@
arrowhead: 2,
ax: 0,
ay: -40,
- font: { family: "monospace", size: 12, color: isDark ? tw.slate[50] : tw.slate[900] },
+ font: {
+ family: "monospace",
+ size: 12,
+ color: isDark ? tw.slate[50] : tw.slate[900]
+ },
bgcolor: isDark ? tw.slate[800] : tw.slate[50],
bordercolor: tw.cyan[500],
borderwidth: 1,
@@ -1438,19 +1867,78 @@
x = s.x0;
y = s.y1 ?? 0;
}
- return { x, y, xref, yref, text: `
${s.label}`, showarrow: false, xanchor, yanchor, font: { size: 10, color: s.color || tw.cyan[500], family: "monospace" }, bgcolor: isDark ? tw.slate[900] + "B3" : tw.slate[50] + "B3", borderpad: 2 };
+ return {
+ x,
+ y,
+ xref,
+ yref,
+ text: `
${s.label}`,
+ showarrow: false,
+ xanchor,
+ yanchor,
+ font: {
+ size: 10,
+ color: s.color || tw.cyan[500],
+ family: "monospace"
+ },
+ bgcolor: isDark ? tw.slate[900] + "B3" : tw.slate[50] + "B3",
+ borderpad: 2
+ };
})
],
- shapes: (this.config.shapes ?? []).map((s) => {
+ shapes: isPolar ? [] : (this.config.shapes ?? []).map((s) => {
const shapeColor = s.color || tw.cyan[500];
- const base = { line: { color: shapeColor, width: 2, dash: s.dash ?? "solid" }, layer: "below" };
- if (s.type === "vline") return { ...base, type: "line", x0: s.x0, x1: s.x0, y0: 0, y1: 1, yref: "paper" };
- if (s.type === "hline") return { ...base, type: "line", y0: s.y0, y1: s.y0, x0: 0, x1: 1, xref: "paper" };
- if (s.type === "rect") return { ...base, type: "rect", x0: s.x0, x1: s.x1, y0: s.y0, y1: s.y1, fillcolor: isDark ? `${shapeColor}1A` : `${shapeColor}26`, line: { ...base.line, width: 1 } };
+ const base = {
+ line: { color: shapeColor, width: 2, dash: s.dash ?? "solid" },
+ layer: "below"
+ };
+ if (s.type === "vline")
+ return {
+ ...base,
+ type: "line",
+ x0: s.x0,
+ x1: s.x0,
+ y0: 0,
+ y1: 1,
+ yref: "paper"
+ };
+ if (s.type === "hline")
+ return {
+ ...base,
+ type: "line",
+ y0: s.y0,
+ y1: s.y0,
+ x0: 0,
+ x1: 1,
+ xref: "paper"
+ };
+ if (s.type === "rect")
+ return {
+ ...base,
+ type: "rect",
+ x0: s.x0,
+ x1: s.x1,
+ y0: s.y0,
+ y1: s.y1,
+ fillcolor: isDark ? `${shapeColor}1A` : `${shapeColor}26`,
+ line: { ...base.line, width: 1 }
+ };
return base;
})
};
- const config = { responsive: true, displaylogo: false, displayModeBar: true, modeBarButtonsToRemove: ["toImage"], doubleClick: false };
+ const config = {
+ responsive: true,
+ displaylogo: false,
+ displayModeBar: true,
+ modeBarButtonsToRemove: ["toImage"],
+ doubleClick: false
+ };
+ const plotEl = document.getElementById("plot-area");
+ if (plotEl && this._renderedPlotType !== this.config.plotType) {
+ Plotly.purge(plotEl);
+ this._renderedPlotType = this.config.plotType ?? null;
+ return Plotly.newPlot("plot-area", traces, layout, config);
+ }
return Plotly.react("plot-area", traces, layout, config);
}
};
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/main.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/main.js
deleted file mode 100644
index 97116d0f..00000000
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/main.js
+++ /dev/null
@@ -1,295 +0,0 @@
-// Global Helpers
-const formatTimeAgo = (seconds) => {
- if (!seconds || seconds < 60) return `${seconds || 0}s ago`;
- const mins = Math.floor(seconds / 60);
- return mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h ago`;
-};
-
-// Global Alpine Initialization
-document.addEventListener("alpine:init", () => {
- Alpine.store("dojo", {
- isPageReady: false,
- isFullscreen: false,
- loadStartTime: Date.now(),
- isComplete: false,
- isMuted: localStorage.getItem("mojo_muted") !== "false",
- isAutoRefresh: localStorage.getItem("mojo_auto") !== "false",
-
- // streaming state
- isConnected: false,
- isSyncing: false,
- syncProgress: 0,
- secondsSinceUpdate: 0,
- lastUpdate: null,
- source: null,
-
- // loading messages
- showPhrase: true,
- loadingIndex: 0,
- loadingInterval: null,
- loadingPhrases: [
- "Eliminating side fumbling in the kinematic tree...",
- "Cooling off the physics engine...",
- "Lubricating spurving bearings with phenylhydrobenzamine...",
- "Was it (x, y, z, w) or (w, x, y, z)...?",
- "Synchronizing cardinal grammeters with the warm-start...",
- "Fromaging the bituminous spandrels for stability...",
- "Reducing sinusoidal depleneration in the dingle arm...",
- "Checking the prefabulated amulite for micro-cracks...",
- "Recalculating Chomondeley's annual grillage coefficient...",
- "Polishing the hydrocoptic marzelvanes...",
- "Resolving contact constraints (it's complicated)...",
- "Nubbing the regurgitative purwell to the wennel-sprocket...",
- "Ensuring nofer trunnions are within tolerance...",
- "Consulting the transcendental hopper dadoscope...",
- "Minimizing side-fumbling in the ambifacient vaneshaft...",
- "Aligning the lotus-o-delta stator windings...",
- "Preparing for the inevitable...",
- "Correcting the Lotus-o-delta offset in the kinematic tree...",
- "Tightening the roffit bars on the spamshaft...",
- "Re-aligning the hydrocoptic marzelvanes...",
- "Calibrating the metapolar pilfrometer...",
- "Evaluating the diathecial evolute of retrograde temperature...",
- "De-nubbing the superaminative wennel-sprocket...",
- "Buffering the anhydrous nagling pins...",
- "Shimming the kyptonastic boiling tank...",
- "Analyzing quasi-pietic stresses in the gremlin studs...",
- "Applying drammock oil to the nivelsheave...",
- "Synchronizing the barescent skor motion...",
- "Filtering out reminative tetraiodohexamine...",
- "Stabilizing the modial interaction of magneto-reluctance...",
- "Compensating for capacitive directance...",
- "Scrubbing the manestically placed grouting brushes...",
- "Zeroing out the transcendental hopper dadoscope...",
- "Wrangling the inertia tensor...",
- "Converting Euler angles (and regretting it)...",
- "Refining the convex hull of the collision geometry...",
- "Validating the mass-proportional damping coefficients...",
- "Buffering the unilateral phase detectors...",
- "Extending the drawn reciprocating dingle arm...",
- "Optimizing the panendermic semiboloid slots...",
- "Bleeding air from the non-reversible tremie pipe...",
- "Adjusting the differential girdlespring tension...",
- ],
-
- init() {
- // 1. Immediate check on load
- this.checkServerHealth();
-
- // 2. Periodic heartbeat (every 1 seconds)
- setInterval(() => {
- this.checkServerHealth();
- }, 10000);
-
- // 3. Start the SSE sync if appropriate
- this.startGlobalSync();
- },
-
- async checkServerHealth() {
- // Create a timeout so the fetch doesn't hang for 30 seconds
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 2000);
-
- try {
- const response = await fetch("/monitor/api/status", {
- method: "GET",
- cache: "no-store", // CRITICAL: Stop the browser from lying to us
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- // Explicitly check for 200 OK
- this.isConnected = response.ok;
- } catch (err) {
- // If the server is down, fetch throws a TypeError or AbortError
- this.isConnected = false;
-
- // If we were syncing, stop it now
- if (this.source) {
- this.stopGlobalSync();
- }
- }
- },
-
- setPageReady(val, force = false) {
- if (val) {
- // If we're forcing it (e.g., job is done), duration is 0.
- // Otherwise, keep the 2-second technobabble for "immersion."
- const minDuration = force ? 0 : 2000;
-
- const elapsed = Date.now() - this.loadStartTime;
- const remaining = Math.max(0, minDuration - elapsed);
-
- setTimeout(() => {
- this.isPageReady = true;
- this.stopLoadingMessages();
- }, remaining);
- } else {
- this.loadStartTime = Date.now();
- this.isPageReady = false;
- this.startLoadingMessages();
- }
- },
-
- startLoadingMessages() {
- if (this.loadingInterval) return;
-
- // 1. Initial random seed
- this.loadingIndex = Math.floor(
- Math.random() * this.loadingPhrases.length,
- );
- this.showPhrase = true;
-
- this.loadingInterval = setInterval(() => {
- // 2. Start the exit transition
- this.showPhrase = false;
-
- setTimeout(() => {
- // 3. Pick a new random index that ISN'T the current one
- let nextIndex;
- do {
- nextIndex = Math.floor(Math.random() * this.loadingPhrases.length);
- } while (nextIndex === this.loadingIndex);
-
- this.loadingIndex = nextIndex;
-
- // 4. Trigger the entry transition
- this.showPhrase = true;
- }, 300); // 300ms matches the x-transition duration
- }, 4000);
- },
-
- stopLoadingMessages() {
- clearInterval(this.loadingInterval);
- this.loadingInterval = null;
- },
-
- toggleMute() {
- this.isMuted = !this.isMuted;
- localStorage.setItem("mojo_muted", this.isMuted.toString());
- },
-
- toggleAuto() {
- this.isAutoRefresh = !this.isAutoRefresh;
- localStorage.setItem("mojo_auto", this.isAutoRefresh);
-
- if (this.isAutoRefresh) {
- this.startGlobalSync();
- } else {
- this.stopGlobalSync();
- }
- },
-
- // global sse management
- startGlobalSync() {
- if (this.source || !this.isAutoRefresh || this.isComplete) return;
-
- this.source = new EventSource("/monitor/api/status/stream");
-
- this.source.onmessage = (event) => {
- // 1. SILENT GUARD: Skip empty keep-alive pings from the server
- if (!event.data || !event.data.trim()) return;
-
- try {
- // 2. PROTECTIVE PARSE: Don't let a bad payload kill the app
- const data = JSON.parse(event.data);
-
- // 3. LOGICAL DISPATCH
- if (data.type === "start") this.startSync();
-
- if (data.type === "progress") this.setSyncProgress(data.value);
-
- if (data.type === "final") {
- this.endSync(Date.now(), data.status?.is_complete);
-
- // Broadcast the update to other components (Mosaic/Monitor)
- window.dispatchEvent(
- new CustomEvent("mojo-data-updated", { detail: data.status }),
- );
- }
- } catch (err) {
- // 4. THE SAFETY NET: Handle non-JSON strings or HTML error pages
- console.warn(
- "[Mojo Sync] Received invalid payload. Stream may be idle or server errored.",
- {
- raw: event.data,
- error: err.message,
- },
- );
- }
- };
-
- this.source.onerror = (err) => {
- console.error("[Mojo Sync] Connection lost. Attempting recovery...");
- this.isSyncing = false;
- this.stopGlobalSync();
-
- // Attempt reconnection in 5s
- setTimeout(() => this.startGlobalSync(), 5000);
- };
- },
-
- stopGlobalSync() {
- if (this.source) {
- this.source.close();
- this.source = null;
- this.isConnected = false;
- this.isSyncing = false;
- }
- },
-
- // animation helpers for the 'laser beam'
- startSync() {
- this.isSyncing = true;
- this.syncProgress = 0;
- },
-
- setSyncProgress(val) {
- this.syncProgress = val;
- },
-
- endSync(timestamp, isComplete) {
- this.syncProgress = 100;
- this.lastUpdate = timestamp;
- this.secondsSinceUpdate = 0;
- this.isComplete = isComplete;
- this.isSyncing = false;
-
- // job is done and auto refresh should turn itself off
- if (isComplete) {
- this.stopGlobalSync();
- }
-
- setTimeout(() => {
- this.syncProgress = 0;
- }, 700);
- },
-
- updateSync(timestamp, isComplete = false) {
- this.lastUpdate = timestamp;
- this.secondsSinceUpdate = 0;
- this.isComplete = isComplete;
-
- if (isComplete) {
- this.stopGlobalSync();
- }
- },
- });
-
- const store = Alpine.store("dojo");
-
- // Global Timer
- setInterval(() => {
- if (store.lastUpdate) {
- store.secondsSinceUpdate = Math.floor(
- (Date.now() - store.lastUpdate) / 1000,
- );
- }
- }, 1000);
-
- if (!store.isPageReady) {
- store.loadStartTime = Date.now(); // Record the exact start time of the app
- store.startLoadingMessages();
- }
-});
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/monitor.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/monitor.js
deleted file mode 100644
index fd46b6ca..00000000
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/monitor.js
+++ /dev/null
@@ -1,298 +0,0 @@
-function monitor() {
- return {
- status: {
- n_done: 0,
- n_success: 0,
- n_failed: 0,
- success_tns: [],
- failure_tns: [],
- progress: 0,
- padding_style: "02d", // fallback
- },
- stats: [],
- hasInitialData: false,
- hasCelebrated: false,
-
- async init() {
- // listen for data updates from the global store
- window.addEventListener("mojo-data-updated", (e) => {
- this.handleDataUpdate(e.detail);
- });
-
- // 1. Initial bootstrap
- try {
- 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);
- this.handleDataUpdate(data);
- }
- } catch (e) {
- console.warn("Monitor bootstrap failed.", e);
- } finally {
- // We must call this to restart the SSE connection if the job was restarted!
- Alpine.store("dojo").startGlobalSync();
-
- // tell the global store to start the live pulse if needed
- const shouldForce = this.status.is_complete;
- Alpine.store("dojo").setPageReady(true, shouldForce);
- }
- },
-
- handleDataUpdate(data) {
- this.status = data;
- this.hasInitialData = true;
- this.refreshStats();
-
- if (this.status.is_complete) {
- this.handleCompletion();
- }
- },
-
- refreshStats() {
- const totalDone = this.status.n_done || 0;
- const n_trial = this.status.n_trial || 1;
- const progress = ((totalDone / n_trial) * 100).toFixed(1);
-
- // update tab title with progress
- document.title = `${this.status.is_complete ? "✓" : "(" + progress + "%)"} Monitor | MuJoCo Mojo`;
-
- const successPerc =
- totalDone > 0
- ? ((this.status.n_success / totalDone) * 100).toFixed(1)
- : 0;
- const failurePerc =
- totalDone > 0
- ? ((this.status.n_failed / totalDone) * 100).toFixed(1)
- : 0;
-
- const lastSuccess =
- this.status.success_tns.length > 0
- ? this.status.success_tns.slice(-1)[0]
- : "None";
- const lastFailure =
- this.status.failure_tns.length > 0
- ? this.status.failure_tns.slice(-1)[0]
- : "None";
-
- this.stats = [
- {
- label: "Successes",
- value: `${this.status.n_success} (${successPerc}%)`,
- color: "text-emerald-500",
- subValue: `Last Success: Trial ${lastSuccess}`,
- },
- {
- label: "Failures",
- value: `${this.status.n_failed} (${failurePerc}%)`,
- color: "text-rose-500",
- subValue: `Last Failure: Trial ${lastFailure}`,
- },
- {
- label: "Remaining",
- value: `${this.status.n_remaining} (${(100 - progress).toFixed(1)}%)`,
- color: "text-amber-500",
- subValue: `${this.status.throughput} trials/min (${this.status.avg_duration} per trial)`,
- },
- {
- label: "Time Elapsed",
- value: this.status.elapsed,
- color: "text-slate-500",
- subValue: `Started: ${this.status.start_time}`,
- },
- {
- label: "Total Done",
- value: `${totalDone}`,
- color: "text-cyan-500",
- subValue: `Target: ${n_trial} trials`,
- },
- {
- label: this.status.is_complete ? "Finished" : "Est. Remaining",
- value: this.status.is_complete
- ? "00:00:00"
- : this.status.time_remaining,
- color: "text-slate-500",
- subValue: this.status.is_complete
- ? "Job Complete"
- : `ETA: ${this.status.end_time}`,
- },
- ];
- },
-
- handleCompletion() {
- // Check the local variable instead of sessionStorage
- if (this.hasCelebrated) return;
-
- const theme = this.getHolidayTheme();
- const chime = document.getElementById("chime");
-
- if (!Alpine.store("dojo").isMuted) {
- if (theme.audioUrl) {
- const holidaySound = new Audio(theme.audioUrl);
- holidaySound.play().catch(() => chime.play().catch(() => {}));
- } else {
- chime.play().catch(() => {});
- }
- }
-
- setTimeout(() => {
- this.fireConfetti(theme);
- }, 300);
-
- // Mark as celebrated so it doesn't fire again until the next refresh
- this.hasCelebrated = true;
- },
-
- getHolidayTheme() {
- const now = new Date();
- const m = now.getMonth(); // 0-11
- const d = now.getDate();
- // const m = 2; // 0-11 // BUG placeholder
- // const d = 17; // BUG placeholder
-
- // New Year
- if ((m === 11 && d === 31) || (m === 0 && d <= 2)) {
- return {
- name: "New Year",
- emojis: ["🎆", "✨", "🥂"],
- colors: ["#ffcc00", "#ffffff"],
- audioUrl:
- "https://actions.google.com/sounds/v1/human_voices/crowd_cheer.ogg", // TODO
- };
- }
- // Pi Day
- if (m === 2 && d === 14) {
- return {
- name: "Pi Day",
- emojis: ["π", "🥧"],
- colors: ["#ff9900"],
- audioUrl:
- "https://actions.google.com/sounds/v1/science_fiction/sci_fi_pulse.ogg", // TODO
- };
- }
- // St. Patrick's
- if (m === 2 && d === 17) {
- return {
- name: "St. Patrick's Day",
- emojis: ["🍀", "🌈"],
- colors: ["#22c55e", "#166534"],
- audioUrl:
- "https://actions.google.com/sounds/v1/foley/wind_chime_vibrant.ogg", // TODO
- };
- }
- // May the 4th
- if (m === 4 && d === 4) {
- return {
- name: "May the 4th",
- emojis: ["⚔️", "🌌", "✨"],
- colors: ["#FFE81F", "#2dd4bf"],
- audioUrl:
- "https://actions.google.com/sounds/v1/science_fiction/laser_burst.ogg", // TODO
- };
- }
- // Halloween
- if ((m === 9 && d >= 25) || (m === 10 && d === 1)) {
- return {
- name: "Halloween",
- emojis: ["🎃", "👻", "🦇"],
- colors: ["#ff6600", "#9437ff"],
- audioUrl:
- "https://actions.google.com/sounds/v1/horror/ghost_ly_laugh.ogg", // TODO
- };
- }
- // Winter/Snow
- if (m === 11 || (m === 0 && d <= 15)) {
- return {
- name: "Winter Snow",
- emojis: ["❄️", "⛄", "🌨️"],
- isSnow: true,
- audioUrl:
- "https://actions.google.com/sounds/v1/foley/sleigh_bells_ring.ogg", // TODO
- };
- }
-
- return {
- name: "Standard Mojo",
- colors: ["#06b6d4", "#3b82f6", "#22c55e"],
- // No audioUrl, defaults to chime
- };
- },
-
- fireConfetti(theme = {}) {
- const themeName = theme.name || "Standard Mojo";
- const isSpecial = !!theme.emojis;
- const isSnow = theme.isSnow || false;
-
- console.log(
- `%c 🎊 Mojo Celebration: ${themeName} `,
- "background: #06b6d4; color: #fff; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
- );
-
- const duration = 3000;
- const animationEnd = Date.now() + duration;
- const colors = theme.colors || ["#06b6d4", "#3b82f6", "#22c55e"];
-
- // 1. Shapes + Color Fix (Pi will now be Orange, not Black)
- let shapes = ["circle", "square"];
- if (theme.emojis) {
- shapes = theme.emojis.map((emoji) =>
- confetti.shapeFromText({
- text: emoji,
- scalar: 5,
- color: colors[0], // Forces the symbol to use the theme color
- }),
- );
- }
-
- // 2. Physics Profiles
- const defaults = {
- zIndex: 1000,
- shapes: shapes,
- colors: colors,
- ticks: isSpecial ? 200 : 100,
- scalar: isSpecial ? 5 : 1,
- // Lower gravity (0.4) makes the rotation much slower and floatier
- gravity: isSnow ? 0.4 : isSpecial ? 0.4 : 1.2,
- };
-
- const interval = setInterval(
- () => {
- const timeLeft = animationEnd - Date.now();
- if (timeLeft <= 0) return clearInterval(interval);
-
- if (isSnow) {
- confetti({
- ...defaults,
- particleCount: 1,
- startVelocity: 0,
- drift: (Math.random() - 0.5) * 1.5,
- origin: { x: Math.random(), y: -0.2 },
- });
- } else {
- // 3. BOOSTED DENSITY & CENTER COVERAGE
- // We increased standard from 50 to 150 particles per tick
- const countMultiplier = isSpecial ? 40 : 150;
- const particleCount = countMultiplier * (timeLeft / duration);
-
- // Triple-pop: Fire 3 random bursts every interval to saturate the screen
- for (let i = 0; i < 3; i++) {
- confetti({
- ...defaults,
- particleCount: Math.ceil(particleCount / 3),
- spread: isSpecial ? 90 : 360,
- // Slower velocity (15) for special items makes them easier to read
- startVelocity: isSpecial ? 15 : 45,
- origin: {
- x: Math.random(), // FILL THE CENTER: Completely random horizontal
- y: Math.random() - 0.2, // Random vertical
- },
- });
- }
- }
- },
- isSpecial ? 400 : 250,
- );
- },
- };
-}
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/mosaic.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/mosaic.js
deleted file mode 100644
index 2f90fad2..00000000
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/mosaic.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// static/mosaic.js
-function mosaic() {
- return {
- trials: [],
- loading: true,
-
- async init() {
- // 1. Bootstrap the global state (The Pulse Check)
- try {
- const statusResp = await fetch("/monitor/api/status");
- const statusData = await statusResp.json();
- if (statusData && !statusData.error) {
- Alpine.store("dojo").updateSync(Date.now(), statusData.is_complete);
- }
- } catch (e) {
- console.warn("Global bootstrap failed for Mosaic.", e);
- }
-
- // 2. Load the actual tiles
- await this.refreshTiles();
-
- // 3. Start the live pulse for the footer and auto-refresh
- Alpine.store("dojo").startGlobalSync();
-
- // 4. Listen for new data to refresh the grid live
- window.addEventListener("mojo-data-updated", () => {
- this.refreshTiles(false); // refresh without showing the big loading blur
- });
-
- this.loading = false;
- Alpine.store("dojo").setPageReady(true);
- },
-
- async refreshTiles(showLoading = true) {
- if (showLoading) this.loading = true;
- try {
- const resp = await fetch("/mosaic/api/trials");
- const data = await resp.json();
- this.trials = data.trials || [];
- } catch (e) {
- console.error("Mosaic refresh failed:", e);
- } finally {
- if (showLoading) this.loading = false;
- }
- },
- };
-}
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/trial-viewer.js b/src/mujoco_mojo/utils/layers/dojo/templates/static/trial-viewer.js
deleted file mode 100644
index 457edd1d..00000000
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/trial-viewer.js
+++ /dev/null
@@ -1,2032 +0,0 @@
-/**
- * TAILWIND OFFLINE PALETTE
- * Formalized hex values extracted from Tailwind CSS defaults.
- */
-const tw = {
- slate: {
- 50: "#f8fafc",
- 100: "#f1f5f9",
- 200: "#e2e8f0",
- 300: "#cbd5e1",
- 400: "#94a3b8",
- 500: "#64748b",
- 600: "#475569",
- 700: "#334155",
- 800: "#1e293b",
- 900: "#0f172a",
- 950: "#020617",
- },
- cyan: {
- 400: "#22d3ee",
- 500: "#06b6d4",
- 600: "#0891b2",
- },
- emerald: { 500: "#10b981" },
- blue: { 500: "#3b82f6" },
- violet: { 500: "#8b5cf6" },
- amber: { 500: "#f59e0b" },
- rose: { 500: "#ef4444" },
-};
-
-const DEFAULT_CONFIG = {
- xAxis: "time",
- yAxes: {}, // Key: signal name, Value: { label, color, width, etc. }
- refFrame: null,
- grid: "all",
- linemode: "lines", // Renamed from markerMode for clarity
- interp: "linear", // line interpolation (linear, spline, etc)
- hover: "closest", // "x unified", "y unified", "closest", "x", "y", "none"
- title: "",
- xAxisTitle: "",
- yAxisTitle: "",
- showSpike: true,
- legendPos: "bottom", // "bottom", "right", "hidden"
- rangeX: null,
- rangeY: null,
- xScale: "linear",
- yScale: "linear",
- vsEnabled: false,
- vsRange: [0, 10],
- annotations: [], // Stores { x, y, text }
- shapes: [], // Stores { type, x0, x1, y0, y1, label }
-};
-
-/**
- * trialViewer - Alpine.js Component
- * Handles telemetry data retrieval, Plotly rendering, and JSON configuration state.
- */
-function trialViewer(trialId, externalUrl) {
- return {
- // --- BASE STATE ---
- trialId: trialId,
- externalUrl: externalUrl,
- warpId: null,
- paddingLen: 2,
- loading: true,
- isMac: /Mac|iPhone|iPod|iPad/.test(navigator.platform),
- data: null,
- errorState: null,
-
- // --- UI / MENU STATES ---
- theme: "dark",
- xMenuOpen: false,
- xSearch: "",
- yMenuOpen: false,
- ySearch: "",
- refFrameMenuOpen: false,
- settingsOpen: false,
- downloadOpen: false,
-
- activeFrame: null,
- dragCounter: 0, // tracks if a file is being hovered over the page
- editorOpen: false, // Controls the visibility of the JSON editor drawer
- columns: [],
- rotateableVectors: [],
- showToast: false,
- toastMessage: "",
- toastType: "success",
- discoveryId: 0,
- plotColors: [
- tw.cyan[500],
- tw.emerald[500],
- tw.blue[500],
- tw.violet[500],
- tw.amber[500],
- tw.rose[500],
- ],
-
- // --- SINGLE SOURCE OF TRUTH: PLOT CONFIGURATION ---
- // This object is the master state. Changes here trigger re-renders.
- config: JSON.parse(JSON.stringify(DEFAULT_CONFIG)), // deep clone the default config
-
- // --- JSON EDITOR STATE ---
- configRaw: "", // Pretty-printed string for the