Skip to content

Conversation

@xinaesthete
Copy link
Contributor

@xinaesthete xinaesthete commented Feb 3, 2026

Still need much better control and probably some refactoring.

Summary by CodeRabbit

  • New Features

    • Viv-backed image viewer: display images alongside shapes/points/labels, multi-view syncing, scale bar, and per-image presets for channels (colors, contrast, visibility).
    • New image layer config API and improved 2D/3D view-state modeling.
  • Bug Fixes

    • Points rendering: optional 3D coordinates honored when enabled.
  • Chores

    • Added fast-deep-equal dependency; exposed runtime lists for spatial and time units.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 3, 2026

📝 Walkthrough

Walkthrough

Spatial canvas delegates image rendering to a new VivSpatialViewer (DetailView-driven) when image layers are present; otherwise it renders a simplified DetailView-based SpatialViewerSimple. Types, channel/config plumbing, and image-loading/getVivLayerProps support were added; runtime dependency fast-deep-equal was introduced.

Changes

Cohort / File(s) Summary
Dependencies
packages/vis/package.json
Added runtime dependency fast-deep-equal (^3.1.3).
Viv viewer
packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx
New React class component implementing Viv/DetailView-based rendering: per-view Viv viewState conversion, image loader integration, layer composition (image + extra + scale bar), multi-view filtering, and DeckGL integration. Exports helpers/types (getVivId, VivViewState, VivSpatialViewerProps).
SpatialViewer switch
packages/vis/src/SpatialCanvas/SpatialViewer.tsx
Reworked to branch: use VivSpatialViewer when image layers exist, otherwise render SpatialViewerSimple using Viv's DetailView and DeckGL. vivLayerProps type tightened to ImageLayerConfig[].
Index / public surface
packages/vis/src/SpatialCanvas/index.tsx
Removed useSpatialCanvasStoreApi export; added VivSpatialViewer and exported ImageLayerConfig type; top-level logic updated to consider deck OR Viv layers for the "no layers" placeholder and docs/examples adjusted.
Layer types
packages/vis/src/SpatialCanvas/types.ts
Introduced ViewState2D/ViewState3D union ViewState; added ChannelConfig; added channels?: ChannelConfig to ImageLayerConfig; moved elementKey into BaseLayerConfig and removed duplicates from specific layer configs.
Layer data & loading
packages/vis/src/SpatialCanvas/useLayerData.ts
Added image-loading support and caching, computed per-image channel defaults, ImageLoaderData, and getVivLayerProps() returning ImageLayerConfig[] (merges defaults with overrides).
Image renderer updates
packages/vis/src/SpatialCanvas/renderers/imageRenderer.ts
Now defers image layer creation to the viewer (returns null); added extractChannelConfig() and async createImageLoader() using loadOmeZarr.
Points renderer
packages/vis/src/SpatialCanvas/renderers/pointsRenderer.ts
Added optional use3d?: boolean to PointsLayerRenderConfig and threaded it into position extraction so Z coordinate is used when use3d is true.
Core schemas exports
packages/core/src/schemas/index.ts
Exported spaceUnitSchema, timeUnitSchema, and runtime lists SPATIAL_UNITS, TIME_UNITS.
Transformations
packages/core/src/transformations/transformations.ts
CoordinateSystemRef.axes type changed to Axis[]; added mapSpatialValuesToXYZ() and updated Translation/Scale/Affine to map axis-named values to [x,y,z].

Sequence Diagram

sequenceDiagram
    participant SV as SpatialViewer
    participant LD as LayerData (useLayerData)
    participant VSV as VivSpatialViewer
    participant VIV as Viv (DetailView)
    participant DGL as DeckGL

    SV->>LD: request deckLayers and vivLayerProps()
    LD-->>SV: deckLayers, vivLayerProps (ImageLayerConfig[])
    SV->>SV: determine hasImageLayers
    alt has image layers
        SV->>VSV: render(vivLayerProps, extraLayers, viewState)
        VSV->>LD: request loader / channel defaults (if needed)
        LD-->>VSV: ImageLoaderData / ImageLayerConfig[]
        VSV->>VIV: create DetailView (width/height, loaders)
        VSV->>VSV: toVivViewState(), composeLayers()
        VSV->>DGL: render composed (Viv image layers + extra + scale bar)
        DGL-->>VSV: onViewStateChange / events
        VSV->>VSV: sync per-view state
    else no image layers
        SV->>DGL: render deckLayers via SpatialViewerSimple (DetailView + DeckGL)
        DGL-->>SV: events
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through channels, bright and deep,

Viv paints tiles while deck glides to keep,
Views sync and scale bars softly peep,
Loaders hum, defaults wake from sleep,
A rabbit cheers — new layers to reap!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: enabling SpatialCanvas to display images alongside other layer types.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch canvas_image

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/vis/src/SpatialCanvas/renderers/imageRenderer.ts (1)

27-33: ⚠️ Potential issue | 🔴 Critical

Fix channel selection typing to resolve TypeScript strict mode compilation failure.

The extractChannelConfig function returns a default object with optional z/c/t properties (line 69) while the return type signature (line 62) requires them as non-optional. This causes TypeScript compilation to fail at lines 73 and 80 when returning defaults or defaults.selections.

The downstream code in useLayerData.ts already expects selections with optional properties (uses Array<{ z?: number; c?: number; t?: number }> and constructs selections as [{}] with empty objects). The fix should make z, c, and t optional across all three locations:

  1. Line 31: ImageLayerRenderConfig.channels.selections
  2. Line 56: extractChannelConfig input parameter
  3. Line 62: extractChannelConfig return type
Proposed fix
-  selections: { z: number; c: number; t: number }[];
+  selections: { z?: number; c?: number; t?: number }[];

Apply the same change to line 56 (input parameter) and line 62 (return type).

🤖 Fix all issues with AI agents
In `@packages/vis/src/SpatialCanvas/index.tsx`:
- Around line 42-45: Two different types named ImageLayerConfig are being
re-exported (one from './types' and one from './useLayerData'), causing an
export conflict; update the re-export from './useLayerData' to alias its type to
a distinct name (e.g., ImageLayerConfigFromUseLayerData or
UseLayerImageLayerConfig) so consumers can import both without ambiguity and
update any internal references to the aliased symbol where needed (look for the
export statement that references ImageLayerConfig from './useLayerData' and
change it to an aliased export, then adjust any uses of that specific type).

In `@packages/vis/src/SpatialCanvas/renderers/pointsRenderer.ts`:
- Around line 77-78: Add an optional boolean use3d flag (default false) to
PointsLayerRenderConfig and update the getPosition accessor in pointsRenderer.ts
to return z = d[2]?.[index] when config.use3d is true, otherwise keep z = 0;
ensure PointsLayerRenderConfig's type declaration includes use3d, that callers
default or pass false to preserve current 2D behavior, and that getPosition
reads the flag (signature using {index, target, ...config}) so 3D coordinates
are only used when explicitly enabled.

In `@packages/vis/src/SpatialCanvas/useLayerData.ts`:
- Around line 322-338: The current logic in useLayerData (after
extractChannelConfig) uses sentinel-value checks to decide if user overrides
exist (e.g., checking channelConfig.colors[0][0] !== 255), which incorrectly
overrides legitimate user-provided values that equal those sentinels; update the
assignments for colors, contrastLimits, channelsVisible, and selections to rely
only on presence checks (e.g., channelConfig.colors.length > 0,
channelConfig.contrastLimits.length > 0, channelConfig.channelsVisible.length >
0, channelConfig.selections.length > 0) and otherwise fall back to imageData.*
or the hardcoded defaults, preserving the existing types for colors:
[number,number,number][], contrastLimits: [number,number][], channelsVisible:
boolean[], and selections: Array<{z?:number;c?:number;t?:number}>; keep
extractChannelConfig usage and simply remove the sentinel value comparisons so
legitimate user values that match former sentinels are honored.

In `@packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx`:
- Around line 131-151: fromVivViewState currently assigns vivViewState.zoom
(which can be number | [number, number]) directly to ViewState.zoom (expects
number); update fromVivViewState to normalize vivViewState.zoom by checking its
type and extracting the first element if it's an array, otherwise use the
numeric value, and default to 0 when undefined so the returned ViewState.zoom is
always a number (refer to function fromVivViewState and
VivViewState.vivViewState.zoom).
- Around line 65-69: The DeckGL ref is typed using the value DeckGL (causing CI
failure); remove the unused deckRef field from the VivSpatialViewerState
interface (symbol: VivSpatialViewerState and property: deckRef) and delete any
other unused deckRef usages in this file (mentions around the other occurrences
of deckRef at the later blocks). If you intend to keep a DeckGL ref, replace the
type with the correct DeckGL ref type (DeckGLRef) instead of DeckGL. Ensure all
references to deckRef (state, initializations, and any refs around
VivSpatialViewer) are either removed or updated to use DeckGLRef consistently.
🧹 Nitpick comments (1)
packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx (1)

293-307: Prefer cloning layers instead of mutating layer.props.

Direct mutation can bypass deck.gl's change detection. Cloning keeps updates explicit and safer. This pattern is already used elsewhere in this file (line 318-321), so adopting it here ensures consistency.

♻️ Suggested refactor
-    const vivLayers = Array.isArray(vivLayersResult) && vivLayersResult.length > 0
+    let vivLayers = Array.isArray(vivLayersResult) && vivLayersResult.length > 0
       ? (Array.isArray(vivLayersResult[0]) ? vivLayersResult[0] : vivLayersResult) as Layer[]
       : [];

     // Apply opacity and visibility to image layers if specified
     if (firstLayerProps.opacity !== undefined || firstLayerProps.visible !== undefined) {
-      for (const layer of vivLayers) {
-        if (firstLayerProps.opacity !== undefined && layer.props.opacity !== firstLayerProps.opacity) {
-          layer.props = { ...layer.props, opacity: firstLayerProps.opacity };
-        }
-        if (firstLayerProps.visible !== undefined && layer.props.visible !== firstLayerProps.visible) {
-          layer.props = { ...layer.props, visible: firstLayerProps.visible };
-        }
-      }
+      vivLayers = vivLayers.map((layer) =>
+        layer.clone({
+          ...(firstLayerProps.opacity !== undefined ? { opacity: firstLayerProps.opacity } : {}),
+          ...(firstLayerProps.visible !== undefined ? { visible: firstLayerProps.visible } : {}),
+        })
+      );
     }

Comment on lines 42 to +45
export type * from './types';
export { useSpatialViewState, useViewStateUrl } from './hooks';
export { VivSpatialViewer } from './VivSpatialViewer';
export type { ImageLayerConfig } from './useLayerData';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resolve duplicate ImageLayerConfig export.

Line 42 already re-exports ImageLayerConfig from ./types, and Line 45 re-exports another ImageLayerConfig from ./useLayerData. These are different shapes, which can cause a TS export conflict or ambiguous API for consumers.

🔧 Suggested fix (alias one of the exports)
-export type { ImageLayerConfig } from './useLayerData';
+export type { ImageLayerConfig as VivImageLayerConfig } from './useLayerData';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type * from './types';
export { useSpatialViewState, useViewStateUrl } from './hooks';
export { VivSpatialViewer } from './VivSpatialViewer';
export type { ImageLayerConfig } from './useLayerData';
export type * from './types';
export { useSpatialViewState, useViewStateUrl } from './hooks';
export { VivSpatialViewer } from './VivSpatialViewer';
export type { ImageLayerConfig as VivImageLayerConfig } from './useLayerData';
🤖 Prompt for AI Agents
In `@packages/vis/src/SpatialCanvas/index.tsx` around lines 42 - 45, Two different
types named ImageLayerConfig are being re-exported (one from './types' and one
from './useLayerData'), causing an export conflict; update the re-export from
'./useLayerData' to alias its type to a distinct name (e.g.,
ImageLayerConfigFromUseLayerData or UseLayerImageLayerConfig) so consumers can
import both without ambiguity and update any internal references to the aliased
symbol where needed (look for the export statement that references
ImageLayerConfig from './useLayerData' and change it to an aliased export, then
adjust any uses of that specific type).

Comment on lines +322 to +338
// Extract channel config (user-provided overrides)
const channelConfig = extractChannelConfig(config);

// Use user-provided config if available, otherwise use computed defaults
const colors: [number, number, number][] = channelConfig.colors.length > 0 && channelConfig.colors[0][0] !== 255
? channelConfig.colors
: (imageData.colors || [[255, 255, 255] as [number, number, number]]);
const contrastLimits: [number, number][] = channelConfig.contrastLimits.length > 0 && channelConfig.contrastLimits[0][1] !== 65535
? channelConfig.contrastLimits
: (imageData.contrastLimits || [[0, 65535] as [number, number]]);
const channelsVisible: boolean[] = channelConfig.channelsVisible.length > 0
? channelConfig.channelsVisible
: (imageData.channelsVisible || [true]);
const selections: Array<{ z?: number; c?: number; t?: number }> = channelConfig.selections.length > 0
? channelConfig.selections
: (imageData.selections || [{}]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid sentinel-value checks for user overrides.

At Line 326-334, the code infers “user provided” values by comparing against default values. This breaks for legitimate user settings that equal defaults (e.g., color [255, 0, 0] or contrast [0, 65535]), which will be silently overridden.

✅ Suggested fix (use presence checks instead of sentinel values)
-      const channelConfig = extractChannelConfig(config);
-
-      const colors: [number, number, number][] = channelConfig.colors.length > 0 && channelConfig.colors[0][0] !== 255 
-        ? channelConfig.colors 
-        : (imageData.colors || [[255, 255, 255] as [number, number, number]]);
-      const contrastLimits: [number, number][] = channelConfig.contrastLimits.length > 0 && channelConfig.contrastLimits[0][1] !== 65535
-        ? channelConfig.contrastLimits
-        : (imageData.contrastLimits || [[0, 65535] as [number, number]]);
-      const channelsVisible: boolean[] = channelConfig.channelsVisible.length > 0
-        ? channelConfig.channelsVisible
-        : (imageData.channelsVisible || [true]);
-      const selections: Array<{ z?: number; c?: number; t?: number }> = channelConfig.selections.length > 0
-        ? channelConfig.selections
-        : (imageData.selections || [{}]);
+      const overrides = config.channels ?? {};
+      const colors: [number, number, number][] = overrides.colors ?? imageData.colors ?? [[255, 255, 255]];
+      const contrastLimits: [number, number][] = overrides.contrastLimits ?? imageData.contrastLimits ?? [[0, 65535]];
+      const channelsVisible: boolean[] = overrides.channelsVisible ?? imageData.channelsVisible ?? [true];
+      const selections: Array<{ z?: number; c?: number; t?: number }> = overrides.selections ?? imageData.selections ?? [{}];
🤖 Prompt for AI Agents
In `@packages/vis/src/SpatialCanvas/useLayerData.ts` around lines 322 - 338, The
current logic in useLayerData (after extractChannelConfig) uses sentinel-value
checks to decide if user overrides exist (e.g., checking
channelConfig.colors[0][0] !== 255), which incorrectly overrides legitimate
user-provided values that equal those sentinels; update the assignments for
colors, contrastLimits, channelsVisible, and selections to rely only on presence
checks (e.g., channelConfig.colors.length > 0,
channelConfig.contrastLimits.length > 0, channelConfig.channelsVisible.length >
0, channelConfig.selections.length > 0) and otherwise fall back to imageData.*
or the hardcoded defaults, preserving the existing types for colors:
[number,number,number][], contrastLimits: [number,number][], channelsVisible:
boolean[], and selections: Array<{z?:number;c?:number;t?:number}>; keep
extractChannelConfig usage and simply remove the sentinel value comparisons so
legitimate user values that match former sentinels are honored.

Comment on lines 131 to 151
function toVivViewState(viewState: ViewState, viewId: string, width: number, height: number): VivViewState {
const [x, y, z = 0] = viewState.target;
return {
id: viewId,
target: [x, y, z],
zoom: viewState.zoom,
// @ts-expect-error - Viv ViewState may have additional properties
width,
height,
};
}

/**
* Convert Viv ViewState to SpatialCanvas ViewState format
*/
function fromVivViewState(vivViewState: VivViewState): ViewState {
const target = vivViewState.target as [number, number, number];
return {
target: [target[0], target[1]],
zoom: vivViewState.zoom,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the file and read it to confirm the current state
find . -name "VivSpatialViewer.tsx" -type f

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 128


🏁 Script executed:

# Search for VivViewState type definition
rg "type VivViewState|interface VivViewState" -A 10

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 1316


🏁 Script executed:

# Search for ViewState type definition
rg "type ViewState|interface ViewState" -A 10

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 854


🏁 Script executed:

# Let's read the VivSpatialViewer.tsx file to see the imports and the actual code
head -50 packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 2032


🏁 Script executed:

# Search for deck.gl imports to understand where these types come from
rg "import.*OrthographicViewState|import.*OrbitViewState" packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx -A 2 -B 2

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 399


🏁 Script executed:

# Let's also check what the actual fromVivViewState function looks like currently
sed -n '131,160p' packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 990


🏁 Script executed:

# Check package.json to see deck.gl version
rg "deck.gl" package.json

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 57


🌐 Web query:

deck.gl OrthographicViewState zoom type documentation

💡 Result:

In deck.gl, the viewState for an OrthographicView uses a zoom field with this type:

  • zoom?: number | [number, number]

Meaning:

  • Single number: uniform zoom on both axes. zoom: 0 maps 1 world unit to 1 screen pixel; each +1 doubles scale (1 → 2×, 2 → 4×, etc.). (deck.gl)
  • Tuple [zoomX, zoomY]: independent zoom for X and Y axes. (deck.gl)

Citations:


🏁 Script executed:

# Check the exact code around line 150 with context to see if there's a ts-expect-error
sed -n '140,155p' packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 499


🏁 Script executed:

# Search for any TypeScript errors or ts-expect-error comments in this file
rg "ts-expect-error|ts-ignore" packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 413


Normalize zoom when converting from Viv view state.

The zoom property from deck.gl's OrthographicViewState can be number | [number, number], but ViewState.zoom expects only a number. The current code at line 150 directly assigns the mismatched type, causing a TypeScript compilation error. Normalize to a single number by extracting the first element if it's an array, and defaulting to 0 if undefined.

Proposed fix
 function fromVivViewState(vivViewState: VivViewState): ViewState {
   const target = vivViewState.target as [number, number, number];
+  const zoomValue = Array.isArray(vivViewState.zoom)
+    ? vivViewState.zoom[0]
+    : (vivViewState.zoom ?? 0);
   return {
     target: [target[0], target[1]],
-    zoom: vivViewState.zoom,
+    zoom: zoomValue,
   };
 }
🧰 Tools
🪛 GitHub Actions: Test

[error] 150-150: Type 'number | [number, number] | undefined' is not assignable to type 'number'.

🤖 Prompt for AI Agents
In `@packages/vis/src/SpatialCanvas/VivSpatialViewer.tsx` around lines 131 - 151,
fromVivViewState currently assigns vivViewState.zoom (which can be number |
[number, number]) directly to ViewState.zoom (expects number); update
fromVivViewState to normalize vivViewState.zoom by checking its type and
extracting the first element if it's an array, otherwise use the numeric value,
and default to 0 when undefined so the returned ViewState.zoom is always a
number (refer to function fromVivViewState and VivViewState.vivViewState.zoom).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/vis/src/SpatialCanvas/renderers/pointsRenderer.ts (1)

26-43: ⚠️ Potential issue | 🟡 Minor

Add default value and null-coalescing for z-coordinate to prevent invalid positions with 2D data.

When use3d is true but pointData.data has only two rows, d[2]?.[index] returns undefined, resulting in invalid position vectors [x, y, undefined] passed to deck.gl. Default use3d to false and apply null-coalescing on the z-coordinate:

Suggested fix
-    use3d,
+    use3d = false,
...
-    getPosition: (_d, {index, target}) => [d[0][index], d[1][index], use3d ? d[2]?.[index] : 0],
+    getPosition: (_d, {index, target}) => [d[0][index], d[1][index], use3d ? (d[2]?.[index] ?? 0) : 0],
packages/vis/src/SpatialCanvas/types.ts (1)

30-38: ⚠️ Potential issue | 🟡 Minor

Remove redundant elementKey declaration from ImageLayerConfig.

ImageLayerConfig inherits elementKey: string from BaseLayerConfig (line 37), making the redeclaration on line 54 redundant and a maintenance liability. All existing usages already provide elementKey when creating layers, so this removal is safe internally.

🧹 Remove redundant redeclaration
 export interface ImageLayerConfig extends BaseLayerConfig {
   type: 'image';
-  elementKey: string;
   /** Optional: Advanced channel configuration (for full Viv controls) */
   channels?: ChannelConfig;
 }
🧹 Nitpick comments (1)
packages/vis/src/SpatialCanvas/renderers/pointsRenderer.ts (1)

20-24: Align PointData.data with the TypedArray intent.
The comment says TypedArray but the type is number[][]. If runtime data is typed arrays, the current typing is misleading. Consider widening the type to accept typed arrays.

♻️ Suggested type alignment
 export interface PointData {
   shape: number[];
-  // this should most definitely be TypedArray...
-  data: number[][];
+  // Accept TypedArrays while keeping index access.
+  data: ArrayLike<number>[];
 }

Please confirm the runtime shape (Array vs TypedArray) before updating.

…aking care of input/output order (but not attempting to reconcile units).
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/core/src/schemas/index.ts`:
- Around line 175-183: The getEnumValues function is using the internal
_def.values and an incorrect type for z.ZodEnum; update it to use the public
schema.options property and fix the type signature to a Zod enum tuple (e.g.,
z.ZodEnum<readonly string[]> or z.ZodEnum<[string, ...string[]]>). Specifically,
in getEnumValues replace the cast/access of _def.values with schema.options and
ensure the function signature and types reflect a Zod enum tuple while still
returning new Set<string>(schema.options).

In `@packages/core/src/transformations/transformations.ts`:
- Around line 32-89: The function mapSpatialValuesToXYZ can misinterpret
legitimate values equal to defaultValue as unassigned; change the logic to track
explicit assignment booleans (e.g., xSet, ySet, zSet) instead of comparing
against defaultValue: set the corresponding flag to true whenever you assign
xValue/yValue/zValue inside the first loop (for axis.type === 'space'), use
those flags when deciding whether to skip assignment, and in the fallback loop
only assign to dimensions whose flag is still false (and then set the flag).
This preserves real values equal to defaultValue and prevents incorrect
overwrites from the spatialAxesInOrder fallback.
🧹 Nitpick comments (4)
packages/core/src/schemas/index.ts (1)

185-197: Consider using as const assertion for stronger typing.

The exported constants are typed as Set<string>, losing the specific unit string literal types. If downstream code needs to check membership with type narrowing, consider exporting the schema's options array directly or adding type assertions.

This is minor since the primary use case is runtime membership checking.

packages/core/src/transformations/transformations.ts (3)

6-16: Address or remove the inline TODO question.

Line 11 contains an open question: "^^ Do we really need to be supporting this? ^^". This should either be resolved before merging or converted to a proper TODO/FIXME with a tracking issue.

Would you like me to open an issue to track the decision on whether backward compatibility for axes-less transformations should be maintained?


37-42: Debug warning should be conditional or use structured logging.

This console.warn will emit on every call with legacy data. Consider guarding it with a debug flag, using a structured logger, or removing it if the backward compatibility path is stable.


191-209: Validation warnings are helpful but could be more actionable.

The dimension mismatch warnings are useful for debugging. Consider including guidance on what the user should do (e.g., "verify your input data" or "check axis configuration").

Minor: The warning on line 202 mentions "Assuming affine matrix represents spatial dimensions only" which duplicates across both branches—this could be extracted.

Comment on lines 175 to +183
/**
* SHOULD contain the field “unit” to specify the physical unit of this dimension.
* Extract enum values from a zod enum schema as a Set of strings.
* Accesses the internal _def.values array from zod enum schemas.
*/
function getEnumValues(schema: z.ZodEnum<Record<string, string>>): Set<string> {
// zod enum schemas have an internal _def.values array
const values = (schema as unknown as { _def: { values: readonly string[] } })._def.values;
return new Set(values);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Zod enum options property public API documentation

💡 Result:

z.enum(...) (a ZodEnum) exposes an .options property as part of the documented API, which returns the allowed values as a tuple (e.g. ["Salmon", "Tuna", "Trout"]). See the enums documentation showing FishEnum.options. [1], [2]


🏁 Script executed:

# First, check the Zod version and the file in question
cd packages/core && cat package.json | grep -A 2 -B 2 '"zod"'

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 177


🏁 Script executed:

# Look at the actual file to confirm the code snippet
sed -n '175,183p' src/schemas/index.ts

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 138


🏁 Script executed:

# Check if there are other uses of this pattern in the codebase
rg "_def\.values" --type ts

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 395


🏁 Script executed:

# Check if .options is used elsewhere in the codebase
rg "\.options" packages/core/src --type ts -B 2 -A 2

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 396


🏁 Script executed:

# Navigate to the correct directory and read the file
cat -n packages/core/src/schemas/index.ts | sed -n '170,190p'

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 909


🏁 Script executed:

# Check for imports of zod to understand version
rg "import.*from.*['\"]zod" packages/core/src/schemas/index.ts -A 5 -B 5

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 497


🏁 Script executed:

# Check how getEnumValues is used in the codebase
rg "getEnumValues" packages/core --type ts -B 2 -A 2

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 1197


🏁 Script executed:

# Find the definitions of spaceUnitSchema and timeUnitSchema
rg "spaceUnitSchema\s*=" packages/core/src/schemas/index.ts -B 2 -A 5

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 313


🏁 Script executed:

# Find timeUnitSchema definition
rg "timeUnitSchema\s*=" packages/core/src/schemas/index.ts -B 2 -A 5

Repository: Taylor-CCB-Group/SpatialData.js

Length of output: 311


Replace _def.values with the public .options property.

The function accesses _def.values, which is an internal implementation detail of Zod that may break on version upgrades. Additionally, the type signature is incorrect—z.ZodEnum uses a tuple type, not Record<string, string>.

Use the public .options property instead:

♻️ Suggested refactor
-/**
- * Extract enum values from a zod enum schema as a Set of strings.
- * Accesses the internal _def.values array from zod enum schemas.
- */
-function getEnumValues(schema: z.ZodEnum<Record<string, string>>): Set<string> {
-  // zod enum schemas have an internal _def.values array
-  const values = (schema as unknown as { _def: { values: readonly string[] } })._def.values;
-  return new Set(values);
-}
+/**
+ * Extract enum values from a zod enum schema as a Set of strings.
+ * Uses the public `.options` property available on zod enum schemas.
+ */
+function getEnumValues<T extends readonly [string, ...string[]]>(schema: z.ZodEnum<T>): Set<T[number]> {
+  return new Set(schema.options);
+}
🤖 Prompt for AI Agents
In `@packages/core/src/schemas/index.ts` around lines 175 - 183, The getEnumValues
function is using the internal _def.values and an incorrect type for z.ZodEnum;
update it to use the public schema.options property and fix the type signature
to a Zod enum tuple (e.g., z.ZodEnum<readonly string[]> or z.ZodEnum<[string,
...string[]]>). Specifically, in getEnumValues replace the cast/access of
_def.values with schema.options and ensure the function signature and types
reflect a Zod enum tuple while still returning new Set<string>(schema.options).

Comment on lines +32 to 89
function mapSpatialValuesToXYZ(
values: number[],
axes?: Axis[],
defaultValue = 1
): [number, number, number] {
if (!axes || axes.length === 0) {
// No axes specified - use direct mapping (backward compatibility)
console.warn("legacy data with no input axis specification - not really expecting to get here?")
const [x = defaultValue, y = defaultValue, z = defaultValue] = values;
return [x, y, z];
}

// Map values to Matrix4 dimensions based on axis name
// Matrix4 uses standard x, y, z ordering
let xValue = defaultValue;
let yValue = defaultValue;
let zValue = defaultValue;

// Track spatial axes in order for fallback mapping
const spatialAxesInOrder: Array<{ name: string; value: number }> = [];

for (let i = 0; i < axes.length && i < values.length; i++) {
const axis = axes[i];
if (axis.type === 'space') {
const axisName = axis.name.toLowerCase();
const value = values[i] ?? defaultValue;

// Map by exact axis name match (most common case: "x", "y", "z")
if (axisName === 'x' && xValue === defaultValue) {
xValue = value;
} else if (axisName === 'y' && yValue === defaultValue) {
yValue = value;
} else if (axisName === 'z' && zValue === defaultValue) {
zValue = value;
} else {
// Store for fallback mapping if name doesn't match exactly
spatialAxesInOrder.push({ name: axisName, value });
}
}
}

// Fallback: if we have unmapped spatial axes, map them in order
// This handles cases where axis names don't match x/y/z exactly
// but preserves the spatial ordering (first → x, second → y, third → z)
let fallbackIndex = 0;
for (const { value } of spatialAxesInOrder) {
if (fallbackIndex === 0 && xValue === defaultValue) {
xValue = value;
} else if (fallbackIndex === 1 && yValue === defaultValue) {
yValue = value;
} else if (fallbackIndex === 2 && zValue === defaultValue) {
zValue = value;
}
fallbackIndex++;
}

return [xValue, yValue, zValue];
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: Axis values equal to defaultValue can be incorrectly overwritten.

The conditions on lines 60-68 check xValue === defaultValue before assigning. If a legitimate axis value happens to equal the default (e.g., scale = [1, 2, 3] where x-scale is actually 1), the value won't be assigned initially, and a later fallback axis could overwrite it.

Consider tracking whether each dimension has been explicitly set instead of comparing against the default value:

🐛 Proposed fix using explicit assignment tracking
   // Map values to Matrix4 dimensions based on axis name
   // Matrix4 uses standard x, y, z ordering
   let xValue = defaultValue;
   let yValue = defaultValue;
   let zValue = defaultValue;
+  let xSet = false;
+  let ySet = false;
+  let zSet = false;
   
   // Track spatial axes in order for fallback mapping
   const spatialAxesInOrder: Array<{ name: string; value: number }> = [];
   
   for (let i = 0; i < axes.length && i < values.length; i++) {
     const axis = axes[i];
     if (axis.type === 'space') {
       const axisName = axis.name.toLowerCase();
       const value = values[i] ?? defaultValue;
       
       // Map by exact axis name match (most common case: "x", "y", "z")
-      if (axisName === 'x' && xValue === defaultValue) {
+      if (axisName === 'x' && !xSet) {
         xValue = value;
-      } else if (axisName === 'y' && yValue === defaultValue) {
+        xSet = true;
+      } else if (axisName === 'y' && !ySet) {
         yValue = value;
-      } else if (axisName === 'z' && zValue === defaultValue) {
+        ySet = true;
+      } else if (axisName === 'z' && !zSet) {
         zValue = value;
+        zSet = true;
       } else {
         // Store for fallback mapping if name doesn't match exactly
         spatialAxesInOrder.push({ name: axisName, value });
       }
     }
   }
   
   // Fallback: if we have unmapped spatial axes, map them in order
-  let fallbackIndex = 0;
   for (const { value } of spatialAxesInOrder) {
-    if (fallbackIndex === 0 && xValue === defaultValue) {
+    if (!xSet) {
       xValue = value;
-    } else if (fallbackIndex === 1 && yValue === defaultValue) {
+      xSet = true;
+    } else if (!ySet) {
       yValue = value;
-    } else if (fallbackIndex === 2 && zValue === defaultValue) {
+      ySet = true;
+    } else if (!zSet) {
       zValue = value;
+      zSet = true;
     }
-    fallbackIndex++;
   }
🤖 Prompt for AI Agents
In `@packages/core/src/transformations/transformations.ts` around lines 32 - 89,
The function mapSpatialValuesToXYZ can misinterpret legitimate values equal to
defaultValue as unassigned; change the logic to track explicit assignment
booleans (e.g., xSet, ySet, zSet) instead of comparing against defaultValue: set
the corresponding flag to true whenever you assign xValue/yValue/zValue inside
the first loop (for axis.type === 'space'), use those flags when deciding
whether to skip assignment, and in the fallback loop only assign to dimensions
whose flag is still false (and then set the flag). This preserves real values
equal to defaultValue and prevents incorrect overwrites from the
spatialAxesInOrder fallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant