Compare commits

..

No commits in common. "212d21e0656ad5d4d672b7ddb7ad46cf1d230a29" and "a173ee33a491e7440563b92a988dd5e3d3a6e5a6" have entirely different histories.

8 changed files with 156 additions and 593 deletions

View file

@ -112,9 +112,8 @@ function App() {
// Use cached uploadedPesData if available, otherwise recalculate // Use cached uploadedPesData if available, otherwise recalculate
if (cachedUploadedPesData) { if (cachedUploadedPesData) {
// Use the exact uploaded data from cache // Use the exact uploaded data from cache
// Calculate the adjusted offset (same logic as upload hook) // Calculate the adjusted offset (same logic as upload)
if (rotation !== 0) { if (rotation !== 0) {
// Calculate center shift using the same helper as store selectors
const originalCenter = calculatePatternCenter(originalPesData.bounds); const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter( const rotatedCenter = calculatePatternCenter(
cachedUploadedPesData.bounds, cachedUploadedPesData.bounds,
@ -141,7 +140,6 @@ function App() {
} }
} else if (rotation !== 0) { } else if (rotation !== 0) {
// Fallback: recalculate if no cached uploaded data (shouldn't happen for new uploads) // 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"); console.warn("[App] No cached uploaded data, recalculating rotation");
const rotatedStitches = transformStitchesRotation( const rotatedStitches = transformStitchesRotation(
originalPesData.stitches, originalPesData.stitches,
@ -154,7 +152,6 @@ function App() {
const decoded = decodePenData(penData); const decoded = decodePenData(penData);
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded); const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate center shift using the same helper as store selectors
const originalCenter = calculatePatternCenter(originalPesData.bounds); const originalCenter = calculatePatternCenter(originalPesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds); const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x; const centerShiftX = rotatedCenter.x - originalCenter.x;

View file

@ -1,13 +1,10 @@
import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers"; import { getErrorDetails } from "../utils/errorCodeHelpers";
import { PopoverContent } from "@/components/ui/popover"; import { PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "./InfoCard";
interface ErrorPopoverContentProps { interface ErrorPopoverContentProps {
machineError?: number; machineError?: number;
@ -27,50 +24,71 @@ export function ErrorPopoverContent({
const errorMsg = pyodideError || errorMessage || ""; const errorMsg = pyodideError || errorMessage || "";
const isInfo = isPairingErr || errorDetails?.isInformational; const isInfo = isPairingErr || errorDetails?.isInformational;
const variant = isInfo ? "info" : "error"; const bgColor = isInfo
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
const iconColor = isInfo
? "text-info-600 dark:text-info-400"
: "text-danger-600 dark:text-danger-400";
const textColor = isInfo
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200";
const descColor = isInfo
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300";
const listColor = isInfo
? "text-info-700 dark:text-info-300"
: "text-danger-700 dark:text-danger-300";
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = const title =
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error"); errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return ( return (
<PopoverContent className="w-[600px] p-0" align="start"> <PopoverContent
<InfoCard variant={variant} className="rounded-lg shadow-none border-0"> className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
<InfoCardTitle variant={variant}>{title}</InfoCardTitle> align="start"
<InfoCardDescription variant={variant}> >
<div className="flex items-start gap-3">
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
<div className="flex-1">
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
{title}
</h3>
<p className={cn("text-sm mb-3", descColor)}>
{errorDetails?.description || errorMsg} {errorDetails?.description || errorMsg}
</InfoCardDescription> </p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<> <>
<h4 <h4 className={cn("text-sm font-semibold mb-2", textColor)}>
className={cn(
"text-sm font-semibold mb-2",
variant === "info"
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200",
)}
>
{isInfo ? "Steps:" : "How to Fix:"} {isInfo ? "Steps:" : "How to Fix:"}
</h4> </h4>
<InfoCardList variant={variant} ordered> <ol
className={cn(
"list-decimal list-inside text-sm space-y-1.5",
listColor,
)}
>
{errorDetails.solutions.map((solution, index) => ( {errorDetails.solutions.map((solution, index) => (
<InfoCardListItem key={index}>{solution}</InfoCardListItem> <li key={index} className="pl-2">
{solution}
</li>
))} ))}
</InfoCardList> </ol>
</> </>
)} )}
{machineError !== undefined && !errorDetails?.isInformational && ( {machineError !== undefined && !errorDetails?.isInformational && (
<p <p className={cn("text-xs mt-3 font-mono", descColor)}>
className={cn(
"text-xs mt-3 font-mono",
variant === "info"
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300",
)}
>
Error Code: 0x Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")} {machineError.toString(16).toUpperCase().padStart(2, "0")}
</p> </p>
)} )}
</InfoCard> </div>
</div>
</PopoverContent> </PopoverContent>
); );
} }

View file

@ -1,191 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/solid";
import { cn } from "@/lib/utils";
const infoCardVariants = cva("border-l-4 p-4 rounded-lg backdrop-blur-sm", {
variants: {
variant: {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
},
},
defaultVariants: {
variant: "info",
},
});
const iconColorVariants = cva("w-6 h-6 flex-shrink-0 mt-0.5", {
variants: {
variant: {
info: "text-info-600 dark:text-info-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
success: "text-success-600 dark:text-success-400",
},
},
defaultVariants: {
variant: "info",
},
});
const titleColorVariants = cva("text-base font-semibold mb-2", {
variants: {
variant: {
info: "text-info-900 dark:text-info-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
success: "text-success-900 dark:text-success-200",
},
},
defaultVariants: {
variant: "info",
},
});
const descriptionColorVariants = cva("text-sm mb-3", {
variants: {
variant: {
info: "text-info-800 dark:text-info-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
success: "text-success-800 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
const listColorVariants = cva("text-sm", {
variants: {
variant: {
info: "text-info-700 dark:text-info-300",
warning: "text-warning-700 dark:text-warning-300",
error: "text-danger-700 dark:text-danger-300",
success: "text-success-700 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
interface InfoCardProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof infoCardVariants> {
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
showDefaultIcon?: boolean;
}
function InfoCard({
className,
variant = "info",
icon: CustomIcon,
showDefaultIcon = true,
children,
...props
}: InfoCardProps) {
// Default icons based on variant
const defaultIcons = {
info: InformationCircleIcon,
warning: ExclamationTriangleIcon,
error: ExclamationTriangleIcon,
success: CheckCircleIcon,
} as const;
const Icon =
CustomIcon || (showDefaultIcon && variant ? defaultIcons[variant] : null);
return (
<div className={cn(infoCardVariants({ variant }), className)} {...props}>
<div className="flex items-start gap-3">
{Icon && <Icon className={iconColorVariants({ variant })} />}
<div className="flex-1">{children}</div>
</div>
</div>
);
}
function InfoCardTitle({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLHeadingElement> &
VariantProps<typeof titleColorVariants>) {
return (
<h3 className={cn(titleColorVariants({ variant }), className)} {...props} />
);
}
function InfoCardDescription({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLParagraphElement> &
VariantProps<typeof descriptionColorVariants>) {
return (
<p
className={cn(descriptionColorVariants({ variant }), className)}
{...props}
/>
);
}
interface InfoCardListProps
extends
React.HTMLAttributes<HTMLOListElement | HTMLUListElement>,
VariantProps<typeof listColorVariants> {
ordered?: boolean;
}
function InfoCardList({
className,
variant = "info",
ordered = false,
children,
...props
}: InfoCardListProps) {
const ListComponent = ordered ? "ol" : "ul";
const listClass = ordered ? "list-decimal" : "list-disc";
return (
<ListComponent
className={cn(
listClass,
"list-inside space-y-1.5",
listColorVariants({ variant }),
className,
)}
{...props}
>
{children}
</ListComponent>
);
}
function InfoCardListItem({
className,
...props
}: React.HTMLAttributes<HTMLLIElement>) {
return <li className={cn("pl-2", className)} {...props} />;
}
export {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
};

View file

@ -43,9 +43,6 @@ export const PatternLayer = memo(function PatternLayer({
onTransformEnd, onTransformEnd,
attachTransformer, attachTransformer,
}: PatternLayerProps) { }: 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( const center = useMemo(
() => calculatePatternCenter(pesData.bounds), () => calculatePatternCenter(pesData.bounds),
[pesData.bounds], [pesData.bounds],

View file

@ -5,15 +5,12 @@
*/ */
import { forwardRef } from "react"; import { forwardRef } from "react";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine"; import { MachineStatus } from "../../types/machine";
import { getGuideContent } from "../../utils/workflowGuideContent"; import { getGuideContent } from "../../utils/workflowGuideContent";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "../InfoCard";
export interface StepPopoverProps { export interface StepPopoverProps {
stepId: number; stepId: number;
@ -25,16 +22,54 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
const content = getGuideContent(stepId, machineStatus); const content = getGuideContent(stepId, machineStatus);
if (!content) return null; if (!content) return null;
// Map content type to InfoCard variant const colorClasses = {
const variantMap = { info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
info: "info", success:
success: "success", "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
warning: "warning", warning:
error: "error", "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
progress: "info", error:
} as const; "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
const variant = variantMap[content.type]; const iconColorClasses = {
info: "text-info-600 dark:text-info-400",
success: "text-success-600 dark:text-success-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
info: "text-info-900 dark:text-info-200",
success: "text-success-900 dark:text-success-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
info: "text-info-800 dark:text-info-300",
success: "text-success-800 dark:text-success-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
info: "text-blue-700 dark:text-blue-300",
success: "text-green-700 dark:text-green-300",
warning: "text-yellow-700 dark:text-yellow-300",
error: "text-red-700 dark:text-red-300",
progress: "text-cyan-700 dark:text-cyan-300",
};
const Icon =
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
return ( return (
<div <div
@ -43,30 +78,45 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
role="dialog" role="dialog"
aria-label="Step guidance" aria-label="Step guidance"
> >
<InfoCard variant={variant} className="shadow-xl"> <div
<InfoCardTitle variant={variant}>{content.title}</InfoCardTitle> className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
<InfoCardDescription variant={variant}> >
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
{content.description} {content.description}
</InfoCardDescription> </p>
{content.items && content.items.length > 0 && ( {content.items && content.items.length > 0 && (
<InfoCardList variant={variant}> <ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => { {content.items.map((item, index) => {
// Parse **text** markdown syntax into React elements safely // Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/); const parts = item.split(/(\*\*.*?\*\*)/);
return ( return (
<InfoCardListItem key={index}> <li key={index} className="pl-2">
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) { if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>; return <strong key={i}>{part.slice(2, -2)}</strong>;
} }
return part; return part;
})} })}
</InfoCardListItem> </li>
); );
})} })}
</InfoCardList> </ul>
)} )}
</InfoCard> </div>
</div>
</div>
</div> </div>
); );
}, },

View file

@ -42,10 +42,6 @@ export interface UsePatternRotationUploadReturn {
* - Center shift calculation to maintain visual position * - Center shift calculation to maintain visual position
* - Upload orchestration with proper caching * - 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 * @param params - Upload and store functions
* @returns Upload handler function * @returns Upload handler function
*/ */
@ -82,8 +78,7 @@ export function usePatternRotationUpload({
// Calculate bounds from the DECODED stitches (the actual data that will be rendered) // Calculate bounds from the DECODED stitches (the actual data that will be rendered)
const rotatedBounds = calculateBoundsFromDecodedStitches(decoded); const rotatedBounds = calculateBoundsFromDecodedStitches(decoded);
// Calculate the center shift caused by rotation // Calculate the center of the rotated pattern
// Uses the same calculatePatternCenter helper as store selectors
const originalCenter = calculatePatternCenter(pesData.bounds); const originalCenter = calculatePatternCenter(pesData.bounds);
const rotatedCenter = calculatePatternCenter(rotatedBounds); const rotatedCenter = calculatePatternCenter(rotatedBounds);
const centerShiftX = rotatedCenter.x - originalCenter.x; const centerShiftX = rotatedCenter.x - originalCenter.x;

View file

@ -1,200 +0,0 @@
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);
});
});
});

View file

@ -1,9 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import type { PesPatternData } from "../formats/import/pesImporter"; import type { PesPatternData } from "../formats/import/pesImporter";
import { onPatternDeleted } from "./storeEvents"; import { onPatternDeleted } from "./storeEvents";
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
import { calculateRotatedBounds } from "../utils/rotationUtils";
interface PatternState { interface PatternState {
// Original pattern (pre-upload) // Original pattern (pre-upload)
@ -32,24 +29,6 @@ interface PatternState {
resetRotation: () => void; 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) => ({ export const usePatternStore = create<PatternState>((set) => ({
// Initial state - original pattern // Initial state - original pattern
pesData: null, pesData: null,
@ -144,88 +123,6 @@ export const useUploadedPatternOffset = () =>
export const usePatternRotation = () => export const usePatternRotation = () =>
usePatternStore((state) => state.patternRotation); 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. // Subscribe to pattern deleted event.
// This subscription is intended to persist for the lifetime of the application, // This subscription is intended to persist for the lifetime of the application,
// so the unsubscribe function returned by `onPatternDeleted` is intentionally // so the unsubscribe function returned by `onPatternDeleted` is intentionally