Initial commit: Refactor AgvAutoRunControls

This commit is contained in:
2025-12-18 23:52:02 +09:00
commit c4089aeb20
25 changed files with 7517 additions and 0 deletions

142
components/AcsControls.tsx Normal file
View File

@@ -0,0 +1,142 @@
import React, { useState, useMemo } from 'react';
import { Navigation, Package, Zap, ChevronRight, Hash, Type } from 'lucide-react';
import { SimulationMap } from '../types';
interface AcsControlsProps {
onSend: (cmd: number, data?: number[]) => void;
isConnected: boolean;
mapData: SimulationMap;
}
const AcsControls: React.FC<AcsControlsProps> = ({ onSend, isConnected, mapData }) => {
const [tagId, setTagId] = useState('0001');
const [alias, setAlias] = useState('charger1');
const PRESET_ALIASES = [
'charger1', 'charger2',
'loader', 'unloader', 'cleaner',
'buffer1', 'buffer2', 'buffer3', 'buffer4', 'buffer5', 'buffer6'
];
const availableTags = useMemo(() => {
// Extract unique, non-empty RFID tags from map nodes
const tags = mapData.nodes
.filter(n => n.rfidId && n.rfidId.trim() !== '')
.map(n => n.rfidId);
return Array.from(new Set(tags)).sort();
}, [mapData]);
const handleSend = (cmd: number, dataStr?: string, dataByte?: number) => {
if (!isConnected) return;
let payload: number[] = [];
if (dataStr !== undefined) {
// Convert string to ASCII bytes
for (let i = 0; i < dataStr.length; i++) {
payload.push(dataStr.charCodeAt(i));
}
} else if (dataByte !== undefined) {
payload.push(dataByte);
}
onSend(cmd, payload);
};
return (
<div className={`bg-gray-800 p-4 border-t border-gray-700 select-none ${!isConnected ? 'opacity-50 pointer-events-none' : ''}`}>
<h3 className="text-sm font-semibold mb-3 text-green-400 flex items-center justify-between">
<span className="flex items-center gap-2"><Navigation size={16} /> ACS Control</span>
<span className="text-[10px] bg-gray-900 px-1.5 py-0.5 rounded text-gray-500 border border-gray-700">EXT CMD</span>
</h3>
{/* Navigation Commands */}
<div className="space-y-2 mb-4">
{/* Goto Tag */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Hash size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
<input
list="tag-options"
type="text"
value={tagId}
onChange={(e) => setTagId(e.target.value)}
className="w-full bg-gray-900 border border-gray-600 rounded py-1 pl-7 pr-2 text-xs text-white font-mono focus:border-green-500 outline-none"
placeholder="Tag ID"
/>
<datalist id="tag-options">
{availableTags.map(tag => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
<button
onClick={() => handleSend(0x81, tagId)}
className="bg-gray-700 hover:bg-green-700 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1 transition-colors border border-gray-600"
>
GO <ChevronRight size={10} />
</button>
</div>
{/* Goto Alias */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Type size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-500" />
<input
list="alias-options"
type="text"
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="w-full bg-gray-900 border border-gray-600 rounded py-1 pl-7 pr-2 text-xs text-white font-mono focus:border-green-500 outline-none"
placeholder="Alias Name"
/>
<datalist id="alias-options">
{PRESET_ALIASES.map(item => (
<option key={item} value={item} />
))}
</datalist>
</div>
<button
onClick={() => handleSend(0x82, alias)}
className="bg-gray-700 hover:bg-green-700 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1 transition-colors border border-gray-600"
>
GO <ChevronRight size={10} />
</button>
</div>
</div>
<div className="h-px bg-gray-700 my-3" />
{/* Action Buttons Grid */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleSend(0x83, undefined, 1)}
className="flex items-center justify-center gap-2 bg-blue-900/40 hover:bg-blue-800 border border-blue-800 text-blue-200 py-2 rounded text-xs transition-colors"
>
<Package size={14} /> Pick ON
</button>
<button
onClick={() => handleSend(0x83, undefined, 0)}
className="flex items-center justify-center gap-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 text-gray-300 py-2 rounded text-xs transition-colors"
>
<Package size={14} /> Pick OFF
</button>
<button
onClick={() => handleSend(0x84, undefined, 1)}
className="flex items-center justify-center gap-2 bg-yellow-900/40 hover:bg-yellow-800 border border-yellow-800 text-yellow-200 py-2 rounded text-xs transition-colors"
>
<Zap size={14} /> Charge ON
</button>
<button
onClick={() => handleSend(0x84, undefined, 0)}
className="flex items-center justify-center gap-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 text-gray-300 py-2 rounded text-xs transition-colors"
>
<Zap size={14} /> Charge OFF
</button>
</div>
</div>
);
};
export default AcsControls;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { Play, Square, ArrowLeft, ArrowRight, Radar } from 'lucide-react';
import { AgvState, AgvRunConfig } from '../types';
interface AgvAutoRunControlsProps {
agvState: AgvState;
updateRunConfig: (key: keyof AgvRunConfig, value: any) => void;
toggleRun: () => void;
isRunning: boolean;
isError: boolean;
setLidar: (isOn: boolean) => void;
}
const AgvAutoRunControls: React.FC<AgvAutoRunControlsProps> = ({
agvState,
updateRunConfig,
toggleRun,
isRunning,
isError,
setLidar,
}) => {
return (
<div className={`${isError ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="flex items-center justify-between mb-2 px-1">
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Auto Run</h3>
<span className={`text-[10px] font-bold uppercase px-2 py-0.5 rounded-full border ${isRunning ? 'bg-green-900/50 border-green-500 text-green-400' : 'bg-gray-800 border-gray-600 text-gray-500'}`}>
{isRunning ? 'Running' : 'Ready'}
</span>
</div>
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700 shadow-inner space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400 font-medium">Direction</span>
<div className="flex bg-gray-900 rounded p-1 gap-1">
{['FWD', 'BWD'].map(dir => (
<button
key={dir}
onClick={() => updateRunConfig('direction', dir)}
disabled={isRunning}
className={`text-xs px-3 py-1 rounded transition-colors ${agvState.runConfig.direction === dir ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}`}
>
{dir}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400 font-medium">Branch</span>
<div className="flex bg-gray-900 rounded p-1 gap-1">
<button onClick={() => updateRunConfig('branch', 'LEFT')} disabled={isRunning} className={`p-1.5 rounded ${agvState.runConfig.branch === 'LEFT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}><ArrowLeft size={14}/></button>
<button onClick={() => updateRunConfig('branch', 'STRAIGHT')} disabled={isRunning} className={`px-2 py-1 text-xs rounded ${agvState.runConfig.branch === 'STRAIGHT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}>STR</button>
<button onClick={() => updateRunConfig('branch', 'RIGHT')} disabled={isRunning} className={`p-1.5 rounded ${agvState.runConfig.branch === 'RIGHT' ? 'bg-blue-600 text-white' : 'text-gray-500 hover:bg-gray-800'}`}><ArrowRight size={14}/></button>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400 font-medium">Speed</span>
<div className="flex bg-gray-900 rounded p-1 gap-1">
{['L', 'M', 'H'].map(spd => (
<button
key={spd}
onClick={() => updateRunConfig('speedLevel', spd)}
disabled={isRunning}
className={`w-8 py-1 text-xs rounded ${agvState.runConfig.speedLevel === spd ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'}`}
>
{spd}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400 font-medium">Lidar</span>
<button
onClick={() => setLidar(!agvState.lidarEnabled)}
className={`flex items-center gap-2 px-3 py-1 rounded text-xs transition-colors ${
agvState.lidarEnabled
? 'bg-cyan-600 text-white shadow-sm'
: 'bg-gray-700 text-gray-500 hover:bg-gray-600'
}`}
>
<Radar size={12} className={agvState.lidarEnabled ? "animate-pulse" : ""} />
{agvState.lidarEnabled ? 'ON' : 'OFF'}
</button>
</div>
<button
onClick={toggleRun}
className={`w-full py-2.5 rounded font-bold text-sm flex items-center justify-center gap-2 transition-all shadow-md ${
isRunning
? 'bg-red-600 hover:bg-red-500 text-white ring-2 ring-red-500/50'
: 'bg-green-600 hover:bg-green-500 text-white'
}`}
>
{isRunning ? <><Square size={16} fill="currentColor" /> STOP</> : <><Play size={16} fill="currentColor" /> START</>}
</button>
</div>
</div>
);
};
export default AgvAutoRunControls;

167
components/AgvControls.tsx Normal file
View File

@@ -0,0 +1,167 @@
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';
import AgvAutoRunControls from './AgvAutoRunControls';
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 toggleRun = () => {
if (isError) return;
if (isRunning) {
setMotion(AgvMotionState.IDLE);
} else {
const isFwd = agvState.runConfig.direction === 'FWD';
const hasLine = isFwd ? agvState.sensorLineFront : agvState.sensorLineRear;
if (!hasLine) {
setError('LINE_OUT');
return;
}
setMotion(AgvMotionState.RUNNING);
}
};
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 (분리된 콤포넌트) */}
<AgvAutoRunControls
agvState={agvState}
updateRunConfig={updateRunConfig}
toggleRun={toggleRun}
isRunning={isRunning}
isError={isError}
setLidar={setLidar}
/>
</div>
);
};
export default AgvControls;

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { ArrowUp, ArrowDown, RotateCcw, RotateCw, StopCircle, Disc } from 'lucide-react';
import { AgvState, AgvMotionState, AgvRunConfig } from '../types';
interface AgvManualControlsProps {
agvState: AgvState;
setMotion: (state: AgvMotionState) => void;
updateRunConfig: (key: keyof AgvRunConfig, value: any) => void;
onTurn180: (direction: 'LEFT' | 'RIGHT') => void;
handleMarkStop: () => void;
isRunning: boolean;
isError: boolean;
}
const AgvManualControls: React.FC<AgvManualControlsProps> = ({
agvState,
setMotion,
updateRunConfig,
onTurn180,
handleMarkStop,
isRunning,
isError,
}) => {
const setManualMotion = (motion: AgvMotionState, direction?: 'FWD' | 'BWD') => {
setMotion(motion);
if (direction) {
updateRunConfig('direction', direction);
}
};
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 />
<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>
<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>
{/* 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>
</div>
</div>
</div>
);
};
export default AgvManualControls;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { AgvState, AgvError, AgvSignal, SystemFlag0, SystemFlag1 } from '../types';
import { CircleCheck, AlertOctagon, Cpu } from 'lucide-react';
interface AgvStatusPanelProps {
agvState: AgvState;
}
const AgvStatusPanel: React.FC<AgvStatusPanelProps> = ({ agvState }) => {
const renderBit = (label: string, value: number, bitIndex: number, colorClass = 'bg-green-500') => {
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">
<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>
);
};
const getEnumKeys = (e: any) => {
return Object.keys(e).filter(k => typeof e[k as any] === "number");
};
const getActiveKeys = (enumObj: any, value: number) => {
return getEnumKeys(enumObj).filter(key => (value & (1 << (enumObj[key as any] as number))) !== 0);
};
return (
<div className="flex flex-col h-full bg-gray-900 border-l border-gray-700 overflow-y-auto">
<div className="p-3 bg-gray-800 border-b border-gray-700 font-bold text-xs text-center text-gray-200 sticky top-0 z-10">
AGV PROTOCOL FLAGS (ACTIVE)
</div>
{/* System 1 (Control State) */}
<div className="p-2 border-b border-gray-700">
<h4 className="text-xs font-bold text-blue-400 mb-2 flex items-center gap-1">
<Cpu size={12} /> SYSTEM 1
</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) =>
renderBit(key, agvState.system1, SystemFlag1[key as any] as unknown as number, 'bg-blue-500')
)
) : (
<span className="text-[10px] text-gray-600 italic px-1">No active flags</span>
)}
</div>
</div>
{/* Signals */}
<div className="p-2 border-b border-gray-700">
<h4 className="text-xs font-bold text-yellow-400 mb-2 flex items-center gap-1">
<CircleCheck size={12} /> SIGNALS
</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) =>
renderBit(key, agvState.signalFlags, AgvSignal[key as any] as unknown as number, 'bg-yellow-500')
)
) : (
<span className="text-[10px] text-gray-600 italic px-1">No active signals</span>
)}
</div>
</div>
{/* Errors */}
<div className="p-2 border-b border-gray-700">
<h4 className="text-xs font-bold text-red-400 mb-2 flex items-center gap-1">
<AlertOctagon size={12} /> ERRORS
</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) =>
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)]')
)
) : (
<span className="text-[10px] text-gray-600 italic px-1">None</span>
)}
</div>
</div>
{/* System 0 (Hardware) */}
<div className="p-2 border-b border-gray-700">
<h4 className="text-xs font-bold text-cyan-400 mb-2 flex items-center gap-1">
<Cpu size={12} /> SYSTEM 0
</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) =>
renderBit(key, agvState.system0, SystemFlag0[key as any] as unknown as number, 'bg-cyan-500')
)
) : (
<span className="text-[10px] text-gray-600 italic px-1">No active flags</span>
)}
</div>
</div>
</div>
);
};
export default AgvStatusPanel;

88
components/BmsPanel.tsx Normal file
View File

@@ -0,0 +1,88 @@
import React from 'react';
import { BatteryCharging, Zap, Thermometer, Sliders } from 'lucide-react';
import { AgvState } from '../types';
interface BmsPanelProps {
state: AgvState;
setBatteryLevel: (level: number) => void;
}
const BmsPanel: React.FC<BmsPanelProps> = ({ state, setBatteryLevel }) => {
const currentCapacity = (state.maxCapacity * (state.batteryLevel / 100)).toFixed(1);
return (
<div className="bg-gray-800 p-4 border-t border-gray-700 h-auto select-none">
<h3 className="text-sm font-semibold mb-3 text-gray-300 flex items-center justify-between">
<span className="flex items-center gap-2"><BatteryCharging size={16} /> BMS Monitor</span>
<span className="text-xs font-mono text-gray-500">ID: 01</span>
</h3>
{/* Main Info Grid */}
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Total Voltage</div>
<div className="text-lg font-mono text-white font-bold flex items-baseline gap-1">
{(state.cellVoltages.reduce((a,b)=>a+b, 0)).toFixed(1)} <span className="text-xs text-gray-400">V</span>
</div>
</div>
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
<div className="text-[10px] text-gray-400 flex items-center gap-1 uppercase tracking-wider">
<Thermometer size={10} /> Temp
</div>
<div className="text-lg font-mono text-white font-bold flex items-baseline gap-1">
{state.batteryTemp.toFixed(1)} <span className="text-xs text-gray-400">°C</span>
</div>
</div>
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Capacity (Ah)</div>
<div className="text-sm font-mono text-white">
<span className="text-yellow-400 font-bold">{currentCapacity}</span>
<span className="text-gray-500"> / </span>
{state.maxCapacity}
</div>
</div>
<div className="bg-gray-700/50 p-2 rounded border border-gray-600">
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Level (%)</div>
<div className={`text-sm font-mono font-bold ${state.batteryLevel < 20 ? 'text-red-400' : 'text-green-400'}`}>
{state.batteryLevel.toFixed(1)}%
</div>
</div>
</div>
{/* Manual Slider */}
<div className="mb-4">
<div className="flex justify-between text-xs text-gray-400 mb-1">
<span className="flex items-center gap-1"><Sliders size={10}/> Manual Adjust</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
value={state.batteryLevel}
onChange={(e) => setBatteryLevel(Number(e.target.value))}
className="w-full h-1.5 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-green-500 hover:accent-green-400"
/>
</div>
{/* Cells */}
<div className="space-y-1">
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1">Cell Voltages (V)</div>
<div className="grid grid-cols-4 gap-1">
{state.cellVoltages.map((v, i) => (
<div key={i} className={`py-1 px-0.5 text-center rounded text-[10px] font-mono border ${
v < 2.9 ? 'bg-red-900/30 border-red-800 text-red-300' :
v > 3.5 ? 'bg-blue-900/30 border-blue-800 text-blue-300' :
'bg-gray-700/30 border-gray-600 text-gray-300'
}`}>
{v.toFixed(3)}
</div>
))}
</div>
</div>
</div>
);
};
export default BmsPanel;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Plug, Unplug, Activity, Battery } from 'lucide-react';
interface ConnectionStatusBarProps {
agvConnected: boolean;
bmsConnected: boolean;
onConnectAgv: () => void;
onDisconnectAgv: () => void;
onConnectBms: () => void;
onDisconnectBms: () => void;
agvBaudRate: number;
setAgvBaudRate: (rate: number) => void;
bmsBaudRate: number;
setBmsBaudRate: (rate: number) => void;
}
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200];
const ConnectionStatusBar: React.FC<ConnectionStatusBarProps> = ({
agvConnected,
bmsConnected,
onConnectAgv,
onDisconnectAgv,
onConnectBms,
onDisconnectBms,
agvBaudRate,
setAgvBaudRate,
bmsBaudRate,
setBmsBaudRate,
}) => {
return (
<div className="h-10 bg-gray-900 border-t border-gray-700 flex items-center justify-between px-4 select-none shrink-0">
{/* AGV Connection Controls */}
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-2 py-1 rounded border ${agvConnected ? 'bg-blue-900/30 border-blue-700' : 'bg-gray-800 border-gray-700'}`}>
<span className="text-xs font-bold text-gray-300 flex items-center gap-1">
<Activity size={14} className={agvConnected ? "text-green-400" : "text-gray-500"} />
AGV
</span>
<div className="h-4 w-px bg-gray-700 mx-1" />
<select
value={agvBaudRate}
onChange={(e) => setAgvBaudRate(Number(e.target.value))}
disabled={agvConnected}
className="bg-transparent text-xs text-white outline-none disabled:opacity-50 cursor-pointer"
>
{BAUD_RATES.map(rate => <option key={rate} value={rate} className="bg-gray-800">{rate}</option>)}
</select>
<button
onClick={agvConnected ? onDisconnectAgv : onConnectAgv}
className={`ml-2 px-2 py-0.5 text-[10px] font-bold rounded flex items-center gap-1 transition-colors ${
agvConnected
? 'bg-red-900/50 text-red-300 hover:bg-red-900'
: 'bg-blue-700 text-white hover:bg-blue-600'
}`}
>
{agvConnected ? <><Unplug size={10} /> DISCONNECT</> : <><Plug size={10} /> CONNECT</>}
</button>
</div>
</div>
<div className="text-xs text-gray-600 font-mono">
SERIAL COMMUNICATION
</div>
{/* BMS Connection Controls */}
<div className="flex items-center gap-3">
<div className={`flex items-center gap-2 px-2 py-1 rounded border ${bmsConnected ? 'bg-yellow-900/30 border-yellow-700' : 'bg-gray-800 border-gray-700'}`}>
<span className="text-xs font-bold text-gray-300 flex items-center gap-1">
<Battery size={14} className={bmsConnected ? "text-yellow-400" : "text-gray-500"} />
BMS
</span>
<div className="h-4 w-px bg-gray-700 mx-1" />
<select
value={bmsBaudRate}
onChange={(e) => setBmsBaudRate(Number(e.target.value))}
disabled={bmsConnected}
className="bg-transparent text-xs text-white outline-none disabled:opacity-50 cursor-pointer"
>
{BAUD_RATES.map(rate => <option key={rate} value={rate} className="bg-gray-800">{rate}</option>)}
</select>
<button
onClick={bmsConnected ? onDisconnectBms : onConnectBms}
className={`ml-2 px-2 py-0.5 text-[10px] font-bold rounded flex items-center gap-1 transition-colors ${
bmsConnected
? 'bg-red-900/50 text-red-300 hover:bg-red-900'
: 'bg-blue-700 text-white hover:bg-blue-600'
}`}
>
{bmsConnected ? <><Unplug size={10} /> DISCONNECT</> : <><Plug size={10} /> CONNECT</>}
</button>
</div>
</div>
</div>
);
};
export default ConnectionStatusBar;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { MousePointer2, GitCommitHorizontal, Radio, Disc, Eraser, Save, Upload, Minus, Spline } from 'lucide-react';
import { ToolType } from '../types';
interface EditorToolbarProps {
activeTool: ToolType;
setTool: (t: ToolType) => void;
onSave: () => void;
onLoad: () => void;
}
const EditorToolbar: React.FC<EditorToolbarProps> = ({ activeTool, setTool, onSave, onLoad }) => {
const tools = [
{ id: ToolType.SELECT, icon: <MousePointer2 size={20} />, label: 'Select/Move' },
{ id: ToolType.DRAW_LINE, icon: <GitCommitHorizontal size={20} />, label: 'Logical Connection (Graph)' },
{ id: ToolType.DRAW_MAGNET_STRAIGHT, icon: <Minus size={20} />, label: 'Magnet Line (Straight)' },
{ id: ToolType.DRAW_MAGNET_CURVE, icon: <Spline size={20} />, label: 'Magnet Line (Curve)' },
{ id: ToolType.ADD_RFID, icon: <Radio size={20} />, label: 'Place RFID' },
{ id: ToolType.ADD_MARK, icon: <Disc size={20} />, label: 'Place Mark' },
{ id: ToolType.ERASER, icon: <Eraser size={20} />, label: 'Delete' },
];
return (
<div className="flex flex-col gap-2 bg-gray-800 p-2 rounded-lg border border-gray-700 h-full">
<div className="text-xs font-bold text-gray-400 mb-2 uppercase text-center">Map Editor</div>
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setTool(tool.id)}
title={tool.label}
className={`p-3 rounded-md transition-colors flex justify-center items-center ${
activeTool === tool.id
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{tool.icon}
</button>
))}
<div className="h-px bg-gray-600 my-2" />
<button onClick={onSave} className="p-3 bg-green-700 hover:bg-green-600 text-white rounded-md flex justify-center" title="Save Map">
<Save size={20} />
</button>
<button onClick={onLoad} className="p-3 bg-yellow-700 hover:bg-yellow-600 text-white rounded-md flex justify-center" title="Load Map">
<Upload size={20} />
</button>
</div>
);
};
export default EditorToolbar;

View File

@@ -0,0 +1,134 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { LogEntry } from '../types';
import { Terminal, Plug, Unplug, Settings2, Trash2, Usb } from 'lucide-react';
interface SerialConsoleProps {
title: string;
logs: LogEntry[];
isConnected: boolean;
onConnect: () => void;
onDisconnect: () => void;
onClear: () => void;
baudRate: number;
setBaudRate: (rate: number) => void;
portInfo?: string | null;
colorClass?: string; // Text color for the source label
}
const BAUD_RATES = [9600, 19200, 38400, 57600, 115200];
const SerialConsole: React.FC<SerialConsoleProps> = ({
title,
logs,
isConnected,
onConnect,
onDisconnect,
onClear,
baudRate,
setBaudRate,
portInfo,
colorClass = 'text-gray-400'
}) => {
const logContainerRef = useRef<HTMLDivElement>(null);
// Memoize reversed logs to prevent unnecessary array operations on every render
const reversedLogs = useMemo(() => [...logs].reverse(), [logs]);
// Force scroll to top whenever logs update to ensure the newest (top) is visible
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = 0;
}
}, [reversedLogs]);
return (
<div className="flex flex-col h-full bg-gray-950 border-r border-gray-800 last:border-r-0 overflow-hidden relative group">
{/* Header with Connection Controls */}
<div className="flex items-center justify-between px-2 py-1.5 bg-gray-900 border-b border-gray-800 shrink-0">
<div className="flex items-center gap-2 overflow-hidden">
<div className={`font-bold text-xs ${colorClass} flex items-center gap-1.5 whitespace-nowrap`}>
<Terminal size={12} /> {title}
</div>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isConnected ? 'bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.6)]' : 'bg-red-500/50'}`} />
{isConnected && portInfo && (
<div className="hidden sm:flex items-center gap-1 text-[9px] text-gray-500 bg-black/20 px-1.5 py-0.5 rounded border border-gray-800/50 truncate max-w-[100px]">
<Usb size={9} />
{portInfo.replace('ID:', '')}
</div>
)}
</div>
<div className="flex items-center gap-1">
<select
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
disabled={isConnected}
className="bg-gray-800 text-[9px] text-gray-300 border border-gray-700 rounded px-1 py-0.5 outline-none focus:border-blue-500 cursor-pointer disabled:opacity-50"
>
{BAUD_RATES.map(rate => <option key={rate} value={rate}>{rate}</option>)}
</select>
<button
onClick={isConnected ? onDisconnect : onConnect}
title={isConnected ? "Disconnect" : "Connect"}
className={`p-1 rounded transition-colors ${
isConnected
? 'text-red-400 hover:bg-red-900/30'
: 'text-blue-400 hover:bg-blue-900/30'
}`}
>
{isConnected ? <Unplug size={14} /> : <Plug size={14} />}
</button>
<div className="w-px h-3 bg-gray-800 mx-0.5"></div>
<button
onClick={onClear}
title="Clear Logs"
className="p-1 text-gray-500 hover:text-gray-200 hover:bg-gray-800 rounded transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Log Body */}
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto p-2 font-mono text-[10px] space-y-0.5 leading-tight bg-black"
>
{reversedLogs.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-gray-800 space-y-1">
<Settings2 size={20} className="opacity-20" />
<span className="text-[9px]">No Data</span>
</div>
)}
{reversedLogs.map((log, index) => (
<div key={log.id} className="flex gap-2 break-all opacity-90 hover:opacity-100 hover:bg-gray-900/30 py-[1px] border-b border-gray-900/50 last:border-0 items-start">
{/* Row Number (1 = Newest) */}
<span className="text-gray-700 select-none min-w-[20px] text-right text-[9px] pt-0.5">{index + 1}</span>
<span className="text-gray-600 select-none whitespace-nowrap min-w-[45px] text-[9px] pt-0.5">{log.timestamp}</span>
<span className={`font-bold select-none whitespace-nowrap w-4 text-center text-[9px] pt-0.5 ${
log.type === 'TX' ? 'text-green-600' : log.type === 'RX' ? 'text-purple-500' : 'text-gray-600'
}`}>
{log.type === 'RX' ? 'RX' : log.type === 'TX' ? 'TX' : '--'}
</span>
<span className={`flex-1 font-mono break-all
${log.type === 'TX' ? 'text-green-200/90' : ''}
${log.type === 'RX' ? 'text-purple-200/90' : ''}
${log.type === 'ERROR' ? 'text-red-400' : ''}
${log.type === 'INFO' ? 'text-gray-500 italic' : ''}
`}>
{log.message}
</span>
</div>
))}
</div>
</div>
);
};
export default SerialConsole;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import React, { useRef, useEffect, useMemo } from 'react';
import { LogEntry } from '../types';
import { Terminal, Trash2, Info } from 'lucide-react';
interface SystemLogPanelProps {
logs: LogEntry[];
onClear: () => void;
}
const SystemLogPanel: React.FC<SystemLogPanelProps> = ({ logs, onClear }) => {
const logContainerRef = useRef<HTMLDivElement>(null);
const reversedLogs = useMemo(() => [...logs].reverse(), [logs]);
// Force scroll to top
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = 0;
}
}, [reversedLogs]);
return (
<div className="flex flex-col h-full bg-gray-900 border-t border-gray-700 overflow-hidden relative">
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-800 border-b border-gray-700 shrink-0">
<div className="flex items-center gap-2">
<div className="font-bold text-xs text-gray-300 flex items-center gap-1.5">
<Info size={12} className="text-gray-400" /> SYSTEM LOG
</div>
</div>
<button
onClick={onClear}
title="Clear Logs"
className="p-1 text-gray-500 hover:text-gray-200 hover:bg-gray-700 rounded transition-colors"
>
<Trash2 size={12} />
</button>
</div>
{/* Log Body */}
<div
ref={logContainerRef}
className="flex-1 overflow-y-auto p-2 font-mono text-[10px] space-y-0.5 leading-tight bg-gray-900"
>
{reversedLogs.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-gray-700 space-y-1">
<span className="text-[9px]">No System Activity</span>
</div>
)}
{reversedLogs.map((log, index) => (
<div key={log.id} className="flex gap-2 break-all opacity-90 hover:opacity-100 hover:bg-gray-800/50 py-[1px] border-b border-gray-800/30 last:border-0 items-start">
<span className="text-gray-600 select-none min-w-[20px] text-right text-[9px] pt-0.5">{index + 1}</span>
<span className="text-gray-500 select-none whitespace-nowrap min-w-[45px] text-[9px] pt-0.5">{log.timestamp}</span>
<span className={`flex-1 font-mono break-all
${log.type === 'ERROR' ? 'text-red-400 font-bold' : 'text-gray-300'}
${log.type === 'INFO' ? 'text-gray-400' : ''}
`}>
{log.message}
</span>
</div>
))}
</div>
</div>
);
};
export default SystemLogPanel;