diff --git a/e2e/README.md b/e2e/README.md index b5decd204..a05519407 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -13,6 +13,16 @@ Tests are split into two Playwright projects: | `ui` | `tests/ui/` | UI tests — launch the Electron app and interact with the UI | | `integration` | `tests/integration/` | Real renderer/main IPC, Go HTTP catalogs, WebSocket subscriptions and profile isolation | +Logging View also has a standalone browser suite in `tests/logging-view/`, using +`playwright.logging.config.ts`. Run `pnpm --dir e2e test:logging-view` from the +repository root. It starts Vite on port 9007 and verifies dashboard tabs, independent +signal/layout/time settings, keyboard navigation, and session resets without Electron +or a backend binary. +The suite also covers the tab settings popover, colors, drag and keyboard reordering, +and lock protection for tab metadata, plots, sidebar selection and time controls. +Compact controls in the tab bar and the expandable full controls are checked for +state preservation, lock protection and narrow layouts. + --- ## UI Tests @@ -57,18 +67,30 @@ vehicle commands. The WebSocket integration test only subscribes to connection s | `pnpm test:fast` | Run all tests without rebuilding (assumes already built) | | `pnpm test:fast:ui` | Run `tests/ui` without rebuilding | | `pnpm test:fast:integration` | Run integration tests without rebuilding | +| `pnpm test:logging-view` | Run Logging View dashboard tests against a local Vite server | | `pnpm report` | Open the last Playwright HTML report | The renderer build does not compile Go or package Electron. It copies each frontend once, preserving Testing's E2E build rather than rebuilding it in normal mode. Use `:fast` variants when iterating against already-built renderers. +`pnpm build` also seeds the About credits. The About screen reads a snapshot of +contributor photographs and per-person commit counts that `generateCredits.js` +fetches from the GitHub API when the app is packaged; it is gitignored, so a +fresh clone — every CI run — has neither it nor the avatars beside it, and +`tests/ui/about.test.ts` would find an empty contributor list. The build writes +a fixed, offline fixture through `scripts/about-credits.mjs` when no usable +snapshot is on disk, and leaves a generated one untouched, so a developer's own +build stays what the About tests exercise locally. The suite never calls GitHub: +the About tests assert that the screen is entirely network-free. + --- ## Requirements - A platform-appropriate, prebuilt backend binary must exist in `electron-app/binaries/`. CI builds it before running the suite. - `testing-view` must be built in e2e mode (`build:e2e`), also handled automatically. +- About credits need no network or GitHub token: `pnpm build` seeds an offline fixture when nothing has generated the snapshot. - Workers are set to `1` — Electron tests must run serially since only one app instance can run at a time. CI builds all four tested views and runs both projects. `E2E_REQUIRED_VIEWS=testing,competition,logging,adj` makes missing CI build artifacts a failure instead of silently skipping cross-view tests. Fast local runs may still skip optional-view cases when testing a reduced build. diff --git a/e2e/package.json b/e2e/package.json index 52f849dda..3590a9df3 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,6 +11,7 @@ "test:fast": "playwright test", "test:fast:ui": "playwright test tests/ui", "test:fast:integration": "playwright test --project=integration", + "test:logging-view": "playwright test --config playwright.logging.config.ts", "report": "playwright show-report" }, "devDependencies": { diff --git a/e2e/playwright.logging.config.ts b/e2e/playwright.logging.config.ts new file mode 100644 index 000000000..26aae6a5a --- /dev/null +++ b/e2e/playwright.logging.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/logging-view", + timeout: 60_000, + workers: 1, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:9007", + viewport: { width: 1440, height: 900 }, + trace: "retain-on-failure", + }, + webServer: { + command: + "pnpm --dir ../frontend/logging-view dev --host 127.0.0.1 --port 9007 --strictPort", + url: "http://127.0.0.1:9007", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/e2e/scripts/about-credits.mjs b/e2e/scripts/about-credits.mjs new file mode 100644 index 000000000..4f7eb5345 --- /dev/null +++ b/e2e/scripts/about-credits.mjs @@ -0,0 +1,185 @@ +/** + * The About screen reads electron-app/src/about/credits.snapshot.json and the + * avatars next to it. Both are generated from the GitHub API at build time and + * deliberately not committed (they are contributor photographs and per-person + * commit counts, in a public repository), so a fresh clone — every CI run — + * has neither, About lists nobody and tests/ui/about.test.ts cannot find a + * single contributor. + * + * The suite must not fetch them either: the About tests assert that the screen + * is entirely network-free, and making every pull request depend on the GitHub + * commits API would be slow and flaky. So seed a fixed, offline snapshot plus + * matching images, and leave a real generated snapshot alone when there is one, + * so a developer's own build keeps being what the tests exercise locally. + */ +import { deflateSync } from "node:zlib"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ELECTRON_APP = fileURLToPath( + new URL("../../electron-app/", import.meta.url), +); +const SNAPSHOT = path.join(ELECTRON_APP, "src/about/credits.snapshot.json"); +const AVATARS = path.join(ELECTRON_APP, "renderer/about/avatars"); +// The renderer only builds an for a path shaped like this one. +const AVATAR_URL = /^\.\/avatars\/[a-zA-Z0-9-]+\.(png|jpg|webp)$/; + +// Ids far outside GitHub's range for this repository's members, so the fixture +// images can never be mistaken for, or overwrite, a real contributor's photo. +// Descending, distinct counts: the first test asserts the list is sorted and +// the second one opens the top member's profile, which needs a login. +const FIXTURE = [ + { + id: "900000001", + login: "e2e-ada", + contributions: 128, + colour: [64, 110, 235], + }, + { + id: "900000002", + login: "e2e-grace", + contributions: 57, + colour: [217, 72, 140], + }, + { + id: "900000003", + login: "e2e-linus", + contributions: 9, + colour: [48, 168, 122], + }, +]; + +const CRC_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + return value >>> 0; +}); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, "ascii"); + const tail = Buffer.alloc(4); + tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, tail]); +} + +/** + * Encodes a solid square as a real PNG. The test requires each avatar to + * decode in the renderer (`naturalWidth > 0`), so these are complete images + * rather than placeholder bytes, and no binary fixture needs committing. + * @param {number} size - Side length in pixels. + * @param {[number, number, number]} colour - 8-bit RGB fill. + * @returns {Buffer} + */ +function solidPng(size, colour) { + const stride = size * 3 + 1; + const raw = Buffer.alloc(stride * size); + for (let row = 0; row < size; row++) { + raw[row * stride] = 0; // Filter type 0: no filtering. + for (let column = 0; column < size; column++) { + const pixel = row * stride + 1 + column * 3; + raw[pixel] = colour[0]; + raw[pixel + 1] = colour[1]; + raw[pixel + 2] = colour[2]; + } + } + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header[8] = 8; // Bit depth. + header[9] = 2; // Colour type: truecolour RGB. + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +/** + * Reports whether the snapshot on disk can carry the About tests: it must + * list somebody, be counted the way getAboutCredits() demands, name at least + * one GitHub profile to open, and reference only images that are still there, + * because a missing file leaves an that never loads. + * @returns {Promise} + */ +async function hasUsableCredits() { + let snapshot; + try { + snapshot = JSON.parse(await readFile(SNAPSHOT, "utf8")); + } catch { + return false; + } + const people = snapshot?.contributors; + if (snapshot?.counting !== "non-merge" || !Array.isArray(people)) + return false; + const listed = people.filter((person) => person.type !== "Bot"); + if (!listed.length || !listed.some((person) => person.login)) return false; + const images = listed + .map((person) => person.avatarUrl) + .filter((url) => typeof url === "string" && url); + if (!images.length || !images.every((url) => AVATAR_URL.test(url))) + return false; + const present = await Promise.all( + images.map((url) => + access(path.join(AVATARS, path.basename(url))).then( + () => true, + () => false, + ), + ), + ); + return present.every(Boolean); +} + +/** + * Writes the offline About fixture unless a usable generated snapshot is + * already on disk. + * @returns {Promise<"fixture" | "generated">} Which credits the tests will read. + */ +export async function seedAboutCredits() { + if (await hasUsableCredits()) return "generated"; + await mkdir(AVATARS, { recursive: true }); + await mkdir(path.dirname(SNAPSHOT), { recursive: true }); + await Promise.all( + FIXTURE.map((person) => + writeFile( + path.join(AVATARS, `${person.id}.png`), + solidPng(96, person.colour), + ), + ), + ); + await writeFile( + SNAPSHOT, + `${JSON.stringify( + { + schemaVersion: 3, + repository: "Hyperloop-UPV/software", + counting: "non-merge", + // No revision, so the next real build refetches instead of reusing this. + headSha: null, + fetchedAt: "2026-01-02T03:04:05.000Z", + contributors: FIXTURE.map(({ id, login, contributions }) => ({ + id, + login, + name: login, + contributions, + avatarUrl: `./avatars/${id}.png`, + type: "User", + })), + }, + null, + 2, + )}\n`, + "utf8", + ); + return "fixture"; +} diff --git a/e2e/scripts/build-renderers.mjs b/e2e/scripts/build-renderers.mjs index ed13e14b5..e9873d3c3 100644 --- a/e2e/scripts/build-renderers.mjs +++ b/e2e/scripts/build-renderers.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { seedAboutCredits } from "./about-credits.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); const pnpmCli = process.env.npm_execpath; @@ -32,3 +33,11 @@ for (const view of ["testing", "competition", "logging", "adj"]) { mkdirSync(destination, { recursive: true }); cpSync(source, destination, { recursive: true }); } + +// About reads a build-time snapshot that a fresh clone does not have. +const credits = await seedAboutCredits(); +console.log( + credits === "fixture" + ? "About credits: wrote the offline e2e fixture." + : "About credits: keeping the generated snapshot already on disk.", +); diff --git a/e2e/tests/logging-view/dashboards.test.ts b/e2e/tests/logging-view/dashboards.test.ts new file mode 100644 index 000000000..5f65934fc --- /dev/null +++ b/e2e/tests/logging-view/dashboards.test.ts @@ -0,0 +1,1711 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function openSession(page: Page, folder = "demo") { + await page.evaluate(async (folderName) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + await useStore.getState().openSession( + ["velocity", "position", "temperature"].map((name, index) => ({ + name: `${name}.csv`, + webkitRelativePath: `${folderName}/data/LCU/${name}.csv`, + text: async () => + `0,LCU,backend,${index}\n50000,LCU,backend,${index + 2}\n100000,LCU,backend,${index + 1}`, + })), + ); + }, folder); +} + +async function chooseSignal(page: Page, name: string) { + const sidebar = page.locator('[data-slot="sidebar-content"]'); + const board = sidebar.getByRole("button", { name: /^LCU/ }); + if ((await board.getAttribute("data-state")) !== "open") await board.click(); + await sidebar.getByText(name, { exact: true }).click(); +} + +// Two frames is what the existing wheel assertions use to let ECharts repaint +// and the rAF-coalesced store commit land. +// Layout and view actions of a plot's right-click menu live in submenus. +async function pickPaneSubmenuItem( + page: Page, + chart: number, + submenu: "Layout" | "View", + item: string, +) { + await page + .getByRole("menu", { name: `Chart ${chart} actions` }) + .getByRole("menuitem", { name: submenu, exact: true }) + .click(); + await page.getByRole("menuitem", { name: item, exact: true }).click(); +} + +async function flushFrames(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} + +// Puts `name` on a plot. Selecting in the sidebar only marks the signal; the +// pane menu is what assigns it. +async function plotSignals(page: Page, names: string[], pane = 1) { + // Starts from an empty selection so the count in the menu item's label is + // known. The header's "Clear selection" button does not settle before the + // next click, so go through the store action it calls. + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + useStore.getState().clearNormalSeries(); + }); + for (const name of names) await chooseSignal(page, name); + await page + .getByRole("region", { name: `Chart panel ${pane}`, exact: true }) + .click({ button: "right" }); + await page + .getByRole("menu", { name: `Chart ${pane} actions` }) + .getByRole("menuitem", { name: /^Add \d+ selected measurements?$/ }) + .click(); +} + +const addSignalToPane = (page: Page, name: string, pane = 1) => + plotSignals(page, [name], pane); + +async function applyWindow(page: Page, start: string, end: string) { + await page + .getByRole("spinbutton", { name: "Start time (s)", exact: true }) + .fill(start); + await page + .getByRole("spinbutton", { name: "End time (s)", exact: true }) + .fill(end); + await page.getByRole("button", { name: "Apply", exact: true }).click(); +} + +const paneWindow = (page: Page, pane = 1) => + page + .getByRole("region", { name: `Chart panel ${pane}`, exact: true }) + .getByTitle(/time window for this plot|its own time window/); + +test.beforeEach(async ({ page }) => { + await page.goto("/#/normal"); + await expect( + page.getByRole("heading", { name: "Open a logging session" }), + ).toBeVisible(); + await openSession(page); + await page + .getByRole("button", { + name: "Expand layout and time controls", + exact: true, + }) + .click(); +}); + +// Recent sessions are an Electron-only feature: the browser's folder picker +// never yields a path. This stubs the three bridge methods the renderer needs +// so the list can be exercised against the dev server. +async function stubElectronBridge(page: Page) { + await page.addInitScript(() => { + const folder = "/logs/demo"; + const entries = ["velocity", "position", "temperature"].map( + (name) => `data/LCU/${name}.csv`, + ); + const values: Record = { + velocity: 0, + position: 1, + temperature: 2, + }; + (window as unknown as { electronAPI: unknown }).electronAPI = { + switchView: async (view: string) => view, + selectFolder: async () => folder, + listSessionFiles: async () => ({ folderName: "demo", entries }), + readSessionFile: async (_dir: string, relative: string) => { + const name = relative.split("/").pop()!.replace(".csv", ""); + const index = values[name] ?? 0; + return `0,LCU,backend,${index}\n50000,LCU,backend,${index + 2}\n100000,LCU,backend,${index + 1}`; + }, + }; + }); +} + +test("recent sessions reopen a folder by path", async ({ page }) => { + await stubElectronBridge(page); + // A full reload is what applies the init script and drops the session the + // beforeEach opened (session state is not persisted). + await page.reload(); + // Nothing opened yet, so nothing to recall. + await expect( + page.getByRole("region", { name: "Recent sessions", exact: true }), + ).toHaveCount(0); + + // The native dialog path records the session, so closing it leaves a recent. + await page.getByRole("button", { name: "or browse…", exact: true }).click(); + await expect(page.locator("header")).toContainText("demo"); + await page + .getByRole("button", { name: "Close session", exact: true }) + .click(); + const recent = page.getByRole("region", { + name: "Recent sessions", + exact: true, + }); + await expect(recent).toBeVisible(); + await expect(recent).toContainText("/logs/demo"); + + // One click reopens it, and its CSVs are readable through the bridge. + await recent.getByRole("button", { name: "Open demo", exact: true }).click(); + await expect(page.locator("header")).toContainText("demo · 0 selected"); + await chooseSignal(page, "velocity"); + await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .click({ button: "right" }); + await page + .getByRole("menu", { name: "Chart 1 actions" }) + .getByRole("menuitem", { name: "Add 1 selected measurement", exact: true }) + .click(); + await expect(page.locator("canvas")).toHaveCount(1); + + // It survives a reload, and can be forgotten. + await page + .getByRole("button", { name: "Close session", exact: true }) + .click(); + await page.reload(); + await expect(recent).toBeVisible(); + await recent + .getByRole("button", { name: "Forget demo", exact: true }) + .click(); + await expect(recent).toHaveCount(0); +}); + +test("dashboards keep separate signals, layout, panel sizes and time settings", async ({ + page, +}, testInfo) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + // Selecting a measurement marks it; a plot is what draws it, so each chart + // needs its own pane. + await plotSignals(page, ["velocity"]); + // The sidebar's selection bar offers a "New plot" too, so scope to the bar. + await page + .getByRole("group", { name: "Analysis controls", exact: true }) + .getByRole("button", { name: "New plot", exact: true }) + .click(); + await plotSignals(page, ["position"], 2); + await expect(page.locator("canvas")).toHaveCount(2); + // Both selected again: the assertions below are about per-dashboard scope. + await chooseSignal(page, "velocity"); + await expect(page.locator("header")).toContainText("2 selected"); + await page.getByRole("button", { name: "Rows", exact: true }).click(); + await page + .getByRole("spinbutton", { name: "Start time (s)", exact: true }) + .fill("10"); + await page + .getByRole("spinbutton", { name: "End time (s)", exact: true }) + .fill("50"); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await page.getByRole("combobox", { name: "Time unit" }).selectOption("ms"); + await page + .getByRole("button", { + name: "Hide LCU / velocity in chart 1", + exact: true, + }) + .click(); + const divider = page.getByRole("separator"); + await divider.focus(); + await divider.press("ArrowUp"); + const panelSize = await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .boundingBox(); + const tabBar = await page + .getByRole("tablist", { name: "Dashboards" }) + .boundingBox(); + const controls = await page + .getByRole("group", { name: "Analysis controls", exact: true }) + .boundingBox(); + expect(tabBar!.y + tabBar!.height).toBeLessThanOrEqual(controls!.y); + + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await expect( + page.getByRole("tab", { name: "Dashboard 2", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(page.getByText("Empty plot", { exact: true })).toBeVisible(); + await expect(page.locator("header")).toContainText("0 selected"); + await plotSignals(page, ["temperature"]); + await expect(page.locator("canvas")).toHaveCount(1); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toHaveValue("0"); + await page.getByRole("button", { name: "Single", exact: true }).click(); + await page + .getByRole("button", { name: "Zoom in comparison", exact: true }) + .click(); + + await page.getByRole("tab", { name: "Dashboard", exact: true }).click(); + await expect(page.locator("header")).toContainText("2 selected"); + await expect( + page.getByRole("spinbutton", { name: "Start time (ms)", exact: true }), + ).toHaveValue("10000"); + await expect( + page.getByRole("spinbutton", { name: "End time (ms)", exact: true }), + ).toHaveValue("50000"); + await expect( + page.getByRole("button", { + name: "Show LCU / velocity in chart 1", + exact: true, + }), + ).toBeVisible(); + const restoredSize = await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .boundingBox(); + expect(Math.abs(restoredSize!.height - panelSize!.height)).toBeLessThan(2); + + await page.getByRole("tab", { name: "Dashboard 2", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Single", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toHaveValue("25"); + await expect( + page.getByRole("spinbutton", { name: "End time (s)", exact: true }), + ).toHaveValue("75"); + await page.screenshot({ path: testInfo.outputPath("dashboards.png") }); + expect(errors).toEqual([]); +}); + +test("tabs support renaming, keyboard switching, closing and overflow", async ({ + page, +}, testInfo) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const second = page.getByRole("tab", { name: "Dashboard 2", exact: true }); + await second.focus(); + await second.press("F2"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .fill("Thermal"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .press("Enter"); + const thermal = page.getByRole("tab", { name: "Thermal", exact: true }); + await expect(thermal).toBeFocused(); + await thermal.press("F2"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .fill("Cancelled name"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .press("Escape"); + await expect(thermal).toBeFocused(); + await thermal.press("ArrowLeft"); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await page + .getByRole("button", { name: "Close Thermal", exact: true }) + .click(); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await page + .getByRole("button", { name: "Close Dashboard", exact: true }) + .click(); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect(page.getByText("Empty plot", { exact: true })).toBeVisible(); + for (let i = 0; i < 8; i++) + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await page.setViewportSize({ width: 600, height: 850 }); + await expect( + page.getByRole("button", { name: "New dashboard", exact: true }), + ).toBeInViewport(); + await expect(page.getByRole("tab", { selected: true })).toBeInViewport(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath("dashboards-compact.png"), + }); +}); + +test("clearing, mode switching and a new session keep selections scoped", async ({ + page, +}) => { + await plotSignals(page, ["velocity"]); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await chooseSignal(page, "temperature"); + await page + .getByRole("button", { name: "Clear selection", exact: true }) + .click(); + await expect(page.getByText("Empty plot", { exact: true })).toBeVisible(); + await page.getByRole("tab", { name: "Dashboard", exact: true }).click(); + await expect(page.locator("header")).toContainText("1 selected"); + await page.getByRole("button", { name: "Simple", exact: true }).click(); + await expect(page).toHaveURL(/#\/simple$/); + await expect( + page.getByRole("heading", { name: "Logging View", exact: true }), + ).toBeVisible(); + await chooseSignal(page, "position"); + await page.getByRole("button", { name: "Normal", exact: true }).click(); + await expect(page).toHaveURL(/#\/normal$/); + await expect(page.getByRole("tab")).toHaveCount(2); + // The plot survives the round trip through Simple mode. Panes are titled + // "Plot", not after their signal, so the trace chip is what names it. + await expect(page.locator("canvas")).toHaveCount(1); + await expect( + page.getByRole("region", { name: "Chart panel 1", exact: true }), + ).toContainText("LCU / velocity"); + await expect(page.locator("header")).toContainText("1 selected"); + await openSession(page, "another-session"); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect(page.locator("header")).toContainText( + "another-session · 0 selected", + ); + await expect(page.getByText("Empty plot", { exact: true })).toBeVisible(); +}); + +test("tab editor keeps text separate and supports colors and individual reordering", async ({ + page, +}, testInfo) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const first = page.getByRole("tab", { name: "Dashboard", exact: true }); + await first.dblclick(); + const editor = page.getByRole("form", { name: "Dashboard tab settings" }); + await expect(editor).toBeVisible(); + const name = editor.getByRole("textbox", { + name: "Dashboard name", + exact: true, + }); + await expect(name).toBeFocused(); + await expect(page.getByRole("tablist").getByRole("textbox")).toHaveCount(0); + const tabBox = await first.boundingBox(); + const inputBox = await name.boundingBox(); + expect(inputBox!.y).toBeGreaterThanOrEqual(tabBox!.y + tabBox!.height); + await name.fill("Power and cooling"); + await editor + .getByRole("button", { name: "Blue tab color", exact: true }) + .click(); + await page.screenshot({ path: testInfo.outputPath("tab-editor.png") }); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + const renamed = page.getByRole("tab", { + name: "Power and cooling", + exact: true, + }); + await expect(renamed).toBeFocused(); + await expect(renamed).toHaveCSS("box-shadow", /rgb\(59, 130, 246\)/); + await renamed.press("F2"); + await editor.getByLabel("Custom tab color", { exact: true }).fill("#14b8a6"); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect(renamed).toHaveCSS("box-shadow", /rgb\(20, 184, 166\)/); + + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await renamed.dblclick(); + await editor + .getByRole("combobox", { name: "Tab position", exact: true }) + .selectOption("2"); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + const tabNames = () => + page + .getByRole("tab") + .evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label"))); + expect(await tabNames()).toEqual([ + "Dashboard", + "Dashboard 2", + "Power and cooling", + ]); + await expect(renamed).toHaveAttribute("aria-selected", "true"); + await renamed.press("Alt+Shift+ArrowLeft"); + await renamed.press("Alt+Shift+ArrowLeft"); + expect(await tabNames()).toEqual([ + "Power and cooling", + "Dashboard", + "Dashboard 2", + ]); + const last = page.getByRole("tab", { name: "Dashboard 2", exact: true }); + const lastBox = await last.boundingBox(); + await renamed.dragTo(last, { + targetPosition: { x: lastBox!.width - 5, y: lastBox!.height / 2 }, + }); + await expect + .poll(tabNames) + .toEqual(["Dashboard", "Dashboard 2", "Power and cooling"]); + await page.screenshot({ + path: testInfo.outputPath("reordered-colored-tabs.png"), + }); + expect(errors).toEqual([]); +}); + +test("locking protects tab and dashboard state until explicitly unlocked", async ({ + page, +}, testInfo) => { + await plotSignals(page, ["velocity", "position"]); + await page.getByRole("button", { name: "Rows", exact: true }).click(); + await page + .getByRole("spinbutton", { name: "Start time (s)", exact: true }) + .fill("10"); + await page + .getByRole("spinbutton", { name: "End time (s)", exact: true }) + .fill("50"); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const first = page.getByRole("tab", { name: "Dashboard", exact: true }); + await first.dblclick(); + const editor = page.getByRole("form", { name: "Dashboard tab settings" }); + await editor + .getByRole("checkbox", { name: "Lock dashboard", exact: true }) + .check(); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + page.getByText("Dashboard locked · Unlock in tab settings to edit", { + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Rows", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("combobox", { name: "Time unit", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Zoom in comparison", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Clear selection", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Configure chart 1", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { + name: "Hide LCU / velocity in chart 1", + exact: true, + }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Close Dashboard", exact: true }), + ).toBeDisabled(); + for (const checkbox of await page + .locator('[data-slot="sidebar-content"]') + .getByRole("checkbox") + .all()) { + await expect(checkbox).toBeDisabled(); + } + await expect(first.locator("..")).toHaveAttribute("draggable", "false"); + const protectedState = await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + const id = state.activeNormalDashboardId; + const before = JSON.stringify(state.normalDashboards); + state.toggleNormalSeries("LCU/temperature"); + state.clearNormalSeries(); + state.renameNormalDashboard(id, "Should not change"); + state.moveNormalDashboard(id, 1); + state.updateNormalDashboardSettings(id, (settings: unknown) => ({ + ...(settings as object), + range: { start: 0, end: 100 }, + })); + state.configureNormalDashboard(id, { + name: "Should not change", + color: "#ef4444", + locked: true, + position: 1, + }); + state.closeNormalDashboard(id); + return before === JSON.stringify(useStore.getState().normalDashboards); + }); + expect(protectedState).toBe(true); + const chart = page + .getByLabel("Interactive signal chart", { exact: true }) + .first(); + await chart.hover(); + await page.mouse.wheel(0, -300); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toHaveValue("10"); + await expect( + page.getByRole("spinbutton", { name: "End time (s)", exact: true }), + ).toHaveValue("50"); + await first.press("Alt+Shift+ArrowRight"); + await expect(page.getByRole("tab").first()).toHaveAttribute( + "aria-label", + "Dashboard", + ); + await page.screenshot({ path: testInfo.outputPath("locked-dashboard.png") }); + + await page.getByRole("tab", { name: "Dashboard 2", exact: true }).click(); + await chooseSignal(page, "temperature"); + await expect( + page.getByRole("button", { name: "Rows", exact: true }), + ).toBeEnabled(); + await first.dblclick(); + await expect( + editor.getByRole("textbox", { name: "Dashboard name", exact: true }), + ).toBeDisabled(); + await expect( + editor.getByRole("button", { name: "Blue tab color", exact: true }), + ).toBeDisabled(); + await expect( + editor.getByRole("combobox", { name: "Tab position", exact: true }), + ).toBeDisabled(); + await editor + .getByRole("checkbox", { name: "Lock dashboard", exact: true }) + .uncheck(); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Clear selection", exact: true }), + ).toBeEnabled(); + await expect( + page.getByRole("button", { name: "Close Dashboard", exact: true }), + ).toBeEnabled(); + await chooseSignal(page, "temperature"); + await expect(page.locator("header")).toContainText("3 selected"); +}); + +test("uncolored tabs have visible shapes and can move to both ends of the bar", async ({ + page, +}, testInfo) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const first = page.getByRole("tab", { name: "Dashboard", exact: true }); + const middle = page.getByRole("tab", { name: "Dashboard 2", exact: true }); + const last = page.getByRole("tab", { name: "Dashboard 3", exact: true }); + const inactiveStyle = await first.evaluate((tab) => { + const style = getComputedStyle(tab); + return { + borderColor: style.borderTopColor, + borderWidth: style.borderTopWidth, + background: style.backgroundColor, + accent: style.boxShadow, + }; + }); + expect(inactiveStyle.borderWidth).toBe("1px"); + expect(inactiveStyle.borderColor).not.toBe("rgba(0, 0, 0, 0)"); + expect(inactiveStyle.background).not.toBe("rgba(0, 0, 0, 0)"); + expect(inactiveStyle.accent).not.toBe("none"); + await expect(last).toHaveCSS("font-weight", "600"); + await page.screenshot({ path: testInfo.outputPath("uncolored-tabs.png") }); + const bar = page + .getByRole("tablist", { name: "Dashboards", exact: true }) + .locator(".."); + const rect = await bar.boundingBox(); + const tabNames = () => + page + .getByRole("tab") + .evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label"))); + await middle.dragTo(bar, { targetPosition: { x: 2, y: rect!.height / 2 } }); + await expect + .poll(tabNames) + .toEqual(["Dashboard 2", "Dashboard", "Dashboard 3"]); + await middle.dragTo(bar, { + targetPosition: { x: rect!.width - 2, y: rect!.height / 2 }, + }); + await expect + .poll(tabNames) + .toEqual(["Dashboard", "Dashboard 3", "Dashboard 2"]); + + const editor = page.getByRole("form", { + name: "Dashboard tab settings", + exact: true, + }); + await middle.dblclick(); + await editor + .getByRole("button", { name: "Move to first", exact: true }) + .click(); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect + .poll(tabNames) + .toEqual(["Dashboard 2", "Dashboard", "Dashboard 3"]); + await middle.dblclick(); + await editor + .getByRole("button", { name: "Move to last", exact: true }) + .click(); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect + .poll(tabNames) + .toEqual(["Dashboard", "Dashboard 3", "Dashboard 2"]); + await middle.press("Alt+Shift+Home"); + await expect + .poll(tabNames) + .toEqual(["Dashboard 2", "Dashboard", "Dashboard 3"]); + await middle.press("Alt+Shift+End"); + await expect + .poll(tabNames) + .toEqual(["Dashboard", "Dashboard 3", "Dashboard 2"]); + await expect(middle).toHaveAttribute("aria-selected", "true"); + for (let index = 0; index < 5; index++) { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + } + await page.setViewportSize({ width: 600, height: 850 }); + const overflowTab = page.getByRole("tab", { + name: "Dashboard 8", + exact: true, + }); + const compactBar = await bar.boundingBox(); + await overflowTab.dragTo(bar, { + targetPosition: { x: 2, y: compactBar!.height / 2 }, + }); + await expect(page.getByRole("tab").first()).toHaveAttribute( + "aria-label", + "Dashboard 8", + ); + await overflowTab.dragTo(bar, { + targetPosition: { x: compactBar!.width - 2, y: compactBar!.height / 2 }, + }); + await expect(page.getByRole("tab").last()).toHaveAttribute( + "aria-label", + "Dashboard 8", + ); + await expect(page.getByRole("tab")).toHaveCount(8); + await expect(overflowTab).toBeInViewport(); + await page.screenshot({ + path: testInfo.outputPath("uncolored-tabs-overflow.png"), + }); +}); + +test("closing a tab asks for confirmation at four charts, including inactive tabs", async ({ + page, +}) => { + await plotSignals(page, ["velocity"]); + await expect(page.locator("canvas")).toHaveCount(1); + for (let index = 0; index < 2; index++) { + await page + .getByRole("button", { name: "Split chart 1 into columns", exact: true }) + .click(); + } + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 3, + ); + await page + .getByRole("button", { name: "Close Dashboard", exact: true }) + .click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); + + await plotSignals(page, ["velocity"]); + await expect(page.locator("canvas")).toHaveCount(1); + for (let index = 0; index < 3; index++) { + await page + .getByRole("button", { name: "Split chart 1 into columns", exact: true }) + .click(); + } + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 4, + ); + const close = page.getByRole("button", { + name: "Close Dashboard", + exact: true, + }); + const dialog = page.getByRole("alertdialog", { + name: "Close Dashboard?", + exact: true, + }); + await close.click(); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("4 charts"); + await expect( + dialog.getByRole("button", { name: "Cancel", exact: true }), + ).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(close).toBeFocused(); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 4, + ); + await close.click(); + await dialog.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 4, + ); + + await page + .getByRole("button", { name: "Split chart 1 into columns", exact: true }) + .click(); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 5, + ); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await close.click(); + await expect(dialog).toContainText("5 charts"); + await page.keyboard.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("tab", { name: "Dashboard 2", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await close.click(); + await dialog.getByRole("button", { name: "Close tab", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toHaveCount(0); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect( + page.getByRole("tab", { name: "Dashboard 2", exact: true }), + ).toHaveAttribute("aria-selected", "true"); +}); + +test("right-click menus duplicate tabs and act on plots", async ({ + page, +}, testInfo) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const tabNames = () => + page + .getByRole("tab") + .evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label"))); + + // Build a dashboard worth copying: two plots, one holding a measurement. + await chooseSignal(page, "velocity"); + await page + .getByRole("button", { name: "Split chart 1 into columns", exact: true }) + .click(); + await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .click({ button: "right" }); + const paneMenu = page.getByRole("menu", { name: "Chart 1 actions" }); + await expect(paneMenu).toBeVisible(); + await paneMenu + .getByRole("menuitem", { name: "Add 1 selected measurement", exact: true }) + .click(); + await expect(page.locator("canvas")).toHaveCount(1); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + + await page + .getByRole("button", { name: "Add marker to chart 1", exact: true }) + .click(); + await page + .getByRole("spinbutton", { name: "Marker time for chart 1", exact: true }) + .fill("1.5"); + await page + .getByRole("textbox", { name: "Marker label for chart 1", exact: true }) + .fill("Launch"); + await page.getByRole("button", { name: "Add marker", exact: true }).click(); + await page + .getByRole("button", { name: "Add marker to chart 1", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Remove marker Launch" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Remove marker Launch" }).click(); + await page + .getByRole("button", { name: "Add marker to chart 1", exact: true }) + .click(); + await page + .getByLabel("Interactive signal chart", { exact: true }) + .locator("canvas") + .first() + .dblclick({ position: { x: 300, y: 120 } }); + await expect( + page.getByRole("heading", { name: "Add chart marker", exact: true }), + ).toBeVisible(); + await page + .getByRole("textbox", { name: "Marker title for chart 1", exact: true }) + .fill("Double click"); + await page + .getByRole("dialog") + .getByRole("button", { name: "Add marker", exact: true }) + .click(); + + // The settings popover must survive the menu closing behind it. Radix + // restores focus to whatever was focused when the menu opened, in a timeout + // that runs after the exit animation, so the bug only reproduces when that + // element is real — hence the click first. + const config = page.getByRole("textbox", { + name: "Search signals for chart 1", + exact: true, + }); + for (const open of [ + async () => { + await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .click({ button: "right" }); + await paneMenu + .getByRole("menuitem", { + name: "Signals, units and scale", + exact: true, + }) + .click(); + }, + async () => { + await page + .getByRole("button", { name: "Configure chart 1", exact: true }) + .click(); + }, + ]) { + await page + .getByRole("button", { name: "Zoom in comparison", exact: true }) + .click(); + await open(); + await expect(config).toBeVisible(); + // Past the exit animation and the focus-restore timeout behind it. + await page.waitForTimeout(400); + await flushFrames(page); + await expect(config).toBeVisible(); + expect( + await page.evaluate( + () => + !!document.activeElement?.closest('[data-slot="popover-content"]'), + ), + ).toBe(true); + await page.keyboard.press("Escape"); + await expect(config).toHaveCount(0); + } + await page + .getByRole("button", { name: "Reset zoom", exact: true }) + .first() + .click(); + + // Duplicate from the tab menu: copy lands right after the original, active. + const first = page.getByRole("tab", { name: "Dashboard", exact: true }); + await first.click({ button: "right" }); + const tabMenu = page.getByRole("menu", { name: "Dashboard tab actions" }); + await expect(tabMenu).toBeVisible(); + await tabMenu + .getByRole("menuitem", { name: "Duplicate", exact: true }) + .click(); + const copy = page.getByRole("tab", { name: "Dashboard (copy)", exact: true }); + await expect(copy).toHaveAttribute("aria-selected", "true"); + // Radix keeps the page aria-hidden while the menu unmounts, so poll. + await expect.poll(tabNames).toEqual(["Dashboard", "Dashboard (copy)"]); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + await expect(page.locator("canvas")).toHaveCount(1); + + // The copy is independent: closing one of its plots leaves the original. + await page + .getByRole("region", { name: "Chart panel 2", exact: true }) + .click({ button: "right" }); + await pickPaneSubmenuItem(page, 2, "Layout", "Close plot"); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 1, + ); + await first.click(); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + + // A second duplicate gets the next free copy name. + await first.click({ button: "right" }); + await tabMenu + .getByRole("menuitem", { name: "Duplicate", exact: true }) + .click(); + await expect + .poll(tabNames) + .toEqual(["Dashboard", "Dashboard (copy 2)", "Dashboard (copy)"]); + + // Workspace background menu drives layout and the statistics panel. + await page + .getByLabel("Signal analysis workspace", { exact: true }) + // Inside the workspace's 12px padding band, clear of the panes and of the + // sidebar's toggle handle at the left edge. + .click({ button: "right", position: { x: 300, y: 4 } }); + const workspaceMenu = page.getByRole("menu", { name: "Workspace actions" }); + await expect(workspaceMenu).toBeVisible(); + await workspaceMenu + .getByRole("menuitemcheckbox", { name: "Window statistics", exact: true }) + .click(); + await expect( + page.getByRole("region", { + name: "Visible window statistics", + exact: true, + }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Customize statistics columns" }) + .click(); + await page.getByRole("checkbox", { name: "Minimum" }).click(); + await expect( + page.getByRole("columnheader", { name: "Minimum", exact: true }), + ).toHaveCount(0); + await page.getByRole("button", { name: "Close window statistics" }).click(); + await expect( + page.getByRole("region", { + name: "Visible window statistics", + exact: true, + }), + ).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath("context-menus.png") }); + expect(errors).toEqual([]); +}); + +test("the tab menu moves, locks and bulk-closes tabs", async ({ page }) => { + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const tabNames = () => + page + .getByRole("tab") + .evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label"))); + const third = page.getByRole("tab", { name: "Dashboard 3", exact: true }); + const tabMenu = page.getByRole("menu", { name: "Dashboard tab actions" }); + + await third.click({ button: "right" }); + await tabMenu + .getByRole("menuitem", { name: "Move to first", exact: true }) + .click(); + await expect + .poll(tabNames) + .toEqual(["Dashboard 3", "Dashboard", "Dashboard 2"]); + + // Locking through the menu disables the mutating items on the next open. + await third.click({ button: "right" }); + await tabMenu.getByRole("menuitem", { name: "Lock", exact: true }).click(); + await third.click({ button: "right" }); + await expect( + tabMenu.getByRole("menuitem", { name: "Close", exact: true }), + ).toBeDisabled(); + await expect( + tabMenu.getByRole("menuitem", { name: "Move to last", exact: true }), + ).toBeDisabled(); + await tabMenu.getByRole("menuitem", { name: "Unlock", exact: true }).click(); + + // "Close other tabs" skips nothing here and needs no confirmation (0 charts + // each, under the four-chart threshold). + await third.click({ button: "right" }); + await tabMenu + .getByRole("menuitem", { name: "Close other tabs", exact: true }) + .click(); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect(third).toBeVisible(); + + // The four-chart confirmation covers the menu's Close item too. + const split = page.getByRole("button", { + name: "Split chart 1 into columns", + exact: true, + }); + const panels = page.getByRole("region", { name: /^Chart panel / }); + for (let index = 0; index < 3; index++) await split.click(); + await expect(panels).toHaveCount(4); + await third.click({ button: "right" }); + await tabMenu.getByRole("menuitem", { name: "Close", exact: true }).click(); + const dialog = page.getByRole("alertdialog", { + name: "Close Dashboard 3?", + exact: true, + }); + await expect(dialog).toContainText("4 charts"); + await dialog.getByRole("button", { name: "Close tab", exact: true }).click(); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); + + // And a bulk close asks once for the whole batch. + const newDashboard = page.getByRole("button", { + name: "New dashboard", + exact: true, + }); + await newDashboard.click(); + await newDashboard.click(); + await page.getByRole("tab", { name: "Dashboard 2", exact: true }).click(); + for (let index = 0; index < 3; index++) await split.click(); + await expect(panels).toHaveCount(4); + await page + .getByRole("tab", { name: "Dashboard", exact: true }) + .click({ button: "right" }); + await tabMenu + .getByRole("menuitem", { name: "Close other tabs", exact: true }) + .click(); + const bulk = page.getByRole("alertdialog", { + name: "Close 2 tabs?", + exact: true, + }); + await expect(bulk).toContainText("5 charts"); + await bulk.getByRole("button", { name: "Close tabs", exact: true }).click(); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); +}); + +test("a duplicate shares no node ids and keeps its panel sizes", async ({ + page, +}) => { + const result = await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const store = useStore.getState(); + const id = store.activeNormalDashboardId; + // Two plots, so the tree gains a split node, then give that split a + // non-default divider position — the part a naive copy would lose. + store.addNormalPlot(["LCU/velocity"]); + store.addNormalPlot(["LCU/position"]); + const splitId = useStore + .getState() + .normalDashboards.find((item: { id: string }) => item.id === id).settings + .layoutState.tree.id; + store.updateNormalDashboardSettings(id, (settings: object) => ({ + ...settings, + panelSizes: { + [splitId]: { [`${splitId}-first`]: 0.7, [`${splitId}-second`]: 0.3 }, + }, + })); + + const copyId = store.duplicateNormalDashboard(id); + const dashboards = useStore.getState().normalDashboards; + const find = (target: string) => + dashboards.find((item: { id: string }) => item.id === target); + const source = find(id); + const copy = find(copyId); + const nodeIds = ( + node: { id: string; type: string; first?: object; second?: object }, + out: string[] = [], + ) => { + out.push(node.id); + if (node.type === "split") { + nodeIds(node.first, out); + nodeIds(node.second, out); + } + return out; + }; + const sourceIds = nodeIds(source.settings.layoutState.tree); + const copyIds = nodeIds(copy.settings.layoutState.tree); + const keys = Object.keys(copy.settings.panelSizes); + return { + shared: copyIds.filter((node: string) => sourceIds.includes(node)), + keysAreOwnNodes: keys.every((key) => copyIds.includes(key)), + innerKeys: keys + .flatMap((key) => Object.keys(copy.settings.panelSizes[key])) + .sort(), + expectedInnerKeys: keys + .flatMap((key) => [`${key}-first`, `${key}-second`]) + .sort(), + sizes: keys.flatMap((key) => + Object.values(copy.settings.panelSizes[key]), + ), + names: dashboards.map((item: { name: string }) => item.name), + sourceSignals: source.settings.layoutState.ids, + copySignals: copy.settings.layoutState.ids, + }; + }); + + expect(result.shared).toEqual([]); + expect(result.keysAreOwnNodes).toBe(true); + expect(result.innerKeys).toEqual(result.expectedInnerKeys); + expect(result.sizes).toEqual([0.7, 0.3]); + expect(result.copySignals).toEqual(result.sourceSignals); + expect(result.names).toEqual(["Dashboard", "Dashboard (copy)"]); +}); + +test("default dashboard names reset for the only tab and reuse free names", async ({ + page, +}) => { + for (let index = 0; index < 3; index++) { + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Close Dashboard", exact: true }) + .click(); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect( + page.getByRole("tab", { name: "Dashboard", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("tab", { name: /^Dashboard \d+$/ }), + ).toHaveCount(0); + } + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const third = page.getByRole("tab", { name: "Dashboard 3", exact: true }); + const thirdId = await third.getAttribute("id"); + await page + .getByRole("button", { name: "Close Dashboard 2", exact: true }) + .click(); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + const reused = page.getByRole("tab", { name: "Dashboard 2", exact: true }); + await expect(reused).toHaveAttribute("aria-selected", "true"); + await expect(third).toHaveAttribute("id", thirdId!); + await reused.press("F2"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .fill("Thermal overview"); + await page + .getByRole("textbox", { name: "Dashboard name", exact: true }) + .press("Enter"); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await expect( + page.getByRole("tab", { name: "Dashboard 2", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByRole("tab", { name: "Thermal overview", exact: true }), + ).toBeVisible(); + const names = await page + .getByRole("tab") + .evaluateAll((tabs) => tabs.map((tab) => tab.getAttribute("aria-label"))); + expect(new Set(names).size).toBe(names.length); +}); + +test("layout and time controls compact into the tab bar without resetting charts", async ({ + page, +}, testInfo) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await plotSignals(page, ["velocity"]); + // The sidebar's selection bar offers a "New plot" too, so scope to the bar. + await page + .getByRole("group", { name: "Analysis controls", exact: true }) + .getByRole("button", { name: "New plot", exact: true }) + .click(); + await plotSignals(page, ["position"], 2); + await expect(page.locator("canvas")).toHaveCount(2); + const canvas = await page.locator("canvas").first().elementHandle(); + const before = await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .boundingBox(); + await page + .getByRole("button", { + name: "Compact layout and time controls", + exact: true, + }) + .click(); + const tabBar = page + .getByRole("tablist", { name: "Dashboards", exact: true }) + .locator(".."); + const controls = page.getByRole("group", { + name: "Analysis controls", + exact: true, + }); + await expect( + tabBar.getByRole("group", { name: "Analysis controls", exact: true }), + ).toBeVisible(); + const after = await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .boundingBox(); + expect(after!.y).toBeLessThan(before!.y - 40); + expect(await canvas!.evaluate((element) => element.isConnected)).toBe(true); + await page + .getByRole("button", { name: "Layout settings", exact: true }) + .click(); + await page.getByRole("button", { name: "Rows", exact: true }).click(); + await page + .getByRole("button", { name: "Time window settings", exact: true }) + .click(); + await page + .getByRole("spinbutton", { name: "Start time (s)", exact: true }) + .fill("10"); + await page + .getByRole("spinbutton", { name: "End time (s)", exact: true }) + .fill("50"); + await page.getByRole("button", { name: "Apply", exact: true }).click(); + await page + .getByRole("combobox", { name: "Time unit", exact: true }) + .selectOption("ms"); + await page + .getByRole("spinbutton", { name: "Start time (ms)", exact: true }) + .press("Escape"); + await expect( + page.getByRole("form", { name: "Visible time window", exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Time window settings", exact: true }), + ).toContainText("10,000–50,000 ms"); + await page.screenshot({ path: testInfo.outputPath("compact-tab-bar.png") }); + + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await plotSignals(page, ["temperature"]); + await expect( + page.getByRole("button", { name: "Time window settings", exact: true }), + ).toContainText("0–100 s"); + await page.getByRole("tab", { name: "Dashboard", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Time window settings", exact: true }), + ).toContainText("10,000–50,000 ms"); + await page + .getByRole("button", { + name: "Expand layout and time controls", + exact: true, + }) + .click(); + await expect( + page.getByRole("button", { name: "Rows", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + await expect( + page.getByRole("spinbutton", { name: "Start time (ms)", exact: true }), + ).toHaveValue("10000"); + await page + .getByRole("button", { + name: "Compact layout and time controls", + exact: true, + }) + .click(); + await page.getByRole("tab", { name: "Dashboard", exact: true }).dblclick(); + const editor = page.getByRole("form", { + name: "Dashboard tab settings", + exact: true, + }); + await editor + .getByRole("checkbox", { name: "Lock dashboard", exact: true }) + .check(); + await editor + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Layout settings", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Time window settings", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { + name: "Expand layout and time controls", + exact: true, + }), + ).toBeEnabled(); + await page.setViewportSize({ width: 600, height: 850 }); + await expect(page.getByRole("tab", { selected: true })).toBeInViewport(); + await expect(controls).toBeInViewport(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.screenshot({ + path: testInfo.outputPath("compact-tab-bar-narrow.png"), + }); + expect(errors).toEqual([]); +}); + +test("shift+scroll pans the visible window without zooming", async ({ + page, +}) => { + await addSignalToPane(page, "velocity"); + await expect(page.locator("canvas")).toHaveCount(1); + await applyWindow(page, "10", "50"); + + const start = page.getByRole("spinbutton", { + name: "Start time (s)", + exact: true, + }); + const end = page.getByRole("spinbutton", { + name: "End time (s)", + exact: true, + }); + await expect(start).toHaveValue("10"); + + const chart = page + .getByLabel("Interactive signal chart", { exact: true }) + .first(); + await chart.hover(); + await page.keyboard.down("Shift"); + await page.mouse.wheel(0, 300); + await page.keyboard.up("Shift"); + await flushFrames(page); + + // Panned forward, and the span is untouched: this is the whole point, since + // ECharts on its own would have zoomed at the same time. + await expect + .poll(async () => Number(await start.inputValue())) + .toBeGreaterThan(10); + const span = + Number(await end.inputValue()) - Number(await start.inputValue()); + expect(Math.abs(span - 40)).toBeLessThan(0.5); +}); + +test("charts can hold independent time windows", async ({ page }) => { + await addSignalToPane(page, "velocity"); + await expect(page.locator("canvas")).toHaveCount(1); + await applyWindow(page, "10", "50"); + + // A second plot, so one can move while the other stays put. + await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .click({ button: "right" }); + await pickPaneSubmenuItem(page, 1, "Layout", "Split into columns"); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + await addSignalToPane(page, "position", 2); + await expect(page.locator("canvas")).toHaveCount(2); + + // Linked is the default, so no per-plot label is shown. + await expect(paneWindow(page, 1)).toHaveCount(0); + + const link = page.getByRole("button", { + name: "Link time window across charts", + exact: true, + }); + await expect(link).toHaveAttribute("aria-pressed", "true"); + await link.click(); + await expect(link).toHaveAttribute("aria-pressed", "false"); + // Unlinked, each plot states its own window — both still on the shared one. + await expect(paneWindow(page, 1)).toContainText("10"); + await expect(paneWindow(page, 2)).toContainText("10"); + + await page + .getByLabel("Interactive signal chart", { exact: true }) + .first() + .hover(); + await page.keyboard.down("Shift"); + await page.mouse.wheel(0, 300); + await page.keyboard.up("Shift"); + await flushFrames(page); + + // Only the panned plot moved, and the toolbar did not follow it. + await expect.poll(() => paneWindow(page, 2).textContent()).toContain("10–50"); + expect(await paneWindow(page, 1).textContent()).not.toContain("10–50"); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toHaveValue("10"); + + // Unlinked, the bar has no single window to edit, so its time controls are + // off; only the link toggle and the unit stay usable. + for (const name of ["Start time (s)", "End time (s)"]) + await expect( + page.getByRole("spinbutton", { name, exact: true }), + ).toBeDisabled(); + for (const name of [ + "Apply", + "Previous time window", + "Next time window", + "Zoom in comparison", + "Zoom out comparison", + "Reset zoom", + ]) + await expect( + page.getByRole("button", { name, exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("combobox", { name: "Time unit", exact: true }), + ).toBeEnabled(); + + // Re-linking drops the per-plot labels and hands the window back to the bar. + await link.click(); + await expect(paneWindow(page, 1)).toHaveCount(0); + await applyWindow(page, "20", "60"); + await expect( + page.getByRole("spinbutton", { name: "Start time (s)", exact: true }), + ).toHaveValue("20"); +}); + +test("a plot added after unlinking does not link the charts again", async ({ + page, +}) => { + await addSignalToPane(page, "velocity"); + await expect(page.locator("canvas")).toHaveCount(1); + await applyWindow(page, "10", "50"); + await page + .getByRole("button", { + name: "Link time window across charts", + exact: true, + }) + .click(); + + // The second chart mounts while the dashboard is already unlinked. + await page + .getByRole("region", { name: "Chart panel 1", exact: true }) + .click({ button: "right" }); + await pickPaneSubmenuItem(page, 1, "Layout", "Split into columns"); + // Through the store: unlinked, the plot menu is one row taller and opens + // over the pointer. + const secondPane = await page + .getByRole("region", { name: "Chart panel 2", exact: true }) + .getAttribute("data-pane-id"); + await page.evaluate(async (paneId) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + useStore.getState().assignNormalSignalsToPlot(paneId, ["LCU/position"]); + }, secondPane); + await expect(page.locator("canvas")).toHaveCount(2); + + await page + .getByLabel("Interactive signal chart", { exact: true }) + .first() + .hover(); + await page.keyboard.down("Shift"); + await page.mouse.wheel(0, 300); + await page.keyboard.up("Shift"); + await flushFrames(page); + + await expect + .poll(() => paneWindow(page, 1).textContent()) + .not.toContain("10–50"); + await flushFrames(page); + expect(await paneWindow(page, 2).textContent()).toContain("10–50"); +}); + +// An ADJ-backed session: enum handling is driven entirely by ADJ metadata +// (isDiscreteMeasurement), so the plain CSV fixture above can never produce a +// discrete signal. ADJ names are set equal to their ids to keep selectors +// identical to the other tests. +async function openEnumSession(page: Page) { + const commit = "abc1234"; + await page.route( + `https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-${commit}.json`, + (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + boards: { + LCU: { + LCU_measurements: [ + { + id: "velocity", + name: "velocity", + type: "float32", + displayUnits: "m/s", + }, + { + id: "position", + name: "position", + type: "float32", + displayUnits: "mm", + }, + { + id: "state", + name: "state", + type: "enum", + enumValues: ["IDLE", "READY", "RUNNING"], + }, + { + id: "brake", + name: "brake", + type: "enum", + enumValues: ["OPEN", "CLOSED"], + }, + ], + }, + }, + }), + }), + ); + await page.evaluate(async (adjCommit) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const numeric = (offset: number) => + `0,LCU,backend,${offset}\n50000,LCU,backend,${offset + 2}\n100000,LCU,backend,${offset + 1}`; + await useStore.getState().openSession([ + { + name: "logger_settings.json", + webkitRelativePath: "demo/logger_settings.json", + text: async () => + JSON.stringify({ + adj_commit_hash: adjCommit, + time_unit: "ms", + date: "2026-01-01", + }), + }, + { + name: "velocity.csv", + webkitRelativePath: "demo/data/LCU/velocity.csv", + text: async () => numeric(0), + }, + { + name: "position.csv", + webkitRelativePath: "demo/data/LCU/position.csv", + text: async () => numeric(1), + }, + { + name: "state.csv", + webkitRelativePath: "demo/data/LCU/state.csv", + text: async () => + "0,LCU,backend,IDLE\n40000,LCU,backend,READY\n80000,LCU,backend,RUNNING", + }, + { + name: "brake.csv", + webkitRelativePath: "demo/data/LCU/brake.csv", + text: async () => "0,LCU,backend,OPEN\n60000,LCU,backend,CLOSED", + }, + ]); + }, commit); +} + +test("an enum drawn as a background band leaves the numeric axes alone", async ({ + page, +}) => { + await openEnumSession(page); + for (const name of ["velocity", "position", "state"]) + await addSignalToPane(page, name); + await expect(page.locator("canvas")).toHaveCount(1); + + const pane = page.getByRole("region", { name: "Chart panel 1", exact: true }); + const band = page.getByRole("checkbox", { + name: "Draw the enum as a background band", + exact: true, + }); + const openConfig = async () => { + await page + .getByRole("button", { name: "Configure chart 1", exact: true }) + .click(); + }; + + // Two numeric units plus a banded enum: three signals, but only two axes, so + // the panel must stay on original values. + await expect(pane).not.toContainText("Relative %"); + await openConfig(); + await expect(band).toBeChecked(); + + // Drawing it as a line gives it an axis of its own, which is the third + // scale — that is what forces relative trends. + await band.click(); + await page.keyboard.press("Escape"); + await expect(pane).toContainText("Relative %"); + + // Back to a band, back to original values. + await openConfig(); + await band.click(); + await page.keyboard.press("Escape"); + await expect(pane).not.toContainText("Relative %"); +}); + +test("a plot of enums only renders as a state timeline", async ({ page }) => { + await openEnumSession(page); + await addSignalToPane(page, "state"); + // Several enums are allowed together: each becomes a timeline row. + await addSignalToPane(page, "brake"); + await expect(page.locator("canvas")).toHaveCount(1); + + const pane = page.getByRole("region", { name: "Chart panel 1", exact: true }); + await expect(pane).toContainText("LCU / state"); + await expect(pane).toContainText("LCU / brake"); + // A state timeline has no numeric axes to normalize and no band to toggle. + await expect(pane).not.toContainText("Relative %"); + await page + .getByRole("button", { name: "Configure chart 1", exact: true }) + .click(); + await expect( + page.getByRole("checkbox", { + name: "Draw the enum as a background band", + exact: true, + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); +}); + +test("a plot refuses a second enum alongside numeric signals", async ({ + page, +}) => { + await openEnumSession(page); + await addSignalToPane(page, "velocity"); + await addSignalToPane(page, "state"); + const pane = page.getByRole("region", { name: "Chart panel 1", exact: true }); + await expect(pane).toContainText("LCU / state"); + + // One enum is in alongside a numeric signal, so the second one is refused + // and said so, rather than silently producing an unreadable stack of bands. + await addSignalToPane(page, "brake"); + await expect(page.getByRole("alert")).toContainText("one enum"); + await expect(pane).not.toContainText("LCU / brake"); + await expect(pane).toContainText("LCU / state"); +}); + +test("a duplicate keeps its own per-plot windows", async ({ page }) => { + await addSignalToPane(page, "velocity"); + await expect(page.locator("canvas")).toHaveCount(1); + await applyWindow(page, "10", "50"); + await page + .getByRole("button", { + name: "Link time window across charts", + exact: true, + }) + .click(); + + // Move the original off the dashboard window. + await page + .getByLabel("Interactive signal chart", { exact: true }) + .first() + .hover(); + await page.keyboard.down("Shift"); + await page.mouse.wheel(0, 300); + await page.keyboard.up("Shift"); + await flushFrames(page); + const moved = await paneWindow(page, 1).textContent(); + expect(moved).not.toContain("10–50"); + + await page + .getByRole("tab", { name: "Dashboard", exact: true }) + .click({ button: "right" }); + await page + .getByRole("menu", { name: "Dashboard tab actions" }) + .getByRole("menuitem", { name: "Duplicate", exact: true }) + .click(); + await expect( + page.getByRole("tab", { name: "Dashboard (copy)", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + // The copy inherits the position... + await expect.poll(() => paneWindow(page, 1).textContent()).toBe(moved); + + // ...but not the object: panning the copy must leave the original alone. + await page + .getByLabel("Interactive signal chart", { exact: true }) + .first() + .hover(); + await page.keyboard.down("Shift"); + await page.mouse.wheel(0, 300); + await page.keyboard.up("Shift"); + await flushFrames(page); + const copyMoved = await paneWindow(page, 1).textContent(); + expect(copyMoved).not.toBe(moved); + + await page.getByRole("tab", { name: "Dashboard", exact: true }).click(); + await expect.poll(() => paneWindow(page, 1).textContent()).toBe(moved); +}); diff --git a/e2e/tests/logging-view/export-and-history.test.ts b/e2e/tests/logging-view/export-and-history.test.ts new file mode 100644 index 000000000..741246847 --- /dev/null +++ b/e2e/tests/logging-view/export-and-history.test.ts @@ -0,0 +1,303 @@ +import { expect, test } from "@playwright/test"; +import { + chartOption, + gotoNormal, + openBoard, + openSession, + pane, + panes, + patchSettings, + pickPaneItem, + plotSignals, + readDownload, + settings, + sidebar, +} from "./helpers"; + +test.beforeEach(async ({ page }) => { + await gotoNormal(page); + await openSession(page); + await plotSignals(page, ["velocity", "position"]); + await expect(page.locator("canvas")).toHaveCount(1); +}); + +test("statistics and cursor tables export as CSV and copy as a table", async ({ + page, + context, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await patchSettings( + page, + "return { ...current, showStatistics: true, showCursors: true, cursors: { a: 25000, b: 75000 } };", + ); + const statistics = page.getByRole("region", { + name: "Visible window statistics", + }); + const csv = await readDownload(page, () => + statistics + .getByRole("button", { name: "Download statistics as CSV" }) + .click(), + ); + expect(csv.name).toMatch(/^window-statistics__.*\.csv$/); + const lines = csv.bytes.toString().trim().split("\n"); + expect(lines[0]).toBe( + "Board,Signal,Unit,Samples,Minimum,Maximum,Average,Standard deviation,First,Last,State changes", + ); + // Raw numbers; state changes only apply to enums, so the cell is empty. + expect(lines[1]).toMatch(/^LCU,velocity,,3,0,2,1,0\.8164965\d*,0,1,$/); + + await statistics + .getByRole("button", { name: "Copy statistics as a table" }) + .click(); + await expect( + statistics.getByRole("button", { name: "Copy statistics as a table" }), + ).toContainText("Copied"); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied.split("\n")[1]).toMatch( + /^LCU\tvelocity\t\t3\t0\t2\t1\t0\.8164965\d*\t0\t1\t$/, + ); + + const cursors = page.getByRole("region", { name: "Cursor measurements" }); + const cursorCsv = await readDownload(page, () => + cursors + .getByRole("button", { name: "Download cursor measurements as CSV" }) + .click(), + ); + expect(cursorCsv.bytes.toString()).toContain( + "LCU,velocity,,25,75,1,1.5,0.5,0.01", + ); +}); + +test("a plot exports its image and the samples in its window", async ({ + page, +}) => { + const png = await readDownload(page, () => + pickPaneItem(page, "Export", "Save as PNG"), + ); + expect(png.name).toMatch(/^Plot__.*\.png$/); + expect(png.bytes.subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + + // Only the second half: the samples at 50 and 100 s of each signal. + await patchSettings( + page, + "return { ...current, range: { start: 50, end: 100 } };", + ); + const data = await readDownload(page, () => + pickPaneItem(page, "Export", "Visible data as CSV"), + ); + expect(data.bytes.toString().trim().split("\n")).toEqual([ + "Board,Signal,Unit,Time (s),Value", + "LCU,velocity,,50,2", + "LCU,velocity,,100,1", + "LCU,position,,50,3", + "LCU,position,,100,2", + ]); +}); + +test("double-clicking a measurement adds it to the plot last clicked", async ({ + page, +}) => { + await page.keyboard.press("n"); + await expect(panes(page)).toHaveCount(2); + // The plot clicked last is the target. + await pane(page, 2).click(); + await openBoard(page); + await sidebar(page).getByText("temperature", { exact: true }).dblclick(); + await expect( + pane(page, 2).getByRole("button", { name: /LCU \/ temperature/ }), + ).toBeVisible(); + + await pane(page, 1).click(); + await sidebar(page).getByText("temperature", { exact: true }).dblclick(); + await expect( + pane(page, 1).getByRole("button", { name: /LCU \/ temperature/ }), + ).toBeVisible(); + // The two clicks of a double-click leave the selection untouched. + await expect(page.locator("header")).toContainText("0 selected"); +}); + +test("the search box adds measurements to the active plot or a new one", async ({ + page, +}) => { + await page.keyboard.press("/"); + const dialog = page.getByRole("dialog", { name: "Add a measurement" }); + const search = dialog.getByRole("combobox", { name: "Search measurements" }); + await search.fill("temp"); + await expect(dialog.getByRole("option")).toHaveCount(1); + await search.press("Enter"); + await expect(dialog.getByRole("status")).toHaveText( + "Added LCU / temperature", + ); + expect((await settings(page)).layoutState.ids).toContain("LCU/temperature"); + + await search.fill("lcu"); + await expect(dialog.getByRole("option")).toHaveCount(3); + await search.press("ArrowDown"); + await expect(dialog.getByRole("option").nth(1)).toHaveAttribute( + "aria-selected", + "true", + ); + await search.press("Shift+Enter"); + // The modal hides the plots from the accessibility tree; ask the store. + await expect + .poll(async () => { + const tree = (await settings(page)).layoutState.tree; + return tree.type === "split" ? tree.second.signalIds : []; + }) + .toEqual(["LCU/temperature"]); + await search.fill("nothing like this"); + await expect(dialog).toContainText("No measurement matches"); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); +}); + +test("undo and redo bring back dashboard changes but not zoom", async ({ + page, +}) => { + const undo = page.getByRole("button", { name: "Undo", exact: true }); + const redo = page.getByRole("button", { name: "Redo", exact: true }); + await page.keyboard.press("n"); + await expect(panes(page)).toHaveCount(2); + await page.waitForTimeout(500); + await pickPaneItem(page, "Layout", "Close plot", 1); + await expect(panes(page)).toHaveCount(1); + + // Zooming is view state: no undo step for it, and undo leaves it alone. + await page.keyboard.press("+"); + await expect + .poll(async () => (await settings(page)).range) + .toEqual({ start: 25, end: 75 }); + + await page.keyboard.press("Control+z"); + await expect(panes(page)).toHaveCount(2); + await expect( + pane(page, 1).getByRole("button", { name: /LCU \/ velocity/ }), + ).toBeVisible(); + expect((await settings(page)).range).toEqual({ start: 25, end: 75 }); + await expect(redo).toBeEnabled(); + + await page.keyboard.press("Control+Shift+z"); + await expect(panes(page)).toHaveCount(1); + await undo.click(); + await expect(panes(page)).toHaveCount(2); + await undo.click(); + await expect(panes(page)).toHaveCount(1); + // One step left: adding the measurements, done when the test started. + await undo.click(); + await expect(page.getByText("Empty plot", { exact: true })).toBeVisible(); + await expect(undo).toBeDisabled(); + await redo.click(); + await expect(page.locator("canvas")).toHaveCount(1); + + // In a text field, Ctrl+Z is the field's own undo. + await page.keyboard.press("m"); + const name = page + .getByRole("dialog", { name: "New formula" }) + .getByLabel("Name", { exact: true }); + await name.fill("Gap"); + await name.press("Control+z"); + await page.keyboard.press("Escape"); + await expect(panes(page)).toHaveCount(1); +}); + +test("a dashboard saved as a template starts a dashboard on another log", async ({ + page, +}) => { + await page.keyboard.press("n"); + await expect(panes(page)).toHaveCount(2); + await page + .getByRole("tab", { name: "Dashboard", exact: true }) + .click({ button: "right" }); + await page + .getByRole("menu", { name: "Dashboard tab actions" }) + .getByRole("menuitem", { name: "Save as template…" }) + .click(); + const dialog = page.getByRole("dialog", { name: "Save as template" }); + await dialog.getByLabel("Template name").fill("Speed check"); + await dialog.getByRole("button", { name: "Save template" }).click(); + await expect(dialog).toBeHidden(); + + // Another log, where temperature was never recorded. + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + await useStore.getState().openSession( + ["velocity", "position"].map((name) => ({ + name: `${name}.csv`, + webkitRelativePath: `other/data/LCU/${name}.csv`, + text: async () => "0,LCU,backend,5\n10000,LCU,backend,6", + })), + ); + }); + await expect(page.getByRole("tab")).toHaveCount(1); + await expect(panes(page)).toHaveCount(1); + + const templates = page.getByRole("button", { + name: "New dashboard from a template", + }); + await templates.click(); + await page.getByRole("menuitem", { name: /^Speed check/ }).click(); + await expect(page.getByRole("tab")).toHaveCount(2); + await expect( + page.getByRole("tab", { name: "Speed check", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(panes(page)).toHaveCount(2); + await expect(page.locator("canvas")).toHaveCount(1); + + await templates.click(); + await page + .getByRole("button", { name: "Delete template Speed check" }) + .click(); + // Deleting neither applies the template nor closes the menu. + await expect(page.getByRole("menu")).toContainText("Save as template"); + await expect( + page.getByRole("menuitem", { name: /^Speed check/ }), + ).toHaveCount(0); + // The open menu hides the tabs from the accessibility tree; ask the store. + expect( + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + return useStore.getState().normalDashboards.length; + }), + ).toBe(2); +}); + +test("a plot can draw its signals against another signal", async ({ page }) => { + await pane(page).click({ button: "right" }); + await page + .getByRole("menu", { name: "Chart 1 actions" }) + .getByRole("menuitem", { name: "View", exact: true }) + .click(); + await page.getByRole("menuitem", { name: "X axis", exact: true }).click(); + await page.getByRole("menuitemradio", { name: "LCU / velocity" }).click(); + + await expect(pane(page).getByText("X: velocity")).toBeVisible(); + await expect(pane(page).getByLabel("X-Y chart")).toBeVisible(); + // position against velocity: the three shared instants, x spanning 0–2. + const xy = await chartOption(page, "X-Y chart"); + expect(xy.xType).toBe("value"); + expect(xy.seriesLengths).toEqual([3]); + expect(xy.xExtent[0]).toBeLessThanOrEqual(0); + expect(xy.xExtent[1]).toBeGreaterThanOrEqual(2); + const plotted = await settings(page); + const first = plotted.layoutState.tree; + expect(first.xSignalId).toBe("LCU/velocity"); + expect(first.signalIds).toEqual(["LCU/position"]); + + // Back to time: velocity is a plotted signal again. + await pane(page).click({ button: "right" }); + await page + .getByRole("menu", { name: "Chart 1 actions" }) + .getByRole("menuitem", { name: "View", exact: true }) + .click(); + await page.getByRole("menuitem", { name: "X axis", exact: true }).click(); + await page.getByRole("menuitemradio", { name: "Time", exact: true }).click(); + await expect(pane(page).getByLabel("Interactive signal chart")).toBeVisible(); + expect((await settings(page)).layoutState.tree.signalIds).toEqual([ + "LCU/position", + "LCU/velocity", + ]); +}); diff --git a/e2e/tests/logging-view/formulas.test.ts b/e2e/tests/logging-view/formulas.test.ts new file mode 100644 index 000000000..ac9218f52 --- /dev/null +++ b/e2e/tests/logging-view/formulas.test.ts @@ -0,0 +1,181 @@ +import { expect, test, type Page } from "@playwright/test"; + +// velocity = 0, 2, 1 · position = 1, 3, 2 · temperature = 2, 4, 3, all sampled +// at the same three instants. +async function openSession(page: Page) { + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + await useStore.getState().openSession( + ["velocity", "position", "temperature"].map((name, index) => ({ + name: `${name}.csv`, + webkitRelativePath: `demo/data/LCU/${name}.csv`, + text: async () => + `0,LCU,backend,${index}\n50000,LCU,backend,${index + 2}\n100000,LCU,backend,${index + 1}`, + })), + ); + }); +} + +async function plotSignals(page: Page, names: string[]) { + const sidebar = page.locator('[data-slot="sidebar-content"]'); + const board = sidebar.getByRole("button", { name: /^LCU/ }); + if ((await board.getAttribute("data-state")) !== "open") await board.click(); + for (const name of names) + await sidebar.getByText(name, { exact: true }).click(); + await pane(page).click({ button: "right" }); + await page + .getByRole("menu", { name: "Chart 1 actions" }) + .getByRole("menuitem", { name: /^Add \d+ selected measurements?$/ }) + .click(); +} + +const pane = (page: Page, number = 1) => + page.getByRole("region", { name: `Chart panel ${number}`, exact: true }); + +const statistics = (page: Page) => + page.getByRole("region", { name: "Visible window statistics" }); + +const formulaRow = (page: Page, name: string) => + statistics(page).getByRole("row").filter({ hasText: name }); + +async function openMathMenu(page: Page) { + await page.getByRole("button", { name: "Formula signals" }).click(); + return page.getByRole("menu"); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/#/normal"); + await expect( + page.getByRole("heading", { name: "Open a logging session" }), + ).toBeVisible(); + await openSession(page); + await plotSignals(page, ["velocity", "position"]); +}); + +test("a formula built from a plot is drawn, measured, edited and deleted", async ({ + page, +}) => { + await page.getByRole("button", { name: "Configure chart 1" }).click(); + await page + .getByRole("button", { name: "New formula from this plot…" }) + .click(); + const dialog = page.getByRole("dialog", { name: "New formula" }); + // Both plotted signals arrive bound to a and b with a starting expression. + await expect(dialog.getByLabel("Expression")).toHaveValue("a - b"); + await dialog.getByLabel("Expression").fill("abs(a - b) * 5"); + await dialog.getByLabel("Name", { exact: true }).fill("Gap"); + await dialog.getByRole("button", { name: "Create formula" }).click(); + await expect(dialog).toBeHidden(); + + await expect( + pane(page).getByRole("button", { name: /Math \/ Gap/ }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Plot overlays" }).click(); + await page + .getByRole("menuitemcheckbox", { name: "Window statistics" }) + .click(); + await page.keyboard.press("Escape"); + // |velocity - position| is 1 at every sample. + await expect(formulaRow(page, "Gap")).toContainText("5"); + await expect(formulaRow(page, "Gap")).not.toContainText("10"); + + // Editing keeps the signal in its plot and recomputes it. + await pane(page) + .getByRole("button", { name: /Math \/ Gap/ }) + .click({ + button: "right", + }); + await page.getByRole("menuitem", { name: "Edit formula…" }).click(); + const editor = page.getByRole("dialog", { name: "Edit formula" }); + await editor.getByLabel("Expression").fill("abs(a - b) * 10"); + await editor.getByRole("button", { name: "Save" }).click(); + await expect(formulaRow(page, "Gap")).toContainText("10"); + + const menu = await openMathMenu(page); + await menu.getByRole("menuitem", { name: /Gap/ }).click(); + await page.getByRole("menuitem", { name: "Delete formula" }).click(); + await expect( + pane(page).getByRole("button", { name: /Math \/ Gap/ }), + ).toHaveCount(0); + await expect( + pane(page).getByRole("button", { name: /LCU \/ velocity/ }), + ).toBeVisible(); +}); + +test("the editor refuses expressions it cannot evaluate", async ({ page }) => { + const menu = await openMathMenu(page); + await menu.getByRole("menuitem", { name: "New formula…" }).click(); + const dialog = page.getByRole("dialog", { name: "New formula" }); + const expression = dialog.getByLabel("Expression"); + const create = dialog.getByRole("button", { name: "Create formula" }); + await expect(create).toBeDisabled(); + + await expression.fill("a +"); + await expect(dialog.getByRole("status")).toHaveText( + "The expression ends too early", + ); + await expression.fill("a + q"); + await expect(dialog.getByRole("status")).toHaveText( + '"q" is not one of the variables above', + ); + // Syntactically fine, but `a` has no measurement yet. + await expression.fill("a * 2"); + await expect(dialog.getByRole("status")).toHaveText( + 'Choose a measurement for "a"', + ); + await expect(create).toBeDisabled(); + + await dialog.getByRole("button", { name: "Measurement for a" }).click(); + // A single board skips the board step and lands on its measurements. + await page.getByPlaceholder("Filter…").fill("temp"); + await page.getByRole("button", { name: "temperature", exact: true }).click(); + await expect(create).toBeEnabled(); + await dialog.getByRole("combobox").click(); + await page.getByRole("option", { name: "A new plot" }).click(); + await create.click(); + + // The source was not plotted anywhere; it is loaded for the formula alone. + await expect( + pane(page, 2).getByRole("button", { name: /Math \/ Formula 1/ }), + ).toBeVisible(); +}); + +test("the measurement picker goes board first and its list scrolls", async ({ + page, +}) => { + // Two boards, one with far more measurements than fit in the list. + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const file = (board: string, name: string) => ({ + name: `${name}.csv`, + webkitRelativePath: `demo/data/${board}/${name}.csv`, + text: async () => `0,${board},backend,1\n50000,${board},backend,2`, + }); + await useStore + .getState() + .openSession([ + file("LCU", "velocity"), + ...Array.from({ length: 40 }, (_, index) => + file("BMS", `cell_${String(index).padStart(2, "0")}`), + ), + ]); + }); + const menu = await openMathMenu(page); + await menu.getByRole("menuitem", { name: "New formula…" }).click(); + const dialog = page.getByRole("dialog", { name: "New formula" }); + await dialog.getByRole("button", { name: "Measurement for a" }).click(); + await page.getByRole("button", { name: /^BMS/ }).click(); + + const last = page.getByRole("button", { name: "cell_39", exact: true }); + await expect(last).not.toBeInViewport(); + await page.getByRole("button", { name: "cell_02", exact: true }).hover(); + await page.mouse.wheel(0, 2000); + await expect(last).toBeInViewport(); + await last.click(); + await expect( + dialog.getByRole("button", { name: "Measurement for a" }), + ).toHaveText("BMS/cell_39"); +}); diff --git a/e2e/tests/logging-view/helpers.ts b/e2e/tests/logging-view/helpers.ts new file mode 100644 index 000000000..0cf9579f7 --- /dev/null +++ b/e2e/tests/logging-view/helpers.ts @@ -0,0 +1,135 @@ +import { expect, type Page } from "@playwright/test"; + +// velocity = 0, 2, 1 · position = 1, 3, 2 · temperature = 2, 4, 3, sampled at +// 0, 50 and 100 s. +export const SIGNALS = ["velocity", "position", "temperature"]; + +export async function openSession(page: Page, folder = "demo") { + await page.evaluate( + async ({ names, folder }) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + await useStore.getState().openSession( + names.map((name, index) => ({ + name: `${name}.csv`, + webkitRelativePath: `${folder}/data/LCU/${name}.csv`, + text: async () => + `0,LCU,backend,${index}\n50000,LCU,backend,${index + 2}\n100000,LCU,backend,${index + 1}`, + })), + ); + }, + { names: SIGNALS, folder }, + ); +} + +export async function gotoNormal(page: Page) { + await page.goto("/#/normal"); + await expect( + page.getByRole("heading", { name: "Open a logging session" }), + ).toBeVisible(); +} + +export const pane = (page: Page, number = 1) => + page.getByRole("region", { name: `Chart panel ${number}`, exact: true }); + +export const panes = (page: Page) => + page.getByRole("region", { name: /^Chart panel / }); + +export const paneMenu = (page: Page, number = 1) => + page.getByRole("menu", { name: `Chart ${number} actions` }); + +export const sidebar = (page: Page) => + page.locator('[data-slot="sidebar-content"]'); + +export async function openBoard(page: Page) { + const board = sidebar(page).getByRole("button", { name: /^LCU/ }); + if ((await board.getAttribute("data-state")) !== "open") await board.click(); +} + +export async function plotSignals(page: Page, names: string[], number = 1) { + await openBoard(page); + for (const name of names) + await sidebar(page).getByText(name, { exact: true }).click(); + await pane(page, number).click({ button: "right" }); + await paneMenu(page, number) + .getByRole("menuitem", { name: /^Add \d+ selected measurements?$/ }) + .click(); + // Leave nothing selected, so the next call starts clean. + for (const name of names) + await sidebar(page).getByText(name, { exact: true }).click(); +} + +export async function pickPaneItem( + page: Page, + submenu: string, + item: string, + number = 1, +) { + await pane(page, number).click({ button: "right" }); + await paneMenu(page, number) + .getByRole("menuitem", { name: submenu, exact: true }) + .click(); + await page.getByRole("menuitem", { name: item, exact: true }).click(); +} + +/** The active dashboard's settings, straight from the store. */ +export async function settings(page: Page) { + return page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + return state.normalDashboards.find( + (dashboard: { id: string }) => + dashboard.id === state.activeNormalDashboardId, + ).settings; + }); +} + +/** Runs `body` as `(current) => next` over the active dashboard's settings. */ +export async function patchSettings(page: Page, body: string) { + await page.evaluate(async (source) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + const change = new Function("current", source) as ( + current: object, + ) => object; + state.updateNormalDashboardSettings(state.activeNormalDashboardId, change); + }, body); +} + +/** The app's own ECharts instance for the first chart matching `label`. */ +export async function chartOption( + page: Page, + label = "Interactive signal chart", +) { + return page.evaluate(async (ariaLabel) => { + const modulePath = performance + .getEntriesByType("resource") + .find((entry) => /\/deps\/echarts\.js/.test(entry.name))!.name; + const echarts = await import(/* @vite-ignore */ modulePath); + const element = document.querySelector(`[aria-label="${ariaLabel}"]`); + const instance = echarts.getInstanceByDom(element); + const xAxis = instance.getModel().getComponent("xAxis", 0).axis; + return { + xType: xAxis.type as string, + xExtent: xAxis.scale.getExtent() as [number, number], + seriesLengths: (instance.getOption().series as { data: unknown[] }[]).map( + (series) => series.data.length, + ), + }; + }, label); +} + +export async function readDownload( + page: Page, + trigger: () => Promise, +): Promise<{ name: string; bytes: Buffer }> { + const download = page.waitForEvent("download"); + await trigger(); + const file = await download; + const stream = await file.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + return { name: file.suggestedFilename(), bytes: Buffer.concat(chunks) }; +} diff --git a/e2e/tests/logging-view/interaction.test.ts b/e2e/tests/logging-view/interaction.test.ts new file mode 100644 index 000000000..cd4596f47 --- /dev/null +++ b/e2e/tests/logging-view/interaction.test.ts @@ -0,0 +1,465 @@ +import { expect, test, type Page } from "@playwright/test"; + +// velocity = 0, 2, 1 · position = 1, 3, 2, sampled at the same three instants. +async function openSession(page: Page) { + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + await useStore.getState().openSession( + ["velocity", "position"].map((name, index) => ({ + name: `${name}.csv`, + webkitRelativePath: `demo/data/LCU/${name}.csv`, + text: async () => + `0,LCU,backend,${index}\n50000,LCU,backend,${index + 2}\n100000,LCU,backend,${index + 1}`, + })), + ); + }); +} + +const pane = (page: Page, number = 1) => + page.getByRole("region", { name: `Chart panel ${number}`, exact: true }); + +const paneMenu = (page: Page, number = 1) => + page.getByRole("menu", { name: `Chart ${number} actions` }); + +const chart = (page: Page) => + page.getByLabel("Interactive signal chart").first(); + +async function plotSignals(page: Page, names: string[]) { + const sidebar = page.locator('[data-slot="sidebar-content"]'); + const board = sidebar.getByRole("button", { name: /^LCU/ }); + if ((await board.getAttribute("data-state")) !== "open") await board.click(); + for (const name of names) + await sidebar.getByText(name, { exact: true }).click(); + await pane(page).click({ button: "right" }); + await paneMenu(page) + .getByRole("menuitem", { name: /^Add \d+ selected measurements?$/ }) + .click(); + await expect(page.locator("canvas")).toHaveCount(1); +} + +// The active dashboard's settings, straight from the store. +async function settings(page: Page) { + return page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + return state.normalDashboards.find( + (dashboard: { id: string }) => + dashboard.id === state.activeNormalDashboardId, + ).settings; + }); +} + +// Extent of the first y axis as ECharts drew it. +async function yExtent(page: Page): Promise<[number, number]> { + return page.evaluate(async () => { + // The exact URL the app loaded, hash included: any other one would be a + // second copy of ECharts that knows nothing about the app's charts. + const modulePath = performance + .getEntriesByType("resource") + .find((entry) => /\/deps\/echarts\.js/.test(entry.name))!.name; + const echarts = await import(/* @vite-ignore */ modulePath); + const element = document.querySelector( + '[aria-label="Interactive signal chart"]', + ); + const instance = echarts.getInstanceByDom(element); + return instance.getModel().getComponent("yAxis", 0).axis.scale.getExtent(); + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/#/normal"); + await expect( + page.getByRole("heading", { name: "Open a logging session" }), + ).toBeVisible(); + await openSession(page); +}); + +test("right-clicking a plot at the bottom of the window only opens its menu", async ({ + page, +}) => { + await plotSignals(page, ["velocity"]); + const box = (await pane(page).boundingBox())!; + // Low enough that the menu has no room below and opens over the pointer. + await page.mouse.click(box.x + box.width / 2, box.y + box.height - 6, { + button: "right", + }); + await expect(paneMenu(page)).toBeVisible(); + // Nothing was picked by the release of that same click. + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(pane(page).getByRole("textbox")).toHaveCount(0); + await expect( + paneMenu(page).getByRole("menuitem", { name: "Layout", exact: true }), + ).toBeVisible(); + await expect( + paneMenu(page).getByRole("menuitem", { name: "View", exact: true }), + ).toBeVisible(); +}); + +test("ctrl+drag zooms to the selected stretch of time", async ({ page }) => { + await plotSignals(page, ["velocity"]); + expect((await settings(page)).range).toEqual({ start: 0, end: 100 }); + const box = (await chart(page).boundingBox())!; + const y = box.y + box.height / 2; + await page.keyboard.down("Control"); + await page.mouse.move(box.x + box.width * 0.4, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.5, y, { steps: 4 }); + await page.mouse.move(box.x + box.width * 0.7, y, { steps: 4 }); + await page.mouse.up(); + await page.keyboard.up("Control"); + await expect + .poll(async () => (await settings(page)).range.end) + .toBeLessThan(90); + const { start, end } = (await settings(page)).range; + expect(start).toBeGreaterThan(10); + expect(end - start).toBeGreaterThan(15); + expect(end - start).toBeLessThan(60); +}); + +test("fit Y scales the axis to the visible window", async ({ page }) => { + await plotSignals(page, ["velocity"]); + // Second half of the recording: velocity only moves between 2 and 1. + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + state.updateNormalDashboardSettings( + state.activeNormalDashboardId, + (current: object) => ({ ...current, range: { start: 50, end: 100 } }), + ); + }); + await expect.poll(async () => (await settings(page)).range.start).toBe(50); + expect((await yExtent(page))[0]).toBeLessThanOrEqual(0); + + await pane(page).click({ button: "right" }); + await paneMenu(page) + .getByRole("menuitem", { name: "View", exact: true }) + .click(); + await page + .getByRole("menuitemcheckbox", { name: "Fit Y to window", exact: true }) + .click(); + await expect.poll(async () => (await yExtent(page))[0]).toBeGreaterThan(0.5); + const [min, max] = await yExtent(page); + expect(min).toBeLessThan(1); + expect(max).toBeGreaterThan(2); + expect(max).toBeLessThan(2.5); +}); + +const readout = (page: Page) => + page.getByRole("region", { name: "Cursor measurements" }); + +test("cursors measure the time and value between two clicks", async ({ + page, +}) => { + await plotSignals(page, ["velocity"]); + await page.getByRole("button", { name: "Plot overlays" }).click(); + await page + .getByRole("menuitemcheckbox", { name: "Cursors", exact: true }) + .click(); + await page.keyboard.press("Escape"); + await expect(readout(page)).toContainText("Click a plot to place A"); + + const box = (await chart(page).boundingBox())!; + const y = box.y + box.height / 2; + await page.mouse.click(box.x + box.width * 0.3, y); + await expect + .poll(async () => (await settings(page)).cursors.a) + .not.toBeNull(); + await page.keyboard.down("Shift"); + await page.mouse.click(box.x + box.width * 0.7, y); + await page.keyboard.up("Shift"); + await expect + .poll(async () => (await settings(page)).cursors.b) + .not.toBeNull(); + + // Dragging pans (nothing to pan at full zoom) but must not move a cursor. + const placed = (await settings(page)).cursors; + await page.mouse.move(box.x + box.width * 0.5, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.6, y, { steps: 4 }); + await page.mouse.up(); + expect((await settings(page)).cursors).toEqual(placed); + + // Pin both to known instants: velocity is 0 → 2 → 1 over 0, 50 and 100 s. + await page.evaluate(async () => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + state.updateNormalDashboardSettings( + state.activeNormalDashboardId, + (current: object) => ({ ...current, cursors: { a: 25_000, b: 75_000 } }), + ); + }); + await expect(readout(page).getByLabel("Cursor A")).toHaveText("25 s"); + await expect(readout(page).getByLabel("Cursor B")).toHaveText("75 s"); + await expect(readout(page).getByLabel("Cursor Δt")).toHaveText("50 s"); + await expect(readout(page).getByLabel("Cursor 1/Δt")).toHaveText("0.02 Hz"); + const row = readout(page).getByRole("row").filter({ hasText: "velocity" }); + // At A = 1, at B = 1.5, Δ = 0.5, slope = 0.01 per second. + await expect(row.getByRole("cell")).toHaveText([ + /.*/, + "1", + "1.5", + "0.5", + "0.01", + ]); + + await readout(page).getByRole("button", { name: "Clear cursors" }).click(); + expect((await settings(page)).cursors).toEqual({ a: null, b: null }); +}); + +test("keyboard shortcuts drive the dashboard and stay out of text fields", async ({ + page, +}) => { + await plotSignals(page, ["velocity"]); + const box = (await chart(page).boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + await page.keyboard.press("+"); + await expect + .poll(async () => (await settings(page)).range) + .toEqual({ start: 25, end: 75 }); + await page.keyboard.press("ArrowRight"); + await expect + .poll(async () => (await settings(page)).range) + .toEqual({ start: 35, end: 85 }); + await page.keyboard.press("-"); + await page.keyboard.press("0"); + await expect + .poll(async () => (await settings(page)).range) + .toEqual({ start: 0, end: 100 }); + + await page.keyboard.press("s"); + await expect( + page.getByRole("region", { name: "Visible window statistics" }), + ).toBeVisible(); + await page.keyboard.press("c"); + await expect(readout(page)).toBeVisible(); + await page.keyboard.press("l"); + expect((await settings(page)).linkTimeAxis).toBe(false); + await page.keyboard.press("l"); + expect((await settings(page)).linkTimeAxis).toBe(true); + + await page.keyboard.press("n"); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + // The pointer is still over the first plot, so F and Y act on it. + await page.mouse.move(box.x + 40, box.y + 40); + await page.keyboard.press("f"); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 1, + ); + await page.keyboard.press("y"); + await expect + .poll(async () => { + const { layoutState, focusedId } = await settings(page); + const find = (node: any): any => + node.type === "pane" + ? node.id === focusedId + ? node + : null + : (find(node.first) ?? find(node.second)); + return find(layoutState.tree)?.fitY; + }) + .toBe(true); + await page.keyboard.press("Escape"); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + + // Typing in a field is just typing. + await page.keyboard.press("m"); + const dialog = page.getByRole("dialog", { name: "New formula" }); + await dialog.getByLabel("Name", { exact: true }).fill("scn0-+"); + await expect(dialog.getByLabel("Name", { exact: true })).toHaveValue( + "scn0-+", + ); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(page.getByRole("region", { name: /^Chart panel / })).toHaveCount( + 2, + ); + expect((await settings(page)).showStatistics).toBe(true); + + await page.keyboard.press("?"); + const help = page.getByRole("dialog", { name: "Keyboard shortcuts" }); + await expect(help).toContainText("Zoom in"); + await expect(help).toContainText("Measuring cursors"); +}); + +const panes = (page: Page) => + page.getByRole("region", { name: /^Chart panel / }); + +async function splitFirstPlot(page: Page) { + await pane(page).click({ button: "right" }); + await paneMenu(page) + .getByRole("menuitem", { name: "Layout", exact: true }) + .click(); + await page + .getByRole("menuitem", { name: "Split into columns", exact: true }) + .click(); + await expect(panes(page)).toHaveCount(2); +} + +test("reopening a session brings its dashboards back", async ({ page }) => { + await plotSignals(page, ["velocity"]); + await splitFirstPlot(page); + await page + .getByRole("button", { name: "New dashboard", exact: true }) + .click(); + await expect(page.getByRole("tab")).toHaveCount(2); + await page.getByRole("tab", { name: "Dashboard", exact: true }).click(); + await expect + .poll(() => + page.evaluate(() => + (localStorage.getItem("logging-view:dashboards:demo") ?? "").includes( + "LCU/velocity", + ), + ), + ) + .toBe(true); + + // A full reload: nothing survives in memory. + await page.reload(); + await expect( + page.getByRole("heading", { name: "Open a logging session" }), + ).toBeVisible(); + await openSession(page); + await expect(page.getByRole("tab")).toHaveCount(2); + await expect(panes(page)).toHaveCount(2); + await expect(page.locator("canvas")).toHaveCount(1); + expect((await settings(page)).layoutState.ids).toEqual(["LCU/velocity"]); +}); + +async function importFromMenu(page: Page) { + await page + .getByRole("button", { name: "New dashboard from a template" }) + .click(); + await page + .getByRole("menuitem", { name: "Import dashboard…", exact: true }) + .click(); +} + +test("a dashboard survives a round trip through a file", async ({ + page, +}, testInfo) => { + await plotSignals(page, ["velocity"]); + await splitFirstPlot(page); + const tab = page.getByRole("tab", { name: "Dashboard", exact: true }); + const tabMenu = page.getByRole("menu", { name: "Dashboard tab actions" }); + + await tab.click({ button: "right" }); + const download = page.waitForEvent("download"); + await tabMenu + .getByRole("menuitem", { name: "Export dashboard…", exact: true }) + .click(); + const file = await download; + expect(file.suggestedFilename()).toBe("Dashboard.dashboard.json"); + const path = testInfo.outputPath("Dashboard.dashboard.json"); + await file.saveAs(path); + + const chooser = page.waitForEvent("filechooser"); + await importFromMenu(page); + await (await chooser).setFiles(path); + await expect(page.getByRole("tab")).toHaveCount(2); + await expect( + page.getByRole("tab", { name: "Dashboard (2)", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(panes(page)).toHaveCount(2); + await expect(page.locator("canvas")).toHaveCount(1); + + // Anything else is refused with a message and no new tab. + const second = page.waitForEvent("filechooser"); + await importFromMenu(page); + await ( + await second + ).setFiles({ + name: "notes.json", + mimeType: "application/json", + buffer: Buffer.from('{"hello":"world"}'), + }); + await expect(page.getByRole("alert")).toContainText( + "not an exported dashboard", + ); + await expect(page.getByRole("tab")).toHaveCount(2); +}); + +async function patchSettings(page: Page, script: string) { + await page.evaluate(async (body) => { + const modulePath = "/src/store/store.ts"; + const { useStore } = await import(/* @vite-ignore */ modulePath); + const state = useStore.getState(); + const change = new Function("current", body) as (current: object) => object; + state.updateNormalDashboardSettings(state.activeNormalDashboardId, change); + }, script); +} + +test("a cursor follows the pointer when dragged", async ({ page }) => { + await plotSignals(page, ["velocity"]); + await patchSettings( + page, + "return { ...current, showCursors: true, cursors: { a: 50000, b: null } };", + ); + await expect(readout(page).getByLabel("Cursor A")).toHaveText("50 s"); + const box = (await chart(page).boundingBox())!; + const y = box.y + box.height / 2; + // Find the line: click-free, through the same maths the chart uses. + const lineX = await page.evaluate(async () => { + const modulePath = performance + .getEntriesByType("resource") + .find((entry) => /\/deps\/echarts\.js/.test(entry.name))!.name; + const echarts = await import(/* @vite-ignore */ modulePath); + const element = document.querySelector( + '[aria-label="Interactive signal chart"]', + )!; + return ( + element.getBoundingClientRect().left + + echarts.getInstanceByDom(element).convertToPixel({ xAxisIndex: 0 }, 50) + ); + }); + await page.mouse.move(lineX, y); + await page.mouse.down(); + await page.mouse.move(lineX + 60, y, { steps: 5 }); + await page.mouse.move(lineX + 120, y, { steps: 5 }); + await page.mouse.up(); + await expect + .poll(async () => (await settings(page)).cursors.a) + .toBeGreaterThan(55_000); + // It was a cursor drag, not a pan or a second cursor. + const after = await settings(page); + expect(after.cursors.b).toBeNull(); + expect(after.range).toEqual({ start: 0, end: 100 }); +}); + +test("comma and period step cursor A through the markers", async ({ page }) => { + await plotSignals(page, ["velocity"]); + await patchSettings( + page, + `const mark = (node) => node.type === "pane" + ? { ...node, markers: [ + { id: "m1", label: "One", timeMs: 20000 }, + { id: "m2", label: "Two", timeMs: 90000 } ] } + : { ...node, first: mark(node.first), second: mark(node.second) }; + return { ...current, range: { start: 0, end: 40 }, + layoutState: { ...current.layoutState, tree: mark(current.layoutState.tree) } };`, + ); + const box = (await chart(page).boundingBox())!; + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + + // From the middle of the window (20 s) the next marker is the one at 90 s, + // which is off screen: the window moves to show it. + await page.keyboard.press("."); + await expect.poll(async () => (await settings(page)).cursors.a).toBe(90_000); + const moved = await settings(page); + expect(moved.showCursors).toBe(true); + expect(moved.range).toEqual({ start: 60, end: 100 }); + await page.keyboard.press(","); + await expect.poll(async () => (await settings(page)).cursors.a).toBe(20_000); + expect((await settings(page)).range).toEqual({ start: 0, end: 40 }); + // Nothing before the first one. + await page.keyboard.press(","); + expect((await settings(page)).cursors.a).toBe(20_000); +}); diff --git a/electron-app/preload.js b/electron-app/preload.js index d5606a8f0..a4d5debf6 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -39,6 +39,11 @@ contextBridge.exposeInMainWorld("electronAPI", { selectFolder: () => ipcRenderer.invoke("select-folder"), // Open a folder path in the OS file explorer openFolder: (path) => ipcRenderer.invoke("open-folder", path), + // Logging View: list and read a session folder by path, so a recently used + // session can be reopened without going through the folder picker + listSessionFiles: (dir) => ipcRenderer.invoke("list-session-files", dir), + readSessionFile: (dir, relativePath) => + ipcRenderer.invoke("read-session-file", dir, relativePath), // BLCU: open native file picker and return the selected path blcuSelectFile: () => ipcRenderer.invoke("blcu-select-file"), // BLCU: read a file from disk and return its contents as a Buffer diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 66f55b32e..f16fce3f7 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -9,9 +9,9 @@ import { app, dialog, ipcMain, shell } from "electron"; import { execFile } from "child_process"; -import { readFile } from "fs/promises"; +import { readFile, readdir, stat } from "fs/promises"; import fs from "fs"; -import { isAbsolute, join } from "path"; +import { basename, isAbsolute, join, resolve, sep } from "path"; import { promisify } from "util"; import { importConfig, @@ -234,6 +234,98 @@ function setupIpcHandlers() { } }); + // Logging View session folders. The renderer's folder picker only yields + // browser File objects, so reopening a recent session from its path needs + // the main process to list and read it. + + // Guard: these two read arbitrary paths, so only the window that actually + // hosts the views may call them (same spirit as the About window's check). + const assertSessionSender = (event) => { + const mainWindow = getMainWindow(); + if (!mainWindow || event.sender !== mainWindow.webContents) { + throw new Error("Session file IPC is restricted to the main window"); + } + }; + + // A session is /logger_settings.json, /data//*.csv and + // /order/order.csv — exactly what the renderer's openSession reads. + // Walking only those three shapes keeps a huge parent folder from being + // scanned, and Dirent.isDirectory()/isFile() skip symlinks. + const SESSION_FILE_LIMIT = 4000; + + const listSessionEntries = async (dir) => { + const entries = []; + if (fs.existsSync(join(dir, "logger_settings.json"))) { + entries.push("logger_settings.json"); + } + if (fs.existsSync(join(dir, "order", "order.csv"))) { + entries.push("order/order.csv"); + } + let boards = []; + try { + boards = await readdir(join(dir, "data"), { withFileTypes: true }); + } catch { + return entries; + } + for (const board of boards) { + if (!board.isDirectory()) continue; + const files = await readdir(join(dir, "data", board.name), { + withFileTypes: true, + }); + for (const file of files) { + if (!file.isFile() || !file.name.endsWith(".csv")) continue; + entries.push(`data/${board.name}/${file.name}`); + if (entries.length >= SESSION_FILE_LIMIT) return entries; + } + } + return entries; + }; + + /** + * @event list-session-files + * @async + * @description Lists the files a logging session folder is made of. + * @param {import("electron").IpcMainInvokeEvent} event - The IPC event object. + * @param {string} dir - Absolute path of the session folder. + * @returns {Promise<{folderName: string, entries: string[]}>} Folder basename + * plus slash-separated paths relative to it. + * @throws {Error} If the path is not an absolute existing directory. + */ + ipcMain.handle("list-session-files", async (event, dir) => { + assertSessionSender(event); + if (typeof dir !== "string" || !isAbsolute(dir)) { + throw new Error("A session folder must be given as an absolute path"); + } + const stats = await stat(dir); + if (!stats.isDirectory()) { + throw new Error(`Not a folder: ${dir}`); + } + return { + folderName: basename(dir) || "Session", + entries: await listSessionEntries(dir), + }; + }); + + /** + * @event read-session-file + * @async + * @description Reads one file of a session folder as UTF-8 text. + * @param {import("electron").IpcMainInvokeEvent} event - The IPC event object. + * @param {string} dir - Absolute path of the session folder. + * @param {string} relativePath - Path inside that folder, as listed. + * @returns {Promise} + * @throws {Error} If the path would escape the session folder. + */ + ipcMain.handle("read-session-file", async (event, dir, relativePath) => { + assertSessionSender(event); + const root = resolve(dir); + const target = resolve(root, relativePath); + if (target !== root && !target.startsWith(root + sep)) { + throw new Error("Refusing to read outside the session folder"); + } + return await readFile(target, "utf-8"); + }); + // BLCU — only the file picker runs through IPC; all API calls go directly // from the renderer to http://localhost:8000. diff --git a/frontend/frontend-kit/kit/src/icons/arrows.ts b/frontend/frontend-kit/kit/src/icons/arrows.ts index 573ea82bb..38abf9ac8 100644 --- a/frontend/frontend-kit/kit/src/icons/arrows.ts +++ b/frontend/frontend-kit/kit/src/icons/arrows.ts @@ -8,6 +8,7 @@ export { ExternalLink, Play, RefreshCw, + RotateCcw, TrendingDown, TrendingUp, } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/design.ts b/frontend/frontend-kit/kit/src/icons/design.ts index 74faef01d..02a159a9b 100644 --- a/frontend/frontend-kit/kit/src/icons/design.ts +++ b/frontend/frontend-kit/kit/src/icons/design.ts @@ -1,2 +1,2 @@ -export { Battery, Layout, LayoutGrid, Palette, Pencil } from "lucide-react"; +export { Battery, Layout, LayoutGrid, Palette, Pencil, Ruler } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/files.ts b/frontend/frontend-kit/kit/src/icons/files.ts index aaf3110e1..a604ec030 100644 --- a/frontend/frontend-kit/kit/src/icons/files.ts +++ b/frontend/frontend-kit/kit/src/icons/files.ts @@ -1,6 +1,8 @@ export { + Copy, FileCode2, FileDown, + FileSpreadsheet, Folder, FolderOpen, Trash2, diff --git a/frontend/frontend-kit/kit/src/icons/index.ts b/frontend/frontend-kit/kit/src/icons/index.ts index ef1b8f4c5..d39eed902 100644 --- a/frontend/frontend-kit/kit/src/icons/index.ts +++ b/frontend/frontend-kit/kit/src/icons/index.ts @@ -10,6 +10,7 @@ export * from "./devices"; export * from "./files"; export * from "./gaming"; export * from "./layout"; +export * from "./links"; export * from "./mail"; export * from "./math"; export * from "./notifications"; diff --git a/frontend/frontend-kit/kit/src/icons/layout.ts b/frontend/frontend-kit/kit/src/icons/layout.ts index c2d3646ba..993f5c90a 100644 --- a/frontend/frontend-kit/kit/src/icons/layout.ts +++ b/frontend/frontend-kit/kit/src/icons/layout.ts @@ -1,7 +1,16 @@ export { + ChartNoAxesCombined, + ChartScatter, Columns, Filter, GripVertical, + Grid2X2, + LayoutDashboard, + Maximize2, + Minimize2, PanelBottom, PanelRight, + PanelTopClose, + PanelTopOpen, + Rows2, } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/links.ts b/frontend/frontend-kit/kit/src/icons/links.ts new file mode 100644 index 000000000..4ea82dce9 --- /dev/null +++ b/frontend/frontend-kit/kit/src/icons/links.ts @@ -0,0 +1 @@ +export { Link2, Link2Off } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/math.ts b/frontend/frontend-kit/kit/src/icons/math.ts index 583bec966..7063e0f8b 100644 --- a/frontend/frontend-kit/kit/src/icons/math.ts +++ b/frontend/frontend-kit/kit/src/icons/math.ts @@ -1 +1 @@ -export { Plus } from "lucide-react"; +export { Calculator, Minus, Plus } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/notifications.ts b/frontend/frontend-kit/kit/src/icons/notifications.ts index 0e371e227..553ca4062 100644 --- a/frontend/frontend-kit/kit/src/icons/notifications.ts +++ b/frontend/frontend-kit/kit/src/icons/notifications.ts @@ -3,6 +3,7 @@ export { AlertTriangle, Check, CheckCircle2, + Clock, X, XCircle, } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/photography.ts b/frontend/frontend-kit/kit/src/icons/photography.ts index 9c0d5dd9e..546dffe4d 100644 --- a/frontend/frontend-kit/kit/src/icons/photography.ts +++ b/frontend/frontend-kit/kit/src/icons/photography.ts @@ -1 +1 @@ -export { Camera } from "lucide-react"; +export { Camera, ImageDown } from "lucide-react"; diff --git a/frontend/frontend-kit/kit/src/icons/text.ts b/frontend/frontend-kit/kit/src/icons/text.ts index 6b39d55ea..38e55658d 100644 --- a/frontend/frontend-kit/kit/src/icons/text.ts +++ b/frontend/frontend-kit/kit/src/icons/text.ts @@ -1 +1 @@ -export { Keyboard, List, ListFilterPlus, SquareLibrary } from "lucide-react"; +export { Clipboard, Keyboard, List, ListFilterPlus, Redo2, SquareLibrary, Undo2 } from "lucide-react"; diff --git a/frontend/logging-view/eslint.config.js b/frontend/logging-view/eslint.config.js index b38f6843f..70c95417e 100644 --- a/frontend/logging-view/eslint.config.js +++ b/frontend/logging-view/eslint.config.js @@ -1,3 +1,26 @@ import { config } from "@hyperloopupv/eslint-config/vite"; -export default config; +export default [ + ...config, + { + // Icons (and any other shared primitive) must come from the kit, so the + // catalogue stays the single place where they are curated. The legacy + // vanilla app in src/base/ is not built and is exempt. + files: ["src/**/*.{ts,tsx}"], + ignores: ["src/base/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "lucide-react", + message: + "Import icons from @hyperloopupv/frontend-kit/icons. Add a missing icon with `pnpm icon:add src/icons` in frontend/frontend-kit/kit.", + }, + ], + }, + ], + }, + }, +]; diff --git a/frontend/logging-view/package.json b/frontend/logging-view/package.json index bc817b5a7..d5f03ee94 100644 --- a/frontend/logging-view/package.json +++ b/frontend/logging-view/package.json @@ -17,6 +17,7 @@ "@tanstack/react-virtual": "^3.13.18", "@types/plotly.js": "^3.0.10", "@vitejs/plugin-react-swc": "^4.2.3", + "echarts": "^6.1.0", "@hyperloopupv/frontend-kit": "workspace:*", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", diff --git a/frontend/logging-view/src/App.tsx b/frontend/logging-view/src/App.tsx index df09f26a1..9d4a53356 100644 --- a/frontend/logging-view/src/App.tsx +++ b/frontend/logging-view/src/App.tsx @@ -1,8 +1,7 @@ // Root component. Wraps all pages in the shared AppLayout (sidebar + header). // The active view mode (Normal / Simple) is encoded in the URL so each mode -// can have a completely independent UI. Navigating to "/" redirects to -// "/simple" — Simple (Plot Studio) is the default mode for now because -// Normal mode isn't developed yet; switch this back to "/normal" once it is. +// can have a completely independent UI. Normal is the default analysis view; +// Simple remains the plot studio for publication and PDF export. import { Navigate, Route, Routes } from "react-router"; import AppLayout from "./layout/AppLayout"; import NormalPage from "./pages/NormalPage"; @@ -12,7 +11,7 @@ const App = () => { return ( - } /> + } /> } /> } /> diff --git a/frontend/logging-view/src/components/KeyboardShortcutsHelp.tsx b/frontend/logging-view/src/components/KeyboardShortcutsHelp.tsx index 9a55167cc..168c1b4e5 100644 --- a/frontend/logging-view/src/components/KeyboardShortcutsHelp.tsx +++ b/frontend/logging-view/src/components/KeyboardShortcutsHelp.tsx @@ -1,58 +1,81 @@ import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, - Separator, } from "@hyperloopupv/frontend-kit/components"; import { Keyboard } from "@hyperloopupv/frontend-kit/icons"; -// NOTE: the original hooks module was not available at this path in some -// environments. Define a local fallback for SHORTCUT_DEFS here so this -// component compiles and still shows useful keyboard shortcuts. Keep the -// shape compatible with the original usage: { key, label, description }. -const SHORTCUT_DEFS: { key: string; label: string; description: string }[] = [ - { key: "help", label: "?", description: "Open keyboard shortcuts" }, - { key: "focus-search", label: "/", description: "Focus search input" }, - { key: "new-entry", label: "n", description: "Create new log entry" }, -]; +import { NORMAL_GESTURES, NORMAL_SHORTCUTS } from "../lib/normal/shortcuts"; interface KeyboardShortcutsHelpProps { open: boolean; onOpenChange: (open: boolean) => void; } -/** - * Modal dialog listing all keyboard shortcuts. - * Opened via the header button or by pressing '?'. - */ -const KeyboardShortcutsHelp = ({ open, onOpenChange }: KeyboardShortcutsHelpProps) => ( +const GROUPS = ["Time", "Plots", "Panels", "General"] as const; + +const Key = ({ children }: { children: string }) => ( + + {children} + +); + +/** Lists the Normal mode shortcuts. Opened with `?` or the footer button. */ +const KeyboardShortcutsHelp = ({ + open, + onOpenChange, +}: KeyboardShortcutsHelpProps) => ( - + - Keyboard Shortcuts + Keyboard shortcuts + + Disabled while typing in a field or while a menu or dialog is open. + - - -
    - {SHORTCUT_DEFS.map(({ key, label, description }) => ( -
  • - {description} - - {label} - -
  • +
    + {GROUPS.map((group) => ( +
    +

    + {group} +

    +
      + {NORMAL_SHORTCUTS.filter( + (shortcut) => shortcut.group === group, + ).map(({ id, label, description }) => ( +
    • + {description} + {label} +
    • + ))} +
    +
    ))} -
- - - -

- Shortcuts are disabled while typing in input fields. -

+
+

+ Mouse +

+
    + {NORMAL_GESTURES.map(({ label, description }) => ( +
  • + {description} + {label} +
  • + ))} +
+
+
); diff --git a/frontend/logging-view/src/components/header/Header.tsx b/frontend/logging-view/src/components/header/Header.tsx index bc7644668..b1a49296a 100644 --- a/frontend/logging-view/src/components/header/Header.tsx +++ b/frontend/logging-view/src/components/header/Header.tsx @@ -1,9 +1,12 @@ // Top bar with the page title and a Normal / Simple mode segmented control. // Mode switching navigates between routes (/normal, /simple) rather than // store state, since each mode has a distinct UI. -import { SegmentedControl } from "@hyperloopupv/frontend-kit/components"; +import { Button, SegmentedControl } from "@hyperloopupv/frontend-kit/components"; +import { X } from "@hyperloopupv/frontend-kit/icons"; import { useLocation, useNavigate } from "react-router"; -import logo from "@hyperloopupv/frontend-kit/outreach/h11/h11-isotipo_black.svg"; +import logo from "@hyperloopupv/frontend-kit/outreach/main/logo_icon.svg"; +import { useStore } from "../../store/store"; +import { useSeriesSelection } from "../../hooks/useSeriesSelection"; type ViewMode = "normal" | "simple"; @@ -16,13 +19,51 @@ const Header = () => { const navigate = useNavigate(); const { pathname } = useLocation(); const activeMode: ViewMode = pathname.startsWith("/simple") ? "simple" : "normal"; + const folderName = useStore((s) => s.folderName); + const dashboardName = useStore( + (s) => + s.normalDashboards.find((d) => d.id === s.activeNormalDashboardId)?.name, + ); + const { selectedSeries, clearSelectedSeries, isSelectionLocked } = useSeriesSelection(); + const selectedCount = Object.values(selectedSeries).filter(Boolean).length; + // The title always names the app; the session context lives in the subtitle, + // led by the active dashboard so the top bar says which tab you are in. + const hasNormalSession = activeMode === "normal" && !!folderName; + const sessionLine = [dashboardName, folderName].filter(Boolean).join(" · "); return (
- Hyperloop UPV -

Logging View

+ + Hyperloop UPV + +
+

+ Logging View +

+ {hasNormalSession && ( +

+ {sessionLine} · {selectedCount} selected · shared session time axis +

+ )} +
-
+
+ {hasNormalSession && selectedCount > 0 && ( + + )} void; + domain: TimeDomain; + range: TimeRange; + linkTimeAxis: boolean; + enumAsBackground: boolean; + timeUnit: TimeUnit; + isDarkMode: boolean; + syncGroup: string; + canSplit: boolean; + canClose: boolean; + focused: boolean; + // null puts the pane back on the dashboard window. + onRangeChange: (paneId: string, range: TimeRange | null) => void; + onSplit: (id: string, direction: "horizontal" | "vertical") => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; + onChange: ( + id: string, + changes: Partial< + Pick< + ChartPane, + | "title" + | "signalIds" + | "normalized" + | "fitY" + | "xSignalId" + | "hiddenSignalIds" + | "enumDisplay" + | "markers" + > + >, + ) => void; + onUnitChange: (id: string, unit: string) => void; +} + +function ComparisonPane({ + dashboardId, + locked, + node, + index, + allSignals, + formulas, + orders, + showOrders, + cursors, + onCursorAt, + domain, + range, + linkTimeAxis, + enumAsBackground, + timeUnit, + isDarkMode, + syncGroup, + canSplit, + canClose, + focused, + onRangeChange, + onSplit, + onClose, + onFocus, + onChange, + onUnitChange, +}: PaneProps) { + const { setNodeRef, isOver } = useDroppable({ + id: `normal-plot:${node.id}`, + disabled: locked, + data: { kind: "normal-plot", dashboardId, plotId: node.id }, + }); + const signals = useMemo( + () => allSignals.filter((signal) => node.signalIds.includes(signal.id)), + [allSignals, node.signalIds], + ); + const [signalQuery, setSignalQuery] = useState(""); + const [configOpen, setConfigOpen] = useState(false); + const [markerOpen, setMarkerOpen] = useState(false); + const [markerTime, setMarkerTime] = useState(""); + const [markerLabel, setMarkerLabel] = useState(""); + const [clickedMarkerTime, setClickedMarkerTime] = useState( + null, + ); + const [clickedMarkerLabel, setClickedMarkerLabel] = useState(""); + // Set while the context menu is closing *because* it opened the config + // popover, so the menu's focus restore does not dismiss it (see below). + const reopenConfigRef = useRef(false); + const menuOpenedAtRef = useRef(0); + const setActivePane = useStore((state) => state.setNormalActivePaneId); + const active = useStore( + (state) => + state.activeNormalDashboardId === dashboardId && + state.normalDashboards.some( + (dashboard) => + dashboard.id === dashboardId && + targetPaneId(dashboard, state.normalActivePaneId) === node.id, + ), + ); + const chartHandleRef = useRef(null); + // X‑Y: what the signals are drawn against, if not time. + const xSignal = node.xSignalId + ? allSignals.find((signal) => signal.id === node.xSignalId) + : undefined; + const xCandidates = allSignals.filter((signal) => !signal.discrete); + const setXSignal = (id: string | null) => { + if (!id) + // Back to time: the old X becomes a plotted signal again. + onChange(node.id, { + xSignalId: undefined, + signalIds: + node.xSignalId && !node.signalIds.includes(node.xSignalId) + ? [...node.signalIds, node.xSignalId] + : node.signalIds, + }); + else + onChange(node.id, { + xSignalId: id, + signalIds: node.signalIds.filter((signalId) => signalId !== id), + }); + }; + const exportName = () => + safeFileName(node.title || `Plot ${index + 1}`, "plot"); + const savePlotImage = async () => { + const blob = await chartHandleRef.current?.getImage(); + if (blob) downloadBlob(`${exportName()}__${exportTimestamp()}.png`, blob); + }; + const copyPlotImage = async () => { + const blob = await chartHandleRef.current?.getImage(); + if (blob) await copyImage(blob); + }; + const saveVisibleData = () => + downloadText( + `${exportName()}__${exportTimestamp()}.csv`, + toCsv( + visibleDataRows( + // Filtered here, not the memoized list: handing that one to another + // function makes the React Compiler give up on this component. + signals.filter( + (signal) => !node.hiddenSignalIds?.includes(signal.id), + ), + domain, + paneRange, + timeUnit, + ), + ), + "text/csv", + ); + const configContentRef = useRef(null); + const [editingTitle, setEditingTitle] = useState(false); + const { selectedSeries } = useSeriesSelection(); + const assignSignals = useStore((state) => state.assignNormalSignalsToPlot); + const openFormulaEditor = useStore((state) => state.openNormalFormulaEditor); + // Formulas of this dashboard that this plot does not show yet. They are not + // in `allSignals` until some plot uses them, so the picker lists them apart. + const otherFormulas = formulas.filter( + (formula) => !node.signalIds.includes(formula.id), + ); + const selectedIds = useMemo( + () => Object.keys(selectedSeries).filter((key) => selectedSeries[key]), + [selectedSeries], + ); + const [draftTitle, setDraftTitle] = useState( + node.title ?? `Plot ${index + 1}`, + ); + const visibleSignals = useMemo( + () => + signals.filter((signal) => !node.hiddenSignalIds?.includes(signal.id)), + [signals, node.hiddenSignalIds], + ); + const filteredSignals = allSignals.filter((signal) => + `${signal.id} ${signal.name} ${signal.units ?? ""}` + .toLowerCase() + .includes(signalQuery.trim().toLowerCase()), + ); + const enumDisplay = + node.enumDisplay ?? (enumAsBackground ? "background" : "series"); + const render = useMemo( + () => classifyPaneSignals(visibleSignals, enumDisplay), + [visibleSignals, enumDisplay], + ); + const hasEnum = visibleSignals.some((signal) => signal.discrete); + // Only the states this enum actually takes, so a 20-variant enum does not + // print 20 swatches. + const bandVariants = useMemo(() => { + const band = render.band; + if (!band) return []; + const codes = new Set(); + for (const value of band.data.value) { + codes.add(Math.round(value)); + if (codes.size > 12) break; + } + return [...codes] + .sort((a, b) => a - b) + .map((code) => ({ + code, + label: band.enumLabels?.[code] ?? String(code), + })); + }, [render.band]); + // Only signals that actually take a y axis count: an enum drawn as a + // background band takes none, so it must not push the panel into + // relative-trend mode. + const tooManyUnits = new Set(render.plotted.map(axisKey)).size > 2; + const normalized = + render.mode === "lines" && (node.normalized || tooManyUnits); + const title = node.title || `Plot ${index + 1}`; + // While linked every pane shows the dashboard window; unlinked, a pane keeps + // its own only once it has actually been moved. + const paneRange = linkTimeAxis ? range : (node.timeWindow ?? range); + const defaultMarkerTime = + ((domain.endMs - domain.startMs) * (paneRange.start + paneRange.end)) / 200; + const pinned = !linkTimeAxis && !!node.timeWindow; + const windowLabel = + `${formatWindowTime(paneRange.start, domain, timeUnit)}` + + `–${formatWindowTime(paneRange.end, domain, timeUnit)} ${timeUnit}`; + const saveTitle = () => { + const next = draftTitle.trim().slice(0, 80); + if (next && next !== title) onChange(node.id, { title: next }); + else setDraftTitle(title); + setEditingTitle(false); + }; + + const newFormulaFromPlot = () => + openFormulaEditor({ + paneId: node.id, + sourceIds: signals + .filter((signal) => !signal.formula && !signal.discrete) + .map((signal) => signal.id), + }); + + const startRename = () => { + setDraftTitle(title); + setEditingTitle(true); + }; + + return ( + { + if (open) menuOpenedAtRef.current = performance.now(); + }} + > + +
event.stopPropagation()} + className={`border-border/80 flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border bg-white shadow-sm transition-[border-color,box-shadow] dark:bg-[#21252c] ${isOver ? "border-primary ring-primary/25 ring-2" : ""}`} + aria-label={`Chart panel ${index + 1}`} + data-pane-id={node.id} + data-active={active || undefined} + onPointerDownCapture={() => setActivePane(node.id)} + > +
+ + {String(index + 1).padStart(2, "0")} + + {node.xSignalId && ( + + X: {xSignal ? xSignal.name : "loading…"} + + )} + {!linkTimeAxis && ( + + {windowLabel} + + )} + {editingTitle ? ( + setDraftTitle(event.target.value)} + onBlur={saveTitle} + onKeyDown={(event) => { + if (event.key === "Enter") saveTitle(); + if (event.key === "Escape") { + setDraftTitle(title); + setEditingTitle(false); + } + }} + /> + ) : ( + + )} +
+ { + setMarkerOpen(open); + if (open) { + setMarkerTime( + (defaultMarkerTime / TIME_DIVISORS[timeUnit]).toString(), + ); + setMarkerLabel(""); + } + }} + > + + + + +

Add marker

+

+ Markers stay attached to this chart. +

+ + + + {node.markers && node.markers.length > 0 && ( +
+ {node.markers.map((marker) => ( +
+ + {marker.label} + + + {( + marker.timeMs / TIME_DIVISORS[timeUnit] + ).toLocaleString()} + + +
+ ))} +
+ )} +
+
+ + + + + + + event.preventDefault()} + > +
+

Signals & units

+

+ Choose signals to compare in this panel. +

+
+
+ +
+
+ setSignalQuery(event.target.value)} + placeholder="Search signals, boards or units…" + aria-label={`Search signals for chart ${index + 1}`} + className="h-8 text-xs" + /> +
+
+ {filteredSignals.length === 0 && ( +

+ No matching signals. +

+ )} + {filteredSignals.map((signal) => { + const checked = node.signalIds.includes(signal.id); + // Refuse the mix up front rather than after the fact. + const blocked = + !checked && !canMixSignals(signals, [signal]); + return ( +
+ + {checked && !signal.discrete && ( + + )} +
+ ); + })} +
+
+

+ Computed signals +

+ {otherFormulas.map((formula) => ( + + ))} + +
+
+ +

+ {tooManyUnits + ? "More than two unit scales use relative trends. Split this panel to inspect original scales." + : "Original values use a separate axis for each unit. Editing a unit label does not convert values."} +

+
+ {hasEnum && render.mode === "lines" && ( +
+ +

+ A band keeps the numeric axes to themselves. Unchecked, + the enum is drawn as a step line on its own axis. +

+
+ )} +
+
+ + {canClose && ( + + )} +
+
+
+ {signals.map((signal) => { + const hidden = !!node.hiddenSignalIds?.includes(signal.id); + const label = `${signal.board} / ${signal.name}`; + const toggleTrace = () => + onChange(node.id, { + hiddenSignalIds: hidden + ? (node.hiddenSignalIds ?? []).filter( + (id) => id !== signal.id, + ) + : [...(node.hiddenSignalIds ?? []), signal.id], + }); + return ( + + + + + + + {hidden ? ( + + ) : ( + + )} + {hidden ? "Show this trace" : "Hide this trace"} + + void navigator.clipboard?.writeText(label)} + > + + Copy name + + {signal.formula && ( + + openFormulaEditor({ formulaId: signal.id }) + } + > + + Edit formula… + + )} + + + onChange(node.id, { + signalIds: node.signalIds.filter( + (id) => id !== signal.id, + ), + hiddenSignalIds: (node.hiddenSignalIds ?? []).filter( + (id) => id !== signal.id, + ), + }) + } + > + + Remove from this plot + + + + ); + })} + {normalized && ( + + Relative % + + )} +
+
+ {node.xSignalId ? ( + xSignal ? ( + !signal.discrete)} + domain={domain} + range={paneRange} + timeUnit={timeUnit} + isDarkMode={isDarkMode} + handleRef={chartHandleRef} + /> + ) : ( +
+ Loading measurements… +
+ ) + ) : visibleSignals.length > 0 ? ( + onRangeChange(node.id, next)} + handleRef={chartHandleRef} + onMarkerAt={(timeMs) => { + setClickedMarkerTime(timeMs); + setClickedMarkerLabel(""); + }} + /> + ) : signals.length > 0 ? ( +
+ +

All traces in this panel are hidden.

+ +
+ ) : ( +
+ {node.signalIds.length ? ( + "Loading measurements…" + ) : ( +
+

+ {isOver ? "Release to add measurements" : "Empty plot"} +

+

+ Select measurements, then drag them here or use Add to + plot. +

+
+ )} +
+ )} +
+ { + if (!open) setClickedMarkerTime(null); + }} + > + + + Add chart marker + +

+ {clickedMarkerTime === null + ? "" + : `Time: ${(clickedMarkerTime / TIME_DIVISORS[timeUnit]).toLocaleString(undefined, { maximumFractionDigits: 3 })} ${timeUnit}`} +

+ + + + + +
+
+
+
+ { + if ( + event.button === 2 && + performance.now() - menuOpenedAtRef.current < 400 + ) + event.stopPropagation(); + }} + // Radix restores focus to the trigger in a setTimeout that runs after + // the exit animation, i.e. after the popover we just opened has + // mounted; that focusin reads as "focus outside" and closes it. No + // deferral can win that race, so skip the restore just for this path + // and keep it for every other close (Escape, other items). + onCloseAutoFocus={(event) => { + if (!reopenConfigRef.current) return; + reopenConfigRef.current = false; + event.preventDefault(); + // When the popover opens under the pointer, the menu item sees the + // pointer leave and pulls focus back into the dying menu, which + // then drops it on . Hand it to the popover instead. + const content = configContentRef.current; + if (content && !content.contains(document.activeElement)) + (content.querySelector("input") ?? content).focus(); + }} + > + assignSignals(node.id, selectedIds)} + > + + Add {selectedIds.length || ""} selected measurement + {selectedIds.length === 1 ? "" : "s"} + + { + reopenConfigRef.current = true; + setConfigOpen(true); + }} + > + + Signals, units and scale + + + + New formula from this plot… + + + + Rename + + + + + + Layout + + + onSplit(node.id, "horizontal")} + > + + Split into columns + + onSplit(node.id, "vertical")} + > + + Split into rows + + onFocus(node.id)}> + {focused ? ( + + ) : ( + + )} + {focused ? "Restore panels" : "Expand panel"} + + {canClose && ( + <> + + onClose(node.id)} + > + + Close plot + + + )} + + + + + + View + + + + + X axis + + + setXSignal(value || null)} + > + Time + + {xCandidates.length === 0 ? ( +

+ Plot a numeric measurement first. +

+ ) : ( + xCandidates.map((signal) => ( + + + + {signal.board} /{" "} + + {signal.name} + + + )) + )} +
+
+
+ + + onChange(node.id, { normalized: checked === true }) + } + > + Normalize to 0–100% + + + onChange(node.id, { fitY: checked === true }) + } + > + Fit Y to window + + onChange(node.id, { hiddenSignalIds: [] })} + > + + Show all traces + + {!linkTimeAxis && ( + onRangeChange(node.id, null)} + > + + Reset this plot's zoom + + )} +
+
+ + + + Export + + + + + Save as PNG + + {canCopyImages() && ( + + + Copy image + + )} + + + Visible data as CSV + + + + + + onChange(node.id, { signalIds: [], hiddenSignalIds: [] }) + } + > + + Clear measurements + +
+
+ ); +} + +interface LayoutNodeProps extends Omit< + PaneProps, + "node" | "index" | "focused" +> { + node: ChartLayout; + panes: ChartPane[]; + focusedId: string | null; + onResizeStart: () => void; + panelSizes: DashboardSettings["panelSizes"]; + onPanelResize: (id: string, sizes: Record) => void; +} + +function LayoutNode({ + node, + panes, + focusedId, + onResizeStart, + panelSizes, + onPanelResize, + ...props +}: LayoutNodeProps) { + if (node.type === "pane") + return ( + pane.id === node.id)} + focused={focusedId === node.id} + /> + ); + const firstSize = minimumLayoutSize(node.first); + const secondSize = minimumLayoutSize(node.second); + const dimension = node.direction === "horizontal" ? "width" : "height"; + return ( + onPanelResize(node.id, sizes)} + > + + + + { + if (event.key.startsWith("Arrow")) onResizeStart(); + }} + className="[&>div]:bg-muted [&>div]:border-border mx-1 w-0.5 shrink-0 bg-transparent aria-[orientation=horizontal]:mx-0 aria-[orientation=horizontal]:my-1 aria-[orientation=horizontal]:h-0.5" + /> + + + + + ); +} + +export default function AnalysisWorkspace({ + dashboard, + signals, + isDarkMode, +}: { + dashboard: NormalDashboard; + signals: ComparisonSignal[]; + isDarkMode: boolean; +}) { + const syncGroup = useId(); + const [layoutState, setLayoutState] = useDashboardSetting( + dashboard, + "layoutState", + ); + const [showStatistics, setShowStatistics] = useDashboardSetting( + dashboard, + "showStatistics", + ); + const [statisticsColumns, setStatisticsColumns] = useDashboardSetting( + dashboard, + "statisticsColumns", + ); + const [showOrders, setShowOrders] = useDashboardSetting( + dashboard, + "showOrders", + ); + const [enumAsBackground, setEnumAsBackground] = useDashboardSetting( + dashboard, + "enumAsBackground", + ); + const [showCursors, setShowCursors] = useDashboardSetting( + dashboard, + "showCursors", + ); + const [cursors, setCursors] = useDashboardSetting(dashboard, "cursors"); + const placeCursor = useCallback( + (cursor: "a" | "b", timeMs: number) => + setCursors((current) => ({ ...current, [cursor]: timeMs })), + [setCursors], + ); + const orders = useStore((state) => state.orders); + const plotWarning = useStore((state) => state.normalPlotWarning); + const setPlotWarning = useStore((state) => state.setNormalPlotWarning); + + useEffect(() => { + if (!plotWarning) return; + const timer = setTimeout(() => setPlotWarning(null), 6000); + return () => clearTimeout(timer); + }, [plotWarning, setPlotWarning]); + const { selectedSeries } = useSeriesSelection(); + const addPlot = useStore((state) => state.addNormalPlot); + const openFormulaEditor = useStore((state) => state.openNormalFormulaEditor); + const openSearch = useStore((state) => state.setNormalSignalSearchOpen); + const undo = useStore((state) => state.undoNormalDashboard); + const redo = useStore((state) => state.redoNormalDashboard); + const selectedIds = useMemo( + () => Object.keys(selectedSeries).filter((key) => selectedSeries[key]), + [selectedSeries], + ); + const [focusedId, setFocusedId] = useDashboardSetting(dashboard, "focusedId"); + const [units, setUnits] = useDashboardSetting(dashboard, "units"); + const timeUnit = dashboard.settings.timeUnit; + const { + range, + linkTimeAxis, + setRange, + setRangeForAll, + setPaneWindow, + setLinked, + } = useDashboardTimeWindow(dashboard); + const [panelSizes, setPanelSizes] = useDashboardSetting( + dashboard, + "panelSizes", + ); + const resizePanel = useCallback( + (id: string, sizes: Record) => { + setPanelSizes((current) => { + const previous = current[id]; + return previous && + Object.keys(previous).length === Object.keys(sizes).length && + Object.entries(sizes).every( + ([key, value]) => Math.abs(previous[key] - value) < 0.01, + ) + ? current + : { ...current, [id]: sizes }; + }); + }, + [setPanelSizes], + ); + + // `echarts.connect` is a module-level flag keyed by group id, consulted when + // an action is broadcast. Owning it here (instead of inside each chart) means + // unlinking really stops the broadcast, and no chart is ever re-initialised. + useEffect(() => { + if (linkTimeAxis) echarts.connect(syncGroup); + return () => { + echarts.disconnect(syncGroup); + }; + }, [linkTimeAxis, syncGroup]); + + const resolvedSignals = useMemo( + () => + signals.map((signal) => ({ + ...signal, + units: units[signal.id] ?? signal.units, + })), + [signals, units], + ); + const domain = useMemo( + () => + signals.length === 0 + ? { startMs: 0, endMs: 1 } + : { + startMs: Math.min(...signals.map((signal) => signal.data.time[0])), + endMs: Math.max( + ...signals.map( + (signal) => signal.data.time[signal.data.time.length - 1], + ), + ), + }, + [signals], + ); + const panes = getPanes(layoutState.tree); + const focused = panes.find((pane) => pane.id === focusedId); + const visibleLayout = focused ?? layoutState.tree; + const size = minimumLayoutSize(visibleLayout); + + const changeRange = useCallback( + (paneId: string, next: TimeRange | null) => { + if (!next) setPaneWindow(paneId, null); + else if (linkTimeAxis) setRange(next); + else setPaneWindow(paneId, next); + }, + [linkTimeAxis, setRange, setPaneWindow], + ); + const changePane = useCallback( + ( + id: string, + changes: Partial< + Pick< + ChartPane, + | "title" + | "signalIds" + | "normalized" + | "fitY" + | "xSignalId" + | "hiddenSignalIds" + | "markers" + > + >, + ) => { + setLayoutState((current) => ({ + ...current, + preset: null, + tree: updatePane(current.tree, id, (pane) => ({ ...pane, ...changes })), + })); + }, + [setLayoutState], + ); + const split = (id: string, direction: "horizontal" | "vertical") => { + setLayoutState((current) => ({ + ...current, + preset: null, + tree: splitPane(current.tree, id, direction), + })); + }; + const close = (id: string) => { + setFocusedId(null); + setLayoutState((current) => ({ + ...current, + preset: null, + tree: removePane(current.tree, id) ?? current.tree, + })); + }; + const formatTime = (percent: number) => + formatWindowTime(percent, domain, timeUnit); + + const [shortcutsOpen, setShortcutsOpen] = useState(false); + // Pane a per-plot shortcut applies to: the one under the pointer, else the + // expanded one, else the only one. + const targetPane = () => { + const id = hoveredPaneId() ?? focused?.id; + return ( + panes.find((pane) => pane.id === id) ?? + (panes.length === 1 ? panes[0] : undefined) + ); + }; + // Linked, time shortcuts move the dashboard window; unlinked, they move + // only the target plot, like its own wheel and drag do. + const moveWindow = (next: (current: TimeRange) => TimeRange) => { + if (linkTimeAxis) return setRange(next(range)); + const pane = targetPane(); + if (pane) setPaneWindow(pane.id, next(pane.timeWindow ?? range)); + }; + const zoomBy = (factor: number) => + moveWindow((current) => { + const span = Math.min( + 100, + Math.max(0.01, (current.end - current.start) * factor), + ); + const start = Math.max( + 0, + Math.min(100 - span, (current.start + current.end - span) / 2), + ); + return { start, end: start + span }; + }); + const panBy = (direction: number) => + moveWindow((current) => { + const span = current.end - current.start; + const start = Math.max( + 0, + Math.min(100 - span, current.start + direction * span * 0.2), + ); + return { start, end: start + span }; + }); + // Orders and markers, as ms from the start of the recording, in time order. + const eventTimes = () => { + const duration = domain.endMs - domain.startMs; + const times = [ + ...((showOrders ?? true) + ? orders.map( + (order) => + (order.absoluteTime ?? domain.startMs + order.time) - + domain.startMs, + ) + : []), + ...panes.flatMap((pane) => + (pane.markers ?? []).map((marker) => marker.timeMs), + ), + ].filter((time) => time >= 0 && time <= duration); + return [...new Set(times)].sort((a, b) => a - b); + }; + // Cursor A is the "current position": it steps from event to event, and the + // window follows whenever the event would fall outside it. + const jumpToEvent = (direction: 1 | -1) => { + const duration = domain.endMs - domain.startMs; + const times = eventTimes(); + if (times.length === 0 || duration <= 0) return; + const from = + (showCursors ? cursors.a : null) ?? + (duration * (range.start + range.end)) / 200; + const target = + direction > 0 + ? times.find((time) => time > from + 1e-6) + : [...times].reverse().find((time) => time < from - 1e-6); + if (target === undefined) return; + setShowCursors(true); + setCursors((current) => ({ ...current, a: target })); + const percent = (target / duration) * 100; + moveWindow((current) => { + if (percent >= current.start && percent <= current.end) return current; + const span = current.end - current.start; + const start = Math.max(0, Math.min(100 - span, percent - span / 2)); + return { start, end: start + span }; + }); + }; + useNormalShortcuts({ + previousEvent: () => jumpToEvent(-1), + nextEvent: () => jumpToEvent(1), + zoomIn: () => zoomBy(0.5), + zoomOut: () => zoomBy(2), + panLeft: () => panBy(-1), + panRight: () => panBy(1), + resetZoom: () => moveWindow(() => ({ start: 0, end: 100 })), + linkTime: () => setLinked(!linkTimeAxis), + newPlot: () => { + if (!dashboard.locked && panes.length < MAX_PANES && !focused) addPlot(); + }, + newFormula: () => { + if (!dashboard.locked) openFormulaEditor({}); + }, + expand: () => { + const pane = targetPane(); + if (pane) + setFocusedId((current) => (current === pane.id ? null : pane.id)); + }, + fitY: () => { + const pane = targetPane(); + if (pane) changePane(pane.id, { fitY: !pane.fitY }); + }, + statistics: () => setShowStatistics((current) => !current), + cursors: () => setShowCursors((current) => !current), + orders: () => setShowOrders((current) => !(current ?? true)), + escape: () => { + if (focused) setFocusedId(null); + else setCursors({ a: null, b: null }); + }, + help: () => setShortcutsOpen(true), + undo: () => undo(dashboard.id), + redo: () => redo(dashboard.id), + search: () => { + if (!dashboard.locked) openSearch(true); + }, + }); + const statisticsGroups = useMemo(() => { + if (linkTimeAxis) + return [ + { + id: dashboard.id, + label: "All plots", + range, + signals: resolvedSignals, + }, + ]; + // A signal assigned to two plots legitimately appears twice: different + // windows, different numbers. + return panes.map((pane, index) => ({ + id: pane.id, + label: pane.title || `Plot ${index + 1}`, + range: pane.timeWindow ?? range, + signals: resolvedSignals.filter((signal) => + pane.signalIds.includes(signal.id), + ), + })); + }, [linkTimeAxis, dashboard.id, range, resolvedSignals, panes]); + + return ( +
+ + +
+
+ 1} + onRangeChange={changeRange} + onSplit={split} + onClose={close} + onFocus={(id) => + setFocusedId((current) => (current === id ? null : id)) + } + onChange={changePane} + onUnitChange={(id, unit) => + setUnits((current) => ({ ...current, [id]: unit })) + } + onResizeStart={() => + setLayoutState((current) => ({ ...current, preset: null })) + } + /> +
+
+
+ + = MAX_PANES} + onClick={() => addPlot()} + > + + New plot + + = MAX_PANES || + selectedIds.length === 0 + } + onClick={() => addPlot(selectedIds)} + > + + New plot from {selectedIds.length || ""} selected measurement + {selectedIds.length === 1 ? "" : "s"} + + openFormulaEditor()} + > + + New formula… + + + + + + Layout + + + {LAYOUT_ITEMS.map(({ value, label, Icon }) => ( + 1} + onClick={() => { + setFocusedId(null); + setLayoutState({ + ids: layoutState.ids, + tree: arrangePanes(panes, value), + preset: value, + }); + }} + > + + {label} + + ))} + + + + setShowStatistics(checked === true)} + > + Window statistics + + setShowCursors(checked === true)} + > + Cursors + + setShowOrders(checked === true)} + > + + Order markers ({orders.length}) + + setEnumAsBackground(checked === true)} + > + + Enum background + + setRangeForAll({ start: 0, end: 100 })} + > + + Reset zoom + + +
+ {plotWarning && ( +
+ + {plotWarning} + +
+ )} + {showCursors && ( + setCursors({ a: null, b: null })} + onClose={() => setShowCursors(false)} + /> + )} + {showStatistics && ( + setShowStatistics(false)} + columns={statisticsColumns} + onColumnsChange={setStatisticsColumns} + /> + )} +
+ + {dashboard.locked + ? "Dashboard locked · Unlock in tab settings to edit" + : "Drop selected measurements onto a plot · Scroll to zoom · Drag to pan · Ctrl+drag to zoom to a stretch"} + + + + + {panes.length} panels ·{" "} + {linkTimeAxis + ? `${formatTime(range.start)}–${formatTime(range.end)} ${timeUnit}` + : "independent windows"} + +
+
+ ); +} diff --git a/frontend/logging-view/src/components/normal/ComparisonChart.tsx b/frontend/logging-view/src/components/normal/ComparisonChart.tsx new file mode 100644 index 000000000..d6f1d60cd --- /dev/null +++ b/frontend/logging-view/src/components/normal/ComparisonChart.tsx @@ -0,0 +1,943 @@ +import * as echarts from "echarts"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import { + chartBackground, + useChartHandle, + type ChartHandle, +} from "../../lib/normal/chartImage"; +import { decimateLTTB } from "../../lib/plotStudio/decimate"; +import { lowerBound, upperBound } from "../../lib/plotStudio/range"; +import { enumBandColor, enumVariantColor } from "../../lib/normal/enumColors"; +import { axisKey } from "../../lib/normal/units"; +import { computeStateSegments } from "../../lib/plotStudio/timeline"; +import { formatOrderParameters } from "../../lib/orders"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type ChartMarker, + type MeasureCursors, + type TimeDomain, + type TimeRange, + type TimeUnit, +} from "../../types/normal"; +import type { LoggedOrder } from "../../types/session"; + +interface TooltipPoint { + seriesIndex: number; + data?: number[]; + axisValue?: number; +} + +interface Props { + locked: boolean; + signals: ComparisonSignal[]; + domain: TimeDomain; + range: TimeRange; + timeUnit: TimeUnit; + normalized: boolean; + isDarkMode: boolean; + syncGroup: string; + orders: LoggedOrder[]; + showOrders: boolean; + markers: ChartMarker[]; + /** An enum drawn as a background wash behind the curves, if any. */ + band: ComparisonSignal | null; + /** True when every signal is discrete: draw a Gantt-style state timeline. */ + timeline: boolean; + /** Scale each y axis to the samples inside the visible window. */ + fitY: boolean; + /** A/B cursors to draw; null while cursors are off. */ + cursors: MeasureCursors | null; + onCursorAt: (cursor: "a" | "b", timeMs: number) => void; + onRangeChange: (range: TimeRange) => void; + onMarkerAt: (timeMs: number) => void; + handleRef?: Ref; +} + +const escapeHtml = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + +export default function ComparisonChart({ + locked, + signals, + domain, + range, + timeUnit, + normalized, + isDarkMode, + syncGroup, + orders, + showOrders, + markers, + band, + timeline, + fitY, + cursors, + onCursorAt, + onRangeChange, + onMarkerAt, + handleRef, +}: Props) { + const elementRef = useRef(null); + const zoomBandRef = useRef(null); + const chartRef = useRef(null); + const rangeRef = useRef(range); + const onRangeChangeRef = useRef(onRangeChange); + const onMarkerAtRef = useRef(onMarkerAt); + const domainRef = useRef(domain); + const timeUnitRef = useRef(timeUnit); + // The last window this chart pushed to the store. The store pushes every + // change back (see the effect at the end), so without this we would re-apply + // our own gesture mid-flight; an external change still gets through. + const lastSentRef = useRef(range); + const lockedRef = useRef(locked); + const cursorsRef = useRef(cursors); + const onCursorAtRef = useRef(onCursorAt); + const [width, setWidth] = useState(0); + useChartHandle(handleRef, chartRef, chartBackground(isDarkMode)); + + useEffect(() => { + lockedRef.current = locked; + }, [locked]); + + useEffect(() => { + cursorsRef.current = cursors; + onCursorAtRef.current = onCursorAt; + }, [cursors, onCursorAt]); + + useEffect(() => { + onRangeChangeRef.current = onRangeChange; + }, [onRangeChange]); + + useEffect(() => { + onMarkerAtRef.current = onMarkerAt; + }, [onMarkerAt]); + + useEffect(() => { + domainRef.current = domain; + timeUnitRef.current = timeUnit; + }, [domain, timeUnit]); + + // Keep normalization relative to the whole recording while sampling only + // the visible interval so zooming reveals samples omitted at wider scales. + const extents = useMemo( + () => + signals.map((signal) => { + let min = Infinity; + let max = -Infinity; + for (const value of signal.data.value) { + min = Math.min(min, value); + max = Math.max(max, value); + } + return { min, max }; + }), + [signals], + ); + + const prepared = useMemo( + () => + signals.map((signal, signalIndex) => { + const { min, max } = extents[signalIndex]; + const duration = domain.endMs - domain.startMs; + // Include neighbours to retain lines crossing either viewport edge. + const first = Math.max( + 0, + lowerBound( + signal.data.time, + domain.startMs + (duration * range.start) / 100, + ) - 1, + ); + const last = Math.min( + signal.data.time.length, + upperBound( + signal.data.time, + domain.startMs + (duration * range.end) / 100, + ) + 1, + ); + const sampled = decimateLTTB( + signal.data.time.subarray(first, last), + signal.data.value.subarray(first, last), + 8_000, + ); + return Array.from(sampled.time, (time, index) => { + const raw = sampled.value[index]; + return [ + (time - domain.startMs) / TIME_DIVISORS[timeUnit], + normalized + ? max === min + ? 50 + : ((raw - min) / (max - min)) * 100 + : raw, + raw, + ]; + }); + }), + [ + signals, + extents, + domain.startMs, + domain.endMs, + range, + timeUnit, + normalized, + ], + ); + const orderMarkers = useMemo(() => { + if (!showOrders) return []; + const duration = domain.endMs - domain.startMs; + return orders + .map((order) => ({ + order, + plotTimeMs: + (order.absoluteTime ?? domain.startMs + order.time) - domain.startMs, + })) + .filter(({ plotTimeMs }) => plotTimeMs >= 0 && plotTimeMs <= duration); + }, [domain.endMs, domain.startMs, orders, showOrders]); + + // Segments for whatever is drawn from state runs: the background band and + // every timeline row. Computed on the undecimated series on purpose — + // decimateLTTB would move the state boundaries, which are the whole point. + const toPlotTime = useCallback( + (ms: number) => (ms - domain.startMs) / TIME_DIVISORS[timeUnit], + [domain.startMs, timeUnit], + ); + const windowMs = useMemo(() => { + const span = domain.endMs - domain.startMs; + return { + from: domain.startMs + (span * range.start) / 100, + to: domain.startMs + (span * range.end) / 100, + }; + }, [domain.startMs, domain.endMs, range.start, range.end]); + + const bandSegments = useMemo(() => { + if (!band) return []; + return ( + computeStateSegments(band.data, domain.endMs) + .filter( + (segment) => + segment.end >= windowMs.from && segment.start <= windowMs.to, + ) + // A pathologically noisy enum would otherwise emit one mark per sample. + .slice(0, 1000) + ); + }, [band, windowMs, domain.endMs]); + + const timelineRows = useMemo( + () => + timeline + ? signals.map((signal) => `${signal.board} / ${signal.name}`) + : [], + [timeline, signals], + ); + + const timelineSegments = useMemo(() => { + if (!timeline) return []; + return signals.map((signal) => + computeStateSegments(signal.data, domain.endMs) + .filter( + (segment) => + segment.end >= windowMs.from && segment.start <= windowMs.to, + ) + .slice(0, 1000), + ); + }, [timeline, signals, windowMs, domain.endMs]); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const chart = echarts.init(element); + chartRef.current = chart; + // Only joins the group. Whether the group broadcasts is decided by + // AnalysisWorkspace from `linkTimeAxis`; connecting here as well would + // re-link an unlinked dashboard every time a chart mounts. + chart.group = syncGroup; + // ECharts emits its own dataZoom action every 20 ms during a gesture, and + // each store write re-renders the workspace and re-decimates every pane, + // so commit at most once per frame. ECharts has already repainted itself, + // so the visible response stays immediate either way. + let pending: TimeRange | null = null; + let frame = 0; + const commit = () => { + frame = 0; + if (!pending) return; + const next = pending; + pending = null; + lastSentRef.current = next; + onRangeChangeRef.current(next); + }; + const updateRange = () => { + const zoom = (chart.getOption() as { dataZoom?: TimeRange[] }) + .dataZoom?.[0]; + if (zoom && Number.isFinite(zoom.start) && Number.isFinite(zoom.end)) { + pending = { start: zoom.start, end: zoom.end }; + if (!frame) frame = requestAnimationFrame(commit); + } + }; + chart.on("datazoom", updateRange); + // Time under a pixel of this chart, in ms from the start of the recording. + const timeAtPixel = (x: number, y: number) => { + const point = chart.convertFromPixel({ xAxisIndex: 0 }, [x, y]); + const value = Array.isArray(point) ? point[0] : point; + const currentDomain = domainRef.current; + const duration = currentDomain.endMs - currentDomain.startMs; + const convertedTimeMs = + Number(value) * TIME_DIVISORS[timeUnitRef.current]; + // convertFromPixel can be unavailable for a just-resized chart. Keep the + // gesture useful by deriving the same x position from the plot width. + const fallbackTimeMs = + (Math.max(0, Math.min(element.clientWidth, x)) / + Math.max(1, element.clientWidth)) * + duration; + return Number.isFinite(convertedTimeMs) && + convertedTimeMs >= 0 && + convertedTimeMs <= duration + ? convertedTimeMs + : fallbackTimeMs; + }; + const addMarkerAtPixel = (x: number, y: number) => { + if (lockedRef.current) return; + onMarkerAtRef.current(timeAtPixel(x, y)); + }; + const onDoubleClick = (event: MouseEvent) => { + if (!(event.target instanceof Node) || !element.contains(event.target)) + return; + const bounds = element.getBoundingClientRect(); + addMarkerAtPixel(event.clientX - bounds.left, event.clientY - bounds.top); + }; + // zrender can stop the canvas event before it reaches React or the chart + // container, so capture at the document and scope it back to this chart. + document.addEventListener("dblclick", onDoubleClick, true); + + // Shift+wheel pans in time. ECharts cannot do this on its own: its roam + // controller evaluates zoomOnMouseWheel and moveOnMouseWheel + // independently and fires both events, so `moveOnMouseWheel: "shift"` + // would pan *and* zoom at once. Plain wheel is left to ECharts' zoom. + // Capture phase is what puts us ahead of zrender, which binds its own + // handlers to the div it creates inside this element. + const onWheel = (event: WheelEvent) => { + if (!event.shiftKey || lockedRef.current) return; + event.preventDefault(); + event.stopPropagation(); + const delta = + Math.abs(event.deltaX) > Math.abs(event.deltaY) + ? event.deltaX + : event.deltaY; + if (delta === 0) return; + const { start, end } = rangeRef.current; + const span = end - start; + const next = Math.max( + 0, + Math.min(100 - span, start + Math.sign(delta) * span * 0.2), + ); + if (Math.abs(next - start) < 1e-4) return; + // Not silent on purpose: this must behave exactly like a drag-pan, i.e. + // emit datazoom so the commit above and the group broadcast both run. + chart.dispatchAction({ type: "dataZoom", start: next, end: next + span }); + }; + element.addEventListener("wheel", onWheel, { + capture: true, + passive: false, + }); + + // Ctrl+drag selects a stretch of time and zooms to it. Captured ahead of + // zrender for the same reason as the wheel: its roam controller would + // otherwise start a pan from the same mousedown. + let zoomFrom: number | null = null; + const band = zoomBandRef.current; + const localX = (event: MouseEvent) => { + const bounds = element.getBoundingClientRect(); + return Math.max(0, Math.min(bounds.width, event.clientX - bounds.left)); + }; + const onZoomMove = (event: MouseEvent) => { + if (zoomFrom === null || !band) return; + const x = localX(event); + band.style.left = `${Math.min(zoomFrom, x)}px`; + band.style.width = `${Math.abs(x - zoomFrom)}px`; + band.hidden = false; + }; + const onZoomEnd = (event: MouseEvent) => { + document.removeEventListener("mousemove", onZoomMove, true); + document.removeEventListener("mouseup", onZoomEnd, true); + if (band) band.hidden = true; + const from = zoomFrom; + zoomFrom = null; + if (from === null) return; + const to = localX(event); + // A plain Ctrl+click is not a selection. + if (Math.abs(to - from) < 5) return; + const duration = domainRef.current.endMs - domainRef.current.startMs; + if (duration <= 0) return; + const percents = [from, to] + .map((x) => (timeAtPixel(x, 0) / duration) * 100) + .sort((a, b) => a - b); + const start = Math.max(0, Math.min(100, percents[0])); + const end = Math.max(start + 0.01, Math.min(100, percents[1])); + chart.dispatchAction({ type: "dataZoom", start, end }); + }; + const onZoomStart = (event: MouseEvent) => { + if (!event.ctrlKey || event.button !== 0 || lockedRef.current) return; + event.preventDefault(); + event.stopPropagation(); + zoomFrom = localX(event); + document.addEventListener("mousemove", onZoomMove, true); + document.addEventListener("mouseup", onZoomEnd, true); + }; + element.addEventListener("mousedown", onZoomStart, true); + + // A click that did not travel places a cursor: A, or B with Shift. A drag + // from the same press is a pan and must stay one. + // Pressing on a cursor line instead grabs it, and it follows the pointer. + let pressedAt: { x: number; y: number } | null = null; + let dragging: "a" | "b" | null = null; + let dragFrame = 0; + let dragX = 0; + const cursorNear = (event: MouseEvent): "a" | "b" | null => { + const placed = cursorsRef.current; + if (!placed || lockedRef.current) return null; + const x = localX(event); + let nearest: "a" | "b" | null = null; + let distance = 6; + for (const key of ["a", "b"] as const) { + const timeMs = placed[key]; + if (timeMs === null) continue; + const pixel = chart.convertToPixel( + { xAxisIndex: 0 }, + timeMs / TIME_DIVISORS[timeUnitRef.current], + ); + const gap = Math.abs(Number(pixel) - x); + if (gap <= distance) { + distance = gap; + nearest = key; + } + } + return nearest; + }; + const onCursorDrag = (event: MouseEvent) => { + dragX = localX(event); + if (dragFrame) return; + dragFrame = requestAnimationFrame(() => { + dragFrame = 0; + if (dragging) onCursorAtRef.current(dragging, timeAtPixel(dragX, 0)); + }); + }; + const onCursorDrop = (event: MouseEvent) => { + document.removeEventListener("mousemove", onCursorDrag, true); + document.removeEventListener("mouseup", onCursorDrop, true); + if (dragFrame) cancelAnimationFrame(dragFrame); + dragFrame = 0; + if (dragging) + onCursorAtRef.current(dragging, timeAtPixel(localX(event), 0)); + dragging = null; + }; + // Shows the line can be grabbed. + const onCursorHover = (event: MouseEvent) => { + if (dragging || event.buttons !== 0) return; + const zr = chart.getZr(); + if (cursorNear(event)) zr.setCursorStyle("ew-resize"); + }; + const onCursorPress = (event: MouseEvent) => { + pressedAt = null; + if (event.button !== 0 || event.ctrlKey || !cursorsRef.current) return; + const grabbed = cursorNear(event); + if (grabbed) { + // Ahead of zrender, so the same press does not start a pan. + event.preventDefault(); + event.stopPropagation(); + dragging = grabbed; + document.addEventListener("mousemove", onCursorDrag, true); + document.addEventListener("mouseup", onCursorDrop, true); + return; + } + pressedAt = { x: event.clientX, y: event.clientY }; + }; + const onCursorRelease = (event: MouseEvent) => { + const from = pressedAt; + pressedAt = null; + if (!from || event.button !== 0 || lockedRef.current) return; + if (Math.hypot(event.clientX - from.x, event.clientY - from.y) > 4) + return; + const bounds = element.getBoundingClientRect(); + onCursorAtRef.current( + event.shiftKey ? "b" : "a", + timeAtPixel(event.clientX - bounds.left, event.clientY - bounds.top), + ); + }; + element.addEventListener("mousedown", onCursorPress, true); + element.addEventListener("mouseup", onCursorRelease, true); + element.addEventListener("mousemove", onCursorHover); + + const observer = new ResizeObserver(() => { + setWidth(element.clientWidth); + chart.resize(); + }); + observer.observe(element); + return () => { + if (frame) cancelAnimationFrame(frame); + observer.disconnect(); + element.removeEventListener("wheel", onWheel, { capture: true }); + element.removeEventListener("mousedown", onZoomStart, true); + element.removeEventListener("mousedown", onCursorPress, true); + element.removeEventListener("mouseup", onCursorRelease, true); + element.removeEventListener("mousemove", onCursorHover); + document.removeEventListener("mousemove", onCursorDrag, true); + document.removeEventListener("mouseup", onCursorDrop, true); + if (dragFrame) cancelAnimationFrame(dragFrame); + document.removeEventListener("mousemove", onZoomMove, true); + document.removeEventListener("mouseup", onZoomEnd, true); + chart.off("datazoom", updateRange); + document.removeEventListener("dblclick", onDoubleClick, true); + chart.dispose(); + chartRef.current = null; + }; + }, [syncGroup]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || signals.length === 0) return; + rangeRef.current = range; + const text = isDarkMode ? "#d5d8df" : "#374151"; + const muted = isDarkMode ? "#9ba3b2" : "#6b7280"; + const grid = isDarkMode ? "#3a404b" : "#e6e9ef"; + const orderColor = isDarkMode ? "#fbbf24" : "#d97706"; + const markerColor = isDarkMode ? "#67e8f9" : "#0891b2"; + const orderTooltipTolerance = Math.max( + 1e-9, + ((domain.endMs - domain.startMs) / + TIME_DIVISORS[timeUnit] / + Math.max(width, 1)) * + 10, + ); + const units = normalized + ? ["Relative (%)"] + : [...new Set(signals.map(axisKey))]; + // One row per signal, read top to bottom in assignment order. + const timelineAxis = { + type: "category" as const, + data: timelineRows, + inverse: true, + axisTick: { show: false }, + axisLine: { show: false }, + axisLabel: { color: muted, fontSize: 10, hideOverlap: true }, + splitArea: { + show: true, + areaStyle: { + color: isDarkMode + ? ["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"] + : ["rgba(0,0,0,0.015)", "rgba(0,0,0,0.04)"], + }, + }, + splitLine: { show: false }, + }; + // With fitY, an axis spans only what its signals do inside the window. + // `prepared` is already cut to the window plus one neighbour either side. + const windowFrom = + ((domain.endMs - domain.startMs) * range.start) / + 100 / + TIME_DIVISORS[timeUnit]; + const windowTo = + ((domain.endMs - domain.startMs) * range.end) / + 100 / + TIME_DIVISORS[timeUnit]; + const fitted = (unit: string) => { + let min = Infinity; + let max = -Infinity; + signals.forEach((signal, index) => { + if (axisKey(signal) !== unit) return; + for (const [time, value] of prepared[index]) { + if (time < windowFrom || time > windowTo) continue; + if (value < min) min = value; + if (value > max) max = value; + } + }); + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const pad = max === min ? Math.abs(max) * 0.05 || 1 : (max - min) * 0.05; + return { min: min - pad, max: max + pad }; + }; + const axes = units.map((unit, index) => { + const signal = signals.find((item) => axisKey(item) === unit); + const fit = + fitY && !normalized && !signal?.discrete ? fitted(unit) : null; + return { + type: "value" as const, + name: normalized ? "Relative (%)" : signal?.discrete ? "State" : unit, + nameLocation: "middle" as const, + nameGap: 48, + nameTextStyle: { color: muted, fontSize: 11 }, + position: index === 0 ? ("left" as const) : ("right" as const), + min: normalized ? 0 : fit?.min, + max: normalized ? 100 : fit?.max, + scale: !normalized, + minInterval: signal?.discrete ? 1 : undefined, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: muted, + fontSize: 10, + hideOverlap: true, + formatter: + signal?.enumLabels && !normalized + ? (value: number) => signal.enumLabels?.[value] ?? String(value) + : undefined, + }, + splitLine: { + show: index === 0, + lineStyle: { color: grid, type: "dashed" as const }, + }, + }; + }); + const orderMarkLine = { + silent: false, + symbol: ["none", "circle"], + symbolSize: [0, 6], + animation: false, + lineStyle: { + color: orderColor, + width: 1.25, + type: "dashed", + opacity: 0.9, + }, + label: { + show: orderMarkers.length <= 8, + color: orderColor, + fontSize: 9, + position: "insideEndTop", + formatter: "{b}", + }, + data: orderMarkers.map(({ order, plotTimeMs }) => ({ + name: + order.name.length > 18 ? `${order.name.slice(0, 17)}…` : order.name, + xAxis: plotTimeMs / TIME_DIVISORS[timeUnit], + order, + })), + }; + const markerMarkLine = { + silent: true, + symbol: ["none", "none"], + animation: false, + lineStyle: { + color: markerColor, + width: 1.25, + type: "solid", + opacity: 0.9, + }, + label: { + show: true, + color: markerColor, + fontSize: 9, + position: "insideEndTop", + formatter: "{b}", + }, + data: markers.map((marker) => ({ + name: marker.label, + xAxis: marker.timeMs / TIME_DIVISORS[timeUnit], + lineStyle: { color: markerColor, width: 1.25, type: "solid" }, + label: { color: markerColor }, + })), + }; + const cursorColor = isDarkMode ? "#f472b6" : "#db2777"; + const cursorLines = (["a", "b"] as const).flatMap((key) => { + const timeMs = cursors?.[key]; + if (timeMs === null || timeMs === undefined) return []; + return [ + { + name: key.toUpperCase(), + xAxis: timeMs / TIME_DIVISORS[timeUnit], + lineStyle: { color: cursorColor, width: 1.5, type: "solid" }, + label: { + show: true, + color: cursorColor, + fontSize: 11, + fontWeight: "bold", + position: "insideEndBottom", + }, + }, + ]; + }); + const chartMarkLine = { + ...orderMarkLine, + data: [...orderMarkLine.data, ...markerMarkLine.data, ...cursorLines], + }; + const hasMarkLines = chartMarkLine.data.length > 0; + chart.setOption( + { + backgroundColor: "transparent", + animation: false, + textStyle: { color: text }, + grid: { + left: width < 420 ? 62 : 72, + right: axes.length > 1 ? 72 : 22, + top: 22, + bottom: 54, + }, + xAxis: { + type: "value", + min: 0, + max: Math.max( + 1 / TIME_DIVISORS[timeUnit], + (domain.endMs - domain.startMs) / TIME_DIVISORS[timeUnit], + ), + name: `Time (${timeUnit})`, + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { color: muted, fontSize: 11 }, + axisLine: { lineStyle: { color: grid } }, + axisTick: { show: false }, + axisLabel: { color: muted, hideOverlap: true, fontSize: 10 }, + splitLine: { lineStyle: { color: grid, opacity: 0.6 } }, + axisPointer: { + show: true, + label: { show: false }, + lineStyle: { color: muted, type: "dashed" }, + }, + }, + yAxis: timeline ? timelineAxis : axes, + tooltip: { + trigger: timeline ? "item" : "axis", + confine: true, + backgroundColor: isDarkMode + ? "rgba(34,39,48,0.97)" + : "rgba(255,255,255,0.98)", + borderColor: grid, + textStyle: { color: text, fontSize: 12 }, + formatter: (raw: unknown) => { + const params = (Array.isArray(raw) ? raw : [raw]) as TooltipPoint[]; + if (timeline) { + const point = params[0]; + const signal = signals[point?.seriesIndex ?? -1]; + const data = point?.data; + if (!signal || !data) return ""; + const [from, to, , code] = data; + const label = + signal.enumLabels?.[Math.round(code)] ?? String(code); + const span = (to - from).toLocaleString(undefined, { + maximumFractionDigits: 3, + }); + return ( + `${escapeHtml(label)}
` + + `${escapeHtml(signal.board)} / ${escapeHtml(signal.name)}
` + + `${from.toLocaleString(undefined, { maximumFractionDigits: 3 })} → ` + + `${to.toLocaleString(undefined, { maximumFractionDigits: 3 })} ${timeUnit}` + + ` (${span} ${timeUnit})` + ); + } + const time = Number( + params[0]?.data?.[0] ?? params[0]?.axisValue ?? 0, + ); + const rows = params + .map((param) => { + const signal = signals[param.seriesIndex]; + if (!signal) return ""; + const value = Number(param.data?.[2] ?? param.data?.[1]); + if (!Number.isFinite(value)) return ""; + const state = signal.discrete + ? signal.enumLabels?.[Math.round(value)] + : undefined; + const shown = + state ?? + `${value.toLocaleString(undefined, { maximumFractionDigits: 4 })}${signal.units ? ` ${signal.units}` : ""}`; + return `
● ${escapeHtml(signal.board)} / ${escapeHtml(signal.name)} ${escapeHtml(shown)}
`; + }) + .join(""); + const orderRows = orderMarkers + .filter( + ({ plotTimeMs }) => + Math.abs(plotTimeMs / TIME_DIVISORS[timeUnit] - time) <= + orderTooltipTolerance, + ) + .map( + ({ order }) => + `
◆ ${escapeHtml(order.name)}
${escapeHtml(order.from)} → ${escapeHtml(order.to)} · ${formatOrderParameters(order.parameters)}
`, + ) + .join(""); + // The band is a silent markArea, so it contributes no param of + // its own; look its state up by the hovered time instead. + const bandRow = (() => { + if (!band) return ""; + const segment = bandSegments.find( + (current) => + toPlotTime(current.start) <= time && + time <= toPlotTime(current.end), + ); + if (!segment) return ""; + const label = + band.enumLabels?.[Math.round(segment.value)] ?? + String(segment.value); + return `
▮ ${escapeHtml(band.board)} / ${escapeHtml(band.name)} ${escapeHtml(label)}
`; + })(); + return `${time.toLocaleString(undefined, { maximumFractionDigits: 3 })} ${timeUnit}${rows}${bandRow}${orderRows}`; + }, + }, + dataZoom: [ + { + type: "inside", + disabled: locked, + xAxisIndex: 0, + ...rangeRef.current, + filterMode: "none", + throttle: 100, + zoomOnMouseWheel: !locked, + moveOnMouseMove: !locked, + moveOnMouseWheel: false, + }, + ], + series: (timeline + ? signals.map((signal, index) => ({ + id: signal.id, + name: timelineRows[index], + // A custom series keeps the x axis a value axis in time units, + // so the existing dataZoom and the order markLine keep working; + // stacked bars would have forced a category x axis. + type: "custom" as const, + clip: true, + animation: false, + encode: { x: [0, 1], y: 2 }, + renderItem: ( + params: echarts.CustomSeriesRenderItemParams, + api: echarts.CustomSeriesRenderItemAPI, + ) => { + const row = api.value(2) as number; + const start = api.coord([api.value(0) as number, row]); + const end = api.coord([api.value(1) as number, row]); + const height = (api.size?.([0, 1]) as number[])[1] * 0.62; + // The cartesian coord sys does carry the rect; the union + // type ECharts declares for renderItem does not say so. + const coordSys = params.coordSys as unknown as { + x: number; + y: number; + width: number; + height: number; + }; + const shape = echarts.graphic.clipRectByRect( + { + x: start[0], + y: start[1] - height / 2, + width: Math.max(1, end[0] - start[0]), + height, + }, + coordSys, + ); + if (!shape) return undefined; + return { + type: "rect" as const, + shape, + style: { + fill: enumVariantColor(Math.round(api.value(3) as number)), + }, + }; + }, + data: timelineSegments[index].map((segment) => [ + toPlotTime(segment.start), + toPlotTime(segment.end), + index, + segment.value, + ]), + ...(index === 0 && hasMarkLines + ? { markLine: chartMarkLine } + : {}), + })) + : signals.map((signal, index) => ({ + id: signal.id, + name: `${signal.board} / ${signal.name}`, + type: "line", + yAxisIndex: normalized ? 0 : units.indexOf(axisKey(signal)), + data: prepared[index], + showSymbol: signal.data.time.length < 20, + symbolSize: 5, + step: signal.discrete ? "end" : false, + lineStyle: { color: signal.color, width: 1.8 }, + itemStyle: { color: signal.color }, + emphasis: { focus: "series" }, + progressive: 5000, + ...(index === 0 && hasMarkLines + ? { markLine: chartMarkLine } + : {}), + // Marks belong to a series, and series 0 is the one proven to + // draw them, so the band rides along with the order markers. + ...(index === 0 && bandSegments.length > 0 + ? { + markArea: { + // Out of the axis tooltip: its state is added by hand in + // the formatter above. + silent: true, + animation: false, + data: bandSegments.map((segment) => [ + { + xAxis: toPlotTime(segment.start), + itemStyle: { + color: enumBandColor( + Math.round(segment.value), + isDarkMode, + ), + }, + }, + { xAxis: toPlotTime(segment.end) }, + ]), + }, + } + : {}), + }))) as echarts.SeriesOption[], + }, + true, + ); + chart.resize(); + }, [ + signals, + prepared, + domain, + range, + timeUnit, + normalized, + fitY, + isDarkMode, + width, + locked, + orderMarkers, + markers, + cursors, + timeline, + timelineRows, + timelineSegments, + band, + bandSegments, + toPlotTime, + ]); + + useEffect(() => { + rangeRef.current = range; + // Our own commit coming back round: the chart is already showing it. + if ( + Math.abs(range.start - lastSentRef.current.start) < 1e-4 && + Math.abs(range.end - lastSentRef.current.end) < 1e-4 + ) { + return; + } + lastSentRef.current = range; + chartRef.current?.dispatchAction( + { type: "dataZoom", ...range }, + { silent: true }, + ); + }, [range]); + + return ( +
+
+ + ); +} diff --git a/frontend/logging-view/src/components/normal/CursorReadout.tsx b/frontend/logging-view/src/components/normal/CursorReadout.tsx new file mode 100644 index 000000000..a6b925269 --- /dev/null +++ b/frontend/logging-view/src/components/normal/CursorReadout.tsx @@ -0,0 +1,233 @@ +import { useMemo } from "react"; +import { Button } from "@hyperloopupv/frontend-kit/components"; +import { X } from "@hyperloopupv/frontend-kit/icons"; +import { valueAt } from "../../lib/normal/cursors"; +import type { Cell } from "../../lib/normal/tableExport"; +import TableExportButtons from "./TableExportButtons"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type MeasureCursors, + type TimeDomain, + type TimeUnit, +} from "../../types/normal"; + +const format = (value: number) => + value.toLocaleString(undefined, { maximumSignificantDigits: 6 }); + +const dash = "—"; + +export default function CursorReadout({ + cursors, + signals, + domain, + timeUnit, + onClear, + onClose, +}: { + cursors: MeasureCursors; + signals: ComparisonSignal[]; + domain: TimeDomain; + timeUnit: TimeUnit; + onClear: () => void; + onClose: () => void; +}) { + const { a, b } = cursors; + const rows = useMemo( + () => + signals.map((signal) => { + const at = (cursor: number | null) => + cursor === null + ? null + : valueAt(signal.data, domain.startMs + cursor, signal.discrete); + return { signal, atA: at(a), atB: at(b) }; + }), + [signals, domain.startMs, a, b], + ); + const time = (cursor: number | null) => + cursor === null ? dash : format(cursor / TIME_DIVISORS[timeUnit]); + const deltaMs = a !== null && b !== null ? b - a : null; + const deltaTime = deltaMs === null ? null : deltaMs / TIME_DIVISORS[timeUnit]; + + const exportRows = (): Cell[][] => { + const divisor = TIME_DIVISORS[timeUnit]; + const at = (cursor: number | null) => + cursor === null ? null : cursor / divisor; + return [ + [ + "Board", + "Signal", + "Unit", + `A (${timeUnit})`, + `B (${timeUnit})`, + "At A", + "At B", + "Δ (B − A)", + `Δ / ${timeUnit}`, + ], + ...rows.map(({ signal, atA, atB }) => { + const state = (value: number | null): Cell => + value === null + ? null + : signal.discrete + ? (signal.enumLabels?.[Math.round(value)] ?? value) + : value; + const delta = + atA !== null && atB !== null && !signal.discrete ? atB - atA : null; + return [ + signal.board, + signal.name, + signal.discrete ? "State" : (signal.units ?? ""), + at(a), + at(b), + state(atA), + state(atB), + delta, + delta === null || !deltaTime ? null : delta / deltaTime, + ]; + }), + ]; + }; + + return ( +
+
+
+

Cursors

+ {a === null && b === null ? ( + + Click a plot to place A · Shift+click to place B + + ) : ( +
+ {( + [ + ["A", `${time(a)} ${timeUnit}`], + ["B", `${time(b)} ${timeUnit}`], + [ + "Δt", + deltaTime === null + ? dash + : `${format(deltaTime)} ${timeUnit}`, + ], + [ + "1/Δt", + deltaMs ? `${format(1000 / Math.abs(deltaMs))} Hz` : dash, + ], + ] as const + ).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ )} +
+
+ + + +
+
+ {rows.length > 0 && (a !== null || b !== null) && ( +
+ + + + {[ + "Signal", + "Unit", + "At A", + "At B", + "Δ (B − A)", + `Δ / ${timeUnit}`, + ].map((label, index) => ( + + ))} + + + + {rows.map(({ signal, atA, atB }) => { + const shown = (value: number | null) => + value === null + ? dash + : signal.discrete + ? (signal.enumLabels?.[Math.round(value)] ?? + format(value)) + : format(value); + const delta = + atA !== null && atB !== null && !signal.discrete + ? atB - atA + : null; + return ( + + + + + + + + + ); + })} + +
+ {label} +
+ + {signal.board} / {signal.name} + + {signal.discrete ? dash : signal.units || dash} + {shown(atA)}{shown(atB)} + {delta === null ? dash : format(delta)} + + {delta === null || !deltaTime + ? dash + : format(delta / deltaTime)} +
+
+ )} +
+ ); +} diff --git a/frontend/logging-view/src/components/normal/DashboardControls.tsx b/frontend/logging-view/src/components/normal/DashboardControls.tsx new file mode 100644 index 000000000..ea1e739c8 --- /dev/null +++ b/frontend/logging-view/src/components/normal/DashboardControls.tsx @@ -0,0 +1,552 @@ +import { + Badge, + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Popover, + PopoverContent, + PopoverTrigger, +} from "@hyperloopupv/frontend-kit/components"; +import { + Calculator, + Redo2, + Search, + Undo2, + ChartNoAxesCombined, + Ruler, + ChevronDown, + Clock, + Columns, + Eye, + Grid2X2, + Layers, + Link2Off, + Minus, + Pencil, + Plus, + RotateCcw, + Rows2, + Send, + Square, + Trash2, +} from "@hyperloopupv/frontend-kit/icons"; +import { useState } from "react"; +import { useDashboardSetting } from "../../hooks/useDashboardSetting"; +import { useDashboardTimeWindow } from "../../hooks/useDashboardTimeWindow"; +import { removeFormula } from "../../lib/normal/formula"; +import { arrangePanes, getPanes, updatePane } from "../../lib/normal/layout"; +import { useStore } from "../../store/store"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type NormalDashboard, +} from "../../types/normal"; +import TimeWindowControls from "./TimeWindowControls"; + +const PRESETS = [ + { value: "single", label: "Single", Icon: Square }, + { value: "columns", label: "Columns", Icon: Columns }, + { value: "rows", label: "Rows", Icon: Rows2 }, + { value: "grid", label: "Grid", Icon: Grid2X2 }, +] as const; + +export default function DashboardControls({ + dashboard, + signals, + compact, +}: { + dashboard: NormalDashboard; + signals: ComparisonSignal[]; + compact: boolean; +}) { + const [layoutState, setLayoutState] = useDashboardSetting( + dashboard, + "layoutState", + ); + const [, setFocusedId] = useDashboardSetting(dashboard, "focusedId"); + const [showCursors, setShowCursors] = useDashboardSetting( + dashboard, + "showCursors", + ); + const [showStatistics, setShowStatistics] = useDashboardSetting( + dashboard, + "showStatistics", + ); + const [showOrders, setShowOrders] = useDashboardSetting( + dashboard, + "showOrders", + ); + const [enumAsBackground, setEnumAsBackground] = useDashboardSetting( + dashboard, + "enumAsBackground", + ); + const [timeUnit, setTimeUnit] = useDashboardSetting(dashboard, "timeUnit"); + // Toolbar actions are dashboard-wide: they move every plot and pull any + // individually panned one back onto the shared window. + const { range, linkTimeAxis, setRangeForAll, setLinked } = + useDashboardTimeWindow(dashboard); + const [layoutOpen, setLayoutOpen] = useState(false); + const addNormalPlot = useStore((state) => state.addNormalPlot); + const assignSignals = useStore((state) => state.assignNormalSignalsToPlot); + const openFormulaEditor = useStore((state) => state.openNormalFormulaEditor); + const openSearch = useStore((state) => state.setNormalSignalSearchOpen); + const undo = useStore((state) => state.undoNormalDashboard); + const redo = useStore((state) => state.redoNormalDashboard); + const canUndo = useStore( + (state) => (state.normalHistory[dashboard.id]?.past.length ?? 0) > 0, + ); + const canRedo = useStore( + (state) => (state.normalHistory[dashboard.id]?.future.length ?? 0) > 0, + ); + const updateSettings = useStore( + (state) => state.updateNormalDashboardSettings, + ); + const formulas = dashboard.settings.formulas; + const orderCount = useStore((state) => state.orders.length); + const panes = getPanes(layoutState.tree); + const chartDisabled = dashboard.locked || signals.length === 0; + // The bar only edits the shared window; unlinked plots are moved one by one. + const timeDisabled = chartDisabled || !linkTimeAxis; + const startMs = signals.length + ? Math.min(...signals.map((signal) => signal.data.time[0])) + : 0; + const endMs = signals.length + ? Math.max( + ...signals.map( + (signal) => signal.data.time[signal.data.time.length - 1], + ), + ) + : 0; + const duration = (endMs - startMs) / TIME_DIVISORS[timeUnit]; + const formatTime = (percent: number) => + ((duration * percent) / 100).toLocaleString(undefined, { + maximumFractionDigits: 3, + }); + const preset = PRESETS.find((item) => item.value === layoutState.preset); + const LayoutIcon = preset?.Icon ?? Grid2X2; + const zoom = (factor: number) => { + const span = Math.min( + 100, + Math.max(0.01, (range.end - range.start) * factor), + ); + const start = Math.max( + 0, + Math.min(100 - span, (range.start + range.end - span) / 2), + ); + setRangeForAll({ start, end: start + span }); + }; + const layout = ( +
+ {PRESETS.map(({ value, label, Icon }) => ( + + ))} +
+ ); + const timeWindow = ( + + ); + // One menu for every overlay, so the bar speaks a single visual language + // instead of two different "pressed" idioms sitting side by side. + const overlayCount = + (showStatistics ? 1 : 0) + + (showCursors ? 1 : 0) + + ((showOrders ?? true) && orderCount > 0 ? 1 : 0); + const viewMenu = ( + + + + + + setShowStatistics(checked)} + > + + Window statistics + + setShowCursors(checked)} + > + + Cursors + + setShowOrders(checked)} + > + + Order markers + {orderCount > 0 && ( + + {orderCount} + + )} + + setEnumAsBackground(checked)} + > + + Enum background + + + setRangeForAll({ start: 0, end: 100 })} + > + + Reset zoom + + + + ); + + const mathMenu = ( + + + + + + openFormulaEditor()}> + + New formula… + + {formulas.length > 0 && } + {formulas.map((formula) => ( + + + + {formula.name} + + {formula.expression} + + + + + openFormulaEditor({ formulaId: formula.id })} + > + + Edit… + + + {panes.map((pane, index) => ( + { + if (checked) assignSignals(pane.id, [formula.id]); + else + setLayoutState((current) => ({ + ...current, + tree: updatePane(current.tree, pane.id, (plot) => ({ + ...plot, + signalIds: plot.signalIds.filter( + (id) => id !== formula.id, + ), + hiddenSignalIds: plot.hiddenSignalIds?.filter( + (id) => id !== formula.id, + ), + })), + })); + }} + > + + {pane.title || `Plot ${index + 1}`} + + + ))} + + + updateSettings(dashboard.id, (settings) => + removeFormula(settings, formula.id), + ) + } + > + + Delete formula + + + + ))} + + + ); + + const zoomControls = ( +
+ + + +
+ ); + + return ( +
+ {compact ? ( + <> + + + + + + +

Chart layout

+ {layout} +
+
+ + + + + +

Time window

+
+ {timeWindow} + {zoomControls} +
+
+
+ + ) : ( + <> + +
+ + Layout + + {layout} +
+
+ ); +} diff --git a/frontend/logging-view/src/components/normal/DashboardTabs.tsx b/frontend/logging-view/src/components/normal/DashboardTabs.tsx new file mode 100644 index 000000000..e673ddd96 --- /dev/null +++ b/frontend/logging-view/src/components/normal/DashboardTabs.tsx @@ -0,0 +1,775 @@ +import { + Button, + Checkbox, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Popover, + PopoverAnchor, + PopoverContent, + TabsList, + TabsTrigger, +} from "@hyperloopupv/frontend-kit/components"; +import { + ChevronLeft, + ChevronRight, + Copy, + FileDown, + LayoutDashboard, + Lock, + PanelTopClose, + PanelTopOpen, + Plus, + Settings2, + Unlock, + X, +} from "@hyperloopupv/frontend-kit/icons"; +import { NewDashboardMenu, SaveTemplateDialog } from "./DashboardTemplates"; +import { + DASHBOARD_FILE_EXTENSION, + dashboardFileName, + serializeDashboard, +} from "../../lib/normal/dashboardStorage"; +import { downloadText } from "../../lib/download"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { useStore } from "../../store/store"; +import { getPanes } from "../../lib/normal/layout"; +import type { DashboardTabSettings, NormalDashboard } from "../../types/normal"; + +const TAB_COLORS = [ + { name: "Orange", value: "#f97316" }, + { name: "Blue", value: "#3b82f6" }, + { name: "Green", value: "#22c55e" }, + { name: "Purple", value: "#a855f7" }, + { name: "Pink", value: "#ec4899" }, + { name: "Red", value: "#ef4444" }, +]; + +function TabEditor({ + dashboard, + position, + count, + onSave, +}: { + dashboard: NormalDashboard; + position: number; + count: number; + onSave: (settings: DashboardTabSettings) => void; +}) { + const nameId = useId(); + const [settings, setSettings] = useState(() => ({ + name: dashboard.name, + color: dashboard.color, + locked: dashboard.locked, + position, + })); + + return ( +
{ + event.preventDefault(); + if (settings.name.trim()) onSave(settings); + }} + > +
+

Tab settings

+

+ Name, color, position and edit protection. +

+
+
+
+ + event.currentTarget.select()} + onChange={(event) => + setSettings({ ...settings, name: event.target.value }) + } + className="h-8 text-xs" + /> +
+
+

Tab color

+
+
+ +
+ +
+ + +
+
+
+ +

+ Protect plots, layout, time settings and this tab. Unlock here to make + changes. +

+
+ +
+ ); +} + +export default function DashboardTabs({ + compact, + controls, + onToggleCompact, +}: { + compact: boolean; + controls?: ReactNode; + onToggleCompact: () => void; +}) { + const dashboards = useStore((state) => state.normalDashboards); + const activeId = useStore((state) => state.activeNormalDashboardId); + const addDashboard = useStore((state) => state.addNormalDashboard); + const closeDashboard = useStore((state) => state.closeNormalDashboard); + const configureDashboard = useStore( + (state) => state.configureNormalDashboard, + ); + const moveDashboard = useStore((state) => state.moveNormalDashboard); + const duplicateDashboard = useStore( + (state) => state.duplicateNormalDashboard, + ); + const [editingId, setEditingId] = useState(null); + // A list, so "Close other tabs" can reuse the same confirmation dialog + // instead of bypassing the four-chart guard. + const [closingIds, setClosingIds] = useState(null); + const closing = closingIds + ? dashboards.filter((dashboard) => closingIds.includes(dashboard.id)) + : []; + const closingDashboard = closing.length === 1 ? closing[0] : undefined; + const closingCharts = closing.reduce( + (total, dashboard) => + total + getPanes(dashboard.settings.layoutState.tree).length, + 0, + ); + // Which tab was right-clicked. Falls back to the active one so the keyboard + // path (Shift+F10 / Menu key) and a click on empty strip still act on a tab. + const [contextId, setContextId] = useState(null); + const cancelCloseRef = useRef(null); + const closeOriginRef = useRef(null); + const [draggingId, setDraggingId] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const tabListRef = useRef(null); + const tabRefs = useRef(new Map()); + const focusActive = useRef(false); + const [templateTarget, setTemplateTarget] = useState( + null, + ); + const importDashboard = useStore((state) => state.importNormalDashboard); + const exportDashboard = (dashboard: NormalDashboard) => + downloadText( + dashboardFileName(dashboard.name), + serializeDashboard(dashboard), + "application/json", + ); + const pickDashboardFile = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = `${DASHBOARD_FILE_EXTENSION},.json,application/json`; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + focusActive.current = true; + importDashboard(await file.text()); + }; + input.click(); + }; + const restoreEditorFocus = useRef(true); + const order = dashboards.map((dashboard) => dashboard.id).join(":"); + + useEffect(() => { + const tab = tabRefs.current.get(activeId); + tab?.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (focusActive.current) { + tab?.focus(); + focusActive.current = false; + } + const observer = new ResizeObserver(() => + tab?.scrollIntoView({ block: "nearest", inline: "nearest" }), + ); + if (tabListRef.current) observer.observe(tabListRef.current); + return () => observer.disconnect(); + }, [activeId, order]); + + const startEditing = (id: string) => { + restoreEditorFocus.current = true; + setEditingId(id); + }; + // Shared by the close button, the context menu and "Close other tabs": + // locked tabs are skipped, and four or more charts ask first. + const requestClose = (ids: string[], origin?: HTMLButtonElement | null) => { + const targets = dashboards.filter( + (dashboard) => ids.includes(dashboard.id) && !dashboard.locked, + ); + if (targets.length === 0) return; + const charts = targets.reduce( + (total, dashboard) => + total + getPanes(dashboard.settings.layoutState.tree).length, + 0, + ); + if (charts >= 4) { + closeOriginRef.current = origin ?? null; + setClosingIds(targets.map((dashboard) => dashboard.id)); + return; + } + focusActive.current = true; + for (const dashboard of targets) closeDashboard(dashboard.id); + }; + const menuTarget = + dashboards.find((dashboard) => dashboard.id === contextId) ?? + dashboards.find((dashboard) => dashboard.id === activeId); + const menuIndex = menuTarget ? dashboards.indexOf(menuTarget) : -1; + const menuOtherIds = dashboards + .filter((dashboard) => dashboard.id !== menuTarget?.id && !dashboard.locked) + .map((dashboard) => dashboard.id); + const insertionAt = (clientX: number) => { + const bar = tabListRef.current?.parentElement?.getBoundingClientRect(); + if (bar && clientX <= bar.left + 12) return 0; + if (bar && clientX >= bar.right - 12) return dashboards.length; + const index = dashboards.findIndex((dashboard) => { + const rect = tabRefs.current.get(dashboard.id)?.getBoundingClientRect(); + return rect && clientX < rect.left + rect.width / 2; + }); + return index < 0 ? dashboards.length : index; + }; + + return ( +
{ + if (!draggingId) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + const list = tabListRef.current; + if (list) { + const rect = list.getBoundingClientRect(); + if (event.clientX < rect.left + 28) list.scrollLeft -= 24; + else if (event.clientX > rect.right - 28) list.scrollLeft += 24; + } + setDropIndex(insertionAt(event.clientX)); + }} + onDragLeave={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) + setDropIndex(null); + }} + onDrop={(event) => { + const sourceId = event.dataTransfer.getData( + "application/x-logging-dashboard", + ); + const sourceIndex = dashboards.findIndex( + (item) => item.id === sourceId, + ); + if (sourceIndex < 0) return; + event.preventDefault(); + event.stopPropagation(); + const insertion = insertionAt(event.clientX); + moveDashboard(sourceId, insertion - (sourceIndex < insertion ? 1 : 0)); + setDraggingId(null); + setDropIndex(null); + }} + > + { + if (!open) setContextId(null); + }} + > + + + {dashboards.map((dashboard, index) => ( + { + if (!open) setEditingId(null); + }} + > + +
setContextId(dashboard.id)} + draggable={!dashboard.locked && editingId !== dashboard.id} + onDragStart={(event) => { + if ( + dashboard.locked || + (event.target as HTMLElement).closest( + "[data-tab-action]", + ) + ) { + event.preventDefault(); + return; + } + event.dataTransfer.setData( + "application/x-logging-dashboard", + dashboard.id, + ); + event.dataTransfer.effectAllowed = "move"; + setDraggingId(dashboard.id); + }} + onDragEnd={() => { + setDraggingId(null); + setDropIndex(null); + }} + > + { + if (element) tabRefs.current.set(dashboard.id, element); + else tabRefs.current.delete(dashboard.id); + }} + value={dashboard.id} + aria-label={dashboard.name} + className="border-border bg-muted/60 text-muted-foreground hover:bg-muted data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-border dark:data-[state=active]:bg-background relative h-9 max-w-64 min-w-44 justify-start gap-2 rounded-t-lg rounded-b-none border border-b-0 px-3 pr-16 text-xs data-[state=active]:font-semibold data-[state=active]:shadow-none" + style={{ + boxShadow: `inset 0 2px 0 ${dashboard.color ?? "var(--muted-foreground)"}`, + }} + title={`${dashboard.name}${dashboard.locked ? " · locked" : " · drag to reorder"} · double-click or press F2 for settings`} + onDoubleClick={() => startEditing(dashboard.id)} + onKeyDown={(event) => { + if (event.key === "F2") { + event.preventDefault(); + startEditing(dashboard.id); + } + if ( + event.altKey && + event.shiftKey && + (event.key === "ArrowLeft" || + event.key === "ArrowRight") + ) { + event.preventDefault(); + event.stopPropagation(); + focusActive.current = true; + moveDashboard( + dashboard.id, + index + (event.key === "ArrowLeft" ? -1 : 1), + ); + } + if ( + event.altKey && + event.shiftKey && + (event.key === "Home" || event.key === "End") + ) { + event.preventDefault(); + event.stopPropagation(); + focusActive.current = true; + moveDashboard( + dashboard.id, + event.key === "Home" ? 0 : dashboards.length - 1, + ); + } + }} + > + {dashboard.color ? ( + + + + {(dropIndex === index || + (dropIndex === dashboards.length && + index === dashboards.length - 1)) && ( +
+
+ { + restoreEditorFocus.current = false; + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (restoreEditorFocus.current) + tabRefs.current.get(dashboard.id)?.focus(); + }} + > + { + configureDashboard(dashboard.id, settings); + setEditingId(null); + }} + /> + +
+ ))} +
+
+ + { + if (!menuTarget) return; + focusActive.current = true; + duplicateDashboard(menuTarget.id); + }} + > + + Duplicate + + menuTarget && exportDashboard(menuTarget)} + > + + Export dashboard… + + menuTarget && setTemplateTarget(menuTarget)} + > + + Save as template… + + menuTarget && startEditing(menuTarget.id)} + > + + Rename / settings + F2 + + + menuTarget && + configureDashboard(menuTarget.id, { + name: menuTarget.name, + color: menuTarget.color, + locked: !menuTarget.locked, + position: menuIndex, + }) + } + > + {menuTarget?.locked ? ( + + ) : ( + + )} + {menuTarget?.locked ? "Unlock" : "Lock"} + + + { + if (!menuTarget) return; + focusActive.current = true; + moveDashboard(menuTarget.id, 0); + }} + > + + Move to first + + { + if (!menuTarget) return; + focusActive.current = true; + moveDashboard(menuTarget.id, dashboards.length - 1); + }} + > + + Move to last + + + menuTarget && requestClose([menuTarget.id])} + > + + Close + + requestClose(menuOtherIds)} + > + + Close other tabs + + +
+ + { + focusActive.current = true; + addDashboard(); + }} + onImportFile={pickDashboardFile} + onCreated={() => { + focusActive.current = true; + }} + /> + setTemplateTarget(null)} + /> +
+ {controls} + +
+ 0} + onOpenChange={(open) => { + if (!open) setClosingIds(null); + }} + > + { + event.preventDefault(); + cancelCloseRef.current?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (closeOriginRef.current?.isConnected) + closeOriginRef.current.focus(); + else + tabRefs.current + .get(useStore.getState().activeNormalDashboardId) + ?.focus(); + closeOriginRef.current = null; + }} + > + + + {closingDashboard + ? `Close ${closingDashboard.name}?` + : `Close ${closing.length} tabs?`} + + + {closingDashboard + ? "This dashboard contains" + : "These dashboards contain"}{" "} + {closingCharts}{" "} + {closingDashboard + ? "charts. Closing it will discard its charts, layout, and time settings." + : "charts. Closing them will discard their charts, layout, and time settings."} + + + + + + + + +
+ ); +} diff --git a/frontend/logging-view/src/components/normal/DashboardTemplates.tsx b/frontend/logging-view/src/components/normal/DashboardTemplates.tsx new file mode 100644 index 000000000..962a19ac5 --- /dev/null +++ b/frontend/logging-view/src/components/normal/DashboardTemplates.tsx @@ -0,0 +1,211 @@ +// Dashboard templates: a layout saved by name that can start a dashboard on +// any log. Stored like an exported file, and applied through the same import, +// so measurements a log lacks are left out with a warning. +import { useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, +} from "@hyperloopupv/frontend-kit/components"; +import { + ChevronDown, + Plus, + Trash2, + Upload, +} from "@hyperloopupv/frontend-kit/icons"; +import { useDashboardTemplates } from "../../hooks/useDashboardTemplates"; +import { + deleteTemplate, + saveTemplate, +} from "../../lib/normal/dashboardStorage"; +import { useStore } from "../../store/store"; +import type { NormalDashboard } from "../../types/normal"; + +const formatDate = (time: number) => + new Date(time).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); + +/** The chevron next to "New dashboard": blank, from a template, or a file. */ +export function NewDashboardMenu({ + onBlank, + onImportFile, + onCreated, +}: { + onBlank: () => void; + onImportFile: () => void; + /** Called after a tab was added, so the bar can focus it. */ + onCreated: () => void; +}) { + const templates = useDashboardTemplates(); + const importDashboard = useStore((state) => state.importNormalDashboard); + return ( + + + + + + + + Blank dashboard + + + + Templates + + {templates.length === 0 ? ( +

+ Right-click a tab and choose “Save as template…”. +

+ ) : ( + templates.map((template) => ( + { + if (importDashboard(template.file)) onCreated(); + }} + > + + {template.name} + + {formatDate(template.savedAt)} + + + + + )) + )} + + + + Import dashboard… + +
+
+ ); +} + +export function SaveTemplateDialog({ + dashboard, + onClose, +}: { + dashboard: NormalDashboard | null; + onClose: () => void; +}) { + return ( + { + if (!open) onClose(); + }} + > + + {dashboard && ( + + )} + + + ); +} + +function SaveTemplateForm({ + dashboard, + onClose, +}: { + dashboard: NormalDashboard; + onClose: () => void; +}) { + const templates = useDashboardTemplates(); + const [name, setName] = useState(dashboard.name); + const [failed, setFailed] = useState(false); + const replaces = templates.some((item) => item.name === name.trim()); + return ( +
{ + event.preventDefault(); + if (!name.trim()) return; + if (saveTemplate(name, dashboard)) onClose(); + else setFailed(true); + }} + > + + Save as template + + Plots, formulas, markers and settings, ready to start a dashboard on + any log. + + + +

+ {failed + ? "The template could not be saved: browser storage is full or blocked." + : replaces + ? "A template with this name exists and will be replaced." + : ""} +

+ + + + +
+ ); +} diff --git a/frontend/logging-view/src/components/normal/FormulaDialog.tsx b/frontend/logging-view/src/components/normal/FormulaDialog.tsx new file mode 100644 index 000000000..137cf85b3 --- /dev/null +++ b/frontend/logging-view/src/components/normal/FormulaDialog.tsx @@ -0,0 +1,362 @@ +// Editor for a dashboard's formula signals. Mounted once per dashboard and +// opened through the store, because the toolbar and the plots that launch it +// are sibling trees. +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@hyperloopupv/frontend-kit/components"; +import { Plus, X } from "@hyperloopupv/frontend-kit/icons"; +import { useMemo, useState } from "react"; +import { useDashboardSetting } from "../../hooks/useDashboardSetting"; +import { + FORMULA_REFERENCE, + nextVariableName, + parseFormula, + variableNameError, +} from "../../lib/normal/formula"; +import { getPanes } from "../../lib/normal/layout"; +import type { FormulaEditorRequest } from "../../store/slices/normalDashboardsSlice"; +import { useStore } from "../../store/store"; +import SignalSelect from "../simple/SignalSelect"; +import { + FORMULA_ID_PREFIX, + type FormulaSignal, + type NormalDashboard, +} from "../../types/normal"; + +const MAX_PANES = 8; +const MAX_VARIABLES = 5; +const NO_PLOT = "none"; +const NEW_PLOT = "new"; + +interface VariableRow { + key: string; + name: string; + source: string; +} + +function FormulaForm({ + dashboard, + request, + onClose, +}: { + dashboard: NormalDashboard; + request: FormulaEditorRequest; + onClose: () => void; +}) { + const [formulas, setFormulas] = useDashboardSetting(dashboard, "formulas"); + const addPlot = useStore((state) => state.addNormalPlot); + const assignSignals = useStore((state) => state.assignNormalSignalsToPlot); + const editing = formulas.find((item) => item.id === request.formulaId); + const panes = getPanes(dashboard.settings.layoutState.tree); + + const [name, setName] = useState(() => { + if (editing) return editing.name; + const taken = new Set(formulas.map((item) => item.name)); + let number = formulas.length + 1; + while (taken.has(`Formula ${number}`)) number++; + return `Formula ${number}`; + }); + const [units, setUnits] = useState(editing?.units ?? ""); + const [rows, setRows] = useState(() => { + if (editing) + return Object.entries(editing.variables).map(([variable, source]) => ({ + key: crypto.randomUUID(), + name: variable, + source, + })); + const sources = (request.sourceIds ?? []).slice(0, MAX_VARIABLES); + const names: string[] = []; + while (names.length < Math.max(1, sources.length)) + names.push(nextVariableName(names)); + return names.map((variable, index) => ({ + key: crypto.randomUUID(), + name: variable, + source: sources[index] ?? "", + })); + }); + const [expression, setExpression] = useState( + editing?.expression ?? + (rows.length === 2 && rows.every((row) => row.source) + ? `${rows[0].name} - ${rows[1].name}` + : ""), + ); + const [target, setTarget] = useState( + request.paneId && panes.some((pane) => pane.id === request.paneId) + ? request.paneId + : (panes[0]?.id ?? NO_PLOT), + ); + + const rowErrors = rows.map((row, index) => { + const invalid = variableNameError(row.name); + if (invalid) return invalid; + if (rows.findIndex((other) => other.name === row.name) !== index) + return `"${row.name}" is used twice`; + return null; + }); + const parsed = useMemo(() => parseFormula(expression), [expression]); + const bound = new Set( + rows.filter((row) => row.source).map((row) => row.name), + ); + const declared = new Set(rows.map((row) => row.name)); + let problem: string | null = null; + if (!expression.trim()) problem = null; + else if (!parsed.ok) problem = parsed.error; + else if (parsed.variables.length === 0) + problem = "Use at least one variable in the expression"; + else { + const unknown = parsed.variables.find((item) => !declared.has(item)); + const unbound = parsed.variables.find((item) => !bound.has(item)); + if (unknown) problem = `"${unknown}" is not one of the variables above`; + else if (unbound) problem = `Choose a measurement for "${unbound}"`; + } + const valid = + !!name.trim() && + !!expression.trim() && + !problem && + rowErrors.every((error) => !error); + + const updateRow = (key: string, changes: Partial) => + setRows((current) => + current.map((row) => (row.key === key ? { ...row, ...changes } : row)), + ); + + const save = () => { + if (!valid) return; + const formula: FormulaSignal = { + id: editing?.id ?? `${FORMULA_ID_PREFIX}${crypto.randomUUID()}`, + name: name.trim().slice(0, 60), + expression: expression.trim(), + variables: Object.fromEntries( + rows.filter((row) => row.source).map((row) => [row.name, row.source]), + ), + units: units.trim() || undefined, + }; + if (editing) { + setFormulas((current) => + current.map((item) => (item.id === formula.id ? formula : item)), + ); + } else { + setFormulas((current) => [...current, formula]); + if (target === NEW_PLOT) addPlot([formula.id]); + else if (target !== NO_PLOT) assignSignals(target, [formula.id]); + } + onClose(); + }; + + return ( +
{ + event.preventDefault(); + save(); + }} + > + + {editing ? "Edit formula" : "New formula"} + + Combine measurements into a computed signal for this dashboard. + + + +
+ + +
+ +
+

Variables

+ {rows.map((row, index) => ( +
+
+ + updateRow(row.key, { name: event.target.value.trim() }) + } + className="h-8 w-[4.5rem] shrink-0 font-mono text-xs" + /> + = +
+ updateRow(row.key, { source })} + placeholder="Choose a measurement…" + ariaLabel={`Measurement for ${row.name || `variable ${index + 1}`}`} + seriesOnly + size="sm" + /> +
+ +
+ {rowErrors[index] && ( +

{rowErrors[index]}

+ )} +
+ ))} + +
+ +
+ +

+ {problem ?? + "Measurements logged at different times are aligned by interpolation."} +

+
+ + What can I write? + +
+ {FORMULA_REFERENCE.map((entry) => ( +
+
+ {entry.label} +
+
{entry.items}
+
+ ))} +
+
+
+ + {!editing && ( + + )} + + + + + +
+ ); +} + +export default function FormulaDialog({ + dashboard, +}: { + dashboard: NormalDashboard; +}) { + const request = useStore((state) => state.normalFormulaEditor); + const close = useStore((state) => state.closeNormalFormulaEditor); + return ( + { + if (!open) close(); + }} + > + + {request && ( + + )} + + + ); +} diff --git a/frontend/logging-view/src/components/normal/SignalSearch.tsx b/frontend/logging-view/src/components/normal/SignalSearch.tsx new file mode 100644 index 000000000..5c9db142a --- /dev/null +++ b/frontend/logging-view/src/components/normal/SignalSearch.tsx @@ -0,0 +1,211 @@ +// Quick search over every measurement in the session and the dashboard's +// formulas. Enter adds the highlighted one to the active plot, Shift+Enter +// gives it a plot of its own; the dialog stays open to add several. +import { useMemo, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Input, +} from "@hyperloopupv/frontend-kit/components"; +import { Search } from "@hyperloopupv/frontend-kit/icons"; +import { getSignalName, getSignalUnits } from "../../lib/plotStudio/units"; +import { getPanes } from "../../lib/normal/layout"; +import { useStore } from "../../store/store"; +import type { NormalDashboard } from "../../types/normal"; + +const MAX_RESULTS = 200; +const MAX_PANES = 8; + +interface Entry { + id: string; + board: string; + name: string; + detail: string; + haystack: string; +} + +function SearchBody({ + dashboard, + onClose, +}: { + dashboard: NormalDashboard; + onClose: () => void; +}) { + const availableSeries = useStore((state) => state.availableSeries); + const adjData = useStore((state) => state.adjData); + const addToTarget = useStore((state) => state.addToTargetNormalPlot); + const addPlot = useStore((state) => state.addNormalPlot); + const [query, setQuery] = useState(""); + const [highlighted, setHighlighted] = useState(0); + const [lastAdded, setLastAdded] = useState(null); + const listRef = useRef(null); + + const entries = useMemo(() => { + const series = Object.entries(availableSeries).flatMap(([board, ids]) => + ids.map((measurement) => { + const id = `${board}/${measurement}`; + const name = getSignalName(adjData, id) ?? measurement; + const units = getSignalUnits(adjData, id); + return { + id, + board, + name, + detail: [measurement !== name ? measurement : null, units] + .filter(Boolean) + .join(" · "), + haystack: + `${board}/${name} ${measurement} ${units ?? ""}`.toLowerCase(), + }; + }), + ); + const formulas = dashboard.settings.formulas.map((formula) => ({ + id: formula.id, + board: "Math", + name: formula.name, + detail: formula.expression, + haystack: + `math/${formula.name} ${formula.expression} ${formula.units ?? ""}`.toLowerCase(), + })); + return [...formulas, ...series].sort((a, b) => + `${a.board}/${a.name}`.localeCompare(`${b.board}/${b.name}`), + ); + }, [availableSeries, adjData, dashboard.settings.formulas]); + + const results = useMemo(() => { + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + return entries + .filter((entry) => words.every((word) => entry.haystack.includes(word))) + .slice(0, MAX_RESULTS); + }, [entries, query]); + const current = Math.min(highlighted, Math.max(0, results.length - 1)); + const canAddPlot = + getPanes(dashboard.settings.layoutState.tree).length < MAX_PANES; + + const add = (entry: Entry | undefined, newPlot: boolean) => { + if (!entry) return; + if (newPlot) { + if (!canAddPlot) return; + addPlot([entry.id]); + } else addToTarget([entry.id]); + setLastAdded(`${entry.board} / ${entry.name}`); + }; + const move = (step: number) => { + const next = Math.max(0, Math.min(results.length - 1, current + step)); + setHighlighted(next); + listRef.current + ?.querySelector(`[data-index="${next}"]`) + ?.scrollIntoView({ block: "nearest" }); + }; + + return ( +
+ + + + Add a measurement + + + Enter adds it to the active plot · Shift+Enter puts it in a new plot + + + { + setQuery(event.target.value); + setHighlighted(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + move(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + move(-1); + } else if (event.key === "Enter") { + event.preventDefault(); + add(results[current], event.shiftKey); + } + }} + className="h-9 text-sm" + /> +
    + {results.length === 0 ? ( +
  • + No measurement matches “{query.trim()}”. +
  • + ) : ( + results.map((entry, index) => ( +
  • setHighlighted(index)} + onClick={(event) => add(entry, event.shiftKey)} + > + + {entry.board} / + + {entry.name} + {entry.detail && ( + + {entry.detail} + + )} +
  • + )) + )} +
+
+ + {lastAdded ? `Added ${lastAdded}` : `${entries.length} signals`} + + +
+
+ ); +} + +export default function SignalSearch({ + dashboard, +}: { + dashboard: NormalDashboard; +}) { + const open = useStore((state) => state.normalSignalSearchOpen); + const setOpen = useStore((state) => state.setNormalSignalSearchOpen); + return ( + + + {open && ( + setOpen(false)} /> + )} + + + ); +} diff --git a/frontend/logging-view/src/components/normal/TableExportButtons.tsx b/frontend/logging-view/src/components/normal/TableExportButtons.tsx new file mode 100644 index 000000000..42eb87d1f --- /dev/null +++ b/frontend/logging-view/src/components/normal/TableExportButtons.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import { Button } from "@hyperloopupv/frontend-kit/components"; +import { + Check, + Clipboard, + FileSpreadsheet, +} from "@hyperloopupv/frontend-kit/icons"; +import { copyText, downloadText, safeFileName } from "../../lib/download"; +import { exportTimestamp } from "../../lib/plotStudio/format"; +import { toCsv, toTsv, type Cell } from "../../lib/normal/tableExport"; + +/** "Copy" and "CSV" for a panel's table; `rows()` includes the header row. */ +export default function TableExportButtons({ + name, + label, + rows, + disabled, +}: { + /** Base of the downloaded file name. */ + name: string; + /** What is exported, for the accessible names: "statistics", "cursors". */ + label: string; + rows: () => Cell[][]; + disabled?: boolean; +}) { + const [copied, setCopied] = useState<"ok" | "failed" | null>(null); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(null), 1500); + return () => clearTimeout(timer); + }, [copied]); + + return ( + <> + + + + ); +} diff --git a/frontend/logging-view/src/components/normal/TimeWindowControls.tsx b/frontend/logging-view/src/components/normal/TimeWindowControls.tsx new file mode 100644 index 000000000..aae5f0092 --- /dev/null +++ b/frontend/logging-view/src/components/normal/TimeWindowControls.tsx @@ -0,0 +1,190 @@ +import { Button, Input } from "@hyperloopupv/frontend-kit/components"; +import { + ChevronLeft, + ChevronRight, + Link2, + Link2Off, +} from "@hyperloopupv/frontend-kit/icons"; +import { useId, useState } from "react"; +import type { TimeRange, TimeUnit } from "../../types/normal"; + +export default function TimeWindowControls({ + duration, + range, + timeUnit, + linked, + onUnitChange, + onChange, + onLinkedChange, +}: { + duration: number; + range: TimeRange; + timeUnit: TimeUnit; + linked: boolean; + onUnitChange: (unit: TimeUnit) => void; + onChange: (range: TimeRange) => void; + onLinkedChange: (linked: boolean) => void; +}) { + const errorId = useId(); + const [draft, setDraft] = useState<{ start: string; end: string } | null>( + null, + ); + const [error, setError] = useState(""); + const values = draft ?? { + start: String(Number(((duration * range.start) / 100).toPrecision(12))), + end: String(Number(((duration * range.end) / 100).toPrecision(12))), + }; + // Unlinked, there is no single window to edit: each plot holds its own, and + // writing one here would silently pull them all back together. + const editable = linked && duration > 0; + const lockedHint = linked + ? undefined + : "Link the charts to set a shared time window"; + const move = (direction: number) => { + const span = range.end - range.start; + const start = Math.max( + 0, + Math.min(100 - span, range.start + direction * span), + ); + onChange({ start, end: start + span }); + }; + + return ( +
{ + event.preventDefault(); + if (!editable) return; + const start = Number(values.start); + const end = Number(values.end); + if ( + !values.start.trim() || + !values.end.trim() || + !Number.isFinite(start) || + !Number.isFinite(end) || + start < 0 || + end > duration || + start >= end + ) { + setError( + `Enter a start before the end, between 0 and ${duration} ${timeUnit}.`, + ); + return; + } + setError(""); + setDraft(null); + onChange({ + start: (start / duration) * 100, + end: (end / duration) * 100, + }); + }} + > + + {(["start", "end"] as const).map((field) => ( + + ))} + + +
+ + +
+ {error && ( + + )} +
+ ); +} diff --git a/frontend/logging-view/src/components/normal/WindowStatistics.tsx b/frontend/logging-view/src/components/normal/WindowStatistics.tsx new file mode 100644 index 000000000..09a5ca9c7 --- /dev/null +++ b/frontend/logging-view/src/components/normal/WindowStatistics.tsx @@ -0,0 +1,340 @@ +import { useMemo } from "react"; +import { + Button, + Checkbox, + Popover, + PopoverContent, + PopoverTrigger, +} from "@hyperloopupv/frontend-kit/components"; +import { Settings2, X } from "@hyperloopupv/frontend-kit/icons"; +import { summarizeWindow } from "../../lib/normal/statistics"; +import type { + ComparisonSignal, + StatisticsColumn, + TimeDomain, + TimeRange, +} from "../../types/normal"; +import { DEFAULT_STATISTICS_COLUMNS } from "../../types/normal"; +import type { Cell } from "../../lib/normal/tableExport"; +import TableExportButtons from "./TableExportButtons"; + +const format = (value: number) => + value.toLocaleString(undefined, { maximumSignificantDigits: 6 }); + +export interface StatisticsGroup { + id: string; + label: string; + range: TimeRange; + signals: ComparisonSignal[]; +} + +const COLUMNS: { id: StatisticsColumn; label: string }[] = [ + { id: "samples", label: "Samples" }, + { id: "min", label: "Minimum" }, + { id: "max", label: "Maximum" }, + { id: "mean", label: "Average" }, + { id: "std", label: "Standard deviation" }, + { id: "firstLast", label: "First → last" }, + { id: "changes", label: "State changes" }, +]; + +export default function WindowStatistics({ + groups, + domain, + onClose, + columns, + onColumnsChange, +}: { + // One group while the time axis is linked; one per plot when it is not, + // because then each plot summarises a different stretch of the recording. + groups: StatisticsGroup[]; + domain: TimeDomain; + onClose: () => void; + columns?: StatisticsColumn[]; + onColumnsChange: (columns: StatisticsColumn[]) => void; +}) { + // Existing saved dashboards predate this preference. + const visibleColumns = columns ?? DEFAULT_STATISTICS_COLUMNS; + const sections = useMemo(() => { + const duration = domain.endMs - domain.startMs; + return groups.map((group) => { + const start = domain.startMs + (duration * group.range.start) / 100; + const end = domain.startMs + (duration * group.range.end) / 100; + return { + ...group, + rows: group.signals.map((signal) => ({ + signal, + stats: summarizeWindow(signal.data, start, end), + })), + }; + }); + }, [groups, domain]); + + // Raw numbers, not the localized display strings, so a spreadsheet can use + // them; enum states go out by name. + const exportRows = (): Cell[][] => { + const columns = COLUMNS.filter(({ id }) => visibleColumns.includes(id)); + const grouped = sections.length > 1; + const header: Cell[] = [ + ...(grouped ? ["Plot", "From (%)", "To (%)"] : []), + "Board", + "Signal", + "Unit", + ...columns.flatMap(({ id, label }) => + id === "firstLast" ? ["First", "Last"] : [label], + ), + ]; + const body = sections.flatMap((section) => + section.rows.map(({ signal, stats }) => { + const state = (value: number): Cell => + signal.discrete ? (signal.enumLabels?.[value] ?? value) : value; + const numeric = (value: number): Cell => + signal.discrete ? null : value; + const cells = columns.flatMap(({ id }): Cell[] => { + if (!stats) return id === "firstLast" ? [null, null] : [null]; + switch (id) { + case "samples": + return [stats.count]; + case "min": + return [numeric(stats.min)]; + case "max": + return [numeric(stats.max)]; + case "mean": + return [numeric(stats.mean)]; + case "std": + return [numeric(stats.std)]; + case "firstLast": + return [state(stats.first), state(stats.last)]; + case "changes": + return [signal.discrete ? stats.changes : null]; + } + }); + return [ + ...(grouped + ? [section.label, section.range.start, section.range.end] + : []), + signal.board, + signal.name, + signal.discrete ? "State" : (signal.units ?? ""), + ...cells, + ]; + }), + ); + return [header, ...body]; + }; + + const toggleColumn = (column: StatisticsColumn, checked: boolean) => { + if (checked) { + onColumnsChange( + COLUMNS.filter( + ({ id }) => visibleColumns.includes(id) || id === column, + ).map(({ id }) => id), + ); + } else if (visibleColumns.length > 1) { + onColumnsChange(visibleColumns.filter((id) => id !== column)); + } + }; + + return ( +
+
+
+

Window statistics

+ + {groups.length === 1 + ? `${groups[0].range.start.toFixed(1)}–${groups[0].range.end.toFixed(1)}% of recording` + : "Per-plot windows"} + +
+
+

+ Original values · Recorded samples only · Average is sample-weighted +

+ section.rows.length === 0)} + /> + + + + + +

Visible metrics

+

+ Saved for this dashboard. +

+
+ {COLUMNS.map(({ id, label }) => { + const checked = visibleColumns.includes(id); + return ( + + ); + })} +
+
+
+ +
+
+
+ + + + {[ + "Signal", + "Unit", + ...COLUMNS.filter(({ id }) => visibleColumns.includes(id)).map( + ({ label }) => label, + ), + ].map((label, index) => ( + + ))} + + + {sections.map((section) => ( + + {sections.length > 1 && ( + + + + )} + {section.rows.map(({ signal, stats }) => { + const state = (value: number) => + signal.discrete + ? (signal.enumLabels?.[value] ?? format(value)) + : format(value); + return ( + + + + {stats ? ( + <> + {visibleColumns.includes("samples") && ( + + )} + {visibleColumns.includes("min") && ( + + )} + {visibleColumns.includes("max") && ( + + )} + {visibleColumns.includes("mean") && ( + + )} + {visibleColumns.includes("std") && ( + + )} + {visibleColumns.includes("firstLast") && ( + + )} + {visibleColumns.includes("changes") && ( + + )} + + ) : ( + + )} + + ); + })} + + ))} +
+ {label} +
+ {section.label} + + {section.range.start.toFixed(1)}– + {section.range.end.toFixed(1)}% + +
+ + + + + {signal.board} /{" "} + + {signal.name} + + + + {signal.discrete ? "State" : signal.units || "—"} + + {stats.count.toLocaleString()} + + {signal.discrete ? "—" : format(stats.min)} + + {signal.discrete ? "—" : format(stats.max)} + + {signal.discrete ? "—" : format(stats.mean)} + + {signal.discrete ? "—" : format(stats.std)} + + {state(stats.first)} → {state(stats.last)} + + {signal.discrete + ? stats.changes.toLocaleString() + : "—"} + + No samples in this window +
+
+
+ ); +} diff --git a/frontend/logging-view/src/components/normal/XYChart.tsx b/frontend/logging-view/src/components/normal/XYChart.tsx new file mode 100644 index 000000000..60ab79126 --- /dev/null +++ b/frontend/logging-view/src/components/normal/XYChart.tsx @@ -0,0 +1,220 @@ +// One signal against another, inside the plot's current time window: each +// point is a pair of values the two signals had at the same instant, aligned +// the way formulas align their inputs. +import * as echarts from "echarts"; +import { useEffect, useMemo, useRef, type Ref } from "react"; +import { alignOnGrid } from "../../lib/normal/formula"; +import { + chartBackground, + useChartHandle, + type ChartHandle, +} from "../../lib/normal/chartImage"; +import { lowerBound, upperBound } from "../../lib/plotStudio/range"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type TimeDomain, + type TimeRange, + type TimeUnit, +} from "../../types/normal"; + +const MAX_POINTS = 8_000; + +const escapeHtml = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + +const format = (value: number) => + value.toLocaleString(undefined, { maximumSignificantDigits: 6 }); + +const label = (signal: ComparisonSignal) => + `${signal.board} / ${signal.name}${signal.units ? ` (${signal.units})` : ""}`; + +/** [x, y, time] triples of `y` against `x` within [fromMs, toMs]. */ +function pairs( + x: ComparisonSignal, + y: ComparisonSignal, + fromMs: number, + toMs: number, + domainStartMs: number, + divisor: number, +): number[][] { + // Cut to the window first (plus a neighbour each side for interpolation), + // so a short window over a long log stays cheap. + const cut = (signal: ComparisonSignal) => { + const { time, value } = signal.data; + const first = Math.max(0, lowerBound(time, fromMs) - 1); + const last = Math.min(time.length, upperBound(time, toMs) + 1); + return { + data: { + time: time.subarray(first, last), + value: value.subarray(first, last), + }, + discrete: signal.discrete, + }; + }; + const aligned = alignOnGrid([cut(x), cut(y)]); + if (!aligned) return []; + const { grid, values } = aligned; + const inside: number[] = []; + for (let i = 0; i < grid.length; i++) + if ( + grid[i] >= fromMs && + grid[i] <= toMs && + Number.isFinite(values[0][i]) && + Number.isFinite(values[1][i]) + ) + inside.push(i); + const stride = Math.max(1, Math.ceil(inside.length / MAX_POINTS)); + const out: number[][] = []; + for (let k = 0; k < inside.length; k += stride) { + const i = inside[k]; + out.push([values[0][i], values[1][i], (grid[i] - domainStartMs) / divisor]); + } + return out; +} + +export default function XYChart({ + x, + signals, + domain, + range, + timeUnit, + isDarkMode, + handleRef, +}: { + x: ComparisonSignal; + /** Drawn on the y axis; discrete ones are left out by the caller. */ + signals: ComparisonSignal[]; + domain: TimeDomain; + range: TimeRange; + timeUnit: TimeUnit; + isDarkMode: boolean; + handleRef?: Ref; +}) { + const elementRef = useRef(null); + const chartRef = useRef(null); + useChartHandle(handleRef, chartRef, chartBackground(isDarkMode)); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const chart = echarts.init(element); + chartRef.current = chart; + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(element); + return () => { + observer.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + const series = useMemo(() => { + const duration = domain.endMs - domain.startMs; + const fromMs = domain.startMs + (duration * range.start) / 100; + const toMs = domain.startMs + (duration * range.end) / 100; + return signals.map((signal) => ({ + signal, + data: pairs( + x, + signal, + fromMs, + toMs, + domain.startMs, + TIME_DIVISORS[timeUnit], + ), + })); + }, [x, signals, domain, range, timeUnit]); + + useEffect(() => { + // Effects run in declaration order: on mount the chart exists already. + const chart = chartRef.current; + if (!chart) return; + const text = isDarkMode ? "#d5d8df" : "#374151"; + const muted = isDarkMode ? "#9ba3b2" : "#6b7280"; + const grid = isDarkMode ? "#3a404b" : "#e6e9ef"; + const yUnits = [...new Set(signals.map((signal) => signal.units ?? ""))]; + const axis = (name: string) => ({ + type: "value" as const, + scale: true, + name, + nameLocation: "middle" as const, + nameTextStyle: { color: muted, fontSize: 11 }, + axisLine: { lineStyle: { color: grid } }, + axisTick: { show: false }, + axisLabel: { color: muted, fontSize: 10, hideOverlap: true }, + splitLine: { lineStyle: { color: grid, type: "dashed" as const } }, + }); + chart.setOption( + { + backgroundColor: "transparent", + animation: false, + textStyle: { color: text }, + grid: { left: 72, right: 22, top: 22, bottom: 54 }, + xAxis: { ...axis(label(x)), nameGap: 30 }, + yAxis: { + ...axis( + signals.length === 1 + ? label(signals[0]) + : yUnits.filter(Boolean).join(" · "), + ), + nameGap: 48, + }, + tooltip: { + trigger: "item", + confine: true, + backgroundColor: isDarkMode + ? "rgba(34,39,48,0.97)" + : "rgba(255,255,255,0.98)", + borderColor: grid, + textStyle: { color: text, fontSize: 12 }, + formatter: (raw: unknown) => { + const point = raw as { seriesIndex: number; data: number[] }; + const signal = signals[point.seriesIndex]; + if (!signal) return ""; + const [xValue, yValue, time] = point.data; + return ( + `${format(time)} ${timeUnit}` + + `
${escapeHtml(label(x))} ${format(xValue)}
` + + `
● ${escapeHtml(label(signal))} ${format(yValue)}
` + ); + }, + }, + dataZoom: [ + { type: "inside", xAxisIndex: 0, filterMode: "none" }, + { type: "inside", yAxisIndex: 0, filterMode: "none" }, + ], + series: series.map(({ signal, data }) => ({ + id: signal.id, + name: label(signal), + type: "line", + data, + showSymbol: true, + symbolSize: 3, + lineStyle: { color: signal.color, width: 1, opacity: 0.45 }, + itemStyle: { color: signal.color }, + emphasis: { focus: "series" }, + })), + }, + true, + ); + }, [series, signals, x, isDarkMode, timeUnit]); + + const empty = series.every(({ data }) => data.length === 0); + return ( +
+
+ {empty && ( +

+ {signals.length === 0 + ? "Add a numeric measurement to draw it against the X axis." + : "These signals do not overlap in time inside this window."} +

+ )} +
+ ); +} diff --git a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx index d77e81f7d..1d13cd8cd 100644 --- a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx @@ -9,33 +9,44 @@ import { SidebarHeader, SidebarSeparator, } from "@hyperloopupv/frontend-kit/components"; +import { useStore } from "../../store/store"; import FolderPickerGroup from "./FolderPickerGroup"; +import OrdersTimelineControl from "./OrdersTimelineControl"; import SeriesGroup from "./SeriesGroup"; import SeriesSelectionBar from "./SeriesSelectionBar"; import SidebarToggleHandle from "./SidebarToggleHandle"; import ThemeToggleItem from "./ThemeToggleItem"; -const AppSidebar = ({ ...props }: React.ComponentProps) => ( - - {/* Fixed — always visible regardless of scroll position */} - - - - - +const AppSidebar = ({ ...props }: React.ComponentProps) => { + const hasLoadedSession = useStore((s) => s.folderName !== null); - {/* Scrollable area for series selection */} - - - + return ( + + {/* Fixed — always visible regardless of scroll position */} + + + {hasLoadedSession && } + + - -
- - + {/* Scrollable area for series selection */} + + + - - -); + + {hasLoadedSession && ( + <> + + + + )} + + + + + + ); +}; export default AppSidebar; diff --git a/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx b/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx index 0971d45eb..2e48c8ed9 100644 --- a/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx +++ b/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx @@ -21,6 +21,7 @@ import { } from "@hyperloopupv/frontend-kit/icons"; import { cn } from "@hyperloopupv/frontend-kit/lib"; import { useCallback, useRef, useState } from "react"; +import { useSessionFolder } from "../../hooks/useSessionFolder"; import { useStore } from "../../store/store"; import type { DroppedFile } from "../../types/session"; import SessionStatusBadge from "./SessionStatusBadge"; @@ -85,12 +86,18 @@ const FolderPickerGroup = () => { const isSessionPanelOpen = useStore((s) => s.isSessionPanelOpen); const setSessionPanelOpen = useStore((s) => s.setSessionPanelOpen); + const { canOpenPaths, pickFolder } = useSessionFolder(); const inputRef = useRef(null); // Counter tracks nested dragenter/dragleave pairs to avoid flicker. const dragCounter = useRef(0); const [isDragOver, setIsDragOver] = useState(false); - const handleButtonClick = () => inputRef.current?.click(); + // In the desktop app the native dialog returns a path, which is what lets a + // session be remembered as recent; the hidden input stays the browser path. + const handleButtonClick = () => { + if (canOpenPaths) void pickFolder(); + else inputRef.current?.click(); + }; const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; diff --git a/frontend/logging-view/src/components/sidebar/Logo.tsx b/frontend/logging-view/src/components/sidebar/Logo.tsx index 43520de58..785d9a59e 100644 --- a/frontend/logging-view/src/components/sidebar/Logo.tsx +++ b/frontend/logging-view/src/components/sidebar/Logo.tsx @@ -1,6 +1,6 @@ import { SidebarMenuButton } from "@hyperloopupv/frontend-kit/components"; import { Link } from "react-router"; -import logo from "@hyperloopupv/frontend-kit/outreach/h11/h11-isotipo_black.svg"; +import logo from "@hyperloopupv/frontend-kit/outreach/main/logo_icon.svg"; const Logo = () => ( @@ -9,7 +9,9 @@ const Logo = () => ( tooltip="Hyperloop UPV — Logging View" className="text-base font-bold" > - Hyperloop UPV + + Hyperloop UPV + Logging View diff --git a/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx b/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx new file mode 100644 index 000000000..382f659f7 --- /dev/null +++ b/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx @@ -0,0 +1,229 @@ +// A session-wide view of command events. Orders are not telemetry series, so +// they live in the sidebar footer and open their own time-oriented view. +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from "@hyperloopupv/frontend-kit/components"; +import { Send } from "@hyperloopupv/frontend-kit/icons"; +import * as echarts from "echarts"; +import { useEffect, useRef, useState } from "react"; +import { formatOrderAnnotation } from "../../lib/orders"; +import { useStore } from "../../store/store"; + +interface OrderPoint { + id: string; + value: [number, string]; + from: string; + to: string; + annotation: string; +} + +const escapeHtml = (value: string) => value.replace(/&/g, "&").replace(//g, ">"); +const routeColumnWidth = (chartWidth: number) => Math.min(220, Math.max(120, Math.round(chartWidth * 0.28))); +const describeParameters = (parameters: Record | null) => + parameters && Object.keys(parameters).length > 0 + ? Object.entries(parameters).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`).join(" · ") + : "No parameters"; + +function OrdersTimelineChart() { + const orders = useStore((s) => s.orders); + const isDarkMode = useStore((s) => s.isDarkMode); + const elementRef = useRef(null); + const chartRef = useRef(null); + const rangeRef = useRef({ start: 0, end: 100 }); + const [selectedId, setSelectedId] = useState(null); + const selectedOrder = orders.find((order) => order.id === selectedId) ?? orders[0]; + const routeCount = new Set(orders.map((order) => `${order.from} → ${order.to}`)).size; + + const zoom = (factor: number) => { + const chart = chartRef.current; + if (!chart) return; + const { start, end } = rangeRef.current; + const width = Math.min(100, Math.max(1, (end - start) * factor)); + const midpoint = (start + end) / 2; + const nextStart = Math.max(0, Math.min(100 - width, midpoint - width / 2)); + chart.dispatchAction({ type: "dataZoom", start: nextStart, end: nextStart + width }); + }; + + const resetZoom = () => { + chartRef.current?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); + }; + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const chart = echarts.init(element); + chartRef.current = chart; + const updateRange = () => { + const current = (chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> }).dataZoom?.[0]; + if (current?.start !== undefined && current.end !== undefined) { + rangeRef.current = { start: current.start, end: current.end }; + } + }; + chart.on("datazoom", updateRange); + const selectOrder = (params: { data?: unknown }) => { + const point = params.data as OrderPoint | undefined; + if (point?.id) setSelectedId(point.id); + }; + chart.on("click", selectOrder); + const observer = new ResizeObserver(() => { + const labelSpace = routeColumnWidth(element.clientWidth); + chart.setOption({ grid: { left: labelSpace }, yAxis: { axisLabel: { width: labelSpace - 30 } } }); + chart.resize(); + }); + observer.observe(element); + return () => { + observer.disconnect(); + chart.off("datazoom", updateRange); + chart.off("click", selectOrder); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + const rows = [...new Set(orders.map((order) => `${order.from} → ${order.to}`))]; + const points: OrderPoint[] = orders.map((order) => ({ + id: order.id, + value: [order.time, `${order.from} → ${order.to}`], + from: order.from, + to: order.to, + annotation: formatOrderAnnotation(order.name, order.parameters), + })); + const text = isDarkMode ? "#f9fafb" : "#111827"; + const grid = isDarkMode ? "#383838" : "#e5e7eb"; + const background = isDarkMode ? "#181818" : "#ffffff"; + const labelSpace = routeColumnWidth(elementRef.current?.clientWidth ?? 0); + + chart.setOption({ + backgroundColor: "transparent", + animation: false, + grid: { left: labelSpace, right: 28, top: 16, bottom: 56 }, + tooltip: { + trigger: "item", + backgroundColor: isDarkMode ? "rgba(24,24,24,0.96)" : "rgba(255,255,255,0.98)", + borderColor: grid, + textStyle: { color: text }, + formatter: (params: { data: unknown }) => { + const point = params.data as OrderPoint; + return `${point.annotation}
From: ${escapeHtml(point.from)}
To: ${escapeHtml(point.to)}
Time: ${point.value[0].toFixed(0)} ms`; + }, + }, + xAxis: { + type: "value", + name: "Time (ms)", + nameLocation: "middle", + nameGap: 34, + nameTextStyle: { color: isDarkMode ? "#a1a1aa" : "#6b7280" }, + axisLine: { lineStyle: { color: grid } }, + axisLabel: { color: isDarkMode ? "#a1a1aa" : "#6b7280", hideOverlap: true }, + splitLine: { lineStyle: { color: grid, type: "dashed", opacity: 0.7 } }, + }, + yAxis: { + type: "category", + data: rows, + inverse: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: text, width: labelSpace - 30, overflow: "truncate", margin: 12 }, + splitLine: { show: false }, + splitArea: { show: true, areaStyle: { color: ["transparent", isDarkMode ? "#202020" : "#f8fafc"] } }, + }, + dataZoom: [{ type: "inside", xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: false }], + series: [{ + type: "scatter", + data: points, + symbolSize: 13, + itemStyle: { color: "#ff7f0e", borderColor: background, borderWidth: 2 }, + emphasis: { scale: 1.3 }, + }], + }, true); + }, [orders, isDarkMode]); + + return ( +
+
+
+

Execution timeline

+

Wheel to zoom · drag to move · select a marker for details

+
+
+ + + +
+
+
From → To
+
+ {selectedOrder && ( +
+
+

{selectedOrder.name}

+

+ {selectedOrder.from} → {selectedOrder.to} · {describeParameters(selectedOrder.parameters)} +

+
+ {selectedOrder.time.toLocaleString()} ms +
+ )} +
+ ); +} + +function OrdersTimelineModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const orders = useStore((s) => s.orders); + const routeCount = new Set(orders.map((order) => `${order.from} → ${order.to}`)).size; + + return ( + { if (!nextOpen) onClose(); }}> + + +
+
+ +
+ Orders +
+

+ {orders.length} command event{orders.length !== 1 && "s"} across {routeCount} route{routeCount !== 1 && "s"} +

+
+ {orders.length > 0 ? ( +
+ +
+ ) : ( +
+ No orders were recorded in this session. +
+ )} +
+
+ ); +} + +export default function OrdersTimelineControl() { + const [open, setOpen] = useState(false); + const ordersCount = useStore((s) => s.orders.length); + + return ( + <> + + + setOpen(true)}> + + Orders + + {ordersCount > 0 && ( + + {ordersCount} + + )} + + + setOpen(false)} /> + + ); +} diff --git a/frontend/logging-view/src/components/sidebar/RecentSessions.tsx b/frontend/logging-view/src/components/sidebar/RecentSessions.tsx new file mode 100644 index 000000000..80fa0e27d --- /dev/null +++ b/frontend/logging-view/src/components/sidebar/RecentSessions.tsx @@ -0,0 +1,83 @@ +// The last session folders opened through the desktop app, offered on the +// empty state. Hidden in a plain browser, where a path cannot be reopened. +import { Button } from "@hyperloopupv/frontend-kit/components"; +import { Folder, X } from "@hyperloopupv/frontend-kit/icons"; +import { useStore } from "../../store/store"; +import { useSessionFolder } from "../../hooks/useSessionFolder"; + +const MINUTE = 60_000; + +function relativeTime(at: number): string { + const elapsed = Date.now() - at; + if (elapsed < MINUTE) return "just now"; + if (elapsed < 60 * MINUTE) return `${Math.round(elapsed / MINUTE)} min ago`; + if (elapsed < 48 * 60 * MINUTE) + return `${Math.round(elapsed / (60 * MINUTE))} h ago`; + return new Date(at).toLocaleDateString(); +} + +export default function RecentSessions({ limit = 5 }: { limit?: number }) { + const recentSessions = useStore((state) => state.recentSessions); + const forgetRecentSession = useStore((state) => state.forgetRecentSession); + const { canOpenPaths, openPath, busyPath, failure } = useSessionFolder(); + + if (!canOpenPaths || recentSessions.length === 0) return null; + + return ( +
+

+ Recent sessions +

+
    + {recentSessions.slice(0, limit).map((session) => ( +
  • +
    + + +
    + {failure?.path === session.path && ( +

    + {failure.message} — the folder may have moved; remove it from + the list. +

    + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/logging-view/src/components/sidebar/SeriesGroup.tsx b/frontend/logging-view/src/components/sidebar/SeriesGroup.tsx index 0a10b2c67..fbaefad00 100644 --- a/frontend/logging-view/src/components/sidebar/SeriesGroup.tsx +++ b/frontend/logging-view/src/components/sidebar/SeriesGroup.tsx @@ -46,11 +46,13 @@ import { } from "@hyperloopupv/frontend-kit/components"; import { ChevronRight, GripVertical, Layers, Plus } from "@hyperloopupv/frontend-kit/icons"; import { cn, getTypeBadgeClass } from "@hyperloopupv/frontend-kit/lib"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation } from "react-router"; import { useAssignSignalsToPlot } from "../simple/hooks/useStudioSignals"; -import type { PlotState } from "../../types/plotStudio"; import type { AdjMeasurement, AdjPacket } from "../../types/session"; import { useStore } from "../../store/store"; +import { useSeriesSelection } from "../../hooks/useSeriesSelection"; +import { getPanes } from "../../lib/normal/layout"; // Converts a packet period+unit to a compact display string (e.g. "500 µs", "2 ms"). const formatPeriod = (period?: number, unit?: string): string | null => { @@ -72,25 +74,30 @@ export interface ContextTarget { } interface SeriesRowProps { + selectionLocked: boolean; dragKey: string; // "BOARD/measId" measId: string; checked: boolean; meta?: AdjMeasurement; period: string | null; - onToggle: () => void; + /** Gets the click when there is one (not for keyboard activation). */ + onToggle: (click?: MouseEvent) => void; /** Ids to drag when a drag starts from this row (whole selection, or just this row). */ dragIds: string[]; onContextTarget: (target: ContextTarget) => void; /** tanstack-virtual's remeasure ref — rows vary in height (badges/enum * lists), so actual size is measured after mount rather than assumed. */ measureRef?: (el: HTMLElement | null) => void; + /** Normal mode: a double-click adds the measurement to the active plot. */ + quickAdd?: boolean; } // Own component (not inlined in the .map() below) because useDraggable is a // hook — it must be called once per row, not once for the whole list. -function SeriesRow({ dragKey, measId, checked, meta, period, onToggle, dragIds, onContextTarget, measureRef }: SeriesRowProps) { +function SeriesRow({ selectionLocked, dragKey, measId, checked, meta, period, onToggle, dragIds, onContextTarget, measureRef, quickAdd }: SeriesRowProps) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: dragKey, + disabled: selectionLocked, data: { ids: dragIds }, }); @@ -112,12 +119,15 @@ function SeriesRow({ dragKey, measId, checked, meta, period, onToggle, dragIds, className={cn("pb-1", isDragging && "opacity-30")} > onToggle(event.nativeEvent)} + title={quickAdd ? "Double-click to add to the active plot" : undefined} + aria-disabled={selectionLocked} className="group/row h-auto min-w-0 cursor-grab gap-2 py-1.5 active:cursor-grabbing" > onToggle()} className="pointer-events-none size-3.5 shrink-0" />
@@ -169,23 +179,27 @@ function SeriesRow({ dragKey, measId, checked, meta, period, onToggle, dragIds, } interface BoardSectionProps { + selectionLocked: boolean; boardName: string; measIds: string[]; selectedSeries: Record; totalSelectedCount: number; measurementMeta: Record; measPacket: Record; - toggleSeries: (key: string) => void; - plots: PlotState[]; - addStudioPlot: () => string; - assignSignalsToPlot: (plotId: string, signalIds: string[]) => Promise; + toggleSeries: (key: string, click?: MouseEvent) => void; + plots: Array<{ id: string; name: string }>; + quickAdd: boolean; + canCreatePlot: boolean; + onCreatePlot: (signalIds: string[]) => void; + onAddToPlot: (plotId: string, signalIds: string[]) => void; } // One board's collapsible section, with ONE shared ContextMenu covering all // its rows (see file header comment for why not one per row). function BoardSection({ + selectionLocked, boardName, measIds, selectedSeries, totalSelectedCount, measurementMeta, measPacket, - toggleSeries, plots, addStudioPlot, assignSignalsToPlot, + toggleSeries, plots, canCreatePlot, onCreatePlot, onAddToPlot, quickAdd, }: BoardSectionProps) { const [contextTarget, setContextTarget] = useState(null); // Controlled (not defaultOpen) so opening re-renders BoardSection itself — @@ -215,12 +229,11 @@ function BoardSection({ const handleNewPlot = () => { if (!contextTarget) return; - const plotId = addStudioPlot(); - void assignSignalsToPlot(plotId, contextTarget.dragIds); + onCreatePlot(contextTarget.dragIds); }; const handleAddToPlot = (plotId: string) => { if (!contextTarget) return; - void assignSignalsToPlot(plotId, contextTarget.dragIds); + onAddToPlot(plotId, contextTarget.dragIds); }; return ( @@ -282,12 +295,14 @@ function BoardSection({ }} > toggleSeries(key)} + onToggle={(click) => toggleSeries(key, click)} + quickAdd={quickAdd} dragIds={dragIds} onContextTarget={setContextTarget} /> @@ -298,12 +313,17 @@ function BoardSection({
- + New Plot - + Add to Plot @@ -317,6 +337,7 @@ function BoardSection({ contextTarget && toggleSeries(contextTarget.key)} > @@ -331,16 +352,91 @@ function BoardSection({ } const SeriesGroup = () => { + const isNormal = !useLocation().pathname.startsWith("/simple"); const availableSeries = useStore((s) => s.availableSeries); const adjData = useStore((s) => s.adjData); - const selectedSeries = useStore((s) => s.selectedSeries); - const toggleSeries = useStore((s) => s.toggleSeries); + const { selectedSeries, toggleSeries, isSelectionLocked } = useSeriesSelection(); const studioPlots = useStore((s) => s.studioPlots); const addStudioPlot = useStore((s) => s.addStudioPlot); - const assignSignalsToPlot = useAssignSignalsToPlot(); + const addNormalPlot = useStore((s) => s.addNormalPlot); + const assignNormalSignalsToPlot = useStore( + (s) => s.assignNormalSignalsToPlot, + ); + const dashboard = useStore((s) => + s.normalDashboards.find( + (item) => item.id === s.activeNormalDashboardId, + ), + ); + const assignStudioSignals = useAssignSignalsToPlot(); + const addToTargetNormalPlot = useStore((s) => s.addToTargetNormalPlot); + + // Normal mode: a double-click adds the measurement to the active plot. Its + // first click has already toggled the row and may have shifted the list + // (the selection bar appears above it), so the second click can land on + // something else entirely. Remember where the first one hit, and take the + // second click from the document. + const lastClickRef = useRef<{ key: string; x: number; y: number } | null>( + null, + ); + const toggleRow = (key: string, click?: MouseEvent) => { + // Second and later clicks of a burst never toggle; see the effect below. + if (click && click.detail >= 2) return; + lastClickRef.current = + isNormal && click + ? { key, x: click.clientX, y: click.clientY } + : null; + toggleSeries(key); + }; + const quickAddRef = useRef<(key: string) => void>(() => {}); + useEffect(() => { + quickAddRef.current = (key: string) => { + toggleSeries(key); + addToTargetNormalPlot([key]); + }; + }); + useEffect(() => { + if (!isNormal) return; + const onClick = (event: MouseEvent) => { + if (event.detail !== 2) return; + const first = lastClickRef.current; + lastClickRef.current = null; + // The browser already requires both clicks to be close in time. + if ( + first && + Math.hypot(event.clientX - first.x, event.clientY - first.y) < 6 + ) + quickAddRef.current(first.key); + }; + document.addEventListener("click", onClick, true); + return () => document.removeEventListener("click", onClick, true); + }, [isNormal]); // Computed once for the whole list (not per row). - const plots = useMemo(() => Array.from(studioPlots.values()), [studioPlots]); + const plots = useMemo( + () => + isNormal && dashboard + ? getPanes(dashboard.settings.layoutState.tree).map((plot, index) => ({ + id: plot.id, + name: plot.title || `Plot ${index + 1}`, + })) + : Array.from(studioPlots.values()).map((plot) => ({ + id: plot.id, + name: plot.name, + })), + [dashboard, isNormal, studioPlots], + ); + const createPlot = (signalIds: string[]) => { + if (isNormal) { + addNormalPlot(signalIds); + return; + } + const plotId = addStudioPlot(); + void assignStudioSignals(plotId, signalIds); + }; + const addToPlot = (plotId: string, signalIds: string[]) => { + if (isNormal) assignNormalSignalsToPlot(plotId, signalIds); + else void assignStudioSignals(plotId, signalIds); + }; // Build measurement metadata and packet-frequency lookups from the ADJ // archive. Memoized on adjData alone — previously rebuilt on every render @@ -390,6 +486,7 @@ const SeriesGroup = () => { {boards.map((boardName) => ( { totalSelectedCount={totalSelectedCount} measurementMeta={measurementMeta} measPacket={measPacket} - toggleSeries={toggleSeries} + toggleSeries={toggleRow} + quickAdd={isNormal} plots={plots} - addStudioPlot={addStudioPlot} - assignSignalsToPlot={assignSignalsToPlot} + canCreatePlot={!isNormal || plots.length < 8} + onCreatePlot={createPlot} + onAddToPlot={addToPlot} /> ))} diff --git a/frontend/logging-view/src/components/sidebar/SeriesSelectionBar.tsx b/frontend/logging-view/src/components/sidebar/SeriesSelectionBar.tsx index c085a9c73..e703d52df 100644 --- a/frontend/logging-view/src/components/sidebar/SeriesSelectionBar.tsx +++ b/frontend/logging-view/src/components/sidebar/SeriesSelectionBar.tsx @@ -1,74 +1,142 @@ -// Appears above the Series checklist once one or more measurements are -// checked, letting the user add them all to an existing plot or spin up a -// new plot containing them — turns the (previously decorative) checkbox -// selection into a real bulk-assign action. -import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hyperloopupv/frontend-kit/components"; +// Bulk actions for the checked measurements. In Normal mode the selection is +// staged inside the active dashboard; in Simple mode it targets Plot Studio. +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@hyperloopupv/frontend-kit/components"; import { Layers, Plus, X } from "@hyperloopupv/frontend-kit/icons"; import { useMemo, useState } from "react"; -import { useAssignSignalsToPlot } from "../simple/hooks/useStudioSignals"; +import { useLocation } from "react-router"; +import { useSeriesSelection } from "../../hooks/useSeriesSelection"; +import { getPanes } from "../../lib/normal/layout"; import { useStore } from "../../store/store"; +import { useAssignSignalsToPlot } from "../simple/hooks/useStudioSignals"; export default function SeriesSelectionBar() { - const selectedSeries = useStore((s) => s.selectedSeries); - const clearSelectedSeries = useStore((s) => s.clearSelectedSeries); - const studioPlots = useStore((s) => s.studioPlots); - const addStudioPlot = useStore((s) => s.addStudioPlot); - const assignSignalsToPlot = useAssignSignalsToPlot(); + const isNormal = !useLocation().pathname.startsWith("/simple"); + const { selectedSeries, clearSelectedSeries, isSelectionLocked } = + useSeriesSelection(); + const studioPlots = useStore((state) => state.studioPlots); + const addStudioPlot = useStore((state) => state.addStudioPlot); + const addNormalPlot = useStore((state) => state.addNormalPlot); + const assignNormalSignalsToPlot = useStore( + (state) => state.assignNormalSignalsToPlot, + ); + const dashboard = useStore((state) => + state.normalDashboards.find( + (item) => item.id === state.activeNormalDashboardId, + ), + ); + const assignStudioSignals = useAssignSignalsToPlot(); const [busy, setBusy] = useState(false); const selectedIds = useMemo( - () => Object.keys(selectedSeries).filter((k) => selectedSeries[k]), + () => Object.keys(selectedSeries).filter((key) => selectedSeries[key]), [selectedSeries], ); - const plots = useMemo(() => Array.from(studioPlots.values()), [studioPlots]); + const plots = useMemo( + () => + isNormal && dashboard + ? getPanes(dashboard.settings.layoutState.tree).map((plot, index) => ({ + id: plot.id, + name: plot.title || `Plot ${index + 1}`, + })) + : Array.from(studioPlots.values()).map((plot) => ({ + id: plot.id, + name: plot.name, + })), + [dashboard, isNormal, studioPlots], + ); if (selectedIds.length === 0) return null; - const runAndClear = async (action: () => Promise) => { + const handleNewPlot = async () => { + if (isNormal) { + addNormalPlot(selectedIds); + clearSelectedSeries(); + return; + } setBusy(true); try { - await action(); + const plotId = addStudioPlot(); + await assignStudioSignals(plotId, selectedIds); clearSelectedSeries(); } finally { setBusy(false); } }; - const handleNewPlot = () => - runAndClear(async () => { - const plotId = addStudioPlot(); - await assignSignalsToPlot(plotId, selectedIds); - }); + const handleAddToPlot = async (plotId: string) => { + if (isNormal) { + assignNormalSignalsToPlot(plotId, selectedIds); + clearSelectedSeries(); + return; + } + setBusy(true); + try { + await assignStudioSignals(plotId, selectedIds); + clearSelectedSeries(); + } finally { + setBusy(false); + } + }; - const handleAddToPlot = (plotId: string) => - runAndClear(() => assignSignalsToPlot(plotId, selectedIds)); + const atPlotLimit = isNormal && plots.length >= 8; + const disabled = busy || isSelectionLocked; return (
-
- {selectedIds.length} selected +
+ + {selectedIds.length} measurement + {selectedIds.length === 1 ? "" : "s"} selected +
- - void handleAddToPlot(plotId)} + > - + - {plots.map((p) => ( - {p.name} + {plots.map((plot) => ( + + {plot.name} + ))} diff --git a/frontend/logging-view/src/components/simple/SignalDragOverlay.tsx b/frontend/logging-view/src/components/simple/SignalDragOverlay.tsx index 14fb4548f..608981a75 100644 --- a/frontend/logging-view/src/components/simple/SignalDragOverlay.tsx +++ b/frontend/logging-view/src/components/simple/SignalDragOverlay.tsx @@ -17,7 +17,7 @@ export default function SignalDragOverlay({ activeIds }: SignalDragOverlayProps) const label = activeIds.length === 1 ? (getSignalName(adjData, activeIds[0]) ?? activeIds[0]) - : `${activeIds.length} series`; + : `${activeIds.length} measurements`; return ( diff --git a/frontend/logging-view/src/components/simple/SignalSelect.tsx b/frontend/logging-view/src/components/simple/SignalSelect.tsx index 5e5b7ad45..41e47a982 100644 --- a/frontend/logging-view/src/components/simple/SignalSelect.tsx +++ b/frontend/logging-view/src/components/simple/SignalSelect.tsx @@ -17,6 +17,9 @@ interface SignalSelectProps { placeholder?: string; /** Signal ids to hide (e.g. already assigned to the plot). */ exclude?: string[]; + /** Offer session measurements only, leaving out Plot Studio's composed signals. */ + seriesOnly?: boolean; + ariaLabel?: string; triggerClassName?: string; size?: "sm" | "default"; } @@ -29,6 +32,8 @@ export default function SignalSelect({ onValueChange, placeholder = "Select signal…", exclude, + seriesOnly = false, + ariaLabel, triggerClassName, size = "default", }: SignalSelectProps) { @@ -47,6 +52,7 @@ export default function SignalSelect({ const composed: AvailableSignal[] = []; for (const s of signals) { if (excluded.has(s.id)) continue; + if (seriesOnly && s.kind !== "series") continue; if (s.board) { const arr = byBoard.get(s.board); if (arr) { arr.push(s); } else { byBoard.set(s.board, [s]); } @@ -98,6 +104,7 @@ export default function SignalSelect({ -
@@ -104,10 +203,17 @@ function ZoomGroup({ function IconDownload() { return ( - - - - + + + + ); } @@ -123,160 +229,217 @@ export interface PlotExportHandle { // exportHeight: target render canvas height in px — legend/title spacing // is tuned relative to it (see plotlyTheme.ts). Defaults to 1600, matching // the single-plot PNG export button. - getExportFigure: (exportHeight?: number) => { data: Plotly.Data[]; layout: Partial } | null; + getExportFigure: ( + exportHeight?: number, + ) => { data: Plotly.Data[]; layout: Partial } | null; } -interface PlotWrapperProps { plot: PlotState } +interface PlotWrapperProps { + plot: PlotState; +} -const PlotWrapper = forwardRef(({ plot }, ref) => { - // Only the signals this plot actually uses — resolved from the shared - // studioFiles/studioOperations/studioTransforms Maps via useShallow, so an - // unrelated edit elsewhere (another plot's signal, a new file) doesn't - // invalidate this component just because those Maps got a new reference. - const resolvedSignals = useStore( - useShallow((s) => plot.signals.map((sig) => getSignal(sig.signalId, s))), - ); - const fftSampleRateOverride = useStore((s) => s.fftSampleRateOverride); - const adjData = useStore((s) => s.adjData); - const removeStudioPlot = useStore((s) => s.removeStudioPlot); - const renameStudioPlot = useStore((s) => s.renameStudioPlot); - const isDarkMode = useStore((s) => s.isDarkMode); - const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); - const toggleStudioPlotLocked = useStore((s) => s.toggleStudioPlotLocked); - const locked = !!plot.locked; - - const chartRef = useRef(null); - const containerRef = useRef(null); - const [plotHeight, setPlotHeight] = useState(500); - const [showStats, setShowStats] = useState(false); - const [collapsed, setCollapsed] = useState(false); - // Current X-axis viewport, tracked (debounced) from Plotly relayout events. - // Drives both zoom-adaptive decimation (traces useMemo) and the Stats panel. - const [visibleRange, setVisibleRange] = useState<[number, number] | null>(null); - - // Drop target for signals dragged from the left Series sidebar — assignment - // itself happens centrally in useSignalDnd's handleDragEnd (AppLayout.tsx). - // Disabled while locked, so a stray drag can't assign a signal by mistake. - const { setNodeRef: setDropRef, isOver } = useDroppable({ id: plot.id, disabled: locked }); - - // Inline rename - const [editingName, setEditingName] = useState(false); - const [draftName, setDraftName] = useState(plot.name); - - const startRename = () => { if (locked) return; setDraftName(plot.name); setEditingName(true); }; - const commitRename = () => { - const t = draftName.trim(); - if (t && t !== plot.name) renameStudioPlot(plot.id, t); - setEditingName(false); - }; +const PlotWrapper = forwardRef( + ({ plot }, ref) => { + // Only the signals this plot actually uses — resolved from the shared + // studioFiles/studioOperations/studioTransforms Maps via useShallow, so an + // unrelated edit elsewhere (another plot's signal, a new file) doesn't + // invalidate this component just because those Maps got a new reference. + const resolvedSignals = useStore( + useShallow((s) => plot.signals.map((sig) => getSignal(sig.signalId, s))), + ); + const fftSampleRateOverride = useStore((s) => s.fftSampleRateOverride); + const adjData = useStore((s) => s.adjData); + const orders = useStore((s) => s.orders); + const removeStudioPlot = useStore((s) => s.removeStudioPlot); + const renameStudioPlot = useStore((s) => s.renameStudioPlot); + const isDarkMode = useStore((s) => s.isDarkMode); + const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); + const toggleStudioPlotLocked = useStore((s) => s.toggleStudioPlotLocked); + const locked = !!plot.locked; - const hasLeftAxis = plot.signals.some((s) => s.yAxis === "left"); - const hasRightAxis = plot.signals.some((s) => s.yAxis === "right"); - const hasFFT = plot.showFFT; - const signalCount = plot.signals.length; - - // No manual toggle — a plot automatically becomes a "Cronograma" (Gantt - // state timeline) the moment its first assigned signal is enum/boolean. - // ADJ type metadata, when present, is authoritative (a known "float32" - // signal must never fall through to the data heuristic below, or a - // slowly-changing/quantized float gets misread as discrete). The data - // heuristic only kicks in for signals with NO ADJ metadata at all — plain - // CSV files loaded directly into Plot Studio. Later signals just adopt - // whatever shape the first signal set. FFT is an explicit user toggle and - // takes precedence if both would otherwise apply. - const firstSignal = plot.signals[0]; - const hasTimeline = useMemo(() => { - if (hasFFT || !firstSignal) return false; - if (getSignalType(adjData, firstSignal.signalId) !== undefined) { - return isDiscreteMeasurement(adjData, firstSignal.signalId); - } - const data = resolvedSignals[0]?.data; - return !!data && isDiscreteSeries(data); - }, [hasFFT, firstSignal, adjData, resolvedSignals]); - - // Units are only meaningful in time-domain mode — an FFT'd plot shows - // magnitude, not any signal's source unit. - const axisUnits = useMemo(() => { - if (hasFFT) return { left: undefined, right: undefined, leftMismatch: false, rightMismatch: false }; - const leftList = plot.signals.filter((s) => s.yAxis === "left").map((s) => getSignalUnits(adjData, s.signalId)); - const rightList = plot.signals.filter((s) => s.yAxis === "right").map((s) => getSignalUnits(adjData, s.signalId)); - return { - left: commonUnits(leftList), - right: commonUnits(rightList), - leftMismatch: unitsMismatch(leftList), - rightMismatch: unitsMismatch(rightList), + const chartRef = useRef(null); + const containerRef = useRef(null); + const [plotHeight, setPlotHeight] = useState(500); + const [showStats, setShowStats] = useState(false); + const [collapsed, setCollapsed] = useState(false); + // Current X-axis viewport, tracked (debounced) from Plotly relayout events. + // Drives both zoom-adaptive decimation (traces useMemo) and the Stats panel. + const [visibleRange, setVisibleRange] = useState<[number, number] | null>( + null, + ); + + // Drop target for signals dragged from the left Series sidebar — assignment + // itself happens centrally in useSignalDnd's handleDragEnd (AppLayout.tsx). + // Disabled while locked, so a stray drag can't assign a signal by mistake. + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: plot.id, + disabled: locked, + }); + + // Inline rename + const [editingName, setEditingName] = useState(false); + const [draftName, setDraftName] = useState(plot.name); + + const startRename = () => { + if (locked) return; + setDraftName(plot.name); + setEditingName(true); + }; + const commitRename = () => { + const t = draftName.trim(); + if (t && t !== plot.name) renameStudioPlot(plot.id, t); + setEditingName(false); }; - }, [plot.signals, adjData, hasFFT]); - const hasUnitsMismatch = axisUnits.leftMismatch || axisUnits.rightMismatch; - - const getVisibleRange = useCallback((): [number, number] | null => { - const div = chartRef.current?.getDiv(); - if (!div) return null; - const gd = div as unknown as Plotly.PlotlyHTMLElement & { _fullLayout: Record }; - const range = gd._fullLayout["xaxis"]?.range; - if (!range || range.length < 2) return null; - return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]; - }, []); - - // Row label per signal, top-to-bottom in assignment order — shared between - // the timeline traces below and buildTimelineLayout's categorical y-axis. - const timelineRowLabels = useMemo( - () => - plot.signals.map((sig, idx) => { - const signal = resolvedSignals[idx]; - return getSignalName(adjData, sig.signalId) ?? displayName(signal?.name ?? sig.signalId); - }), - [plot.signals, resolvedSignals, adjData], - ); - const timelineTraces = useMemo(() => { - if (!hasTimeline) return []; - // One flat list of segments across every signal, tagged with its row - // label and the label to show for its state value (per-signal enum - // lookup, since two signals could give different meanings to the same - // numeric code). - const allSegments: { row: string; start: number; end: number; value: number; label: string; hoverText: string }[] = []; - plot.signals.forEach((sig, idx) => { - const data = resolvedSignals[idx]?.data; - if (!data || data.value.length < 1) return; - const enumLabels = getEnumLabels(adjData, sig.signalId); - const row = timelineRowLabels[idx]; - for (const seg of computeStateSegments(data)) { - const label = stateLabel(seg.value, enumLabels); - allSegments.push({ - row, start: seg.start, end: seg.end, value: seg.value, label, - hoverText: `${label} (${seg.start.toFixed(0)} – ${seg.end.toFixed(0)} ms)`, - }); + const hasLeftAxis = plot.signals.some((s) => s.yAxis === "left"); + const hasRightAxis = plot.signals.some((s) => s.yAxis === "right"); + const hasFFT = plot.showFFT; + const signalCount = plot.signals.length; + + // No manual toggle — a plot automatically becomes a "Cronograma" (Gantt + // state timeline) the moment its first assigned signal is enum/boolean. + // ADJ type metadata, when present, is authoritative (a known "float32" + // signal must never fall through to the data heuristic below, or a + // slowly-changing/quantized float gets misread as discrete). The data + // heuristic only kicks in for signals with NO ADJ metadata at all — plain + // CSV files loaded directly into Plot Studio. Later signals just adopt + // whatever shape the first signal set. FFT is an explicit user toggle and + // takes precedence if both would otherwise apply. + const firstSignal = plot.signals[0]; + const hasTimeline = useMemo(() => { + if (hasFFT || !firstSignal) return false; + if (getSignalType(adjData, firstSignal.signalId) !== undefined) { + return isDiscreteMeasurement(adjData, firstSignal.signalId); } - }); + const data = resolvedSignals[0]?.data; + return !!data && isDiscreteSeries(data); + }, [hasFFT, firstSignal, adjData, resolvedSignals]); - // Group into one bar trace per unique state value so each state gets a - // single legend entry spanning every signal's row. - const uniqueValues = [...new Set(allSegments.map((s) => s.value))].sort((a, b) => a - b); - return uniqueValues.map((value, colorIdx) => { - const segs = allSegments.filter((s) => s.value === value); + // Units are only meaningful in time-domain mode — an FFT'd plot shows + // magnitude, not any signal's source unit. + const axisUnits = useMemo(() => { + if (hasFFT) + return { + left: undefined, + right: undefined, + leftMismatch: false, + rightMismatch: false, + }; + const leftList = plot.signals + .filter((s) => s.yAxis === "left") + .map((s) => getSignalUnits(adjData, s.signalId)); + const rightList = plot.signals + .filter((s) => s.yAxis === "right") + .map((s) => getSignalUnits(adjData, s.signalId)); return { - type: "bar" as const, - orientation: "h" as const, - base: segs.map((s) => s.start), - x: segs.map((s) => s.end - s.start), - y: segs.map((s) => s.row), - text: segs.map((s) => s.hoverText), - name: segs[0].label, - marker: { color: traceColor(colorIdx) }, - hovertemplate: "%{y}
%{text}", + left: commonUnits(leftList), + right: commonUnits(rightList), + leftMismatch: unitsMismatch(leftList), + rightMismatch: unitsMismatch(rightList), }; - }); - }, [hasTimeline, plot.signals, resolvedSignals, adjData, timelineRowLabels]); + }, [plot.signals, adjData, hasFFT]); + const hasUnitsMismatch = axisUnits.leftMismatch || axisUnits.rightMismatch; - const traces = useMemo( - () => { + const getVisibleRange = useCallback((): [number, number] | null => { + const div = chartRef.current?.getDiv(); + if (!div) return null; + const gd = div as unknown as Plotly.PlotlyHTMLElement & { + _fullLayout: Record; + }; + const range = gd._fullLayout["xaxis"]?.range; + if (!range || range.length < 2) return null; + return [Math.min(range[0], range[1]), Math.max(range[0], range[1])]; + }, []); + + // Row label per signal, top-to-bottom in assignment order — shared between + // the timeline traces below and buildTimelineLayout's categorical y-axis. + const timelineRowLabels = useMemo( + () => + plot.signals.map((sig, idx) => { + const signal = resolvedSignals[idx]; + return ( + getSignalName(adjData, sig.signalId) ?? + displayName(signal?.name ?? sig.signalId) + ); + }), + [plot.signals, resolvedSignals, adjData], + ); + + const timelineTraces = useMemo(() => { + if (!hasTimeline) return []; + // One flat list of segments across every signal, tagged with its row + // label and the label to show for its state value (per-signal enum + // lookup, since two signals could give different meanings to the same + // numeric code). + const allSegments: { + row: string; + start: number; + end: number; + value: number; + label: string; + hoverText: string; + }[] = []; + // The end of the plot, so the last state of a shorter signal still holds + // to the right edge instead of collapsing to nothing. + const plotEndMs = plot.signals.reduce((latest, _sig, idx) => { + const data = resolvedSignals[idx]?.data; + const last = data?.time[data.time.length - 1]; + return last === undefined ? latest : Math.max(latest, last); + }, 0); + plot.signals.forEach((sig, idx) => { + const data = resolvedSignals[idx]?.data; + if (!data || data.value.length < 1) return; + const enumLabels = getEnumLabels(adjData, sig.signalId); + const row = timelineRowLabels[idx]; + for (const seg of computeStateSegments(data, plotEndMs)) { + const label = stateLabel(seg.value, enumLabels); + allSegments.push({ + row, + start: seg.start, + end: seg.end, + value: seg.value, + label, + hoverText: `${label} (${seg.start.toFixed(0)} – ${seg.end.toFixed(0)} ms)`, + }); + } + }); + + // Group into one bar trace per unique state value so each state gets a + // single legend entry spanning every signal's row. + const uniqueValues = [...new Set(allSegments.map((s) => s.value))].sort( + (a, b) => a - b, + ); + return uniqueValues.map((value, colorIdx) => { + const segs = allSegments.filter((s) => s.value === value); + return { + type: "bar" as const, + orientation: "h" as const, + base: segs.map((s) => s.start), + x: segs.map((s) => s.end - s.start), + y: segs.map((s) => s.row), + text: segs.map((s) => s.hoverText), + name: segs[0].label, + marker: { color: traceColor(colorIdx) }, + hovertemplate: "%{y}
%{text}", + }; + }); + }, [ + hasTimeline, + plot.signals, + resolvedSignals, + adjData, + timelineRowLabels, + ]); + + const traces = useMemo(() => { if (hasTimeline) return timelineTraces; const signalTraces: Plotly.Data[] = plot.signals.flatMap((sig, idx) => { const signal = resolvedSignals[idx]; const data = signal?.data; if (!data || data.value.length < 2) return []; - let name = getSignalName(adjData, sig.signalId) ?? displayName(signal?.name ?? sig.signalId); + let name = + getSignalName(adjData, sig.signalId) ?? + displayName(signal?.name ?? sig.signalId); const color = resolveSignalColor(sig.color, sig.colorIndex); const isRight = sig.yAxis === "right"; // Right-axis traces are dashed so axis membership reads at a glance, @@ -285,7 +448,9 @@ const PlotWrapper = forwardRef(({ plot }, re const dash: Plotly.Dash = isRight ? "dash" : "solid"; // Axis title can't show a unit when signals on it disagree — put each // signal's own unit in the legend instead so it's still visible. - const axisMismatch = isRight ? axisUnits.rightMismatch : axisUnits.leftMismatch; + const axisMismatch = isRight + ? axisUnits.rightMismatch + : axisUnits.leftMismatch; if (axisMismatch && !hasFFT) { const unit = getSignalUnits(adjData, sig.signalId); if (unit) name = `${name} (${unit})`; @@ -325,21 +490,29 @@ const PlotWrapper = forwardRef(({ plot }, re const traceType = "scatter" as const; if (hasFFT) { - return [{ - x: renderX, y: renderY, + return [ + { + x: renderX, + y: renderY, + type: traceType, + mode: "lines" as const, + name: `${name} (FFT)`, + line: { width: 2, color, dash }, + yaxis: isRight ? ("y2" as const) : ("y" as const), + }, + ]; + } + return [ + { + x: renderX, + y: renderY, type: traceType, mode: "lines" as const, - name: `${name} (FFT)`, line: { width: 2, color, dash }, + name, + line: { width: 2.5, color, dash }, yaxis: isRight ? ("y2" as const) : ("y" as const), - }]; - } - return [{ - x: renderX, y: renderY, - type: traceType, - mode: "lines" as const, - name, line: { width: 2.5, color, dash }, - yaxis: isRight ? ("y2" as const) : ("y" as const), - }]; + }, + ]; }); // If every trace ended up on the right (secondary) axis, the primary @@ -350,7 +523,8 @@ const PlotWrapper = forwardRef(({ plot }, re // moving the real signal off the right axis the user chose for it. if (hasRightAxis && !hasLeftAxis && signalTraces.length > 0) { signalTraces.push({ - x: [], y: [], + x: [], + y: [], type: "scatter" as const, mode: "markers" as const, yaxis: "y" as const, @@ -360,421 +534,728 @@ const PlotWrapper = forwardRef(({ plot }, re } return signalTraces; - }, - [plot.signals, resolvedSignals, fftSampleRateOverride, adjData, axisUnits, hasFFT, hasLeftAxis, hasRightAxis, hasTimeline, timelineTraces, visibleRange], - ); + }, [ + plot.signals, + resolvedSignals, + fftSampleRateOverride, + adjData, + axisUnits, + hasFFT, + hasLeftAxis, + hasRightAxis, + hasTimeline, + timelineTraces, + visibleRange, + ]); + + const hasTraces = traces.length > 0; + + // Orders share the session timebase with telemetry. Render them as paper- + // height vertical event lines so they remain meaningful for plots with any + // unit (and for the categorical timeline). FFT uses frequency, so order + // timestamps intentionally do not appear in that mode. + const visibleOrders = useMemo( + () => + !hasFFT + ? orders.filter((order) => !plot.hiddenOrderIds?.[order.id]) + : [], + [orders, plot.hiddenOrderIds, hasFFT], + ); + const orderLabelLanes = useMemo( + () => assignOrderLabelLanes(visibleOrders), + [visibleOrders], + ); + + const addOrderAnnotations = useCallback( + (baseLayout: Partial) => { + if (orderLabelLanes.length === 0) return baseLayout; + const theme = getPlotlyTheme(isDarkMode); + return { + ...baseLayout, + shapes: [ + ...(baseLayout.shapes ?? []), + ...orderLabelLanes.map(({ order }) => ({ + type: "line" as const, + xref: "x" as const, + yref: "paper" as const, + x0: order.time, + x1: order.time, + y0: 0, + y1: 1, + line: { + color: ORDER_EVENT_COLOR, + width: 1.25, + dash: "dot" as const, + }, + layer: "above" as const, + })), + ], + annotations: [ + ...(baseLayout.annotations ?? []), + ...orderLabelLanes.map(({ order, placement, lane }) => ({ + x: order.time, + xref: "x" as const, + // Keep the first lane just inside the plot. Placing it in the top + // margin makes it overlap Plotly's editable chart title. + y: placement === "bottom" ? 0 : 1, + yref: "paper" as const, + text: formatOrderAnnotation(order.name, order.parameters), + showarrow: false, + xanchor: "left" as const, + yanchor: "top" as const, + xshift: 3, + yshift: placement === "bottom" ? -5 : -6 - lane * 32, + font: { size: 10, color: theme.fontColor }, + bgcolor: theme.paperBg, + bordercolor: ORDER_EVENT_COLOR, + borderwidth: 1, + opacity: 0.94, + align: "left" as const, + })), + ], + }; + }, + [orderLabelLanes, isDarkMode], + ); + + const layout = useMemo>( + () => + addOrderAnnotations( + hasTimeline + ? buildTimelineLayout({ + theme: getPlotlyTheme(isDarkMode), + plotId: plot.id, + rowLabels: timelineRowLabels, + locked, + }) + : buildPlotLayout({ + theme: getPlotlyTheme(isDarkMode), + hasFFT, + hasLeftAxis, + hasRightAxis, + plotId: plot.id, + leftUnits: axisUnits.left, + rightUnits: axisUnits.right, + locked, + }), + ), + [ + hasLeftAxis, + hasRightAxis, + hasFFT, + hasTimeline, + timelineRowLabels, + plot.id, + axisUnits, + isDarkMode, + locked, + addOrderAnnotations, + ], + ); + + // editable (click-to-rename title/legend) is the one PLOTLY_CONFIG option + // that isn't axis-scoped (fixedrange, set via `layout` above, already + // blocks pan/zoom/scroll-zoom/box-zoom while locked) — gated separately here. + const plotlyConfig = useMemo>( + () => ({ ...PLOTLY_CONFIG, editable: !locked }), + [locked], + ); + + // Drag-to-resize + const isResizing = useRef(false); + const startY = useRef(0); + const startHeight = useRef(0); + + const onResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + if (locked) return; + isResizing.current = true; + startY.current = e.clientY; + startHeight.current = containerRef.current?.offsetHeight ?? plotHeight; + e.preventDefault(); + }, + [plotHeight, locked], + ); + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!isResizing.current) return; + setPlotHeight( + Math.max(300, startHeight.current + (e.clientY - startY.current)), + ); + }; + const onUp = () => { + isResizing.current = false; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + }, []); + + // Track the visible X range whenever the user zooms/pans, so decimation and + // the Stats panel both track the current viewport live. Debounced — relayout + // fires many times/sec during a drag, and recomputing on every tick is what + // made interaction janky/unresponsive. Runs regardless of whether Stats is + // open, since decimation needs this too. + useEffect(() => { + if (!hasTraces) return; + const div = chartRef.current?.getDiv() as + | PlotlyEventDiv + | null + | undefined; + if (!div?.on) return; + let timer: ReturnType | null = null; + const handler = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => setVisibleRange(getVisibleRange()), 200); + }; + div.on("plotly_relayout", handler); + return () => { + if (timer) clearTimeout(timer); + div.removeAllListeners?.("plotly_relayout"); + }; + }, [hasTraces, getVisibleRange]); + + // Time (ms) and frequency (Hz) are unrelated axes — a range captured in one + // mode must not be reused to slice the other after toggling FFT. Plotly's + // own uirevision-driven zoom reset on mode switch is an internal `react()` + // reconciliation, not a user interaction, so it never fires plotly_relayout. + // Reset during render (not an effect) per React's "adjusting state when a + // prop changes" pattern — avoids an extra cascading render. + const [prevHasFFT, setPrevHasFFT] = useState(hasFFT); + if (prevHasFFT !== hasFFT) { + setPrevHasFFT(hasFFT); + setVisibleRange(null); + } - const hasTraces = traces.length > 0; + const zoomAxis = useCallback( + (axis: "x" | "y1" | "y2", direction: "in" | "out") => { + const div = chartRef.current?.getDiv(); + if (!div) return; + const gd = div as unknown as Plotly.PlotlyHTMLElement & { + _fullLayout: Record; + }; + const factor = direction === "in" ? 0.7 : 1.4; + const axisKey = + axis === "x" ? "xaxis" : axis === "y1" ? "yaxis" : "yaxis2"; + const rangeKey = + axis === "x" + ? "xaxis.range" + : axis === "y1" + ? "yaxis.range" + : "yaxis2.range"; + const range = gd._fullLayout[axisKey]?.range; + if (!range) return; + const center = (range[0] + range[1]) / 2; + const half = ((range[1] - range[0]) / 2) * factor; + Plotly.relayout(div, { [rangeKey]: [center - half, center + half] }); + }, + [], + ); - const layout = useMemo>( - () => - hasTimeline - ? buildTimelineLayout({ theme: getPlotlyTheme(isDarkMode), plotId: plot.id, rowLabels: timelineRowLabels, locked }) + const resetZoom = useCallback(() => { + const div = chartRef.current?.getDiv(); + if (!div) return; + Plotly.relayout(div, { + "xaxis.autorange": true, + "yaxis.autorange": true, + "yaxis2.autorange": true, + } as Partial); + }, []); + + // Exports always render in the light/academic theme regardless of the + // on-screen app theme (publication-figure look), but keep whatever + // zoom/pan range is currently visible rather than autoranging to all data. + const buildExportFigure = (exportHeight = 1600) => { + const div = chartRef.current?.getDiv(); + if (!div) return null; + const gd = div as unknown as Plotly.PlotlyHTMLElement & { + _fullLayout: Record; + }; + let exportLayout = hasTimeline + ? buildTimelineLayout({ + theme: getPlotlyTheme(false), + plotId: plot.id, + rowLabels: timelineRowLabels, + fontScale: 1.8, + exportHeight, + }) : buildPlotLayout({ - theme: getPlotlyTheme(isDarkMode), - hasFFT, hasLeftAxis, hasRightAxis, plotId: plot.id, - leftUnits: axisUnits.left, rightUnits: axisUnits.right, - locked, - }), - [hasLeftAxis, hasRightAxis, hasFFT, hasTimeline, timelineRowLabels, plot.id, axisUnits, isDarkMode, locked], - ); + theme: getPlotlyTheme(false), + hasFFT, + hasLeftAxis, + hasRightAxis, + plotId: plot.id, + leftUnits: axisUnits.left, + rightUnits: axisUnits.right, + fontScale: 1.8, + exportHeight, + }); + // Export is always light, so rebuild annotations with its print theme. + if (orderLabelLanes.length > 0) { + const printTheme = getPlotlyTheme(false); + exportLayout = { + ...exportLayout, + shapes: [ + ...(exportLayout.shapes ?? []), + ...orderLabelLanes.map(({ order }) => ({ + type: "line" as const, + xref: "x" as const, + yref: "paper" as const, + x0: order.time, + x1: order.time, + y0: 0, + y1: 1, + line: { + color: ORDER_EVENT_COLOR, + width: 2, + dash: "dot" as const, + }, + layer: "above" as const, + })), + ], + annotations: [ + ...(exportLayout.annotations ?? []), + ...orderLabelLanes.map(({ order, placement, lane }) => ({ + x: order.time, + xref: "x" as const, + y: placement === "bottom" ? 0 : 1, + yref: "paper" as const, + text: formatOrderAnnotation(order.name, order.parameters), + showarrow: false, + xanchor: "left" as const, + yanchor: "top" as const, + xshift: 5, + yshift: placement === "bottom" ? -8 : -10 - lane * 56, + font: { size: 18, color: printTheme.fontColor }, + bgcolor: printTheme.paperBg, + bordercolor: ORDER_EVENT_COLOR, + borderwidth: 1.5, + align: "left" as const, + })), + ], + }; + } + // Carry over whatever title the user typed via Plotly's click-to-edit — + // gd.layout is Plotly's live, mutated layout (relayout writes + // title.text into it), whereas exportLayout is rebuilt fresh from + // buildPlotLayout and starts blank. + const liveTitle = gd.layout?.title; + const liveTitleText = + typeof liveTitle === "object" ? liveTitle?.text : liveTitle; + if (liveTitleText) + exportLayout.title = { ...exportLayout.title, text: liveTitleText }; - // editable (click-to-rename title/legend) is the one PLOTLY_CONFIG option - // that isn't axis-scoped (fixedrange, set via `layout` above, already - // blocks pan/zoom/scroll-zoom/box-zoom while locked) — gated separately here. - const plotlyConfig = useMemo>( - () => ({ ...PLOTLY_CONFIG, editable: !locked }), - [locked], - ); + // The y-axis is categorical in timeline mode — only the x (time) range + // carries over, copying a numeric range onto a category axis would break it. + const xRange = gd._fullLayout["xaxis"]?.range; + if (xRange) + exportLayout.xaxis = { + ...exportLayout.xaxis, + range: xRange, + autorange: false, + }; + if (!hasTimeline) { + const yRange = gd._fullLayout["yaxis"]?.range; + const y2Range = gd._fullLayout["yaxis2"]?.range; + if (yRange) + exportLayout.yaxis = { + ...exportLayout.yaxis, + range: yRange, + autorange: false, + }; + if (y2Range && exportLayout.yaxis2) + exportLayout.yaxis2 = { + ...exportLayout.yaxis2, + range: y2Range, + autorange: false, + }; + } - // Drag-to-resize - const isResizing = useRef(false); - const startY = useRef(0); - const startHeight = useRef(0); - - const onResizeMouseDown = useCallback((e: React.MouseEvent) => { - if (locked) return; - isResizing.current = true; - startY.current = e.clientY; - startHeight.current = containerRef.current?.offsetHeight ?? plotHeight; - e.preventDefault(); - }, [plotHeight, locked]); - - useEffect(() => { - const onMove = (e: MouseEvent) => { - if (!isResizing.current) return; - setPlotHeight(Math.max(300, startHeight.current + (e.clientY - startY.current))); - }; - const onUp = () => { isResizing.current = false; }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - return () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - }; - }, []); - - // Track the visible X range whenever the user zooms/pans, so decimation and - // the Stats panel both track the current viewport live. Debounced — relayout - // fires many times/sec during a drag, and recomputing on every tick is what - // made interaction janky/unresponsive. Runs regardless of whether Stats is - // open, since decimation needs this too. - useEffect(() => { - if (!hasTraces) return; - const div = chartRef.current?.getDiv() as PlotlyEventDiv | null | undefined; - if (!div?.on) return; - let timer: ReturnType | null = null; - const handler = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => setVisibleRange(getVisibleRange()), 200); + // Team branding watermark, bottom-right corner — export-only (not shown + // on the live/on-screen chart). Paper coords span the whole canvas + // (margins included), so x:1/y:0 with right/bottom anchors is the true + // image corner. ~260px square on the 2400x1600 export canvas. + exportLayout.images = [ + { + source: logoIcon, + xref: "paper", + yref: "paper", + x: 0.99, + y: 0.015, + xanchor: "right", + yanchor: "bottom", + sizex: 0.11, + sizey: 0.165, + opacity: 1, + layer: "above", + }, + ]; + + return { data: gd.data, layout: exportLayout }; }; - div.on("plotly_relayout", handler); - return () => { - if (timer) clearTimeout(timer); - div.removeAllListeners?.("plotly_relayout"); + + useImperativeHandle(ref, () => ({ + plotId: plot.id, + title: plot.name, + hasTraces, + getExportFigure: buildExportFigure, + })); + + const exportPNG = () => { + const figure = buildExportFigure(); + if (!figure) return; + // downloadImage's types don't expose `scale` (DownloadImgopts lacks it, + // unlike ToImgopts) even though Plotly supports it at runtime — go + // through toImage + a manual anchor download instead, matching the + // pattern the SVG export used before it was removed. + Plotly.toImage(figure, { + format: "png", + width: 2400, + height: 1600, + scale: 4, + }).then((url) => { + const a = document.createElement("a"); + a.href = url; + a.download = `${plot.name}__${exportTimestamp()}.png`; + a.click(); + }); }; - }, [hasTraces, getVisibleRange]); - - // Time (ms) and frequency (Hz) are unrelated axes — a range captured in one - // mode must not be reused to slice the other after toggling FFT. Plotly's - // own uirevision-driven zoom reset on mode switch is an internal `react()` - // reconciliation, not a user interaction, so it never fires plotly_relayout. - // Reset during render (not an effect) per React's "adjusting state when a - // prop changes" pattern — avoids an extra cascading render. - const [prevHasFFT, setPrevHasFFT] = useState(hasFFT); - if (prevHasFFT !== hasFFT) { - setPrevHasFFT(hasFFT); - setVisibleRange(null); - } - - const zoomAxis = useCallback((axis: "x" | "y1" | "y2", direction: "in" | "out") => { - const div = chartRef.current?.getDiv(); - if (!div) return; - const gd = div as unknown as Plotly.PlotlyHTMLElement & { _fullLayout: Record }; - const factor = direction === "in" ? 0.7 : 1.4; - const axisKey = axis === "x" ? "xaxis" : axis === "y1" ? "yaxis" : "yaxis2"; - const rangeKey = axis === "x" ? "xaxis.range" : axis === "y1" ? "yaxis.range" : "yaxis2.range"; - const range = gd._fullLayout[axisKey]?.range; - if (!range) return; - const center = (range[0] + range[1]) / 2; - const half = ((range[1] - range[0]) / 2) * factor; - Plotly.relayout(div, { [rangeKey]: [center - half, center + half] }); - }, []); - - const resetZoom = useCallback(() => { - const div = chartRef.current?.getDiv(); - if (!div) return; - Plotly.relayout(div, { "xaxis.autorange": true, "yaxis.autorange": true, "yaxis2.autorange": true } as Partial); - }, []); - - // Exports always render in the light/academic theme regardless of the - // on-screen app theme (publication-figure look), but keep whatever - // zoom/pan range is currently visible rather than autoranging to all data. - const buildExportFigure = (exportHeight = 1600) => { - const div = chartRef.current?.getDiv(); - if (!div) return null; - const gd = div as unknown as Plotly.PlotlyHTMLElement & { _fullLayout: Record }; - const exportLayout = hasTimeline - ? buildTimelineLayout({ theme: getPlotlyTheme(false), plotId: plot.id, rowLabels: timelineRowLabels, fontScale: 1.8, exportHeight }) - : buildPlotLayout({ - theme: getPlotlyTheme(false), - hasFFT, hasLeftAxis, hasRightAxis, plotId: plot.id, - leftUnits: axisUnits.left, rightUnits: axisUnits.right, - fontScale: 1.8, exportHeight, - }); - // Carry over whatever title the user typed via Plotly's click-to-edit — - // gd.layout is Plotly's live, mutated layout (relayout writes - // title.text into it), whereas exportLayout is rebuilt fresh from - // buildPlotLayout and starts blank. - const liveTitle = gd.layout?.title; - const liveTitleText = typeof liveTitle === "object" ? liveTitle?.text : liveTitle; - if (liveTitleText) exportLayout.title = { ...exportLayout.title, text: liveTitleText }; - - // The y-axis is categorical in timeline mode — only the x (time) range - // carries over, copying a numeric range onto a category axis would break it. - const xRange = gd._fullLayout["xaxis"]?.range; - if (xRange) exportLayout.xaxis = { ...exportLayout.xaxis, range: xRange, autorange: false }; - if (!hasTimeline) { - const yRange = gd._fullLayout["yaxis"]?.range; - const y2Range = gd._fullLayout["yaxis2"]?.range; - if (yRange) exportLayout.yaxis = { ...exportLayout.yaxis, range: yRange, autorange: false }; - if (y2Range && exportLayout.yaxis2) exportLayout.yaxis2 = { ...exportLayout.yaxis2, range: y2Range, autorange: false }; - } - // Team branding watermark, bottom-right corner — export-only (not shown - // on the live/on-screen chart). Paper coords span the whole canvas - // (margins included), so x:1/y:0 with right/bottom anchors is the true - // image corner. ~260px square on the 2400x1600 export canvas. - exportLayout.images = [{ - source: logoIcon, - xref: "paper", yref: "paper", - x: 0.99, y: 0.015, - xanchor: "right", yanchor: "bottom", - sizex: 0.11, sizey: 0.165, - opacity: 1, - layer: "above", - }]; - - return { data: gd.data, layout: exportLayout }; - }; + const statsData = useMemo( + () => + plot.signals.map((sig, idx) => { + const signal = resolvedSignals[idx]; + const data = signal?.data ?? null; + const name = + getSignalName(adjData, sig.signalId) ?? + displayName(signal?.name ?? sig.signalId); + return { + signalId: sig.signalId, + name, + data, + color: resolveSignalColor(sig.color, idx), + }; + }), + [plot.signals, resolvedSignals, adjData], + ); - useImperativeHandle(ref, () => ({ - plotId: plot.id, - title: plot.name, - hasTraces, - getExportFigure: buildExportFigure, - })); - - const exportPNG = () => { - const figure = buildExportFigure(); - if (!figure) return; - // downloadImage's types don't expose `scale` (DownloadImgopts lacks it, - // unlike ToImgopts) even though Plotly supports it at runtime — go - // through toImage + a manual anchor download instead, matching the - // pattern the SVG export used before it was removed. - Plotly.toImage(figure, { format: "png", width: 2400, height: 1600, scale: 4 }).then((url) => { - const a = document.createElement("a"); - a.href = url; a.download = `${plot.name}__${exportTimestamp()}.png`; a.click(); - }); - }; + const chartSurface = getPlotlyTheme(isDarkMode).paperBg; - const statsData = useMemo( - () => plot.signals.map((sig, idx) => { - const signal = resolvedSignals[idx]; - const data = signal?.data ?? null; - const name = getSignalName(adjData, sig.signalId) ?? displayName(signal?.name ?? sig.signalId); - return { signalId: sig.signalId, name, data, color: resolveSignalColor(sig.color, idx) }; - }), - [plot.signals, resolvedSignals, adjData], - ); + return ( + + +
+ {/* Gradient accent strip */} +
- return ( - - -
- {/* Gradient accent strip */} -
- - {/* Header */} -
- - -
- - - {editingName ? ( - setDraftName(e.target.value)} - onBlur={commitRename} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(); - if (e.key === "Escape") setEditingName(false); - }} - className="h-6 w-44 px-2 text-sm shadow-none" - aria-label="Plot name" - /> - ) : ( - - )} - - {signalCount > 0 && ( - - {signalCount} signal{signalCount !== 1 ? "s" : ""} - - )} - - {hasUnitsMismatch && ( - - - - - Units mismatch - - - Signals sharing an axis have different units — the axis title omits units - - )} -
- -
- {/* Zoom cluster — only meaningful with traces, and hidden while + + +
+ + + {editingName ? ( + setDraftName(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") setEditingName(false); + }} + className="h-6 w-44 px-2 text-sm shadow-none" + aria-label="Plot name" + /> + ) : ( + + )} + + {signalCount > 0 && ( + + {signalCount} signal{signalCount !== 1 ? "s" : ""} + + )} + + {hasUnitsMismatch && ( + + + + + Units mismatch + + + + Signals sharing an axis have different units — the axis + title omits units + + + )} +
+ +
+ {/* Zoom cluster — only meaningful with traces, and hidden while locked since the axes are fixedrange (see `layout` above) and these buttons would otherwise bypass that via direct relayout calls. */} - {hasTraces && !locked && ( -
- {/* Categorical y-axis in timeline mode isn't zoomable the same way — X (time) still is. */} - {!hasTimeline && hasLeftAxis && } - {!hasTimeline && hasRightAxis && } - - - - - - Reset zoom - -
- )} - - - - + + Reset zoom + +
)} - > - {locked ? : } - - - - {locked ? "Locked — view can't be changed by accident. Click to unlock" : "Lock plot"} - - - - - -
- - - + + + {locked + ? "Locked — view can't be changed by accident. Click to unlock" + : "Lock plot"} + + + + - - Export PNG - -
- +
+ + + + + Export PNG + +
- - - - - Close plot - -
-
+ - {/* Kept mounted (not conditionally removed) while collapsed, so Plotly's - zoom/pan state and WebGL context survive expand/collapse. */} -
- {hasTraces ? ( - // Chart canvas follows app dark mode; exports stay pinned to the - // light/academic theme regardless (see buildExportFigure above). -
- - {!locked && ( -
-
+ + + + + Close plot +
- )} -
- ) : ( -
- -

- No signals to display — assign one from the Plots panel -

-
- )} +
- {showStats && hasTraces && !hasTimeline && ( - setShowStats(false)} /> - )} -
-
- - - - - Rename - - toggleStudioPlotLocked(plot.id)}> - {locked ? : } - {locked ? "Unlock" : "Lock"} - - toggleStudioPlotFFT(plot.id)}> - Frequency spectrum (FFT) - - setCollapsed((v) => !v)}> - - {collapsed ? "Expand" : "Collapse"} - - - - Reset Zoom - - - setShowStats((v) => !v)} disabled={!hasTraces || hasTimeline}> - Show Stats - - - - Export PNG - - - removeStudioPlot(plot.id)}> - - Close - - - - ); -}); + {/* Kept mounted (not conditionally removed) while collapsed, so Plotly's + zoom/pan state and WebGL context survive expand/collapse. */} +
+ {hasTraces ? ( + // Chart canvas follows app dark mode; exports stay pinned to the + // light/academic theme regardless (see buildExportFigure above). +
+ + {!locked && ( +
+
+
+ )} +
+ ) : ( +
+ +

+ No signals to display — assign one from the{" "} + Plots{" "} + panel +

+
+ )} + + {showStats && hasTraces && !hasTimeline && ( + setShowStats(false)} + /> + )} +
+
+ + + + + Rename + + toggleStudioPlotLocked(plot.id)}> + {locked ? ( + + ) : ( + + )} + {locked ? "Unlock" : "Lock"} + + toggleStudioPlotFFT(plot.id)} + > + Frequency spectrum (FFT) + + setCollapsed((v) => !v)}> + + {collapsed ? "Expand" : "Collapse"} + + + + Reset Zoom + + + setShowStats((v) => !v)} + disabled={!hasTraces || hasTimeline} + > + Show Stats + + + + Export PNG + + + removeStudioPlot(plot.id)} + > + + Close + + + + ); + }, +); PlotWrapper.displayName = "PlotWrapper"; export default memo(PlotWrapper); diff --git a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx index 401a9fde5..f7d1e154f 100644 --- a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx +++ b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx @@ -5,6 +5,7 @@ // chip matches the trace color in the chart (lib/plotStudio/palette). import { Button, + Checkbox, ContextMenu, ContextMenuContent, ContextMenuItem, @@ -22,12 +23,13 @@ import { TooltipContent, TooltipTrigger, } from "@hyperloopupv/frontend-kit/components"; -import { Activity, Eye, EyeOff, GripVertical, Plus, Trash2, X } from "@hyperloopupv/frontend-kit/icons"; +import { ChevronDown, Eye, EyeOff, GripVertical, Plus, Send, Trash2, X } from "@hyperloopupv/frontend-kit/icons"; import { cn } from "@hyperloopupv/frontend-kit/lib"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { resolveSignalColor } from "../../../lib/plotStudio/palette"; import { getSignalName } from "../../../lib/plotStudio/units"; +import { formatOrderParameters } from "../../../lib/orders"; import { getSignal } from "../../../store/slices/plotStudioSlice"; import { useStore } from "../../../store/store"; import { useSignalLoader } from "../hooks/useStudioSignals"; @@ -59,12 +61,18 @@ export default function PlotsSection() { const updateStudioSignalAxis = useStore((s) => s.updateStudioSignalAxis); const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); const updateStudioSignalColor = useStore((s) => s.updateStudioSignalColor); + const orders = useStore((s) => s.orders); + const toggleStudioPlotOrder = useStore((s) => s.toggleStudioPlotOrder); + const setStudioPlotOrdersVisible = useStore((s) => s.setStudioPlotOrdersVisible); const ensureLoaded = useSignalLoader(); // Plot ids with a CSV parse in flight (shows "Loading…" in the trigger) const [assigning, setAssigning] = useState>(new Set()); // Drag-to-reorder: id of the plot currently being dragged const [draggingId, setDraggingId] = useState(null); + const [collapsedPlots, setCollapsedPlots] = useState>(new Set()); + // Order lists can be long, so let each plot keep its own collapsed state. + const [collapsedOrderSections, setCollapsedOrderSections] = useState>(new Set()); const plots = Array.from(studioPlots.values()); @@ -125,12 +133,27 @@ export default function PlotsSection() { draggable onDragStart={() => setDraggingId(plot.id)} onDragEnd={() => setDraggingId(null)} - className="from-primary/5 flex cursor-grab items-center gap-2 border-b bg-gradient-to-r to-transparent px-3 py-2 active:cursor-grabbing" + className={cn( + "from-primary/5 flex cursor-grab items-center gap-2 bg-gradient-to-r to-transparent px-3 py-2 active:cursor-grabbing", + !collapsedPlots.has(plot.id) && "border-b", + )} > -
- -
+
+ {!collapsedPlots.has(plot.id) && ( +
{/* Assigned signals — grouped by axis so left/right membership is obvious from list position, not just a small per-row dropdown. */} {plot.signals.length > 0 && ( @@ -278,6 +303,103 @@ export default function PlotsSection() {
)} + {orders.length > 0 && ( +
+ + + {!collapsedOrderSections.has(plot.id) && ( +
+
+ + +
+ +
+ {orders.map((order) => { + const checked = !plot.hiddenOrderIds?.[order.id]; + return ( + + ); + })} +
+
+ )} +
+ )} + {/* Assign dropdown — grouped by board, hides already-assigned */}
0 ? "border-t pt-1.5" : "pt-2")}>
+
+ )}
))}
diff --git a/frontend/logging-view/src/hooks/useDashboardSetting.ts b/frontend/logging-view/src/hooks/useDashboardSetting.ts new file mode 100644 index 000000000..db08dc2df --- /dev/null +++ b/frontend/logging-view/src/hooks/useDashboardSetting.ts @@ -0,0 +1,28 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { useStore } from "../store/store"; +import type { DashboardSettings, NormalDashboard } from "../types/normal"; + +export function useDashboardSetting( + dashboard: NormalDashboard, + key: K, +): [DashboardSettings[K], Dispatch>] { + const update = useStore((state) => state.updateNormalDashboardSettings); + const id = dashboard.id; + const setValue = useCallback( + (next: SetStateAction) => { + update(id, (settings) => { + const value = + typeof next === "function" + ? (next as (current: DashboardSettings[K]) => DashboardSettings[K])( + settings[key], + ) + : next; + return value === settings[key] + ? settings + : { ...settings, [key]: value }; + }); + }, + [id, key, update], + ); + return [dashboard.settings[key], setValue]; +} diff --git a/frontend/logging-view/src/hooks/useDashboardTemplates.ts b/frontend/logging-view/src/hooks/useDashboardTemplates.ts new file mode 100644 index 000000000..fcfa4d44c --- /dev/null +++ b/frontend/logging-view/src/hooks/useDashboardTemplates.ts @@ -0,0 +1,10 @@ +import { useSyncExternalStore } from "react"; +import { + listTemplates, + subscribeTemplates, +} from "../lib/normal/dashboardStorage"; + +/** The saved dashboard templates, newest first; updates when they change. */ +export function useDashboardTemplates() { + return useSyncExternalStore(subscribeTemplates, listTemplates); +} diff --git a/frontend/logging-view/src/hooks/useDashboardTimeWindow.ts b/frontend/logging-view/src/hooks/useDashboardTimeWindow.ts new file mode 100644 index 000000000..2279fc61b --- /dev/null +++ b/frontend/logging-view/src/hooks/useDashboardTimeWindow.ts @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from "react"; +import { mapPanes } from "../lib/normal/layout"; +import { useStore } from "../store/store"; +import type { + ChartPane, + DashboardSettings, + NormalDashboard, + TimeRange, +} from "../types/normal"; + +const SAME = 1e-4; + +const equal = (a: TimeRange, b: TimeRange) => + Math.abs(a.start - b.start) < SAME && Math.abs(a.end - b.end) < SAME; + +// Drop a pane's own window so it follows the dashboard again. +const unpin = (pane: ChartPane): ChartPane => { + if (!pane.timeWindow) return pane; + const rest = { ...pane }; + delete rest.timeWindow; + return rest; +}; + +const releaseAllPanes = (settings: DashboardSettings): DashboardSettings => { + const tree = mapPanes(settings.layoutState.tree, unpin); + return tree === settings.layoutState.tree + ? settings + : { ...settings, layoutState: { ...settings.layoutState, tree } }; +}; + +/** + * The single funnel for everything that moves time in a dashboard. + * + * Deliberately not routed through `changePane`: that helper always clears + * `layoutState.preset`, and panning time must not drop the layout preset. + */ +export function useDashboardTimeWindow(dashboard: NormalDashboard) { + const update = useStore((state) => state.updateNormalDashboardSettings); + const id = dashboard.id; + const { range, linkTimeAxis } = dashboard.settings; + + // The hot path while linked: one window for the whole dashboard. + const setRange = useCallback( + (next: TimeRange) => { + update(id, (settings) => + equal(settings.range, next) ? settings : { ...settings, range: next }, + ); + }, + [id, update], + ); + + // Toolbar actions: move everything and pull stray plots back in line. + const setRangeForAll = useCallback( + (next: TimeRange) => { + update(id, (settings) => { + const released = releaseAllPanes(settings); + return equal(settings.range, next) && released === settings + ? settings + : { ...released, range: next }; + }); + }, + [id, update], + ); + + const setPaneWindow = useCallback( + (paneId: string, next: TimeRange | null) => { + update(id, (settings) => { + const tree = mapPanes(settings.layoutState.tree, (pane) => { + if (pane.id !== paneId) return pane; + if (!next) return unpin(pane); + if (pane.timeWindow && equal(pane.timeWindow, next)) return pane; + return { ...pane, timeWindow: next }; + }); + return tree === settings.layoutState.tree + ? settings + : { ...settings, layoutState: { ...settings.layoutState, tree } }; + }); + }, + [id, update], + ); + + const setLinked = useCallback( + (next: boolean) => { + update(id, (settings) => { + if (settings.linkTimeAxis === next) return settings; + // Re-linking adopts the dashboard window everywhere; keeping the old + // per-pane positions would only make a later unlink jump. + const base = next ? releaseAllPanes(settings) : settings; + return { ...base, linkTimeAxis: next }; + }); + }, + [id, update], + ); + + return useMemo( + () => ({ + range, + linkTimeAxis, + setRange, + setRangeForAll, + setPaneWindow, + setLinked, + }), + [range, linkTimeAxis, setRange, setRangeForAll, setPaneWindow, setLinked], + ); +} diff --git a/frontend/logging-view/src/hooks/useNormalShortcuts.ts b/frontend/logging-view/src/hooks/useNormalShortcuts.ts new file mode 100644 index 000000000..8482fc7e9 --- /dev/null +++ b/frontend/logging-view/src/hooks/useNormalShortcuts.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { shortcutForKey, type ShortcutId } from "../lib/normal/shortcuts"; + +export type ShortcutHandlers = Record void>; + +const isTyping = (target: EventTarget | null) => + target instanceof HTMLElement && + (target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)); + +// A dialog, menu or picker owns the keyboard while it is open. +const OVERLAYS = '[role="dialog"], [role="menu"], [role="listbox"]'; + +const ARROW_WIDGETS = + '[role="tablist"], [role="separator"], [role="slider"], [role="radiogroup"]'; + +/** Id of the plot the pointer is over, if any. */ +export function hoveredPaneId(): string | null { + const hovered = document.querySelectorAll(":hover"); + const deepest = hovered[hovered.length - 1]; + return ( + deepest?.closest("[data-pane-id]")?.dataset.paneId ?? null + ); +} + +export function useNormalShortcuts(handlers: ShortcutHandlers) { + const handlersRef = useRef(handlers); + useEffect(() => { + handlersRef.current = handlers; + }, [handlers]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (isTyping(event.target) || document.querySelector(OVERLAYS)) return; + const id = shortcutForKey(event); + if (!id) return; + // Tabs, resize handles and sliders move with the arrow keys themselves. + if ( + event.key.startsWith("Arrow") && + event.target instanceof Element && + event.target.closest(ARROW_WIDGETS) + ) + return; + event.preventDefault(); + handlersRef.current[id](); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); +} diff --git a/frontend/logging-view/src/hooks/useSeriesSelection.ts b/frontend/logging-view/src/hooks/useSeriesSelection.ts new file mode 100644 index 000000000..0e796a3ca --- /dev/null +++ b/frontend/logging-view/src/hooks/useSeriesSelection.ts @@ -0,0 +1,34 @@ +import { useLocation } from "react-router"; +import { useStore } from "../store/store"; + +// Sidebar checkboxes and the header always act on the visible workspace. +// Simple mode retains its separate bulk selection for Plot Studio. +export function useSeriesSelection() { + const isNormal = !useLocation().pathname.startsWith("/simple"); + const selectedSeries = useStore((state) => + isNormal + ? state.normalDashboards.find( + (dashboard) => dashboard.id === state.activeNormalDashboardId, + )!.selectedSeries + : state.selectedSeries, + ); + const toggleSeries = useStore((state) => + isNormal ? state.toggleNormalSeries : state.toggleSeries, + ); + const clearSelectedSeries = useStore((state) => + isNormal ? state.clearNormalSeries : state.clearSelectedSeries, + ); + const isSelectionLocked = useStore( + (state) => + isNormal && + !!state.normalDashboards.find( + (dashboard) => dashboard.id === state.activeNormalDashboardId, + )?.locked, + ); + return { + selectedSeries, + toggleSeries, + clearSelectedSeries, + isSelectionLocked, + }; +} diff --git a/frontend/logging-view/src/hooks/useSessionFolder.ts b/frontend/logging-view/src/hooks/useSessionFolder.ts new file mode 100644 index 000000000..7b36d2d08 --- /dev/null +++ b/frontend/logging-view/src/hooks/useSessionFolder.ts @@ -0,0 +1,71 @@ +// Opening a session folder by path (Electron only) and recording it as recent. +// The folder picker and the empty state's recent list both go through here so +// a session is remembered no matter which one opened it. +import { useCallback, useState } from "react"; +import { + canOpenSessionPaths, + sessionFilesFromPath, +} from "../lib/session/electronFolder"; +import { useStore } from "../store/store"; + +export function useSessionFolder() { + const openSession = useStore((state) => state.openSession); + const rememberRecentSession = useStore( + (state) => state.rememberRecentSession, + ); + const [busyPath, setBusyPath] = useState(null); + const [failure, setFailure] = useState<{ + path: string; + message: string; + } | null>(null); + + const openPath = useCallback( + async (dir: string) => { + setBusyPath(dir); + setFailure(null); + try { + const files = await sessionFilesFromPath(dir); + await openSession(files); + const { availableSeries, folderName } = useStore.getState(); + const boards = Object.keys(availableSeries); + rememberRecentSession({ + path: dir, + folderName: + folderName ?? dir.split(/[\\/]/).filter(Boolean).pop() ?? "Session", + openedAt: Date.now(), + boards: boards.length, + series: boards.reduce( + (total, board) => total + availableSeries[board].length, + 0, + ), + }); + return true; + } catch (error) { + setFailure({ + path: dir, + message: error instanceof Error ? error.message : String(error), + }); + return false; + } finally { + setBusyPath(null); + } + }, + [openSession, rememberRecentSession], + ); + + // Native dialog: unlike , it hands back a real path. + const pickFolder = useCallback(async () => { + const dir = await window.electronAPI?.selectFolder?.(); + if (!dir) return false; + return openPath(dir); + }, [openPath]); + + return { + canOpenPaths: canOpenSessionPaths(), + openPath, + pickFolder, + busyPath, + failure, + dismissFailure: () => setFailure(null), + }; +} diff --git a/frontend/logging-view/src/layout/AppLayout.tsx b/frontend/logging-view/src/layout/AppLayout.tsx index a540c2e75..47bcb74c7 100644 --- a/frontend/logging-view/src/layout/AppLayout.tsx +++ b/frontend/logging-view/src/layout/AppLayout.tsx @@ -35,7 +35,7 @@ const AppLayout = ({ children }: AppLayoutProps) => {
- +
{children}
diff --git a/frontend/logging-view/src/lib/download.ts b/frontend/logging-view/src/lib/download.ts new file mode 100644 index 000000000..db3bc52c5 --- /dev/null +++ b/frontend/logging-view/src/lib/download.ts @@ -0,0 +1,43 @@ +// Getting things out of the app: a file download or the clipboard. Both work +// in a plain browser and inside the Electron shell. + +export function downloadBlob(name: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = name; + link.click(); + // Revoked on the next tick: some browsers read the URL after click() returns. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export function downloadText(name: string, text: string, type: string) { + downloadBlob(name, new Blob([text], { type })); +} + +/** Resolves false when the clipboard is unavailable or permission is denied. */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export const canCopyImages = () => + typeof ClipboardItem !== "undefined" && !!navigator.clipboard?.write; + +export async function copyImage(blob: Blob): Promise { + try { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + return true; + } catch { + return false; + } +} + +/** A name safe on every file system, keeping letters in any script. */ +export function safeFileName(name: string, fallback = "export") { + return name.trim().replace(/[^\p{L}\p{N} ._-]+/gu, "_") || fallback; +} diff --git a/frontend/logging-view/src/lib/normal/chartImage.ts b/frontend/logging-view/src/lib/normal/chartImage.ts new file mode 100644 index 000000000..2e08bcd8a --- /dev/null +++ b/frontend/logging-view/src/lib/normal/chartImage.ts @@ -0,0 +1,36 @@ +import type { Ref } from "react"; +import { useImperativeHandle } from "react"; +import type * as echarts from "echarts"; + +/** What a plot's chart offers to its pane, whatever kind of chart it is. */ +export interface ChartHandle { + /** The chart as drawn now, as a PNG on a solid background. */ + getImage: () => Promise; +} + +export function useChartHandle( + ref: Ref | undefined, + chartRef: { current: echarts.ECharts | null }, + background: string, +) { + useImperativeHandle( + ref, + () => ({ + getImage: async () => { + const chart = chartRef.current; + if (!chart) return null; + const url = chart.getDataURL({ + type: "png", + pixelRatio: 2, + backgroundColor: background, + }); + return (await fetch(url)).blob(); + }, + }), + [chartRef, background], + ); +} + +/** The panes' own surface colours, so an export looks like the screen. */ +export const chartBackground = (isDarkMode: boolean) => + isDarkMode ? "#21252c" : "#ffffff"; diff --git a/frontend/logging-view/src/lib/normal/cursors.ts b/frontend/logging-view/src/lib/normal/cursors.ts new file mode 100644 index 000000000..e507c0006 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/cursors.ts @@ -0,0 +1,27 @@ +import { lowerBound } from "../plotStudio/range"; +import type { SeriesData } from "../../types/plotStudio"; + +/** + * Value of a signal at an instant: interpolated between the two neighbouring + * samples, or the last sample held for discrete (enum / bool) signals. + * `null` outside the stretch the signal was recorded for. + */ +export function valueAt( + data: SeriesData, + timeMs: number, + discrete: boolean, +): number | null { + const { time, value } = data; + const count = time.length; + if (count === 0 || timeMs < time[0] || timeMs > time[count - 1]) return null; + const next = lowerBound(time, timeMs); + if (time[next] === timeMs) return value[next]; + const previous = next - 1; + if (discrete) return value[previous]; + const span = time[next] - time[previous]; + if (span <= 0) return value[previous]; + return ( + value[previous] + + ((value[next] - value[previous]) * (timeMs - time[previous])) / span + ); +} diff --git a/frontend/logging-view/src/lib/normal/dashboardStorage.ts b/frontend/logging-view/src/lib/normal/dashboardStorage.ts new file mode 100644 index 000000000..1a2a3fc8e --- /dev/null +++ b/frontend/logging-view/src/lib/normal/dashboardStorage.ts @@ -0,0 +1,359 @@ +// Saving Normal mode dashboards: per session in localStorage, so reopening a +// log brings its layout back, and as a file that can be loaded onto another +// log. Both go through `restoreDashboard`, which trusts nothing it reads. +import { getPanes, mapPanes, signalIdsInLayout } from "./layout"; +import { safeFileName } from "../download"; +import { + FORMULA_ID_PREFIX, + type ChartLayout, + type DashboardSettings, + type FormulaSignal, + type NormalDashboard, + type TimeRange, +} from "../../types/normal"; + +const VERSION = 1; +const FILE_KIND = "logging-view-dashboard"; +const KEY_PREFIX = "logging-view:dashboards:"; +const INDEX_KEY = "logging-view:dashboards-index"; +// Sessions remembered; the least recently saved one is dropped past this. +const MAX_SESSIONS = 20; + +export const DASHBOARD_FILE_EXTENSION = ".dashboard.json"; + +interface SavedSession { + version: number; + activeId: string; + dashboards: unknown[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"); + +const isRange = (value: unknown): value is TimeRange => + isRecord(value) && + typeof value.start === "number" && + typeof value.end === "number" && + value.start >= 0 && + value.end <= 100 && + value.start < value.end; + +function isLayout(value: unknown, depth = 0): value is ChartLayout { + if (!isRecord(value) || typeof value.id !== "string" || depth > 8) + return false; + if (value.type === "pane") + return typeof value.title === "string" && isStringArray(value.signalIds); + return ( + value.type === "split" && + (value.direction === "horizontal" || value.direction === "vertical") && + isLayout(value.first, depth + 1) && + isLayout(value.second, depth + 1) + ); +} + +const isFormula = (value: unknown): value is FormulaSignal => + isRecord(value) && + typeof value.id === "string" && + value.id.startsWith(FORMULA_ID_PREFIX) && + typeof value.name === "string" && + typeof value.expression === "string" && + isRecord(value.variables) && + Object.values(value.variables).every((source) => typeof source === "string"); + +const cursorTime = (value: unknown) => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** + * Rebuilds a dashboard from saved data, or `null` when it is not one. + * + * Fields the saved copy lacks (it may predate them) come from `defaults`. + * Measurements the open session does not have are taken off the plots and + * counted in `dropped`; formulas are kept and report their own missing inputs. + */ +export function restoreDashboard( + raw: unknown, + defaults: NormalDashboard, + hasSeries: (id: string) => boolean, +): { dashboard: NormalDashboard; dropped: number } | null { + if (!isRecord(raw) || !isRecord(raw.settings)) return null; + const saved = raw.settings; + if (!isRecord(saved.layoutState) || !isLayout(saved.layoutState.tree)) + return null; + + const formulas = Array.isArray(saved.formulas) + ? saved.formulas.filter(isFormula) + : []; + const formulaIds = new Set(formulas.map((formula) => formula.id)); + const known = (id: string) => + id.startsWith(FORMULA_ID_PREFIX) ? formulaIds.has(id) : hasSeries(id); + + const missing = new Set(); + const tree = mapPanes(saved.layoutState.tree, (pane) => { + const signalIds = pane.signalIds.filter((id) => { + if (known(id)) return true; + missing.add(id); + return false; + }); + const xSignalId = + typeof pane.xSignalId === "string" && known(pane.xSignalId) + ? pane.xSignalId + : undefined; + if (typeof pane.xSignalId === "string" && !xSignalId) + missing.add(pane.xSignalId); + return { + ...pane, + signalIds, + xSignalId, + normalized: pane.normalized === true, + hiddenSignalIds: isStringArray(pane.hiddenSignalIds) + ? pane.hiddenSignalIds.filter((id) => signalIds.includes(id)) + : [], + markers: Array.isArray(pane.markers) + ? pane.markers.filter( + (marker) => + isRecord(marker) && + typeof marker.id === "string" && + typeof marker.label === "string" && + typeof marker.timeMs === "number", + ) + : [], + ...(isRange(pane.timeWindow) + ? { timeWindow: pane.timeWindow } + : { timeWindow: undefined }), + }; + }); + const paneIds = new Set(getPanes(tree).map((pane) => pane.id)); + const base = defaults.settings; + const flag = (key: keyof DashboardSettings) => + typeof saved[key] === "boolean" ? (saved[key] as boolean) : base[key]; + const savedCursors = isRecord(saved.cursors) ? saved.cursors : {}; + + const settings: DashboardSettings = { + ...base, + layoutState: { + ids: signalIdsInLayout(tree), + tree, + preset: + (saved.layoutState + .preset as DashboardSettings["layoutState"]["preset"]) ?? null, + }, + showStatistics: flag("showStatistics") as boolean, + statisticsColumns: + isStringArray(saved.statisticsColumns) && + saved.statisticsColumns.length > 0 + ? (saved.statisticsColumns as DashboardSettings["statisticsColumns"]) + : base.statisticsColumns, + showOrders: flag("showOrders") as boolean, + enumAsBackground: flag("enumAsBackground") as boolean, + focusedId: + typeof saved.focusedId === "string" && paneIds.has(saved.focusedId) + ? saved.focusedId + : null, + units: isRecord(saved.units) + ? Object.fromEntries( + Object.entries(saved.units).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : {}, + timeUnit: ["us", "ms", "s", "min"].includes(saved.timeUnit as string) + ? (saved.timeUnit as DashboardSettings["timeUnit"]) + : base.timeUnit, + range: isRange(saved.range) ? saved.range : base.range, + linkTimeAxis: flag("linkTimeAxis") as boolean, + panelSizes: isRecord(saved.panelSizes) + ? (saved.panelSizes as DashboardSettings["panelSizes"]) + : {}, + formulas, + showCursors: flag("showCursors") as boolean, + cursors: { a: cursorTime(savedCursors.a), b: cursorTime(savedCursors.b) }, + }; + + return { + dashboard: { + id: typeof raw.id === "string" ? raw.id : defaults.id, + name: + typeof raw.name === "string" && raw.name.trim() + ? raw.name.slice(0, 80) + : defaults.name, + color: typeof raw.color === "string" ? raw.color : null, + locked: raw.locked === true, + selectedSeries: isRecord(raw.selectedSeries) + ? (Object.fromEntries( + Object.entries(raw.selectedSeries).filter( + ([id, selected]) => selected === true && hasSeries(id), + ), + ) as Record) + : {}, + settings, + }, + dropped: missing.size, + }; +} + +// ---- Per-session storage ------------------------------------------------- + +// localStorage can be unavailable or full; saving a layout is a convenience +// and must never break the app. +function readJson(key: string): unknown { + try { + const text = localStorage.getItem(key); + return text === null ? null : JSON.parse(text); + } catch { + return null; + } +} + +export function saveSessionDashboards( + folderName: string, + dashboards: NormalDashboard[], + activeId: string, +) { + try { + const saved: SavedSession = { version: VERSION, activeId, dashboards }; + localStorage.setItem(KEY_PREFIX + folderName, JSON.stringify(saved)); + const index = readJson(INDEX_KEY); + const sessions = [ + folderName, + ...(isStringArray(index) ? index : []).filter( + (name) => name !== folderName, + ), + ]; + for (const evicted of sessions.slice(MAX_SESSIONS)) + localStorage.removeItem(KEY_PREFIX + evicted); + localStorage.setItem( + INDEX_KEY, + JSON.stringify(sessions.slice(0, MAX_SESSIONS)), + ); + } catch { + // Not saved this time; the next change tries again. + } +} + +/** The raw dashboards saved for a session, still to be validated. */ +export function loadSessionDashboards( + folderName: string, +): { activeId: string; dashboards: unknown[] } | null { + const saved = readJson(KEY_PREFIX + folderName); + if ( + !isRecord(saved) || + saved.version !== VERSION || + !Array.isArray(saved.dashboards) || + saved.dashboards.length === 0 + ) + return null; + return { + activeId: typeof saved.activeId === "string" ? saved.activeId : "", + dashboards: saved.dashboards, + }; +} + +// ---- Files --------------------------------------------------------------- + +export function serializeDashboard(dashboard: NormalDashboard): string { + return JSON.stringify( + { + kind: FILE_KIND, + version: VERSION, + name: dashboard.name, + color: dashboard.color, + settings: dashboard.settings, + }, + null, + 2, + ); +} + +/** The dashboard inside an exported file, still to be validated. */ +export function parseDashboardFile(text: string): unknown | null { + try { + const file: unknown = JSON.parse(text); + return isRecord(file) && file.kind === FILE_KIND && file.version === VERSION + ? file + : null; + } catch { + return null; + } +} + +export function dashboardFileName(name: string): string { + return `${safeFileName(name, "dashboard")}${DASHBOARD_FILE_EXTENSION}`; +} + +// ---- Templates ----------------------------------------------------------- + +const TEMPLATES_KEY = "logging-view:dashboard-templates"; +const MAX_TEMPLATES = 30; + +export interface DashboardTemplate { + id: string; + name: string; + savedAt: number; + /** The same text an exported dashboard file holds. */ + file: string; +} + +const isTemplate = (value: unknown): value is DashboardTemplate => + isRecord(value) && + typeof value.id === "string" && + typeof value.name === "string" && + typeof value.savedAt === "number" && + typeof value.file === "string"; + +// A stable snapshot for useSyncExternalStore: the same array until it changes. +let templates: DashboardTemplate[] | null = null; +const templateListeners = new Set<() => void>(); + +export function listTemplates(): DashboardTemplate[] { + if (!templates) { + const saved = readJson(TEMPLATES_KEY); + templates = Array.isArray(saved) ? saved.filter(isTemplate) : []; + } + return templates; +} + +export function subscribeTemplates(listener: () => void) { + templateListeners.add(listener); + return () => { + templateListeners.delete(listener); + }; +} + +function writeTemplates(next: DashboardTemplate[]): boolean { + try { + localStorage.setItem(TEMPLATES_KEY, JSON.stringify(next)); + } catch { + return false; + } + templates = next; + templateListeners.forEach((listener) => listener()); + return true; +} + +/** Saves under `name`, replacing a template of the same name. */ +export function saveTemplate( + name: string, + dashboard: NormalDashboard, +): boolean { + const trimmed = name.trim().slice(0, 80); + if (!trimmed) return false; + const template: DashboardTemplate = { + id: crypto.randomUUID(), + name: trimmed, + savedAt: Date.now(), + file: serializeDashboard({ ...dashboard, name: trimmed }), + }; + return writeTemplates( + [ + template, + ...listTemplates().filter((item) => item.name !== trimmed), + ].slice(0, MAX_TEMPLATES), + ); +} + +export function deleteTemplate(id: string) { + writeTemplates(listTemplates().filter((item) => item.id !== id)); +} diff --git a/frontend/logging-view/src/lib/normal/enumColors.ts b/frontend/logging-view/src/lib/normal/enumColors.ts new file mode 100644 index 000000000..3195e2ba2 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/enumColors.ts @@ -0,0 +1,37 @@ +// Colors for enum variants. Nothing in the monorepo did this before: the kit's +// getTypeBadgeClass colors by measurement *type*, and Plot Studio picks trace +// colors by position. Two requirements shape this list: +// - it must not include the orange reserved for order markers +// (see lib/plotStudio/palette.ts), and +// - behind a line chart it has to stay out of the way, so the band variant +// is deliberately pale. +const ENUM_HUES = [ + "#1f77b4", // blue + "#2ca02c", // green + "#9467bd", // purple + "#17becf", // cyan + "#bcbd22", // olive + "#e377c2", // pink + "#8c564b", // brown + "#0f766e", // teal + "#6b7280", // gray + "#d62728", // red +] as const; + +/** Opaque color for a variant, for a timeline row or a legend swatch. */ +export function enumVariantColor(index: number): string { + return ENUM_HUES[((index % ENUM_HUES.length) + ENUM_HUES.length) % ENUM_HUES.length]; +} + +/** + * The same color as a translucent wash, for a band drawn behind curves. + * Dark mode needs a touch more alpha to read against the darker panel. + */ +export function enumBandColor(index: number, isDarkMode: boolean): string { + const hex = enumVariantColor(index); + const alpha = isDarkMode ? 0.28 : 0.18; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} diff --git a/frontend/logging-view/src/lib/normal/enums.ts b/frontend/logging-view/src/lib/normal/enums.ts new file mode 100644 index 000000000..80595c71c --- /dev/null +++ b/frontend/logging-view/src/lib/normal/enums.ts @@ -0,0 +1,59 @@ +// How a pane's visible signals are drawn once enums are in play. +// +// Three shapes, decided by composition rather than by a mode switch: +// - every signal discrete -> a Gantt-style state timeline +// - exactly one discrete + numerics -> the enum as a background band +// - anything else -> plain lines (a discrete signal then +// draws as a step line on its own axis) +// +// Mixing two enums with numeric series is refused, the way testing-view's +// canAddSeriesToChart refuses it: the band can only express one state at a +// time, and stacking several washes is unreadable. +import type { ComparisonSignal } from "../../types/normal"; + +export type EnumDisplay = "background" | "series"; + +export interface PaneRender { + mode: "timeline" | "lines"; + /** Signals drawn as lines (or as timeline rows when mode is "timeline"). */ + plotted: ComparisonSignal[]; + /** The enum drawn as a background band, if any. */ + band: ComparisonSignal | null; +} + +export function classifyPaneSignals( + visible: ComparisonSignal[], + enumDisplay: EnumDisplay, +): PaneRender { + if (visible.length === 0) return { mode: "lines", plotted: [], band: null }; + + const discrete = visible.filter((signal) => signal.discrete); + if (discrete.length === visible.length) + return { mode: "timeline", plotted: visible, band: null }; + + if (discrete.length === 1 && enumDisplay === "background") + return { + mode: "lines", + plotted: visible.filter((signal) => !signal.discrete), + band: discrete[0], + }; + + return { mode: "lines", plotted: visible, band: null }; +} + +/** + * Whether `incoming` may join a pane that already holds `current`. Keeps the + * band's "one enum at a time" rule enforceable at the point signals enter. + */ +export function canMixSignals( + current: ComparisonSignal[], + incoming: ComparisonSignal[], +): boolean { + const all = [...current, ...incoming]; + const discrete = all.filter((signal) => signal.discrete).length; + if (discrete === 0 || discrete === all.length) return true; + return discrete === 1; +} + +export const MIXED_ENUM_MESSAGE = + "A plot can hold one enum alongside numeric signals, or several enums on their own."; diff --git a/frontend/logging-view/src/lib/normal/formula.ts b/frontend/logging-view/src/lib/normal/formula.ts new file mode 100644 index 000000000..b59168b1b --- /dev/null +++ b/frontend/logging-view/src/lib/normal/formula.ts @@ -0,0 +1,498 @@ +// Formula signals: an expression over session measurements, evaluated into a +// new series. The expression is parsed into an AST and evaluated a whole +// array at a time — nothing is handed to `eval`/`new Function`. +import type { DashboardSettings, FormulaSignal } from "../../types/normal"; +import type { SeriesData } from "../../types/plotStudio"; +import { lowerBound, upperBound } from "../plotStudio/range"; +import { getPanes, updatePane } from "./layout"; + +type Node = + | { kind: "number"; value: number } + | { kind: "variable"; name: string } + | { kind: "negate"; operand: Node } + | { + kind: "binary"; + operator: "+" | "-" | "*" | "/" | "^"; + left: Node; + right: Node; + } + | { kind: "call"; name: string; args: Node[] }; + +export type FormulaAst = Node; + +type Value = number | Float64Array; + +const CONSTANTS: Record = { pi: Math.PI, e: Math.E }; + +// Evaluated per sample; arguments broadcast like a spreadsheet would. +const POINTWISE: Record< + string, + { arity: [number, number]; apply: (...args: number[]) => number } +> = { + abs: { arity: [1, 1], apply: Math.abs }, + sqrt: { arity: [1, 1], apply: Math.sqrt }, + exp: { arity: [1, 1], apply: Math.exp }, + ln: { arity: [1, 1], apply: Math.log }, + log10: { arity: [1, 1], apply: Math.log10 }, + sin: { arity: [1, 1], apply: Math.sin }, + cos: { arity: [1, 1], apply: Math.cos }, + tan: { arity: [1, 1], apply: Math.tan }, + asin: { arity: [1, 1], apply: Math.asin }, + acos: { arity: [1, 1], apply: Math.acos }, + atan: { arity: [1, 1], apply: Math.atan }, + atan2: { arity: [2, 2], apply: Math.atan2 }, + sign: { arity: [1, 1], apply: Math.sign }, + round: { arity: [1, 1], apply: Math.round }, + floor: { arity: [1, 1], apply: Math.floor }, + ceil: { arity: [1, 1], apply: Math.ceil }, + pow: { arity: [2, 2], apply: Math.pow }, + min: { arity: [2, 8], apply: Math.min }, + max: { arity: [2, 8], apply: Math.max }, + clamp: { + arity: [3, 3], + apply: (value, low, high) => Math.min(high, Math.max(low, value)), + }, +}; + +// Need the neighbouring samples and the time axis, so they take whole series. +const SERIES_ARITY: Record = { + derivative: [1, 1], + integral: [1, 1], + smooth: [2, 2], +}; + +/** Shown in the editor as a cheat-sheet. */ +export const FORMULA_REFERENCE: { label: string; items: string }[] = [ + { label: "Operators", items: "+ − * / ^ ( )" }, + { label: "Constants", items: "pi, e, t (seconds since the first sample)" }, + { + label: "Functions", + items: + "abs, sqrt, exp, ln, log10, sin, cos, tan, asin, acos, atan, atan2(y, x), sign, round, floor, ceil, pow(x, n), min(…), max(…), clamp(x, low, high)", + }, + { + label: "Over time", + items: + "derivative(x) per second, integral(x) cumulative ·s, smooth(x, n) moving average of n samples", + }, +]; + +const RESERVED = new Set([ + ...Object.keys(CONSTANTS), + "t", + ...Object.keys(POINTWISE), + ...Object.keys(SERIES_ARITY), +]); + +/** Why `name` cannot be a variable name, or null when it can. */ +export function variableNameError(name: string): string | null { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) + return "Use letters, digits and _ only, starting with a letter"; + if (RESERVED.has(name)) return `"${name}" is a built-in name`; + return null; +} + +/** First free name from a, b, c… skipping built-ins such as e and t. */ +export function nextVariableName(taken: Iterable): string { + const used = new Set(taken); + for (const letter of "abcdfghijklmnopqrsuvwxyz") + if (!used.has(letter)) return letter; + for (let number = 1; ; number++) + if (!used.has(`x${number}`)) return `x${number}`; +} + +type Token = + | { type: "number"; value: number; at: number } + | { type: "name"; value: string; at: number } + | { type: "symbol"; value: string; at: number }; + +class FormulaError extends Error {} + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + const pattern = + /\s*(?:(\d+\.?\d*(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)|([A-Za-z_][A-Za-z0-9_]*)|([-+*/^(),]))/y; + let position = 0; + while (position < source.length) { + if (/^\s*$/.test(source.slice(position))) break; + pattern.lastIndex = position; + const match = pattern.exec(source); + if (!match) { + const at = position + source.slice(position).search(/\S/); + throw new FormulaError( + `Unexpected "${source[at]}" at position ${at + 1}`, + ); + } + const at = pattern.lastIndex - (match[1] ?? match[2] ?? match[3]).length; + if (match[1] !== undefined) + tokens.push({ type: "number", value: Number(match[1]), at }); + else if (match[2] !== undefined) + tokens.push({ type: "name", value: match[2], at }); + else tokens.push({ type: "symbol", value: match[3], at }); + position = pattern.lastIndex; + } + return tokens; +} + +function parse(source: string): Node { + const tokens = tokenize(source); + let index = 0; + const peek = () => tokens[index]; + const isSymbol = (value: string) => { + const token = peek(); + return token?.type === "symbol" && token.value === value; + }; + const fail = (): never => { + const token = peek(); + throw new FormulaError( + token + ? `Unexpected "${token.value}" at position ${token.at + 1}` + : "The expression ends too early", + ); + }; + const expect = (value: string) => { + if (!isSymbol(value)) fail(); + index++; + }; + + const primary = (): Node => { + const token = peek(); + if (!token) return fail(); + if (token.type === "number") { + index++; + return { kind: "number", value: token.value }; + } + if (token.type === "name") { + index++; + if (!isSymbol("(")) return { kind: "variable", name: token.value }; + index++; + const args: Node[] = []; + if (!isSymbol(")")) { + args.push(additive()); + while (isSymbol(",")) { + index++; + args.push(additive()); + } + } + expect(")"); + const arity = POINTWISE[token.value]?.arity ?? SERIES_ARITY[token.value]; + if (!arity) throw new FormulaError(`Unknown function "${token.value}"`); + if (args.length < arity[0] || args.length > arity[1]) + throw new FormulaError( + `${token.value}() takes ${ + arity[0] === arity[1] ? arity[0] : `${arity[0]} to ${arity[1]}` + } argument${arity[1] === 1 ? "" : "s"}`, + ); + return { kind: "call", name: token.value, args }; + } + if (token.value === "(") { + index++; + const inner = additive(); + expect(")"); + return inner; + } + return fail(); + }; + // -a^2 is -(a^2), and 2^-1 is allowed: the exponent is parsed as a unary. + const power = (): Node => { + const base = primary(); + if (!isSymbol("^")) return base; + index++; + return { kind: "binary", operator: "^", left: base, right: unary() }; + }; + const unary = (): Node => { + if (isSymbol("-")) { + index++; + return { kind: "negate", operand: unary() }; + } + if (isSymbol("+")) { + index++; + return unary(); + } + return power(); + }; + const multiplicative = (): Node => { + let left = unary(); + while (isSymbol("*") || isSymbol("/")) { + const operator = (peek() as Token).value as "*" | "/"; + index++; + left = { kind: "binary", operator, left, right: unary() }; + } + return left; + }; + const additive = (): Node => { + let left = multiplicative(); + while (isSymbol("+") || isSymbol("-")) { + const operator = (peek() as Token).value as "+" | "-"; + index++; + left = { kind: "binary", operator, left, right: multiplicative() }; + } + return left; + }; + + if (tokens.length === 0) throw new FormulaError("Type an expression"); + const ast = additive(); + if (index < tokens.length) fail(); + return ast; +} + +function collectVariables(node: Node, into: Set) { + if (node.kind === "variable") { + if (!(node.name in CONSTANTS) && node.name !== "t") into.add(node.name); + } else if (node.kind === "negate") collectVariables(node.operand, into); + else if (node.kind === "binary") { + collectVariables(node.left, into); + collectVariables(node.right, into); + } else if (node.kind === "call") + for (const arg of node.args) collectVariables(arg, into); +} + +export type ParsedFormula = + | { ok: true; ast: FormulaAst; variables: string[] } + | { ok: false; error: string }; + +/** Syntax check only: it does not know which variables are bound. */ +export function parseFormula(expression: string): ParsedFormula { + try { + const ast = parse(expression); + const variables = new Set(); + collectVariables(ast, variables); + return { ok: true, ast, variables: [...variables] }; + } catch (error) { + if (error instanceof FormulaError) + return { ok: false, error: error.message }; + throw error; + } +} + +export interface FormulaInput { + data: SeriesData; + /** Enum/bool sources hold their state between samples instead of ramping. */ + discrete: boolean; +} + +/** Resample `input` at each `grid` time (grid lies inside the input's span). */ +/** + * Puts several signals on one time base: the timestamps of the densest one, + * restricted to the span all of them cover, with the others interpolated (or + * held, if discrete) onto it. `null` when they share no time at all. + */ +export function alignOnGrid( + inputs: FormulaInput[], +): { grid: Float64Array; values: Float64Array[] } | null { + let start = -Infinity; + let end = Infinity; + for (const { data } of inputs) { + if (data.time.length === 0) return null; + start = Math.max(start, data.time[0]); + end = Math.min(end, data.time[data.time.length - 1]); + } + let grid: Float64Array = new Float64Array(0); + for (const { data } of inputs) { + const candidate = data.time.subarray( + lowerBound(data.time, start), + upperBound(data.time, end), + ); + if (candidate.length > grid.length) grid = candidate; + } + if (grid.length === 0) return null; + return { grid, values: inputs.map((input) => resample(input, grid)) }; +} + +function resample(input: FormulaInput, grid: Float64Array): Float64Array { + const { time, value } = input.data; + const out = new Float64Array(grid.length); + let index = 0; + for (let i = 0; i < grid.length; i++) { + const at = grid[i]; + while (index < time.length - 1 && time[index + 1] <= at) index++; + const span = index < time.length - 1 ? time[index + 1] - time[index] : 0; + out[i] = + input.discrete || span <= 0 || at <= time[index] + ? value[index] + : value[index] + + ((at - time[index]) / span) * (value[index + 1] - value[index]); + } + return out; +} + +function broadcast( + args: Value[], + length: number, + apply: (...values: number[]) => number, +): Value { + if (args.every((arg) => typeof arg === "number")) + return apply(...(args as number[])); + const out = new Float64Array(length); + const row = new Array(args.length); + for (let i = 0; i < length; i++) { + for (let j = 0; j < args.length; j++) { + const arg = args[j]; + row[j] = typeof arg === "number" ? arg : arg[i]; + } + out[i] = apply(...row); + } + return out; +} + +function toSeries(value: Value, length: number): Float64Array { + return typeof value === "number" + ? new Float64Array(length).fill(value) + : value; +} + +function derivative(x: Float64Array, time: Float64Array): Float64Array { + const n = x.length; + const out = new Float64Array(n); + for (let i = 0; i < n; i++) { + const before = Math.max(0, i - 1); + const after = Math.min(n - 1, i + 1); + // Time is in ms; a repeated timestamp gives NaN, dropped later. + out[i] = ((x[after] - x[before]) / (time[after] - time[before])) * 1000; + } + return out; +} + +function integral(x: Float64Array, time: Float64Array): Float64Array { + const out = new Float64Array(x.length); + for (let i = 1; i < x.length; i++) + out[i] = + out[i - 1] + ((x[i] + x[i - 1]) / 2) * ((time[i] - time[i - 1]) / 1000); + return out; +} + +function smooth(x: Float64Array, samples: number): Float64Array { + const n = x.length; + const window = Math.max(1, Math.round(samples)); + const before = Math.floor((window - 1) / 2); + const after = window - 1 - before; + const sums = new Float64Array(n + 1); + for (let i = 0; i < n; i++) sums[i + 1] = sums[i] + x[i]; + const out = new Float64Array(n); + for (let i = 0; i < n; i++) { + const from = Math.max(0, i - before); + const to = Math.min(n, i + after + 1); + out[i] = (sums[to] - sums[from]) / (to - from); + } + return out; +} + +/** + * Evaluate a parsed formula. Inputs are aligned first: the result lives on + * the timestamps of the densest input, restricted to the span every input + * covers, and the others are interpolated onto it. Samples whose result is + * not finite (division by zero, sqrt of a negative…) are left out. + * Throws an Error with a user-facing message. + */ +export function evaluateFormula( + ast: FormulaAst, + inputs: Record, +): SeriesData { + const names = new Set(); + collectVariables(ast, names); + if (names.size === 0) + throw new Error("The expression must use at least one measurement"); + const ordered = [...names]; + for (const name of ordered) { + const input = inputs[name]; + if (!input) throw new Error(`"${name}" is not bound to a measurement`); + if (input.data.time.length === 0) + throw new Error(`"${name}" has no samples`); + } + const aligned = alignOnGrid(ordered.map((name) => inputs[name])); + if (!aligned) throw new Error("The measurements do not overlap in time"); + const { grid } = aligned; + + const length = grid.length; + const series: Record = {}; + ordered.forEach((name, index) => (series[name] = aligned.values[index])); + let seconds: Float64Array | null = null; + + const run = (node: Node): Value => { + switch (node.kind) { + case "number": + return node.value; + case "variable": + if (node.name in CONSTANTS) return CONSTANTS[node.name]; + if (node.name === "t") { + seconds ??= grid.map((time) => (time - grid[0]) / 1000); + return seconds; + } + return series[node.name]; + case "negate": + return broadcast([run(node.operand)], length, (value) => -value); + case "binary": { + const operator = node.operator; + return broadcast( + [run(node.left), run(node.right)], + length, + operator === "+" + ? (a, b) => a + b + : operator === "-" + ? (a, b) => a - b + : operator === "*" + ? (a, b) => a * b + : operator === "/" + ? (a, b) => a / b + : Math.pow, + ); + } + case "call": { + const args = node.args.map(run); + if (node.name === "derivative") + return derivative(toSeries(args[0], length), grid); + if (node.name === "integral") + return integral(toSeries(args[0], length), grid); + if (node.name === "smooth") { + if (typeof args[1] !== "number" || !(args[1] >= 1)) + throw new Error( + "smooth(x, n) needs a constant number of samples, 1 or more", + ); + return smooth(toSeries(args[0], length), args[1]); + } + return broadcast(args, length, POINTWISE[node.name].apply); + } + } + }; + + const result = toSeries(run(ast), length); + const time = new Float64Array(length); + const value = new Float64Array(length); + let count = 0; + for (let i = 0; i < length; i++) { + if (!Number.isFinite(result[i])) continue; + time[count] = grid[i]; + value[count] = result[i]; + count++; + } + if (count === 0) throw new Error("The result has no finite values"); + return { time: time.subarray(0, count), value: value.subarray(0, count) }; +} + +/** Drop a formula and take it out of every plot that showed it. */ +export function removeFormula( + settings: DashboardSettings, + id: string, +): DashboardSettings { + let tree = settings.layoutState.tree; + for (const pane of getPanes(tree)) + if (pane.signalIds.includes(id) || pane.xSignalId === id) + tree = updatePane(tree, pane.id, (current) => ({ + ...current, + signalIds: current.signalIds.filter((item) => item !== id), + hiddenSignalIds: current.hiddenSignalIds?.filter((item) => item !== id), + xSignalId: current.xSignalId === id ? undefined : current.xSignalId, + })); + const units = { ...settings.units }; + delete units[id]; + return { + ...settings, + units, + formulas: settings.formulas.filter((formula) => formula.id !== id), + layoutState: { ...settings.layoutState, tree }, + }; +} + +export function formulaLabel(formula: FormulaSignal): string { + return `${formula.name} = ${formula.expression}`; +} diff --git a/frontend/logging-view/src/lib/normal/history.ts b/frontend/logging-view/src/lib/normal/history.ts new file mode 100644 index 000000000..c604f7597 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/history.ts @@ -0,0 +1,89 @@ +// Undo/redo for a dashboard's content. What is recorded is settings snapshots +// (immutable, so keeping them costs nothing); what is NOT recorded is view +// state, so zooming or panning never fills the history and undoing a plot +// change leaves the view where it is. +import { getPanes, mapPanes, signalIdsInLayout } from "./layout"; +import type { ChartPane, DashboardSettings } from "../../types/normal"; + +export const HISTORY_LIMIT = 50; +/** Edits closer together than this merge into one step (typing, dragging). */ +export const MERGE_MS = 400; + +export interface DashboardHistory { + past: DashboardSettings[]; + future: DashboardSettings[]; +} + +const VIEW_KEYS = new Set([ + "range", + "cursors", + "panelSizes", + "focusedId", + "showStatistics", + "showCursors", + "showOrders", + "statisticsColumns", + "timeUnit", +]); + +const withoutWindow = (pane: ChartPane): ChartPane => { + if (!pane.timeWindow) return pane; + const rest = { ...pane }; + delete rest.timeWindow; + return rest; +}; + +const contentOf = (settings: DashboardSettings) => + JSON.stringify(mapPanes(settings.layoutState.tree, withoutWindow)); + +/** True when only view state differs, i.e. nothing worth an undo step. */ +export function isViewOnlyChange( + previous: DashboardSettings, + next: DashboardSettings, +): boolean { + for (const key of Object.keys(next) as (keyof DashboardSettings)[]) { + if (VIEW_KEYS.has(key) || key === "layoutState") continue; + if (previous[key] !== next[key]) return false; + } + if (previous.layoutState === next.layoutState) return true; + if (previous.layoutState.preset !== next.layoutState.preset) return false; + return contentOf(previous) === contentOf(next); +} + +/** + * `snapshot`'s content with `current`'s view: the zoom, the panels' own + * windows and the open panels stay as they are on screen. + */ +export function restoreContent( + snapshot: DashboardSettings, + current: DashboardSettings, +): DashboardSettings { + const windows = new Map( + getPanes(current.layoutState.tree).map((pane) => [ + pane.id, + pane.timeWindow, + ]), + ); + const tree = mapPanes(snapshot.layoutState.tree, (pane) => { + const window = windows.get(pane.id); + const base = withoutWindow(pane); + return window ? { ...base, timeWindow: window } : base; + }); + const view = Object.fromEntries( + [...VIEW_KEYS].map((key) => [key, current[key]]), + ) as Partial; + const paneIds = new Set(getPanes(tree).map((pane) => pane.id)); + return { + ...snapshot, + ...view, + focusedId: + current.focusedId && paneIds.has(current.focusedId) + ? current.focusedId + : null, + layoutState: { + ...snapshot.layoutState, + ids: signalIdsInLayout(tree), + tree, + }, + }; +} diff --git a/frontend/logging-view/src/lib/normal/layout.ts b/frontend/logging-view/src/lib/normal/layout.ts new file mode 100644 index 000000000..0472248ea --- /dev/null +++ b/frontend/logging-view/src/lib/normal/layout.ts @@ -0,0 +1,210 @@ +import type { ChartLayout, ChartPane, LayoutPreset } from "../../types/normal"; + +export function createPane( + signalIds: string[] = [], + title = "Plot", +): ChartPane { + return { + type: "pane", + id: crypto.randomUUID(), + title, + signalIds, + normalized: false, + }; +} + +function split( + first: ChartLayout, + second: ChartLayout, + direction: "horizontal" | "vertical", +): ChartLayout { + return { type: "split", id: crypto.randomUUID(), direction, first, second }; +} + +export function getPanes(layout: ChartLayout): ChartPane[] { + return layout.type === "pane" + ? [layout] + : [...getPanes(layout.first), ...getPanes(layout.second)]; +} + +export function createLayout(ids: string[], preset: LayoutPreset): ChartLayout { + const count = + preset === "single" + ? 1 + : Math.min(preset === "grid" ? 4 : 2, Math.max(1, ids.length)); + const panes = Array.from({ length: count }, (_, index) => + createPane( + ids.filter((_, i) => i % count === index), + count === 1 ? "Plot" : `Plot ${index + 1}`, + ), + ); + if (count === 1) return panes[0]; + if (preset !== "grid" || count === 2) + return split( + panes[0], + panes[1], + preset === "rows" ? "vertical" : "horizontal", + ); + return split( + split(panes[0], panes[1], "horizontal"), + count === 3 ? panes[2] : split(panes[2], panes[3], "horizontal"), + "vertical", + ); +} + +function combine( + nodes: ChartLayout[], + direction: "horizontal" | "vertical", +): ChartLayout { + return nodes + .slice(1) + .reduce((layout, node) => split(layout, node, direction), nodes[0]); +} + +/** Rearrange complete plots while preserving their titles and measurements. */ +export function arrangePanes( + panes: ChartPane[], + preset: LayoutPreset, +): ChartLayout { + if (panes.length === 0) return createPane(); + if (panes.length === 1 || preset === "single") return panes[0]; + if (preset === "columns") return combine(panes, "horizontal"); + if (preset === "rows") return combine(panes, "vertical"); + + const rows: ChartLayout[] = []; + for (let index = 0; index < panes.length; index += 2) { + rows.push( + index + 1 < panes.length + ? split(panes[index], panes[index + 1], "horizontal") + : panes[index], + ); + } + return rows.length === 1 ? rows[0] : combine(rows, "vertical"); +} + +/** + * Rebuild a layout with brand-new ids for every node, so a copy shares no + * identity with its source. Returns the old -> new id map too: `focusedId` + * and every `panelSizes` key are node ids that have to be remapped with it. + */ +export function cloneLayout(layout: ChartLayout): { + tree: ChartLayout; + idMap: Map; +} { + const idMap = new Map(); + const clone = (node: ChartLayout): ChartLayout => { + const id = crypto.randomUUID(); + idMap.set(node.id, id); + return node.type === "pane" + ? { + ...node, + id, + signalIds: [...node.signalIds], + ...(node.hiddenSignalIds + ? { hiddenSignalIds: [...node.hiddenSignalIds] } + : {}), + ...(node.timeWindow ? { timeWindow: { ...node.timeWindow } } : {}), + } + : { ...node, id, first: clone(node.first), second: clone(node.second) }; + }; + return { tree: clone(layout), idMap }; +} + +/** + * Apply `update` to every pane. Nodes whose update returns the same object are + * returned unchanged, so an unaffected subtree keeps its identity and + * `updateNormalDashboardSettings`' reference bail-out still fires. + */ +export function mapPanes( + layout: ChartLayout, + update: (pane: ChartPane) => ChartPane, +): ChartLayout { + if (layout.type === "pane") return update(layout); + const first = mapPanes(layout.first, update); + const second = mapPanes(layout.second, update); + return first === layout.first && second === layout.second + ? layout + : { ...layout, first, second }; +} + +export function signalIdsInLayout(layout: ChartLayout): string[] { + // The X of an X‑Y plot is loaded and computed like any plotted signal. + return [ + ...new Set( + getPanes(layout).flatMap((current) => + current.xSignalId + ? [...current.signalIds, current.xSignalId] + : current.signalIds, + ), + ), + ]; +} + +export function nextPaneTitle(layout: ChartLayout): string { + const titles = new Set(getPanes(layout).map((current) => current.title)); + if (!titles.has("Plot")) return "Plot"; + let number = 2; + while (titles.has(`Plot ${number}`)) number += 1; + return `Plot ${number}`; +} + +export function appendPane( + layout: ChartLayout, + next: ChartPane, + preset: LayoutPreset | null, +): ChartLayout { + const arrangement = + preset === "columns" || preset === "rows" ? preset : "grid"; + return arrangePanes([...getPanes(layout), next], arrangement); +} + +export function updatePane( + layout: ChartLayout, + id: string, + update: (pane: ChartPane) => ChartLayout, +): ChartLayout { + if (layout.type === "pane") return layout.id === id ? update(layout) : layout; + return { + ...layout, + first: updatePane(layout.first, id, update), + second: updatePane(layout.second, id, update), + }; +} + +export function splitPane( + layout: ChartLayout, + id: string, + direction: "horizontal" | "vertical", +): ChartLayout { + return updatePane(layout, id, (current) => { + return split(current, createPane([], nextPaneTitle(layout)), direction); + }); +} + +export function removePane( + layout: ChartLayout, + id: string, +): ChartLayout | null { + if (layout.type === "pane") return layout.id === id ? null : layout; + const first = removePane(layout.first, id); + const second = removePane(layout.second, id); + return first && second ? { ...layout, first, second } : (first ?? second); +} + +export function minimumLayoutSize(layout: ChartLayout): { + width: number; + height: number; +} { + if (layout.type === "pane") return { width: 320, height: 270 }; + const first = minimumLayoutSize(layout.first); + const second = minimumLayoutSize(layout.second); + return layout.direction === "horizontal" + ? { + width: first.width + second.width + 10, + height: Math.max(first.height, second.height), + } + : { + width: Math.max(first.width, second.width), + height: first.height + second.height + 10, + }; +} diff --git a/frontend/logging-view/src/lib/normal/shortcuts.ts b/frontend/logging-view/src/lib/normal/shortcuts.ts new file mode 100644 index 000000000..97796ad5e --- /dev/null +++ b/frontend/logging-view/src/lib/normal/shortcuts.ts @@ -0,0 +1,209 @@ +// The single list of Normal mode shortcuts: the key handler and the help +// dialog both read it, so the dialog cannot drift from what the keys do. + +export type ShortcutId = + | "zoomIn" + | "zoomOut" + | "panLeft" + | "panRight" + | "resetZoom" + | "newPlot" + | "newFormula" + | "search" + | "statistics" + | "cursors" + | "orders" + | "linkTime" + | "expand" + | "fitY" + | "previousEvent" + | "nextEvent" + | "escape" + | "undo" + | "redo" + | "help"; + +export interface ShortcutDefinition { + id: ShortcutId; + /** `KeyboardEvent.key` values, lower-cased, that trigger it. */ + keys: string[]; + /** How the keys are shown to the user. */ + label: string; + description: string; + group: "Time" | "Plots" | "Panels" | "General"; +} + +export const NORMAL_SHORTCUTS: ShortcutDefinition[] = [ + { + id: "zoomIn", + keys: ["+", "="], + label: "+", + description: "Zoom in", + group: "Time", + }, + { + id: "zoomOut", + keys: ["-", "_"], + label: "−", + description: "Zoom out", + group: "Time", + }, + { + id: "panLeft", + keys: ["arrowleft"], + label: "←", + description: "Move the window back", + group: "Time", + }, + { + id: "panRight", + keys: ["arrowright"], + label: "→", + description: "Move the window forward", + group: "Time", + }, + { + id: "resetZoom", + keys: ["0"], + label: "0", + description: "Reset zoom", + group: "Time", + }, + { + id: "previousEvent", + keys: [","], + label: ",", + description: "Cursor A to the previous order or marker", + group: "Time", + }, + { + id: "nextEvent", + keys: ["."], + label: ".", + description: "Cursor A to the next order or marker", + group: "Time", + }, + { + id: "linkTime", + keys: ["l"], + label: "L", + description: "Link or unlink the time axis of the plots", + group: "Time", + }, + { + id: "newPlot", + keys: ["n"], + label: "N", + description: "New plot", + group: "Plots", + }, + { + id: "search", + keys: ["/"], + label: "/", + description: "Search measurements and add one to the active plot", + group: "Plots", + }, + { + id: "newFormula", + keys: ["m"], + label: "M", + description: "New formula", + group: "Plots", + }, + { + id: "expand", + keys: ["f"], + label: "F", + description: "Expand or restore the plot under the pointer", + group: "Plots", + }, + { + id: "fitY", + keys: ["y"], + label: "Y", + description: "Fit the Y axis of the plot under the pointer to its window", + group: "Plots", + }, + { + id: "statistics", + keys: ["s"], + label: "S", + description: "Window statistics", + group: "Panels", + }, + { + id: "cursors", + keys: ["c"], + label: "C", + description: "Measuring cursors", + group: "Panels", + }, + { + id: "orders", + keys: ["o"], + label: "O", + description: "Order markers", + group: "Panels", + }, + { + id: "undo", + // Matched by shortcutForKey with Ctrl/Cmd held, never on their own. + keys: [], + label: "Ctrl+Z", + description: "Undo the last change to the dashboard", + group: "General", + }, + { + id: "redo", + keys: [], + label: "Ctrl+Shift+Z", + description: "Redo", + group: "General", + }, + { + id: "escape", + keys: ["escape"], + label: "Esc", + description: "Restore the expanded plot, or clear the cursors", + group: "General", + }, + { + id: "help", + keys: ["?"], + label: "?", + description: "Show this list", + group: "General", + }, +]; + +/** Mouse gestures, listed in the same dialog for reference only. */ +export const NORMAL_GESTURES: { label: string; description: string }[] = [ + { label: "Scroll", description: "Zoom in time" }, + { label: "Drag · Shift+scroll", description: "Pan in time" }, + { label: "Ctrl+drag", description: "Zoom to the selected stretch" }, + { + label: "Click · Shift+click", + description: "Place cursor A · B (with cursors on)", + }, + { label: "Double click", description: "Add a marker" }, + { + label: "Double-click a measurement", + description: "Add it to the active plot", + }, +]; + +export function shortcutForKey(event: KeyboardEvent): ShortcutId | null { + if (event.altKey) return null; + // Ctrl on Linux and Windows, Cmd on macOS. + if (event.ctrlKey || event.metaKey) { + const key = event.key.toLowerCase(); + if (key === "z") return event.shiftKey ? "redo" : "undo"; + if (key === "y" && !event.shiftKey) return "redo"; + return null; + } + const key = event.key.toLowerCase(); + return ( + NORMAL_SHORTCUTS.find((shortcut) => shortcut.keys.includes(key))?.id ?? null + ); +} diff --git a/frontend/logging-view/src/lib/normal/statistics.ts b/frontend/logging-view/src/lib/normal/statistics.ts new file mode 100644 index 000000000..e6f057ad2 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/statistics.ts @@ -0,0 +1,48 @@ +import type { SeriesData } from "../../types/plotStudio"; +import { lowerBound, upperBound } from "../plotStudio/range"; + +/** Summarize recorded samples within an inclusive window, without interpolation. */ +export function summarizeWindow( + data: SeriesData, + startMs: number, + endMs: number, +) { + const first = lowerBound(data.time, startMs); + const end = upperBound(data.time, endMs); + let count = 0; + let min = Infinity; + let max = -Infinity; + let mean = 0; + let m2 = 0; + let firstValue = 0; + let lastValue = 0; + let changes = 0; + + for (let i = first; i < end; i++) { + const value = data.value[i]; + if (!Number.isFinite(value)) continue; + if (count === 0) firstValue = value; + else if (value !== lastValue) changes++; + lastValue = value; + count++; + min = Math.min(min, value); + max = Math.max(max, value); + // Welford's method avoids cancellation for signals with large offsets. + const delta = value - mean; + mean += delta / count; + m2 += delta * (value - mean); + } + + return count === 0 + ? null + : { + count, + min, + max, + mean, + std: Math.sqrt(Math.max(0, m2 / count)), + first: firstValue, + last: lastValue, + changes, + }; +} diff --git a/frontend/logging-view/src/lib/normal/tableExport.ts b/frontend/logging-view/src/lib/normal/tableExport.ts new file mode 100644 index 000000000..ec6c7ef33 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/tableExport.ts @@ -0,0 +1,75 @@ +import { lowerBound, upperBound } from "../plotStudio/range"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type TimeDomain, + type TimeRange, + type TimeUnit, +} from "../../types/normal"; + +// Tables leave the app as delimited text: comma-separated for files, tab- +// separated for the clipboard, which spreadsheets split into cells on paste. + +export type Cell = string | number | null | undefined; + +const cellText = (cell: Cell) => + cell === null || cell === undefined + ? "" + : typeof cell === "number" + ? Number.isFinite(cell) + ? String(cell) + : "" + : cell; + +export function toDelimited(rows: Cell[][], separator: "," | "\t"): string { + return rows + .map((row) => + row + .map((cell) => { + const text = cellText(cell); + return /["\n\r]/.test(text) || text.includes(separator) + ? `"${text.replace(/"/g, '""')}"` + : text; + }) + .join(separator), + ) + .join("\n"); +} + +export const toCsv = (rows: Cell[][]) => `${toDelimited(rows, ",")}\n`; +export const toTsv = (rows: Cell[][]) => toDelimited(rows, "\t"); + +/** + * The recorded samples of each signal inside a window, one row per sample. + * Long format on purpose: signals logged at different instants share no time + * column, and aligning them would mean inventing values. + */ +export function visibleDataRows( + signals: ComparisonSignal[], + domain: TimeDomain, + range: TimeRange, + timeUnit: TimeUnit, +): Cell[][] { + const duration = domain.endMs - domain.startMs; + const from = domain.startMs + (duration * range.start) / 100; + const to = domain.startMs + (duration * range.end) / 100; + const divisor = TIME_DIVISORS[timeUnit]; + const rows: Cell[][] = [ + ["Board", "Signal", "Unit", `Time (${timeUnit})`, "Value"], + ]; + for (const signal of signals) { + const { time, value } = signal.data; + const unit = signal.discrete ? "State" : (signal.units ?? ""); + for (let i = lowerBound(time, from); i < upperBound(time, to); i++) { + const raw = value[i]; + rows.push([ + signal.board, + signal.name, + unit, + (time[i] - domain.startMs) / divisor, + signal.discrete ? (signal.enumLabels?.[Math.round(raw)] ?? raw) : raw, + ]); + } + } + return rows; +} diff --git a/frontend/logging-view/src/lib/normal/units.ts b/frontend/logging-view/src/lib/normal/units.ts new file mode 100644 index 000000000..3e0ef74d5 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/units.ts @@ -0,0 +1,5 @@ +import type { ComparisonSignal } from "../../types/normal"; + +export function axisKey(signal: ComparisonSignal): string { + return signal.discrete ? `state:${signal.id}` : signal.units || "Value"; +} diff --git a/frontend/logging-view/src/lib/orders.ts b/frontend/logging-view/src/lib/orders.ts new file mode 100644 index 000000000..fbea42cce --- /dev/null +++ b/frontend/logging-view/src/lib/orders.ts @@ -0,0 +1,86 @@ +import type { AdjArchive, AdjPacket, LoggedOrder } from "../types/session"; + +const TIME_UNIT_TO_MS: Record = { ns: 1e-6, us: 1e-3, ms: 1, s: 1e3 }; + +function escapePlotlyText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +// Order parameters are JSON and can contain commas, so a plain split(",") is +// not sufficient for the backend's CSV rows. +function splitCsvRow(row: string): string[] { + const fields: string[] = []; + let field = ""; + let quoted = false; + for (let i = 0; i < row.length; i++) { + const char = row[i]; + if (char === '"') { + if (quoted && row[i + 1] === '"') { field += char; i++; } + else quoted = !quoted; + } else if (char === "," && !quoted) { + fields.push(field); + field = ""; + } else field += char; + } + fields.push(field); + return fields; +} + +function orderName(adjData: AdjArchive | null, board: string, id: number): string { + const group = adjData?.boards[board]; + for (const key of ["orders", "orders_old"]) { + const orders = group?.[key]; + if (Array.isArray(orders)) { + const order = (orders as AdjPacket[]).find((packet) => packet.id === id); + if (order?.name) return order.name; + } + } + return `Order ${id}`; +} + +/** Parses the backend's order/order.csv file. Invalid rows are skipped so an + * otherwise useful session remains viewable if one record is malformed. */ +export function parseOrdersCsv(text: string, timeUnit: string, adjData: AdjArchive | null): LoggedOrder[] { + const multiplier = TIME_UNIT_TO_MS[timeUnit] ?? 1; + const records: Array & { rawTime: number }> = []; + + for (const row of text.split(/\r?\n/)) { + if (!row.trim()) continue; + const [timestamp, from, to, orderId, rawParameters] = splitCsvRow(row); + const rawTime = Number(timestamp); + const numericOrderId = Number(orderId); + if (!Number.isFinite(rawTime) || !Number.isInteger(numericOrderId)) continue; + let parameters: Record | null = null; + try { + const parsed: unknown = JSON.parse(rawParameters); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) parameters = parsed as Record; + } catch { /* Older logs may have a non-JSON map representation. */ } + records.push({ rawTime, from, to, orderId: numericOrderId, parameters, name: orderName(adjData, to, numericOrderId) }); + } + + const start = records[0]?.rawTime ?? 0; + return records.map(({ rawTime, ...record }, index) => ({ + ...record, + id: `${rawTime}-${record.to}-${record.orderId}-${index}`, + absoluteTime: rawTime * multiplier, + time: (rawTime - start) * multiplier, + })); +} + +export function formatOrderParameters(parameters: Record | null): string { + if (!parameters || Object.keys(parameters).length === 0) return "No parameters"; + return Object.entries(parameters) + .map(([key, value]) => `${escapePlotlyText(key)}: ${escapePlotlyText(typeof value === "string" ? value : JSON.stringify(value))}`) + .join(" · "); +} + +export function describeOrderParameters(parameters: Record | null): string { + if (!parameters || Object.keys(parameters).length === 0) return "No parameters"; + return Object.entries(parameters) + .map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`) + .join(" · "); +} + +export function formatOrderAnnotation(name: string, parameters: Record | null): string { + return `${escapePlotlyText(name)}
${formatOrderParameters(parameters)}`; +} diff --git a/frontend/logging-view/src/lib/plotStudio/csv.ts b/frontend/logging-view/src/lib/plotStudio/csv.ts index 973b4a23e..073777ef1 100644 --- a/frontend/logging-view/src/lib/plotStudio/csv.ts +++ b/frontend/logging-view/src/lib/plotStudio/csv.ts @@ -35,12 +35,13 @@ function encodeTextValue(raw: string, enumValues: string[] | undefined, seen: Ma /** * Parse a 4-column CSV produced by the backend logger. * Expected column order: timestamp, board, backend, value. - * Time is normalized to start at 0 and converted to ms using `timeUnit`. + * Time is converted to ms using `timeUnit`; by default each signal starts at + * zero, while comparison views can preserve the original session timestamps. * `enumValues` — the ADJ measurement's state names, when known — lets an * enum/bool signal logged as text ("Idle", "true") be recovered as the * matching numeric code instead of being silently dropped by parseFloat. */ -export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): SeriesData { +export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[], normalizeTime = true): SeriesData { const toMs = TIME_UNIT_TO_MS[timeUnit] ?? 1; const lines = text.trim().split("\n"); const time = new Float64Array(lines.length); @@ -65,7 +66,7 @@ export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): const t = time.subarray(0, count); const val = value.subarray(0, count); - if (count > 0) { + if (normalizeTime && count > 0) { const startTime = t[0]; for (let i = 0; i < count; i++) t[i] -= startTime; } @@ -78,7 +79,7 @@ export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): * hundreds of thousands of rows, and reading + parsing that synchronously * (as parseCSV does) would freeze the tab for the whole duration. */ -export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumValues?: string[]): Promise { +export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumValues?: string[], normalizeTime = true): Promise { // Read the text on the main thread and hand the worker a plain string — // directory-drop sessions store synthetic DroppedFile objects (a closure // over the real File, not the File itself), and a function property like @@ -93,6 +94,6 @@ export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumV resolve({ time: new Float64Array(timeBuffer, 0, count), value: new Float64Array(valueBuffer, 0, count) }); }; worker.onerror = (err) => { worker.terminate(); reject(err); }; - worker.postMessage({ text, timeUnit, enumValues }); + worker.postMessage({ text, timeUnit, enumValues, normalizeTime }); }); } diff --git a/frontend/logging-view/src/lib/plotStudio/csv.worker.ts b/frontend/logging-view/src/lib/plotStudio/csv.worker.ts index e162d3f16..5bc22b7d1 100644 --- a/frontend/logging-view/src/lib/plotStudio/csv.worker.ts +++ b/frontend/logging-view/src/lib/plotStudio/csv.worker.ts @@ -3,9 +3,9 @@ // in ./csv.ts — do not import this file directly elsewhere. import { parseCSV } from "./csv"; -self.onmessage = (e: MessageEvent<{ text: string; timeUnit: string; enumValues?: string[] }>) => { - const { text, timeUnit, enumValues } = e.data; - const { time, value } = parseCSV(text, timeUnit, enumValues); +self.onmessage = (e: MessageEvent<{ text: string; timeUnit: string; enumValues?: string[]; normalizeTime?: boolean }>) => { + const { text, timeUnit, enumValues, normalizeTime = true } = e.data; + const { time, value } = parseCSV(text, timeUnit, enumValues, normalizeTime); const timeBuffer = time.buffer; const valueBuffer = value.buffer; (self as unknown as Worker).postMessage( diff --git a/frontend/logging-view/src/lib/plotStudio/palette.ts b/frontend/logging-view/src/lib/plotStudio/palette.ts index 0c788cba6..fac2f2e8f 100644 --- a/frontend/logging-view/src/lib/plotStudio/palette.ts +++ b/frontend/logging-view/src/lib/plotStudio/palette.ts @@ -1,8 +1,9 @@ -// D3 Category10 — Plotly's default colorway, pinned explicitly so the UI -// (signal chips, stats headers) can show the exact color of each trace. +// D3-inspired palette, pinned explicitly so the UI (signal chips, stats +// headers) can show the exact color of each trace. Orange is reserved for +// order-event markers, so it is deliberately not assigned to the second +// signal (the usual secondary/right-axis series). export const TRACE_COLORS = [ "#1f77b4", // blue - "#ff7f0e", // orange "#2ca02c", // green "#d62728", // red "#9467bd", // purple @@ -11,6 +12,7 @@ export const TRACE_COLORS = [ "#7f7f7f", // gray "#bcbd22", // olive "#17becf", // cyan + "#0f766e", // teal ] as const; export const traceColor = (index: number): string => diff --git a/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts index bf9090448..874907354 100644 --- a/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts +++ b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts @@ -32,11 +32,11 @@ export function getPlotlyTheme(isDarkMode: boolean): PlotlyThemeColors { }; } return { - paperBg: "#181818", plotBg: "#181818", - fontColor: "#f9fafb", gridColor: "#404040", neutralLineColor: "#f9fafb", - hoverBg: "rgba(24,24,24,0.95)", hoverBorder: "#404040", hoverFontColor: "#f9fafb", - legendBg: "rgba(24,24,24,0.9)", legendBorder: "#404040", legendFontColor: "#f9fafb", - modebar: { bgcolor: "rgba(24,24,24,0)", color: "#b5b5b5", activecolor: "#ff7f24" }, + paperBg: "#242424", plotBg: "#2b2b2b", + fontColor: "#f5f5f5", gridColor: "#484848", neutralLineColor: "#d4d4d4", + hoverBg: "rgba(43,43,43,0.97)", hoverBorder: "#5a5a5a", hoverFontColor: "#f5f5f5", + legendBg: "rgba(36,36,36,0.96)", legendBorder: "#5a5a5a", legendFontColor: "#f5f5f5", + modebar: { bgcolor: "rgba(36,36,36,0)", color: "#c4c4c4", activecolor: "#ff7f24" }, }; } diff --git a/frontend/logging-view/src/lib/plotStudio/timeline.ts b/frontend/logging-view/src/lib/plotStudio/timeline.ts index 429c46cff..be97ca507 100644 --- a/frontend/logging-view/src/lib/plotStudio/timeline.ts +++ b/frontend/logging-view/src/lib/plotStudio/timeline.ts @@ -33,7 +33,17 @@ export function isDiscreteSeries(data: SeriesData, maxUnique = 12): boolean { // Single pass over (time, value): emits one segment per contiguous run of // the same value, mirroring the single-pass style used in stats.ts. -export function computeStateSegments(data: SeriesData): StateSegment[] { +// +// `endMs` is when the recording ends. The final run holds until then, which +// matters whenever the last state change is the last sample of this series: +// closing that run at its own last sample would give it zero duration and +// draw it as a 1px sliver, reading as "that state never happened". Callers +// pass the end of the whole plot/recording; omitting it keeps the old +// self-closing behaviour. +export function computeStateSegments( + data: SeriesData, + endMs?: number, +): StateSegment[] { const { time, value } = data; const n = time.length; if (n === 0) return []; @@ -48,14 +58,26 @@ export function computeStateSegments(data: SeriesData): StateSegment[] { current = value[i]; } } - segments.push({ start, end: time[n - 1], value: current }); + segments.push({ + start, + end: Math.max(time[n - 1], endMs ?? time[n - 1]), + value: current, + }); return segments; } // Human-readable label for a state code: enum name if available, True/False // for boolean-looking 0/1, otherwise a generic "State N" fallback. -export function stateLabel(value: number, enumLabels: string[] | undefined): string { - if (enumLabels && Number.isInteger(value) && value >= 0 && value < enumLabels.length) { +export function stateLabel( + value: number, + enumLabels: string[] | undefined, +): string { + if ( + enumLabels && + Number.isInteger(value) && + value >= 0 && + value < enumLabels.length + ) { return enumLabels[value]; } if (value === 0 || value === 1) return value === 1 ? "True" : "False"; diff --git a/frontend/logging-view/src/lib/session/electronFolder.ts b/frontend/logging-view/src/lib/session/electronFolder.ts new file mode 100644 index 000000000..409ee4f30 --- /dev/null +++ b/frontend/logging-view/src/lib/session/electronFolder.ts @@ -0,0 +1,32 @@ +// Reopening a session from a stored path, for the "recent sessions" list. +// +// openSession only needs `{ name, webkitRelativePath, text() }` (DroppedFile) +// and reads each CSV lazily, the first time a signal is drawn — so a file-like +// object whose text() is an IPC call behaves exactly like a browser File, and +// nothing downstream has to know the difference. +import type { DroppedFile } from "../../types/session"; + +/** True when the Electron bridge can list and read a folder by path. */ +export function canOpenSessionPaths(): boolean { + return !!( + window.electronAPI?.listSessionFiles && window.electronAPI?.readSessionFile + ); +} + +export async function sessionFilesFromPath( + dir: string, +): Promise { + const api = window.electronAPI; + if (!api?.listSessionFiles || !api.readSessionFile) + throw new Error("Opening a session by path needs the desktop app"); + const { folderName, entries } = await api.listSessionFiles(dir); + if (entries.length === 0) + throw new Error("No session files found in that folder"); + return entries.map((relative) => ({ + name: relative.split("/").pop() ?? relative, + // openSession derives the session name from the first segment, and + // sessionFiles is keyed by this exact string. + webkitRelativePath: `${folderName}/${relative}`, + text: () => api.readSessionFile!(dir, relative), + })); +} diff --git a/frontend/logging-view/src/pages/NormalPage.tsx b/frontend/logging-view/src/pages/NormalPage.tsx index c0032f447..9b7c5a721 100644 --- a/frontend/logging-view/src/pages/NormalPage.tsx +++ b/frontend/logging-view/src/pages/NormalPage.tsx @@ -1,30 +1,330 @@ -// Normal mode: full-featured log analysis view. -// Placeholder — UI to be implemented. -import swLogoBlack from "@hyperloopupv/frontend-kit/outreach/main/software_black.png?inline"; -import swLogoWhite from "@hyperloopupv/frontend-kit/outreach/main/software_white.png?inline"; +// Normal mode is the exploration workspace: sidebar selections become linked +// ECharts views. Simple mode keeps its separate plot studio and PDF workflow. +import { Activity } from "@hyperloopupv/frontend-kit/icons"; +import { Tabs, TabsContent } from "@hyperloopupv/frontend-kit/components"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import AnalysisWorkspace from "../components/normal/AnalysisWorkspace"; +import DashboardTabs from "../components/normal/DashboardTabs"; +import DashboardControls from "../components/normal/DashboardControls"; +import FormulaDialog from "../components/normal/FormulaDialog"; +import SignalSearch from "../components/normal/SignalSearch"; +import { + evaluateFormula, + parseFormula, + type FormulaInput, +} from "../lib/normal/formula"; +import { parseCSVInWorker } from "../lib/plotStudio/csv"; +import { traceColor } from "../lib/plotStudio/palette"; +import { + getEnumLabels, + getSignalName, + getSignalUnits, + isDiscreteMeasurement, +} from "../lib/plotStudio/units"; +import RecentSessions from "../components/sidebar/RecentSessions"; import { useStore } from "../store/store"; +import { + FORMULA_ID_PREFIX, + type ComparisonSignal, + type FormulaSignal, +} from "../types/normal"; +import type { SeriesData } from "../types/plotStudio"; -const NormalPage = () => { - const isDarkMode = useStore((s) => s.isDarkMode); - +function EmptyView({ + title, + description, + children, +}: { + title: string; + description: string; + children?: ReactNode; +}) { return ( -
-
-

Normal mode

-

- Still under development — use Simple mode for now. +

+
+
+ +
+

{title}

+

+ {description}

-
-
- ;) - sw logo + {children}
); -}; +} + +// One computed formula, kept while its definition and source arrays are the +// same objects, so zooming or editing another plot never re-evaluates it. +interface FormulaResult { + formula: FormulaSignal; + inputs: SeriesData[]; + data?: SeriesData; + error?: string; +} + +function SessionComparison() { + const folderName = useStore((s) => s.folderName)!; + const sessionFiles = useStore((s) => s.sessionFiles); + const settings = useStore((s) => s.settings); + const adjData = useStore((s) => s.adjData); + const dashboard = useStore( + (s) => + s.normalDashboards.find((item) => item.id === s.activeNormalDashboardId)!, + ); + const selectDashboard = useStore((s) => s.selectNormalDashboard); + const isDarkMode = useStore((s) => s.isDarkMode); + const [compactControls, setCompactControls] = useState(true); + const [loadedCache, setLoadedCache] = useState<{ + files: typeof sessionFiles; + data: Map; + } | null>(null); + + const assignedIds = dashboard.settings.layoutState.ids; + const formulas = dashboard.settings.formulas; + // CSVs to read: the plotted measurements plus whatever the plotted + // formulas are computed from. + const csvIds = useMemo(() => { + const ids = new Set(); + for (const id of assignedIds) { + if (!id.startsWith(FORMULA_ID_PREFIX)) ids.add(id); + else + for (const source of Object.values( + formulas.find((formula) => formula.id === id)?.variables ?? {}, + )) + ids.add(source); + } + return [...ids]; + }, [assignedIds, formulas]); + // Cache belongs to this exact session. Re-selecting a signal keeps its + // parsed data; opening another session creates an empty cache. + const cache = useMemo( + () => ({ + files: sessionFiles, + data: new Map(), + colors: new Map(), + errors: new Map(), + formulas: new Map(), + }), + [sessionFiles], + ); + + useEffect(() => { + let cancelled = false; + const loadVisible = async () => { + for (const id of csvIds) { + if (cancelled) return; + if (cache.data.has(id) || cache.errors.has(id)) continue; + try { + const slash = id.indexOf("/"); + const file = cache.files.get( + `${folderName}/data/${id.slice(0, slash)}/${id.slice(slash + 1)}.csv`, + ); + if (!file) throw new Error("CSV file not found in this session"); + const data = await parseCSVInWorker( + file, + settings?.time_unit ?? "ms", + getEnumLabels(adjData, id), + false, + ); + if (cancelled) return; + if (data.time.length === 0) + throw new Error("CSV contains no plottable samples"); + cache.data.set(id, data); + } catch (error) { + if (cancelled) return; + cache.errors.set( + id, + error instanceof Error + ? error.message + : "Could not read this signal", + ); + } finally { + if (!cancelled) { + setLoadedCache({ files: cache.files, data: new Map(cache.data) }); + } + } + } + }; + void loadVisible(); + return () => { + cancelled = true; + }; + }, [csvIds, cache, folderName, settings?.time_unit, adjData]); + + const { signals, formulaErrors } = useMemo(() => { + const loaded = + loadedCache?.files === cache.files ? loadedCache.data : undefined; + // Colours are handed out in the order signals are first plotted and then + // kept for the session. + const colorOf = (id: string) => { + if (!cache.colors.has(id)) + cache.colors.set(id, traceColor(cache.colors.size)); + return cache.colors.get(id)!; + }; + const formulaErrors: string[] = []; + const compute = (formula: FormulaSignal): SeriesData | undefined => { + const parsed = parseFormula(formula.expression); + if (!parsed.ok) { + formulaErrors.push(`${formula.name}: ${parsed.error}`); + return undefined; + } + const inputs: Record = {}; + for (const name of parsed.variables) { + const source = formula.variables[name]; + if (!source) { + formulaErrors.push( + `${formula.name}: "${name}" is not bound to a measurement`, + ); + return undefined; + } + if (cache.errors.has(source)) { + formulaErrors.push(`${formula.name}: ${source} could not be read`); + return undefined; + } + const data = loaded?.get(source); + if (!data) return undefined; // still loading + inputs[name] = { + data, + discrete: isDiscreteMeasurement(adjData, source), + }; + } + const arrays = parsed.variables.map((name) => inputs[name].data); + const cached = cache.formulas.get(formula.id); + if ( + !cached || + cached.formula !== formula || + cached.inputs.length !== arrays.length || + cached.inputs.some((data, index) => data !== arrays[index]) + ) { + const result: FormulaResult = { formula, inputs: arrays }; + try { + result.data = evaluateFormula(parsed.ast, inputs); + } catch (error) { + result.error = + error instanceof Error ? error.message : "Could not be computed"; + } + cache.formulas.set(formula.id, result); + } + const result = cache.formulas.get(formula.id)!; + if (result.error) formulaErrors.push(`${formula.name}: ${result.error}`); + return result.data; + }; + + const signals: ComparisonSignal[] = assignedIds.flatMap( + (id): ComparisonSignal[] => { + if (id.startsWith(FORMULA_ID_PREFIX)) { + const formula = formulas.find((item) => item.id === id); + const data = formula && compute(formula); + if (!formula || !data) return []; + return [ + { + id, + name: formula.name, + board: "Math", + units: formula.units, + discrete: false, + color: colorOf(id), + data, + formula, + }, + ]; + } + const data = loaded?.get(id); + if (!data) return []; + const measId = id.slice(id.indexOf("/") + 1); + return [ + { + id, + name: getSignalName(adjData, id) ?? measId, + board: id.slice(0, id.indexOf("/")), + units: getSignalUnits(adjData, id), + enumLabels: getEnumLabels(adjData, id), + discrete: isDiscreteMeasurement(adjData, id), + color: colorOf(id), + data, + }, + ]; + }, + ); + return { signals, formulaErrors }; + }, [assignedIds, formulas, cache, adjData, loadedCache]); + const errors = [ + ...assignedIds.flatMap((id) => + cache.errors.has(id) ? [`${id}: ${cache.errors.get(id)}`] : [], + ), + ...formulaErrors, + ]; + + return ( + + setCompactControls((current) => !current)} + controls={ + compactControls ? ( + + ) : undefined + } + /> + {!compactControls && ( + + )} + + {errors.length > 0 && ( +
+ {errors.map((message) => ( +

+ {message} +

+ ))} +
+ )} + +
+ + +
+ ); +} -export default NormalPage; +export default function NormalPage() { + const folderName = useStore((s) => s.folderName); + if (!folderName) { + return ( + + + + ); + } + return ; +} diff --git a/frontend/logging-view/src/store/slices/normalDashboardsSlice.ts b/frontend/logging-view/src/store/slices/normalDashboardsSlice.ts new file mode 100644 index 000000000..eed434ce8 --- /dev/null +++ b/frontend/logging-view/src/store/slices/normalDashboardsSlice.ts @@ -0,0 +1,626 @@ +import type { StateCreator } from "zustand"; +import { + appendPane, + cloneLayout, + createLayout, + createPane, + getPanes, + nextPaneTitle, + signalIdsInLayout, + updatePane, +} from "../../lib/normal/layout"; +import type { + DashboardSettings, + DashboardTabSettings, + NormalDashboard, +} from "../../types/normal"; +import { DEFAULT_STATISTICS_COLUMNS } from "../../types/normal"; +import { isDiscreteMeasurement } from "../../lib/plotStudio/units"; +import { MIXED_ENUM_MESSAGE } from "../../lib/normal/enums"; +import { + restoreContent, + type DashboardHistory, +} from "../../lib/normal/history"; +import { + loadSessionDashboards, + parseDashboardFile, + restoreDashboard, +} from "../../lib/normal/dashboardStorage"; +import type { Store } from "../store"; + +function createDashboard(existing: NormalDashboard[] = []): NormalDashboard { + const names = new Set(existing.map((dashboard) => dashboard.name)); + let name = "Dashboard"; + for (let number = 2; names.has(name); number++) name = `Dashboard ${number}`; + return { + id: crypto.randomUUID(), + name, + color: null, + locked: false, + selectedSeries: {}, + settings: { + layoutState: { ids: [], tree: createLayout([], "grid"), preset: "grid" }, + showStatistics: false, + statisticsColumns: DEFAULT_STATISTICS_COLUMNS, + showOrders: true, + enumAsBackground: true, + focusedId: null, + units: {}, + timeUnit: "s", + range: { start: 0, end: 100 }, + linkTimeAxis: true, + panelSizes: {}, + formulas: [], + showCursors: false, + cursors: { a: null, b: null }, + }, + }; +} + +// "Thermal" -> "Thermal (copy)" -> "Thermal (copy 2)", skipping taken names. +function copyName(name: string, taken: Set): string { + const base = name.slice(0, 80 - " (copy)".length); + let candidate = `${base} (copy)`; + for (let number = 2; taken.has(candidate); number++) + candidate = `${base} (copy ${number})`; + return candidate.slice(0, 80); +} + +// A duplicated layout must not share node ids with its source: `focusedId` is +// a pane id, and `panelSizes` is keyed by split-node id with inner keys of the +// form `-first` / `-second` (see AnalysisWorkspace), so both +// have to be rewritten through the clone's id map. +function duplicateSettings(settings: DashboardSettings): DashboardSettings { + const { tree, idMap } = cloneLayout(settings.layoutState.tree); + const panelSizes = Object.fromEntries( + Object.entries(settings.panelSizes).flatMap(([nodeId, sizes]) => { + const next = idMap.get(nodeId); + if (!next) return []; + return [ + [ + next, + Object.fromEntries( + Object.entries(sizes).map(([panel, size]) => [ + panel.startsWith(nodeId) + ? next + panel.slice(nodeId.length) + : panel, + size, + ]), + ), + ], + ]; + }), + ); + return { + ...settings, + layoutState: { + ids: signalIdsInLayout(tree), + tree, + preset: settings.layoutState.preset, + }, + focusedId: settings.focusedId + ? (idMap.get(settings.focusedId) ?? null) + : null, + units: { ...settings.units }, + range: { ...settings.range }, + panelSizes, + formulas: settings.formulas.map((formula) => ({ + ...formula, + variables: { ...formula.variables }, + })), + cursors: { ...settings.cursors }, + }; +} + +export function initialNormalDashboards() { + const dashboard = createDashboard(); + return { + normalDashboards: [dashboard], + activeNormalDashboardId: dashboard.id, + }; +} + +const seriesLookup = (availableSeries: Record) => { + const ids = new Set( + Object.entries(availableSeries).flatMap(([board, measurements]) => + measurements.map((measurement) => `${board}/${measurement}`), + ), + ); + return (id: string) => ids.has(id); +}; + +/** + * The dashboards to start a session with: the ones saved the last time this + * folder was open, or a blank one. + */ +export function sessionNormalDashboards( + folderName: string, + availableSeries: Record, +) { + const saved = loadSessionDashboards(folderName); + if (!saved) return initialNormalDashboards(); + const hasSeries = seriesLookup(availableSeries); + const seen = new Set(); + const dashboards: NormalDashboard[] = []; + for (const raw of saved.dashboards) { + const restored = restoreDashboard( + raw, + createDashboard(dashboards), + hasSeries, + ); + if (!restored || seen.has(restored.dashboard.id)) continue; + seen.add(restored.dashboard.id); + dashboards.push(restored.dashboard); + } + if (dashboards.length === 0) return initialNormalDashboards(); + return { + normalDashboards: dashboards, + activeNormalDashboardId: seen.has(saved.activeId) + ? saved.activeId + : dashboards[0].id, + }; +} + +/** + * The plot a quick add (double-click in the sidebar, the search box) lands + * in: the last plot clicked, else the expanded one, else the first empty one, + * else the first one. + */ +export function targetPaneId( + dashboard: NormalDashboard, + activePaneId: string | null, +): string | null { + const panes = getPanes(dashboard.settings.layoutState.tree); + const exists = (id: string | null) => + !!id && panes.some((pane) => pane.id === id); + if (exists(activePaneId)) return activePaneId; + if (exists(dashboard.settings.focusedId)) return dashboard.settings.focusedId; + return ( + (panes.find((pane) => pane.signalIds.length === 0) ?? panes[0])?.id ?? null + ); +} + +/** Set while undo/redo writes, so the history subscriber does not record it. */ +export const historyReplay = { active: false }; + +export interface FormulaEditorRequest { + /** Edit this formula; absent creates a new one. */ + formulaId?: string; + /** Plot the new formula should land in by default. */ + paneId?: string; + /** Measurements pre-bound to a, b, c… for a new formula. */ + sourceIds?: string[]; +} + +export interface NormalDashboardsSlice { + normalDashboards: NormalDashboard[]; + activeNormalDashboardId: string; + addNormalDashboard: () => string; + duplicateNormalDashboard: (id: string) => string; + /** Adds the dashboard in an exported file as a new tab; false if invalid. */ + importNormalDashboard: (fileText: string) => boolean; + selectNormalDashboard: (id: string) => void; + closeNormalDashboard: (id: string) => void; + renameNormalDashboard: (id: string, name: string) => void; + configureNormalDashboard: ( + id: string, + settings: DashboardTabSettings, + ) => void; + moveNormalDashboard: (id: string, position: number) => void; + updateNormalDashboardSettings: ( + id: string, + update: (settings: DashboardSettings) => DashboardSettings, + ) => void; + addNormalPlot: (signalIds?: string[]) => string; + assignNormalSignalsToPlot: (plotId: string, signalIds: string[]) => void; + // Transient refusal message (mixing two enums with numeric series) — + // not persisted, cleared by the strip that shows it. + normalPlotWarning: string | null; + setNormalPlotWarning: (message: string | null) => void; + // Transient: which formula the shared dialog is editing. The toolbar and + // the plots live in sibling trees, so the request travels through here. + normalFormulaEditor: FormulaEditorRequest | null; + /** Last plot the user clicked; where quick adds go. Not persisted. */ + normalActivePaneId: string | null; + setNormalActivePaneId: (id: string | null) => void; + /** Adds measurements to the target plot of the active dashboard. */ + addToTargetNormalPlot: (signalIds: string[]) => void; + /** Per dashboard; recorded by the subscriber in store.ts. Not persisted. */ + normalHistory: Record; + undoNormalDashboard: (id: string) => void; + redoNormalDashboard: (id: string) => void; + normalSignalSearchOpen: boolean; + setNormalSignalSearchOpen: (open: boolean) => void; + openNormalFormulaEditor: (request?: FormulaEditorRequest) => void; + closeNormalFormulaEditor: () => void; + toggleNormalSeries: (id: string) => void; + clearNormalSeries: () => void; +} + +export const createNormalDashboardsSlice: StateCreator< + Store, + [], + [], + NormalDashboardsSlice +> = (set, get) => { + // Undo takes from `past` and gives the current state to `future`; redo is + // the same move the other way round. + const step = ( + id: string, + from: keyof DashboardHistory, + to: keyof DashboardHistory, + ) => { + const state = get(); + const dashboard = state.normalDashboards.find((item) => item.id === id); + const history = state.normalHistory[id]; + const snapshot = history?.[from][history[from].length - 1]; + if (!dashboard || dashboard.locked || !history || !snapshot) return; + historyReplay.active = true; + set({ + normalDashboards: state.normalDashboards.map((item) => + item.id === id + ? { ...item, settings: restoreContent(snapshot, item.settings) } + : item, + ), + normalHistory: { + ...state.normalHistory, + [id]: { + ...history, + [from]: history[from].slice(0, -1), + [to]: [...history[to], dashboard.settings], + } as DashboardHistory, + }, + }); + historyReplay.active = false; + }; + return { + ...initialNormalDashboards(), + importNormalDashboard: (fileText) => { + const state = get(); + const defaults = createDashboard(state.normalDashboards); + const file = parseDashboardFile(fileText); + const restored = file + ? restoreDashboard(file, defaults, seriesLookup(state.availableSeries)) + : null; + if (!restored) { + set({ normalPlotWarning: "That file is not an exported dashboard." }); + return false; + } + const taken = new Set(state.normalDashboards.map((item) => item.name)); + let name = restored.dashboard.name; + for (let number = 2; taken.has(name); number++) + name = `${restored.dashboard.name.slice(0, 74)} (${number})`; + const dashboard: NormalDashboard = { + ...restored.dashboard, + // Fresh ids throughout: the same file can be imported more than once. + id: defaults.id, + name, + locked: false, + selectedSeries: {}, + settings: duplicateSettings(restored.dashboard.settings), + }; + set({ + normalDashboards: [...state.normalDashboards, dashboard], + activeNormalDashboardId: dashboard.id, + ...(restored.dropped > 0 + ? { + normalPlotWarning: `${restored.dropped} measurement${restored.dropped === 1 ? "" : "s"} of the imported dashboard ${restored.dropped === 1 ? "is" : "are"} not in this session and ${restored.dropped === 1 ? "was" : "were"} left out.`, + } + : {}), + }); + return true; + }, + addNormalDashboard: () => { + let id = ""; + set((state) => { + const dashboard = createDashboard(state.normalDashboards); + id = dashboard.id; + return { + normalDashboards: [...state.normalDashboards, dashboard], + activeNormalDashboardId: id, + }; + }); + return id; + }, + // Reading the source only, so a locked dashboard can be duplicated; the copy + // keeps `locked` and lands right after its original. + duplicateNormalDashboard: (id) => { + let copyId = ""; + set((state) => { + const index = state.normalDashboards.findIndex( + (dashboard) => dashboard.id === id, + ); + if (index < 0) return state; + const source = state.normalDashboards[index]; + const copy: NormalDashboard = { + ...source, + id: crypto.randomUUID(), + name: copyName( + source.name, + new Set(state.normalDashboards.map((dashboard) => dashboard.name)), + ), + selectedSeries: { ...source.selectedSeries }, + settings: duplicateSettings(source.settings), + }; + copyId = copy.id; + const normalDashboards = [...state.normalDashboards]; + normalDashboards.splice(index + 1, 0, copy); + return { normalDashboards, activeNormalDashboardId: copy.id }; + }); + return copyId; + }, + selectNormalDashboard: (id) => + set((state) => + state.normalDashboards.some((dashboard) => dashboard.id === id) + ? { activeNormalDashboardId: id } + : state, + ), + closeNormalDashboard: (id) => + set((state) => { + const index = state.normalDashboards.findIndex( + (dashboard) => dashboard.id === id, + ); + if (index < 0 || state.normalDashboards[index].locked) return state; + const remaining = state.normalDashboards.filter( + (dashboard) => dashboard.id !== id, + ); + if (remaining.length === 0) { + const dashboard = createDashboard(); + return { + normalDashboards: [dashboard], + activeNormalDashboardId: dashboard.id, + }; + } + return { + normalDashboards: remaining, + activeNormalDashboardId: + state.activeNormalDashboardId === id + ? remaining[Math.min(index, remaining.length - 1)].id + : state.activeNormalDashboardId, + }; + }), + renameNormalDashboard: (id, name) => { + const trimmed = name.trim().slice(0, 80); + if (!trimmed) return; + set((state) => ({ + normalDashboards: state.normalDashboards.map((dashboard) => + dashboard.id === id && !dashboard.locked + ? { ...dashboard, name: trimmed } + : dashboard, + ), + })); + }, + configureNormalDashboard: (id, settings) => + set((state) => { + const index = state.normalDashboards.findIndex( + (dashboard) => dashboard.id === id, + ); + if (index < 0) return state; + const dashboard = state.normalDashboards[index]; + // A locked tab can only be edited when explicitly unlocking it. + if (dashboard.locked && settings.locked) return state; + const name = settings.name.trim().slice(0, 80); + if ( + !name || + (settings.color !== null && !/^#[0-9a-f]{6}$/i.test(settings.color)) + ) + return state; + const position = Number.isFinite(settings.position) + ? Math.max( + 0, + Math.min( + state.normalDashboards.length - 1, + Math.trunc(settings.position), + ), + ) + : index; + const dashboards = [...state.normalDashboards]; + dashboards.splice(index, 1); + dashboards.splice(position, 0, { + ...dashboard, + name, + color: settings.color, + locked: settings.locked, + }); + return { normalDashboards: dashboards }; + }), + moveNormalDashboard: (id, position) => + set((state) => { + const index = state.normalDashboards.findIndex( + (dashboard) => dashboard.id === id, + ); + if ( + index < 0 || + state.normalDashboards[index].locked || + !Number.isFinite(position) + ) + return state; + const target = Math.max( + 0, + Math.min(state.normalDashboards.length - 1, Math.trunc(position)), + ); + if (target === index) return state; + const dashboards = [...state.normalDashboards]; + const [dashboard] = dashboards.splice(index, 1); + dashboards.splice(target, 0, dashboard); + return { normalDashboards: dashboards }; + }), + updateNormalDashboardSettings: (id, update) => + set((state) => { + const dashboard = state.normalDashboards.find((item) => item.id === id); + if (!dashboard || dashboard.locked) return state; + const settings = update(dashboard.settings); + if (settings === dashboard.settings) return state; + const normalizedSettings = { + ...settings, + layoutState: { + ...settings.layoutState, + ids: signalIdsInLayout(settings.layoutState.tree), + }, + }; + return { + normalDashboards: state.normalDashboards.map((item) => + item.id === id ? { ...item, settings: normalizedSettings } : item, + ), + }; + }), + addNormalPlot: (signalIds = []) => { + let plotId = ""; + set((state) => ({ + normalDashboards: state.normalDashboards.map((dashboard) => { + if ( + dashboard.id !== state.activeNormalDashboardId || + dashboard.locked || + getPanes(dashboard.settings.layoutState.tree).length >= 8 + ) + return dashboard; + const current = dashboard.settings.layoutState; + const currentPanes = getPanes(current.tree); + if ( + signalIds.length > 0 && + currentPanes.length === 1 && + currentPanes[0].signalIds.length === 0 + ) { + plotId = currentPanes[0].id; + const tree = updatePane(current.tree, plotId, (plot) => ({ + ...plot, + signalIds: [...new Set(signalIds)], + })); + return { + ...dashboard, + settings: { + ...dashboard.settings, + layoutState: { + ...current, + ids: signalIdsInLayout(tree), + tree, + }, + }, + }; + } + const plot = createPane( + [...new Set(signalIds)], + nextPaneTitle(current.tree), + ); + plotId = plot.id; + const tree = appendPane(current.tree, plot, current.preset); + return { + ...dashboard, + settings: { + ...dashboard.settings, + focusedId: null, + layoutState: { + ids: signalIdsInLayout(tree), + tree, + preset: current.preset === "single" ? "grid" : current.preset, + }, + }, + }; + }), + })); + return plotId; + }, + normalPlotWarning: null, + setNormalPlotWarning: (message) => set({ normalPlotWarning: message }), + normalFormulaEditor: null, + normalActivePaneId: null, + setNormalActivePaneId: (id) => + get().normalActivePaneId === id + ? undefined + : set({ normalActivePaneId: id }), + addToTargetNormalPlot: (signalIds) => { + const state = get(); + const dashboard = state.normalDashboards.find( + (item) => item.id === state.activeNormalDashboardId, + ); + if (!dashboard || dashboard.locked || signalIds.length === 0) return; + const target = targetPaneId(dashboard, state.normalActivePaneId); + if (target) state.assignNormalSignalsToPlot(target, signalIds); + }, + normalHistory: {}, + undoNormalDashboard: (id) => step(id, "past", "future"), + redoNormalDashboard: (id) => step(id, "future", "past"), + normalSignalSearchOpen: false, + setNormalSignalSearchOpen: (open) => set({ normalSignalSearchOpen: open }), + openNormalFormulaEditor: (request = {}) => + set({ normalFormulaEditor: request }), + closeNormalFormulaEditor: () => set({ normalFormulaEditor: null }), + assignNormalSignalsToPlot: (plotId, signalIds) => + set((state) => { + let refused = false; + const discrete = (id: string) => + isDiscreteMeasurement(state.adjData, id); + return { + normalDashboards: state.normalDashboards.map((dashboard) => { + if ( + dashboard.id !== state.activeNormalDashboardId || + dashboard.locked + ) + return dashboard; + const current = dashboard.settings.layoutState; + const tree = updatePane(current.tree, plotId, (plot) => { + // A background band can only show one state at a time, so a plot + // holds one enum next to numeric signals, or enums on their own. + // Whatever is already in the plot stays; only arrivals that would + // break that are turned away, one at a time. + const accepted = [...plot.signalIds]; + for (const id of signalIds) { + if (accepted.includes(id)) continue; + const enums = + accepted.filter(discrete).length + (discrete(id) ? 1 : 0); + if (enums <= 1 || enums === accepted.length + 1) + accepted.push(id); + else refused = true; + } + return { + ...plot, + signalIds: accepted, + hiddenSignalIds: plot.hiddenSignalIds?.filter( + (id) => !signalIds.includes(id), + ), + }; + }); + return tree === current.tree + ? dashboard + : { + ...dashboard, + settings: { + ...dashboard.settings, + layoutState: { + ...current, + ids: signalIdsInLayout(tree), + tree, + }, + }, + }; + }), + ...(refused ? { normalPlotWarning: MIXED_ENUM_MESSAGE } : {}), + }; + }), + toggleNormalSeries: (id) => + set((state) => { + const active = state.normalDashboards.find( + (dashboard) => dashboard.id === state.activeNormalDashboardId, + ); + if (!active || active.locked) return state; + return { + normalDashboards: state.normalDashboards.map((dashboard) => { + if (dashboard.id !== state.activeNormalDashboardId) + return dashboard; + const selectedSeries = { ...dashboard.selectedSeries }; + if (selectedSeries[id]) delete selectedSeries[id]; + else selectedSeries[id] = true; + return { ...dashboard, selectedSeries }; + }), + }; + }), + clearNormalSeries: () => + set((state) => ({ + normalDashboards: state.normalDashboards.map((dashboard) => + dashboard.id === state.activeNormalDashboardId && !dashboard.locked + ? { ...dashboard, selectedSeries: {} } + : dashboard, + ), + })), + }; +}; diff --git a/frontend/logging-view/src/store/slices/plotStudioSlice.ts b/frontend/logging-view/src/store/slices/plotStudioSlice.ts index b94244a4d..47b077994 100644 --- a/frontend/logging-view/src/store/slices/plotStudioSlice.ts +++ b/frontend/logging-view/src/store/slices/plotStudioSlice.ts @@ -42,6 +42,8 @@ export interface PlotStudioSlice { toggleStudioPlotFFT: (plotId: string) => void; toggleStudioPlotLocked: (plotId: string) => void; updateStudioSignalColor: (plotId: string, signalId: string, color: string) => void; + toggleStudioPlotOrder: (plotId: string, orderId: string) => void; + setStudioPlotOrdersVisible: (plotId: string, orderIds: string[], visible: boolean) => void; setStudioFFTSampleRate: (rate: number | null) => void; setSignalLoadWarning: (message: string | null) => void; setPlotAddedToast: (name: string | null) => void; @@ -250,6 +252,32 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ return { studioPlots: next }; }), + toggleStudioPlotOrder: (plotId, orderId) => + set((s) => { + const plot = s.studioPlots.get(plotId); + if (!plot) return {}; + const hiddenOrderIds = { ...plot.hiddenOrderIds }; + if (hiddenOrderIds[orderId]) delete hiddenOrderIds[orderId]; + else hiddenOrderIds[orderId] = true; + const next = new Map(s.studioPlots); + next.set(plotId, { ...plot, hiddenOrderIds }); + return { studioPlots: next }; + }), + + setStudioPlotOrdersVisible: (plotId, orderIds, visible) => + set((s) => { + const plot = s.studioPlots.get(plotId); + if (!plot) return {}; + const hiddenOrderIds = { ...plot.hiddenOrderIds }; + for (const orderId of orderIds) { + if (visible) delete hiddenOrderIds[orderId]; + else hiddenOrderIds[orderId] = true; + } + const next = new Map(s.studioPlots); + next.set(plotId, { ...plot, hiddenOrderIds }); + return { studioPlots: next }; + }), + setStudioFFTSampleRate: (rate) => set({ fftSampleRateOverride: rate }), setSignalLoadWarning: (message) => set({ signalLoadWarning: message }), diff --git a/frontend/logging-view/src/store/slices/recentSessionsSlice.ts b/frontend/logging-view/src/store/slices/recentSessionsSlice.ts new file mode 100644 index 000000000..8ab04869b --- /dev/null +++ b/frontend/logging-view/src/store/slices/recentSessionsSlice.ts @@ -0,0 +1,43 @@ +// Last opened session folders, newest first. Persisted (plain data only) so +// the empty state can offer them on the next launch. Only sessions opened +// through the Electron folder dialog have a path, so only those are recorded. +import type { StateCreator } from "zustand"; +import type { Store } from "../store"; + +const MAX_RECENT_SESSIONS = 10; + +export interface RecentSession { + path: string; + folderName: string; + openedAt: number; + boards?: number; + series?: number; +} + +export interface RecentSessionsSlice { + recentSessions: RecentSession[]; + rememberRecentSession: (entry: RecentSession) => void; + forgetRecentSession: (path: string) => void; +} + +export const createRecentSessionsSlice: StateCreator< + Store, + [], + [], + RecentSessionsSlice +> = (set) => ({ + recentSessions: [], + rememberRecentSession: (entry) => + set((state) => ({ + recentSessions: [ + entry, + ...state.recentSessions.filter((item) => item.path !== entry.path), + ].slice(0, MAX_RECENT_SESSIONS), + })), + forgetRecentSession: (path) => + set((state) => ({ + recentSessions: state.recentSessions.filter( + (item) => item.path !== path, + ), + })), +}); diff --git a/frontend/logging-view/src/store/slices/sessionSlice.ts b/frontend/logging-view/src/store/slices/sessionSlice.ts index 8a5f87e75..3c4c44252 100644 --- a/frontend/logging-view/src/store/slices/sessionSlice.ts +++ b/frontend/logging-view/src/store/slices/sessionSlice.ts @@ -15,10 +15,16 @@ import type { AdjStatus, DroppedFile, LoggerSettings, + LoggedOrder, SeriesKey, SessionStatus, } from "../../types/session"; +import { parseOrdersCsv } from "../../lib/orders"; import type { Store } from "../store"; +import { + initialNormalDashboards, + sessionNormalDashboards, +} from "./normalDashboardsSlice"; const ADJ_ARCHIVE_URL = (hash: string) => `https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-${hash}.json`; @@ -65,16 +71,16 @@ export interface SessionSlice { selectedSeries: Record; // All session files keyed by webkitRelativePath, for later CSV reading. sessionFiles: Map; + orders: LoggedOrder[]; isLoading: boolean; // Persistent — reflects the currently open session, cleared only by clearSession. sessionStatus: SessionStatus | null; // Transient — set once per openSession() call, meant to auto-dismiss as a toast. // Kept separate from sessionStatus so dismissing the toast doesn't blank the badge. sessionStatusToast: SessionStatus | null; - // Open/closed state of the "Session" collapsible in the sidebar. Auto-closes - // the first time a measurement gets checked (0 → 1 selected), to free up - // space for the series list — but not on every subsequent selection, so a - // user who reopens it isn't fought every time they check another box. + // Open/closed state of the "Session" collapsible in the sidebar. A loaded + // session starts closed to prioritise the series list; it also auto-closes + // on the first measurement selection when the user has opened it. isSessionPanelOpen: boolean; // Accepts files from or a directory drop traversal. @@ -93,6 +99,7 @@ export const createSessionSlice: StateCreator = (se availableSeries: {}, selectedSeries: {}, sessionFiles: new Map(), + orders: [], isLoading: false, sessionStatus: null, sessionStatusToast: null, @@ -169,6 +176,19 @@ export const createSessionSlice: StateCreator = (se } } + // Orders are optional session events. Their absence or malformed content + // never changes the telemetry session status. + let orders: LoggedOrder[] = []; + const orderFile = fileArray.find((f) => { + const parts = f.webkitRelativePath.split("/"); + return f.name === "order.csv" && parts.length === 3 && parts[1] === "order"; + }); + if (orderFile) { + try { + orders = parseOrdersCsv(await orderFile.text(), settings?.time_unit ?? "ms", adjData); + } catch { /* Leave events empty when an optional order file cannot be read. */ } + } + const status = computeSessionStatus(settingsIssue, availableSeries, adjStatus); set({ @@ -176,10 +196,12 @@ export const createSessionSlice: StateCreator = (se settings, adjData, availableSeries, + ...sessionNormalDashboards(folderName, availableSeries), selectedSeries: {}, sessionFiles: new Map(fileArray.map((f) => [f.webkitRelativePath, f])), + orders, isLoading: false, - isSessionPanelOpen: true, + isSessionPanelOpen: false, sessionStatus: status, sessionStatusToast: status, }); @@ -221,11 +243,13 @@ export const createSessionSlice: StateCreator = (se clearSession: () => set({ folderName: null, + ...initialNormalDashboards(), settings: null, adjData: null, availableSeries: {}, selectedSeries: {}, sessionFiles: new Map(), + orders: [], sessionStatus: null, sessionStatusToast: null, isSessionPanelOpen: true, diff --git a/frontend/logging-view/src/store/store.ts b/frontend/logging-view/src/store/store.ts index ae54f8cd4..252cfbb9b 100644 --- a/frontend/logging-view/src/store/store.ts +++ b/frontend/logging-view/src/store/store.ts @@ -1,5 +1,17 @@ import { create } from "zustand"; +import { saveSessionDashboards } from "../lib/normal/dashboardStorage"; +import { + HISTORY_LIMIT, + MERGE_MS, + isViewOnlyChange, + type DashboardHistory, +} from "../lib/normal/history"; +import { historyReplay } from "./slices/normalDashboardsSlice"; import { persist } from "zustand/middleware"; +import { + createNormalDashboardsSlice, + type NormalDashboardsSlice, +} from "./slices/normalDashboardsSlice"; import { createAppSlice, type AppSlice } from "./slices/appSlice"; import { createCatalogSlice, type CatalogSlice } from "./slices/catalogSlice"; import { @@ -15,9 +27,10 @@ import { type PlotStudioSlice, } from "./slices/plotStudioSlice"; import { - createSessionSlice, - type SessionSlice, -} from "./slices/sessionSlice"; + createRecentSessionsSlice, + type RecentSessionsSlice, +} from "./slices/recentSessionsSlice"; +import { createSessionSlice, type SessionSlice } from "./slices/sessionSlice"; import { createTelemetrySlice, type TelemetrySlice, @@ -28,8 +41,10 @@ export type Store = AppSlice & ConnectionsSlice & MessagesSlice & PlotStudioSlice & + RecentSessionsSlice & SessionSlice & - TelemetrySlice; + TelemetrySlice & + NormalDashboardsSlice; export const useStore = create()( persist( @@ -39,8 +54,10 @@ export const useStore = create()( ...createConnectionsSlice(...a), ...createMessagesSlice(...a), ...createPlotStudioSlice(...a), + ...createRecentSessionsSlice(...a), ...createSessionSlice(...a), ...createTelemetrySlice(...a), + ...createNormalDashboardsSlice(...a), }), { name: "competition-view-storage", @@ -48,7 +65,93 @@ export const useStore = create()( // Only persist lightweight user-preference data, never live telemetry. partialize: (state) => ({ isDarkMode: state.isDarkMode, + recentSessions: state.recentSessions, }), }, ), ); + +// Normal mode dashboards are saved per session folder, apart from the +// preferences above: they are larger, and keyed by the session they belong to. +let saveTimer: ReturnType | undefined; +const saveDashboards = () => { + clearTimeout(saveTimer); + saveTimer = undefined; + const { folderName, normalDashboards, activeNormalDashboardId } = + useStore.getState(); + if (folderName) + saveSessionDashboards( + folderName, + normalDashboards, + activeNormalDashboardId, + ); +}; +useStore.subscribe((state, previous) => { + if ( + state.normalDashboards === previous.normalDashboards && + state.activeNormalDashboardId === previous.activeNormalDashboardId + ) + return; + // Closing a session resets the dashboards; that reset is not a change to + // save, and neither is whatever was still pending for the closed session. + if (!state.folderName || state.folderName !== previous.folderName) { + clearTimeout(saveTimer); + saveTimer = undefined; + if (state.folderName) saveDashboards(); + return; + } + clearTimeout(saveTimer); + saveTimer = setTimeout(saveDashboards, 500); +}); +if (typeof window !== "undefined") + window.addEventListener("pagehide", () => { + if (saveTimer !== undefined) saveDashboards(); + }); + +// Undo history for Normal mode dashboards, recorded here rather than in each +// action so that every way of editing a dashboard is covered. +const lastRecordedAt = new Map(); +useStore.subscribe((state, previous) => { + if (state.normalDashboards === previous.normalDashboards) return; + // A new session or a closed one starts every dashboard afresh. + if (state.folderName !== previous.folderName) { + lastRecordedAt.clear(); + if (Object.keys(state.normalHistory).length > 0) + useStore.setState({ normalHistory: {} }); + return; + } + if (historyReplay.active) return; + const before = new Map( + previous.normalDashboards.map((dashboard) => [dashboard.id, dashboard]), + ); + const now = performance.now(); + let history: Record | null = null; + for (const dashboard of state.normalDashboards) { + const old = before.get(dashboard.id); + if (!old || old.settings === dashboard.settings) continue; + if (isViewOnlyChange(old.settings, dashboard.settings)) continue; + history ??= { ...state.normalHistory }; + const current = history[dashboard.id] ?? { past: [], future: [] }; + const merge = + now - (lastRecordedAt.get(dashboard.id) ?? -Infinity) < MERGE_MS; + lastRecordedAt.set(dashboard.id, now); + history[dashboard.id] = { + past: + merge && current.past.length > 0 + ? current.past + : [...current.past, old.settings].slice(-HISTORY_LIMIT), + future: [], + }; + } + // Closed dashboards take their history with them. + const alive = new Set( + state.normalDashboards.map((dashboard) => dashboard.id), + ); + for (const id of Object.keys(history ?? state.normalHistory)) + if (!alive.has(id)) { + history ??= { ...state.normalHistory }; + delete history[id]; + lastRecordedAt.delete(id); + } + if (history) useStore.setState({ normalHistory: history }); +}); diff --git a/frontend/logging-view/src/types/normal.ts b/frontend/logging-view/src/types/normal.ts new file mode 100644 index 000000000..518104746 --- /dev/null +++ b/frontend/logging-view/src/types/normal.ts @@ -0,0 +1,149 @@ +import type { EnumDisplay } from "../lib/normal/enums"; + +import type { SeriesData } from "./plotStudio"; + +export interface ComparisonSignal { + id: string; + board: string; + name: string; + units?: string; + enumLabels?: string[]; + discrete: boolean; + color: string; + data: SeriesData; + /** Set only for computed signals. */ + formula?: FormulaSignal; +} + +export const FORMULA_ID_PREFIX = "calc:"; + +/** A signal computed from an expression over session measurements. */ +export interface FormulaSignal { + /** `calc:` — never contains "/", unlike "BOARD/measId" ids. */ + id: string; + name: string; + expression: string; + /** Variable name used in the expression -> "BOARD/measId". */ + variables: Record; + units?: string; +} + +export type TimeUnit = "us" | "ms" | "s" | "min"; +export const TIME_DIVISORS: Record = { + us: 0.001, + ms: 1, + s: 1_000, + min: 60_000, +}; + +export interface TimeRange { + start: number; + end: number; +} + +export interface TimeDomain { + startMs: number; + endMs: number; +} + +export type StatisticsColumn = + | "samples" + | "min" + | "max" + | "mean" + | "std" + | "firstLast" + | "changes"; + +export const DEFAULT_STATISTICS_COLUMNS: StatisticsColumn[] = [ + "samples", + "min", + "max", + "mean", + "std", + "firstLast", + "changes", +]; + +export interface ChartMarker { + id: string; + /** Time from the start of the recording, in milliseconds. */ + timeMs: number; + label: string; +} + +export interface ChartPane { + type: "pane"; + id: string; + title: string; + signalIds: string[]; + normalized: boolean; + /** Scale y axes to the visible window instead of the whole recording. */ + fitY?: boolean; + /** Draws the signals against this one instead of against time (X‑Y). */ + xSignalId?: string; + hiddenSignalIds?: string[]; + markers?: ChartMarker[]; + // Only consulted when the dashboard's time axis is unlinked; absent means + // this plot follows DashboardSettings.range. + timeWindow?: TimeRange; + // Per-plot override of DashboardSettings.enumAsBackground. + enumDisplay?: EnumDisplay; +} + +export interface ChartSplit { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + first: ChartLayout; + second: ChartLayout; +} + +export type ChartLayout = ChartPane | ChartSplit; +export type LayoutPreset = "single" | "columns" | "rows" | "grid"; + +/** A/B measuring cursors, in ms from the start of the recording. */ +export interface MeasureCursors { + a: number | null; + b: number | null; +} + +export interface DashboardSettings { + layoutState: { + ids: string[]; + tree: ChartLayout; + preset: LayoutPreset | null; + }; + showStatistics: boolean; + statisticsColumns: StatisticsColumn[]; + showOrders: boolean; + // Dashboard-wide default for how an enum mixed with numeric series is + // drawn; a pane can override it with ChartPane.enumDisplay. + enumAsBackground: boolean; + focusedId: string | null; + units: Record; + timeUnit: TimeUnit; + range: TimeRange; + // false lets each plot hold its own window in ChartPane.timeWindow. + linkTimeAxis: boolean; + panelSizes: Record>; + formulas: FormulaSignal[]; + showCursors: boolean; + cursors: MeasureCursors; +} + +export interface NormalDashboard { + id: string; + name: string; + color: string | null; + locked: boolean; + selectedSeries: Record; + settings: DashboardSettings; +} + +export interface DashboardTabSettings { + name: string; + color: string | null; + locked: boolean; + position: number; +} diff --git a/frontend/logging-view/src/types/plotStudio.ts b/frontend/logging-view/src/types/plotStudio.ts index df4b44f70..e14d78ec0 100644 --- a/frontend/logging-view/src/types/plotStudio.ts +++ b/frontend/logging-view/src/types/plotStudio.ts @@ -67,6 +67,8 @@ export interface PlotState { // one plot (they'd need incompatible X-axis semantics on the same axis). showFFT: boolean; nextColorIndex: number; // monotonically increasing; never reused, so colors don't reshuffle on removal + /** Order event ids hidden only on this plot. Missing ids remain visible. */ + hiddenOrderIds?: Record; // When true, blocks pan/zoom/scroll-zoom, drag-to-resize, inline rename and // signal drops onto this plot, so the view can't be changed by accident. locked?: boolean; diff --git a/frontend/logging-view/src/types/session.ts b/frontend/logging-view/src/types/session.ts index 65548412a..619ba7244 100644 --- a/frontend/logging-view/src/types/session.ts +++ b/frontend/logging-view/src/types/session.ts @@ -81,3 +81,18 @@ export interface DroppedFile { webkitRelativePath: string; text: () => Promise; } + +/** An order emitted by the backend during a logging session. Times are + * normalized to milliseconds, matching plotted telemetry signals. */ +export interface LoggedOrder { + id: string; + /** Original session timestamp in milliseconds, used by linked Normal plots. */ + absoluteTime?: number; + time: number; + from: string; + to: string; + orderId: number; + parameters: Record | null; + /** Resolved from the session ADJ when it is available. */ + name: string; +} diff --git a/frontend/logging-view/src/vite-env.d.ts b/frontend/logging-view/src/vite-env.d.ts index 0e65dd578..a3507490c 100644 --- a/frontend/logging-view/src/vite-env.d.ts +++ b/frontend/logging-view/src/vite-env.d.ts @@ -2,6 +2,13 @@ interface ElectronAPI { switchView: (view: string, query?: Record) => Promise; + // Present only inside the Electron shell; the folder picker in a plain + // browser never yields a path, so recent sessions are Electron-only. + selectFolder?: () => Promise; + listSessionFiles?: ( + dir: string, + ) => Promise<{ folderName: string; entries: string[] }>; + readSessionFile?: (dir: string, relativePath: string) => Promise; } declare global { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1121c4f0e..0df48307a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.2.3 version: 4.2.3(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)) + echarts: + specifier: ^6.1.0 + version: 6.1.0 jspdf: specifier: ^4.2.1 version: 4.2.1 @@ -3168,6 +3171,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -5418,6 +5424,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5865,6 +5874,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -8780,6 +8792,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -11434,6 +11451,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.3.0: {} + tslib@2.8.1: {} turbo-darwin-64@2.8.3: @@ -11898,6 +11917,10 @@ snapshots: zod@4.3.6: {} + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + zustand@5.0.11(@types/react@19.2.11)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.11