mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Merge pull request #61 from jhbruhn/copilot/add-computed-values-selectors
Add computed selectors to pattern store for memoized transformations
This commit is contained in:
commit
656f501a92
5 changed files with 316 additions and 2 deletions
|
|
@ -112,8 +112,9 @@ function App() {
|
|||
// Use cached uploadedPesData if available, otherwise recalculate
|
||||
if (cachedUploadedPesData) {
|
||||
// Use the exact uploaded data from cache
|
||||
// Calculate the adjusted offset (same logic as upload)
|
||||
// Calculate the adjusted offset (same logic as upload hook)
|
||||
if (rotation !== 0) {
|
||||
// Calculate center shift using the same helper as store selectors
|
||||
const originalCenter = calculatePatternCenter(originalPesData.bounds);
|
||||
const rotatedCenter = calculatePatternCenter(
|
||||
cachedUploadedPesData.bounds,
|
||||
|
|
@ -140,6 +141,7 @@ function App() {
|
|||
}
|
||||
} else if (rotation !== 0) {
|
||||
// Fallback: recalculate if no cached uploaded data (shouldn't happen for new uploads)
|
||||
// This uses the same transformation logic as usePatternRotationUpload hook
|
||||
console.warn("[App] No cached uploaded data, recalculating rotation");
|
||||
const rotatedStitches = transformStitchesRotation(
|
||||
originalPesData.stitches,
|
||||
|
|
@ -152,6 +154,7 @@ function App() {
|
|||
const decoded = decodePenData(penData);
|
||||
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
|
||||
|
||||
// Calculate center shift using the same helper as store selectors
|
||||
const originalCenter = calculatePatternCenter(originalPesData.bounds);
|
||||
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
||||
const centerShiftX = rotatedCenter.x - originalCenter.x;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export const PatternLayer = memo(function PatternLayer({
|
|||
onTransformEnd,
|
||||
attachTransformer,
|
||||
}: PatternLayerProps) {
|
||||
// Memoize center calculation - this is the pattern center calculation
|
||||
// Note: We keep this local calculation here since the component receives
|
||||
// pesData as a prop which could be either original or uploaded pattern
|
||||
const center = useMemo(
|
||||
() => calculatePatternCenter(pesData.bounds),
|
||||
[pesData.bounds],
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ export interface UsePatternRotationUploadReturn {
|
|||
* - Center shift calculation to maintain visual position
|
||||
* - Upload orchestration with proper caching
|
||||
*
|
||||
* Note: This hook operates on passed parameters rather than store state,
|
||||
* allowing it to be used as a callback handler. The center calculations
|
||||
* use the same helpers as the store selectors for consistency.
|
||||
*
|
||||
* @param params - Upload and store functions
|
||||
* @returns Upload handler function
|
||||
*/
|
||||
|
|
@ -78,7 +82,8 @@ export function usePatternRotationUpload({
|
|||
// Calculate bounds from the DECODED stitches (the actual data that will be rendered)
|
||||
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
|
||||
|
||||
// Calculate the center of the rotated pattern
|
||||
// Calculate the center shift caused by rotation
|
||||
// Uses the same calculatePatternCenter helper as store selectors
|
||||
const originalCenter = calculatePatternCenter(pesData.bounds);
|
||||
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
||||
const centerShiftX = rotatedCenter.x - originalCenter.x;
|
||||
|
|
|
|||
200
src/stores/usePatternStore.test.ts
Normal file
200
src/stores/usePatternStore.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
usePatternStore,
|
||||
selectPatternCenter,
|
||||
selectUploadedPatternCenter,
|
||||
selectRotatedBounds,
|
||||
selectRotationCenterShift,
|
||||
} from "./usePatternStore";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
|
||||
// Mock pattern data for testing
|
||||
const createMockPesData = (
|
||||
bounds = {
|
||||
minX: -100,
|
||||
maxX: 100,
|
||||
minY: -50,
|
||||
maxY: 50,
|
||||
},
|
||||
): PesPatternData => ({
|
||||
stitches: [[0, 0, 0, 0]],
|
||||
threads: [],
|
||||
uniqueColors: [],
|
||||
penData: new Uint8Array(),
|
||||
penStitches: {
|
||||
stitches: [],
|
||||
colorBlocks: [],
|
||||
bounds: { minX: 0, maxX: 0, minY: 0, maxY: 0 },
|
||||
},
|
||||
colorCount: 1,
|
||||
stitchCount: 1,
|
||||
bounds,
|
||||
});
|
||||
|
||||
describe("usePatternStore selectors", () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
const state = usePatternStore.getState();
|
||||
state.setPattern(createMockPesData(), "test.pes");
|
||||
state.resetPatternOffset();
|
||||
state.resetRotation();
|
||||
state.clearUploadedPattern();
|
||||
});
|
||||
|
||||
describe("selectPatternCenter", () => {
|
||||
it("should return null when no pattern is loaded", () => {
|
||||
// Clear the pattern
|
||||
usePatternStore.setState({ pesData: null });
|
||||
|
||||
const center = selectPatternCenter(usePatternStore.getState());
|
||||
expect(center).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate center correctly for symmetric bounds", () => {
|
||||
const state = usePatternStore.getState();
|
||||
const center = selectPatternCenter(state);
|
||||
|
||||
expect(center).not.toBeNull();
|
||||
expect(center!.x).toBe(0); // (minX + maxX) / 2 = (-100 + 100) / 2 = 0
|
||||
expect(center!.y).toBe(0); // (minY + maxY) / 2 = (-50 + 50) / 2 = 0
|
||||
});
|
||||
|
||||
it("should calculate center correctly for asymmetric bounds", () => {
|
||||
const pesData = createMockPesData({
|
||||
minX: 0,
|
||||
maxX: 200,
|
||||
minY: 0,
|
||||
maxY: 100,
|
||||
});
|
||||
usePatternStore.setState({ pesData });
|
||||
|
||||
const state = usePatternStore.getState();
|
||||
const center = selectPatternCenter(state);
|
||||
|
||||
expect(center).not.toBeNull();
|
||||
expect(center!.x).toBe(100); // (0 + 200) / 2
|
||||
expect(center!.y).toBe(50); // (0 + 100) / 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectUploadedPatternCenter", () => {
|
||||
it("should return null when no uploaded pattern", () => {
|
||||
const state = usePatternStore.getState();
|
||||
const center = selectUploadedPatternCenter(state);
|
||||
|
||||
expect(center).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate center of uploaded pattern", () => {
|
||||
const uploadedData = createMockPesData({
|
||||
minX: 50,
|
||||
maxX: 150,
|
||||
minY: 25,
|
||||
maxY: 75,
|
||||
});
|
||||
usePatternStore
|
||||
.getState()
|
||||
.setUploadedPattern(uploadedData, { x: 0, y: 0 });
|
||||
|
||||
const state = usePatternStore.getState();
|
||||
const center = selectUploadedPatternCenter(state);
|
||||
|
||||
expect(center).not.toBeNull();
|
||||
expect(center!.x).toBe(100); // (50 + 150) / 2
|
||||
expect(center!.y).toBe(50); // (25 + 75) / 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRotatedBounds", () => {
|
||||
it("should return original bounds when no rotation", () => {
|
||||
const state = usePatternStore.getState();
|
||||
const result = selectRotatedBounds(state);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.bounds).toEqual({
|
||||
minX: -100,
|
||||
maxX: 100,
|
||||
minY: -50,
|
||||
maxY: 50,
|
||||
});
|
||||
expect(result!.center).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("should return null when no pattern", () => {
|
||||
usePatternStore.setState({ pesData: null });
|
||||
const state = usePatternStore.getState();
|
||||
const result = selectRotatedBounds(state);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate rotated bounds for 90 degree rotation", () => {
|
||||
usePatternStore.getState().setPatternRotation(90);
|
||||
const state = usePatternStore.getState();
|
||||
const result = selectRotatedBounds(state);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// After 90° rotation, X and Y bounds should swap
|
||||
expect(result!.bounds.minX).toBeCloseTo(-50, 0);
|
||||
expect(result!.bounds.maxX).toBeCloseTo(50, 0);
|
||||
expect(result!.bounds.minY).toBeCloseTo(-100, 0);
|
||||
expect(result!.bounds.maxY).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it("should expand bounds for 45 degree rotation", () => {
|
||||
usePatternStore.getState().setPatternRotation(45);
|
||||
const state = usePatternStore.getState();
|
||||
const result = selectRotatedBounds(state);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// After 45° rotation, bounds should expand
|
||||
expect(Math.abs(result!.bounds.minX)).toBeGreaterThan(100);
|
||||
expect(Math.abs(result!.bounds.minY)).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRotationCenterShift", () => {
|
||||
it("should return zero shift when no rotation", () => {
|
||||
const state = usePatternStore.getState();
|
||||
const rotatedBounds = state.pesData!.bounds;
|
||||
const shift = selectRotationCenterShift(state, rotatedBounds);
|
||||
|
||||
expect(shift).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("should return null when no pattern", () => {
|
||||
usePatternStore.setState({ pesData: null });
|
||||
const state = usePatternStore.getState();
|
||||
const shift = selectRotationCenterShift(state, {
|
||||
minX: 0,
|
||||
maxX: 100,
|
||||
minY: 0,
|
||||
maxY: 100,
|
||||
});
|
||||
|
||||
expect(shift).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate center shift for asymmetric pattern", () => {
|
||||
const pesData = createMockPesData({
|
||||
minX: 0,
|
||||
maxX: 200,
|
||||
minY: 0,
|
||||
maxY: 100,
|
||||
});
|
||||
usePatternStore.setState({ pesData });
|
||||
usePatternStore.getState().setPatternRotation(90);
|
||||
|
||||
const state = usePatternStore.getState();
|
||||
const rotatedBounds = selectRotatedBounds(state)!.bounds;
|
||||
const shift = selectRotationCenterShift(state, rotatedBounds);
|
||||
|
||||
expect(shift).not.toBeNull();
|
||||
// Original center: (100, 50)
|
||||
// After 90° rotation around center, new center should be slightly different
|
||||
// due to the asymmetric bounds
|
||||
expect(shift!.x).toBeCloseTo(0, 0);
|
||||
expect(shift!.y).toBeCloseTo(0, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { create } from "zustand";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { PesPatternData } from "../formats/import/pesImporter";
|
||||
import { onPatternDeleted } from "./storeEvents";
|
||||
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
|
||||
import { calculateRotatedBounds } from "../utils/rotationUtils";
|
||||
|
||||
interface PatternState {
|
||||
// Original pattern (pre-upload)
|
||||
|
|
@ -29,6 +32,24 @@ interface PatternState {
|
|||
resetRotation: () => void;
|
||||
}
|
||||
|
||||
// Computed value types
|
||||
export interface PatternCenter {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PatternBounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface TransformedBounds {
|
||||
bounds: PatternBounds;
|
||||
center: PatternCenter;
|
||||
}
|
||||
|
||||
export const usePatternStore = create<PatternState>((set) => ({
|
||||
// Initial state - original pattern
|
||||
pesData: null,
|
||||
|
|
@ -123,6 +144,88 @@ export const useUploadedPatternOffset = () =>
|
|||
export const usePatternRotation = () =>
|
||||
usePatternStore((state) => state.patternRotation);
|
||||
|
||||
// Computed selectors (memoized by Zustand)
|
||||
// These provide single source of truth for derived state
|
||||
|
||||
/**
|
||||
* Select the geometric center of the pattern's bounds
|
||||
*/
|
||||
export const selectPatternCenter = (
|
||||
state: PatternState,
|
||||
): PatternCenter | null => {
|
||||
if (!state.pesData) return null;
|
||||
return calculatePatternCenter(state.pesData.bounds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the center of the uploaded pattern's bounds
|
||||
*/
|
||||
export const selectUploadedPatternCenter = (
|
||||
state: PatternState,
|
||||
): PatternCenter | null => {
|
||||
if (!state.uploadedPesData) return null;
|
||||
return calculatePatternCenter(state.uploadedPesData.bounds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the rotated bounds of the current pattern
|
||||
* Returns original bounds if no rotation or no pattern
|
||||
*/
|
||||
export const selectRotatedBounds = (
|
||||
state: PatternState,
|
||||
): TransformedBounds | null => {
|
||||
if (!state.pesData) return null;
|
||||
|
||||
const bounds =
|
||||
state.patternRotation && state.patternRotation !== 0
|
||||
? calculateRotatedBounds(state.pesData.bounds, state.patternRotation)
|
||||
: state.pesData.bounds;
|
||||
|
||||
const center = calculatePatternCenter(bounds);
|
||||
|
||||
return { bounds, center };
|
||||
};
|
||||
|
||||
/**
|
||||
* Select the center shift caused by rotation
|
||||
* This is used to adjust the offset when rotation is applied
|
||||
* Returns null if no pattern or no rotation
|
||||
*/
|
||||
export const selectRotationCenterShift = (
|
||||
state: PatternState,
|
||||
rotatedBounds: PatternBounds,
|
||||
): { x: number; y: number } | null => {
|
||||
if (!state.pesData) return null;
|
||||
if (!state.patternRotation || state.patternRotation === 0)
|
||||
return { x: 0, y: 0 };
|
||||
|
||||
const originalCenter = calculatePatternCenter(state.pesData.bounds);
|
||||
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
||||
|
||||
return {
|
||||
x: rotatedCenter.x - originalCenter.x,
|
||||
y: rotatedCenter.y - originalCenter.y,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get pattern center (memoized with shallow comparison)
|
||||
*/
|
||||
export const usePatternCenter = () =>
|
||||
usePatternStore(useShallow(selectPatternCenter));
|
||||
|
||||
/**
|
||||
* Hook to get uploaded pattern center (memoized with shallow comparison)
|
||||
*/
|
||||
export const useUploadedPatternCenter = () =>
|
||||
usePatternStore(useShallow(selectUploadedPatternCenter));
|
||||
|
||||
/**
|
||||
* Hook to get rotated bounds (memoized with shallow comparison)
|
||||
*/
|
||||
export const useRotatedBounds = () =>
|
||||
usePatternStore(useShallow(selectRotatedBounds));
|
||||
|
||||
// Subscribe to pattern deleted event.
|
||||
// This subscription is intended to persist for the lifetime of the application,
|
||||
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
|
||||
|
|
|
|||
Loading…
Reference in a new issue