mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
Add thread metadata display and unique color handling
- Fix PyStitch threadlist interpretation: threads = color blocks, not unique colors - Add uniqueColors array to PesPatternData with proper deduplication at data layer - Display thread metadata (brand, catalog number, chart, description) across all components - Show unique colors vs thread blocks (e.g., "5 / 12" colors/blocks) - Improve null value handling for missing thread metadata - Reorder metadata display: brand + catalog # • chart + description - Add metadata to pattern preview legend, tooltips, and color swatches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
501a7e8538
commit
eadbecc401
5 changed files with 865 additions and 615 deletions
|
|
@ -126,7 +126,7 @@ export function FileUpload({
|
||||||
|
|
||||||
{!isLoading && pesData && (
|
{!isLoading && pesData && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
|
@ -140,22 +140,48 @@ export function FileUpload({
|
||||||
{pesData.stitchCount.toLocaleString()}
|
{pesData.stitchCount.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">Colors / Blocks</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{pesData.uniqueColors.length} / {pesData.threads.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
|
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{pesData.threads.slice(0, 8).map((thread, idx) => (
|
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
||||||
<div
|
// Primary metadata: brand and catalog number
|
||||||
key={idx}
|
const primaryMetadata = [
|
||||||
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
|
color.brand,
|
||||||
style={{ backgroundColor: thread.hex }}
|
color.catalogNumber ? `#${color.catalogNumber}` : null
|
||||||
title={`Thread ${idx + 1}: ${thread.hex}`}
|
].filter(Boolean).join(" ");
|
||||||
/>
|
|
||||||
))}
|
// Secondary metadata: chart and description
|
||||||
{pesData.colorCount > 8 && (
|
const secondaryMetadata = [
|
||||||
|
color.chart,
|
||||||
|
color.description
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||||
|
|
||||||
|
const tooltipText = metadata
|
||||||
|
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
|
||||||
|
: `Color ${idx + 1}: ${color.hex}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
title={tooltipText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pesData.uniqueColors.length > 8 && (
|
||||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
|
||||||
+{pesData.colorCount - 8}
|
+{pesData.uniqueColors.length - 8}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -296,17 +296,42 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPat
|
||||||
{pesData && (
|
{pesData && (
|
||||||
<>
|
<>
|
||||||
{/* Thread Legend Overlay */}
|
{/* Thread Legend Overlay */}
|
||||||
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[150px]">
|
<div className="absolute top-2.5 left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2.5 rounded-lg shadow-lg z-10 max-w-[200px]">
|
||||||
<h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Threads</h4>
|
<h4 className="m-0 mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1.5">Colors</h4>
|
||||||
{pesData.threads.map((thread, index) => (
|
{pesData.uniqueColors.map((color, idx) => {
|
||||||
<div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
|
// Primary metadata: brand and catalog number
|
||||||
<div
|
const primaryMetadata = [
|
||||||
className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0"
|
color.brand,
|
||||||
style={{ backgroundColor: thread.hex }}
|
color.catalogNumber ? `#${color.catalogNumber}` : null
|
||||||
/>
|
].filter(Boolean).join(" ");
|
||||||
<span className="text-[11px] text-gray-900 dark:text-gray-100">Thread {index + 1}</span>
|
|
||||||
</div>
|
// Secondary metadata: chart and description
|
||||||
))}
|
const secondaryMetadata = [
|
||||||
|
color.chart,
|
||||||
|
color.description
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-start gap-2 mb-1.5 last:mb-0">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Color {idx + 1}
|
||||||
|
</div>
|
||||||
|
{(primaryMetadata || secondaryMetadata) && (
|
||||||
|
<div className="text-[9px] text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
|
||||||
|
{primaryMetadata}
|
||||||
|
{primaryMetadata && secondaryMetadata && <span className="mx-1">•</span>}
|
||||||
|
{secondaryMetadata}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pattern Offset Indicator */}
|
{/* Pattern Offset Indicator */}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function PatternSummaryCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
|
@ -42,22 +42,50 @@ export function PatternSummaryCard({
|
||||||
{pesData.stitchCount.toLocaleString()}
|
{pesData.stitchCount.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 block">Colors</span>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{pesData.uniqueColors.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
|
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{pesData.threads.slice(0, 8).map((thread, idx) => (
|
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
|
||||||
<div
|
// Primary metadata: brand and catalog number
|
||||||
key={idx}
|
const primaryMetadata = [
|
||||||
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
|
color.brand,
|
||||||
style={{ backgroundColor: thread.hex }}
|
color.catalogNumber ? `#${color.catalogNumber}` : null
|
||||||
title={`Thread ${idx + 1}: ${thread.hex}`}
|
].filter(Boolean).join(" ");
|
||||||
/>
|
|
||||||
))}
|
// Secondary metadata: chart and description
|
||||||
{pesData.colorCount > 8 && (
|
const secondaryMetadata = [
|
||||||
|
color.chart,
|
||||||
|
color.description
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||||
|
|
||||||
|
// Show which thread blocks use this color
|
||||||
|
const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
|
||||||
|
const tooltipText = metadata
|
||||||
|
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
|
||||||
|
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
title={tooltipText}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pesData.uniqueColors.length > 8 && (
|
||||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
|
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-[7px] font-bold text-gray-600 dark:text-gray-300">
|
||||||
+{pesData.colorCount - 8}
|
+{pesData.uniqueColors.length - 8}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,316 +1,412 @@
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
CircleStackIcon,
|
CircleStackIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
CheckBadgeIcon,
|
CheckBadgeIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
PauseCircleIcon,
|
PauseCircleIcon,
|
||||||
ExclamationCircleIcon,
|
ExclamationCircleIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ArrowPathIcon
|
ArrowPathIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from "@heroicons/react/24/solid";
|
||||||
import type { PatternInfo, SewingProgress } from '../types/machine';
|
import type { PatternInfo, SewingProgress } from "../types/machine";
|
||||||
import { MachineStatus } from '../types/machine';
|
import { MachineStatus } from "../types/machine";
|
||||||
import type { PesPatternData } from '../utils/pystitchConverter';
|
import type { PesPatternData } from "../utils/pystitchConverter";
|
||||||
import {
|
import {
|
||||||
canStartSewing,
|
canStartSewing,
|
||||||
canStartMaskTrace,
|
canStartMaskTrace,
|
||||||
canResumeSewing,
|
canResumeSewing,
|
||||||
getStateVisualInfo
|
getStateVisualInfo,
|
||||||
} from '../utils/machineStateHelpers';
|
} from "../utils/machineStateHelpers";
|
||||||
|
|
||||||
interface ProgressMonitorProps {
|
interface ProgressMonitorProps {
|
||||||
machineStatus: MachineStatus;
|
machineStatus: MachineStatus;
|
||||||
patternInfo: PatternInfo | null;
|
patternInfo: PatternInfo | null;
|
||||||
sewingProgress: SewingProgress | null;
|
sewingProgress: SewingProgress | null;
|
||||||
pesData: PesPatternData | null;
|
pesData: PesPatternData | null;
|
||||||
onStartMaskTrace: () => void;
|
onStartMaskTrace: () => void;
|
||||||
onStartSewing: () => void;
|
onStartSewing: () => void;
|
||||||
onResumeSewing: () => void;
|
onResumeSewing: () => void;
|
||||||
onDeletePattern: () => void;
|
onDeletePattern: () => void;
|
||||||
isDeleting?: boolean;
|
isDeleting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressMonitor({
|
export function ProgressMonitor({
|
||||||
machineStatus,
|
machineStatus,
|
||||||
patternInfo,
|
patternInfo,
|
||||||
sewingProgress,
|
sewingProgress,
|
||||||
pesData,
|
pesData,
|
||||||
onStartMaskTrace,
|
onStartMaskTrace,
|
||||||
onStartSewing,
|
onStartSewing,
|
||||||
onResumeSewing,
|
onResumeSewing,
|
||||||
isDeleting = false,
|
isDeleting = false,
|
||||||
}: ProgressMonitorProps) {
|
}: ProgressMonitorProps) {
|
||||||
// State indicators
|
// State indicators
|
||||||
const isMaskTraceComplete = machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
const isMaskTraceComplete =
|
||||||
|
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
|
||||||
const stateVisual = getStateVisualInfo(machineStatus);
|
|
||||||
|
const stateVisual = getStateVisualInfo(machineStatus);
|
||||||
const progressPercent = patternInfo
|
|
||||||
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
|
const progressPercent = patternInfo
|
||||||
: 0;
|
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
|
||||||
|
: 0;
|
||||||
// Calculate color block information from pesData
|
|
||||||
const colorBlocks = pesData ? (() => {
|
// Calculate color block information from pesData
|
||||||
const blocks: Array<{
|
const colorBlocks = pesData
|
||||||
colorIndex: number;
|
? (() => {
|
||||||
threadHex: string;
|
const blocks: Array<{
|
||||||
startStitch: number;
|
colorIndex: number;
|
||||||
endStitch: number;
|
threadHex: string;
|
||||||
stitchCount: number;
|
startStitch: number;
|
||||||
}> = [];
|
endStitch: number;
|
||||||
|
stitchCount: number;
|
||||||
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
|
threadCatalogNumber: string | null;
|
||||||
let blockStartStitch = 0;
|
threadBrand: string | null;
|
||||||
|
threadDescription: string | null;
|
||||||
for (let i = 0; i < pesData.stitches.length; i++) {
|
threadChart: string | null;
|
||||||
const stitchColorIndex = pesData.stitches[i][3];
|
}> = [];
|
||||||
|
|
||||||
// When color changes, save the previous block
|
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
|
||||||
if (stitchColorIndex !== currentColorIndex || i === pesData.stitches.length - 1) {
|
let blockStartStitch = 0;
|
||||||
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
|
|
||||||
blocks.push({
|
for (let i = 0; i < pesData.stitches.length; i++) {
|
||||||
colorIndex: currentColorIndex,
|
const stitchColorIndex = pesData.stitches[i][3];
|
||||||
threadHex: pesData.threads[currentColorIndex]?.hex || '#000000',
|
|
||||||
startStitch: blockStartStitch,
|
// When color changes, save the previous block
|
||||||
endStitch: endStitch,
|
if (
|
||||||
stitchCount: endStitch - blockStartStitch,
|
stitchColorIndex !== currentColorIndex ||
|
||||||
});
|
i === pesData.stitches.length - 1
|
||||||
|
) {
|
||||||
currentColorIndex = stitchColorIndex;
|
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
|
||||||
blockStartStitch = i;
|
const thread = pesData.threads[currentColorIndex];
|
||||||
}
|
blocks.push({
|
||||||
}
|
colorIndex: currentColorIndex,
|
||||||
|
threadHex: thread?.hex || "#000000",
|
||||||
return blocks;
|
threadCatalogNumber: thread?.catalogNumber ?? null,
|
||||||
})() : [];
|
threadBrand: thread?.brand ?? null,
|
||||||
|
threadDescription: thread?.description ?? null,
|
||||||
// Determine current color block based on current stitch
|
threadChart: thread?.chart ?? null,
|
||||||
const currentStitch = sewingProgress?.currentStitch || 0;
|
startStitch: blockStartStitch,
|
||||||
const currentBlockIndex = colorBlocks.findIndex(
|
endStitch: endStitch,
|
||||||
block => currentStitch >= block.startStitch && currentStitch < block.endStitch
|
stitchCount: endStitch - blockStartStitch,
|
||||||
);
|
});
|
||||||
|
|
||||||
const stateIndicatorColors = {
|
currentColorIndex = stitchColorIndex;
|
||||||
idle: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
|
blockStartStitch = i;
|
||||||
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-600',
|
}
|
||||||
active: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
|
}
|
||||||
waiting: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
|
|
||||||
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500',
|
return blocks;
|
||||||
complete: 'bg-green-50 dark:bg-green-900/20 border-green-600',
|
})()
|
||||||
success: 'bg-green-50 dark:bg-green-900/20 border-green-600',
|
: [];
|
||||||
interrupted: 'bg-red-50 dark:bg-red-900/20 border-red-600',
|
|
||||||
error: 'bg-red-50 dark:bg-red-900/20 border-red-600',
|
// Determine current color block based on current stitch
|
||||||
danger: 'bg-red-50 dark:bg-red-900/20 border-red-600',
|
const currentStitch = sewingProgress?.currentStitch || 0;
|
||||||
};
|
const currentBlockIndex = colorBlocks.findIndex(
|
||||||
|
(block) =>
|
||||||
return (
|
currentStitch >= block.startStitch && currentStitch < block.endStitch,
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
|
);
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
|
const stateIndicatorColors = {
|
||||||
<div className="flex-1 min-w-0">
|
idle: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Sewing Progress</h3>
|
info: "bg-blue-50 dark:bg-blue-900/20 border-blue-600",
|
||||||
{sewingProgress && (
|
active: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
waiting: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
|
||||||
{progressPercent.toFixed(1)}% complete
|
warning: "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500",
|
||||||
</p>
|
complete: "bg-green-50 dark:bg-green-900/20 border-green-600",
|
||||||
)}
|
success: "bg-green-50 dark:bg-green-900/20 border-green-600",
|
||||||
</div>
|
interrupted: "bg-red-50 dark:bg-red-900/20 border-red-600",
|
||||||
</div>
|
error: "bg-red-50 dark:bg-red-900/20 border-red-600",
|
||||||
|
danger: "bg-red-50 dark:bg-red-900/20 border-red-600",
|
||||||
{/* Pattern Info */}
|
};
|
||||||
{patternInfo && (
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-purple-600 dark:border-purple-500">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span>
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.totalStitches.toLocaleString()}</span>
|
<ChartBarIcon className="w-6 h-6 text-purple-600 dark:text-purple-400 flex-shrink-0 mt-0.5" />
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Est. Time</span>
|
Sewing Progress
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
</h3>
|
||||||
{Math.floor(patternInfo.totalTime / 60)}:{String(patternInfo.totalTime % 60).padStart(2, '0')}
|
{sewingProgress && (
|
||||||
</span>
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
</div>
|
{progressPercent.toFixed(1)}% complete
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
</p>
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Speed</span>
|
)}
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{patternInfo.speed} spm</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{/* Pattern Info */}
|
||||||
|
{patternInfo && (
|
||||||
{/* Progress Bar */}
|
<div className="grid grid-cols-3 gap-2 text-xs mb-3">
|
||||||
{sewingProgress && (
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<div className="mb-3">
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
|
Total Stitches
|
||||||
<div className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
|
</span>
|
||||||
</div>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{patternInfo.totalStitches.toLocaleString()}
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
</span>
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
</div>
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Current Stitch</span>
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
{sewingProgress.currentStitch.toLocaleString()} / {patternInfo?.totalStitches.toLocaleString() || 0}
|
Est. Time
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
{Math.floor(patternInfo.totalTime / 60)}:
|
||||||
<span className="text-gray-600 dark:text-gray-400 block">Time Elapsed</span>
|
{String(patternInfo.totalTime % 60).padStart(2, "0")}
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
</span>
|
||||||
{Math.floor(sewingProgress.currentTime / 60)}:{String(sewingProgress.currentTime % 60).padStart(2, '0')}
|
</div>
|
||||||
</span>
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
</div>
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
</div>
|
Speed
|
||||||
</div>
|
</span>
|
||||||
)}
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{patternInfo.speed} spm
|
||||||
{/* State Visual Indicator */}
|
</span>
|
||||||
{patternInfo && (() => {
|
</div>
|
||||||
const iconMap = {
|
</div>
|
||||||
ready: <ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />,
|
)}
|
||||||
active: <PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
|
|
||||||
waiting: <PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />,
|
{/* Progress Bar */}
|
||||||
complete: <CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />,
|
{sewingProgress && (
|
||||||
interrupted: <PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />,
|
<div className="mb-3">
|
||||||
error: <ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded-md overflow-hidden shadow-inner relative mb-2">
|
||||||
};
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-purple-600 to-purple-700 dark:from-purple-600 dark:to-purple-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
|
||||||
return (
|
style={{ width: `${progressPercent}%` }}
|
||||||
<div className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}>
|
/>
|
||||||
<div className="flex-shrink-0">
|
</div>
|
||||||
{iconMap[stateVisual.iconName]}
|
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
|
||||||
<div className="flex-1">
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<div className="font-semibold text-xs dark:text-gray-100">{stateVisual.label}</div>
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
<div className="text-[10px] text-gray-600 dark:text-gray-400">{stateVisual.description}</div>
|
Current Stitch
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
);
|
{sewingProgress.currentStitch.toLocaleString()} /{" "}
|
||||||
})()}
|
{patternInfo?.totalStitches.toLocaleString() || 0}
|
||||||
|
</span>
|
||||||
{/* Color Blocks */}
|
</div>
|
||||||
{colorBlocks.length > 0 && (
|
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
|
||||||
<div className="mb-3">
|
<span className="text-gray-600 dark:text-gray-400 block">
|
||||||
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">Color Blocks</h4>
|
Time Elapsed
|
||||||
<div className="flex flex-col gap-2">
|
</span>
|
||||||
{colorBlocks.map((block, index) => {
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
const isCompleted = currentStitch >= block.endStitch;
|
{Math.floor(sewingProgress.currentTime / 60)}:
|
||||||
const isCurrent = index === currentBlockIndex;
|
{String(sewingProgress.currentTime % 60).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
// Calculate progress within current block
|
</div>
|
||||||
let blockProgress = 0;
|
</div>
|
||||||
if (isCurrent) {
|
</div>
|
||||||
blockProgress = ((currentStitch - block.startStitch) / block.stitchCount) * 100;
|
)}
|
||||||
} else if (isCompleted) {
|
|
||||||
blockProgress = 100;
|
{/* State Visual Indicator */}
|
||||||
}
|
{patternInfo &&
|
||||||
|
(() => {
|
||||||
return (
|
const iconMap = {
|
||||||
<div
|
ready: (
|
||||||
key={index}
|
<ClockIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
),
|
||||||
isCompleted
|
active: (
|
||||||
? 'border-green-600 bg-green-50 dark:bg-green-900/20'
|
<PlayIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
: isCurrent
|
),
|
||||||
? 'border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow'
|
waiting: (
|
||||||
: 'border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70'
|
<PauseCircleIcon className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
}`}
|
),
|
||||||
role="listitem"
|
complete: (
|
||||||
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? 'completed' : isCurrent ? 'in progress' : 'pending'}`}
|
<CheckBadgeIcon className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
>
|
),
|
||||||
<div className="flex items-center gap-2.5">
|
interrupted: (
|
||||||
{/* Color swatch */}
|
<PauseCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
<div
|
),
|
||||||
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
error: (
|
||||||
style={{
|
<ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
backgroundColor: block.threadHex,
|
),
|
||||||
...(isCurrent && { borderColor: '#9333ea' })
|
};
|
||||||
}}
|
|
||||||
title={`Thread color: ${block.threadHex}`}
|
return (
|
||||||
aria-label={`Thread color ${block.threadHex}`}
|
<div
|
||||||
/>
|
className={`flex items-center gap-3 p-2.5 rounded-lg mb-3 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}
|
||||||
|
>
|
||||||
{/* Thread info */}
|
<div className="flex-shrink-0">
|
||||||
<div className="flex-1 min-w-0">
|
{iconMap[stateVisual.iconName]}
|
||||||
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
</div>
|
||||||
Thread {block.colorIndex + 1}
|
<div className="flex-1">
|
||||||
</div>
|
<div className="font-semibold text-xs dark:text-gray-100">
|
||||||
<div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
|
{stateVisual.label}
|
||||||
{block.stitchCount.toLocaleString()} stitches
|
</div>
|
||||||
</div>
|
<div className="text-[10px] text-gray-600 dark:text-gray-400">
|
||||||
</div>
|
{stateVisual.description}
|
||||||
|
</div>
|
||||||
{/* Status icon */}
|
</div>
|
||||||
{isCompleted ? (
|
</div>
|
||||||
<CheckCircleIcon className="w-5 h-5 text-green-600 flex-shrink-0" aria-label="Completed" />
|
);
|
||||||
) : isCurrent ? (
|
})()}
|
||||||
<ArrowRightIcon className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse" aria-label="In progress" />
|
|
||||||
) : (
|
{/* Color Blocks */}
|
||||||
<CircleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-label="Pending" />
|
{colorBlocks.length > 0 && (
|
||||||
)}
|
<div className="mb-3">
|
||||||
</div>
|
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300">
|
||||||
|
Color Blocks
|
||||||
{/* Progress bar for current block */}
|
</h4>
|
||||||
{isCurrent && (
|
<div className="flex flex-col gap-2">
|
||||||
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
{colorBlocks.map((block, index) => {
|
||||||
<div
|
const isCompleted = currentStitch >= block.endStitch;
|
||||||
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
|
const isCurrent = index === currentBlockIndex;
|
||||||
style={{ width: `${blockProgress}%` }}
|
|
||||||
role="progressbar"
|
// Calculate progress within current block
|
||||||
aria-valuenow={Math.round(blockProgress)}
|
let blockProgress = 0;
|
||||||
aria-valuemin={0}
|
if (isCurrent) {
|
||||||
aria-valuemax={100}
|
blockProgress =
|
||||||
aria-label={`${Math.round(blockProgress)}% complete`}
|
((currentStitch - block.startStitch) / block.stitchCount) *
|
||||||
/>
|
100;
|
||||||
</div>
|
} else if (isCompleted) {
|
||||||
)}
|
blockProgress = 100;
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
})}
|
return (
|
||||||
</div>
|
<div
|
||||||
</div>
|
key={index}
|
||||||
)}
|
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
|
||||||
|
isCompleted
|
||||||
{/* Action buttons */}
|
? "border-green-600 bg-green-50 dark:bg-green-900/20"
|
||||||
<div className="flex gap-2">
|
: isCurrent
|
||||||
{/* Resume has highest priority when available */}
|
? "border-purple-600 bg-purple-50 dark:bg-purple-900/20 shadow-lg shadow-purple-600/20 animate-pulseGlow"
|
||||||
{canResumeSewing(machineStatus) && (
|
: "border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50 opacity-70"
|
||||||
<button
|
}`}
|
||||||
onClick={onResumeSewing}
|
role="listitem"
|
||||||
disabled={isDeleting}
|
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
>
|
||||||
aria-label="Resume sewing the current pattern"
|
<div className="flex items-center gap-2.5">
|
||||||
>
|
{/* Color swatch */}
|
||||||
<PlayIcon className="w-3.5 h-3.5" />
|
<div
|
||||||
Resume Sewing
|
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
|
||||||
</button>
|
style={{
|
||||||
)}
|
backgroundColor: block.threadHex,
|
||||||
|
...(isCurrent && { borderColor: "#9333ea" }),
|
||||||
{/* Start Sewing - primary action, takes more space */}
|
}}
|
||||||
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
title={`Thread color: ${block.threadHex}`}
|
||||||
<button
|
aria-label={`Thread color ${block.threadHex}`}
|
||||||
onClick={onStartSewing}
|
/>
|
||||||
disabled={isDeleting}
|
|
||||||
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
{/* Thread info */}
|
||||||
aria-label="Start sewing the pattern"
|
<div className="flex-1 min-w-0">
|
||||||
>
|
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
|
||||||
<PlayIcon className="w-3.5 h-3.5" />
|
Thread {block.colorIndex + 1}
|
||||||
Start Sewing
|
{(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
|
||||||
</button>
|
<span className="font-normal text-gray-600 dark:text-gray-400">
|
||||||
)}
|
{" "}
|
||||||
|
(
|
||||||
{/* Start Mask Trace - secondary action */}
|
{(() => {
|
||||||
{canStartMaskTrace(machineStatus) && (
|
// Primary metadata: brand and catalog number
|
||||||
<button
|
const primaryMetadata = [
|
||||||
onClick={onStartMaskTrace}
|
block.threadBrand,
|
||||||
disabled={isDeleting}
|
block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
].filter(Boolean).join(" ");
|
||||||
aria-label={isMaskTraceComplete ? 'Start mask trace again' : 'Start mask trace'}
|
|
||||||
>
|
// Secondary metadata: chart and description
|
||||||
<ArrowPathIcon className="w-3.5 h-3.5" />
|
const secondaryMetadata = [
|
||||||
{isMaskTraceComplete ? 'Trace Again' : 'Start Mask Trace'}
|
block.threadChart,
|
||||||
</button>
|
block.threadDescription
|
||||||
)}
|
].filter(Boolean).join(" ");
|
||||||
</div>
|
|
||||||
</div>
|
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
|
||||||
);
|
})()}
|
||||||
}
|
)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-600 dark:text-gray-400 mt-0.5">
|
||||||
|
{block.stitchCount.toLocaleString()} stitches
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircleIcon
|
||||||
|
className="w-5 h-5 text-green-600 flex-shrink-0"
|
||||||
|
aria-label="Completed"
|
||||||
|
/>
|
||||||
|
) : isCurrent ? (
|
||||||
|
<ArrowRightIcon
|
||||||
|
className="w-5 h-5 text-purple-600 flex-shrink-0 animate-pulse"
|
||||||
|
aria-label="In progress"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircleStackIcon
|
||||||
|
className="w-5 h-5 text-gray-400 flex-shrink-0"
|
||||||
|
aria-label="Pending"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar for current block */}
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-purple-600 dark:bg-purple-500 transition-all duration-300 rounded-full"
|
||||||
|
style={{ width: `${blockProgress}%` }}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(blockProgress)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`${Math.round(blockProgress)}% complete`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* Resume has highest priority when available */}
|
||||||
|
{canResumeSewing(machineStatus) && (
|
||||||
|
<button
|
||||||
|
onClick={onResumeSewing}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Resume sewing the current pattern"
|
||||||
|
>
|
||||||
|
<PlayIcon className="w-3.5 h-3.5" />
|
||||||
|
Resume Sewing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start Sewing - primary action, takes more space */}
|
||||||
|
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
|
||||||
|
<button
|
||||||
|
onClick={onStartSewing}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-[2] flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded font-semibold text-xs hover:bg-blue-700 dark:hover:bg-blue-600 active:bg-blue-800 dark:active:bg-blue-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Start sewing the pattern"
|
||||||
|
>
|
||||||
|
<PlayIcon className="w-3.5 h-3.5" />
|
||||||
|
Start Sewing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start Mask Trace - secondary action */}
|
||||||
|
{canStartMaskTrace(machineStatus) && (
|
||||||
|
<button
|
||||||
|
onClick={onStartMaskTrace}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded font-semibold text-xs hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
aria-label={
|
||||||
|
isMaskTraceComplete
|
||||||
|
? "Start mask trace again"
|
||||||
|
: "Start mask trace"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="w-3.5 h-3.5" />
|
||||||
|
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,341 @@
|
||||||
import { pyodideLoader } from './pyodideLoader';
|
import { pyodideLoader } from "./pyodideLoader";
|
||||||
import {
|
import {
|
||||||
STITCH,
|
STITCH,
|
||||||
MOVE,
|
MOVE,
|
||||||
TRIM,
|
TRIM,
|
||||||
END,
|
END,
|
||||||
PEN_FEED_DATA,
|
PEN_FEED_DATA,
|
||||||
PEN_CUT_DATA,
|
PEN_CUT_DATA,
|
||||||
PEN_COLOR_END,
|
PEN_COLOR_END,
|
||||||
PEN_DATA_END,
|
PEN_DATA_END,
|
||||||
} from './embroideryConstants';
|
} from "./embroideryConstants";
|
||||||
|
|
||||||
// JavaScript constants module to expose to Python
|
// JavaScript constants module to expose to Python
|
||||||
const jsEmbConstants = {
|
const jsEmbConstants = {
|
||||||
STITCH,
|
STITCH,
|
||||||
MOVE,
|
MOVE,
|
||||||
TRIM,
|
TRIM,
|
||||||
END,
|
END,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PesPatternData {
|
export interface PesPatternData {
|
||||||
stitches: number[][];
|
stitches: number[][];
|
||||||
threads: Array<{
|
threads: Array<{
|
||||||
color: number;
|
color: number;
|
||||||
hex: string;
|
hex: string;
|
||||||
}>;
|
brand: string | null;
|
||||||
penData: Uint8Array;
|
catalogNumber: string | null;
|
||||||
colorCount: number;
|
description: string | null;
|
||||||
stitchCount: number;
|
chart: string | null;
|
||||||
bounds: {
|
}>;
|
||||||
minX: number;
|
uniqueColors: Array<{
|
||||||
maxX: number;
|
color: number;
|
||||||
minY: number;
|
hex: string;
|
||||||
maxY: number;
|
brand: string | null;
|
||||||
};
|
catalogNumber: string | null;
|
||||||
}
|
description: string | null;
|
||||||
|
chart: string | null;
|
||||||
/**
|
threadIndices: number[]; // Which thread entries use this color
|
||||||
* Reads a PES file using PyStitch and converts it to PEN format
|
}>;
|
||||||
*/
|
penData: Uint8Array;
|
||||||
export async function convertPesToPen(file: File): Promise<PesPatternData> {
|
colorCount: number;
|
||||||
// Ensure Pyodide is initialized
|
stitchCount: number;
|
||||||
const pyodide = await pyodideLoader.initialize();
|
bounds: {
|
||||||
|
minX: number;
|
||||||
// Register our JavaScript constants module for Python to import
|
maxX: number;
|
||||||
pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
// Read the PES file
|
};
|
||||||
const buffer = await file.arrayBuffer();
|
}
|
||||||
const uint8Array = new Uint8Array(buffer);
|
|
||||||
|
/**
|
||||||
// Write file to Pyodide virtual filesystem
|
* Reads a PES file using PyStitch and converts it to PEN format
|
||||||
const filename = '/tmp/pattern.pes';
|
*/
|
||||||
pyodide.FS.writeFile(filename, uint8Array);
|
export async function convertPesToPen(file: File): Promise<PesPatternData> {
|
||||||
|
// Ensure Pyodide is initialized
|
||||||
// Read the pattern using PyStitch
|
const pyodide = await pyodideLoader.initialize();
|
||||||
const result = await pyodide.runPythonAsync(`
|
|
||||||
import pystitch
|
// Register our JavaScript constants module for Python to import
|
||||||
from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
|
pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
|
||||||
from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
|
|
||||||
|
// Read the PES file
|
||||||
# Read the PES file
|
const buffer = await file.arrayBuffer();
|
||||||
pattern = pystitch.read('${filename}')
|
const uint8Array = new Uint8Array(buffer);
|
||||||
|
|
||||||
def map_cmd(pystitch_cmd):
|
// Write file to Pyodide virtual filesystem
|
||||||
"""Map PyStitch command to our JavaScript constant values
|
const filename = "/tmp/pattern.pes";
|
||||||
|
pyodide.FS.writeFile(filename, uint8Array);
|
||||||
This ensures we have known, consistent values regardless of PyStitch's internal values.
|
|
||||||
Our JS constants use pyembroidery-style bitmask values:
|
// Read the pattern using PyStitch
|
||||||
STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
|
const result = await pyodide.runPythonAsync(`
|
||||||
"""
|
import pystitch
|
||||||
if pystitch_cmd == STITCH:
|
from pystitch.EmbConstant import STITCH, JUMP, TRIM, STOP, END, COLOR_CHANGE
|
||||||
return JS_STITCH
|
from js_emb_constants import STITCH as JS_STITCH, MOVE as JS_MOVE, TRIM as JS_TRIM, END as JS_END
|
||||||
elif pystitch_cmd == JUMP:
|
|
||||||
return JS_MOVE # PyStitch JUMP maps to our MOVE constant
|
# Read the PES file
|
||||||
elif pystitch_cmd == TRIM:
|
pattern = pystitch.read('${filename}')
|
||||||
return JS_TRIM
|
|
||||||
elif pystitch_cmd == END:
|
def map_cmd(pystitch_cmd):
|
||||||
return JS_END
|
"""Map PyStitch command to our JavaScript constant values
|
||||||
else:
|
|
||||||
# For any other commands, preserve as bitmask
|
This ensures we have known, consistent values regardless of PyStitch's internal values.
|
||||||
result = JS_STITCH
|
Our JS constants use pyembroidery-style bitmask values:
|
||||||
if pystitch_cmd & JUMP:
|
STITCH = 0x00, MOVE/JUMP = 0x10, TRIM = 0x20, END = 0x100
|
||||||
result |= JS_MOVE
|
"""
|
||||||
if pystitch_cmd & TRIM:
|
if pystitch_cmd == STITCH:
|
||||||
result |= JS_TRIM
|
return JS_STITCH
|
||||||
if pystitch_cmd & END:
|
elif pystitch_cmd == JUMP:
|
||||||
result |= JS_END
|
return JS_MOVE # PyStitch JUMP maps to our MOVE constant
|
||||||
return result
|
elif pystitch_cmd == TRIM:
|
||||||
|
return JS_TRIM
|
||||||
# Use the raw stitches list which preserves command flags
|
elif pystitch_cmd == END:
|
||||||
# Each stitch in pattern.stitches is [x, y, cmd]
|
return JS_END
|
||||||
# We need to assign color indices based on COLOR_CHANGE commands
|
else:
|
||||||
# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
|
# For any other commands, preserve as bitmask
|
||||||
|
result = JS_STITCH
|
||||||
stitches_with_colors = []
|
if pystitch_cmd & JUMP:
|
||||||
current_color = 0
|
result |= JS_MOVE
|
||||||
|
if pystitch_cmd & TRIM:
|
||||||
for i, stitch in enumerate(pattern.stitches):
|
result |= JS_TRIM
|
||||||
x, y, cmd = stitch
|
if pystitch_cmd & END:
|
||||||
|
result |= JS_END
|
||||||
# Check for color change command - increment color but don't add stitch
|
return result
|
||||||
if cmd == COLOR_CHANGE:
|
|
||||||
current_color += 1
|
# Use the raw stitches list which preserves command flags
|
||||||
continue
|
# Each stitch in pattern.stitches is [x, y, cmd]
|
||||||
|
# We need to assign color indices based on COLOR_CHANGE commands
|
||||||
# Check for stop command - skip it
|
# and filter out COLOR_CHANGE and STOP commands (they're not actual stitches)
|
||||||
if cmd == STOP:
|
|
||||||
continue
|
stitches_with_colors = []
|
||||||
|
current_color = 0
|
||||||
# Check for standalone END command (no stitch data)
|
|
||||||
if cmd == END:
|
for i, stitch in enumerate(pattern.stitches):
|
||||||
continue
|
x, y, cmd = stitch
|
||||||
|
|
||||||
# Add actual stitch with color index and mapped command
|
# Check for color change command - increment color but don't add stitch
|
||||||
# Map PyStitch cmd values to our known JavaScript constant values
|
if cmd == COLOR_CHANGE:
|
||||||
mapped_cmd = map_cmd(cmd)
|
current_color += 1
|
||||||
stitches_with_colors.append([x, y, mapped_cmd, current_color])
|
continue
|
||||||
|
|
||||||
# Convert to JSON-serializable format
|
# Check for stop command - skip it
|
||||||
{
|
if cmd == STOP:
|
||||||
'stitches': stitches_with_colors,
|
continue
|
||||||
'threads': [
|
|
||||||
{
|
# Check for standalone END command (no stitch data)
|
||||||
'color': thread.color if hasattr(thread, 'color') else 0,
|
if cmd == END:
|
||||||
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000'
|
continue
|
||||||
}
|
|
||||||
for thread in pattern.threadlist
|
# Add actual stitch with color index and mapped command
|
||||||
],
|
# Map PyStitch cmd values to our known JavaScript constant values
|
||||||
'thread_count': len(pattern.threadlist),
|
mapped_cmd = map_cmd(cmd)
|
||||||
'stitch_count': len(stitches_with_colors),
|
stitches_with_colors.append([x, y, mapped_cmd, current_color])
|
||||||
'color_changes': current_color
|
|
||||||
}
|
# Convert to JSON-serializable format
|
||||||
`);
|
{
|
||||||
|
'stitches': stitches_with_colors,
|
||||||
// Convert Python result to JavaScript
|
'threads': [
|
||||||
const data = result.toJs({ dict_converter: Object.fromEntries });
|
{
|
||||||
|
'color': thread.color if hasattr(thread, 'color') else 0,
|
||||||
|
'hex': thread.hex_color() if hasattr(thread, 'hex_color') else '#000000',
|
||||||
// Clean up virtual file
|
'catalog_number': thread.catalog_number if hasattr(thread, 'catalog_number') else -1,
|
||||||
try {
|
'brand': thread.brand if hasattr(thread, 'brand') else "",
|
||||||
pyodide.FS.unlink(filename);
|
'description': thread.description if hasattr(thread, 'description') else "",
|
||||||
} catch {
|
'chart': thread.chart if hasattr(thread, 'chart') else ""
|
||||||
// Ignore errors
|
}
|
||||||
}
|
for thread in pattern.threadlist
|
||||||
|
],
|
||||||
// Extract stitches and validate
|
'thread_count': len(pattern.threadlist),
|
||||||
const stitches: number[][] = Array.from(data.stitches as ArrayLike<ArrayLike<number>>).map((stitch) =>
|
'stitch_count': len(stitches_with_colors),
|
||||||
Array.from(stitch)
|
'color_changes': current_color
|
||||||
);
|
}
|
||||||
|
`);
|
||||||
if (!stitches || stitches.length === 0) {
|
|
||||||
throw new Error('Invalid PES file or no stitches found');
|
// Convert Python result to JavaScript
|
||||||
}
|
const data = result.toJs({ dict_converter: Object.fromEntries });
|
||||||
|
|
||||||
// Extract thread data
|
// Clean up virtual file
|
||||||
const threads = (data.threads as Array<{ color?: number; hex?: string }>).map((thread) => ({
|
try {
|
||||||
color: thread.color || 0,
|
pyodide.FS.unlink(filename);
|
||||||
hex: thread.hex || '#000000',
|
} catch {
|
||||||
}));
|
// Ignore errors
|
||||||
|
}
|
||||||
// Track bounds
|
|
||||||
let minX = Infinity;
|
// Extract stitches and validate
|
||||||
let maxX = -Infinity;
|
const stitches: number[][] = Array.from(
|
||||||
let minY = Infinity;
|
data.stitches as ArrayLike<ArrayLike<number>>,
|
||||||
let maxY = -Infinity;
|
).map((stitch) => Array.from(stitch));
|
||||||
|
|
||||||
// PyStitch returns ABSOLUTE coordinates
|
if (!stitches || stitches.length === 0) {
|
||||||
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
|
throw new Error("Invalid PES file or no stitches found");
|
||||||
const penStitches: number[] = [];
|
}
|
||||||
|
|
||||||
for (let i = 0; i < stitches.length; i++) {
|
// Extract thread data - preserve null values for unavailable metadata
|
||||||
const stitch = stitches[i];
|
const threads = (
|
||||||
const absX = Math.round(stitch[0]);
|
data.threads as Array<{
|
||||||
const absY = Math.round(stitch[1]);
|
color?: number;
|
||||||
const cmd = stitch[2];
|
hex?: string;
|
||||||
const stitchColor = stitch[3]; // Color index from PyStitch
|
catalog_number?: number | string;
|
||||||
|
brand?: string;
|
||||||
// Track bounds for non-jump stitches
|
description?: string;
|
||||||
if (cmd === STITCH) {
|
chart?: string;
|
||||||
minX = Math.min(minX, absX);
|
}>
|
||||||
maxX = Math.max(maxX, absX);
|
).map((thread) => {
|
||||||
minY = Math.min(minY, absY);
|
// Normalize catalog_number - can be string or number from PyStitch
|
||||||
maxY = Math.max(maxY, absY);
|
const catalogNum = thread.catalog_number;
|
||||||
}
|
const normalizedCatalog =
|
||||||
|
catalogNum !== undefined &&
|
||||||
// Encode absolute coordinates with flags in low 3 bits
|
catalogNum !== null &&
|
||||||
// Shift coordinates left by 3 bits to make room for flags
|
catalogNum !== -1 &&
|
||||||
// As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
|
catalogNum !== "-1" &&
|
||||||
let xEncoded = (absX << 3) & 0xFFFF;
|
catalogNum !== ""
|
||||||
let yEncoded = (absY << 3) & 0xFFFF;
|
? String(catalogNum)
|
||||||
|
: null;
|
||||||
// Add command flags to Y-coordinate based on stitch type
|
|
||||||
if (cmd & MOVE) {
|
return {
|
||||||
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
|
color: thread.color ?? 0,
|
||||||
yEncoded |= PEN_FEED_DATA;
|
hex: thread.hex || "#000000",
|
||||||
}
|
catalogNumber: normalizedCatalog,
|
||||||
if (cmd & TRIM) {
|
brand: thread.brand && thread.brand !== "" ? thread.brand : null,
|
||||||
// TRIM: Set bit 1 (CUT_DATA) - cut thread command
|
description: thread.description && thread.description !== "" ? thread.description : null,
|
||||||
yEncoded |= PEN_CUT_DATA;
|
chart: thread.chart && thread.chart !== "" ? thread.chart : null,
|
||||||
}
|
};
|
||||||
|
});
|
||||||
// Check if this is the last stitch
|
|
||||||
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
|
// Track bounds
|
||||||
|
let minX = Infinity;
|
||||||
// Check for color change by comparing stitch color index
|
let maxX = -Infinity;
|
||||||
// Mark the LAST stitch of the previous color with PEN_COLOR_END
|
let minY = Infinity;
|
||||||
// BUT: if this is the last stitch of the entire pattern, use DATA_END instead
|
let maxY = -Infinity;
|
||||||
const nextStitch = stitches[i + 1];
|
|
||||||
const nextStitchColor = nextStitch?.[3];
|
// PyStitch returns ABSOLUTE coordinates
|
||||||
|
// PEN format uses absolute coordinates, shifted left by 3 bits (as per official app line 780)
|
||||||
if (!isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor) {
|
const penStitches: number[] = [];
|
||||||
// This is the last stitch before a color change (but not the last stitch overall)
|
|
||||||
xEncoded = (xEncoded & 0xFFF8) | PEN_COLOR_END;
|
for (let i = 0; i < stitches.length; i++) {
|
||||||
} else if (isLastStitch) {
|
const stitch = stitches[i];
|
||||||
// This is the very last stitch of the pattern
|
const absX = Math.round(stitch[0]);
|
||||||
xEncoded = (xEncoded & 0xFFF8) | PEN_DATA_END;
|
const absY = Math.round(stitch[1]);
|
||||||
}
|
const cmd = stitch[2];
|
||||||
|
const stitchColor = stitch[3]; // Color index from PyStitch
|
||||||
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
|
|
||||||
penStitches.push(
|
// Track bounds for non-jump stitches
|
||||||
xEncoded & 0xFF,
|
if (cmd === STITCH) {
|
||||||
(xEncoded >> 8) & 0xFF,
|
minX = Math.min(minX, absX);
|
||||||
yEncoded & 0xFF,
|
maxX = Math.max(maxX, absX);
|
||||||
(yEncoded >> 8) & 0xFF
|
minY = Math.min(minY, absY);
|
||||||
);
|
maxY = Math.max(maxY, absY);
|
||||||
|
}
|
||||||
// Check for end command
|
|
||||||
if ((cmd & END) !== 0) {
|
// Encode absolute coordinates with flags in low 3 bits
|
||||||
break;
|
// Shift coordinates left by 3 bits to make room for flags
|
||||||
}
|
// As per official app line 780: buffer[index64] = (byte) ((int) numArray4[index64 / 4, 0] << 3 & (int) byte.MaxValue);
|
||||||
}
|
let xEncoded = (absX << 3) & 0xffff;
|
||||||
|
let yEncoded = (absY << 3) & 0xffff;
|
||||||
const penData = new Uint8Array(penStitches);
|
|
||||||
|
// Add command flags to Y-coordinate based on stitch type
|
||||||
return {
|
if (cmd & MOVE) {
|
||||||
stitches,
|
// MOVE/JUMP: Set bit 0 (FEED_DATA) - move without stitching
|
||||||
threads,
|
yEncoded |= PEN_FEED_DATA;
|
||||||
penData,
|
}
|
||||||
colorCount: data.thread_count,
|
if (cmd & TRIM) {
|
||||||
stitchCount: data.stitch_count,
|
// TRIM: Set bit 1 (CUT_DATA) - cut thread command
|
||||||
bounds: {
|
yEncoded |= PEN_CUT_DATA;
|
||||||
minX: minX === Infinity ? 0 : minX,
|
}
|
||||||
maxX: maxX === -Infinity ? 0 : maxX,
|
|
||||||
minY: minY === Infinity ? 0 : minY,
|
// Check if this is the last stitch
|
||||||
maxY: maxY === -Infinity ? 0 : maxY,
|
const isLastStitch = i === stitches.length - 1 || (cmd & END) !== 0;
|
||||||
},
|
|
||||||
};
|
// Check for color change by comparing stitch color index
|
||||||
}
|
// Mark the LAST stitch of the previous color with PEN_COLOR_END
|
||||||
|
// BUT: if this is the last stitch of the entire pattern, use DATA_END instead
|
||||||
/**
|
const nextStitch = stitches[i + 1];
|
||||||
* Get thread color from pattern data
|
const nextStitchColor = nextStitch?.[3];
|
||||||
*/
|
|
||||||
export function getThreadColor(data: PesPatternData, colorIndex: number): string {
|
if (
|
||||||
if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
|
!isLastStitch &&
|
||||||
// Default colors if not specified or index out of bounds
|
nextStitchColor !== undefined &&
|
||||||
const defaultColors = [
|
nextStitchColor !== stitchColor
|
||||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
) {
|
||||||
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
// This is the last stitch before a color change (but not the last stitch overall)
|
||||||
];
|
xEncoded = (xEncoded & 0xfff8) | PEN_COLOR_END;
|
||||||
const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
|
} else if (isLastStitch) {
|
||||||
return defaultColors[safeIndex];
|
// This is the very last stitch of the pattern
|
||||||
}
|
xEncoded = (xEncoded & 0xfff8) | PEN_DATA_END;
|
||||||
|
}
|
||||||
return data.threads[colorIndex]?.hex || '#000000';
|
|
||||||
}
|
// Add stitch as 4 bytes: [X_low, X_high, Y_low, Y_high]
|
||||||
|
penStitches.push(
|
||||||
|
xEncoded & 0xff,
|
||||||
|
(xEncoded >> 8) & 0xff,
|
||||||
|
yEncoded & 0xff,
|
||||||
|
(yEncoded >> 8) & 0xff,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for end command
|
||||||
|
if ((cmd & END) !== 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const penData = new Uint8Array(penStitches);
|
||||||
|
|
||||||
|
// Calculate unique colors from threads (threads represent color blocks, not unique colors)
|
||||||
|
const uniqueColors = threads.reduce((acc, thread, idx) => {
|
||||||
|
const existing = acc.find(c => c.hex === thread.hex);
|
||||||
|
if (existing) {
|
||||||
|
existing.threadIndices.push(idx);
|
||||||
|
} else {
|
||||||
|
acc.push({
|
||||||
|
color: thread.color,
|
||||||
|
hex: thread.hex,
|
||||||
|
brand: thread.brand,
|
||||||
|
catalogNumber: thread.catalogNumber,
|
||||||
|
description: thread.description,
|
||||||
|
chart: thread.chart,
|
||||||
|
threadIndices: [idx],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [] as PesPatternData['uniqueColors']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stitches,
|
||||||
|
threads,
|
||||||
|
uniqueColors,
|
||||||
|
penData,
|
||||||
|
colorCount: data.thread_count,
|
||||||
|
stitchCount: data.stitch_count,
|
||||||
|
bounds: {
|
||||||
|
minX: minX === Infinity ? 0 : minX,
|
||||||
|
maxX: maxX === -Infinity ? 0 : maxX,
|
||||||
|
minY: minY === Infinity ? 0 : minY,
|
||||||
|
maxY: maxY === -Infinity ? 0 : maxY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get thread color from pattern data
|
||||||
|
*/
|
||||||
|
export function getThreadColor(
|
||||||
|
data: PesPatternData,
|
||||||
|
colorIndex: number,
|
||||||
|
): string {
|
||||||
|
if (!data.threads || colorIndex < 0 || colorIndex >= data.threads.length) {
|
||||||
|
// Default colors if not specified or index out of bounds
|
||||||
|
const defaultColors = [
|
||||||
|
"#FF0000",
|
||||||
|
"#00FF00",
|
||||||
|
"#0000FF",
|
||||||
|
"#FFFF00",
|
||||||
|
"#FF00FF",
|
||||||
|
"#00FFFF",
|
||||||
|
"#FFA500",
|
||||||
|
"#800080",
|
||||||
|
];
|
||||||
|
const safeIndex = Math.max(0, colorIndex) % defaultColors.length;
|
||||||
|
return defaultColors[safeIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.threads[colorIndex]?.hex || "#000000";
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue