From cd43a64bc42e3c4927a2755bfd54a96465a2c3bd Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 5 Dec 2025 23:38:23 +0100 Subject: [PATCH] Migrate to declarative react-konva and add pattern offset caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React-Konva Migration: - Create KonvaComponents.tsx with declarative components - Convert Grid, Origin, Hoop, Stitches, PatternBounds, and CurrentPosition to React components - Add React.memo and useMemo for performance optimization - Remove imperative layer manipulation (destroyChildren, add, batchDraw) - Remove backgroundLayerRef and patternLayerRef - Let React handle component lifecycle and updates - Improve performance through React's diffing algorithm Pattern Offset Persistence: - Add patternOffset field to CachedPattern interface - Update PatternCacheService.savePattern to accept and store offset - Modify useBrotherMachine to save offset when uploading pattern - Update resumedPattern state to include offset information - Restore cached pattern offset in App.tsx on resume - Add initialPatternOffset prop to PatternCanvas component - Pattern position now persists across page reloads and reconnections Benefits: - More maintainable and React-idiomatic code - Better performance with large patterns - Automatic cleanup and no memory leaks - Pattern positioning workflow preserved across sessions - Improved developer experience 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 9 +- src/components/KonvaComponents.tsx | 229 ++++++++++++++++++++++++++++ src/components/PatternCanvas.tsx | 113 +++++--------- src/hooks/useBrotherMachine.ts | 18 +-- src/services/PatternCacheService.ts | 7 +- 5 files changed, 291 insertions(+), 85 deletions(-) create mode 100644 src/components/KonvaComponents.tsx diff --git a/src/App.tsx b/src/App.tsx index 61f83f0..a12e5cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,8 +32,12 @@ function App() { // Auto-load cached pattern when available useEffect(() => { if (machine.resumedPattern && !pesData) { - console.log('[App] Loading resumed pattern:', machine.resumeFileName); - setPesData(machine.resumedPattern); + console.log('[App] Loading resumed pattern:', machine.resumeFileName, 'Offset:', machine.resumedPattern.patternOffset); + setPesData(machine.resumedPattern.pesData); + // Restore the cached pattern offset + if (machine.resumedPattern.patternOffset) { + setPatternOffset(machine.resumedPattern.patternOffset); + } } }, [machine.resumedPattern, pesData, machine.resumeFileName]); @@ -106,6 +110,7 @@ function App() { pesData={pesData} sewingProgress={machine.sewingProgress} machineInfo={machine.machineInfo} + initialPatternOffset={patternOffset} onPatternOffsetChange={handlePatternOffsetChange} /> diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx new file mode 100644 index 0000000..58ab887 --- /dev/null +++ b/src/components/KonvaComponents.tsx @@ -0,0 +1,229 @@ +import { memo, useMemo } from 'react'; +import { Group, Line, Rect, Text, Circle } from 'react-konva'; +import type { PesPatternData } from '../utils/pystitchConverter'; +import { getThreadColor } from '../utils/pystitchConverter'; +import type { MachineInfo } from '../types/machine'; + +const MOVE = 0x10; + +interface GridProps { + gridSize: number; + bounds: { minX: number; maxX: number; minY: number; maxY: number }; + machineInfo: MachineInfo | null; +} + +export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => { + const lines = useMemo(() => { + const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX; + const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX; + const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY; + const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY; + + const verticalLines: number[][] = []; + const horizontalLines: number[][] = []; + + // Vertical lines + for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) { + verticalLines.push([x, gridMinY, x, gridMaxY]); + } + + // Horizontal lines + for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) { + horizontalLines.push([gridMinX, y, gridMaxX, y]); + } + + return { verticalLines, horizontalLines }; + }, [gridSize, bounds, machineInfo]); + + return ( + + {lines.verticalLines.map((points, i) => ( + + ))} + {lines.horizontalLines.map((points, i) => ( + + ))} + + ); +}); + +Grid.displayName = 'Grid'; + +export const Origin = memo(() => { + return ( + + + + + ); +}); + +Origin.displayName = 'Origin'; + +interface HoopProps { + machineInfo: MachineInfo; +} + +export const Hoop = memo(({ machineInfo }: HoopProps) => { + const { maxWidth, maxHeight } = machineInfo; + const hoopLeft = -maxWidth / 2; + const hoopTop = -maxHeight / 2; + + return ( + + + + + ); +}); + +Hoop.displayName = 'Hoop'; + +interface PatternBoundsProps { + bounds: { minX: number; maxX: number; minY: number; maxY: number }; +} + +export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => { + const { minX, maxX, minY, maxY } = bounds; + const width = maxX - minX; + const height = maxY - minY; + + return ( + + ); +}); + +PatternBounds.displayName = 'PatternBounds'; + +interface StitchesProps { + stitches: number[][]; + pesData: PesPatternData; + currentStitchIndex: number; +} + +export const Stitches = memo(({ stitches, pesData, currentStitchIndex }: StitchesProps) => { + const stitchGroups = useMemo(() => { + interface StitchGroup { + color: string; + points: number[]; + completed: boolean; + isJump: boolean; + } + + const groups: StitchGroup[] = []; + let currentGroup: StitchGroup | null = null; + + for (let i = 0; i < stitches.length; i++) { + const stitch = stitches[i]; + const [x, y, cmd, colorIndex] = stitch; + const isCompleted = i < currentStitchIndex; + const isJump = (cmd & MOVE) !== 0; + const color = getThreadColor(pesData, colorIndex); + + // Start new group if color/status/type changes + if ( + !currentGroup || + currentGroup.color !== color || + currentGroup.completed !== isCompleted || + currentGroup.isJump !== isJump + ) { + currentGroup = { + color, + points: [x, y], + completed: isCompleted, + isJump, + }; + groups.push(currentGroup); + } else { + currentGroup.points.push(x, y); + } + } + + return groups; + }, [stitches, pesData, currentStitchIndex]); + + return ( + + {stitchGroups.map((group, i) => ( + + ))} + + ); +}); + +Stitches.displayName = 'Stitches'; + +interface CurrentPositionProps { + currentStitchIndex: number; + stitches: number[][]; +} + +export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => { + if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) { + return null; + } + + const [x, y] = stitches[currentStitchIndex]; + + return ( + + + + + + + + ); +}); + +CurrentPosition.displayName = 'CurrentPosition'; diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx index b76823d..58c3fcf 100644 --- a/src/components/PatternCanvas.tsx +++ b/src/components/PatternCanvas.tsx @@ -3,34 +3,35 @@ import { Stage, Layer, Group } from 'react-konva'; import Konva from 'konva'; import type { PesPatternData } from '../utils/pystitchConverter'; import type { SewingProgress, MachineInfo } from '../types/machine'; -import { - renderGrid, - renderOrigin, - renderHoop, - renderStitches, - renderPatternBounds, - calculateInitialScale, -} from '../utils/konvaRenderers'; +import { calculateInitialScale } from '../utils/konvaRenderers'; +import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents'; interface PatternCanvasProps { pesData: PesPatternData | null; sewingProgress: SewingProgress | null; machineInfo: MachineInfo | null; + initialPatternOffset?: { x: number; y: number }; onPatternOffsetChange?: (offsetX: number, offsetY: number) => void; } -export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) { +export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange }: PatternCanvasProps) { const containerRef = useRef(null); const stageRef = useRef(null); - const backgroundLayerRef = useRef(null); - const patternLayerRef = useRef(null); const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stageScale, setStageScale] = useState(1); - const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 }); + const [patternOffset, setPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 }); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const initialScaleRef = useRef(1); + // Update pattern offset when initialPatternOffset changes + useEffect(() => { + if (initialPatternOffset) { + setPatternOffset(initialPatternOffset); + console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset); + } + }, [initialPatternOffset]); + // Track container size useEffect(() => { if (!containerRef.current) return; @@ -166,61 +167,6 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO } }, [onPatternOffsetChange]); - const handlePatternDragMove = useCallback(() => { - // Just for visual feedback during drag - }, []); - - // Render background layer content - const renderBackgroundLayer = useCallback((layer: Konva.Layer) => { - if (!pesData) return; - - layer.destroyChildren(); - - const { bounds } = pesData; - const gridSize = 100; // 10mm grid (100 units in 0.1mm) - - renderGrid(layer, gridSize, bounds, machineInfo); - renderOrigin(layer); - - if (machineInfo) { - renderHoop(layer, machineInfo); - } - - layer.batchDraw(); - }, [pesData, machineInfo]); - - // Render pattern layer content - const renderPatternLayer = useCallback((layer: Konva.Layer, group: Konva.Group) => { - if (!pesData) return; - - group.destroyChildren(); - - const currentStitch = sewingProgress?.currentStitch || 0; - const { stitches, bounds } = pesData; - - renderStitches(group, stitches, pesData, currentStitch); - renderPatternBounds(group, bounds); - - layer.batchDraw(); - }, [pesData, sewingProgress]); - - // Update background layer when deps change - useEffect(() => { - if (backgroundLayerRef.current) { - renderBackgroundLayer(backgroundLayerRef.current); - } - }, [renderBackgroundLayer]); - - // Update pattern layer when deps change - useEffect(() => { - if (patternLayerRef.current) { - const patternGroup = patternLayerRef.current.findOne('.pattern-group') as Konva.Group; - if (patternGroup) { - renderPatternLayer(patternLayerRef.current, patternGroup); - } - } - }, [renderPatternLayer]); - return (

Pattern Preview

@@ -253,10 +199,22 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO }} > {/* Background layer: grid, origin, hoop */} - + + {pesData && ( + <> + + + {machineInfo && } + + )} + {/* Pattern layer: draggable stitches and bounds */} - + {pesData && ( { const stage = e.target.getStage(); if (stage) stage.container().style.cursor = 'move'; @@ -273,14 +230,26 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO const stage = e.target.getStage(); if (stage) stage.container().style.cursor = 'grab'; }} - /> + > + + + )} {/* Current position layer */} {pesData && sewingProgress && sewingProgress.currentStitch > 0 && ( - + + + )} diff --git a/src/hooks/useBrotherMachine.ts b/src/hooks/useBrotherMachine.ts index 3d6e490..1847c22 100644 --- a/src/hooks/useBrotherMachine.ts +++ b/src/hooks/useBrotherMachine.ts @@ -29,7 +29,7 @@ export function useBrotherMachine() { const [isPolling, setIsPolling] = useState(false); const [resumeAvailable, setResumeAvailable] = useState(false); const [resumeFileName, setResumeFileName] = useState(null); - const [resumedPattern, setResumedPattern] = useState( + const [resumedPattern, setResumedPattern] = useState<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>( null, ); @@ -58,11 +58,11 @@ export function useBrotherMachine() { const cached = PatternCacheService.getPatternByUUID(uuidStr); if (cached) { - console.log("[Resume] Pattern found in cache:", cached.fileName); + console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset); console.log("[Resume] Auto-loading cached pattern..."); setResumeAvailable(true); setResumeFileName(cached.fileName); - setResumedPattern(cached.pesData); + setResumedPattern({ pesData: cached.pesData, patternOffset: cached.patternOffset }); // Fetch pattern info from machine try { @@ -166,7 +166,7 @@ export function useBrotherMachine() { }, [service, isConnected]); const loadCachedPattern = - useCallback(async (): Promise => { + useCallback(async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => { if (!resumeAvailable) return null; try { @@ -177,10 +177,10 @@ export function useBrotherMachine() { const cached = PatternCacheService.getPatternByUUID(uuidStr); if (cached) { - console.log("[Resume] Loading cached pattern:", cached.fileName); + console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset); // Refresh pattern info from machine await refreshPatternInfo(); - return cached.pesData; + return { pesData: cached.pesData, patternOffset: cached.patternOffset }; } return null; @@ -212,10 +212,10 @@ export function useBrotherMachine() { ); setUploadProgress(100); - // Cache the pattern with its UUID + // Cache the pattern with its UUID and offset const uuidStr = uuidToString(uuid); - PatternCacheService.savePattern(uuidStr, pesData, fileName); - console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr); + PatternCacheService.savePattern(uuidStr, pesData, fileName, patternOffset); + console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset); // Clear resume state since we just uploaded setResumeAvailable(false); diff --git a/src/services/PatternCacheService.ts b/src/services/PatternCacheService.ts index b12f5b6..bde64af 100644 --- a/src/services/PatternCacheService.ts +++ b/src/services/PatternCacheService.ts @@ -5,6 +5,7 @@ interface CachedPattern { pesData: PesPatternData; fileName: string; timestamp: number; + patternOffset?: { x: number; y: number }; } const CACHE_KEY = 'brother_pattern_cache'; @@ -34,7 +35,8 @@ export class PatternCacheService { static savePattern( uuid: string, pesData: PesPatternData, - fileName: string + fileName: string, + patternOffset?: { x: number; y: number } ): void { try { // Convert penData Uint8Array to array for JSON serialization @@ -48,10 +50,11 @@ export class PatternCacheService { pesData: pesDataWithArrayPenData, fileName, timestamp: Date.now(), + patternOffset, }; localStorage.setItem(CACHE_KEY, JSON.stringify(cached)); - console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid); + console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset); } catch (err) { console.error('[PatternCache] Failed to save pattern:', err); // If quota exceeded, clear and try again