mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
Compare commits
16 commits
a173ee33a4
...
212d21e065
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
212d21e065 | ||
|
|
7d8af87660 | ||
|
|
656f501a92 | ||
|
|
5296590a45 | ||
|
|
bcb5ea1786 | ||
|
|
2a5fbb2232 | ||
|
|
7c3f79ae7e | ||
|
|
edb8fa9264 | ||
|
|
df71f74396 | ||
|
|
6fbb3ebf1a | ||
|
|
fb94591f78 | ||
|
|
b2f0455d4c | ||
|
|
1d79ffb2a4 | ||
|
|
a828bf4c8f | ||
|
|
2114bacdae | ||
|
|
20fff4cdfb |
8 changed files with 593 additions and 156 deletions
|
|
@ -112,8 +112,9 @@ 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)
|
// Calculate the adjusted offset (same logic as upload hook)
|
||||||
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,
|
||||||
|
|
@ -140,6 +141,7 @@ 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,
|
||||||
|
|
@ -152,6 +154,7 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
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;
|
||||||
|
|
@ -24,71 +27,50 @@ export function ErrorPopoverContent({
|
||||||
const errorMsg = pyodideError || errorMessage || "";
|
const errorMsg = pyodideError || errorMessage || "";
|
||||||
const isInfo = isPairingErr || errorDetails?.isInformational;
|
const isInfo = isPairingErr || errorDetails?.isInformational;
|
||||||
|
|
||||||
const bgColor = isInfo
|
const variant = isInfo ? "info" : "error";
|
||||||
? "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
|
<PopoverContent className="w-[600px] p-0" align="start">
|
||||||
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)}
|
<InfoCard variant={variant} className="rounded-lg shadow-none border-0">
|
||||||
align="start"
|
<InfoCardTitle variant={variant}>{title}</InfoCardTitle>
|
||||||
>
|
<InfoCardDescription variant={variant}>
|
||||||
<div className="flex items-start gap-3">
|
{errorDetails?.description || errorMsg}
|
||||||
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
|
</InfoCardDescription>
|
||||||
<div className="flex-1">
|
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
||||||
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
|
<>
|
||||||
{title}
|
<h4
|
||||||
</h3>
|
className={cn(
|
||||||
<p className={cn("text-sm mb-3", descColor)}>
|
"text-sm font-semibold mb-2",
|
||||||
{errorDetails?.description || errorMsg}
|
variant === "info"
|
||||||
|
? "text-info-900 dark:text-info-200"
|
||||||
|
: "text-danger-900 dark:text-danger-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isInfo ? "Steps:" : "How to Fix:"}
|
||||||
|
</h4>
|
||||||
|
<InfoCardList variant={variant} ordered>
|
||||||
|
{errorDetails.solutions.map((solution, index) => (
|
||||||
|
<InfoCardListItem key={index}>{solution}</InfoCardListItem>
|
||||||
|
))}
|
||||||
|
</InfoCardList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{machineError !== undefined && !errorDetails?.isInformational && (
|
||||||
|
<p
|
||||||
|
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
|
||||||
|
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
||||||
</p>
|
</p>
|
||||||
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
|
)}
|
||||||
<>
|
</InfoCard>
|
||||||
<h4 className={cn("text-sm font-semibold mb-2", textColor)}>
|
|
||||||
{isInfo ? "Steps:" : "How to Fix:"}
|
|
||||||
</h4>
|
|
||||||
<ol
|
|
||||||
className={cn(
|
|
||||||
"list-decimal list-inside text-sm space-y-1.5",
|
|
||||||
listColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{errorDetails.solutions.map((solution, index) => (
|
|
||||||
<li key={index} className="pl-2">
|
|
||||||
{solution}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{machineError !== undefined && !errorDetails?.isInformational && (
|
|
||||||
<p className={cn("text-xs mt-3 font-mono", descColor)}>
|
|
||||||
Error Code: 0x
|
|
||||||
{machineError.toString(16).toUpperCase().padStart(2, "0")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
191
src/components/InfoCard.tsx
Normal file
191
src/components/InfoCard.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
@ -43,6 +43,9 @@ 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],
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -22,54 +25,16 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
|
||||||
const content = getGuideContent(stepId, machineStatus);
|
const content = getGuideContent(stepId, machineStatus);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
const colorClasses = {
|
// Map content type to InfoCard variant
|
||||||
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
const variantMap = {
|
||||||
success:
|
info: "info",
|
||||||
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
|
success: "success",
|
||||||
warning:
|
warning: "warning",
|
||||||
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
|
error: "error",
|
||||||
error:
|
progress: "info",
|
||||||
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
|
} as const;
|
||||||
progress:
|
|
||||||
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconColorClasses = {
|
const variant = variantMap[content.type];
|
||||||
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
|
||||||
|
|
@ -78,45 +43,30 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Step guidance"
|
aria-label="Step guidance"
|
||||||
>
|
>
|
||||||
<div
|
<InfoCard variant={variant} className="shadow-xl">
|
||||||
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
<InfoCardTitle variant={variant}>{content.title}</InfoCardTitle>
|
||||||
>
|
<InfoCardDescription variant={variant}>
|
||||||
<div className="flex items-start gap-3">
|
{content.description}
|
||||||
<Icon
|
</InfoCardDescription>
|
||||||
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
|
{content.items && content.items.length > 0 && (
|
||||||
/>
|
<InfoCardList variant={variant}>
|
||||||
<div className="flex-1">
|
{content.items.map((item, index) => {
|
||||||
<h3
|
// Parse **text** markdown syntax into React elements safely
|
||||||
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
|
const parts = item.split(/(\*\*.*?\*\*)/);
|
||||||
>
|
return (
|
||||||
{content.title}
|
<InfoCardListItem key={index}>
|
||||||
</h3>
|
{parts.map((part, i) => {
|
||||||
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
|
if (part.startsWith("**") && part.endsWith("**")) {
|
||||||
{content.description}
|
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||||
</p>
|
}
|
||||||
{content.items && content.items.length > 0 && (
|
return part;
|
||||||
<ul
|
})}
|
||||||
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
|
</InfoCardListItem>
|
||||||
>
|
);
|
||||||
{content.items.map((item, index) => {
|
})}
|
||||||
// Parse **text** markdown syntax into React elements safely
|
</InfoCardList>
|
||||||
const parts = item.split(/(\*\*.*?\*\*)/);
|
)}
|
||||||
return (
|
</InfoCard>
|
||||||
<li key={index} className="pl-2">
|
|
||||||
{parts.map((part, i) => {
|
|
||||||
if (part.startsWith("**") && part.endsWith("**")) {
|
|
||||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
|
||||||
}
|
|
||||||
return part;
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -78,7 +82,8 @@ 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 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 originalCenter = calculatePatternCenter(pesData.bounds);
|
||||||
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
const rotatedCenter = calculatePatternCenter(rotatedBounds);
|
||||||
const centerShiftX = rotatedCenter.x - originalCenter.x;
|
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 { 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)
|
||||||
|
|
@ -29,6 +32,24 @@ 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,
|
||||||
|
|
@ -123,6 +144,88 @@ 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue