mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 02:13:41 +00:00
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:
parent
30d87f82bc
commit
cd43a64bc4
5 changed files with 291 additions and 85 deletions
|
|
@ -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>
|
||||
|
|
|
|||
229
src/components/KonvaComponents.tsx
Normal file
229
src/components/KonvaComponents.tsx
Normal 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';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue