diff --git a/viewer/README.md b/viewer/README.md
index e7e068b..77a84b2 100644
--- a/viewer/README.md
+++ b/viewer/README.md
@@ -98,6 +98,16 @@ The ground reference grid is separate from the scientific signal lattice. Its sq
`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.
+
### Device geometry visibility
Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed.
diff --git a/viewer/browser/scalar-ranges.mjs b/viewer/browser/scalar-ranges.mjs
new file mode 100644
index 0000000..6df7fe7
--- /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 b66a1b4..d216c93 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -66,8 +66,14 @@
Cells
Species channel
+
-
+
+
—
Value
@@ -108,6 +114,20 @@
Signal grid
value="0"
/>
+
+
+
+
+
+ —
+ Signal
+ —
+
+
diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts
index 85f0855..8734d73 100644
--- a/viewer/src/colony-viewer.ts
+++ b/viewer/src/colony-viewer.ts
@@ -36,7 +36,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 { capsuleGeometries, CapsuleTransform } from "./capsule";
import type { SignalSlice } from "./grid";
import {
@@ -323,25 +324,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 f5b93f0..7290b74 100644
--- a/viewer/src/color.ts
+++ b/viewer/src/color.ts
@@ -1,4 +1,11 @@
-import { channelLabel, type SceneCell, type SceneFrame } from "./scene";
+import { channelLabel, 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),
channelLabel(frame, "species", config.speciesIndex),
+ config.range,
);
}
}
diff --git a/viewer/src/main.ts b/viewer/src/main.ts
index a65fa47..3f06215 100644
--- a/viewer/src/main.ts
+++ b/viewer/src/main.ts
@@ -1,6 +1,12 @@
import "./style.css";
-import { mapCellColors, type ColorMode } from "./color";
+import { mapCellColors, rgbBytes, viridis, type ColorMode } from "./color";
+import {
+ DatasetScalarRanges,
+ resolveScalarRange,
+ type ResolvedScalarRange,
+} from "./scalar-range";
+import { ScalarRangeControls } from "./scalar-range-controls";
import { ColonyViewer } from "./colony-viewer";
import { signalSlice, sliceDimension, type SliceAxis } from "./grid";
import { DatasetPresentationState } from "./presentation-state";
@@ -77,6 +83,20 @@ let livePlaying = false;
let liveCheckpointEnabled = false;
let liveConnection: LiveConnection | null = null;
const presentation = new DatasetPresentationState();
+const scalarRanges = new DatasetScalarRanges();
+const speciesRangeRoot = required("species-range");
+const speciesRangeControls = new ScalarRangeControls(
+ speciesRangeRoot,
+ scalarRanges,
+ "species",
+ updateColors,
+);
+const signalRangeControls = new ScalarRangeControls(
+ required("signal-color-range"),
+ scalarRanges,
+ "signals",
+ updateSignal,
+);
const viewer = new ColonyViewer(canvasHost, viewCubeElement, updateSelection);
@@ -134,16 +154,39 @@ function updateColors(): void {
}
const mode = colorMode.value as ColorMode;
speciesField.hidden = mode !== "species";
+ speciesRangeRoot.hidden = mode !== "species";
const mapping = mapCellColors(frame, {
mode,
speciesIndex: selectedInteger(speciesChannel),
+ range: scalarRanges.get("species", selectedInteger(speciesChannel)),
});
viewer.setCellColors(mapping.colors);
- const scalar = mapping.minimum !== null && mapping.maximum !== null;
- colorLegend.hidden = !scalar;
- legendTitle.textContent = mapping.title;
- legendMinimum.textContent = scalar ? formatNumber(mapping.minimum ?? 0) : "—";
- legendMaximum.textContent = scalar ? formatNumber(mapping.maximum ?? 0) : "—";
+ colorLegend.hidden = mapping.range === null;
+ if (mapping.range !== null) {
+ if (mode === "species")
+ speciesRangeControls.bind(selectedInteger(speciesChannel), mapping.range);
+ legendTitle.textContent = mapping.title;
+ legendMinimum.textContent =
+ mapping.minimum === null ? "—" : formatNumber(mapping.minimum);
+ legendMaximum.textContent =
+ mapping.maximum === null ? "—" : formatNumber(mapping.maximum);
+ updateRangeLegend("legend", mapping.range);
+ }
+}
+
+function updateRangeLegend(prefix: string, range: ResolvedScalarRange): void {
+ const mode = range.mode === "fixed" ? "Fixed" : "Automatic";
+ const constant =
+ range.mode === "automatic" &&
+ range.count > 0 &&
+ range.minimum === range.maximum;
+ required(`${prefix}-mode`).textContent =
+ `${mode}${range.count === 0 ? " · no values" : constant ? " · constant" : ""}`;
+ const ramp = required(`${prefix}-ramp`);
+ ramp.hidden = range.mode === "automatic" && range.count === 0;
+ ramp.style.background = constant
+ ? `rgb(${rgbBytes(viridis(0.5)).join(",")})`
+ : "";
}
function updateSignalRange(): void {
@@ -158,18 +201,31 @@ function updateSignalRange(): void {
}
function updateSignal(): void {
- if (frame?.signalGrid === null || frame === null || !signalVisible.checked) {
+ const legend = required("signal-legend");
+ if (frame?.signalGrid === null || frame === null) {
viewer.setSignalSlice(null);
+ legend.hidden = true;
return;
}
const axis = signalAxis.value as SliceAxis;
+ const index = selectedInteger(signalChannel);
const value = signalSlice(
frame.signalGrid,
- selectedInteger(signalChannel),
+ index,
axis,
selectedInteger(signalRange),
);
- viewer.setSignalSlice(value);
+ const config = scalarRanges.get("signals", index);
+ const range = resolveScalarRange(value.values, config);
+ signalRangeControls.bind(index, range);
+ viewer.setSignalSlice(signalVisible.checked ? value : null, config);
+ legend.hidden = false;
+ required("signal-legend-title").textContent = `Signal ${index}`;
+ required("signal-legend-min").textContent =
+ range.minimum === null ? "—" : formatNumber(range.minimum);
+ required("signal-legend-max").textContent =
+ range.maximum === null ? "—" : formatNumber(range.maximum);
+ updateRangeLegend("signal-legend", range);
}
function detail(label: string, value: string): HTMLDivElement {
@@ -233,6 +289,9 @@ function presentScene(
): void {
if (newDataset) {
presentation.beginDataset();
+ scalarRanges.beginDataset();
+ speciesRangeControls.beginDataset();
+ signalRangeControls.beginDataset();
viewer.beginDataset();
}
const display = presentation.forFrame(next);
diff --git a/viewer/src/scalar-range-controls.ts b/viewer/src/scalar-range-controls.ts
new file mode 100644
index 0000000..3e90aa2
--- /dev/null
+++ b/viewer/src/scalar-range-controls.ts
@@ -0,0 +1,134 @@
+import {
+ DatasetScalarRanges,
+ parseFixedScalarRange,
+ suggestedFixedRange,
+ type ResolvedScalarRange,
+ type ScalarChannelKind,
+} from "./scalar-range";
+
+/** Reusable keyboard-accessible editor; draft text never changes the active range. */
+export class ScalarRangeControls {
+ private readonly mode: HTMLSelectElement;
+ private readonly fixedFields: HTMLElement;
+ private readonly minimum: HTMLInputElement;
+ private readonly maximum: HTMLInputElement;
+ private readonly message: HTMLElement;
+ private channel: number | null = null;
+ private resolved: ResolvedScalarRange | null = null;
+
+ public constructor(
+ root: HTMLElement,
+ private readonly ranges: DatasetScalarRanges,
+ private readonly kind: ScalarChannelKind,
+ private readonly onChange: () => void,
+ ) {
+ const label = kind === "species" ? "Species" : "Signal";
+ const form = document.createElement("form");
+ form.noValidate = true;
+ form.className = "scalar-range-control";
+ form.setAttribute("aria-label", `${label} concentration range`);
+ const modeLabel = document.createElement("label");
+ modeLabel.className = "field";
+ const modeText = document.createElement("span");
+ modeText.textContent = "Color range";
+ this.mode = document.createElement("select");
+ this.mode.setAttribute("aria-label", `${label} range mode`);
+ for (const [value, text] of [
+ ["automatic", "Automatic"],
+ ["fixed", "Fixed"],
+ ] as const) {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = text;
+ this.mode.append(option);
+ }
+ modeLabel.append(modeText, this.mode);
+ this.fixedFields = document.createElement("div");
+ this.fixedFields.className = "scalar-range-fields";
+ const input = (title: string): HTMLInputElement => {
+ const labelElement = document.createElement("label");
+ labelElement.className = "field";
+ const text = document.createElement("span");
+ text.textContent = title;
+ const element = document.createElement("input");
+ element.type = "text";
+ element.inputMode = "decimal";
+ element.setAttribute("aria-label", `${label} ${title.toLowerCase()}`);
+ element.setAttribute("aria-describedby", `${root.id}-message`);
+ labelElement.append(text, element);
+ this.fixedFields.append(labelElement);
+ return element;
+ };
+ this.minimum = input("Minimum");
+ this.maximum = input("Maximum");
+ const apply = document.createElement("button");
+ apply.type = "submit";
+ apply.className = "button button-secondary";
+ apply.textContent = "Apply range";
+ this.message = document.createElement("p");
+ this.message.id = `${root.id}-message`;
+ this.message.className = "scalar-range-message";
+ this.message.setAttribute("role", "status");
+ this.fixedFields.append(apply, this.message);
+ form.append(modeLabel, this.fixedFields);
+ root.append(form);
+ this.mode.addEventListener("change", () => {
+ if (this.channel === null || this.resolved === null) return;
+ const config =
+ this.mode.value === "automatic"
+ ? { mode: "automatic" as const }
+ : (this.ranges.lastFixed(this.kind, this.channel) ??
+ suggestedFixedRange(this.resolved));
+ this.ranges.set(this.kind, this.channel, config);
+ this.syncFields();
+ this.onChange();
+ });
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ if (this.channel === null || this.mode.value !== "fixed") return;
+ try {
+ const config = parseFixedScalarRange(
+ this.minimum.value,
+ this.maximum.value,
+ );
+ this.ranges.set(this.kind, this.channel, config);
+ this.syncFields();
+ this.onChange();
+ } catch (error) {
+ this.message.textContent = `${error instanceof Error ? error.message : String(error)} The previous range remains active.`;
+ this.message.dataset.error = "true";
+ this.minimum.setAttribute("aria-invalid", "true");
+ this.maximum.setAttribute("aria-invalid", "true");
+ }
+ });
+ }
+
+ public beginDataset(): void {
+ this.channel = null;
+ this.resolved = null;
+ }
+
+ public bind(channel: number, resolved: ResolvedScalarRange): void {
+ this.resolved = resolved;
+ if (this.channel !== channel) {
+ this.channel = channel;
+ this.syncFields();
+ }
+ }
+
+ private syncFields(): void {
+ if (this.channel === null || this.resolved === null) return;
+ const active = this.ranges.get(this.kind, this.channel);
+ const fixed =
+ this.ranges.lastFixed(this.kind, this.channel) ??
+ suggestedFixedRange(this.resolved);
+ this.mode.value = active.mode;
+ this.fixedFields.hidden = active.mode !== "fixed";
+ this.minimum.value = String(fixed.minimum);
+ this.maximum.value = String(fixed.maximum);
+ this.minimum.removeAttribute("aria-invalid");
+ this.maximum.removeAttribute("aria-invalid");
+ this.message.textContent = "Apply or press Enter to use edited bounds.";
+ delete this.message.dataset.error;
+ }
+}
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/src/style.css b/viewer/src/style.css
index 2b36f98..9f38396 100644
--- a/viewer/src/style.css
+++ b/viewer/src/style.css
@@ -722,6 +722,48 @@ dd {
}
}
+.scalar-range-fields {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 8px;
+}
+
+.scalar-range-fields input {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--line-strong);
+ border-radius: 6px;
+ padding: 7px 8px;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 12px;
+}
+
+.scalar-range-fields input[aria-invalid="true"] {
+ border-color: var(--danger);
+}
+
+.scalar-range-fields > button,
+.scalar-range-message {
+ grid-column: 1 / -1;
+}
+
+.scalar-range-message,
+.legend-caption {
+ margin: 0;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.scalar-range-message[data-error="true"] {
+ color: var(--danger);
+}
+
+.legend-caption {
+ margin-block: 6px;
+}
+
.device-visibility {
display: flex;
align-items: center;
diff --git a/viewer/tests/color.test.ts b/viewer/tests/color.test.ts
index 5ec1f98..5de12ad 100644
--- a/viewer/tests/color.test.ts
+++ b/viewer/tests/color.test.ts
@@ -64,6 +64,47 @@ describe("cell color mappings", () => {
expect(mapping.colors[2]).toEqual(viridis(0.5));
});
+ it("uses the selected fixed range across frames without changing cell values", () => {
+ const config = {
+ mode: "species" as const,
+ speciesIndex: 0,
+ range: { mode: "fixed" as const, minimum: 0, maximum: 10 },
+ };
+ const initial = mapCellColors(frame, config);
+ const later = {
+ ...frame,
+ cells: [cell(0, 0, 0, [2, 8]), cell(1, 0, 0, [40, 6])],
+ };
+ const mapping = mapCellColors(later, config);
+ expect(initial.colors[0]).toEqual(mapping.colors[0]);
+ expect(mapping.colors[1]).toEqual(viridis(1));
+ expect(mapping.range).toEqual({
+ mode: "fixed",
+ minimum: 0,
+ maximum: 10,
+ count: 2,
+ });
+ expect(later.cells[1]?.species[0]).toBe(40);
+ });
+
+ it("reports a configured fixed scale but zero values for an empty frame", () => {
+ const mapping = mapCellColors(
+ { ...frame, cells: [] },
+ {
+ mode: "species",
+ speciesIndex: 0,
+ range: { mode: "fixed", minimum: -2, maximum: 2 },
+ },
+ );
+ expect(mapping.colors).toEqual([]);
+ expect(mapping.range).toEqual({
+ mode: "fixed",
+ minimum: -2,
+ maximum: 2,
+ count: 0,
+ });
+ });
+
it("rejects an unavailable species channel", () => {
expect(() =>
mapCellColors(frame, { mode: "species", speciesIndex: 2 }),
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" });
+ });
+});