Migrate to declarative react-konva and add pattern offset caching

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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2025-12-05 23:38:23 +01:00
parent 30d87f82bc
commit cd43a64bc4
5 changed files with 291 additions and 85 deletions

View file

@ -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}
/>
</div>

View file

@ -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 (
<Group name="grid">
{lines.verticalLines.map((points, i) => (
<Line
key={`v-${i}`}
points={points}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
{lines.horizontalLines.map((points, i) => (
<Line
key={`h-${i}`}
points={points}
stroke="#e0e0e0"
strokeWidth={1}
/>
))}
</Group>
);
});
Grid.displayName = 'Grid';
export const Origin = memo(() => {
return (
<Group name="origin">
<Line points={[-10, 0, 10, 0]} stroke="#888" strokeWidth={2} />
<Line points={[0, -10, 0, 10]} stroke="#888" strokeWidth={2} />
</Group>
);
});
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 (
<Group name="hoop">
<Rect
x={hoopLeft}
y={hoopTop}
width={maxWidth}
height={maxHeight}
stroke="#2196F3"
strokeWidth={3}
dash={[10, 5]}
/>
<Text
x={hoopLeft + 10}
y={hoopTop + 10}
text={`Hoop: ${(maxWidth / 10).toFixed(0)} x ${(maxHeight / 10).toFixed(0)} mm`}
fontSize={14}
fontFamily="sans-serif"
fontStyle="bold"
fill="#2196F3"
/>
</Group>
);
});
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 (
<Rect
x={minX}
y={minY}
width={width}
height={height}
stroke="#ff0000"
strokeWidth={2}
dash={[5, 5]}
/>
);
});
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 (
<Group name="stitches">
{stitchGroups.map((group, i) => (
<Line
key={i}
points={group.points}
stroke={group.isJump ? (group.completed ? '#cccccc' : '#e8e8e8') : group.color}
strokeWidth={1.5}
lineCap="round"
lineJoin="round"
dash={group.isJump ? [3, 3] : undefined}
opacity={group.isJump ? 1 : (group.completed ? 1.0 : 0.3)}
/>
))}
</Group>
);
});
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 (
<Group name="currentPosition">
<Circle
x={x}
y={y}
radius={8}
fill="rgba(255, 0, 0, 0.3)"
stroke="#ff0000"
strokeWidth={3}
/>
<Line points={[x - 12, y, x - 3, y]} stroke="#ff0000" strokeWidth={2} />
<Line points={[x + 12, y, x + 3, y]} stroke="#ff0000" strokeWidth={2} />
<Line points={[x, y - 12, x, y - 3]} stroke="#ff0000" strokeWidth={2} />
<Line points={[x, y + 12, x, y + 3]} stroke="#ff0000" strokeWidth={2} />
</Group>
);
});
CurrentPosition.displayName = 'CurrentPosition';

View file

@ -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<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const backgroundLayerRef = useRef<Konva.Layer | null>(null);
const patternLayerRef = useRef<Konva.Layer | null>(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<number>(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 (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern Preview</h2>
@ -253,10 +199,22 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
}}
>
{/* Background layer: grid, origin, hoop */}
<Layer ref={backgroundLayerRef} />
<Layer>
{pesData && (
<>
<Grid
gridSize={100}
bounds={pesData.bounds}
machineInfo={machineInfo}
/>
<Origin />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}
</Layer>
{/* Pattern layer: draggable stitches and bounds */}
<Layer ref={patternLayerRef}>
<Layer>
{pesData && (
<Group
name="pattern-group"
@ -264,7 +222,6 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
x={patternOffset.x}
y={patternOffset.y}
onDragEnd={handlePatternDragEnd}
onDragMove={handlePatternDragMove}
onMouseEnter={(e) => {
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';
}}
>
<Stitches
stitches={pesData.stitches}
pesData={pesData}
currentStitchIndex={sewingProgress?.currentStitch || 0}
/>
<PatternBounds bounds={pesData.bounds} />
</Group>
)}
</Layer>
{/* Current position layer */}
<Layer>
{pesData && sewingProgress && sewingProgress.currentStitch > 0 && (
<Group x={patternOffset.x} y={patternOffset.y} />
<Group x={patternOffset.x} y={patternOffset.y}>
<CurrentPosition
currentStitchIndex={sewingProgress.currentStitch}
stitches={pesData.stitches}
/>
</Group>
)}
</Layer>
</Stage>

View file

@ -29,7 +29,7 @@ export function useBrotherMachine() {
const [isPolling, setIsPolling] = useState(false);
const [resumeAvailable, setResumeAvailable] = useState(false);
const [resumeFileName, setResumeFileName] = useState<string | null>(null);
const [resumedPattern, setResumedPattern] = useState<PesPatternData | null>(
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<PesPatternData | null> => {
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);

View file

@ -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