Compare commits

...

3 Commits

8 changed files with 2162 additions and 1749 deletions

44
App.tsx
View File

@@ -4,12 +4,13 @@ import { INITIAL_MAP, MAX_SPEED } from './constants';
import EditorToolbar from './components/EditorToolbar';
import SimulationCanvas from './components/SimulationCanvas';
import SerialConsole from './components/SerialConsole';
import AgvControls from './components/AgvControls';
import AgvManualControls from './components/AgvManualControls';
import BmsPanel from './components/BmsPanel';
import AcsControls from './components/AcsControls';
import AgvStatusPanel from './components/AgvStatusPanel';
import AgvAutoRunControls from './components/AgvAutoRunControls';
import SystemLogPanel from './components/SystemLogPanel';
import PropertyPanel from './components/PropertyPanel';
import { SerialPortHandler } from './services/serialService';
// --- ACS CRC16 Table Generation (Matches C# Logic) ---
@@ -43,6 +44,7 @@ const calculateAcsCrc16 = (data: number[] | Uint8Array): number => {
const App: React.FC = () => {
// --- State ---
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
// Map Data
const [mapData, setMapData] = useState<SimulationMap>(INITIAL_MAP);
@@ -123,6 +125,20 @@ const App: React.FC = () => {
const [bmsPortInfo, setBmsPortInfo] = useState<string | null>(null);
const [acsPortInfo, setAcsPortInfo] = useState<string | null>(null);
const handlePropertyUpdate = (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => {
setMapData(prev => {
const next = { ...prev };
if (type === 'NODE') {
next.nodes = prev.nodes.map(n => n.id === id ? { ...n, ...data } : n);
} else if (type === 'MAGNET') {
next.magnets = prev.magnets.map(m => m.id === id ? { ...m, ...data } : m);
} else if (type === 'MARK') {
next.marks = prev.marks.map(m => m.id === id ? { ...m, ...data } : m);
}
return next;
});
};
// Serial Configuration
const [agvBaudRate, setAgvBaudRate] = useState(57600);
const [bmsBaudRate, setBmsBaudRate] = useState(9600);
@@ -443,6 +459,11 @@ const App: React.FC = () => {
barr[22] = s.sensorStatus.charCodeAt(0);
barr.set(encodeHex(s.signalFlags, 2), 23);
// Fill bytes 25-30 with '0' padding
for (let i = 25; i <= 30; i++) {
barr[i] = 0x30; // '0'
}
barr[31] = '*'.charCodeAt(0); barr[32] = '*'.charCodeAt(0); barr[33] = 0x03;
agvSerialRef.current.send(barr);
@@ -1206,6 +1227,13 @@ const App: React.FC = () => {
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Overlay: Property Panel (Floating) */}
<PropertyPanel
selectedItemIds={selectedItemIds}
mapData={mapData}
onUpdate={handlePropertyUpdate}
/>
{/* 1. Far Left: Toolbar */}
<div className="w-16 p-2 border-r border-gray-800 flex flex-col items-center shrink-0">
<EditorToolbar
@@ -1271,6 +1299,7 @@ const App: React.FC = () => {
agvState={agvState}
setAgvState={setAgvState}
onLog={(msg) => addLog('SYSTEM', 'INFO', msg)}
onSelectionChange={setSelectedItemIds}
/>
</div>
@@ -1326,16 +1355,15 @@ const App: React.FC = () => {
{/* Top: Controls (Scrollable if needed) */}
<div className="flex-1 overflow-y-auto">
<AgvControls
{/* Manual Controls */}
<AgvManualControls
agvState={agvState}
setMotion={(m) => setAgvState(s => ({ ...s, motionState: m }))}
setLift={(h) => setAgvState(s => ({ ...s, liftHeight: h }))}
setRunConfig={(c) => setAgvState(s => ({ ...s, runConfig: c }))}
setError={(e) => setAgvState(s => ({ ...s, error: e }))}
setMotion={(state) => setAgvState(s => ({ ...s, motionState: state }))}
setRunConfig={(config) => setAgvState(s => ({ ...s, runConfig: config }))}
setError={(err) => setAgvState(s => ({ ...s, error: err }))}
onTurn180={handleTurn180}
setMagnet={(isOn) => setAgvState(s => ({ ...s, magnetOn: isOn }))}
setLiftStatus={(status) => setAgvState(s => ({ ...s, liftStatus: status }))}
setLidar={(isOn) => setAgvState(s => ({ ...s, lidarEnabled: isOn, sensorStatus: isOn ? '1' : '0' }))}
setMagnet={(isOn) => setAgvState(s => ({ ...s, magnetOn: isOn }))}
/>
</div>

View File

@@ -1,145 +0,0 @@
import React from 'react';
import { StopCircle, Play, Square, AlertTriangle, ChevronsUp, ChevronsDown, Magnet, Radar, ArrowLeft, ArrowRight } from 'lucide-react';
import { AgvState, AgvMotionState, AgvRunConfig } from '../types';
import AgvManualControls from './AgvManualControls';
interface AgvControlsProps {
agvState: AgvState;
setMotion: (state: AgvMotionState) => void;
setLift: (val: number) => void;
setRunConfig: (config: AgvRunConfig) => void;
setError: (error: string | null) => void;
onTurn180: (direction: 'LEFT' | 'RIGHT') => void;
setMagnet: (isOn: boolean) => void;
setLiftStatus: (status: 'IDLE' | 'UP' | 'DOWN') => void;
setLidar: (isOn: boolean) => void;
}
const AgvControls: React.FC<AgvControlsProps> = ({ agvState, setMotion, setLift, setRunConfig, setError, onTurn180, setMagnet, setLiftStatus, setLidar }) => {
const isRunning = agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING;
const isError = agvState.error !== null;
const updateRunConfig = (key: keyof AgvRunConfig, value: any) => {
if (isError) return;
setRunConfig({
...agvState.runConfig,
[key]: value
});
};
const handleMarkStop = () => {
if (agvState.motionState === AgvMotionState.RUNNING) {
setRunConfig({
...agvState.runConfig,
speedLevel: 'L'
});
setMotion(AgvMotionState.MARK_STOPPING);
}
};
const resetError = () => {
setError(null);
};
const handleLiftSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (agvState.liftStatus !== 'IDLE') {
setLiftStatus('IDLE');
}
setLift(parseInt(e.target.value));
};
return (
<div className="flex flex-col gap-4 p-3 bg-gray-900 border-b border-gray-700">
{/* Error Overlay */}
{isError && (
<div className="bg-red-900/80 border border-red-600 p-3 rounded text-center animate-pulse">
<div className="text-red-100 font-bold text-sm flex items-center justify-center gap-2 mb-2">
<AlertTriangle size={18} />
{agvState.error}
</div>
<button onClick={resetError} className="bg-red-600 hover:bg-red-500 text-white text-xs font-bold px-3 py-1 rounded shadow-sm">
RESET ERROR
</button>
</div>
)}
{/* Manual Operation (분리된 콤포넌트) */}
<AgvManualControls
agvState={agvState}
setMotion={setMotion}
updateRunConfig={updateRunConfig}
onTurn180={onTurn180}
handleMarkStop={handleMarkStop}
isRunning={isRunning}
isError={isError}
/>
{/* Lift & Magnet */}
<div className={`px-1 ${isError ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span className="flex items-center gap-1"><ChevronsUp size={12} /> Lift Height</span>
<span className="font-mono text-white">{Math.round(agvState.liftHeight)}%</span>
</div>
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => setLiftStatus('DOWN')}
disabled={agvState.liftHeight === 0}
className={`p-1.5 rounded disabled:opacity-30 transition-colors ${agvState.liftStatus === 'DOWN'
? 'bg-blue-600 text-white shadow-[0_0_10px_rgba(37,99,235,0.5)]'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
title="Lower Lift"
>
<ChevronsDown size={16} />
</button>
<input
type="range"
min="0"
max="100"
value={agvState.liftHeight}
onChange={handleLiftSliderChange}
className="flex-1 h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<button
onClick={() => setLiftStatus('UP')}
disabled={agvState.liftHeight === 100}
className={`p-1.5 rounded disabled:opacity-30 transition-colors ${agvState.liftStatus === 'UP'
? 'bg-blue-600 text-white shadow-[0_0_10px_rgba(37,99,235,0.5)]'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
title="Raise Lift"
>
<ChevronsUp size={16} />
</button>
</div>
<button
onClick={() => setMagnet(!agvState.magnetOn)}
className={`w-full py-1.5 text-xs rounded border flex items-center justify-center gap-2 transition-colors ${agvState.magnetOn
? 'bg-orange-600 text-white border-orange-500'
: 'bg-gray-800 text-gray-400 border-gray-600 hover:bg-gray-700'
}`}
>
<Magnet size={14} className={agvState.magnetOn ? "animate-pulse" : ""} />
MAGNET {agvState.magnetOn ? 'ON' : 'OFF'}
</button>
</div>
<div className="h-px bg-gray-800" />
{/* Auto Run Controls */}
{/* Auto Run Controls (분리된 콤포넌트) */}
</div>
);
};
export default AgvControls;

View File

@@ -1,27 +1,51 @@
import React from 'react';
import { ArrowUp, ArrowDown, RotateCcw, RotateCw, StopCircle, Disc } from 'lucide-react';
import { ArrowUp, ArrowDown, RotateCcw, RotateCw, StopCircle, Disc, ChevronsUp, ChevronsDown, Magnet, AlertTriangle } from 'lucide-react';
import { AgvState, AgvMotionState, AgvRunConfig } from '../types';
interface AgvManualControlsProps {
agvState: AgvState;
setMotion: (state: AgvMotionState) => void;
updateRunConfig: (key: keyof AgvRunConfig, value: any) => void;
setRunConfig: (config: AgvRunConfig) => void;
setError: (error: string | null) => void;
onTurn180: (direction: 'LEFT' | 'RIGHT') => void;
handleMarkStop: () => void;
isRunning: boolean;
isError: boolean;
setLiftStatus: (status: 'IDLE' | 'UP' | 'DOWN') => void;
setMagnet: (isOn: boolean) => void;
}
const AgvManualControls: React.FC<AgvManualControlsProps> = ({
agvState,
setMotion,
updateRunConfig,
setRunConfig,
setError,
onTurn180,
handleMarkStop,
isRunning,
isError,
setLiftStatus,
setMagnet,
}) => {
const isRunning = agvState.motionState === AgvMotionState.RUNNING || agvState.motionState === AgvMotionState.MARK_STOPPING;
const isError = agvState.error !== null;
const updateRunConfig = (key: keyof AgvRunConfig, value: any) => {
if (isError) return;
setRunConfig({
...agvState.runConfig,
[key]: value
});
};
const handleMarkStop = () => {
if (agvState.motionState === AgvMotionState.RUNNING) {
setRunConfig({
...agvState.runConfig,
speedLevel: 'L'
});
setMotion(AgvMotionState.MARK_STOPPING);
}
};
const resetError = () => {
setError(null);
};
const setManualMotion = (motion: AgvMotionState, direction?: 'FWD' | 'BWD') => {
setMotion(motion);
if (direction) {
@@ -30,86 +54,141 @@ const AgvManualControls: React.FC<AgvManualControlsProps> = ({
};
return (
<div className={`${isError ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex justify-between items-center mb-2 px-1">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Manual Operation</h3>
</div>
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700 shadow-inner">
<div className="grid grid-cols-3 gap-2 mb-3">
<div />
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.FORWARD, 'FWD')}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<ArrowUp size={24} />
</button>
<div />
<div className="flex flex-col gap-4 p-3 bg-gray-900 border-b border-gray-700">
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.TURN_LEFT)}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<RotateCcw size={20} />
</button>
<button
onClick={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-red-800 hover:bg-red-700 rounded flex items-center justify-center text-white shadow"
>
<StopCircle size={24} />
</button>
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.TURN_RIGHT)}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<RotateCw size={20} />
{/* Error Overlay */}
{isError && (
<div className="bg-red-900/80 border border-red-600 p-3 rounded text-center animate-pulse">
<div className="text-red-100 font-bold text-sm flex items-center justify-center gap-2 mb-2">
<AlertTriangle size={18} />
{agvState.error}
</div>
<button onClick={resetError} className="bg-red-600 hover:bg-red-500 text-white text-xs font-bold px-3 py-1 rounded shadow-sm">
RESET ERROR
</button>
</div>
)}
<div />
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.BACKWARD, 'BWD')}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<ArrowDown size={24} />
</button>
<div />
<div className={`${isError ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex justify-between items-center mb-2 px-1">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Manual Operation</h3>
</div>
{/* Extra Actions */}
<div className="grid grid-cols-2 gap-2">
<button
disabled={isRunning}
onClick={() => onTurn180('LEFT')}
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
>
<RotateCcw size={12} /> L-Turn 180
</button>
<button
disabled={isRunning}
onClick={() => onTurn180('RIGHT')}
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
>
<RotateCw size={12} /> R-Turn 180
</button>
<button
disabled={!isRunning || agvState.motionState === AgvMotionState.MARK_STOPPING}
onClick={handleMarkStop}
className={`col-span-2 text-xs py-1.5 border rounded flex items-center justify-center gap-1 shadow-sm transition-colors disabled:opacity-30 ${
agvState.motionState === AgvMotionState.MARK_STOPPING
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700 shadow-inner">
<div className="grid grid-cols-3 gap-2 mb-3">
<button
onClick={() => setLiftStatus('UP')}
disabled={agvState.liftHeight === 100}
className={`h-10 rounded flex items-center justify-center transition-colors shadow ${agvState.liftStatus === 'UP'
? 'bg-blue-600 text-white'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
>
<ChevronsUp size={20} />
</button>
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.FORWARD, 'FWD')}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<ArrowUp size={24} />
</button>
<button
onClick={() => setMagnet(true)}
disabled={agvState.magnetOn}
className={`h-10 rounded flex items-center justify-center transition-colors shadow ${agvState.magnetOn
? 'bg-orange-600 text-white opacity-50 cursor-not-allowed'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
>
<Magnet size={20} />
</button>
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.TURN_LEFT)}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<RotateCcw size={20} />
</button>
<button
onClick={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-red-800 hover:bg-red-700 rounded flex items-center justify-center text-white shadow"
>
<StopCircle size={24} />
</button>
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.TURN_RIGHT)}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<RotateCw size={20} />
</button>
<button
onClick={() => setLiftStatus('DOWN')}
disabled={agvState.liftHeight === 0}
className={`h-10 rounded flex items-center justify-center transition-colors shadow ${agvState.liftStatus === 'DOWN'
? 'bg-blue-600 text-white'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
>
<ChevronsDown size={20} />
</button>
<button
disabled={isRunning}
onMouseDown={() => setManualMotion(AgvMotionState.BACKWARD, 'BWD')}
onMouseUp={() => setManualMotion(AgvMotionState.IDLE)}
className="h-10 bg-gray-700 hover:bg-gray-600 rounded flex items-center justify-center active:bg-blue-600 disabled:opacity-30 transition-colors shadow"
>
<ArrowDown size={24} />
</button>
<button
onClick={() => setMagnet(false)}
disabled={!agvState.magnetOn}
className={`h-10 rounded flex items-center justify-center transition-colors shadow relative ${!agvState.magnetOn
? 'bg-gray-600 text-gray-500 cursor-not-allowed opacity-50'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
}`}
>
<Magnet size={20} />
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-6 h-0.5 bg-red-500 rotate-45 transform origin-center"></div>
</div>
</button>
</div>
{/* Extra Actions */}
<div className="grid grid-cols-2 gap-2">
<button
disabled={isRunning}
onClick={() => onTurn180('LEFT')}
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
>
<RotateCcw size={12} /> L-Turn 180
</button>
<button
disabled={isRunning}
onClick={() => onTurn180('RIGHT')}
className="text-xs py-1.5 bg-gray-700 rounded hover:bg-gray-600 flex items-center justify-center gap-1 disabled:opacity-30 shadow-sm"
>
<RotateCw size={12} /> R-Turn 180
</button>
<button
disabled={!isRunning || agvState.motionState === AgvMotionState.MARK_STOPPING}
onClick={handleMarkStop}
className={`col-span-2 text-xs py-1.5 border rounded flex items-center justify-center gap-1 shadow-sm transition-colors disabled:opacity-30 ${agvState.motionState === AgvMotionState.MARK_STOPPING
? 'bg-yellow-600 text-black border-yellow-500 font-bold'
: 'bg-yellow-900/50 text-yellow-200 border-yellow-800/50 hover:bg-yellow-900/70'
}`}
>
<Disc size={12} /> {agvState.motionState === AgvMotionState.MARK_STOPPING ? 'Stopping at Mark...' : 'Drive to Next Mark'}
</button>
}`}
>
<Disc size={12} /> {agvState.motionState === AgvMotionState.MARK_STOPPING ? 'Stopping at Mark...' : 'Drive to Next Mark'}
</button>
</div>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
const isOn = (value & (1 << bitIndex)) !== 0;
// 필터링에 의해 isOn이 true인 것만 전달되지만 스타일 유지를 위해 체크 로직 유지
return (
<div className="flex items-center justify-between text-[10px] py-0.5 border-b border-gray-800 last:border-0 animate-in fade-in slide-in-from-left-1 duration-200">
<div key={label} className="flex items-center justify-between text-[10px] py-0.5 border-b border-gray-800 last:border-0 animate-in fade-in slide-in-from-left-1 duration-200">
<span className="text-gray-300 truncate pr-2" title={label}>{label.replace(/_/g, ' ')}</span>
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOn ? colorClass : 'bg-gray-700'} shadow-sm`} />
</div>
@@ -41,7 +41,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
</h4>
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
{getActiveKeys(SystemFlag1, agvState.system1).length > 0 ? (
getActiveKeys(SystemFlag1, agvState.system1).map((key) =>
getActiveKeys(SystemFlag1, agvState.system1).map((key) =>
renderBit(key, agvState.system1, SystemFlag1[key as any] as unknown as number, 'bg-blue-500')
)
) : (
@@ -57,7 +57,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
</h4>
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
{getActiveKeys(AgvSignal, agvState.signalFlags).length > 0 ? (
getActiveKeys(AgvSignal, agvState.signalFlags).map((key) =>
getActiveKeys(AgvSignal, agvState.signalFlags).map((key) =>
renderBit(key, agvState.signalFlags, AgvSignal[key as any] as unknown as number, 'bg-yellow-500')
)
) : (
@@ -73,7 +73,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
</h4>
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
{getActiveKeys(AgvError, agvState.errorFlags).length > 0 ? (
getActiveKeys(AgvError, agvState.errorFlags).map((key) =>
getActiveKeys(AgvError, agvState.errorFlags).map((key) =>
renderBit(key, agvState.errorFlags, AgvError[key as any] as unknown as number, 'bg-red-600 shadow-[0_0_5px_rgba(220,38,38,0.5)]')
)
) : (
@@ -89,7 +89,7 @@ const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
</h4>
<div className="grid grid-cols-1 gap-0.5 min-h-[1.5rem]">
{getActiveKeys(SystemFlag0, agvState.system0).length > 0 ? (
getActiveKeys(SystemFlag0, agvState.system0).map((key) =>
getActiveKeys(SystemFlag0, agvState.system0).map((key) =>
renderBit(key, agvState.system0, SystemFlag0[key as any] as unknown as number, 'bg-cyan-500')
)
) : (

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect } from 'react';
import { SimulationMap, MapNode, MagnetLine, FloorMark } from '../types';
import { Save, Wand2 } from 'lucide-react';
interface PropertyPanelProps {
selectedItemIds: Set<string>;
mapData: SimulationMap;
onUpdate: (type: 'NODE' | 'MAGNET' | 'MARK', id: string, data: any) => void;
}
const PropertyPanel: React.FC<PropertyPanelProps> = ({ selectedItemIds, mapData, onUpdate }) => {
const [formData, setFormData] = useState<any>(null);
const [position, setPosition] = useState({ x: 1100, y: 100 });
const [isDragging, setIsDragging] = useState(false);
const dragOffset = React.useRef({ x: 0, y: 0 });
// When selection changes, update form data
useEffect(() => {
if (selectedItemIds.size === 1) {
const id = Array.from(selectedItemIds)[0];
const node = mapData.nodes.find(n => n.id === id);
const magnet = mapData.magnets.find(m => m.id === id);
const mark = mapData.marks.find(m => m.id === id);
if (node) {
setFormData({ ...node, formType: 'NODE' });
} else if (magnet) {
setFormData({ ...magnet, formType: 'MAGNET' });
} else if (mark) {
setFormData({ ...mark, formType: 'MARK' });
} else {
setFormData(null);
}
} else {
setFormData(null);
}
}, [selectedItemIds, mapData.nodes, mapData.magnets, mapData.marks]);
const handleChange = (field: string, value: string | number | boolean) => {
if (!formData) return;
setFormData((prev: any) => ({ ...prev, [field]: value }));
};
const handleSave = () => {
if (!formData) return;
// Validate RFID Duplication
if (formData.formType === 'NODE' && formData.rfidId && formData.rfidId !== '0000') {
const isDuplicate = mapData.nodes.some(n =>
n.rfidId === formData.rfidId && n.id !== formData.id
);
if (isDuplicate) {
alert(`Error: RFID "${formData.rfidId}" is already used by another node.`);
return;
}
}
onUpdate(formData.formType, formData.id, formData);
};
// Auto Generate RFID
const handleAutoRfid = () => {
const usedRfids = new Set(mapData.nodes.map(n => n.rfidId));
let nextRfid = 1; // Start from 1, assuming 0000 is default/null
while (nextRfid < 10000) {
const candidate = nextRfid.toString().padStart(4, '0');
if (!usedRfids.has(candidate)) {
handleChange('rfidId', candidate);
return;
}
nextRfid++;
}
alert('No available RFID found between 0001 and 9999');
};
// Drag Logic
const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true);
dragOffset.current = {
x: e.clientX - position.x,
y: e.clientY - position.y
};
};
const handleMouseMove = React.useCallback((e: MouseEvent) => {
if (isDragging) {
setPosition({
x: e.clientX - dragOffset.current.x,
y: e.clientY - dragOffset.current.y
});
}
}, [isDragging]);
const handleMouseUp = React.useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
if (!formData || selectedItemIds.size !== 1) return null;
return (
<div
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 100
}}
className="w-64 bg-gray-800 border border-gray-600 rounded-lg shadow-xl overflow-hidden flex flex-col"
>
{/* Header / Drag Handle */}
<div
onMouseDown={handleMouseDown}
className="bg-gray-700 p-2 cursor-move flex items-center justify-between border-b border-gray-600 select-none"
>
<span className="text-gray-200 font-bold text-xs">Properties</span>
<button
onMouseDown={(e) => e.stopPropagation()} // Prevent drag when clicking button
onClick={handleSave}
className="flex items-center gap-1 bg-blue-600 hover:bg-blue-500 text-white px-2 py-0.5 rounded text-[10px] transition-colors"
>
<Save size={12} /> Save
</button>
</div>
<div className="p-4 flex flex-col gap-4 max-h-[400px] overflow-y-auto">
{/* Common Fields */}
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">ID</label>
<input
type="text"
value={formData.id}
disabled
className="bg-gray-900 border border-gray-700 text-gray-400 text-sm p-1 rounded"
/>
</div>
{formData.formType === 'NODE' && (
<>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Name (Alias)</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => handleChange('name', e.target.value)}
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">RFID ID</label>
<div className="flex gap-2">
<input
type="text"
value={formData.rfidId || ''}
onChange={(e) => handleChange('rfidId', e.target.value)}
className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none font-mono"
/>
<button
onClick={handleAutoRfid}
title="Auto Generate Unique RFID"
className="p-1 bg-gray-600 hover:bg-gray-500 text-white rounded transition-colors"
>
<Wand2 size={16} />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">X</label>
<input
type="number"
value={formData.x}
onChange={(e) => handleChange('x', parseFloat(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">Y</label>
<input
type="number"
value={formData.y}
onChange={(e) => handleChange('y', parseFloat(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-sm p-1 rounded focus:border-blue-500 outline-none"
/>
</div>
</div>
<div className="border-t border-gray-600 my-2 pt-2">
<label className="text-xs font-bold text-gray-300 mb-2 block">Configuration</label>
<div className="grid grid-cols-2 gap-2 mb-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400">Station Type</label>
<input
type="number"
value={formData.stationType || 0}
onChange={(e) => handleChange('stationType', parseInt(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400">Speed Limit</label>
<input
type="number"
value={formData.speedLimit || 0}
onChange={(e) => handleChange('speedLimit', parseInt(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400">Dock Dir</label>
<input
type="number"
value={formData.dockDirection || 0}
onChange={(e) => handleChange('dockDirection', parseInt(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] text-gray-400">Text Size</label>
<input
type="number"
value={formData.nodeTextFontSize || 12}
onChange={(e) => handleChange('nodeTextFontSize', parseInt(e.target.value))}
className="bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded"
/>
</div>
</div>
<div className="flex flex-col gap-1 mb-2">
<label className="text-[10px] text-gray-400">Text Color</label>
<div className="flex gap-2">
<input
type="color"
value={formData.nodeTextForeColor || '#FFFFFF'}
onChange={(e) => handleChange('nodeTextForeColor', e.target.value)}
className="bg-transparent w-6 h-6 p-0 border-0"
/>
<input
type="text"
value={formData.nodeTextForeColor || '#FFFFFF'}
onChange={(e) => handleChange('nodeTextForeColor', e.target.value)}
className="flex-1 bg-gray-700 border border-gray-600 text-gray-200 text-xs p-1 rounded font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-2 mt-2">
{[
{ k: 'canDocking', l: 'Can Docking' },
{ k: 'canTurnLeft', l: 'Can Turn Left' },
{ k: 'canTurnRight', l: 'Can Turn Right' },
{ k: 'disableCross', l: 'Disable Cross' },
{ k: 'isActive', l: 'Is Active' },
].map((item) => (
<label key={item.k} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!formData[item.k]}
onChange={(e) => handleChange(item.k, e.target.checked ? true : false)}
className="rounded bg-gray-700 border-gray-600 text-blue-500 focus:ring-0"
/>
<span className="text-xs text-gray-300">{item.l}</span>
</label>
))}
</div>
</div>
</>
)}
</div>
</div>
);
};
export default PropertyPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -45,9 +45,9 @@ export class SerialPortHandler {
if (this.port.readable) {
this.reader = this.port.readable.getReader();
}
if (this.port.writable) {
this.writer = this.port.writable.getWriter();
this.writer = this.port.writable.getWriter();
}
this.isConnected = true;
@@ -86,19 +86,24 @@ export class SerialPortHandler {
} else {
payload = data;
}
await this.writer.write(payload);
try {
await this.writer.write(payload);
} catch (e) {
console.error("Serial Send Error", e);
throw e; // Re-throw to let caller know if needed, or handle gracefull
}
}
}
getPortInfo(): string | null {
if (this.port) {
const info = this.port.getInfo();
if (info.usbVendorId && info.usbProductId) {
return `ID:${info.usbVendorId.toString(16).padStart(4,'0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4,'0').toUpperCase()}`;
}
return "USB Device";
if (this.port) {
const info = this.port.getInfo();
if (info.usbVendorId && info.usbProductId) {
return `ID:${info.usbVendorId.toString(16).padStart(4, '0').toUpperCase()}:${info.usbProductId.toString(16).padStart(4, '0').toUpperCase()}`;
}
return null;
return "USB Device";
}
return null;
}
private async readLoop() {

View File

@@ -44,6 +44,18 @@ export interface MapNode extends Point {
// New props preservation
fontSize?: number;
// Extended Properties (from C# Model)
stationType?: number;
speedLimit?: number;
canDocking?: boolean;
dockDirection?: number;
canTurnLeft?: boolean;
canTurnRight?: boolean;
disableCross?: boolean;
isActive?: boolean;
nodeTextForeColor?: string;
nodeTextFontSize?: number;
}
export interface MapEdge {