Initial commit: Industrial HMI system with component architecture

- Implement WebView2-based HMI frontend with React + TypeScript + Vite
- Add C# .NET backend with WebSocket communication layer
- Separate UI components into modular structure:
  * RecipePanel: Recipe selection and management
  * IOPanel: I/O monitoring and control (32 inputs/outputs)
  * MotionPanel: Servo control for X/Y/Z axes
  * CameraPanel: Vision system feed with HUD overlay
  * SettingsModal: System configuration management
- Create reusable UI components (CyberPanel, TechButton, PanelHeader)
- Implement dual-mode communication (WebView2 native + WebSocket fallback)
- Add 3D visualization with Three.js/React Three Fiber
- Fix JSON parsing bug in configuration save handler
- Include comprehensive .gitignore for .NET and Node.js projects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 20:40:45 +09:00
commit 8dc6b0f921
78 changed files with 126978 additions and 0 deletions

388
frontend/App.tsx Normal file
View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Activity, Settings, Move, Camera, Play, Square, RotateCw,
Cpu, AlertTriangle, Siren, Terminal, Layers
} from 'lucide-react';
import { Machine3D } from './components/Machine3D';
import { SettingsModal } from './components/SettingsModal';
import { RecipePanel } from './components/RecipePanel';
import { IOPanel } from './components/IOPanel';
import { MotionPanel } from './components/MotionPanel';
import { CameraPanel } from './components/CameraPanel';
import { CyberPanel } from './components/common/CyberPanel';
import { TechButton } from './components/common/TechButton';
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
import { comms } from './communication';
// --- MOCK DATA ---
const MOCK_RECIPES: Recipe[] = [
{ id: '1', name: 'Wafer_Proc_300_Au', lastModified: '2023-10-25' },
{ id: '2', name: 'Wafer_Insp_200_Adv', lastModified: '2023-10-26' },
{ id: '3', name: 'Glass_Gen5_Bonding', lastModified: '2023-10-27' },
];
const INITIAL_IO: IOPoint[] = [
...Array.from({ length: 32 }, (_, i) => {
let name = `DOUT_${i.toString().padStart(2, '0')}`;
if (i === 0) name = "Tower Lamp Red";
if (i === 1) name = "Tower Lamp Yel";
if (i === 2) name = "Tower Lamp Grn";
return { id: i, name, type: 'output' as const, state: false };
}),
...Array.from({ length: 32 }, (_, i) => {
let name = `DIN_${i.toString().padStart(2, '0')}`;
let initialState = false;
if (i === 0) name = "Front Door Sensor";
if (i === 1) name = "Right Door Sensor";
if (i === 2) name = "Left Door Sensor";
if (i === 3) name = "Back Door Sensor";
if (i === 4) { name = "Main Air Pressure"; initialState = true; }
if (i === 5) { name = "Vacuum Generator"; initialState = true; }
if (i === 6) { name = "Emergency Stop Loop"; initialState = true; }
return { id: i, name, type: 'input' as const, state: initialState };
})
];
// --- MAIN APP ---
export default function App() {
const [activeTab, setActiveTab] = useState<'recipe' | 'io' | 'motion' | 'camera' | 'setting' | null>(null);
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
const [currentRecipe, setCurrentRecipe] = useState<Recipe>(MOCK_RECIPES[0]);
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
const [logs, setLogs] = useState<LogEntry[]>([]);
const [ioPoints, setIoPoints] = useState<IOPoint[]>(INITIAL_IO);
const [currentTime, setCurrentTime] = useState(new Date());
const [config, setConfig] = useState<ConfigItem[] | null>(null);
const [isConfigRefreshing, setIsConfigRefreshing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<any>(null);
// Check if running in WebView2 context
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
// -- COMMUNICATION LAYER --
useEffect(() => {
// Subscribe to unified communication layer
const unsubscribe = comms.subscribe((msg: any) => {
if (!msg) return;
if (msg.type === 'STATUS_UPDATE') {
// Update Motion State
if (msg.position) {
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
}
// Update IO State (Merge with existing names/configs)
if (msg.ioState) {
setIoPoints(prev => {
const newIO = [...prev];
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
const idx = newIO.findIndex(p => p.id === update.id && p.type === update.type);
if (idx >= 0) newIO[idx] = { ...newIO[idx], state: update.state };
});
return newIO;
});
}
// Update System State
if (msg.sysState) {
setSystemState(msg.sysState as SystemState);
}
}
});
addLog("COMMUNICATION CHANNEL OPEN", "info");
// Timer for Clock
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => {
clearInterval(timer);
unsubscribe();
};
}, []);
// -- INITIALIZATION --
useEffect(() => {
const initSystem = async () => {
// Just start up without fetching config
addLog("SYSTEM STARTED", "info");
setIsLoading(false);
};
initSystem();
}, []);
// -- ON-DEMAND CONFIG FETCH --
useEffect(() => {
if (activeTab === 'setting') {
const fetchConfig = async () => {
setIsConfigRefreshing(true);
try {
const configStr = await comms.getConfig();
setConfig(JSON.parse(configStr));
addLog("CONFIG REFRESHED", "info");
} catch (e) {
addLog("CONFIG REFRESH FAILED", "error");
}
setIsConfigRefreshing(false);
};
fetchConfig();
}
}, [activeTab]);
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
};
// Logic Helpers
const doorStates = {
front: ioPoints.find(p => p.id === 0 && p.type === 'input')?.state || false,
right: ioPoints.find(p => p.id === 1 && p.type === 'input')?.state || false,
left: ioPoints.find(p => p.id === 2 && p.type === 'input')?.state || false,
back: ioPoints.find(p => p.id === 3 && p.type === 'input')?.state || false,
};
const isLowPressure = !(ioPoints.find(p => p.id === 4 && p.type === 'input')?.state ?? true);
const isEmergencyStop = !(ioPoints.find(p => p.id === 6 && p.type === 'input')?.state ?? true);
// -- COMMAND HANDLERS --
// -- COMMAND HANDLERS --
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
try {
await comms.sendControl(action.toUpperCase());
addLog(`CMD SENT: ${action.toUpperCase()}`, 'info');
} catch (e) {
addLog('COMM ERROR', 'error');
}
};
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
// Only allow output toggling
if (type === 'output') {
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
const nextState = forceState !== undefined ? forceState : !current;
await comms.setIO(id, nextState);
}
};
const moveAxis = async (axis: 'X' | 'Y' | 'Z', value: number) => {
if (isEmergencyStop) return;
await comms.moveAxis(axis, value);
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
};
useEffect(() => {
if (activeTab === 'camera' && navigator.mediaDevices?.getUserMedia) {
navigator.mediaDevices.getUserMedia({ video: true }).then(s => { if (videoRef.current) videoRef.current.srcObject = s });
}
}, [activeTab]);
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
try {
await comms.saveConfig(JSON.stringify(newConfig));
setConfig(newConfig);
addLog("CONFIGURATION SAVED", "success");
} catch (e) {
console.error(e);
addLog("FAILED TO SAVE CONFIG", "error");
}
};
return (
<div className="relative w-screen h-screen bg-slate-950 text-slate-100 overflow-hidden font-sans">
{/* Animated Nebula Background */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-[#050a15] to-[#0a0f20] animate-gradient bg-[length:400%_400%] z-0"></div>
<div className="absolute inset-0 grid-bg opacity-30 z-0"></div>
<div className="absolute inset-0 scanlines z-50 pointer-events-none"></div>
{/* LOADING OVERLAY */}
{isLoading && (
<div className="absolute inset-0 z-[100] bg-black flex flex-col items-center justify-center gap-6">
<div className="relative">
<div className="w-24 h-24 border-4 border-neon-blue/30 rounded-full animate-spin"></div>
<div className="absolute inset-0 border-t-4 border-neon-blue rounded-full animate-spin"></div>
<Cpu className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-neon-blue w-10 h-10 animate-pulse" />
</div>
<div className="text-center">
<h2 className="text-2xl font-tech font-bold text-white tracking-widest mb-2">SYSTEM INITIALIZING</h2>
<p className="font-mono text-neon-blue text-sm animate-pulse">ESTABLISHING CONNECTION...</p>
</div>
</div>
)}
{/* HEADER */}
<header className="absolute top-0 left-0 right-0 h-20 px-6 flex items-center justify-between z-40 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
<div className="flex items-center gap-4 pointer-events-auto">
<div className="w-12 h-12 border-2 border-neon-blue flex items-center justify-center rounded shadow-glow-blue bg-black/50 backdrop-blur">
<Cpu className="text-neon-blue w-8 h-8 animate-pulse-slow" />
</div>
<div>
<h1 className="text-3xl font-tech font-bold text-white tracking-widest uppercase italic text-shadow-glow-blue">
EQUI-HANDLER <span className="text-neon-blue text-sm not-italic">PRO</span>
</h1>
<div className="flex gap-2 text-[10px] text-neon-blue/70 font-mono">
<span>SYS.VER 4.2.0</span>
<span>|</span>
<span className={isWebView ? "text-neon-green" : "text-amber-500"}>
LINK: {isWebView ? "NATIVE" : "SIMULATION"}
</span>
</div>
</div>
</div>
{/* Top Navigation */}
<div className="flex items-center gap-2 pointer-events-auto bg-black/40 backdrop-blur-md p-1 rounded-full border border-white/10">
{[
{ id: 'recipe', icon: Layers, label: 'RECIPE' },
{ id: 'io', icon: Activity, label: 'I/O MONITOR' },
{ id: 'motion', icon: Move, label: 'MOTION' },
{ id: 'camera', icon: Camera, label: 'VISION' },
{ id: 'setting', icon: Settings, label: 'CONFIG' }
].map(item => (
<button
key={item.id}
onClick={() => setActiveTab(activeTab === item.id ? null : item.id as any)}
className={`
flex items-center gap-2 px-6 py-2 rounded-full font-tech font-bold text-sm transition-all border border-transparent
${activeTab === item.id
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
: 'text-slate-400 hover:text-white hover:bg-white/5'}
`}
>
<item.icon className="w-4 h-4" /> {item.label}
</button>
))}
</div>
<div className="text-right pointer-events-auto">
<div className="text-2xl font-mono font-bold text-white text-shadow-glow-blue">
{currentTime.toLocaleTimeString('en-GB')}
</div>
<div className="text-xs font-tech text-slate-400 tracking-[0.3em]">
{currentTime.toLocaleDateString().toUpperCase()}
</div>
</div>
</header>
{/* MAIN CONTENT */}
<main className="absolute inset-0 pt-24 pb-12 px-6 flex gap-6 z-10">
{/* 3D Canvas (Background Layer) */}
<div className="absolute inset-0 z-0">
<Machine3D target={robotTarget} ioState={ioPoints} doorStates={doorStates} />
</div>
{/* Center Alarms */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none flex flex-col items-center gap-4">
{isEmergencyStop && (
<div className="bg-red-600/90 text-white p-8 border-4 border-red-500 shadow-glow-red flex items-center gap-6 animate-pulse">
<Siren className="w-16 h-16 animate-spin" />
<div>
<h1 className="text-5xl font-tech font-bold tracking-widest">EMERGENCY STOP</h1>
<p className="text-center font-mono text-lg">SYSTEM HALTED - RELEASE TO RESET</p>
</div>
</div>
)}
{isLowPressure && !isEmergencyStop && (
<div className="bg-amber-500/80 text-black px-8 py-4 rounded font-bold text-2xl tracking-widest flex items-center gap-4 shadow-glow-red animate-bounce">
<AlertTriangle className="w-8 h-8" /> LOW AIR PRESSURE WARNING
</div>
)}
</div>
{/* Floating Panel (Left) */}
{activeTab && activeTab !== 'setting' && (
<div className="w-[450px] z-20 animate-in slide-in-from-left-20 duration-500 fade-in">
<CyberPanel className="h-full flex flex-col">
{activeTab === 'recipe' && <RecipePanel recipes={MOCK_RECIPES} currentRecipe={currentRecipe} onSelectRecipe={(r) => { setCurrentRecipe(r); addLog(`LOADED: ${r.name}`) }} />}
{activeTab === 'io' && <IOPanel ioPoints={ioPoints} onToggle={toggleIO} />}
{activeTab === 'motion' && <MotionPanel robotTarget={robotTarget} onMove={moveAxis} />}
{activeTab === 'camera' && <CameraPanel videoRef={videoRef} />}
</CyberPanel>
</div>
)}
{/* Settings Modal */}
<SettingsModal
isOpen={activeTab === 'setting'}
onClose={() => setActiveTab(null)}
config={config}
isRefreshing={isConfigRefreshing}
onSave={handleSaveConfig}
/>
{/* Right Sidebar (Dashboard) */}
<div className="w-80 ml-auto z-20 flex flex-col gap-4">
<CyberPanel className="flex-none">
<div className="mb-2 text-xs text-neon-blue font-bold tracking-widest uppercase">System Status</div>
<div className={`text-3xl font-tech font-bold mb-4 ${systemState === SystemState.RUNNING ? 'text-neon-green text-shadow-glow-green' : 'text-slate-400'}`}>
{systemState}
</div>
<div className="space-y-3">
<TechButton
variant="green"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.RUNNING}
onClick={() => handleControl('start')}
>
<Play className="w-4 h-4" /> START AUTO
</TechButton>
<TechButton
variant="amber"
className="w-full flex items-center justify-center gap-2"
active={systemState === SystemState.PAUSED}
onClick={() => handleControl('stop')}
>
<Square className="w-4 h-4 fill-current" /> STOP / PAUSE
</TechButton>
<TechButton
className="w-full flex items-center justify-center gap-2"
onClick={() => handleControl('reset')}
>
<RotateCw className="w-4 h-4" /> SYSTEM RESET
</TechButton>
</div>
</CyberPanel>
<CyberPanel className="flex-1 flex flex-col min-h-0">
<div className="mb-2 flex items-center justify-between text-xs text-neon-blue font-bold tracking-widest uppercase border-b border-white/10 pb-2">
<span>Event Log</span>
<Terminal className="w-3 h-3" />
</div>
<div className="flex-1 overflow-y-auto font-mono text-[10px] space-y-1 pr-1 custom-scrollbar">
{logs.map(log => (
<div key={log.id} className={`flex gap-2 ${log.type === 'error' ? 'text-red-500' : log.type === 'warning' ? 'text-amber-400' : 'text-slate-400'}`}>
<span className="opacity-50">[{log.timestamp}]</span>
<span>{log.message}</span>
</div>
))}
</div>
</CyberPanel>
</div>
</main>
{/* FOOTER STATUS */}
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
<div className="flex gap-6">
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
<div key={hw} className="flex items-center gap-2">
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
<span className="font-bold text-slate-300">{hw}</span>
</div>
))}
</div>
<div className="flex gap-8 text-neon-blue">
<span>POS.X: {robotTarget.x.toFixed(3)}</span>
<span>POS.Y: {robotTarget.y.toFixed(3)}</span>
<span>POS.Z: {robotTarget.z.toFixed(3)}</span>
</div>
</footer>
</div>
);
}