import { useEffect, useRef, useState, useCallback } from 'react'; import Konva from 'konva'; import type { PesPatternData } from '../utils/pystitchConverter'; import type { SewingProgress, MachineInfo } from '../types/machine'; import { renderGrid, renderOrigin, renderHoop, renderStitches, renderPatternBounds, renderCurrentPosition, calculateInitialScale, } from '../utils/konvaRenderers'; interface PatternCanvasProps { pesData: PesPatternData | null; sewingProgress: SewingProgress | null; machineInfo: MachineInfo | null; onPatternOffsetChange?: (offsetX: number, offsetY: number) => void; } export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) { const containerRef = useRef(null); const stageRef = useRef(null); const backgroundLayerRef = useRef(null); const patternLayerRef = useRef(null); const currentPosLayerRef = useRef(null); const patternGroupRef = useRef(null); const [zoomLevel, setZoomLevel] = useState(1); const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 }); const initialScaleRef = useRef(1); const isDraggingRef = useRef(false); // Initialize Konva stage and layers useEffect(() => { if (!containerRef.current) return; // Prevent double initialization if (stageRef.current) return; const container = containerRef.current; // Create stage const stage = new Konva.Stage({ container, width: container.offsetWidth, height: container.offsetHeight, draggable: false, // Stage itself is not draggable }); // Configure stage to center on embroidery origin (0,0) // Simply position the stage so that (0,0) appears at the center stage.position({ x: stage.width() / 2, y: stage.height() / 2 }); // Create layers const backgroundLayer = new Konva.Layer(); const patternLayer = new Konva.Layer(); const currentPosLayer = new Konva.Layer(); stage.add(backgroundLayer, patternLayer, currentPosLayer); // Store refs stageRef.current = stage; backgroundLayerRef.current = backgroundLayer; patternLayerRef.current = patternLayer; currentPosLayerRef.current = currentPosLayer; // Set initial cursor - grab for panning stage.container().style.cursor = 'grab'; // Make stage draggable for panning stage.draggable(true); // Update cursor on drag stage.on('dragstart', () => { stage.container().style.cursor = 'grabbing'; }); stage.on('dragend', () => { stage.container().style.cursor = 'grab'; }); // Mouse wheel zoom handler const handleWheel = (e: Konva.KonvaEventObject) => { e.evt.preventDefault(); const oldScale = stage.scaleX(); const pointer = stage.getPointerPosition(); if (!pointer) return; const scaleBy = 1.1; const direction = e.evt.deltaY > 0 ? -1 : 1; let newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; // Apply constraints newScale = Math.max(0.1, Math.min(10, newScale)); // Zoom towards pointer const mousePointTo = { x: (pointer.x - stage.x()) / oldScale, y: (pointer.y - stage.y()) / oldScale, }; const newPos = { x: pointer.x - mousePointTo.x * newScale, y: pointer.y - mousePointTo.y * newScale, }; stage.scale({ x: newScale, y: newScale }); stage.position(newPos); setZoomLevel(newScale); stage.batchDraw(); }; // Attach wheel event stage.on('wheel', handleWheel); return () => { // Clear refs before destroying to prevent race conditions stageRef.current = null; backgroundLayerRef.current = null; patternLayerRef.current = null; currentPosLayerRef.current = null; patternGroupRef.current = null; // Destroy the stage (this removes the canvas from DOM) stage.destroy(); }; }, []); // Handle responsive resizing useEffect(() => { if (!containerRef.current || !stageRef.current) return; const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; const stage = stageRef.current; if (stage) { // Keep the current pan/zoom, just update size const oldWidth = stage.width(); const oldHeight = stage.height(); const oldPos = stage.position(); stage.width(width); stage.height(height); // Adjust position to maintain center point stage.position({ x: oldPos.x + (width - oldWidth) / 2, y: oldPos.y + (height - oldHeight) / 2, }); stage.batchDraw(); } } }); resizeObserver.observe(containerRef.current); return () => resizeObserver.disconnect(); }, []); // Helper function to zoom to a specific point const zoomToPoint = useCallback( (point: { x: number; y: number }, newScale: number) => { const stage = stageRef.current; if (!stage) return; const oldScale = stage.scaleX(); const mousePointTo = { x: (point.x - stage.x()) / oldScale, y: (point.y - stage.y()) / oldScale, }; const newPos = { x: point.x - mousePointTo.x * newScale, y: point.y - mousePointTo.y * newScale, }; stage.scale({ x: newScale, y: newScale }); stage.position(newPos); setZoomLevel(newScale); stage.batchDraw(); }, [] ); // Zoom control handlers const handleZoomIn = useCallback(() => { const stage = stageRef.current; if (!stage) return; const newScale = Math.min(stage.scaleX() * 1.2, 10); const center = { x: stage.width() / 2, y: stage.height() / 2, }; zoomToPoint(center, newScale); }, [zoomToPoint]); const handleZoomOut = useCallback(() => { const stage = stageRef.current; if (!stage) return; const newScale = Math.max(stage.scaleX() / 1.2, 0.1); const center = { x: stage.width() / 2, y: stage.height() / 2, }; zoomToPoint(center, newScale); }, [zoomToPoint]); const handleZoomReset = useCallback(() => { const stage = stageRef.current; if (!stage) return; const initialScale = initialScaleRef.current; stage.scale({ x: initialScale, y: initialScale }); stage.position({ x: stage.width() / 2, y: stage.height() / 2 }); setZoomLevel(initialScale); stage.batchDraw(); }, []); // Render background layer (grid, origin, hoop) useEffect(() => { const layer = backgroundLayerRef.current; const stage = stageRef.current; if (!layer || !stage || !pesData) return; layer.destroyChildren(); const { bounds } = pesData; // Determine view dimensions - always fit to hoop if available, otherwise fit to pattern const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX; const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY; // Calculate and store initial scale const initialScale = calculateInitialScale(stage.width(), stage.height(), viewWidth, viewHeight); initialScaleRef.current = initialScale; // Only set initial scale if this is the first render (zoom level is still 1) if (zoomLevel === 1) { stage.scale({ x: initialScale, y: initialScale }); stage.position({ x: stage.width() / 2, y: stage.height() / 2 }); setZoomLevel(initialScale); } // Render background elements const gridSize = 100; // 10mm grid (100 units in 0.1mm) renderGrid(layer, gridSize, bounds, machineInfo); renderOrigin(layer); if (machineInfo) { renderHoop(layer, machineInfo); } // Cache the background layer for performance layer.cache(); layer.batchDraw(); }, [machineInfo, pesData]); // Render pattern layer (stitches and bounds in a draggable group) // This effect only runs when the pattern changes, NOT when sewing progress changes useEffect(() => { const layer = patternLayerRef.current; if (!layer || !pesData) return; layer.destroyChildren(); const { stitches, bounds } = pesData; // Create a draggable group for the pattern const patternGroup = new Konva.Group({ draggable: true, x: patternOffset.x, y: patternOffset.y, }); // Store ref patternGroupRef.current = patternGroup; // Render pattern elements into the group (initial render with currentStitch = 0) const currentStitch = sewingProgress?.currentStitch || 0; renderStitches(patternGroup, stitches, pesData, currentStitch); renderPatternBounds(patternGroup, bounds); // Handle drag events patternGroup.on('dragstart', () => { isDraggingRef.current = true; }); patternGroup.on('dragend', () => { isDraggingRef.current = false; const newOffset = { x: patternGroup.x(), y: patternGroup.y(), }; setPatternOffset(newOffset); // Notify parent component of offset change if (onPatternOffsetChange) { onPatternOffsetChange(newOffset.x, newOffset.y); } }); // Add visual feedback on hover patternGroup.on('mouseenter', () => { const stage = stageRef.current; if (stage) stage.container().style.cursor = 'move'; }); patternGroup.on('mouseleave', () => { if (!isDraggingRef.current) { const stage = stageRef.current; if (stage) stage.container().style.cursor = 'grab'; } }); layer.add(patternGroup); layer.batchDraw(); }, [pesData, onPatternOffsetChange]); // Removed sewingProgress from dependencies // Separate effect to update stitches when sewing progress changes // This only updates the stitch rendering, not the entire group useEffect(() => { const patternGroup = patternGroupRef.current; if (!patternGroup || !pesData || isDraggingRef.current) return; const currentStitch = sewingProgress?.currentStitch || 0; const { stitches } = pesData; // Remove old stitches group and re-render const oldStitchesGroup = patternGroup.findOne('.stitches'); if (oldStitchesGroup) { oldStitchesGroup.destroy(); } // Re-render stitches with updated progress renderStitches(patternGroup, stitches, pesData, currentStitch); patternGroup.getLayer()?.batchDraw(); }, [sewingProgress, pesData]); // Separate effect to update pattern position when offset changes externally (not during drag) useEffect(() => { const patternGroup = patternGroupRef.current; if (patternGroup && !isDraggingRef.current) { patternGroup.position({ x: patternOffset.x, y: patternOffset.y }); patternGroup.getLayer()?.batchDraw(); } }, [patternOffset.x, patternOffset.y]); // Render current position layer (updates frequently, follows pattern offset) useEffect(() => { const layer = currentPosLayerRef.current; if (!layer || !pesData) return; layer.destroyChildren(); const currentStitch = sewingProgress?.currentStitch || 0; const { stitches } = pesData; if (currentStitch > 0 && currentStitch < stitches.length) { // Create group at pattern offset const posGroup = new Konva.Group({ x: patternOffset.x, y: patternOffset.y, }); renderCurrentPosition(posGroup, currentStitch, stitches); layer.add(posGroup); } layer.batchDraw(); }, [pesData, sewingProgress, patternOffset.x, patternOffset.y]); return (

Pattern Preview

{/* Konva container - separate from React-managed overlays */}
{/* Placeholder overlay when no pattern is loaded */} {!pesData && (
Load a PES file to preview the pattern
)} {/* Pattern info overlays */} {pesData && ( <> {/* Thread Legend Overlay */}

Threads

{pesData.threads.map((thread, index) => (
Thread {index + 1}
))}
{/* Pattern Dimensions Overlay */}
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
{/* Pattern Offset Indicator */}
Pattern Position:
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
Drag pattern to move • Drag background to pan
{/* Zoom Controls Overlay */}
{Math.round(zoomLevel * 100)}%
)}
); }