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 82534229..fad3210b 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
@@ -140,6 +140,8 @@
isEditingRaw: false,
// --- PROFILES ---
profiles: [],
+ profileWarnings: {},
+ profileSearch: "",
profilesOpen: false,
profileNameDraft: "",
// --- FILTER SCHEMAS (loaded from /mosaic/api/filter-schema on init) ---
@@ -321,7 +323,7 @@
},
handlePlotClickForShapes(pt) {
if (!this.placementMode) return false;
- const defaultColor = tw.cyan[500];
+ const defaultColor = this.plotColors[this.config.shapes.length % this.plotColors.length];
let newShape = null;
if (this.placementMode === "vline") {
newShape = { type: "vline", x0: pt.x, color: defaultColor, label: "" };
@@ -493,11 +495,16 @@
void this.$nextTick(async () => {
await this.renderPlot();
const plotEl = document.getElementById("plot-area");
+ plotEl.on("plotly_doubleclick", () => {
+ this.config.rangeX = null;
+ this.config.rangeY = null;
+ void this.renderPlot();
+ });
plotEl.on("plotly_relayout", (event) => {
- if (event["xaxis.autorange"] ?? event["yaxis.autorange"]) {
+ if (event["xaxis.autorange"] || event["yaxis.autorange"]) {
this.config.rangeX = null;
this.config.rangeY = null;
- this.renderPlot();
+ void this.renderPlot();
return;
}
if (event["xaxis.range[0]"] !== void 0) {
@@ -508,29 +515,47 @@
}
});
plotEl.addEventListener("click", (e) => {
+ if (!this.placementMode) return;
const target = e.target;
- const isPlotValue = target.classList.contains("nsewdrag") || target.classList.contains("drag");
- if (!isPlotValue) 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)
+ });
+ });
+ 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;
+ 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 pt = { x: xVal, y: yVal };
- if (this.placementMode) {
- this.handlePlotClickForShapes(pt);
- return;
- }
setTimeout(() => {
- this.annDraft = { x: pt.x, y: pt.y, text: "" };
+ this.annDraft = { x: xVal, y: yVal, text: "" };
this.annEditIndex = null;
this.annotationsOpen = true;
void this.$nextTick(() => {
- const input = document.querySelector('[x-ref="annInput"]');
- input?.focus();
+ document.querySelector('[x-ref="annInput"]')?.focus();
});
}, 0);
});
+ new MutationObserver((mutations) => {
+ for (const { addedNodes } of mutations) {
+ for (const node of addedNodes) {
+ if (!(node instanceof HTMLElement)) continue;
+ const notif = node.classList.contains("plotly-notifier") ? node : node.querySelector?.(".plotly-notifier");
+ if (!notif) continue;
+ const text = notif.textContent?.replace(/×/g, "").trim();
+ if (text) this.notify(text, "info");
+ notif.style.display = "none";
+ }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
if (plotEl?.offsetParent !== null) Plotly.Plots.resize(plotEl);
}, 100);
@@ -552,8 +577,18 @@
document.querySelector('input[type="number"]')?.focus();
}
if (e.key === "Escape") {
- this.yMenuOpen = this.settingsOpen = this.editorOpen = false;
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 (["INPUT", "TEXTAREA"].includes(tag)) return;
if (e.key === "ArrowLeft") document.getElementById("nav-prev")?.click();
@@ -1125,11 +1160,35 @@
},
// -----------------------------------------------------------------------
// Profiles
+ // Encode each path segment individually so 'project/name' becomes 'project/name'
+ // in the URL (not 'project%2Fname'), matching the {name:path} FastAPI route.
+ _profileUrl(name) {
+ return `/mosaic/api/profiles/${name.split("/").map(encodeURIComponent).join("/")}`;
+ },
// -----------------------------------------------------------------------
async loadProfiles() {
try {
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 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}"`);
+ }
+ 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);
}
@@ -1144,7 +1203,7 @@
const existing = this.profiles.find((p) => normalise(p.name) === normalise(name));
if (existing && !confirm(`Overwrite profile "${existing.name}"?`)) return;
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`, {
+ const resp = await fetch(this._profileUrl(name), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.config)
@@ -1160,20 +1219,32 @@
},
async loadProfile(name) {
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`);
- if (!resp.ok) throw new Error("Not found");
+ const resp = await fetch(this._profileUrl(name));
+ if (!resp.ok) {
+ const body = await resp.json().catch(() => ({}));
+ throw new Error(body.detail ?? `HTTP ${resp.status}`);
+ }
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 missing = [];
+ if (loaded.xAxis && !colSet.has(loaded.xAxis)) missing.push(`x-axis "${loaded.xAxis}"`);
+ 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 (missing.length) {
+ throw new Error(`references columns not in this trial: ${missing.join(", ")}`);
+ }
this.config = { ...this.config, ...loaded };
this.notify(`Profile "${name}" loaded`, "success");
- } catch {
- this.notify(`Failed to load "${name}"`, "error");
+ } catch (e) {
+ this.notify(`Failed to load "${name}": ${e.message}`, "error");
}
},
async deleteProfile(name) {
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`, {
- method: "DELETE"
- });
+ const resp = await fetch(this._profileUrl(name), { method: "DELETE" });
if (!resp.ok) throw new Error("Delete failed");
await this.loadProfiles();
this.notify(`Profile "${name}" deleted`, "info");
@@ -1240,8 +1311,6 @@
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 displayRangeX = this.config.rangeX ?? this.calculatePaddedRange([this.config.xAxis], false);
- const displayRangeY = this.config.rangeY ?? this.calculatePaddedRange(Object.keys(this.config.yAxes));
const yKeys = Object.keys(this.config.yAxes);
let traces = yKeys.map((key, i) => {
const p = this.getYProps(key, i);
@@ -1294,7 +1363,10 @@
}
const xAxisObj = {
type: this.config.xScale ?? "linear",
- range: this.config.xScale === "log" ? [Math.log10(Math.max(1e-6, displayRangeX[0])), Math.log10(Math.max(1e-6, displayRangeX[1]))] : displayRangeX,
+ ...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
+ } : { autorange: true },
dtick: this.config.xScale === "log" && this.config.xLogBase ? Math.log10(this.config.xLogBase) : void 0,
gridcolor: majorGrid,
showgrid: this.config.grid !== "none",
@@ -1302,7 +1374,6 @@
zeroline: false,
tickfont: { color: textColor, size: 14 },
title: { text: this.config.xAxisTitle || this.config.xAxis, font: { size: 14, color: textColor, family: "monospace" } },
- autorange: false,
showspikes: showX,
spikemode: "across",
spikelinecolor: spikeColor,
@@ -1311,7 +1382,10 @@
const frameLabel = this.config.refFrame ? `
[Frame: ${this.config.refFrame}]` : "";
const yAxisObj = {
type: this.config.yScale ?? "linear",
- range: this.config.yScale === "log" ? [Math.log10(Math.max(1e-6, displayRangeY[0])), Math.log10(Math.max(1e-6, displayRangeY[1]))] : displayRangeY,
+ ...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
+ } : { autorange: true },
dtick: this.config.yScale === "log" && this.config.yLogBase ? Math.log10(this.config.yLogBase) : void 0,
gridcolor: majorGrid,
showgrid: this.config.grid !== "none",
@@ -1319,7 +1393,6 @@
zeroline: false,
tickfont: { color: textColor, size: 14 },
title: { text: this.config.yAxisTitle + frameLabel, font: { size: 14, color: textColor, family: "monospace" } },
- autorange: false,
showspikes: showY,
spikemode: "across",
spikelinecolor: spikeColor,
@@ -1377,7 +1450,7 @@
return base;
})
};
- const config = { responsive: true, displaylogo: false, displayModeBar: true, modeBarButtonsToRemove: ["toImage"] };
+ const config = { responsive: true, displaylogo: false, displayModeBar: true, modeBarButtonsToRemove: ["toImage"], doubleClick: false };
return Plotly.react("plot-area", traces, layout, config);
}
};
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 4712159c..b4478080 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,49 +1,72 @@
// ---------------------------------------------------------------------------
// 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
+// and imported from plot-config.generated.ts to avoid duplication.
// ---------------------------------------------------------------------------
-export const DASH_OPTIONS = ['solid', 'dash', 'dot', 'dashdot'] as const;
-export type DashStyle = (typeof DASH_OPTIONS)[number];
+import type {
+ DashStyle,
+ GridMode,
+ HoverMode,
+ InterpMode,
+ LegendPos,
+ LineMode,
+ MarkerSymbol,
+ ScaleType,
+} from "./plot-config.generated";
-export const MARKER_OPTIONS = ['none', 'circle', 'square', 'diamond', 'cross'] as const;
-export type MarkerSymbol = (typeof MARKER_OPTIONS)[number];
+export type {
+ DashStyle,
+ GridMode,
+ HoverMode,
+ InterpMode,
+ LegendPos,
+ LineMode,
+ MarkerSymbol,
+ ScaleType,
+};
-export const GRID_OPTIONS = ['none', 'major', 'all'] as const;
-export type GridMode = (typeof GRID_OPTIONS)[number];
+export const DASH_OPTIONS: DashStyle[] = ["solid", "dash", "dot", "dashdot"];
+
+export const MARKER_OPTIONS: MarkerSymbol[] = [
+ "none",
+ "circle",
+ "square",
+ "diamond",
+ "cross",
+];
+
+export const GRID_OPTIONS: GridMode[] = ["none", "major", "all"];
export const LINE_MODE_OPTIONS = [
- { label: 'Lines', value: 'lines' },
- { label: 'Markers', value: 'markers' },
- { label: 'Both', value: 'lines+markers' },
+ { label: "Lines", value: "lines" as LineMode },
+ { label: "Markers", value: "markers" as LineMode },
+ { label: "Both", value: "lines+markers" as LineMode },
] as const;
-export type LineMode = (typeof LINE_MODE_OPTIONS)[number]['value'];
export const INTERP_OPTIONS = [
- { label: 'Linear', value: 'linear' },
- { label: 'Spline', value: 'spline' },
- { label: 'Step (HV)', value: 'hv' },
- { label: 'Step (VH)', value: 'vh' },
- { label: 'Step (HVH)', value: 'hvh' },
- { label: 'Step (VHV)', value: 'vhv' },
+ { label: "Linear", value: "linear" as InterpMode },
+ { label: "Spline", value: "spline" as InterpMode },
+ { label: "Step (HV)", value: "hv" as InterpMode },
+ { label: "Step (VH)", value: "vh" as InterpMode },
+ { label: "Step (HVH)", value: "hvh" as InterpMode },
+ { label: "Step (VHV)", value: "vhv" as InterpMode },
] as const;
-export type InterpMode = (typeof INTERP_OPTIONS)[number]['value'];
export const HOVER_OPTIONS = [
- { label: 'Unified X', value: 'x unified' },
- { label: 'Unified Y', value: 'y unified' },
- { label: 'Closest', value: 'closest' },
- { label: 'X Axis', value: 'x' },
- { label: 'Y Axis', value: 'y' },
- { label: 'Off', value: 'none' },
+ { label: "Unified X", value: "x unified" as HoverMode },
+ { label: "Unified Y", value: "y unified" as HoverMode },
+ { label: "Closest", value: "closest" as HoverMode },
+ { label: "X Axis", value: "x" as HoverMode },
+ { label: "Y Axis", value: "y" as HoverMode },
+ { label: "Off", value: "none" as HoverMode },
] as const;
-export type HoverMode = (typeof HOVER_OPTIONS)[number]['value'];
-export const LEGEND_POS_OPTIONS = ['bottom', 'right', 'hidden'] as const;
-export type LegendPos = (typeof LEGEND_POS_OPTIONS)[number];
+export const LEGEND_POS_OPTIONS: LegendPos[] = ["bottom", "right", "hidden"];
-export const SCALE_OPTIONS = ['linear', 'log'] as const;
-export type ScaleType = (typeof SCALE_OPTIONS)[number];
+export const SCALE_OPTIONS: ScaleType[] = ["linear", "log"];
// ---------------------------------------------------------------------------
// Label-lookup helpers — derive the display string for a current config value.
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/plot-config.generated.ts b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/plot-config.generated.ts
new file mode 100644
index 00000000..8dc0ccdc
--- /dev/null
+++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/lib/plot-config.generated.ts
@@ -0,0 +1,88 @@
+// ============================================================
+// AUTO-GENERATED — do not edit manually.
+// Source: src/mujoco_mojo/utils/layers/dojo/plot_config.py
+// Regenerate: python scripts/gen_ts_models.py
+// ============================================================
+
+export interface Annotation {
+ x: number;
+ y: number;
+ text: string;
+}
+
+export type DashStyle = "solid" | "dash" | "dot" | "dashdot";
+
+export interface FilterEntry {
+ type: string;
+ enabled?: boolean;
+ [key: string]: unknown;
+}
+
+export type GridMode = "none" | "major" | "all";
+
+export type HoverMode =
+ | "x unified"
+ | "y unified"
+ | "closest"
+ | "x"
+ | "y"
+ | "none";
+
+export type InterpMode = "linear" | "spline" | "hv" | "vh" | "hvh" | "vhv";
+
+export type LegendPos = "bottom" | "right" | "hidden";
+
+export type LineMode = "lines" | "markers" | "lines+markers";
+
+export type MarkerSymbol = "none" | "circle" | "square" | "diamond" | "cross";
+
+export type ScaleType = "linear" | "log";
+
+export interface Shape {
+ type: ShapeType;
+ x0: number;
+ x1?: number | null;
+ y0?: number | null;
+ y1?: number | null;
+ color: string;
+ dash?: DashStyle | null;
+ label: string;
+}
+
+export type ShapeType = "vline" | "hline" | "rect";
+
+export interface YAxisConfig {
+ label: string;
+ color: string;
+ width: number;
+ opacity: number;
+ filters: FilterEntry[];
+ dash: DashStyle;
+ marker: MarkerSymbol;
+}
+
+/** Complete serialisable state of a trial-viewer plot. */
+export interface PlotConfig {
+ xAxis: string;
+ yAxes: Record
;
+ refFrame: string | null;
+ grid: GridMode;
+ linemode: LineMode;
+ interp: InterpMode;
+ hover: HoverMode;
+ title: string;
+ xAxisTitle: string;
+ yAxisTitle: string;
+ showSpike: boolean;
+ legendPos: LegendPos;
+ rangeX: [number, number] | null;
+ rangeY: [number, number] | null;
+ xScale: ScaleType;
+ yScale: ScaleType;
+ xLogBase?: number | null;
+ yLogBase?: number | null;
+ vsEnabled: boolean;
+ vsRange: [number, number];
+ annotations: Annotation[];
+ shapes: Shape[];
+}
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/models.ts b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/models.ts
index bbbe5dc7..0df9e182 100644
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/models.ts
+++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/models.ts
@@ -1,13 +1,19 @@
-import type {
+export type {
+ Annotation,
DashStyle,
+ FilterEntry,
GridMode,
HoverMode,
InterpMode,
LegendPos,
LineMode,
MarkerSymbol,
+ PlotConfig,
ScaleType,
-} from './lib/options';
+ Shape,
+ ShapeType,
+ YAxisConfig,
+} from "./lib/plot-config.generated";
// ---------------------------------------------------------------------------
// Backend API shapes
@@ -51,15 +57,9 @@ export interface TrialDataResponse {
// Filter stack (per-signal server-side transformations)
// ---------------------------------------------------------------------------
-export interface FilterEntry {
- type: string;
- enabled?: boolean;
- [key: string]: unknown;
-}
-
export interface FilterParamSchema {
name: string;
- type: 'float' | 'int' | 'bool' | 'string';
+ type: "float" | "int" | "bool" | "string";
default: number | boolean | string | null;
min?: number;
max?: number;
@@ -80,61 +80,8 @@ export interface FilterSchema {
unit_groups?: UnitGroup[];
}
-// ---------------------------------------------------------------------------
-// Plot configuration (the serialized state stored in localStorage / URL)
-// ---------------------------------------------------------------------------
-
-export interface YAxisConfig {
- label: string;
- color: string;
- width: number;
- opacity: number;
- filters: FilterEntry[];
- dash: DashStyle;
- marker: MarkerSymbol;
-}
-
-export interface Annotation {
- x: number;
- y: number;
- text: string;
-}
-
-export interface Shape {
- type: 'vline' | 'hline' | 'rect';
- x0: number;
- x1?: number;
- y0?: number;
- y1?: number;
- color: string;
- dash?: DashStyle;
- label: string;
-}
-
-export interface PlotConfig {
- xAxis: string;
- yAxes: Record;
- refFrame: string | null;
- grid: GridMode;
- linemode: LineMode;
- interp: InterpMode;
- hover: HoverMode;
- title: string;
- xAxisTitle: string;
- yAxisTitle: string;
- showSpike: boolean;
- legendPos: LegendPos;
- rangeX: [number, number] | null;
- rangeY: [number, number] | null;
- xScale: ScaleType;
- yScale: ScaleType;
- xLogBase?: number;
- yLogBase?: number;
- vsEnabled: boolean;
- vsRange: [number, number];
- annotations: Annotation[];
- shapes: Shape[];
-}
+// PlotConfig and related types are generated from plot_config.py — see the
+// re-exports at the top of this file and lib/plot-config.generated.ts.
// ---------------------------------------------------------------------------
// Notification history
@@ -143,7 +90,7 @@ export interface PlotConfig {
export interface NotificationEntry {
id: number;
message: string;
- type: 'success' | 'error' | 'info';
+ type: "success" | "error" | "info";
timestamp: number;
read: boolean;
}
@@ -160,7 +107,11 @@ export interface DojoStore {
isAutoRefresh: boolean;
isConnected: boolean;
_wasConnected: boolean | null;
- globalToast: { show: boolean; message: string; type: 'success' | 'error' | 'info' };
+ globalToast: {
+ show: boolean;
+ message: string;
+ type: "success" | "error" | "info";
+ };
isSyncing: boolean;
syncProgress: number;
secondsSinceUpdate: number;
@@ -170,13 +121,13 @@ export interface DojoStore {
unreadCount: number;
notifOpen: boolean;
notifTick: number;
- toast(message: string, type?: 'success' | 'error' | 'info'): void;
+ toast(message: string, type?: "success" | "error" | "info"): void;
_setConnected(connected: boolean): void;
startGlobalSync(): void;
stopGlobalSync(): void;
setPageReady(val: boolean, force?: boolean): void;
updateSync(timestamp: number, isComplete?: boolean): void;
- addNotification(message: string, type: 'success' | 'error' | 'info'): void;
+ addNotification(message: string, type: "success" | "error" | "info"): void;
openNotifications(): void;
clearNotifications(): void;
}
diff --git a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/trial-viewer.ts b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/trial-viewer.ts
index a12a5621..6a7dffdd 100644
--- a/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/trial-viewer.ts
+++ b/src/mujoco_mojo/utils/layers/dojo/templates/static/ts/src/trial-viewer.ts
@@ -1,6 +1,6 @@
-import { OPTIONS } from './lib/options';
-import { createToastMixin } from './lib/toast';
-import type { AlpineMagics } from './types/global';
+import { OPTIONS } from "./lib/options";
+import { createToastMixin } from "./lib/toast";
+import type { AlpineMagics } from "./types/global";
import type {
Annotation,
DojoStore,
@@ -12,38 +12,50 @@ import type {
TrialManifest,
UnitGroup,
YAxisConfig,
-} from './models';
+} from "./models";
// ---------------------------------------------------------------------------
// Tailwind offline palette — hex values matching 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' },
+ 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" },
} as const;
const DEFAULT_CONFIG: PlotConfig = {
- xAxis: 'time',
+ xAxis: "time",
yAxes: {},
refFrame: null,
- grid: 'all',
- linemode: 'lines',
- interp: 'linear',
- hover: 'closest',
- title: '',
- xAxisTitle: '',
- yAxisTitle: '',
+ grid: "all",
+ linemode: "lines",
+ interp: "linear",
+ hover: "closest",
+ title: "",
+ xAxisTitle: "",
+ yAxisTitle: "",
showSpike: true,
- legendPos: 'bottom',
+ legendPos: "bottom",
rangeX: null,
rangeY: null,
- xScale: 'linear',
- yScale: 'linear',
+ xScale: "linear",
+ yScale: "linear",
vsEnabled: false,
vsRange: [0, 10],
annotations: [],
@@ -69,11 +81,11 @@ function trialViewer(trialId: string, externalUrl: string) {
errorState: null as string | null,
// --- UI / MENU STATES ---
- theme: 'dark',
+ theme: "dark",
xMenuOpen: false,
- xSearch: '',
+ xSearch: "",
yMenuOpen: false,
- ySearch: '',
+ ySearch: "",
refFrameMenuOpen: false,
settingsOpen: false,
downloadOpen: false,
@@ -83,7 +95,14 @@ function trialViewer(trialId: string, externalUrl: string) {
columns: [] as string[],
rotateableVectors: [] as string[],
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(),
@@ -95,7 +114,7 @@ function trialViewer(trialId: string, externalUrl: string) {
config: JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as PlotConfig,
// --- JSON EDITOR STATE ---
- configRaw: '',
+ configRaw: "",
isValidJson: true,
isValidConfig: true,
configErrors: [] as string[],
@@ -103,8 +122,10 @@ function trialViewer(trialId: string, externalUrl: string) {
// --- PROFILES ---
profiles: [] as Array<{ name: string; modified: number }>,
+ profileWarnings: {} as Record,
+ profileSearch: "",
profilesOpen: false,
- profileNameDraft: '',
+ profileNameDraft: "",
// --- FILTER SCHEMAS (loaded from /mosaic/api/filter-schema on init) ---
filterSchemas: [] as FilterSchema[],
@@ -114,7 +135,10 @@ function trialViewer(trialId: string, externalUrl: string) {
// deduplicates filter error toasts so VS mode (N parallel fetches) shows each error once
_shownFilterErrors: new Set(),
// in-progress signal editor edits that survive closing/reopening the panel
- signalDrafts: {} as Record,
+ signalDrafts: {} as Record<
+ string,
+ { draft: YAxisConfig; baseSnapshot: string }
+ >,
// --- MATCHUP STATE ---
vsDatasets: {} as Record>,
@@ -137,7 +161,7 @@ function trialViewer(trialId: string, externalUrl: string) {
// --- SHAPES ---
shapesOpen: false,
- placementMode: null as 'vline' | 'hline' | 'rect' | null,
+ placementMode: null as "vline" | "hline" | "rect" | null,
rectStart: null as { x: number; y: number } | null,
shapeDraft: null as Shape | null,
shapeEditIndex: null as number | null,
@@ -160,10 +184,14 @@ function trialViewer(trialId: string, externalUrl: string) {
if (this.historyIndex > 0) {
this.isUndoing = true;
this.historyIndex--;
- this.config = JSON.parse(this.historyStack[this.historyIndex] ?? '{}') as PlotConfig;
+ this.config = JSON.parse(
+ this.historyStack[this.historyIndex] ?? "{}",
+ ) as PlotConfig;
this.persistHistory();
- void this.$nextTick(() => { this.isUndoing = false; });
- this.notify('Undo', 'info');
+ void this.$nextTick(() => {
+ this.isUndoing = false;
+ });
+ this.notify("Undo", "info");
}
},
@@ -171,15 +199,22 @@ function trialViewer(trialId: string, externalUrl: string) {
if (this.historyIndex < this.historyStack.length - 1) {
this.isUndoing = true;
this.historyIndex++;
- this.config = JSON.parse(this.historyStack[this.historyIndex] ?? '{}') as PlotConfig;
+ this.config = JSON.parse(
+ this.historyStack[this.historyIndex] ?? "{}",
+ ) as PlotConfig;
this.persistHistory();
- void this.$nextTick(() => { this.isUndoing = false; });
- this.notify('Redo', 'info');
+ void this.$nextTick(() => {
+ this.isUndoing = false;
+ });
+ this.notify("Redo", "info");
}
},
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: number, direction: number, isWarp = false) {
@@ -193,7 +228,9 @@ function trialViewer(trialId: string, externalUrl: string) {
newKeys.splice(index + direction, 0, movedKey);
}
const newYAxes: Record = {};
- newKeys.forEach((k) => { newYAxes[k] = this.config.yAxes[k]!; });
+ newKeys.forEach((k) => {
+ newYAxes[k] = this.config.yAxes[k]!;
+ });
this.config.yAxes = newYAxes;
this.saveAndRender();
},
@@ -201,11 +238,16 @@ function trialViewer(trialId: string, externalUrl: string) {
// -----------------------------------------------------------------------
// Data fetching
// -----------------------------------------------------------------------
- async fetchTrialData(id: string, requiredCols: string[] = []): Promise {
+ async fetchTrialData(
+ id: string,
+ requiredCols: string[] = [],
+ ): Promise {
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);
// include active filter stacks for requested yAxis columns
const filtersPayload: Record = {};
@@ -214,33 +256,46 @@ function trialViewer(trialId: string, externalUrl: string) {
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')));
+ .map((f) =>
+ Object.fromEntries(
+ Object.entries(f).filter(([k]) => k !== "enabled"),
+ ),
+ );
if (active.length > 0) filtersPayload[col] = active;
}
}
if (Object.keys(filtersPayload).length > 0) {
- colParams.append('filters', JSON.stringify(filtersPayload));
+ colParams.append("filters", JSON.stringify(filtersPayload));
}
const queryStr = colParams.toString();
if (queryStr) url += `?${queryStr}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Trial ${id} failed`);
- const result = await resp.json() as TrialDataResponse;
+ const result = (await resp.json()) as TrialDataResponse;
if (result.filter_errors && result.filter_errors.length > 0) {
result.filter_errors.forEach((msg) => {
if (!(this._shownFilterErrors as Set).has(msg)) {
(this._shownFilterErrors as Set).add(msg);
- this.notify(msg, 'error');
+ this.notify(msg, "error");
// clear after 5 s so the same error can resurface if the user tries again
- setTimeout(() => (this._shownFilterErrors as Set).delete(msg), 5000);
+ setTimeout(
+ () => (this._shownFilterErrors as Set).delete(msg),
+ 5000,
+ );
}
});
}
return result;
},
- async trickleFetch(id: string, columnList: string[], label: string, isVsDataset: boolean, loopId: number) {
+ async trickleFetch(
+ id: string,
+ columnList: string[],
+ label: string,
+ isVsDataset: boolean,
+ loopId: number,
+ ) {
const CHUNK_SIZE = 10;
for (let i = 0; i < columnList.length; i += CHUNK_SIZE) {
if (loopId !== this.discoveryId) return;
@@ -249,13 +304,19 @@ function trialViewer(trialId: string, externalUrl: string) {
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);
}
@@ -264,8 +325,17 @@ function trialViewer(trialId: string, externalUrl: string) {
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]);
@@ -273,27 +343,43 @@ function trialViewer(trialId: string, externalUrl: string) {
const activeCols = [this.config.xAxis, ...Object.keys(this.config.yAxes)];
const draftIds = this.allTrials.filter((id) => {
- const n = parseInt(id.split('_').pop() ?? '');
+ const n = parseInt(id.split("_").pop() ?? "");
return n >= start && n <= end && id !== this.trialId;
});
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,
+ );
}
},
// -----------------------------------------------------------------------
// Shapes
// -----------------------------------------------------------------------
- setPlacementMode(type: 'vline' | 'hline' | 'rect') {
+ setPlacementMode(type: "vline" | "hline" | "rect") {
this.placementMode = type;
this.rectStart = null;
this.shapeDraft = null;
- const label = type === 'vline' ? 'Vertical Line' : type === 'hline' ? 'Horizontal Line' : 'Area Rectangle';
- this.notify(`Mode: ${label}. Click plot to place.`, 'info');
+ const label =
+ type === "vline"
+ ? "Vertical Line"
+ : type === "hline"
+ ? "Horizontal Line"
+ : "Area Rectangle";
+ this.notify(`Mode: ${label}. Click plot to place.`, "info");
},
deleteShape(index: number) {
@@ -303,16 +389,34 @@ function trialViewer(trialId: string, externalUrl: string) {
handlePlotClickForShapes(pt: { x: number; y: number }): boolean {
if (!this.placementMode) return false;
- const defaultColor = tw.cyan[500];
+ const defaultColor =
+ this.plotColors[this.config.shapes.length % this.plotColors.length]!;
let newShape: Shape | null = null;
- 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: '' };
- } 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: '' };
+ 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: "",
+ };
+ } 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: "",
+ };
this.rectStart = null;
}
@@ -365,7 +469,9 @@ function trialViewer(trialId: string, externalUrl: string) {
startAnnEdit(index: number) {
this.annEditIndex = index;
this.annDraft = { ...this.config.annotations[index]! };
- void this.$nextTick(() => (this.$refs['annInput'] as HTMLInputElement | undefined)?.focus());
+ void this.$nextTick(() =>
+ (this.$refs["annInput"] as HTMLInputElement | undefined)?.focus(),
+ );
},
cancelAnnDraft() {
@@ -374,23 +480,48 @@ function trialViewer(trialId: string, externalUrl: string) {
},
jumpToAnnotation(ann: Annotation) {
- const el = document.getElementById('plot-area') as (HTMLElement & { _fullLayout?: { xaxis: { p2l(v: number): number }; yaxis: { p2l(v: number): number }; margin: { l: number; t: number } } }) | null;
+ const el = document.getElementById("plot-area") as
+ | (HTMLElement & {
+ _fullLayout?: {
+ xaxis: { p2l(v: number): number };
+ yaxis: { p2l(v: number): number };
+ margin: { l: number; t: number };
+ };
+ })
+ | null;
if (!el || !this.data) return;
const xValues = this.data[this.config.xAxis] ?? [];
const xMin = xValues[0] ?? 0;
const xMax = xValues[xValues.length - 1] ?? 100;
const xSpan = (xMax - xMin) * 0.1;
let newRangeX: [number, number] = [ann.x - xSpan / 2, ann.x + xSpan / 2];
- if (newRangeX[0] < xMin) { newRangeX[1] += xMin - newRangeX[0]; newRangeX[0] = xMin; }
- if (newRangeX[1] > xMax) { newRangeX[0] -= newRangeX[1] - xMax; newRangeX[1] = xMax; }
+ if (newRangeX[0] < xMin) {
+ newRangeX[1] += xMin - newRangeX[0];
+ newRangeX[0] = xMin;
+ }
+ if (newRangeX[1] > xMax) {
+ 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: [number, number] = [ann.y - ySpan / 2, ann.y + ySpan / 2];
+ const newRangeY: [number, number] = [
+ 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();
},
@@ -401,8 +532,8 @@ function trialViewer(trialId: string, externalUrl: string) {
editAnnotation(index: number) {
const ann = this.config.annotations[index]!;
- const newText = prompt('Update Annotation:', ann.text);
- if (newText !== null && newText.trim() !== '') {
+ const newText = prompt("Update Annotation:", ann.text);
+ if (newText !== null && newText.trim() !== "") {
this.config.annotations[index]!.text = newText;
this.saveAndRender();
}
@@ -415,62 +546,82 @@ function trialViewer(trialId: string, externalUrl: string) {
if (!this.columns) return [];
if (!this.config.refFrame) return this.columns;
return this.columns.filter((col) => {
- const parts = col.split(':');
+ const parts = col.split(":");
const suffix = parts.pop();
- const family = parts.join(':');
- return ['x', 'y', 'z'].includes(suffix ?? '') && (this.rotateableVectors ?? []).includes(family);
+ const family = parts.join(":");
+ return (
+ ["x", "y", "z"].includes(suffix ?? "") &&
+ (this.rotateableVectors ?? []).includes(family)
+ );
});
},
get availableQuats(): string[] {
if (!this.columns || !Array.isArray(this.columns)) return [];
- return this.columns.filter((c) => c.endsWith(':w')).map((c) => c.replace(':w', ''));
+ return this.columns
+ .filter((c) => c.endsWith(":w"))
+ .map((c) => c.replace(":w", ""));
},
// -----------------------------------------------------------------------
// Init
// -----------------------------------------------------------------------
async init() {
- this.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
- const currentNum = parseInt(this.trialId.split('_').pop() ?? '');
+ this.theme = document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light";
+ const currentNum = parseInt(this.trialId.split("_").pop() ?? "");
this.warpId = isNaN(currentNum) ? null : currentNum;
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 (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();
}
});
observer.observe(document.documentElement, { attributes: true });
try {
- const schemaResp = await fetch('/mosaic/api/filter-schema');
- this.filterSchemas = await schemaResp.json() as FilterSchema[];
+ const schemaResp = await fetch("/mosaic/api/filter-schema");
+ this.filterSchemas = (await schemaResp.json()) as FilterSchema[];
} catch (e) {
- console.warn('Failed to load filter schemas', e);
+ console.warn("Failed to load filter schemas", e);
}
try {
- const statusResp = await fetch('/monitor/api/status');
- const statusData = await statusResp.json() as { error?: boolean; is_complete: boolean; padding_style: string };
+ const statusResp = await fetch("/monitor/api/status");
+ const statusData = (await statusResp.json()) as {
+ error?: boolean;
+ is_complete: boolean;
+ padding_style: string;
+ };
if (statusData && !statusData.error) {
- (Alpine.store('dojo') as DojoStore).updateSync(Date.now(), statusData.is_complete);
+ (Alpine.store("dojo") as DojoStore).updateSync(
+ Date.now(),
+ statusData.is_complete,
+ );
const match = statusData.padding_style.match(/\d+/);
this.paddingLen = match ? parseInt(match[0]!) : 2;
}
} catch (e) {
- console.warn('Dojo offline', e);
+ console.warn("Dojo offline", e);
}
try {
- const initialCols = [this.config.xAxis, ...Object.keys(this.config.yAxes)];
+ 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;
const params = new URLSearchParams(window.location.search);
- const shared = params.get('v');
+ const shared = params.get("v");
if (shared) {
this.hydrateFromUrl(shared);
this.vsDraft.enabled = this.config.vsEnabled;
@@ -482,97 +633,190 @@ function trialViewer(trialId: string, externalUrl: string) {
this.vsDraft.range = [...this.config.vsRange];
}
- void this.$nextTick(() => { this.pushHistory(); });
+ void this.$nextTick(() => {
+ this.pushHistory();
+ });
void this.$nextTick(async () => {
await this.renderPlot();
- const plotEl = document.getElementById('plot-area') as HTMLElement & {
- on(event: string, handler: (event: Record) => void): void;
- _fullLayout?: { xaxis: { p2l(v: number): number }; yaxis: { p2l(v: number): number }; margin: { l: number; t: number } };
+ const plotEl = document.getElementById("plot-area") as HTMLElement & {
+ on(
+ event: string,
+ handler: (event: Record) => void,
+ ): void;
+ _fullLayout?: {
+ xaxis: { p2l(v: number): number };
+ yaxis: { p2l(v: number): number };
+ margin: { l: number; t: number };
+ };
};
- plotEl.on('plotly_relayout', (event) => {
- if (event['xaxis.autorange'] ?? event['yaxis.autorange']) {
+ plotEl.on("plotly_doubleclick", () => {
+ this.config.rangeX = null;
+ this.config.rangeY = null;
+ void this.renderPlot();
+ });
+
+ plotEl.on("plotly_relayout", (event) => {
+ if (event["xaxis.autorange"] || event["yaxis.autorange"]) {
this.config.rangeX = null;
this.config.rangeY = null;
- this.renderPlot();
+ void this.renderPlot();
return;
}
- if (event['xaxis.range[0]'] !== undefined) {
- this.config.rangeX = [event['xaxis.range[0]'] as number, event['xaxis.range[1]'] as number];
+ if (event["xaxis.range[0]"] !== undefined) {
+ this.config.rangeX = [
+ event["xaxis.range[0]"] as number,
+ event["xaxis.range[1]"] as number,
+ ];
}
- if (event['yaxis.range[0]'] !== undefined) {
- this.config.rangeY = [event['yaxis.range[0]'] as number, event['yaxis.range[1]'] as number];
+ if (event["yaxis.range[0]"] !== undefined) {
+ this.config.rangeY = [
+ event["yaxis.range[0]"] as number,
+ event["yaxis.range[1]"] as number,
+ ];
}
});
- plotEl.addEventListener('click', (e) => {
+ // placementMode uses normal click
+ plotEl.addEventListener("click", (e) => {
+ if (!this.placementMode) return;
const target = e.target as HTMLElement;
- const isPlotValue = target.classList.contains('nsewdrag') || target.classList.contains('drag');
- if (!isPlotValue) return;
-
+ if (
+ !target.classList.contains("nsewdrag") &&
+ !target.classList.contains("drag")
+ )
+ return;
const rect = plotEl.getBoundingClientRect();
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 pt = { x: xVal, y: yVal };
-
- if (this.placementMode) { this.handlePlotClickForShapes(pt); 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,
+ ),
+ });
+ });
+ // middle-click anywhere over the plot opens annotation editor.
+ // Listening at document level so Plotly's stopPropagation on inner SVG elements
+ // cannot block the event.
+ 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;
+ e.preventDefault(); // suppress browser auto-scroll cursor
+ 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,
+ );
setTimeout(() => {
- this.annDraft = { x: pt.x, y: pt.y, text: '' };
+ this.annDraft = { x: xVal, y: yVal, text: "" };
this.annEditIndex = null;
this.annotationsOpen = true;
void this.$nextTick(() => {
- const input = document.querySelector('[x-ref="annInput"]') as HTMLInputElement | null;
- input?.focus();
+ (
+ document.querySelector(
+ '[x-ref="annInput"]',
+ ) as HTMLInputElement | null
+ )?.focus();
});
}, 0);
});
+ // intercept Plotly's own notifier and route through our toast
+ new MutationObserver((mutations) => {
+ for (const { addedNodes } of mutations) {
+ for (const node of addedNodes) {
+ if (!(node instanceof HTMLElement)) continue;
+ const notif = node.classList.contains("plotly-notifier")
+ ? node
+ : node.querySelector?.(".plotly-notifier");
+ if (!notif) continue;
+ const text = notif.textContent?.replace(/×/g, "").trim();
+ if (text) this.notify(text, "info");
+ (notif as HTMLElement).style.display = "none";
+ }
+ }
+ }).observe(document.body, { childList: true, subtree: true });
+
setTimeout(() => {
if (plotEl?.offsetParent !== null) Plotly.Plots.resize(plotEl);
}, 100);
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- this.errorState = msg.includes('not found') ? 'not_found' : 'empty';
- this.notify(msg, 'error');
+ this.errorState = msg.includes("not found") ? "not_found" : "empty";
+ this.notify(msg, "error");
} finally {
this.loading = false;
- (Alpine.store('dojo') as DojoStore).startGlobalSync();
- (Alpine.store('dojo') as DojoStore).setPageReady(true);
+ (Alpine.store("dojo") as DojoStore).startGlobalSync();
+ (Alpine.store("dojo") as DojoStore).setPageReady(true);
}
- window.addEventListener('keydown', (e) => {
+ window.addEventListener("keydown", (e) => {
if (e.repeat) return;
const tag = (e.target as HTMLElement).tagName;
- if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(tag)) {
+ if (e.key === "/" && !["INPUT", "TEXTAREA"].includes(tag)) {
e.preventDefault();
- (document.querySelector('input[type="number"]') as HTMLElement | null)?.focus();
+ (
+ document.querySelector('input[type="number"]') as HTMLElement | null
+ )?.focus();
}
- if (e.key === 'Escape') {
- this.yMenuOpen = this.settingsOpen = this.editorOpen = false;
- if (['INPUT', 'TEXTAREA'].includes(tag)) (e.target as HTMLElement).blur();
+ if (e.key === "Escape") {
+ if (["INPUT", "TEXTAREA"].includes(tag))
+ (e.target as HTMLElement).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 (['INPUT', 'TEXTAREA'].includes(tag)) return;
- if (e.key === 'ArrowLeft') document.getElementById('nav-prev')?.click();
- if (e.key === 'ArrowRight') document.getElementById('nav-next')?.click();
+ 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 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(); }
+ if (cmdOrCtrl && isZ) {
+ e.preventDefault();
+ if (e.shiftKey) this.redo();
+ else this.undo();
+ }
+ if (cmdOrCtrl && isY) {
+ e.preventDefault();
+ this.redo();
+ }
});
- const resp = await fetch('/mosaic/api/trials');
+ const resp = await fetch("/mosaic/api/trials");
const data = (await resp.json()) as TrialManifest;
this.allTrials = data.trials ?? [];
if (this.allTrials.length) {
- const ids = this.allTrials.map((id) => parseInt(id.split('_').pop() ?? '')).filter((n) => !isNaN(n));
+ const ids = this.allTrials
+ .map((id) => parseInt(id.split("_").pop() ?? ""))
+ .filter((n) => !isNaN(n));
const minFleet = Math.min(...ids);
const maxFleet = Math.max(...ids);
if (this.config.vsRange[0] === 0 && this.config.vsRange[1] === 0) {
@@ -581,38 +825,50 @@ function trialViewer(trialId: string, externalUrl: string) {
}
}
- this.$watch('vsDraft.range', () => {
+ 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...');
+ console.debug(
+ "Predictive Sync: User adjusted range, starting hydration...",
+ );
void this.startBackgroundDiscovery();
}
}, 500);
});
- this.$watch('config.refFrame', async (newValue: string | null, oldValue: string | null) => {
- 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: string | null, oldValue: string | null) => {
+ 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', async (value: PlotConfig, oldValue: PlotConfig) => {
+ this.$watch("config", async (value: PlotConfig, oldValue: PlotConfig) => {
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)
+ (value.xAxis !== oldValue.xAxis ||
+ Object.keys(value.yAxes).length !==
+ Object.keys(oldValue.yAxes ?? {}).length)
) {
await this.syncVsRange();
}
@@ -622,16 +878,27 @@ function trialViewer(trialId: string, externalUrl: string) {
// than oldValue, because Alpine.js $watch does not reliably deep-clone oldValue
// for nested reactive objects — both value and oldValue may point to the same data
const changedFilterCols = Object.keys(value.yAxes).filter((col) => {
- const current = JSON.stringify((value.yAxes[col]?.filters ?? []).filter((f) => f.enabled !== false));
- return current !== (this.filterFingerprints[col] ?? '[]');
+ const current = JSON.stringify(
+ (value.yAxes[col]?.filters ?? []).filter(
+ (f) => f.enabled !== false,
+ ),
+ );
+ return current !== (this.filterFingerprints[col] ?? "[]");
});
if (changedFilterCols.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 resp = await this.fetchTrialData(this.trialId, changedFilterCols);
+ const resp = await this.fetchTrialData(
+ this.trialId,
+ changedFilterCols,
+ );
this.data = { ...(this.data ?? {}), ...resp.data };
if (this.config.vsEnabled) await this.syncVsRange();
}
@@ -648,11 +915,11 @@ function trialViewer(trialId: string, externalUrl: string) {
// -----------------------------------------------------------------------
async syncVsRange() {
try {
- const resp = await fetch('/mosaic/api/trials');
+ const resp = await fetch("/mosaic/api/trials");
const data = (await resp.json()) as TrialManifest;
this.allTrials = data.trials ?? [];
} catch (e) {
- console.warn('Manifest sync failed', e);
+ console.warn("Manifest sync failed", e);
}
if (!this.vsDraft.enabled) {
@@ -670,33 +937,54 @@ function trialViewer(trialId: string, externalUrl: string) {
if (this.config.refFrame) {
const families = 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() ?? '');
+ const currentNum = parseInt(this.trialId.split("_").pop() ?? "");
const targetIds = this.allTrials.filter((id) => {
- const n = parseInt(id.split('_').pop() ?? '');
+ 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;
@@ -715,103 +1003,155 @@ function trialViewer(trialId: string, externalUrl: string) {
// -----------------------------------------------------------------------
smartSort(list: string[]): string[] {
return list.sort((a, b) => {
- const aT = a.toLowerCase() === 'time';
- const bT = b.toLowerCase() === 'time';
+ const aT = a.toLowerCase() === "time";
+ const bT = b.toLowerCase() === "time";
if (aT && !bT) return -1;
if (!aT && bT) return 1;
- return a.localeCompare(b, undefined, { sensitivity: 'base' });
+ return a.localeCompare(b, undefined, { sensitivity: "base" });
});
},
getFilteredCols(field: string): string[] {
if (!this.columns || !Array.isArray(this.columns)) return [];
- const base = field === 'x' ? this.columns : this.selectableYColumns;
- const search = (this as unknown as Record)[field + 'Search'] ?? '';
+ const base = field === "x" ? this.columns : this.selectableYColumns;
+ const search =
+ (this as unknown as Record)[field + "Search"] ?? "";
if (!search) return this.smartSort([...base]);
try {
- let pattern = search.replace(/\*/g, '.*').replace(/\/?:/g, '.*:');
- if (pattern.endsWith('/')) pattern = pattern.replace(/\/$/, '\\/?');
- if (pattern.startsWith(':')) pattern = '.*' + pattern;
- if (pattern.toLowerCase() === 'time') pattern = '^time$';
- const query = new RegExp(pattern, 'i');
+ let pattern = search.replace(/\*/g, ".*").replace(/\/?:/g, ".*:");
+ if (pattern.endsWith("/")) pattern = pattern.replace(/\/$/, "\\/?");
+ if (pattern.startsWith(":")) pattern = ".*" + pattern;
+ if (pattern.toLowerCase() === "time") pattern = "^time$";
+ 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: string, segment: string, depth: number | 'suffix') {
- const key = field + 'Search';
+ toggleRegexSegment(
+ field: string,
+ segment: string,
+ depth: number | "suffix",
+ ) {
+ const key = field + "Search";
const self = this as unknown as Record;
- let [pathPart = '', suffixPart = ''] = (self[key] ?? '').split(':');
-
- if (depth === 'suffix') {
- const cleanSeg = segment.replace(':', '');
- let items = (suffixPart ?? '').replace(/[()]/g, '').split('|').filter(Boolean);
- items = items.includes(cleanSeg) ? items.filter((i) => i !== cleanSeg) : [...items, cleanSeg];
- suffixPart = items.length > 1 ? `(${items.sort().join('|')})` : (items[0] ?? '');
+ let [pathPart = "", suffixPart = ""] = (self[key] ?? "").split(":");
+
+ if (depth === "suffix") {
+ const cleanSeg = segment.replace(":", "");
+ let items = (suffixPart ?? "")
+ .replace(/[()]/g, "")
+ .split("|")
+ .filter(Boolean);
+ items = items.includes(cleanSeg)
+ ? items.filter((i) => i !== cleanSeg)
+ : [...items, cleanSeg];
+ suffixPart =
+ items.length > 1 ? `(${items.sort().join("|")})` : (items[0] ?? "");
} else {
- let parts = (pathPart ?? '').split('/').filter((p) => p !== '');
- let target = parts[depth] ?? '';
- let items = target.replace(/[()]/g, '').split('|').filter(Boolean);
- items = items.includes(segment) ? items.filter((i) => i !== segment) : [...items, segment];
+ let parts = (pathPart ?? "").split("/").filter((p) => p !== "");
+ let target = parts[depth] ?? "";
+ let items = target.replace(/[()]/g, "").split("|").filter(Boolean);
+ items = items.includes(segment)
+ ? items.filter((i) => i !== segment)
+ : [...items, segment];
if (items.length === 0) {
parts = parts.slice(0, depth);
} else {
- parts[depth] = items.length === 1 ? (items[0] ?? '') : `(${items.sort().join('|')})`;
+ parts[depth] =
+ items.length === 1
+ ? (items[0] ?? "")
+ : `(${items.sort().join("|")})`;
}
- pathPart = parts.join('/');
- if (pathPart && pathPart.toLowerCase() !== 'time') {
- const isFolder = this.columns.some((c) => c.toLowerCase().startsWith(pathPart!.toLowerCase() + '/'));
- if (isFolder) pathPart += '/';
+ pathPart = parts.join("/");
+ if (pathPart && pathPart.toLowerCase() !== "time") {
+ const isFolder = this.columns.some((c) =>
+ c.toLowerCase().startsWith(pathPart!.toLowerCase() + "/"),
+ );
+ if (isFolder) pathPart += "/";
}
}
- self[key] = (pathPart ?? '') + (suffixPart ? ':' + suffixPart : '');
+ self[key] = (pathPart ?? "") + (suffixPart ? ":" + suffixPart : "");
},
getSegmentsAtDepth(field: string, depth: number): string[] {
- const base = field === 'x' ? this.columns : this.selectableYColumns;
- const search = (this as unknown as Record)[field + 'Search'] ?? '';
- const pathSearch = search.split(':')[0] ?? '';
- const parts = pathSearch.split('/').filter((p) => p !== '');
- const selected = (parts[depth] ?? '').replace(/[()]/g, '').split('|').filter(Boolean);
+ const base = field === "x" ? this.columns : this.selectableYColumns;
+ const search =
+ (this as unknown as Record)[field + "Search"] ?? "";
+ const pathSearch = search.split(":")[0] ?? "";
+ const parts = pathSearch.split("/").filter((p) => p !== "");
+ const selected = (parts[depth] ?? "")
+ .replace(/[()]/g, "")
+ .split("|")
+ .filter(Boolean);
const prefixParts = parts.slice(0, depth);
- const prefix = prefixParts.join('/').replace(/\//g, '\\/?');
- const regex = new RegExp('^' + (prefix ? prefix : ''), 'i');
- const segments = base.filter((c) => regex.test(c)).map((c) => { const p = c.split(':')[0]!.split('/'); return p[depth] ?? null; }).filter(Boolean) as string[];
+ const prefix = prefixParts.join("/").replace(/\//g, "\\/?");
+ const regex = new RegExp("^" + (prefix ? prefix : ""), "i");
+ const segments = base
+ .filter((c) => regex.test(c))
+ .map((c) => {
+ const p = c.split(":")[0]!.split("/");
+ return p[depth] ?? null;
+ })
+ .filter(Boolean) as string[];
return this.smartSort([...new Set([...selected, ...segments])]);
},
getAvailableSuffixes(field: string): string[] {
- const base = field === 'x' ? this.columns : this.selectableYColumns;
- const search = (this as unknown as Record)[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 base = field === "x" ? this.columns : this.selectableYColumns;
+ const search =
+ (this as unknown as Record)[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 matches = base.filter((c) => pathRegex.test(c));
- const available = matches.map((c) => (c.includes(':') ? ':' + c.split(':').pop() : null)).filter(Boolean) as string[];
+ const available = matches
+ .map((c) => (c.includes(":") ? ":" + c.split(":").pop() : null))
+ .filter(Boolean) as string[];
return this.smartSort([...new Set([...selected, ...available])]);
},
- isSegmentActive(field: string, seg: string, depth: number | 'suffix'): boolean {
- const search = (this as unknown as Record)[field + 'Search'] ?? '';
- if (depth === 'suffix') {
- const suffixPart = search.split(':')[1] ?? '';
- const items = suffixPart.replace(/[()]/g, '').split('|').filter(Boolean);
- return items.includes(seg.replace(':', ''));
+ isSegmentActive(
+ field: string,
+ seg: string,
+ depth: number | "suffix",
+ ): boolean {
+ const search =
+ (this as unknown as Record)[field + "Search"] ?? "";
+ if (depth === "suffix") {
+ const suffixPart = search.split(":")[1] ?? "";
+ const items = suffixPart
+ .replace(/[()]/g, "")
+ .split("|")
+ .filter(Boolean);
+ return items.includes(seg.replace(":", ""));
} else {
- const pathPart = search.split(':')[0] ?? '';
- const levels = pathPart.split('/').filter((p) => p !== '');
- const levelContent = levels[depth] ?? '';
- const items = levelContent.replace(/[()]/g, '').split('|').filter(Boolean);
+ const pathPart = search.split(":")[0] ?? "";
+ const levels = pathPart.split("/").filter((p) => p !== "");
+ const levelContent = levels[depth] ?? "";
+ const items = levelContent
+ .replace(/[()]/g, "")
+ .split("|")
+ .filter(Boolean);
return items.includes(seg);
}
},
getActiveLevels(field: string): number[] {
- const search = (this as unknown as Record)[field + 'Search'] ?? '';
- const pathOnly = search.split(':')[0] ?? '';
- const parts = pathOnly.split('/').filter((p) => p !== '');
+ const search =
+ (this as unknown as Record)[field + "Search"] ?? "";
+ const pathOnly = search.split(":")[0] ?? "";
+ const parts = pathOnly.split("/").filter((p) => p !== "");
return Array.from({ length: parts.length + 1 }, (_, i) => i);
},
@@ -819,29 +1159,48 @@ function trialViewer(trialId: string, externalUrl: string) {
// JSON editor
// -----------------------------------------------------------------------
get highlightedJson(): string {
- 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: string | undefined) => {
- 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}`;
- });
+ 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: string | undefined) => {
+ 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}`;
+ },
+ );
},
validateConfig(cfg: PlotConfig): string[] {
const errors: string[] = [];
- if (!this.columns.includes(cfg.xAxis)) errors.push(`X-Axis "${cfg.xAxis}" not found in telemetry.`);
- if (typeof cfg.yAxes !== 'object' || Array.isArray(cfg.yAxes)) {
- errors.push('yAxes must be a hashmap.');
+ if (!this.columns.includes(cfg.xAxis))
+ errors.push(`X-Axis "${cfg.xAxis}" not found in telemetry.`);
+ 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) => {
+ 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;
},
@@ -849,13 +1208,15 @@ function trialViewer(trialId: string, externalUrl: string) {
try {
const parsed = JSON.parse(this.configRaw) as PlotConfig;
this.isValidJson = true;
- if (parsed && typeof parsed === 'object') {
+ if (parsed && typeof parsed === "object") {
this.configErrors = this.validateConfig(parsed);
this.isValidConfig = this.configErrors.length === 0;
if (this.isValidConfig) {
this.isEditingRaw = true;
this.config = { ...this.config, ...parsed };
- void this.$nextTick(() => { this.isEditingRaw = false; });
+ void this.$nextTick(() => {
+ this.isEditingRaw = false;
+ });
}
}
} catch {
@@ -868,23 +1229,31 @@ function trialViewer(trialId: string, externalUrl: string) {
// Config persistence
// -----------------------------------------------------------------------
loadConfig() {
- const saved = localStorage.getItem('mojo_mosaic_config');
+ const saved = localStorage.getItem("mojo_mosaic_config");
if (saved) {
try {
const parsed = JSON.parse(saved) as Partial;
this.config = { ...this.config, ...parsed };
- } catch { console.error('Stored config corrupt'); }
+ } catch {
+ console.error("Stored config corrupt");
+ }
} else {
- if (this.columns.includes('time')) this.config.xAxis = 'time';
+ if (this.columns.includes("time")) this.config.xAxis = "time";
}
- const savedHistory = localStorage.getItem('mojo_mosaic_history');
+ const savedHistory = localStorage.getItem("mojo_mosaic_history");
if (savedHistory) {
try {
- const { stack, index } = JSON.parse(savedHistory) as { stack: string[]; index: number };
+ const { stack, index } = JSON.parse(savedHistory) as {
+ stack: string[];
+ index: number;
+ };
this.historyStack = stack;
this.historyIndex = index;
- } catch { console.warn('History recovery failed.'); this.pushHistory(); }
+ } catch {
+ console.warn("History recovery failed.");
+ this.pushHistory();
+ }
} else {
this.pushHistory();
}
@@ -892,11 +1261,11 @@ function trialViewer(trialId: string, externalUrl: string) {
},
saveAndRender() {
- localStorage.setItem('mojo_mosaic_config', JSON.stringify(this.config));
+ localStorage.setItem("mojo_mosaic_config", JSON.stringify(this.config));
this.persistHistory();
this.renderPlot();
void this.$nextTick(() => {
- const el = document.getElementById('plot-area');
+ const el = document.getElementById("plot-area");
if (el && el.offsetParent !== null) Plotly.Plots.resize(el);
});
},
@@ -904,12 +1273,12 @@ function trialViewer(trialId: string, externalUrl: string) {
hydrateFromUrl(blob: string) {
try {
const decoded = LZString.decompressFromEncodedURIComponent(blob);
- if (!decoded) throw new Error('Decompression failed');
+ if (!decoded) throw new Error("Decompression failed");
const parsed = JSON.parse(decoded) as Partial;
this.config = { ...this.config, ...parsed };
- this.notify('Shared view loaded', 'success');
+ this.notify("Shared view loaded", "success");
} catch {
- this.notify('Failed to decode shared link', 'error');
+ this.notify("Failed to decode shared link", "error");
this.loadConfig();
}
},
@@ -919,111 +1288,169 @@ function trialViewer(trialId: string, externalUrl: string) {
// -----------------------------------------------------------------------
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!');
- } catch { this.notify('Link generation failed', 'error'); }
+ void this.copyToClipboard(
+ `${shareBase}?v=${encoded}`,
+ "Shareable link copied!",
+ );
+ } catch {
+ this.notify("Link generation failed", "error");
+ }
},
- copyRawConfig() { void this.copyToClipboard(this.configRaw, 'JSON Config copied!'); },
+ copyRawConfig() {
+ void this.copyToClipboard(this.configRaw, "JSON Config copied!");
+ },
resetConfig() {
- if (confirm('Reset plot to factory defaults? This will clear your current view.')) {
- localStorage.removeItem('mojo_mosaic_config');
+ 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)) as PlotConfig;
- if (this.columns.includes('time')) this.config.xAxis = 'time';
- this.notify('Settings Reset', 'info');
+ if (this.columns.includes("time")) this.config.xAxis = "time";
+ this.notify("Settings Reset", "info");
this.configRaw = JSON.stringify(this.config, null, 4);
}
},
- async copyToClipboard(text: string, successMsg = 'Copied to clipboard!') {
+ async copyToClipboard(text: string, successMsg = "Copied to clipboard!") {
if (navigator.clipboard && window.isSecureContext) {
- try { await navigator.clipboard.writeText(text); this.notify(successMsg, 'success'); return; }
- catch (err) { console.warn('Modern clipboard failed, falling back...', err); }
+ try {
+ await navigator.clipboard.writeText(text);
+ this.notify(successMsg, "success");
+ return;
+ } catch (err) {
+ console.warn("Modern clipboard failed, falling back...", err);
+ }
}
- const textArea = document.createElement('textarea');
+ const textArea = document.createElement("textarea");
textArea.value = text;
- textArea.style.cssText = 'position:fixed;left:-9999px;top:0';
+ textArea.style.cssText = "position:fixed;left:-9999px;top:0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
- if (document.execCommand('copy')) { this.notify(successMsg, 'success'); }
- else throw new Error('execCommand returned false');
- } catch { this.notify('Failed to copy to clipboard', 'error'); }
+ if (document.execCommand("copy")) {
+ this.notify(successMsg, "success");
+ } else throw new Error("execCommand returned false");
+ } catch {
+ this.notify("Failed to copy to clipboard", "error");
+ }
document.body.removeChild(textArea);
},
async downloadPlot(format: string, scale = 1) {
- const el = document.getElementById('plot-area') as HTMLElement & { layout: { paper_bgcolor: string; plot_bgcolor: string } };
+ const el = document.getElementById("plot-area") as HTMLElement & {
+ layout: { paper_bgcolor: string; plot_bgcolor: string };
+ };
if (!el) return;
- const plotlyFormat = format === 'jpg' ? 'jpeg' : format;
- const isDark = document.documentElement.classList.contains('dark');
- const bgColor = isDark ? tw.slate[800] : '#ffffff';
+ const plotlyFormat = format === "jpg" ? "jpeg" : format;
+ const isDark = document.documentElement.classList.contains("dark");
+ 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 });
- const link = document.createElement('a');
+ 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}×${resH})`, 'success');
- } catch (e) { console.error('Export failed', e); this.notify('Export failed', 'error'); }
- finally { this.downloadOpen = false; }
+ this.notify(
+ `${format.toUpperCase()} saved (${resW}×${resH})`,
+ "success",
+ );
+ } catch (e) {
+ console.error("Export failed", e);
+ this.notify("Export failed", "error");
+ } finally {
+ this.downloadOpen = false;
+ }
},
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;
- let csv = activeCols.join(',') + '\n';
+ let csv = activeCols.join(",") + "\n";
for (let i = 0; i < rowCount; i++) {
- csv += activeCols.map((col) => this.data![col]?.[i] ?? '').join(',') + '\n';
+ 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.setAttribute('download', `${this.trialId}_filtered.csv`);
+ const link = document.createElement("a");
+ 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();
document.body.removeChild(link);
this.downloadOpen = false;
- this.notify('Filtered CSV Exported', 'success');
+ this.notify("Filtered CSV Exported", "success");
},
downloadJSON() {
- const link = document.createElement('a');
- link.href = URL.createObjectURL(new Blob([JSON.stringify(this.config, null, 4)], { type: 'application/json' }));
- link.setAttribute('download', `${this.trialId}_config.json`);
+ const link = document.createElement("a");
+ 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();
document.body.removeChild(link);
this.downloadOpen = false;
- this.notify('Configuration JSON Exported', 'success');
+ this.notify("Configuration JSON Exported", "success");
},
handleDrop(e: DragEvent) {
const file = e.dataTransfer?.files[0];
- if (!file || (file.type !== 'application/json' && !file.name.endsWith('.json'))) {
- this.notify('Please drop a .json file', 'error');
+ if (
+ !file ||
+ (file.type !== "application/json" && !file.name.endsWith(".json"))
+ ) {
+ this.notify("Please drop a .json file", "error");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
try {
- const imported = JSON.parse(event.target?.result as string) as Partial;
+ const imported = JSON.parse(
+ event.target?.result as string,
+ ) as Partial;
this.config = { ...this.config, ...imported };
- this.notify('Configuration restored!', 'success');
+ this.notify("Configuration restored!", "success");
this.configRaw = JSON.stringify(this.config, null, 4);
- } catch { this.notify('Invalid Config File', 'error'); }
+ } catch {
+ this.notify("Invalid Config File", "error");
+ }
};
reader.readAsText(file);
},
@@ -1039,12 +1466,12 @@ function trialViewer(trialId: string, externalUrl: string) {
const nextIndex = Object.keys(this.config.yAxes).length;
this.config.yAxes[col] = {
color: this.getSignalColor(nextIndex),
- label: '',
+ label: "",
width: 3,
opacity: 1,
filters: [],
- dash: 'solid',
- marker: 'none',
+ dash: "solid",
+ marker: "none",
};
}
this.saveAndRender();
@@ -1055,12 +1482,17 @@ function trialViewer(trialId: string, externalUrl: string) {
this.config.yAxes = {};
this.saveAndRender();
this.configRaw = JSON.stringify(this.config, null, 4);
- this.notify('Signals Cleared', 'info');
+ this.notify("Signals Cleared", "info");
},
warpToTrial() {
- if (this.warpId === null || this.warpId === undefined || this.warpId === ('' as unknown)) return;
- const paddedNum = String(this.warpId).padStart(this.paddingLen, '0');
+ if (
+ this.warpId === null ||
+ this.warpId === undefined ||
+ this.warpId === ("" as unknown)
+ )
+ return;
+ const paddedNum = String(this.warpId).padStart(this.paddingLen, "0");
window.location.href = `/mosaic/trial_${paddedNum}`;
},
@@ -1076,8 +1508,8 @@ function trialViewer(trialId: string, externalUrl: string) {
color: obj.color || this.getSignalColor(index),
width: obj.width ?? 3,
opacity: obj.opacity ?? 1.0,
- dash: obj.dash ?? 'solid',
- marker: obj.marker ?? 'none',
+ dash: obj.dash ?? "solid",
+ marker: obj.marker ?? "none",
};
},
@@ -1088,7 +1520,10 @@ function trialViewer(trialId: string, externalUrl: string) {
return this.filterSchemas.find((s) => s.type === filterType);
},
- getUnitOptions(groups: UnitGroup[] | undefined, fromUnit: string | null | undefined): UnitGroup[] {
+ getUnitOptions(
+ groups: UnitGroup[] | undefined,
+ fromUnit: string | null | undefined,
+ ): UnitGroup[] {
if (!groups) return [];
if (!fromUnit) return groups;
const match = groups.find((g) => g.units.includes(fromUnit));
@@ -1097,17 +1532,20 @@ function trialViewer(trialId: string, externalUrl: string) {
getFilterSummary(entry: FilterEntry): string {
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'] ?? '?'} → ${entry['to_unit'] ?? '?'}`;
+ if (!schema || schema.params.length === 0) return "";
+ if (entry.type === "unit")
+ return `${entry["from_unit"] ?? "?"} → ${entry["to_unit"] ?? "?"}`;
const parts = schema.params
.filter((p) => (entry as Record)[p.name] != null)
.map((p) => {
const val = (entry as Record)[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 as string}`;
});
- return parts.slice(0, 3).join(', ');
+ return parts.slice(0, 3).join(", ");
},
addFilterToTemp(temp: YAxisConfig, filterType: string) {
@@ -1134,69 +1572,152 @@ function trialViewer(trialId: string, externalUrl: string) {
if (item) temp.filters.splice(newIdx, 0, item);
},
- setFilterParamOnTemp(temp: YAxisConfig, filterIndex: number, paramName: string, value: unknown) {
+ setFilterParamOnTemp(
+ temp: YAxisConfig,
+ filterIndex: number,
+ paramName: string,
+ value: unknown,
+ ) {
if (!temp.filters?.[filterIndex]) return;
(temp.filters[filterIndex] as Record)[paramName] = value;
},
duplicateFilterInTemp(temp: YAxisConfig, index: number) {
if (!temp.filters?.[index]) return;
- const copy = JSON.parse(JSON.stringify(temp.filters[index])) as FilterEntry;
+ const copy = JSON.parse(
+ JSON.stringify(temp.filters[index]),
+ ) as FilterEntry;
temp.filters.splice(index + 1, 0, copy);
},
// -----------------------------------------------------------------------
// Profiles
+ // Encode each path segment individually so 'project/name' becomes 'project/name'
+ // in the URL (not 'project%2Fname'), matching the {name:path} FastAPI route.
+ _profileUrl(name: string): string {
+ return `/mosaic/api/profiles/${name.split("/").map(encodeURIComponent).join("/")}`;
+ },
+
// -----------------------------------------------------------------------
async loadProfiles() {
try {
- const resp = await fetch('/mosaic/api/profiles');
- this.profiles = await resp.json() as Array<{ name: string; modified: number }>;
+ const resp = await fetch("/mosaic/api/profiles");
+ this.profiles = (await resp.json()) as Array<{
+ name: string;
+ modified: number;
+ }>;
+
+ const colSet = new Set(this.columns);
+ const frames = new Set(
+ this.columns
+ .filter((c) => c.endsWith(":w"))
+ .map((c) => c.replace(":w", "")),
+ );
+ const warnings: Record = {};
+ 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()) as Partial;
+ const w: string[] = [];
+ 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}"`);
+ }
+ if (cfg.refFrame && !frames.has(cfg.refFrame))
+ w.push(`frame "${cfg.refFrame}"`);
+ if (w.length) warnings[p.name] = w;
+ } catch {
+ /* skip */
+ }
+ }),
+ );
+ this.profileWarnings = warnings;
} catch (e) {
- console.warn('[mojo] Failed to load profiles', e);
+ console.warn("[mojo] Failed to load profiles", e);
}
},
async saveProfile() {
const name = this.profileNameDraft.trim();
- if (!name) { this.notify('Enter a profile name', 'error'); return; }
- // check for an existing profile with the same normalised name (spaces → underscores)
- const normalise = (s: string) => s.toLowerCase().replace(/\s+/g, '_');
- const existing = this.profiles.find((p) => normalise(p.name) === normalise(name));
+ if (!name) {
+ this.notify("Enter a profile name", "error");
+ return;
+ }
+ const normalise = (s: string) => s.toLowerCase().replace(/\s+/g, "_");
+ const existing = this.profiles.find(
+ (p) => normalise(p.name) === normalise(name),
+ );
if (existing && !confirm(`Overwrite profile "${existing.name}"?`)) return;
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ const resp = await fetch(this._profileUrl(name), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.config),
});
- if (!resp.ok) throw new Error('Save failed');
- const result = await resp.json() as { name: string };
- this.profileNameDraft = '';
+ if (!resp.ok) throw new Error("Save failed");
+ const result = (await resp.json()) as { name: string };
+ this.profileNameDraft = "";
await this.loadProfiles();
- this.notify(`Profile "${result.name}" saved`, 'success');
- } catch { this.notify('Failed to save profile', 'error'); }
+ this.notify(`Profile "${result.name}" saved`, "success");
+ } catch {
+ this.notify("Failed to save profile", "error");
+ }
},
async loadProfile(name: string) {
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`);
- if (!resp.ok) throw new Error('Not found');
- const loaded = await resp.json() as Partial;
+ const resp = await fetch(this._profileUrl(name));
+ if (!resp.ok) {
+ const body = (await resp.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ throw new Error(body.detail ?? `HTTP ${resp.status}`);
+ }
+ const loaded = (await resp.json()) as Partial;
+
+ const colSet = new Set(this.columns);
+ const frames = new Set(
+ this.columns
+ .filter((c) => c.endsWith(":w"))
+ .map((c) => c.replace(":w", "")),
+ );
+ const missing: string[] = [];
+ if (loaded.xAxis && !colSet.has(loaded.xAxis))
+ missing.push(`x-axis "${loaded.xAxis}"`);
+ 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 (missing.length) {
+ throw new Error(
+ `references columns not in this trial: ${missing.join(", ")}`,
+ );
+ }
+
this.config = { ...this.config, ...loaded };
- this.notify(`Profile "${name}" loaded`, 'success');
- } catch { this.notify(`Failed to load "${name}"`, 'error'); }
+ this.notify(`Profile "${name}" loaded`, "success");
+ } catch (e) {
+ this.notify(
+ `Failed to load "${name}": ${(e as Error).message}`,
+ "error",
+ );
+ }
},
async deleteProfile(name: string) {
try {
- const resp = await fetch(`/mosaic/api/profiles/${encodeURIComponent(name)}`, {
- method: 'DELETE',
- });
- if (!resp.ok) throw new Error('Delete failed');
+ const resp = await fetch(this._profileUrl(name), { method: "DELETE" });
+ if (!resp.ok) throw new Error("Delete failed");
await this.loadProfiles();
- this.notify(`Profile "${name}" deleted`, 'info');
- } catch { this.notify(`Failed to delete "${name}"`, 'error'); }
+ this.notify(`Profile "${name}" deleted`, "info");
+ } catch {
+ this.notify(`Failed to delete "${name}"`, "error");
+ }
},
applySignalConfig(col: string, temp: YAxisConfig) {
@@ -1207,15 +1728,32 @@ function trialViewer(trialId: string, externalUrl: string) {
},
getDraft(col: string): { draft: YAxisConfig; baseSnapshot: string } | null {
- return (this.signalDrafts as Record)[col] ?? null;
+ return (
+ (
+ this.signalDrafts as Record<
+ string,
+ { draft: YAxisConfig; baseSnapshot: string }
+ >
+ )[col] ?? null
+ );
},
saveDraft(col: string, draft: YAxisConfig, baseSnapshot: string) {
- (this.signalDrafts as Record)[col] = { draft, baseSnapshot };
+ (
+ this.signalDrafts as Record<
+ string,
+ { draft: YAxisConfig; baseSnapshot: string }
+ >
+ )[col] = { draft, baseSnapshot };
},
clearDraft(col: string) {
- delete (this.signalDrafts as Record)[col];
+ delete (
+ this.signalDrafts as Record<
+ string,
+ { draft: YAxisConfig; baseSnapshot: string }
+ >
+ )[col];
},
// -----------------------------------------------------------------------
@@ -1229,7 +1767,7 @@ function trialViewer(trialId: string, externalUrl: string) {
if (this.config.vsEnabled) {
const [start, end] = this.config.vsRange;
Object.entries(this.vsDatasets).forEach(([vsId, dataset]) => {
- const n = parseInt(vsId.split('_').pop() ?? '');
+ const n = parseInt(vsId.split("_").pop() ?? "");
if (n >= start && n <= end) activeDatasets.push(dataset);
});
}
@@ -1258,157 +1796,327 @@ function trialViewer(trialId: string, externalUrl: string) {
renderPlot() {
if (!this.data) return;
- const isDark = document.documentElement.classList.contains('dark');
+ const isDark = document.documentElement.classList.contains("dark");
const textColor = isDark ? tw.slate[400] : tw.slate[600];
const majorGrid = isDark ? tw.slate[950] : tw.slate[200];
const minorGrid = isDark ? tw.slate[900] : tw.slate[100];
- const tooltipBg = isDark ? tw.slate[900] : '#ffffff';
+ const tooltipBg = isDark ? tw.slate[900] : "#ffffff";
const tooltipFont = isDark ? tw.slate[50] : tw.slate[900];
const tooltipBorder = tw.cyan[500];
const spikeColor = tw.cyan[500];
- 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 displayRangeX = this.config.rangeX ?? this.calculatePaddedRange([this.config.xAxis], false);
- const displayRangeY = this.config.rangeY ?? this.calculatePaddedRange(Object.keys(this.config.yAxes));
+ 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 yKeys = Object.keys(this.config.yAxes);
- let traces: object[] = yKeys.map((key, i) => {
- const p = this.getYProps(key, i);
- if (!this.data![p.name]) return null;
- return {
- x: this.data![this.config.xAxis],
- 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 },
- 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}
%{x}: %{y:.4f}`,
- };
- }).filter((t): t is NonNullable => t !== null);
+ let traces: object[] = yKeys
+ .map((key, i) => {
+ const p = this.getYProps(key, i);
+ if (!this.data![p.name]) return null;
+ return {
+ x: this.data![this.config.xAxis],
+ 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,
+ },
+ 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}
%{x}: %{y:.4f}`,
+ };
+ })
+ .filter((t): t is NonNullable => t !== null);
if (this.config.vsEnabled) {
const [start, end] = this.config.vsRange;
const legendTracker = 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() ?? '');
+ const n = parseInt(vsId.split("_").pop() ?? "");
if (n < start || n > end || vsId === this.trialId) return;
const dataset = this.vsDatasets[vsId];
if (!dataset) return;
- const vsTraces = yKeys.map((key, i) => {
- const p = this.getYProps(key, i);
- if (!dataset[p.name]) return null;
- const isFirst = !legendTracker.has(key);
- const t = {
- x: dataset[this.config.xAxis],
- 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' },
- opacity: 0.35,
- marker: { size: 4, symbol: p.marker },
- hoverlabel: { namelength: -1 },
- hovertemplate: `${key} (#${n})
%{x}: %{y:.4f}`,
- };
- legendTracker.add(key);
- return t;
- }).filter((t): t is NonNullable => t !== null);
+ const vsTraces = yKeys
+ .map((key, i) => {
+ const p = this.getYProps(key, i);
+ if (!dataset[p.name]) return null;
+ const isFirst = !legendTracker.has(key);
+ const t = {
+ x: dataset[this.config.xAxis],
+ 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",
+ },
+ opacity: 0.35,
+ marker: { size: 4, symbol: p.marker },
+ hoverlabel: { namelength: -1 },
+ hovertemplate: `${key} (#${n})
%{x}: %{y:.4f}`,
+ };
+ legendTracker.add(key);
+ return t;
+ })
+ .filter((t): t is NonNullable => t !== null);
traces = [...traces, ...vsTraces];
});
}
const xAxisObj = {
- type: this.config.xScale ?? 'linear',
- range: this.config.xScale === 'log'
- ? [Math.log10(Math.max(1e-6, displayRangeX[0])), Math.log10(Math.max(1e-6, displayRangeX[1]))]
- : displayRangeX,
- dtick: this.config.xScale === 'log' && this.config.xLogBase ? Math.log10(this.config.xLogBase) : undefined,
+ type: this.config.xScale ?? "linear",
+ ...(this.config.rangeX
+ ? {
+ autorange: false as const,
+ 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 as const }),
+ dtick:
+ this.config.xScale === "log" && this.config.xLogBase
+ ? Math.log10(this.config.xLogBase)
+ : undefined,
gridcolor: majorGrid,
- showgrid: this.config.grid !== 'none',
- minor: { showgrid: this.config.grid === 'all', gridcolor: minorGrid },
+ showgrid: this.config.grid !== "none",
+ 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' } },
- autorange: false,
+ title: {
+ text: this.config.xAxisTitle || this.config.xAxis,
+ font: { size: 14, color: textColor, family: "monospace" },
+ },
showspikes: showX,
- spikemode: 'across',
+ spikemode: "across",
spikelinecolor: spikeColor,
spikethickness: -2,
};
const frameLabel = this.config.refFrame
? `
[Frame: ${this.config.refFrame}]`
- : '';
+ : "";
const yAxisObj = {
- type: this.config.yScale ?? 'linear',
- range: this.config.yScale === 'log'
- ? [Math.log10(Math.max(1e-6, displayRangeY[0])), Math.log10(Math.max(1e-6, displayRangeY[1]))]
- : displayRangeY,
- dtick: this.config.yScale === 'log' && this.config.yLogBase ? Math.log10(this.config.yLogBase) : undefined,
+ type: this.config.yScale ?? "linear",
+ ...(this.config.rangeY
+ ? {
+ autorange: false as const,
+ 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 as const }),
+ dtick:
+ this.config.yScale === "log" && this.config.yLogBase
+ ? Math.log10(this.config.yLogBase)
+ : undefined,
gridcolor: majorGrid,
- showgrid: this.config.grid !== 'none',
- minor: { showgrid: this.config.grid === 'all', gridcolor: minorGrid },
+ showgrid: this.config.grid !== "none",
+ 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' } },
- autorange: false,
+ title: {
+ text: this.config.yAxisTitle + frameLabel,
+ font: { size: 14, color: textColor, family: "monospace" },
+ },
showspikes: showY,
- spikemode: 'across',
+ spikemode: "across",
spikelinecolor: spikeColor,
spikethickness: -2,
};
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,
- 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 },
+ 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,
+ 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,
+ },
hovermode: isHoverDisabled ? false : this.config.hover,
- 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' },
+ 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: [
...(this.config.annotations ?? []).map((ann) => ({
- x: ann.x, y: ann.y, text: ann.text, showarrow: true, arrowhead: 2, ax: 0, ay: -40,
- font: { family: 'monospace', size: 12, color: isDark ? tw.slate[50] : tw.slate[900] },
+ x: ann.x,
+ y: ann.y,
+ text: ann.text,
+ showarrow: true,
+ arrowhead: 2,
+ ax: 0,
+ ay: -40,
+ 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, borderpad: 4,
+ bordercolor: tw.cyan[500],
+ borderwidth: 1,
+ borderpad: 4,
})),
- ...(this.config.shapes ?? []).filter((s) => s.label).map((s) => {
- let x = s.x0, y = s.y0 ?? 0, xanchor = 'left', yanchor = 'bottom', xref = 'x', yref = 'y';
- if (s.type === 'vline') { y = 1; yref = 'paper'; }
- else if (s.type === 'hline') { x = 1; xref = 'paper'; xanchor = 'right'; }
- else if (s.type === 'rect') { 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 };
- }),
+ ...(this.config.shapes ?? [])
+ .filter((s) => s.label)
+ .map((s) => {
+ let x = s.x0,
+ y = s.y0 ?? 0,
+ xanchor = "left",
+ yanchor = "bottom",
+ xref = "x",
+ yref = "y";
+ if (s.type === "vline") {
+ y = 1;
+ yref = "paper";
+ } else if (s.type === "hline") {
+ x = 1;
+ xref = "paper";
+ xanchor = "right";
+ } else if (s.type === "rect") {
+ 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,
+ };
+ }),
],
shapes: (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'] };
- return Plotly.react('plot-area', traces, layout, config);
+ const config = {
+ responsive: true,
+ displaylogo: false,
+ displayModeBar: true,
+ modeBarButtonsToRemove: ["toImage"],
+ doubleClick: false as const,
+ };
+ return Plotly.react("plot-area", traces, layout, config);
},
};
return self;