Repository: allen-cell-animated/timelapse-colorizer
Pull request #601: refactor: Make Canvas interface, simplify ColorizeCanvas API (canvas pt. 1)
Author: login: ShrimpCryptid
Labels: name: internals
description: Tech debt, refactoring, dependencies, etc.
Created: 2025-03-21T18:30:27Z | Merged: 2025-03-31T22:54:08Z

Problem
=======
Part 1 of 2ish for #560, "Refactor CanvasOverlay to be its own class, make generic Canvas."

This change makes the new `IRenderCanvas`, a new interface for the `ColorizeCanvas` class. Eventually, the plan is for the new 3D canvas renderer to also implement `IRenderCanvas`. This will also make it easier to refactor `CanvasWithOverlay` in the next PR.

*Estimated review size: large, 40 minutes*

Solution
========
- Creates the new `IRenderCanvas` interface.
- Refactors `ColorizeCanvas` with the new `setParams` method, which accepts a subset of the `ViewerStoreState`.
- Removes unused setters from `CanvasWrapper.tsx`.

Steps to Verify:
----------------
1. Open preview link: https://allen-cell-animated.github.io/timelapse-colorizer/pr-preview/pr-601/
2. Change canvas settings like the currently selected track, color ramp, feature, time, etc. Everything should work as it did before.

Screenshots (optional):
-----------------------

**Video talk-through (🔊):**

https://github.com/user-attachments/assets/6c66c4d6-780f-4f40-a9e8-96bccd66e7b9

Second video with an overview of the changes:

https://github.com/user-attachments/assets/f7693882-e13d-4677-b03c-537bce3fb519


Keyfiles:
-----------------------
1. `IRenderCanvas.ts`
3. `ColorizeCanvas.ts`




Diff:
diff --git a/src/Viewer.tsx b/src/Viewer.tsx
index 68306d6ed..9573ffca0 100644
--- a/src/Viewer.tsx
+++ b/src/Viewer.tsx
@@ -367,7 +367,6 @@ function Viewer(): ReactElement {
 
       // State updates
       setDataset(newDatasetKey, newDataset);
-      await canv.setDataset(newDataset);
 
       setDatasetOpen(true);
       console.log("Dataset metadata:", newDataset.metadata);
diff --git a/src/colorizer/CanvasWithOverlay.ts b/src/colorizer/CanvasWithOverlay.ts
index e578467fb..f04c33f44 100644
--- a/src/colorizer/CanvasWithOverlay.ts
+++ b/src/colorizer/CanvasWithOverlay.ts
@@ -29,6 +29,7 @@ import { getPixelRatio } from "./canvas/utils";
 import { LabelData } from "./AnnotationData";
 import Collection from "./Collection";
 import ColorizeCanvas from "./ColorizeCanvas";
+import ColorRamp from "./ColorRamp";
 
 /**
  * Extends the ColorizeCanvas class by overlaying and compositing additional
@@ -127,10 +128,11 @@ export default class CanvasWithOverlay extends ColorizeCanvas {
     return this.canvas;
   }
 
-  public setSize(width: number, height: number): void {
+  public setResolution(width: number, height: number): void {
     this.canvasSize.x = width;
     this.canvasSize.y = height;
-    super.setSize(width, height);
+    super.setResolution(width, height);
+    this.render();
   }
 
   public getIdAtPixel(x: number, y: number): number {
@@ -194,9 +196,9 @@ export default class CanvasWithOverlay extends ColorizeCanvas {
     return {
       canvasSize: this.canvasSize,
       collection: this.collection,
-      dataset: this.dataset,
+      dataset: this.params?.dataset || null,
       datasetKey: this.datasetKey,
-      featureKey: this.featureKey,
+      featureKey: this.params?.featureKey || null,
     };
   }
 
@@ -209,7 +211,7 @@ export default class CanvasWithOverlay extends ColorizeCanvas {
       selectedLabelIdx: this.selectedLabelIdx,
       lastSelectedId: this.lastClickedId,
       frameToCanvasCoordinates: this.frameToCanvasCoordinates,
-      frame: this.getCurrentFrame(),
+      frame: this.currentFrame,
       panOffset: this.panOffset,
     };
     return getAnnotationRenderer(this.ctx, params, this.annotationStyle);
@@ -228,7 +230,7 @@ export default class CanvasWithOverlay extends ColorizeCanvas {
     const params: FooterParams = {
       ...baseParams,
       visible,
-      timestamp: { ...baseParams, currentFrame: this.getCurrentFrame(), visible: this.isTimestampVisible },
+      timestamp: { ...baseParams, currentFrame: this.currentFrame, visible: this.isTimestampVisible },
       timestampStyle: this.timestampStyle,
       scaleBar: {
         ...baseParams,
@@ -239,10 +241,10 @@ export default class CanvasWithOverlay extends ColorizeCanvas {
       insetBoxStyle: this.insetBoxStyle,
       legend: {
         ...baseParams,
-        colorRamp: this.colorRamp,
+        colorRamp: this.params?.colorRamp || new ColorRamp(["white"]),
         categoricalPalette: this.categoricalPalette,
-        colorMapRangeMin: this.colorMapRangeMin,
-        colorMapRangeMax: this.colorMapRangeMax,
+        colorMapRangeMin: this.params?.colorRampRange[0] || 0,
+        colorMapRangeMax: this.params?.colorRampRange[1] || 1,
       },
       legendStyle: this.legendStyle,
     };
diff --git a/src/colorizer/ColorizeCanvas.ts b/src/colorizer/ColorizeCanvas.ts
index 891ea134c..a3e178dbb 100644
--- a/src/colorizer/ColorizeCanvas.ts
+++ b/src/colorizer/ColorizeCanvas.ts
@@ -20,6 +20,7 @@ import {
   WebGLRenderer,
   WebGLRenderTarget,
 } from "three";
+import { clamp } from "three/src/math/MathUtils";
 
 import { MAX_FEATURE_CATEGORIES } from "../constants";
 import {
@@ -28,11 +29,13 @@ import {
   OUTLIER_COLOR_DEFAULT,
   OUTLINE_COLOR_DEFAULT,
 } from "./constants";
-import { DrawMode, FeatureDataType, VectorConfig } from "./types";
+import { DrawMode, FeatureDataType } from "./types";
+import { hasPropertyChanged } from "./utils/data_utils";
 import { packDataTexture } from "./utils/texture_utils";
 
 import ColorRamp from "./ColorRamp";
 import Dataset from "./Dataset";
+import { IRenderCanvas, RenderCanvasStateParams } from "./IRenderCanvas";
 import Track from "./Track";
 import VectorField from "./VectorField";
 
@@ -117,7 +120,7 @@ const getDefaultUniforms = (): ColorizeUniforms => {
   };
 };
 
-export default class ColorizeCanvas {
+export default class ColorizeCanvas implements IRenderCanvas {
   private geometry: PlaneGeometry;
   private material: ShaderMaterial;
   private pickMaterial: ShaderMaterial;
@@ -128,7 +131,6 @@ export default class ColorizeCanvas {
   // Rendered track line that shows the trajectory of a cell.
   private line: Line;
   private points: Float32Array;
-  private showTrackPath: boolean;
 
   protected frameSizeInCanvasCoordinates: Vector2;
   protected frameToCanvasCoordinates: Vector2;
@@ -152,17 +154,15 @@ export default class ColorizeCanvas {
   private renderer: WebGLRenderer;
   private pickRenderTarget: WebGLRenderTarget;
 
-  protected dataset: Dataset | null;
-  protected track: Track | null;
-  protected canvasResolution: Vector2 | null;
-
-  protected featureKey: string | null;
-  protected selectedBackdropKey: string | null;
-  protected colorRamp: ColorRamp;
-  protected colorMapRangeMin: number;
-  protected colorMapRangeMax: number;
+  protected params: RenderCanvasStateParams | null;
+  // Categorical palette is stored separately from params because it's not a
+  // direct part of state, and the ColorRamp class must be disposed and
+  // recreated when the palette changes.
   protected categoricalPalette: ColorRamp;
-  private currentFrame: number;
+
+  protected canvasResolution: Vector2;
+
+  protected currentFrame: number;
   private pendingFrame: number;
 
   private onFrameChangeCallback: (isMissing: boolean) => void;
@@ -221,17 +221,10 @@ export default class ColorizeCanvas {
     this.renderer = new WebGLRenderer({ antialias: true });
     this.checkPixelRatio();
 
-    this.dataset = null;
-    this.canvasResolution = null;
-    this.featureKey = null;
-    this.selectedBackdropKey = null;
-    this.colorRamp = new ColorRamp(["black"]);
-    this.categoricalPalette = new ColorRamp(["black"]);
+    this.params = null;
 
-    this.track = null;
-    this.showTrackPath = false;
-    this.colorMapRangeMin = 0;
-    this.colorMapRangeMax = 0;
+    this.canvasResolution = new Vector2(1, 1);
+    this.categoricalPalette = new ColorRamp(["black"]);
     this.currentFrame = 0;
     this.pendingFrame = -1;
 
@@ -243,14 +236,20 @@ export default class ColorizeCanvas {
     this.onFrameChangeCallback = () => {};
 
     this.render = this.render.bind(this);
-    this.getCurrentFrame = this.getCurrentFrame.bind(this);
-    this.setOutOfRangeDrawMode = this.setOutOfRangeDrawMode.bind(this);
     this.updateScaling = this.updateScaling.bind(this);
-    this.isValidFrame = this.isValidFrame.bind(this);
     this.setFrame = this.setFrame.bind(this);
   }
 
-  get domElement(): HTMLCanvasElement {
+  private setUniform<U extends keyof ColorizeUniformTypes>(name: U, value: ColorizeUniformTypes[U]): void {
+    this.material.uniforms[name].value = value;
+    this.pickMaterial.uniforms[name].value = value;
+  }
+
+  public get resolution(): Vector2 {
+    return this.canvasResolution;
+  }
+
+  public get domElement(): HTMLCanvasElement {
     return this.renderer.domElement;
   }
 
@@ -260,7 +259,7 @@ export default class ColorizeCanvas {
     }
   }
 
-  setSize(width: number, height: number): void {
+  public setResolution(width: number, height: number): void {
     this.checkPixelRatio();
 
     this.renderer.setSize(width, height);
@@ -269,15 +268,15 @@ export default class ColorizeCanvas {
     this.pickRenderTarget.setSize(width, height);
 
     this.canvasResolution = new Vector2(width, height);
-    if (this.dataset) {
-      this.updateScaling(this.dataset.frameResolution, this.canvasResolution);
+    if (this.params?.dataset) {
+      this.updateScaling(this.params.dataset.frameResolution, this.canvasResolution);
     }
   }
 
-  setZoom(zoom: number): void {
+  public setZoom(zoom: number): void {
     this.zoomMultiplier = zoom;
-    if (this.dataset) {
-      this.updateScaling(this.dataset.frameResolution, this.canvasResolution);
+    if (this.params?.dataset) {
+      this.updateScaling(this.params.dataset.frameResolution, this.canvasResolution);
     }
     this.render();
   }
@@ -287,7 +286,7 @@ export default class ColorizeCanvas {
    * Expects x and y in a range of [-0.5, 0.5], where [0, 0] means the frame will be centered
    * and [-0.5, -0.5] means the top right corner of the frame will be centered in the canvas view.
    */
-  setPan(x: number, y: number): void {
+  public setPan(x: number, y: number): void {
     this.panOffset = new Vector2(x, y);
     this.setUniform("panOffset", this.panOffset);
 
@@ -346,50 +345,36 @@ export default class ColorizeCanvas {
     this.vectorField.setScale(this.frameToCanvasCoordinates, this.canvasResolution || new Vector2(1, 1));
   }
 
-  public async setDataset(dataset: Dataset): Promise<void> {
-    if (this.dataset !== null) {
-      this.dataset.dispose();
+  /**
+   * Forces a reload of the current frame, even if it's already loaded. If a
+   * different frame is in the process of being loaded, the reload will be
+   * called on that pending frame.
+   */
+  private forceFrameReload(): void {
+    // Force update on the current frame or the frame that's currently being loaded
+    const frame = this.pendingFrame !== -1 ? this.pendingFrame : this.currentFrame;
+    this.setFrame(frame, true).then(() => {
+      this.render();
+    });
+  }
+
+  // PARAM HANDLING /////////////////////////////////////////////////////////////////////////
+
+  private async handleNewDataset(dataset: Dataset | null): Promise<void> {
+    if (dataset === null) {
+      return;
     }
-    this.dataset = dataset;
-    if (this.dataset.outliers) {
-      this.setUniform("outlierData", packDataTexture(Array.from(this.dataset.outliers), FeatureDataType.U8));
+    if (dataset.outliers) {
+      this.setUniform("outlierData", packDataTexture(Array.from(dataset.outliers), FeatureDataType.U8));
     } else {
       this.setUniform("outlierData", packDataTexture([0], FeatureDataType.U8));
     }
-
-    const frame = this.currentFrame;
-    this.currentFrame = -1;
-    this.pendingFrame = -1;
-    await this.setFrame(frame);
-    if (this.featureKey !== null) {
-      this.setFeatureKey(this.featureKey);
-    }
-
-    this.updateScaling(this.dataset.frameResolution, this.canvasResolution);
     this.vectorField.setDataset(dataset);
-    this.render();
-  }
-
-  private setUniform<U extends keyof ColorizeUniformTypes>(name: U, value: ColorizeUniformTypes[U]): void {
-    this.material.uniforms[name].value = value;
-    this.pickMaterial.uniforms[name].value = value;
-  }
-
-  /** Sets the current color ramp. Used when a continuous or discrete feature is selected. */
-  setColorRamp(ramp: ColorRamp): void {
-    if (this.colorRamp !== ramp) {
-      // Dispose of existing ramp
-      this.colorRamp.dispose();
-    }
-    this.colorRamp = ramp;
+    await this.forceFrameReload();
+    this.updateScaling(dataset.frameResolution, this.canvasResolution);
   }
 
-  setCategoricalColors(colors: Color[]): void {
-    this.categoricalPalette.dispose();
-    this.categoricalPalette = new ColorRamp(colors);
-  }
-
-  setOutlineColor(color: Color): void {
+  private setOutlineColor(color: Color): void {
     this.setUniform("outlineColor", color);
 
     // Update line color
@@ -402,43 +387,31 @@ export default class ColorizeCanvas {
     }
   }
 
-  setBackgroundColor(color: Color): void {
+  public setBackgroundColor(color: Color): void {
     this.setUniform("backgroundColor", color);
   }
 
   /** Set the color of the area outside the frame in the canvas. */
-  setCanvasBackgroundColor(color: Color): void {
+  public setCanvasBackgroundColor(color: Color): void {
     this.setUniform("canvasBackgroundColor", color);
   }
 
-  setOutlierDrawMode(mode: DrawMode, color?: Color): void {
+  private setOutlierDrawMode(mode: DrawMode, color: Color): void {
     this.setUniform("outlierDrawMode", mode);
-    if (mode === DrawMode.USE_COLOR && color) {
+    if (mode === DrawMode.USE_COLOR) {
       this.setUniform("outlierColor", color);
     }
   }
 
-  setOutOfRangeDrawMode(mode: DrawMode, color?: Color): void {
+  private setOutOfRangeDrawMode(mode: DrawMode, color: Color): void {
     this.setUniform("outOfRangeDrawMode", mode);
-    if (mode === DrawMode.USE_COLOR && color) {
+    if (mode === DrawMode.USE_COLOR) {
       this.setUniform("outOfRangeColor", color);
     }
   }
 
-  setVectorData(vectorData: Float32Array | null): void {
-    this.vectorField.setVectorData(vectorData);
-  }
-
-  setVectorFieldConfig(config: VectorConfig): void {
-    this.vectorField.setConfig(config);
-  }
-
-  setSelectedTrack(track: Track | null): void {
-    if (this.track && this.track?.trackId === track?.trackId) {
-      return;
-    }
-    this.track = track;
-    if (!track || !track.centroids || track.centroids.length === 0 || !this.dataset) {
+  private updateTrackData(dataset: Dataset | null, track: Track | null): void {
+    if (!track || !track.centroids || track.centroids.length === 0 || !dataset) {
       return;
     }
     // Make a new array of the centroid positions in pixel coordinates.
@@ -459,143 +432,125 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
-  }
-
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
   }
 
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
-  }
-
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
   }
 
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
-
-  setInRangeLUT(inRangeLUT: Uint8Array): void {
+  private setInRangeLUT(inRangeLUT: Uint8Array): void {
     // Save the array to a texture and pass it into the shader
     if (inRangeLUT.length > 0) {
       this.setUniform("inRangeIds", packDataTexture(Array.from(inRangeLUT), FeatureDataType.U8));
-      this.render();
     }
   }
 
-  getColorMapRangeMin(): number {
-    return this.colorMapRangeMin;
-  }
-
-  getColorMapRangeMax(): number {
-    return this.colorMapRangeMax;
-  }
-
-  /**
-   * @returns The number of frames in the dataset. If no dataset is loaded,
-   * returns 0 by default.
-   */
-  getTotalFrames(): number {
-    return this.dataset ? this.dataset.numberOfFrames : 0;
-  }
-
-  getCurrentFrame(): number {
-    return this.currentFrame;
+  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
+    this.onFrameChangeCallback = callback;
   }
 
-  public isValidFrame(index: number): boolean {
-    return index >= 0 && index < this.getTotalFrames();
-  }
+  public setParams(params: RenderCanvasStateParams): void {
+    // TODO: What happens when `setParams` is called again while waiting for a Dataset to load?
+    // May cause visual desync where the color ramp/feature data updates before frames load in fully
+    if (this.params === params) {
+      return;
+    }
+    const prevParams = this.params;
+    this.params = params;
+    // Update dataset and array data
+    if (hasPropertyChanged(params, prevParams, ["dataset"])) {
+      this.handleNewDataset(params.dataset);
+    }
+    if (hasPropertyChanged(params, prevParams, ["dataset", "featureKey"])) {
+      this.updateFeatureData(params.dataset, params.featureKey);
+    }
+    if (hasPropertyChanged(params, prevParams, ["dataset", "track"])) {
+      this.updateTrackData(params.dataset, params.track);
+    }
+    if (hasPropertyChanged(params, prevParams, ["inRangeLUT"])) {
+      this.setInRangeLUT(params.inRangeLUT);
+    }
 
-  public setObjectOpacity(percentOpacity: number): void {
-    percentOpacity = Math.max(0, Math.min(100, percentOpacity));
-    this.setUniform("objectOpacity", percentOpacity / 100);
-  }
+    // Basic rendering settings
+    if (hasPropertyChanged(params, prevParams, ["outlierDrawSettings"])) {
+      this.setOutlierDrawMode(params.outlierDrawSettings.mode, params.outlierDrawSettings.color);
+    }
+    if (hasPropertyChanged(params, prevParams, ["outOfRangeDrawSettings"])) {
+      this.setOutOfRangeDrawMode(params.outOfRangeDrawSettings.mode, params.outOfRangeDrawSettings.color);
+    }
+    if (hasPropertyChanged(params, prevParams, ["outlineColor"])) {
+      this.setOutlineColor(params.outlineColor);
+    }
 
-  public setBackdropSaturation(percentSaturation: number): void {
-    percentSaturation = Math.max(0, Math.min(100, percentSaturation));
-    this.setUniform("backdropSaturation", percentSaturation / 100);
-  }
+    // Update vector data
+    if (hasPropertyChanged(params, prevParams, ["vectorVisible", "vectorColor", "vectorScaleFactor"])) {
+      this.vectorField.setConfig({
+        color: params.vectorColor,
+        scaleFactor: params.vectorScaleFactor,
+        visible: params.vectorVisible,
+      });
+    }
+    if (hasPropertyChanged(params, prevParams, ["vectorMotionDeltas"])) {
+      this.vectorField.setVectorData(params.vectorMotionDeltas);
+    }
 
-  public setBackdropBrightness(percentBrightness: number): void {
-    percentBrightness = Math.max(0, Math.min(200, percentBrightness));
-    this.setUniform("backdropBrightness", percentBrightness / 100);
-  }
+    // Backdrops
+    if (hasPropertyChanged(params, prevParams, ["backdropKey", "backdropVisible"])) {
+      this.forceFrameReload();
+    }
+    if (hasPropertyChanged(params, prevParams, ["backdropVisible", "objectOpacity"])) {
+      if (params.backdropVisible) {
+        this.setUniform("objectOpacity", clamp(params.objectOpacity, 0, 100) / 100);
+      } else {
+        this.setUniform("objectOpacity", 1.0);
+      }
+    }
+    this.setUniform("backdropSaturation", clamp(params.backdropSaturation, 0, 100) / 100);
+    this.setUniform("backdropBrightness", clamp(params.backdropBrightness, 0, 200) / 100);
 
-  public setBackdropKey(key: string | null): void {
-    if (this.selectedBackdropKey === key) {
-      return;
+    // Update color ramp  + palette
+    if (hasPropertyChanged(params, prevParams, ["categoricalPalette"])) {
+      this.categoricalPalette.dispose();
+      this.categoricalPalette = new ColorRamp(params.categoricalPalette);
+    }
+    if (
+      hasPropertyChanged(params, prevParams, [
+        "dataset",
+        "featureKey",
+        "colorRamp",
+        "colorRampRange",
+        "categoricalPalette",
+      ])
+    ) {
+      if (params.dataset !== null && params.featureKey !== null) {
+        const isFeatureCategorical = params.dataset.isFeatureCategorical(params.featureKey);
+        if (isFeatureCategorical) {
+          this.setUniform("colorRamp", this.categoricalPalette.texture);
+          this.setUniform("featureColorRampMin", 0);
+          this.setUniform("featureColorRampMax", MAX_FEATURE_CATEGORIES - 1);
+        } else {
+          this.setUniform("colorRamp", params.colorRamp.texture);
+          this.setUniform("featureColorRampMin", params.colorRampRange[0]);
+          this.setUniform("featureColorRampMax", params.colorRampRange[1]);
+        }
+      }
     }
-    this.selectedBackdropKey = key;
-    // Force update on the current frame or the frame that's currently being loaded
-    const frame = this.pendingFrame !== -1 ? this.pendingFrame : this.currentFrame;
-    this.setFrame(frame, true).then(() => {
-      this.render();
-    });
-  }
 
-  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
-    this.onFrameChangeCallback = callback;
+    this.render();
   }
 
   /**
@@ -605,26 +560,27 @@ export default class ColorizeCanvas {
    * @param forceUpdate Force a reload of the frame data, even if the frame
    * is already loaded.
    */
-  async setFrame(index: number, forceUpdate: boolean = false): Promise<void> {
+  public async setFrame(index: number, forceUpdate: boolean = false): Promise<void> {
+    const dataset = this.params?.dataset;
     // Ignore same or bad frame indices
-    if ((!forceUpdate && this.currentFrame === index) || !this.isValidFrame(index)) {
+    if (!dataset || (!forceUpdate && this.currentFrame === index) || !dataset.isValidFrameIndex(index)) {
       return;
     }
-    // Save loading settings
-    const pendingDataset = this.dataset;
-    // New frame, so load the frame data.
+    // Load the frame data asynchronously.
+    // Save loading settings to prevent race conditions.
+    const pendingDataset = dataset;
     this.pendingFrame = index;
-    const loadedBackdropKey = this.selectedBackdropKey;
+    const pendingBackdropKey = this.params?.backdropKey ?? null;
     let backdropPromise = undefined;
-    if (this.selectedBackdropKey && this.dataset?.hasBackdrop(this.selectedBackdropKey)) {
-      backdropPromise = this.dataset?.loadBackdrop(this.selectedBackdropKey, index);
+    if (this.params?.backdropVisible && pendingBackdropKey !== null && dataset?.hasBackdrop(pendingBackdropKey)) {
+      backdropPromise = dataset?.loadBackdrop(pendingBackdropKey, index);
     }
-    const framePromise = this.dataset?.loadFrame(index);
+    const framePromise = dataset?.loadFrame(index);
     const result = await Promise.allSettled([framePromise, backdropPromise]);
     const [frame, backdrop] = result;
 
     const isForcingUpdateOnLoadedFrame = this.pendingFrame === -1 && this.currentFrame === index && forceUpdate;
-    if ((!isForcingUpdateOnLoadedFrame && this.pendingFrame !== index) || this.dataset !== pendingDataset) {
+    if ((!isForcingUpdateOnLoadedFrame && this.pendingFrame !== index) || this.params?.dataset !== pendingDataset) {
       // Drop the request if:
       // - A different frame number has been requested since the load started
       //   (and it's not the loaded frame being force-reloaded)
@@ -635,7 +591,7 @@ export default class ColorizeCanvas {
     let isMissingFile = false;
 
     if (backdrop.status === "fulfilled" && backdrop.value) {
-      if (this.selectedBackdropKey === loadedBackdropKey) {
+      if (this.params?.backdropKey === pendingBackdropKey) {
         // Only update the backdrop if the selected key is the one we requested
         this.setUniform("backdrop", backdrop.value);
       }
@@ -643,13 +599,10 @@ export default class ColorizeCanvas {
       if (backdrop.status === "rejected") {
         // Only show error message if the backdrop load encountered an error (null/undefined backdrops aren't
         // considered errors, since that means the path has been deliberately marked as missing.)
-        console.error(
-          "Failed to load backdrop " + this.selectedBackdropKey + " for frame " + index + ": ",
-          backdrop.reason
-        );
+        console.error("Failed to load backdrop " + pendingBackdropKey + " for frame " + index + ": ", backdrop.reason);
         isMissingFile = true;
       }
-      if (this.selectedBackdropKey === loadedBackdropKey) {
+      if (this.params?.backdropKey === pendingBackdropKey) {
         // Only clear the backdrop if the selected key (null) is the one we requested
         this.setUniform("backdrop", new DataTexture(new Uint8Array([0, 0, 0, 0]), 1, 1, RGBAFormat, UnsignedByteType));
       }
@@ -672,45 +625,62 @@ export default class ColorizeCanvas {
 
     this.onFrameChangeCallback(isMissingFile);
     // Force rescale in case frame dimensions changed
-    this.updateScaling(this.dataset?.frameResolution || null, this.canvasResolution);
+    this.updateScaling(dataset?.frameResolution || null, this.canvasResolution);
     this.currentFrame = index;
     this.pendingFrame = -1;
     this.vectorField.setFrame(this.currentFrame);
+    this.render();
   }
 
-  /** Switches the coloring between the categorical and color ramps depending on the currently
-   * selected feature.
+  // RENDERING /////////////////////////////////////////////////////////////////////////////
+
+  /**
+   * Updates the range of the track path line so that it shows up the path up to the current
+   * frame.
    */
-  updateRamp(): void {
-    if (this.featureKey && this.dataset?.isFeatureCategorical(this.featureKey)) {
-      this.setUniform("colorRamp", this.categoricalPalette.texture);
-      this.setUniform("featureColorRampMin", 0);
-      this.setUniform("featureColorRampMax", MAX_FEATURE_CATEGORIES - 1);
-    } else {
-      this.setUniform("colorRamp", this.colorRamp.texture);
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
+  private syncTrackPathLine(): void {
+    // Show nothing if track doesn't exist or doesn't have centroid data
+    const track = this.params?.track;
+    if (!track || !track.centroids || !this.params?.showTrackPath) {
+      this.line.geometry.setDrawRange(0, 0);
+      return;
+    }
+
+    // Show path up to current frame
+    let range = this.currentFrame - track.startTime() + 1;
+
+    if (range > track.duration() || range < 0) {
+      // Hide track if we are outside the track range
+      range = 0;
+    }
+
+    this.line.geometry.setDrawRange(0, range);
+  }
+
+  private syncHighlightedId(): void {
+    // Hide highlight if no track is selected
+    if (!this.params?.track) {
+      this.setUniform("highlightedId", -1);
+      return;
     }
+    this.setUniform("highlightedId", this.params?.track.getIdAtTime(this.currentFrame));
   }
 
-  render(): void {
-    this.updateHighlightedId();
-    this.updateTrackRange();
-    this.updateRamp();
+  public render(): void {
+    this.syncHighlightedId();
+    this.syncTrackPathLine();
 
     this.renderer.render(this.scene, this.camera);
   }
 
-  dispose(): void {
-    this.dataset?.dispose();
-    this.dataset = null;
+  public dispose(): void {
     this.material.dispose();
     this.geometry.dispose();
     this.renderer.dispose();
     this.pickMaterial.dispose();
   }
 
-  getIdAtPixel(x: number, y: number): number {
+  public getIdAtPixel(x: number, y: number): number {
     const rt = this.renderer.getRenderTarget();
 
     this.renderer.setRenderTarget(this.pickRenderTarget);
diff --git a/src/colorizer/Dataset.ts b/src/colorizer/Dataset.ts
index 9f1e8bac4..7324ed36d 100644
--- a/src/colorizer/Dataset.ts
+++ b/src/colorizer/Dataset.ts
@@ -537,6 +537,14 @@ export default class Dataset {
     return this.times?.[index] || 0;
   }
 
+  public getTotalFrames(): number {
+    return this.frameFiles.length;
+  }
+
+  public isValidFrameIndex(index: number): boolean {
+    return index >= 0 && index < this.getTotalFrames();
+  }
+
   /** get track id of a given cell id */
   public getTrackId(index: number): number {
     return this.trackIds?.[index] || 0;
diff --git a/src/colorizer/IRenderCanvas.ts b/src/colorizer/IRenderCanvas.ts
new file mode 100644
index 000000000..9392cc99e
--- /dev/null
+++ b/src/colorizer/IRenderCanvas.ts
@@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
+] as const;
+
+export type RenderCanvasStateParams = Pick<ViewerStoreState, (typeof canvasStateDeps)[number]>;
+
+export const renderCanvasStateParamsSelector = (state: ViewerStoreState): RenderCanvasStateParams => {
+  const entries = canvasStateDeps.map((key) => [key, state[key]]);
+  return Object.fromEntries(entries);
+};
+
+/**
+ * A canvas that renders timelapse data.
+ */
+export interface IRenderCanvas {
+  get domElement(): HTMLCanvasElement;
+  /** (X,Y) resolution of the canvas, in pixels. */
+  get resolution(): Vector2;
+  setResolution(width: number, height: number): void;
+  /**
+   * Updates the parameters used to configure the canvas view. See
+   * `CanvasStateParams` for a complete list of parameters required.
+   */
+  setParams(params: RenderCanvasStateParams): void;
+  // TODO: Have `setFrame` report additional information about the frame
+  // such as the frame number and whether the file was missing/load failed.
+  // This is currently handled through `loadFrameCallback`.
+  /**
+   * Loads and renders the data for a specific frame. Returns a Promise that
+   * resolves when the data is loaded and rendered onscreen.
+   * @param frame The frame number to load and render. Ignores frames that are
+   * out of range of the current dataset or that are already loaded.
+   */
+  setFrame(frame: number): Promise<void>;
+  render(): void;
+  /**
+   * Disposes of the canvas and its resources.
+   */
+  dispose(): void;
+  /**
+   * Gets the ID of the segmentation at a pixel coordinate in the canvas, where
+   * `(0,0)` is the top left corner.
+   */
+  getIdAtPixel(x: number, y: number): number;
+}
diff --git a/src/colorizer/VectorField.ts b/src/colorizer/VectorField.ts
index 36b557f2d..22d75812d 100644
--- a/src/colorizer/VectorField.ts
+++ b/src/colorizer/VectorField.ts
@@ -1,7 +1,15 @@
-import { BufferAttribute, BufferGeometry, Line, LineBasicMaterial, LineSegments, Material, Vector2 } from "three";
-
-import { getDefaultVectorConfig } from "./constants";
-import { VectorConfig } from "./types";
+import {
+  BufferAttribute,
+  BufferGeometry,
+  Color,
+  Line,
+  LineBasicMaterial,
+  LineSegments,
+  Material,
+  Vector2,
+} from "three";
+
+import { OUTLINE_COLOR_DEFAULT } from "./constants";
 
 import Dataset from "./Dataset";
 
@@ -16,6 +24,12 @@ const arrowStyle = {
 
 type ArrayVector3 = [number, number, number];
 
+export type VectorFieldConfig = {
+  visible: boolean;
+  scaleFactor: number;
+  color: Color;
+};
+
 /**
  * Renders vector arrows as a Three JS object.
  */
@@ -35,8 +49,9 @@ export default class VectorField {
 
   ///// Stored parameters /////
   private dataset: Dataset | null;
-  private config: VectorConfig;
   private currentFrame: number;
+  private scaleFactor: number;
+  private visible: boolean;
   /** Resolution of the canvas in onscreen pixels. */
   private canvasResolution: Vector2;
   /**
@@ -57,7 +72,8 @@ export default class VectorField {
 
   constructor() {
     this.dataset = null;
-    this.config = getDefaultVectorConfig();
+    this.scaleFactor = 1;
+    this.visible = false;
     this.currentFrame = 0;
     this.canvasResolution = new Vector2();
     this.frameToCanvasCoordinates = new Vector2();
@@ -65,7 +81,7 @@ export default class VectorField {
 
     const lineGeometry = new BufferGeometry();
     const lineMaterial = new LineBasicMaterial({
-      color: this.config.color,
+      color: new Color(OUTLINE_COLOR_DEFAULT),
       // Note: setting line width to anything other than 1.0 is unreliable.
       // See https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/lineWidth
       linewidth: 1,
@@ -85,7 +101,7 @@ export default class VectorField {
 
     // Update vertex geometry range to render.
     const range = this.timeToVertexIndexRange.get(frame);
-    if (range && this.config.visible) {
+    if (range && this.visible) {
       this.line.geometry.setDrawRange(range[0], range[1] - range[0]);
     } else {
       this.line.geometry.setDrawRange(0, 0);
@@ -194,8 +210,8 @@ export default class VectorField {
 
     const vectorStart: ArrayVector3 = [centroid[0], centroid[1], 0];
     const vectorEnd: ArrayVector3 = [
-      centroid[0] + delta[0] * this.config.scaleFactor,
-      centroid[1] + delta[1] * this.config.scaleFactor,
+      centroid[0] + delta[0] * this.scaleFactor,
+      centroid[1] + delta[1] * this.scaleFactor,
       0,
     ];
     const normVectorEnd = this.framePixelsToRelativeFrameCoords(vectorEnd);
@@ -223,7 +239,7 @@ export default class VectorField {
    * a frame number to the range of vertices that should be drawn for that frame.
    */
   private updateLineVertices(): void {
-    if (!this.dataset || !this.vectorData || !this.config.visible) {
+    if (!this.dataset || !this.vectorData || !this.visible) {
       this.line.geometry.setDrawRange(0, 0);
       return;
     }
@@ -304,12 +320,13 @@ export default class VectorField {
     );
   }
 
-  public setConfig(config: VectorConfig): void {
+  public setConfig(config: VectorFieldConfig): void {
     // TODO: will not need update if scaling is controlled by vertex shader
-    const needsUpdate = this.config.scaleFactor !== config.scaleFactor || this.config.visible !== config.visible;
+    const needsUpdate = this.scaleFactor !== config.scaleFactor || this.visible !== config.visible;
 
-    this.config = config;
-    (this.line.material as LineBasicMaterial).color = this.config.color;
+    this.visible = config.visible;
+    this.scaleFactor = config.scaleFactor;
+    (this.line.material as LineBasicMaterial).color = config.color;
 
     if (needsUpdate) {
       this.updateLineVertices();
diff --git a/src/colorizer/utils/data_utils.ts b/src/colorizer/utils/data_utils.ts
index 447ca53ba..7f002a42c 100644
--- a/src/colorizer/utils/data_utils.ts
+++ b/src/colorizer/utils/data_utils.ts
@@ -222,3 +222,21 @@ export function getIntervals(values: number[]): [number, number][] {
   }
   return intervals;
 }
+
+export function hasPropertyChanged<T extends Record<string, unknown>>(
+  curr: T | null,
+  prev: T | null,
+  properties: (keyof T)[]
+): boolean {
+  if (!curr && !prev) {
+    return false;
+  } else if (!curr || !prev) {
+    return true;
+  }
+  for (const property of properties) {
+    if (curr[property] !== prev[property]) {
+      return true;
+    }
+  }
+  return false;
+}
diff --git a/src/components/CanvasWrapper.tsx b/src/components/CanvasWrapper.tsx
index 244dcea5a..5b64c008c 100644
--- a/src/components/CanvasWrapper.tsx
+++ b/src/components/CanvasWrapper.tsx
@@ -4,17 +4,16 @@ import React, { ReactElement, ReactNode, useCallback, useContext, useEffect, use
 import styled from "styled-components";
 import { Color, ColorRepresentation, Vector2 } from "three";
 import { clamp } from "three/src/math/MathUtils";
-import { useShallow } from "zustand/shallow";
 
 import { ImagesIconSVG, ImagesSlashIconSVG, NoImageSVG, TagIconSVG, TagSlashIconSVG } from "../assets";
 import { AnnotationSelectionMode, LoadTroubleshooting, TabType } from "../colorizer/types";
 import * as mathUtils from "../colorizer/utils/math_utils";
 import { AnnotationState } from "../colorizer/utils/react_utils";
 import { INTERNAL_BUILD } from "../constants";
-import { selectVectorConfigFromState } from "../state/slices";
 import { FlexColumn, FlexColumnAlignCenter, VisuallyHidden } from "../styles/utils";
 
 import CanvasUIOverlay from "../colorizer/CanvasWithOverlay";
+import { renderCanvasStateParamsSelector } from "../colorizer/IRenderCanvas";
 import { useViewerStateStore } from "../state/ViewerState";
 import { AppThemeContext } from "./AppStyle";
 import { AlertBannerProps } from "./Banner";
@@ -125,23 +124,13 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
   const props = { ...defaultProps, ...inputProps } as Required<CanvasWrapperProps>;
 
   // Access state properties
-  const backdropBrightness = useViewerStateStore((state) => state.backdropBrightness);
+  const currentFrame = useViewerStateStore((state) => state.currentFrame);
   const backdropKey = useViewerStateStore((state) => state.backdropKey);
-  const backdropSaturation = useViewerStateStore((state) => state.backdropSaturation);
   const backdropVisible = useViewerStateStore((state) => state.backdropVisible);
-  const categoricalPalette = useViewerStateStore((state) => state.categoricalPalette);
   const clearTrack = useViewerStateStore((state) => state.clearTrack);
   const collection = useViewerStateStore((state) => state.collection);
-  const colorRamp = useViewerStateStore((state) => state.colorRamp);
-  const colorRampRange = useViewerStateStore((state) => state.colorRampRange);
   const dataset = useViewerStateStore((state) => state.dataset);
   const datasetKey = useViewerStateStore((state) => state.datasetKey);
-  const featureKey = useViewerStateStore((state) => state.featureKey);
-  const inRangeLUT = useViewerStateStore((state) => state.inRangeLUT);
-  const objectOpacity = useViewerStateStore((state) => state.objectOpacity);
-  const outlierDrawSettings = useViewerStateStore((state) => state.outlierDrawSettings);
-  const outlineColor = useViewerStateStore((state) => state.outlineColor);
-  const outOfRangeDrawSettings = useViewerStateStore((state) => state.outOfRangeDrawSettings);
   const setBackdropVisible = useViewerStateStore((state) => state.setBackdropVisible);
   const setOpenTab = useViewerStateStore((state) => state.setOpenTab);
   const setTrack = useViewerStateStore((state) => state.setTrack);
@@ -149,16 +138,19 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
   const showLegendDuringExport = useViewerStateStore((state) => state.showLegendDuringExport);
   const showScaleBar = useViewerStateStore((state) => state.showScaleBar);
   const showTimestamp = useViewerStateStore((state) => state.showTimestamp);
-  const showTrackPath = useViewerStateStore((state) => state.showTrackPath);
-  const track = useViewerStateStore((state) => state.track);
-  const vectorConfig = useViewerStateStore(useShallow(selectVectorConfigFromState));
-  const vectorData = useViewerStateStore((state) => state.vectorMotionDeltas);
 
   const containerRef = useRef<HTMLDivElement>(null);
 
   const canv = props.canv;
   const canvasPlaceholderRef = useRef<HTMLDivElement>(null);
 
+  // Add subscriber so canvas parameters are updated when the state changes.
+  useEffect(() => {
+    return useViewerStateStore.subscribe(renderCanvasStateParamsSelector, (params) => {
+      canv.setParams(params);
+    });
+  }, []);
+
   /**
    * Canvas zoom level, stored as its inverse. This makes it so linear changes in zoom level
    * (by +/-0.25) affect the zoom level more when zoomed in than zoomed out.
@@ -238,58 +230,6 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
     canv.setCanvasBackgroundColor(new Color(theme.color.viewport.background as ColorRepresentation));
   }, [theme]);
 
-  // Update canvas color ramp
-  useMemo(() => {
-    canv.setColorRamp(colorRamp);
-    canv.setColorMapRangeMin(colorRampRange[0]);
-    canv.setColorMapRangeMax(colorRampRange[1]);
-  }, [colorRamp, colorRampRange]);
-
-  useMemo(() => {
-    if (featureKey) {
-      canv.setFeatureKey(featureKey);
-    }
-  }, [featureKey]);
-
-  // Update backdrops
-  useMemo(() => {
-    if (backdropKey !== null && backdropVisible) {
-      canv.setBackdropKey(backdropKey);
-      canv.setBackdropBrightness(backdropBrightness);
-      canv.setBackdropSaturation(backdropSaturation);
-      canv.setObjectOpacity(objectOpacity);
-    } else {
-      canv.setBackdropKey(null);
-      canv.setObjectOpacity(100);
-    }
-  }, [backdropKey, backdropVisible, backdropBrightness, backdropSaturation, objectOpacity]);
-
-  // Update categorical colors
-  useMemo(() => {
-    canv.setCategoricalColors(categoricalPalette);
-  }, [categoricalPalette]);
-
-  // Update drawing modes for outliers + out of range values
-  useMemo(() => {
-    const settings = outOfRangeDrawSettings;
-    canv.setOutOfRangeDrawMode(settings.mode, settings.color);
-  }, [outOfRangeDrawSettings]);
-
-  useMemo(() => {
-    const settings = outlierDrawSettings;
-    canv.setOutlierDrawMode(settings.mode, settings.color);
-  }, [outlierDrawSettings]);
-
-  useMemo(() => {
-    canv.setInRangeLUT(inRangeLUT);
-  }, [inRangeLUT]);
-
-  // Updated track-related settings
-  useMemo(() => {
-    canv.setSelectedTrack(track);
-    canv.setShowTrackPath(showTrackPath);
-  }, [track, showTrackPath]);
-
   // Update overlay settings
   useMemo(() => {
     canv.isScaleBarVisible = showScaleBar;
@@ -313,18 +253,6 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
     canv.isFooterVisibleOnExport = showLegendDuringExport;
   }, [showLegendDuringExport, props.isRecording]);
 
-  useMemo(() => {
-    canv.setVectorFieldConfig(vectorConfig);
-  }, [vectorConfig]);
-
-  useMemo(() => {
-    canv.setVectorData(vectorData);
-  }, [vectorData]);
-
-  useMemo(() => {
-    canv.setOutlineColor(outlineColor);
-  }, [outlineColor]);
-
   useMemo(() => {
     const annotationLabels = props.annotationState.data.getLabels();
     const timeToAnnotationLabelIds = dataset ? props.annotationState.data.getTimeToLabelIdMap(dataset) : new Map();
@@ -362,13 +290,12 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
   useEffect(() => {
     const updateCanvasDimensions = (): void => {
       const canvasSizePx = getCanvasSizePx();
-      canv.setSize(canvasSizePx.x, canvasSizePx.y);
+      canv.setResolution(canvasSizePx.x, canvasSizePx.y);
     };
     updateCanvasDimensions(); // Initial size setting
 
     const handleResize = (): void => {
       updateCanvasDimensions();
-      canv.render();
     };
 
     window.addEventListener("resize", handleResize);
@@ -621,7 +548,7 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
     if (isMouseOverCanvas.current) {
       reportHoveredIdAtPixel(lastMousePositionPx.current.x, lastMousePositionPx.current.y);
     }
-  }, [canv.getCurrentFrame()]);
+  }, [currentFrame]);
 
   useEffect(() => {
     const onMouseMove = (event: MouseEvent): void => {


Reviews:
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T22:16:27Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T22:55:05Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T22:58:01Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:06:16Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:06:31Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:07:44Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:11:47Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:14:50Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:18:51Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:19:10Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:19:36Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:20:03Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T23:20:49Z
user: login: toloudis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T23:13:07Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T00:18:42Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T00:20:03Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T00:57:37Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T15:45:03Z
user: login: toloudis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T16:01:25Z
user: login: toloudis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T16:03:11Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T16:20:00Z
user: login: ShrimpCryptid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T16:44:09Z
user: login: ascibisz
body: I really appreciated the two video walk throughs! From what I see and what I tested, this looks good to me :)
state: APPROVED
author_association: NONE
submitted_at: 2025-03-31T22:49:36Z

Review comments:
diff_hunk: @@ -1,7 +1,15 @@
-import { BufferAttribute, BufferGeometry, Line, LineBasicMaterial, LineSegments, Material, Vector2 } from "three";
-
-import { getDefaultVectorConfig } from "./constants";
-import { VectorConfig } from "./types";
path: src/colorizer/VectorField.ts
user: login: ShrimpCryptid
body: Refactored to remove dependence on `VectorConfig`.
created_at: 2025-03-21T22:16:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -243,14 +233,21 @@ export default class ColorizeCanvas {
     this.onFrameChangeCallback = () => {};
 
     this.render = this.render.bind(this);
-    this.getCurrentFrame = this.getCurrentFrame.bind(this);
     this.setOutOfRangeDrawMode = this.setOutOfRangeDrawMode.bind(this);
     this.updateScaling = this.updateScaling.bind(this);
-    this.isValidFrame = this.isValidFrame.bind(this);
     this.setFrame = this.setFrame.bind(this);
   }
 
-  get domElement(): HTMLCanvasElement {
+  private setUniform<U extends keyof ColorizeUniformTypes>(name: U, value: ColorizeUniformTypes[U]): void {
+    this.material.uniforms[name].value = value;
+    this.pickMaterial.uniforms[name].value = value;
+  }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Moved up from below
created_at: 2025-03-21T22:55:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -346,50 +343,28 @@ export default class ColorizeCanvas {
     this.vectorField.setScale(this.frameToCanvasCoordinates, this.canvasResolution || new Vector2(1, 1));
   }
 
-  public async setDataset(dataset: Dataset): Promise<void> {
-    if (this.dataset !== null) {
-      this.dataset.dispose();
+  // PARAM HANDLING /////////////////////////////////////////////////////////////////////////
+
+  private async handleNewDataset(dataset: Dataset | null): Promise<void> {
+    if (dataset === null) {
+      return;
     }
-    this.dataset = dataset;
-    if (this.dataset.outliers) {
-      this.setUniform("outlierData", packDataTexture(Array.from(this.dataset.outliers), FeatureDataType.U8));
+    if (dataset.outliers) {
+      this.setUniform("outlierData", packDataTexture(Array.from(dataset.outliers), FeatureDataType.U8));
     } else {
       this.setUniform("outlierData", packDataTexture([0], FeatureDataType.U8));
     }
-
-    const frame = this.currentFrame;
-    this.currentFrame = -1;
-    this.pendingFrame = -1;
-    await this.setFrame(frame);
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Frame setting logic is now in a new method, `this.forceFrameReload()`.
created_at: 2025-03-21T22:58:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +422,136 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
   }
 
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
-  }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Moved below, now named `syncTrackRange`.
created_at: 2025-03-21T23:06:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +422,136 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
   }
 
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
-  }
-
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Moved below to the rendering section, now named `syncHighlightedId`.
created_at: 2025-03-21T23:06:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +422,136 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
   }
 
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
-  }
-
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
   }
 
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
-  }
-
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Unused method
created_at: 2025-03-21T23:07:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +422,136 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
   }
 
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
-  }
-
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
   }
 
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
-  }
-
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
-
-  setInRangeLUT(inRangeLUT: Uint8Array): void {
+  private setInRangeLUT(inRangeLUT: Uint8Array): void {
     // Save the array to a texture and pass it into the shader
     if (inRangeLUT.length > 0) {
       this.setUniform("inRangeIds", packDataTexture(Array.from(inRangeLUT), FeatureDataType.U8));
-      this.render();
     }
   }
 
-  getColorMapRangeMin(): number {
-    return this.colorMapRangeMin;
-  }
-
-  getColorMapRangeMax(): number {
-    return this.colorMapRangeMax;
-  }
-
   /**
-   * @returns The number of frames in the dataset. If no dataset is loaded,
-   * returns 0 by default.
+   * Forces a reload of the current frame, even if it's already loaded. If a
+   * different frame is in the process of being loaded, the reload will be
+   * called on that pending frame.
    */
-  getTotalFrames(): number {
-    return this.dataset ? this.dataset.numberOfFrames : 0;
+  private forceFrameReload(): void {
+    // Force update on the current frame or the frame that's currently being loaded
+    const frame = this.pendingFrame !== -1 ? this.pendingFrame : this.currentFrame;
+    this.setFrame(frame, true).then(() => {
+      this.render();
+    });
   }
 
-  getCurrentFrame(): number {
-    return this.currentFrame;
+  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
+    this.onFrameChangeCallback = callback;
   }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Left a note about this in the `IRenderCanvas` file, but I'm considering removing this, and instead having `setFrame` return a status object that reports if files are missing. Maybe that status will live in state?
created_at: 2025-03-21T23:11:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +422,136 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
   }
 
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
-  }
-
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
   }
 
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
-  }
-
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
-
-  setInRangeLUT(inRangeLUT: Uint8Array): void {
+  private setInRangeLUT(inRangeLUT: Uint8Array): void {
     // Save the array to a texture and pass it into the shader
     if (inRangeLUT.length > 0) {
       this.setUniform("inRangeIds", packDataTexture(Array.from(inRangeLUT), FeatureDataType.U8));
-      this.render();
     }
   }
 
-  getColorMapRangeMin(): number {
-    return this.colorMapRangeMin;
-  }
-
-  getColorMapRangeMax(): number {
-    return this.colorMapRangeMax;
-  }
-
   /**
-   * @returns The number of frames in the dataset. If no dataset is loaded,
-   * returns 0 by default.
+   * Forces a reload of the current frame, even if it's already loaded. If a
+   * different frame is in the process of being loaded, the reload will be
+   * called on that pending frame.
    */
-  getTotalFrames(): number {
-    return this.dataset ? this.dataset.numberOfFrames : 0;
+  private forceFrameReload(): void {
+    // Force update on the current frame or the frame that's currently being loaded
+    const frame = this.pendingFrame !== -1 ? this.pendingFrame : this.currentFrame;
+    this.setFrame(frame, true).then(() => {
+      this.render();
+    });
   }
 
-  getCurrentFrame(): number {
-    return this.currentFrame;
+  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
+    this.onFrameChangeCallback = callback;
   }
 
-  public isValidFrame(index: number): boolean {
-    return index >= 0 && index < this.getTotalFrames();
-  }
+  public setParams(params: CanvasStateParams): void {
+    // TODO: What happens when `setParams` is called again while waiting for a Dataset to load?
+    // May cause visual desync where the color ramp/feature data updates before frames load in fully
+    if (this.params === params) {
+      return;
+    }
+    const prevParams = this.params;
+    this.params = params;
+    const hasDatasetChanged = hasPropertyChanged(params, prevParams, ["dataset"]);
 
-  public setObjectOpacity(percentOpacity: number): void {
-    percentOpacity = Math.max(0, Math.min(100, percentOpacity));
-    this.setUniform("objectOpacity", percentOpacity / 100);
-  }
+    if (hasDatasetChanged) {
+      this.handleNewDataset(params.dataset);
+    }
 
-  public setBackdropSaturation(percentSaturation: number): void {
-    percentSaturation = Math.max(0, Math.min(100, percentSaturation));
-    this.setUniform("backdropSaturation", percentSaturation / 100);
-  }
+    this.updateFeatureData(params.dataset, params.featureKey);
+    this.updateTrackData(params.dataset, params.track);
 
-  public setBackdropBrightness(percentBrightness: number): void {
-    percentBrightness = Math.max(0, Math.min(200, percentBrightness));
-    this.setUniform("backdropBrightness", percentBrightness / 100);
-  }
+    if (hasPropertyChanged(params, prevParams, ["outlierDrawSettings"])) {
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: See implementation in `data_utils.ts`. The strings are required to be keys in the property type (`RenderCanvasStateParams`)
created_at: 2025-03-21T23:14:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
+] as const;
+
+export type RenderCanvasStateParams = Pick<ViewerStoreState, (typeof canvasStateDeps)[number]>;
+
+export const renderCanvasStateParamsSelector = (state: ViewerStoreState): RenderCanvasStateParams => {
+  const entries = canvasStateDeps.map((key) => [key, state[key]]);
+  return Object.fromEntries(entries);
+};
+
+/**
+ * A canvas that renders timelapse data.
+ */
+export interface IRenderCanvas {
+  get domElement(): HTMLCanvasElement;
+  /** (X,Y) resolution of the canvas, in pixels. */
+  get resolution(): Vector2;
+  setResolution(width: number, height: number): void;
+  /**
+   * Updates the parameters used to configure the canvas view. See
+   * `CanvasStateParams` for a complete list of parameters required.
+   */
+  setParams(params: RenderCanvasStateParams): void;
path: src/colorizer/IRenderCanvas.ts
user: login: ShrimpCryptid
body: This is the biggest change, that will most affect `ColorizeCanvas`. I removed the individual property setters and joined the logic into one function call.

Before, there were many individual setters for properties in `ColorizeCanvas`, and many of them had additional side-effects, like triggering a re-render.

This change makes it so that all the parameter changes can be handled at once, and parameters can be pulled directly out of state. Rendering only takes place once, after all the properties have been updated.
created_at: 2025-03-21T23:18:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,40 +124,33 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem
   const props = { ...defaultProps, ...inputProps } as Required<CanvasWrapperProps>;
 
   // Access state properties
-  const backdropBrightness = useViewerStateStore((state) => state.backdropBrightness);
+  const currentFrame = useViewerStateStore((state) => state.currentFrame);
   const backdropKey = useViewerStateStore((state) => state.backdropKey);
-  const backdropSaturation = useViewerStateStore((state) => state.backdropSaturation);
   const backdropVisible = useViewerStateStore((state) => state.backdropVisible);
-  const categoricalPalette = useViewerStateStore((state) => state.categoricalPalette);
   const clearTrack = useViewerStateStore((state) => state.clearTrack);
   const collection = useViewerStateStore((state) => state.collection);
-  const colorRamp = useViewerStateStore((state) => state.colorRamp);
-  const colorRampRange = useViewerStateStore((state) => state.colorRampRange);
   const dataset = useViewerStateStore((state) => state.dataset);
   const datasetKey = useViewerStateStore((state) => state.datasetKey);
-  const featureKey = useViewerStateStore((state) => state.featureKey);
-  const inRangeLUT = useViewerStateStore((state) => state.inRangeLUT);
-  const objectOpacity = useViewerStateStore((state) => state.objectOpacity);
-  const outlierDrawSettings = useViewerStateStore((state) => state.outlierDrawSettings);
-  const outlineColor = useViewerStateStore((state) => state.outlineColor);
-  const outOfRangeDrawSettings = useViewerStateStore((state) => state.outOfRangeDrawSettings);
   const setBackdropVisible = useViewerStateStore((state) => state.setBackdropVisible);
   const setOpenTab = useViewerStateStore((state) => state.setOpenTab);
   const setTrack = useViewerStateStore((state) => state.setTrack);
   const showHeaderDuringExport = useViewerStateStore((state) => state.showHeaderDuringExport);
   const showLegendDuringExport = useViewerStateStore((state) => state.showLegendDuringExport);
   const showScaleBar = useViewerStateStore((state) => state.showScaleBar);
   const showTimestamp = useViewerStateStore((state) => state.showTimestamp);
-  const showTrackPath = useViewerStateStore((state) => state.showTrackPath);
-  const track = useViewerStateStore((state) => state.track);
-  const vectorConfig = useViewerStateStore(useShallow(selectVectorConfigFromState));
-  const vectorData = useViewerStateStore((state) => state.vectorMotionDeltas);
 
   const containerRef = useRef<HTMLDivElement>(null);
 
   const canv = props.canv;
   const canvasPlaceholderRef = useRef<HTMLDivElement>(null);
 
+  // Add subscriber so canvas parameters are updated when the state changes.
+  useEffect(() => {
+    return useViewerStateStore.subscribe(renderCanvasStateParamsSelector, (params) => {
+      canv.setParams(params);
+    });
+  }, []);
+
path: src/components/CanvasWrapper.tsx
user: login: ShrimpCryptid
body: Subscriber means that the canvas can be updated outside of the React render lifecycle! CanvasWrapper doesn't need to rerender when properties change.
created_at: 2025-03-21T23:19:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,17 +154,15 @@ export default class ColorizeCanvas {
   private renderer: WebGLRenderer;
   private pickRenderTarget: WebGLRenderTarget;
 
-  protected dataset: Dataset | null;
-  protected track: Track | null;
-  protected canvasResolution: Vector2 | null;
-
-  protected featureKey: string | null;
-  protected selectedBackdropKey: string | null;
-  protected colorRamp: ColorRamp;
-  protected colorMapRangeMin: number;
-  protected colorMapRangeMax: number;
+  protected params: RenderCanvasStateParams | null;
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Replaced a lot of the tracked properties on the left with a single object, `params`.
created_at: 2025-03-21T23:19:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +432,125 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
-  }
-
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
   }
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: Moved below, now named `syncTrackRange`.
created_at: 2025-03-21T23:20:03Z
author_association: CONTRIBUTOR
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: The diff on this file ended up being a hot mess, sorry.... Happy to walk through it if this is too hard to read 😭
created_at: 2025-03-21T23:20:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
path: src/colorizer/IRenderCanvas.ts
user: login: toloudis
body: anything enforcing that these are valid? can/ should typescript help us here?
created_at: 2025-03-27T22:01:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
+] as const;
+
+export type RenderCanvasStateParams = Pick<ViewerStoreState, (typeof canvasStateDeps)[number]>;
+
+export const renderCanvasStateParamsSelector = (state: ViewerStoreState): RenderCanvasStateParams => {
+  const entries = canvasStateDeps.map((key) => [key, state[key]]);
+  return Object.fromEntries(entries);
+};
+
+/**
+ * A canvas that renders timelapse data.
+ */
+export interface IRenderCanvas {
+  get domElement(): HTMLCanvasElement;
+  /** (X,Y) resolution of the canvas, in pixels. */
+  get resolution(): Vector2;
+  setResolution(width: number, height: number): void;
+  /**
+   * Updates the parameters used to configure the canvas view. See
+   * `CanvasStateParams` for a complete list of parameters required.
+   */
+  setParams(params: RenderCanvasStateParams): void;
+  // TODO: Have `setFrame` report additional information about the frame
+  // such as the frame number and whether the file was missing/load failed.
+  // This is currently handled through `loadFrameCallback`.
+  /**
+   * Loads and renders the data for a specific frame. Returns a Promise that
+   * resolves when the data is loaded and rendered onscreen.
+   * @param frame The frame number to load and render. Ignores frames that are
+   * out of range of the current dataset or that are already loaded.
+   */
+  setFrame(frame: number): Promise<void>;
path: src/colorizer/IRenderCanvas.ts
user: login: toloudis
body: to discuss later: can this survive as an async call and does it actually have to WAIT until rendering is completed -- to be more specific, does it preclude using requestAnimationFrame?
created_at: 2025-03-27T22:37:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,12 +314,13 @@ export default class VectorField {
     );
   }
 
-  public setConfig(config: VectorConfig): void {
+  public setConfig(config: { visible: boolean; scaleFactor: number; color: Color }): void {
path: src/colorizer/VectorField.ts
user: login: toloudis
body: why did the type go away but you still have this take an object?  
created_at: 2025-03-27T22:42:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -222,3 +222,21 @@ export function getIntervals(values: number[]): [number, number][] {
   }
   return intervals;
 }
+
+export function hasPropertyChanged<T extends Record<string, unknown>>(
path: src/colorizer/utils/data_utils.ts
user: login: toloudis
body: i am sad that you actually had to implement this function
created_at: 2025-03-27T23:02:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +432,125 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
-  }
-
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
   }
 
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
-  }
-
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
   }
 
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
-
-  setInRangeLUT(inRangeLUT: Uint8Array): void {
+  private setInRangeLUT(inRangeLUT: Uint8Array): void {
     // Save the array to a texture and pass it into the shader
     if (inRangeLUT.length > 0) {
       this.setUniform("inRangeIds", packDataTexture(Array.from(inRangeLUT), FeatureDataType.U8));
-      this.render();
     }
   }
 
-  getColorMapRangeMin(): number {
-    return this.colorMapRangeMin;
-  }
-
-  getColorMapRangeMax(): number {
-    return this.colorMapRangeMax;
-  }
-
-  /**
-   * @returns The number of frames in the dataset. If no dataset is loaded,
-   * returns 0 by default.
-   */
-  getTotalFrames(): number {
-    return this.dataset ? this.dataset.numberOfFrames : 0;
-  }
-
-  getCurrentFrame(): number {
-    return this.currentFrame;
+  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
+    this.onFrameChangeCallback = callback;
   }
 
-  public isValidFrame(index: number): boolean {
-    return index >= 0 && index < this.getTotalFrames();
-  }
+  public setParams(params: RenderCanvasStateParams): void {
path: src/colorizer/ColorizeCanvas.ts
user: login: toloudis
body: interested to see how much time is spent in here in a profiler when doing various things in the app
created_at: 2025-03-27T23:12:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
path: src/colorizer/IRenderCanvas.ts
user: login: ShrimpCryptid
body: I get Typescript errors when property names change, but it won't highlight the specific lines that are incorrect. Because of this, I ended up changing this section in the next PR after discussing with Cameron to make this more verbose but more explicit with a `Pick<ViewerStoreState, {deps}>` 
created_at: 2025-03-28T00:18:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Vector2 } from "three";
+
+import { ViewerStoreState } from "../state/slices";
+
+const canvasStateDeps = [
+  "dataset",
+  "featureKey",
+  "track",
+  "showTrackPath",
+  "colorRamp",
+  "colorRampRange",
+  "categoricalPalette",
+  "outlineColor",
+  "outlierDrawSettings",
+  "outOfRangeDrawSettings",
+  "inRangeLUT",
+  "vectorMotionDeltas",
+  "vectorVisible",
+  "vectorColor",
+  "vectorScaleFactor",
+  "backdropKey",
+  "backdropVisible",
+  "objectOpacity",
+  "backdropSaturation",
+  "backdropBrightness",
+] as const;
+
+export type RenderCanvasStateParams = Pick<ViewerStoreState, (typeof canvasStateDeps)[number]>;
+
+export const renderCanvasStateParamsSelector = (state: ViewerStoreState): RenderCanvasStateParams => {
+  const entries = canvasStateDeps.map((key) => [key, state[key]]);
+  return Object.fromEntries(entries);
+};
+
+/**
+ * A canvas that renders timelapse data.
+ */
+export interface IRenderCanvas {
+  get domElement(): HTMLCanvasElement;
+  /** (X,Y) resolution of the canvas, in pixels. */
+  get resolution(): Vector2;
+  setResolution(width: number, height: number): void;
+  /**
+   * Updates the parameters used to configure the canvas view. See
+   * `CanvasStateParams` for a complete list of parameters required.
+   */
+  setParams(params: RenderCanvasStateParams): void;
+  // TODO: Have `setFrame` report additional information about the frame
+  // such as the frame number and whether the file was missing/load failed.
+  // This is currently handled through `loadFrameCallback`.
+  /**
+   * Loads and renders the data for a specific frame. Returns a Promise that
+   * resolves when the data is loaded and rendered onscreen.
+   * @param frame The frame number to load and render. Ignores frames that are
+   * out of range of the current dataset or that are already loaded.
+   */
+  setFrame(frame: number): Promise<void>;
path: src/colorizer/IRenderCanvas.ts
user: login: ShrimpCryptid
body: Some things like time playback are always going to need to know when a frame has changed, since we're never guaranteed loading times for images and resources. I think having `setFrame` return a Promise doesn't preclude `requestAnimationFrame`.
created_at: 2025-03-28T00:20:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -222,3 +222,21 @@ export function getIntervals(values: number[]): [number, number][] {
   }
   return intervals;
 }
+
+export function hasPropertyChanged<T extends Record<string, unknown>>(
path: src/colorizer/utils/data_utils.ts
user: login: ShrimpCryptid
body: Should I not have???
created_at: 2025-03-28T00:57:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,12 +314,13 @@ export default class VectorField {
     );
   }
 
-  public setConfig(config: VectorConfig): void {
+  public setConfig(config: { visible: boolean; scaleFactor: number; color: Color }): void {
path: src/colorizer/VectorField.ts
user: login: ShrimpCryptid
body: It takes an object mostly for convenience since it's easy to add in new parameters if I need it, but no hard reason. The `VectorConfig` object had a lot more information than `VectorField` actually needed, and it didn't map easily from state anymore so I wanted to remove it. Should I change this to non-object params?
created_at: 2025-03-31T15:45:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -222,3 +222,21 @@ export function getIntervals(values: number[]): [number, number][] {
   }
   return intervals;
 }
+
+export function hasPropertyChanged<T extends Record<string, unknown>>(
path: src/colorizer/utils/data_utils.ts
user: login: toloudis
body: mostly because I think of React is supposed to do this, and also I'm surprised that there isn't a great library version of this..
created_at: 2025-03-31T16:01:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,12 +314,13 @@ export default class VectorField {
     );
   }
 
-  public setConfig(config: VectorConfig): void {
+  public setConfig(config: { visible: boolean; scaleFactor: number; color: Color }): void {
path: src/colorizer/VectorField.ts
user: login: toloudis
body: This is not a huge problem.. I didn't realize that VectorConfig had more fields.  I think it's ok but when I see this it looks like it's stuck in between having a named type or just being non-object params so I'd consider doing one or the other.
created_at: 2025-03-31T16:03:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -222,3 +222,21 @@ export function getIntervals(values: number[]): [number, number][] {
   }
   return intervals;
 }
+
+export function hasPropertyChanged<T extends Record<string, unknown>>(
path: src/colorizer/utils/data_utils.ts
user: login: ShrimpCryptid
body: Mm, React does do this but we're not in React-land so I can't use hooks for this... There probably is but I haven't looked
created_at: 2025-03-31T16:20:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -459,143 +432,125 @@ export default class ColorizeCanvas {
       }
 
       // Normalize from pixel coordinates to canvas space [-1, 1]
-      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / this.dataset.frameResolution.x) * 2.0 - 1.0;
-      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / this.dataset.frameResolution.y) * 2.0 - 1.0);
+      this.points[3 * i + 0] = (track.centroids[2 * trackIndex] / dataset.frameResolution.x) * 2.0 - 1.0;
+      this.points[3 * i + 1] = -((track.centroids[2 * trackIndex + 1] / dataset.frameResolution.y) * 2.0 - 1.0);
       this.points[3 * i + 2] = 0;
     }
     // Assign new BufferAttribute because the old array has been discarded.
     this.line.geometry.setAttribute("position", new BufferAttribute(this.points, 3));
     this.line.geometry.getAttribute("position").needsUpdate = true;
-    this.updateTrackRange();
-  }
-
-  setShowTrackPath(show: boolean): void {
-    this.showTrackPath = show;
-  }
-
-  /**
-   * Updates the range of the track path line so that it shows up the path up to the current
-   * frame.
-   */
-  updateTrackRange(): void {
-    // Show nothing if track doesn't exist or doesn't have centroid data
-    if (!this.track || !this.track.centroids || !this.showTrackPath) {
-      this.line.geometry.setDrawRange(0, 0);
-      return;
-    }
-
-    // Show path up to current frame
-    let range = this.currentFrame - this.track.startTime() + 1;
-
-    if (range > this.track.duration() || range < 0) {
-      // Hide track if we are outside the track range
-      range = 0;
-    }
-
-    this.line.geometry.setDrawRange(0, range);
   }
 
-  private updateHighlightedId(): void {
-    // Hide highlight if no track is selected
-    if (!this.track) {
-      this.setUniform("highlightedId", -1);
+  private updateFeatureData(dataset: Dataset | null, featureKey: string | null): void {
+    if (featureKey === null || dataset === null) {
       return;
     }
-    this.setUniform("highlightedId", this.track.getIdAtTime(this.currentFrame));
-  }
-
-  setFeatureKey(key: string): void {
-    if (!this.dataset?.hasFeatureKey(key)) {
+    if (!dataset.hasFeatureKey(featureKey)) {
       return;
     }
-    const featureData = this.dataset.getFeatureData(key)!;
-    this.featureKey = key;
+    const featureData = dataset.getFeatureData(featureKey)!;
     this.setUniform("featureData", featureData.tex);
-    this.render(); // re-render necessary because map range may have changed
-  }
-
-  setColorMapRangeMin(newMin: number): void {
-    this.colorMapRangeMin = newMin;
-  }
-
-  setColorMapRangeMax(newMax: number): void {
-    this.colorMapRangeMax = newMax;
   }
 
-  resetColorMapRange(): void {
-    if (!this.featureKey) {
-      return;
-    }
-    const featureData = this.dataset?.getFeatureData(this.featureKey);
-    if (featureData) {
-      this.colorMapRangeMin = featureData.min;
-      this.colorMapRangeMax = featureData.max;
-      this.setUniform("featureColorRampMin", this.colorMapRangeMin);
-      this.setUniform("featureColorRampMax", this.colorMapRangeMax);
-    }
-  }
-
-  setInRangeLUT(inRangeLUT: Uint8Array): void {
+  private setInRangeLUT(inRangeLUT: Uint8Array): void {
     // Save the array to a texture and pass it into the shader
     if (inRangeLUT.length > 0) {
       this.setUniform("inRangeIds", packDataTexture(Array.from(inRangeLUT), FeatureDataType.U8));
-      this.render();
     }
   }
 
-  getColorMapRangeMin(): number {
-    return this.colorMapRangeMin;
-  }
-
-  getColorMapRangeMax(): number {
-    return this.colorMapRangeMax;
-  }
-
-  /**
-   * @returns The number of frames in the dataset. If no dataset is loaded,
-   * returns 0 by default.
-   */
-  getTotalFrames(): number {
-    return this.dataset ? this.dataset.numberOfFrames : 0;
-  }
-
-  getCurrentFrame(): number {
-    return this.currentFrame;
+  public setOnFrameChangeCallback(callback: (isMissing: boolean) => void): void {
+    this.onFrameChangeCallback = callback;
   }
 
-  public isValidFrame(index: number): boolean {
-    return index >= 0 && index < this.getTotalFrames();
-  }
+  public setParams(params: RenderCanvasStateParams): void {
path: src/colorizer/ColorizeCanvas.ts
user: login: ShrimpCryptid
body: ![image](https://github.com/user-attachments/assets/5c1764c5-e592-4577-bad9-afe7de1aabac)

Poked at this. It looks like the overhead of `setParams` is negligible, and most of the time duration is coming from calling `render`
created_at: 2025-03-31T16:44:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-21T18:31:41Z
body: [PR Preview Action](https://github.com/rossjrw/pr-preview-action) v1.4.8
:---:
Preview removed because the pull request was closed.
2025-03-31 22:56 UTC
<!-- Sticky Pull Request Commentpr-preview -->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-21T18:32:23Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🔵</td> <td align="left">Lines</td> <td align="right">79.45%</td> <td align="right">8442 / 10625</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Statements</td> <td align="right">79.45%</td> <td align="right">8442 / 10625</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Functions</td> <td align="right">69.19%</td> <td align="right">292 / 422</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Branches</td> <td align="right">86.18%</td> <td align="right">886 / 1028</td> </tr> </tbody> </table><details><summary>File Coverage</summary><table> <thead> <tr> <th align="left">File</th> <th align="right">Stmts</th> <th align="right">% Branch</th> <th align="right">% Funcs</th> <th align="right">% Lines</th> <th align="left">Uncovered Lines</th> </tr> </thead> <tbody> <tr> <td align="left" colspan="6"><b>Changed Files</b></td> </tr> <tr> <td align="left"><a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts">src/colorizer/Dataset.ts</a></td> <td align="right">82.98%</td> <td align="right">80.37%</td> <td align="right">76.92%</td> <td align="right">82.98%</td> <td align="left"><a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L228-L229" class="text-red">228-229</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L247-L248" class="text-red">247-248</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L260-L261" class="text-red">260-261</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L313-L314" class="text-red">313-314</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L318-L320" class="text-red">318-320</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L324-L325" class="text-red">324-325</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L348-L349" class="text-red">348-349</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L352-L374" class="text-red">352-374</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L402-L403" class="text-red">402-403</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L407-L408" class="text-red">407-408</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L427-L431" class="text-red">427-431</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L449-L450" class="text-red">449-450</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L469-L471" class="text-red">469-471</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L481-L487" class="text-red">481-487</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L505-L508" class="text-red">505-508</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L525-L533" class="text-red">525-533</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L537-L538" class="text-red">537-538</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L541-L542" class="text-red">541-542</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L545-L546" class="text-red">545-546</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L550-L551" class="text-red">550-551</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L557-L564" class="text-red">557-564</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L588-L592" class="text-red">588-592</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L596-L600" class="text-red">596-600</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/Dataset.ts#L615-L622" class="text-red">615-622</a></td> </tr> <tr> <td align="left"><a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts">src/colorizer/utils/data_utils.ts</a></td> <td align="right">85.95%</td> <td align="right">95.34%</td> <td align="right">72.72%</td> <td align="right">85.95%</td> <td align="left"><a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts#L11-L12" class="text-red">11-12</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts#L42-L43" class="text-red">42-43</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts#L162-L166" class="text-red">162-166</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts#L176-L184" class="text-red">176-184</a>, <a href="https://github.com/allen-cell-animated/timelapse-colorizer/blob/457501d68e0434a72b355b76b8744acb4e8bc869/src/colorizer/utils/data_utils.ts#L227-L242" class="text-red">227-242</a></td> </tr> </tbody> </table></details>
<em>Generated in workflow <a href=https://github.com/allen-cell-animated/timelapse-colorizer/actions/runs/14176807535>#1624</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: NONE