feat: enhance wire editing features with dynamic component system and improved rendering

pull/10/head
David Montero Crespo 2026-03-03 21:37:19 -03:00
parent a8bb0b6ad9
commit 85cb535804
4 changed files with 350 additions and 97 deletions

View File

@ -293,18 +293,24 @@ Enable verbose logging:
- ✅ Compilation via arduino-cli to .hex files
- ✅ Real AVR8 emulation with avr8js
- ✅ Pin state tracking and component updates
- ✅ Visual components (Arduino Uno, LEDs, resistors, buttons)
- ✅ Wire rendering (visual only, not functional)
- ✅ Dynamic component system with 48+ wokwi-elements components
- ✅ Component picker modal with search and categories
- ✅ Component property dialog (single-click interaction)
- ✅ Component rotation (90° increments)
- ✅ Wire creation and rendering (orthogonal routing)
- ✅ Segment-based wire editing (drag segments perpendicular to orientation)
- ✅ Real-time wire preview with grid snapping (20px)
- ✅ Pin overlay system for wire connections
**In Progress:**
- 🚧 Wire editing and creation UI
- 🚧 Functional wire connections (signal routing)
- 🚧 Functional wire connections (electrical signal routing)
- 🚧 Wire validation and error handling
**Planned:**
- 📋 More components (sensors, displays, motors)
- 📋 Serial monitor
- 📋 Project persistence (SQLite)
- 📋 Multi-board support (Mega, Nano, ESP32)
- 📋 Undo/redo functionality
## Additional Resources

View File

@ -1,73 +1,216 @@
# React + TypeScript + Vite
# Arduino Emulator - Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
React + TypeScript + Vite frontend for the Arduino emulator with visual simulator and code editor.
Currently, two official plugins are available:
## Features
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
- **Monaco Code Editor** - Full VSCode-like Arduino code editing experience
- **Dynamic Component System** - 48+ wokwi-elements components with search and categories
- **Visual Simulator Canvas** - Interactive drag-and-drop circuit builder
- **Component Property Dialog** - Single-click component interaction (rotate, delete, view pins)
- **Segment-Based Wire Editing** - Drag wire segments perpendicular to orientation (like Wokwi)
- **Real AVR8 Emulation** - Actual ATmega328p emulation using avr8js
- **Pin Management** - Automatic pin mapping and state synchronization
- **Grid Snapping** - 20px grid alignment for clean circuit layouts
## React Compiler
## Tech Stack
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
- **React** 18 - UI framework
- **TypeScript** - Static typing
- **Vite** 5 - Build tool and dev server
- **Monaco Editor** - Code editor (VSCode engine)
- **Zustand** - State management
- **Axios** - HTTP client for backend API
- **avr8js** - AVR8 CPU emulator (local clone)
- **@wokwi/elements** - Electronic web components (local clone)
## Expanding the ESLint configuration
## Development
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
### Prerequisites
- Node.js 18+
- Backend running at http://localhost:8001
- Wokwi libraries built in `../wokwi-libs/`
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
### Install Dependencies
```bash
npm install
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
### Run Development Server
```bash
npm run dev
```
The app will be available at http://localhost:5173
### Build for Production
```bash
npm run build
```
Output will be in the `dist/` directory.
### Lint
```bash
npm run lint
```
## Project Structure
```
frontend/
├── src/
│ ├── components/
│ │ ├── components-wokwi/ # React wrappers for wokwi-elements
│ │ ├── editor/ # Monaco Editor components
│ │ │ ├── CodeEditor.tsx
│ │ │ └── EditorToolbar.tsx
│ │ └── simulator/ # Simulation canvas components
│ │ ├── SimulatorCanvas.tsx
│ │ ├── WireLayer.tsx
│ │ ├── WireRenderer.tsx
│ │ ├── PinOverlay.tsx
│ │ ├── ComponentPropertyDialog.tsx
│ │ ├── ComponentPickerModal.tsx
│ │ └── ComponentPalette.tsx
│ ├── simulation/
│ │ ├── AVRSimulator.ts # AVR8 CPU wrapper
│ │ └── PinManager.ts # Pin mapping and callbacks
│ ├── store/
│ │ ├── useEditorStore.ts # Code editor state
│ │ └── useSimulatorStore.ts # Simulation state
│ ├── services/
│ │ ├── api.ts # Backend API client
│ │ └── ComponentRegistry.ts # Component metadata
│ ├── types/ # TypeScript definitions
│ ├── utils/
│ │ ├── hexParser.ts # Intel HEX parser
│ │ ├── wirePathGenerator.ts # Wire SVG path generation
│ │ └── wireSegments.ts # Segment-based wire editing
│ ├── App.tsx # Main app component
│ └── main.tsx # Entry point
├── public/ # Static assets
├── vite.config.ts # Vite configuration
└── package.json
```
## Key Architecture Patterns
### State Management (Zustand)
Two main stores:
- **useEditorStore** - Code content, theme, compilation state
- **useSimulatorStore** - Simulation running state, components, wires, compiled hex
### Local Wokwi Libraries
Vite aliases point to local clones instead of npm packages:
```typescript
resolve: {
alias: {
'avr8js': path.resolve(__dirname, '../wokwi-libs/avr8js/dist/esm'),
'@wokwi/elements': path.resolve(__dirname, '../wokwi-libs/wokwi-elements/dist/esm'),
},
}
```
### AVR Simulation Loop
- Runs at ~60 FPS using `requestAnimationFrame`
- Executes ~267,000 CPU cycles per frame (16MHz / 60fps)
- Port listeners fire when GPIO registers change
- PinManager routes pin states to component callbacks
### Component System
Components are Web Components from wokwi-elements:
1. React wrappers in `components-wokwi/`
2. Dynamic loading via ComponentRegistry
3. Pin info extracted from component metadata
4. State updates via refs and callbacks
### Wire Editing System
Segment-based editing (like Wokwi):
- Wires consist of orthogonal segments (horizontal/vertical)
- Drag segments perpendicular to orientation:
- Horizontal segments: move up/down (ns-resize)
- Vertical segments: move left/right (ew-resize)
- Local preview state during drag (requestAnimationFrame)
- Store update only on mouse up with grid snapping (20px)
### Performance Optimizations
- `requestAnimationFrame` for smooth wire dragging
- Local state for real-time previews
- Memoized path generation and segment computation
- Store updates batched at interaction completion
## API Integration
Backend endpoints (http://localhost:8001):
- `POST /api/compile` - Compile Arduino code to .hex
- `GET /api/compile/status/{task_id}` - Check compilation status
- `GET /api/compile/download/{filename}` - Download compiled .hex
See [backend documentation](../backend/README.md) for API details.
## Component Development
### Adding a New Component Type
1. Check if wokwi-elements has the component:
```bash
ls ../wokwi-libs/wokwi-elements/src/
```
2. Create React wrapper in `src/components/components-wokwi/`:
```typescript
import React, { useRef, useEffect } from 'react';
export const WokwiMyComponent: React.FC<Props> = ({ ... }) => {
const elementRef = useRef<any>(null);
useEffect(() => {
if (elementRef.current) {
elementRef.current.setAttribute('prop', value);
}
}, [value]);
return <wokwi-my-component ref={elementRef} />;
};
```
3. Add to ComponentRegistry metadata
4. Use in SimulatorCanvas or make available in ComponentPalette
## Troubleshooting
### Monaco Editor Not Loading
- Check if `monaco-editor` is installed
- Verify Vite worker configuration in vite.config.ts
### Components Not Rendering
- Ensure wokwi-elements is built: `cd ../wokwi-libs/wokwi-elements && npm run build`
- Check browser console for Web Component registration errors
- Verify Vite alias paths in vite.config.ts
### Wire Editing Performance Issues
- Ensure `requestAnimationFrame` is being used
- Check that store updates only happen on mouse up, not during drag
- Verify no unnecessary re-renders with React DevTools
### Pin Alignment Issues
- Pin coordinates from wokwi-elements are in CSS pixels
- Do NOT multiply by MM_TO_PX conversion factor
- Verify component position + pin offset calculation
### Compilation Fails
- Check backend is running at http://localhost:8001
- Verify arduino-cli is installed and `arduino:avr` core is available
- Check CORS configuration in backend
## References
- [Main Project README](../README.md)
- [Development Guide (CLAUDE.md)](../CLAUDE.md)
- [Architecture Documentation](../ARCHITECTURE.md)
- [Wokwi Integration](../WOKWI_LIBS.md)
- [Monaco Editor API](https://microsoft.github.io/monaco-editor/api/index.html)
- [Vite Documentation](https://vitejs.dev/)
- [Zustand Guide](https://docs.pmnd.rs/zustand/getting-started/introduction)

View File

@ -40,6 +40,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
const [previewOrthoPoints, setPreviewOrthoPoints] = useState<Array<{ x: number; y: number }> | null>(null);
const svgRef = useRef<SVGGElement>(null);
const rafRef = useRef<number | null>(null); // For requestAnimationFrame
// Generate SVG path (memoized for performance)
// Use preview points during drag, actual wire points otherwise
@ -56,9 +57,41 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
}, [wire, previewOrthoPoints]);
// Compute segments (memoized)
// Use preview points during drag for accurate segment positions
const segments = useMemo(() => {
if (previewOrthoPoints) {
// During drag, compute segments from preview points
const previewSegments: WireSegment[] = [];
for (let i = 0; i < previewOrthoPoints.length - 1; i++) {
const start = previewOrthoPoints[i];
const end = previewOrthoPoints[i + 1];
if (start.x === end.x && start.y === end.y) continue;
const orientation = start.y === end.y ? 'horizontal' : 'vertical';
const length =
orientation === 'horizontal'
? Math.abs(end.x - start.x)
: Math.abs(end.y - start.y);
previewSegments.push({
id: `${wire.id}-seg-${i}`,
startPoint: start,
endPoint: end,
orientation,
midPoint: {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
},
length,
startIndex: i,
endIndex: i + 1,
});
}
return previewSegments;
}
return computeSegments(wire);
}, [wire]);
}, [wire, previewOrthoPoints]);
// Handle wire selection
const handleWireClick = useCallback(
@ -73,7 +106,13 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (dragState) {
// Handle dragging - use local state for smooth updates
// Cancel previous animation frame
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
// Handle dragging - use requestAnimationFrame for smooth updates
rafRef.current = requestAnimationFrame(() => {
const svg = svgRef.current?.ownerSVGElement;
if (!svg) return;
@ -91,6 +130,14 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
offset = mouseX - startMousePos.x;
}
console.log('🎯 Drag Update:', {
segmentId: segment.id,
orientation: segment.orientation,
offset,
originalPointsCount: originalOrthoPoints.length,
mousePos: { x: mouseX, y: mouseY },
});
// No grid snapping during drag for smooth movement
// Grid snapping will be applied on mouse up
@ -101,8 +148,12 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
offset
);
console.log('📍 New Ortho Points:', newOrthoPoints);
// Update preview state (doesn't touch the store)
setPreviewOrthoPoints(newOrthoPoints);
rafRef.current = null;
});
} else if (isSelected) {
// Update hovered segment
const svg = svgRef.current?.ownerSVGElement;
@ -134,6 +185,18 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
const pathPoints = getPathPoints(wire);
const orthoPoints = generateOrthogonalPoints(pathPoints);
console.log('🚀 Start Dragging Segment:', {
segmentId: segment.id,
orientation: segment.orientation,
segmentStart: segment.startPoint,
segmentEnd: segment.endPoint,
pathPointsCount: pathPoints.length,
orthoPointsCount: orthoPoints.length,
wireStart: wire.start,
wireEnd: wire.end,
wireControlPoints: wire.controlPoints,
});
setDragState({
segment,
startMousePos: { x: mouseX, y: mouseY },
@ -144,6 +207,14 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
);
const handleMouseUp = useCallback(() => {
console.log('🖱️ Mouse Up - Drag State:', {
hasDragState: !!dragState,
hasPreviewPoints: !!previewOrthoPoints,
previewPointsCount: previewOrthoPoints?.length,
wireStart: wire.start,
wireEnd: wire.end,
});
if (dragState && previewOrthoPoints) {
// Apply grid snapping to final position
const GRID_SIZE = 20;
@ -152,6 +223,8 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
y: Math.round(p.y / GRID_SIZE) * GRID_SIZE,
}));
console.log('📐 Snapped Points:', snappedPoints);
// Convert back to control points
const newControlPoints = orthogonalPointsToControlPoints(
snappedPoints,
@ -159,6 +232,12 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
wire.end
);
console.log('🔧 New Control Points:', newControlPoints);
console.log('🔌 Wire Endpoints:', {
start: wire.start,
end: wire.end,
});
// Update store only once at the end
updateWire(wire.id, { controlPoints: newControlPoints });
}
@ -190,6 +269,15 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
}
}, [hoveredSegment, dragState]);
// Cleanup animation frame on unmount
useEffect(() => {
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
}
};
}, []);
return (
<g
ref={svgRef}

View File

@ -177,22 +177,38 @@ export function updateOrthogonalPointsForSegmentDrag(
/**
* Convert orthogonal points back to control points
* Removes start/end points and intermediate points that are redundant
*
* IMPORTANT: The first and last orthoPoints should match the wire endpoints.
* We preserve ALL intermediate points that represent corners (direction changes).
*/
export function orthogonalPointsToControlPoints(
orthoPoints: Array<{ x: number; y: number }>,
start: { x: number; y: number },
end: { x: number; y: number }
): WireControlPoint[] {
if (orthoPoints.length < 2) {
return [];
}
// Remove first and last points (those are start/end endpoints)
const innerPoints = orthoPoints.slice(1, -1);
// Remove redundant points (those that are collinear with neighbors)
if (innerPoints.length === 0) {
return [];
}
// Use actual orthoPoints for prev/next comparison (not start/end parameters)
const actualStart = orthoPoints[0];
const actualEnd = orthoPoints[orthoPoints.length - 1];
// Keep only corner points (where direction changes)
const controlPoints: WireControlPoint[] = [];
for (let i = 0; i < innerPoints.length; i++) {
const current = innerPoints[i];
const prev = i === 0 ? start : innerPoints[i - 1];
const next = i === innerPoints.length - 1 ? end : innerPoints[i + 1];
// Get prev from orthoPoints (index i in innerPoints = index i+1 in orthoPoints)
const prev = orthoPoints[i]; // Previous point in orthoPoints
const next = orthoPoints[i + 2]; // Next point in orthoPoints
// Check if current point is a corner (changes direction)
const isCorner =