From ef3ce89179e755800c5d9aa17b1280d706045cd6 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:27:34 -0600 Subject: [PATCH 1/3] Share validated scalar ranges across cell and signal coloring --- viewer/src/colony-viewer.ts | 22 ++--- viewer/src/color.ts | 48 ++++++---- viewer/src/scalar-range.ts | 148 ++++++++++++++++++++++++++++++ viewer/tests/scalar-range.test.ts | 147 +++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 viewer/src/scalar-range.ts create mode 100644 viewer/tests/scalar-range.test.ts diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 26609d7..c9c9605 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -34,7 +34,8 @@ import { } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; -import { rgbBytes, viridis, type RGB } from "./color"; +import { mapScalarColors, rgbBytes, type RGB } from "./color"; +import { AUTOMATIC_SCALAR_RANGE, type ScalarRangeConfig } from "./scalar-range"; import type { SignalSlice } from "./grid"; import { DatasetReferenceGrid, @@ -350,25 +351,20 @@ export class ColonyViewer { } } - public setSignalSlice(slice: SignalSlice | null): void { + public setSignalSlice( + slice: SignalSlice | null, + range: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, + ): void { this.signalTexture?.dispose(); this.signalTexture = null; disposeGroup(this.signal); if (slice === null) { return; } - let minimum = Number.POSITIVE_INFINITY; - let maximum = Number.NEGATIVE_INFINITY; - for (const value of slice.values) { - minimum = Math.min(minimum, value); - maximum = Math.max(maximum, value); - } - const span = maximum - minimum; + const mapping = mapScalarColors(slice.values, range); const pixels = new Uint8Array(slice.width * slice.height * 4); - for (const [index, value] of slice.values.entries()) { - const color = rgbBytes( - viridis(span === 0 ? 0.5 : (value - minimum) / span), - ); + for (const [index, value] of mapping.colors.entries()) { + const color = rgbBytes(value); const offset = index * 4; pixels[offset] = color[0]; pixels[offset + 1] = color[1]; diff --git a/viewer/src/color.ts b/viewer/src/color.ts index 22c0b16..0596cc2 100644 --- a/viewer/src/color.ts +++ b/viewer/src/color.ts @@ -1,4 +1,11 @@ -import type { SceneCell, SceneFrame } from "./scene"; +import type { SceneFrame } from "./scene"; +import { + AUTOMATIC_SCALAR_RANGE, + normalizeScalar, + resolveScalarRange, + type ResolvedScalarRange, + type ScalarRangeConfig, +} from "./scalar-range"; export type RGB = readonly [number, number, number]; export type ColorMode = "cell-type" | "species" | "growth-rate" | "fixed"; @@ -6,6 +13,7 @@ export type ColorMode = "cell-type" | "species" | "growth-rate" | "fixed"; export interface ColorConfig { readonly mode: ColorMode; readonly speciesIndex: number; + readonly range?: ScalarRangeConfig; } export interface ColorMapping { @@ -13,6 +21,7 @@ export interface ColorMapping { readonly title: string; readonly minimum: number | null; readonly maximum: number | null; + readonly range: ResolvedScalarRange | null; } const TYPE_PALETTE: readonly RGB[] = [ @@ -59,28 +68,28 @@ export function viridis(value: number): RGB { return VIRIDIS.at(-1)?.[1] ?? [1, 1, 1]; } +export function mapScalarColors( + values: readonly number[], + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, +): { colors: readonly RGB[]; range: ResolvedScalarRange } { + const range = resolveScalarRange(values, config); + return { + colors: values.map((value) => viridis(normalizeScalar(value, range))), + range, + }; +} + function scalarMapping( - cells: readonly SceneCell[], values: readonly number[], title: string, + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, ): ColorMapping { - if (values.length === 0) { - return { colors: [], title, minimum: null, maximum: null }; - } - let minimum = Number.POSITIVE_INFINITY; - let maximum = Number.NEGATIVE_INFINITY; - for (const value of values) { - minimum = Math.min(minimum, value); - maximum = Math.max(maximum, value); - } - const span = maximum - minimum; + const mapping = mapScalarColors(values, config); return { - colors: cells.map((_, index) => - viridis(span === 0 ? 0.5 : ((values[index] ?? minimum) - minimum) / span), - ), + ...mapping, title, - minimum, - maximum, + minimum: mapping.range.minimum, + maximum: mapping.range.maximum, }; } @@ -100,6 +109,7 @@ export function mapCellColors( title: "Cell type", minimum: null, maximum: null, + range: null, }; case "fixed": return { @@ -109,10 +119,10 @@ export function mapCellColors( title: "Fixed state", minimum: null, maximum: null, + range: null, }; case "growth-rate": return scalarMapping( - frame.cells, frame.cells.map((cell) => cell.growthRate), "Growth rate", ); @@ -126,9 +136,9 @@ export function mapCellColors( ); } return scalarMapping( - frame.cells, frame.cells.map((cell) => cell.species[config.speciesIndex] ?? 0), `Species ${config.speciesIndex}`, + config.range, ); } } diff --git a/viewer/src/scalar-range.ts b/viewer/src/scalar-range.ts new file mode 100644 index 0000000..4cdf031 --- /dev/null +++ b/viewer/src/scalar-range.ts @@ -0,0 +1,148 @@ +export type ScalarRangeConfig = + | Readonly<{ mode: "automatic" }> + | Readonly<{ mode: "fixed"; minimum: number; maximum: number }>; +export type FixedScalarRange = Extract; +export type ScalarChannelKind = "species" | "signals"; +export interface ResolvedScalarRange { + readonly mode: ScalarRangeConfig["mode"]; + readonly minimum: number | null; + readonly maximum: number | null; + readonly count: number; +} +export const AUTOMATIC_SCALAR_RANGE: ScalarRangeConfig = Object.freeze({ + mode: "automatic", +}); + +export function validateScalarRange(config: ScalarRangeConfig): void { + if (config.mode === "automatic") return; + if (!Number.isFinite(config.minimum)) + throw new RangeError("Minimum must be a finite number."); + if (!Number.isFinite(config.maximum)) + throw new RangeError("Maximum must be a finite number."); + if (config.minimum >= config.maximum) + throw new RangeError("Minimum must be less than maximum."); +} + +export function parseFixedScalarRange( + minimum: string, + maximum: string, +): FixedScalarRange { + const number = (text: string, label: string): number => { + if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text.trim())) { + throw new RangeError(`${label} must be a finite number.`); + } + return Number(text); + }; + const result = { + mode: "fixed" as const, + minimum: number(minimum, "Minimum"), + maximum: number(maximum, "Maximum"), + }; + validateScalarRange(result); + return result; +} + +/** Empty automatic ranges have no bounds; fixed bounds still describe a configured scale. */ +export function resolveScalarRange( + values: readonly number[], + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, +): ResolvedScalarRange { + validateScalarRange(config); + let minimum: number | null = null; + let maximum: number | null = null; + for (const value of values) { + if (!Number.isFinite(value)) + throw new RangeError("Scalar values must be finite."); + minimum = minimum === null ? value : Math.min(minimum, value); + maximum = maximum === null ? value : Math.max(maximum, value); + } + return { + mode: config.mode, + minimum: config.mode === "fixed" ? config.minimum : minimum, + maximum: config.mode === "fixed" ? config.maximum : maximum, + count: values.length, + }; +} + +/** Return display intensity only; the source value is never modified. */ +export function normalizeScalar( + value: number, + range: ResolvedScalarRange, +): number { + if (!Number.isFinite(value)) + throw new RangeError("Scalar values must be finite."); + const { minimum, maximum } = range; + if (minimum === null || maximum === null) + throw new RangeError("Cannot normalize without scalar bounds."); + if (maximum === minimum) return 0.5; + if (value <= minimum) return 0; + if (value >= maximum) return 1; + const span = maximum - minimum; + // Extreme finite user bounds can overflow their difference. Scaling both + // numerator and denominator preserves the ratio without overflowing. + const normalized = Number.isFinite(span) + ? (value - minimum) / span + : (value / 2 - minimum / 2) / (maximum / 2 - minimum / 2); + return Math.min(1, Math.max(0, normalized)); +} + +/** Valid initial bounds when switching from automatic to fixed display. */ +export function suggestedFixedRange( + range: ResolvedScalarRange, +): FixedScalarRange { + const { minimum, maximum } = range; + if (minimum === null || maximum === null) + return { mode: "fixed", minimum: 0, maximum: 1 }; + if (minimum < maximum) return { mode: "fixed", minimum, maximum }; + const padding = Math.max(Math.abs(minimum) / 2, 0.5); + return { + mode: "fixed", + minimum: Math.max(-Number.MAX_VALUE, minimum - padding), + maximum: Math.min(Number.MAX_VALUE, maximum + padding), + }; +} + +/** Dataset-local settings keyed by kind and numerical channel index, never labels. */ +export class DatasetScalarRanges { + private readonly channels = new Map< + string, + { active: ScalarRangeConfig; fixed: FixedScalarRange | null } + >(); + private key(kind: ScalarChannelKind, index: number): string { + if (!Number.isSafeInteger(index) || index < 0) + throw new RangeError( + "Channel index must be a non-negative safe integer.", + ); + return `${kind}:${index}`; + } + public beginDataset(): void { + this.channels.clear(); + } + public get(kind: ScalarChannelKind, index: number): ScalarRangeConfig { + return ( + this.channels.get(this.key(kind, index))?.active ?? AUTOMATIC_SCALAR_RANGE + ); + } + public lastFixed( + kind: ScalarChannelKind, + index: number, + ): FixedScalarRange | null { + return this.channels.get(this.key(kind, index))?.fixed ?? null; + } + public set( + kind: ScalarChannelKind, + index: number, + config: ScalarRangeConfig, + ): void { + validateScalarRange(config); + const key = this.key(kind, index); + const active = Object.freeze({ ...config }); + this.channels.set(key, { + active, + fixed: + active.mode === "fixed" + ? active + : (this.channels.get(key)?.fixed ?? null), + }); + } +} diff --git a/viewer/tests/scalar-range.test.ts b/viewer/tests/scalar-range.test.ts new file mode 100644 index 0000000..aea503e --- /dev/null +++ b/viewer/tests/scalar-range.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { mapScalarColors, viridis } from "../src/color"; +import { + DatasetScalarRanges, + normalizeScalar, + parseFixedScalarRange, + resolveScalarRange, + suggestedFixedRange, +} from "../src/scalar-range"; + +const fixed = { mode: "fixed" as const, minimum: 0, maximum: 10 }; + +describe("scalar normalization", () => { + it("keeps shared values identical across different frame and slice extrema", () => { + const first = mapScalarColors([2, 4], fixed); + const second = mapScalarColors([2, 40], fixed); + expect(first.colors[0]).toEqual(second.colors[0]); + expect(first.colors[0]).toEqual(viridis(0.2)); + expect(second.colors[1]).toEqual(viridis(1)); + }); + it("clips display intensity without modifying negative/out-of-range data", () => { + const values = Object.freeze([-30, -10, 0, 10, 300]); + expect( + mapScalarColors(values, { mode: "fixed", minimum: -10, maximum: 10 }) + .colors, + ).toEqual([viridis(0), viridis(0), viridis(0.5), viridis(1), viridis(1)]); + expect(values).toEqual([-30, -10, 0, 10, 300]); + }); + it("represents automatic empty data without invented extrema", () => { + expect(mapScalarColors([])).toEqual({ + colors: [], + range: { mode: "automatic", minimum: null, maximum: null, count: 0 }, + }); + expect(mapScalarColors([], fixed)).toEqual({ + colors: [], + range: { ...fixed, count: 0 }, + }); + }); + it("uses the midpoint for constant automatic data and fixed bounds for constant fixed data", () => { + expect(mapScalarColors([7, 7]).colors).toEqual([ + viridis(0.5), + viridis(0.5), + ]); + expect(mapScalarColors([7, 7], fixed).colors).toEqual([ + viridis(0.7), + viridis(0.7), + ]); + expect(resolveScalarRange([7, 7])).toMatchObject({ + minimum: 7, + maximum: 7, + }); + }); + it("rejects non-finite source values rather than generating NaN colors", () => { + for (const invalid of [NaN, Infinity, -Infinity]) { + expect(() => mapScalarColors([invalid])).toThrow("must be finite"); + expect(() => + normalizeScalar(invalid, resolveScalarRange([0, 1])), + ).toThrow("must be finite"); + } + }); + it("handles extreme finite bounds without overflowing normalization", () => { + const range = resolveScalarRange([-Number.MAX_VALUE, 0, Number.MAX_VALUE], { + mode: "fixed", + minimum: -Number.MAX_VALUE, + maximum: Number.MAX_VALUE, + }); + expect(normalizeScalar(0, range)).toBe(0.5); + expect(normalizeScalar(-Number.MAX_VALUE / 2, range)).toBe(0.25); + expect(normalizeScalar(Number.MAX_VALUE / 2, range)).toBe(0.75); + const tiny = resolveScalarRange([0], { + mode: "fixed", + minimum: 0, + maximum: Number.MIN_VALUE, + }); + expect(normalizeScalar(Number.MIN_VALUE, tiny)).toBe(1); + }); + it("suggests valid bounds for empty, constant, and extreme automatic ranges", () => { + for (const values of [ + [], + [0], + [7], + [-7], + [Number.MAX_VALUE], + [-Number.MAX_VALUE], + [2, 40], + ]) { + const suggestion = suggestedFixedRange(resolveScalarRange(values)); + expect(Number.isFinite(suggestion.minimum)).toBe(true); + expect(Number.isFinite(suggestion.maximum)).toBe(true); + expect(suggestion.minimum).toBeLessThan(suggestion.maximum); + } + }); +}); + +describe("fixed range validation and channel state", () => { + it("accepts negative finite numbers and scientific notation", () => { + expect(parseFixedScalarRange(" -1e2 ", ".5")).toEqual({ + mode: "fixed", + minimum: -100, + maximum: 0.5, + }); + }); + it("rejects missing, nonnumeric, infinite, equal and reversed bounds", () => { + for (const [minimum, maximum] of [ + ["", "1"], + ["0", "NaN"], + ["0", "Infinity"], + ["0", "1e309"], + ["0x10", "20"], + ["2", "2"], + ["3", "2"], + ]) { + expect(() => parseFixedScalarRange(minimum!, maximum!)).toThrow(); + } + }); + it("retains the last valid configuration after invalid changes", () => { + const state = new DatasetScalarRanges(); + state.set("species", 0, fixed); + for (const bounds of [ + { minimum: 2, maximum: 2 }, + { minimum: 3, maximum: 2 }, + { minimum: NaN, maximum: 2 }, + { minimum: 0, maximum: Infinity }, + ]) { + expect(() => + state.set("species", 0, { mode: "fixed", ...bounds }), + ).toThrow(); + expect(state.get("species", 0)).toEqual(fixed); + } + }); + it("separates channel kind/index, remembers fixed bounds, and resets only on a new dataset", () => { + const state = new DatasetScalarRanges(); + state.set("species", 0, fixed); + state.set("species", 1, { mode: "fixed", minimum: -1, maximum: 1 }); + state.set("signals", 0, { mode: "fixed", minimum: 10, maximum: 20 }); + expect(state.get("species", 0)).toEqual(fixed); + expect(state.get("species", 2)).toEqual({ mode: "automatic" }); + state.set("species", 0, { mode: "automatic" }); + expect(state.lastFixed("species", 0)).toEqual(fixed); + expect(state.get("species", 1)).toMatchObject({ minimum: -1 }); + expect(state.get("signals", 0)).toMatchObject({ minimum: 10 }); + state.beginDataset(); + expect(state.get("species", 0)).toEqual({ mode: "automatic" }); + expect(state.lastFixed("species", 0)).toBeNull(); + expect(state.get("signals", 0)).toEqual({ mode: "automatic" }); + }); +}); From 8ee43abc480bb96665b5e4015dfe7638ad5c06b4 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:39:24 -0600 Subject: [PATCH 2/3] Add persistent channel range editors and concentration legends --- viewer/README.md | 10 + viewer/browser/scalar-ranges.mjs | 321 ++++++++++++++++++++++++++++ viewer/index.html | 22 +- viewer/src/main.ts | 77 ++++++- viewer/src/scalar-range-controls.ts | 134 ++++++++++++ viewer/src/style.css | 42 ++++ viewer/tests/color.test.ts | 41 ++++ 7 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 viewer/browser/scalar-ranges.mjs create mode 100644 viewer/src/scalar-range-controls.ts diff --git a/viewer/README.md b/viewer/README.md index 237d6cc..16dd732 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -72,3 +72,13 @@ Opening a scene file, live session, or recording begins a new dataset. Call `Dat The ground reference grid is separate from the scientific signal lattice. Its square extent and origin come from the first frame's finite device geometry (boxes, spheres, and cylinders), or from the initial cell capsule bounds when no finite device exists. Infinite plane constraints are excluded. The extent is at least 10 scene distance units with 20 equal divisions; the grid plane is 0.01 units below the lesser of the initial lower Z bound and zero. An initially empty dataset uses a 10-unit grid centered on the world origin. These values remain fixed even when cells or device geometry appear later, the colony expands beyond the grid, or all cells disappear. Opening another dataset initializes a new reference grid; camera Fit never changes its geometry. `browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. + +## Concentration color ranges + +Species coloring and signal slices each offer Automatic and Fixed color ranges. Automatic uses the current frame's species extrema or the selected signal slice's extrema. Constant automatic data uses the midpoint color and a uniform legend; empty automatic data shows “no values” without numerical bounds. Fixed uses the entered minimum and maximum across frames and slices. Values outside that interval use endpoint colors; the underlying concentrations and inspector values remain unchanged. + +Switching to Fixed starts from the current extrema (with finite padding for constant data), or restores that channel's previously entered fixed bounds. Edit both bounds and choose Apply range or press Enter. Bounds accept finite decimal numbers, including negative numbers and scientific notation, with minimum strictly less than maximum. Invalid or incomplete edits show an explanation and leave the last valid range active. Legends always describe the active range rather than unsubmitted text. + +Settings belong to the numerical species or signal channel within the current dataset. They survive temporarily missing channels/grids, live updates, same-model reset, and frame seeking; opening another dataset restores automatic defaults. The shared `resolveScalarRange()` and `normalizeScalar()` APIs reject non-finite data explicitly, retain zero-valued and negative data, and avoid overflowing the difference between extreme finite bounds. They produce display intensity only and do not modify model data. + +`browser/scalar-ranges.mjs` checks actual cell instance colors, signal texture pixels, legends, validation messages, keyboard interaction, and dataset transitions in Chromium. Run it with a Vite server on port 4315, or set `VIEWER_URL`, using the same optional Playwright module and evidence-directory environment variables as the reference-grid test. diff --git a/viewer/browser/scalar-ranges.mjs b/viewer/browser/scalar-ranges.mjs new file mode 100644 index 0000000..ce02416 --- /dev/null +++ b/viewer/browser/scalar-ranges.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4315"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-scalar-ranges"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +const grid = { + signal_count: 2, + shape: [2, 1, 2], + origin: [-2, -2, -1], + spacing: [4, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: [2, 2, 4, 40, 8, 8, 6, 60], +}; +const first = { + id: "1", + parent_id: null, + slot: 0, + position: [-1, 0, 0.6], + direction: [0, 1, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [2, 8], +}; +const second = { + ...first, + id: "2", + slot: 1, + position: [1, 0, 0.6], + species: [4, 6], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 2, + cells: [first, second], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: grid, +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); +} +const control = (kind, field) => + page.getByRole(field === "range mode" ? "combobox" : "textbox", { + name: `${kind} ${field}`, + exact: true, + }); +async function fixed(kind, minimum, maximum) { + await control(kind, "range mode").focus(); + await page.keyboard.press("f"); + await page.keyboard.press("Tab"); + await expect(control(kind, "range mode")).toHaveValue("fixed"); + await control(kind, "minimum").fill(String(minimum)); + await control(kind, "maximum").fill(String(maximum)); + await control(kind, "maximum").press("Enter"); +} +async function colors() { + return page.evaluate(() => ({ + cells: Array.from( + globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [], + ), + signal: Array.from(globalThis.__testViewer.signalTexture?.image.data ?? []), + })); +} +async function expectedColor(intensity) { + return page.evaluate(async (intensity) => { + const { rgbBytes, viridis } = await import("/src/color.ts"); + const { Color, SRGBColorSpace } = + await import("/node_modules/.vite/deps/three.js"); + const rgb = viridis(intensity); + const linear = new Color().setRGB(...rgb, SRGBColorSpace); + return { + signal: [...rgbBytes(rgb), 205], + cell: [linear.r, linear.g, linear.b].map(Math.fround), + }; + }, intensity); +} +try { + await page.goto(`${url}/?token=scalar-range-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#color-mode").selectOption("species"); + await fixed("Species", 0, 10); + await fixed("Signal", 0, 10); + await expect(page.locator("#legend-mode")).toHaveText("Fixed"); + await expect(page.locator("#signal-legend-mode")).toHaveText("Fixed"); + const initial = await colors(); + const at02 = await expectedColor(0.2); + assert.deepEqual(initial.cells.slice(0, 3), at02.cell); + assert.deepEqual(initial.signal.slice(0, 4), at02.signal); + await page.screenshot({ path: `${evidence}/fixed-ranges.png` }); + + await send({ + ...base, + time: 1, + cells: [first, { ...second, species: [40, 6] }], + signal_grid: { ...grid, levels: [2, 2, 40, 40, 8, 8, 6, 60] }, + }); + const changed = await colors(); + assert.deepEqual(changed.cells.slice(0, 3), initial.cells.slice(0, 3)); + assert.deepEqual(changed.signal.slice(0, 4), initial.signal.slice(0, 4)); + await page.locator("#signal-slice").focus(); + await page.keyboard.press("End"); + assert.deepEqual( + (await colors()).signal.slice(0, 4), + initial.signal.slice(0, 4), + ); + await expect(page.locator("#legend-max")).toHaveText("10"); + await expect(page.locator("#signal-legend-max")).toHaveText("10"); + + const clipped = { + ...base, + time: 2, + cells: [ + { ...first, species: [-5, 8] }, + { ...second, species: [50, 6] }, + ], + signal_grid: { ...grid, levels: [-5, -5, 50, 50, 8, 8, 6, 60] }, + }; + await send(clipped); + const endpoints = await colors(); + const low = await expectedColor(0); + const high = await expectedColor(1); + assert.deepEqual(endpoints.cells, [...low.cell, ...high.cell]); + assert.deepEqual(endpoints.signal, [...low.signal, ...high.signal]); + await page.evaluate(() => globalThis.__testViewer.selectCell(0)); + await expect(page.locator("#species-values code").first()).toHaveText("-5"); + assert.deepEqual( + await page.evaluate(() => + globalThis.__testViewer.cells.map((cell) => cell.species[0]), + ), + [-5, 50], + ); + + for (const kind of ["Species", "Signal"]) { + await control(kind, "minimum").fill("20"); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("Minimum must be less than maximum"); + assert.deepEqual(await colors(), endpoints); + await control(kind, "minimum").fill("Infinity"); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("finite number"); + assert.deepEqual(await colors(), endpoints); + await control(kind, "minimum").fill(""); + await control(kind, "maximum").press("Enter"); + await expect( + page + .getByRole("form", { name: `${kind} concentration range` }) + .getByRole("status"), + ).toContainText("finite number"); + await fixed(kind, 0, 10); + } + + await page.locator("#species-channel").selectOption("1"); + await expect(control("Species", "range mode")).toHaveValue("automatic"); + await fixed("Species", -100, 100); + await page.locator("#species-channel").selectOption("0"); + await expect(control("Species", "minimum")).toHaveValue("0"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await page.locator("#signal-channel").selectOption("1"); + await expect(control("Signal", "range mode")).toHaveValue("automatic"); + await fixed("Signal", -2, 2); + await page.locator("#signal-channel").selectOption("0"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + await send({ + ...base, + time: 3, + species_count: 0, + cells: [], + signal_grid: null, + }); + await send(base); // Same live model reset / seek back to time zero. + await expect(control("Species", "range mode")).toHaveValue("fixed"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + assert.deepEqual((await colors()).cells, initial.cells); + + for (const kind of ["Species", "Signal"]) { + await control(kind, "range mode").focus(); + await page.keyboard.press("a"); + await page.keyboard.press("Tab"); + } + await send({ + ...base, + time: 4, + cells: [ + { ...first, species: [7, 7] }, + { ...second, species: [7, 7] }, + ], + signal_grid: { ...grid, levels: Array(8).fill(7) }, + }); + await expect(page.locator("#legend-mode")).toHaveText("Automatic · constant"); + await expect(page.locator("#signal-legend-mode")).toHaveText( + "Automatic · constant", + ); + const midpoint = await expectedColor(0.5); + assert.deepEqual((await colors()).cells, [ + ...midpoint.cell, + ...midpoint.cell, + ]); + assert.deepEqual((await colors()).signal, [ + ...midpoint.signal, + ...midpoint.signal, + ]); + await send({ ...base, time: 5, cells: [], signal_grid: null }); + await expect(page.locator("#legend-mode")).toHaveText( + "Automatic · no values", + ); + await expect(page.locator("#legend-min")).toHaveText("—"); + await expect(page.locator("#legend-ramp")).toBeHidden(); + await send(base); + await control("Species", "range mode").selectOption("fixed"); + await expect(control("Species", "maximum")).toHaveValue("10"); + await control("Signal", "range mode").selectOption("fixed"); + await expect(control("Signal", "maximum")).toHaveValue("10"); + + await page.goto(url); + const open = async (value) => { + await page.locator("#scene-file").setInputFiles({ + name: "scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(value))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${value.time}`); + }; + await open(base); + await page.locator("#color-mode").selectOption("species"); + await fixed("Species", 0, 10); + await fixed("Signal", 0, 10); + await open({ ...base, time: 99 }); + await page.locator("#color-mode").selectOption("species"); + await expect(control("Species", "range mode")).toHaveValue("automatic"); + await expect(control("Signal", "range mode")).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + evidence, + coverage: + "cell instance RGB, signal texture RGBA, fixed scales across frames/slices, endpoints and inspector, invalid bounds, keyboard controls, independent channels, missing data, reset/seek, constant/empty, new dataset", + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index 0b1a8a3..e5f2322 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -66,8 +66,14 @@

Cells

Species channel +