import React, { useState, useMemo, useEffect, useRef, useCallback, Component, ErrorInfo, ReactNode } from 'react'; import { createRoot } from 'react-dom/client'; import { Settings, Plus, Trash2, Zap, User, Cpu, BarChart3, AlertCircle, Bot, RefreshCcw, ChevronRight, ChevronLeft, Gauge, Activity, CheckCircle2, Layers, Flame, Clock, Upload, ZoomIn, ZoomOut, Eye, ImageIcon, Search, Filter, FileDown, Copy, MoreHorizontal, Folder, Palette, LayoutGrid, Timer, Info, Link as LinkIcon, AlertTriangle, Printer, Camera, Share2, Save, Download, Undo, Redo, Edit3, Link2, Keyboard, Bell, Moon, X, Layout, Pin, Wrench, Sun, Split, Table as TableIcon, GitMerge, ArrowRightLeft, Sparkles, FileSpreadsheet, Unlock, Coins, TrendingUp, Scale, ZapOff, Move, Play, Pause, RotateCcw, FastForward, DollarSign, Calculator, Target, Circle, Calendar, Siren, FileText, MousePointer2, Hand, Users, PieChart as PieChartIcon, Package, Trophy, TrendingDown, Wallet, Radar as RadarIcon, Loader2, MapPin, Home, TimerReset, Briefcase } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend, Cell, LineChart, Line, PieChart, Pie, Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts'; import { GoogleGenAI, Type, GenerateContentResponse } from "@google/genai"; // --- Types & Interfaces --- type WeldingMode = 'MANUAL' | 'ROBOT'; type MaterialType = 'STEEL' | 'STAINLESS' | 'ALUMINUM'; type WeldPosition = string; type WeldSide = 'LEFT' | 'RIGHT'; interface WeldGroup { id: string; name: string; color: string; } interface WeldSegment { id: string; name: string; length: number; travelSpeed: number; approachTime: number; liftTime: number; position: WeldPosition; side: WeldSide; groupId: string; x?: number; y?: number; } interface WeldConstraint { id: string; firstWeldId: string; secondWeldId: string; description: string; } interface ScheduleItem { type: 'WELD' | 'MOVE' | 'IDLE' | 'SETUP'; targetId?: string; fromId?: string; toId?: string; startTime: number; endTime: number; duration: number; inConflict?: boolean; } interface ProcessResult { id: number; weldIds: string[]; cycleTime: number; side: WeldSide | 'MIXED'; distanceTravelled?: number; timeline: ScheduleItem[]; } interface ModelData { id: string; name: string; segments: WeldSegment[]; weldGroups: WeldGroup[]; constraints: WeldConstraint[]; manualOverrides?: Record; // Frame View Data (Per Model) frameImage?: string | null; frameWidthMM?: number; robotBases?: { left: { x: number; y: number }; right: { x: number; y: number } }; } interface RobotSettings { moveSpeed: number; // m/s (Air cut speed) toolChangeTime: number; fixtureRotationTime: number; includeMovementTime: boolean; optimizePath: boolean; includeReturnHome: boolean; useFixedHomeTimes: boolean; // New: Toggle for fixed times fixedApproachTime: number; // New: Home -> First Weld fixedReturnTime: number; // New: Last Weld -> Home maxReach: number; interferenceZoneMinX: number; interferenceZoneMaxX: number; arcDelay: number; avgMoveDist: number; cleaningInterval: number; cleaningTime: number; } interface ManualSettings { handlingTime: number; // General handling (Pick torch, mask) per cycle jigRotationTime: number; // Time to rotate Jig once jigRotationsPerCycle: number; // How many times jig is rotated efficiencyFactor: number; // 1.0 = 100%, 0.9 = 90% performance } interface CostSettings { wirePricePerKg: number; gasPricePerLiter: number; electricPricePerKw: number; wireDiameter: number; gasFlowRate: number; powerSourceKw: number; wireFeedSpeed: number; } interface ROISettings { manualHourlyWage: number; operatorsPerShift: number; shiftsPerDay: number; daysPerMonth: number; robotSystemCost: number; robotMaintenanceYearly: number; } interface WeldPositionDefinition { id: string; label: string; factor: number; } interface ModelStats { modelId: string; modelName: string; totalSegments: number; totalTime: number; processes: ProcessResult[]; maxCycleTime: number; efficiency: number; isBalanced: boolean; totalCost?: number; collisionDetected?: boolean; } // --- Constants --- const MATERIAL_PRESETS = { STEEL: { manual: 6, robot: 15 }, STAINLESS: { manual: 5, robot: 12 }, ALUMINUM: { manual: 8, robot: 20 }, }; const DEFAULT_GROUPS: WeldGroup[] = [ { id: 'G1', name: 'Main Rails (คานหลัก)', color: '#3b82f6' }, { id: 'G2', name: 'Cross Members (คานขวาง)', color: '#10b981' }, { id: 'G3', name: 'Brackets (ขายึด)', color: '#f59e0b' }, ]; const PRESET_COLORS = [ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#6366f1' ]; const DEFAULT_POSITIONS: WeldPositionDefinition[] = [ { id: 'FLAT', label: 'ท่าราบ (Flat 1F)', factor: 0.06 }, { id: 'HORIZONTAL', label: 'ท่าขนานนอน (Horz 2F)', factor: 0.07 }, { id: 'VERTICAL', label: 'ท่าตั้ง (Vert 3F)', factor: 0.08 }, { id: 'OVERHEAD', label: 'ท่าเหนือศีรษะ (Over 4F)', factor: 0.10 } ]; const DEFAULT_ROBOT_BASES = { left: { x: 5, y: 50 }, right: { x: 95, y: 50 } }; const DEFAULT_FRAME_WIDTH = 2000; // --- Sub-Components --- // Simulation Visualizer Component interface SimulationVisualizerProps { showPathFlow: boolean; currentStats: ModelStats; segments: WeldSegment[]; robotBases: { left: { x: number; y: number }; right: { x: number; y: number } }; simTime: number; frameWidthMM: number; robotSettings: RobotSettings; showReachZones: boolean; showInterferenceZone: boolean; isCollisionNow: boolean; } const SimulationVisualizer: React.FC = ({ showPathFlow, currentStats, segments, robotBases, simTime, frameWidthMM, robotSettings, showReachZones, showInterferenceZone, isCollisionNow }) => { return ( {PRESET_COLORS.map((color, i) => ( ))} {/* Robot Reach Zones */} {showReachZones && ( <> )} {/* Interference Zone */} {showInterferenceZone && ( )} {/* Simulation Move Lines */} {showPathFlow && currentStats.processes.map((proc, pIdx) => { const pathColor = PRESET_COLORS[pIdx % PRESET_COLORS.length]; return proc.timeline.map((item, idx) => { if (item.type === 'MOVE' && item.toId) { const sideKey = proc.side === 'RIGHT' ? 'right' : 'left'; const baseCoords = robotBases[sideKey]; let x1: number | undefined, y1: number | undefined; let x2: number | undefined, y2: number | undefined; // Determine Start Point if (item.fromId === 'START') { x1 = baseCoords.x; y1 = baseCoords.y; } else { const fromSeg = segments.find(s => s.id === item.fromId); x1 = fromSeg?.x; y1 = fromSeg?.y; } // Determine End Point if (item.toId === 'HOME') { x2 = baseCoords.x; y2 = baseCoords.y; } else { const toSeg = segments.find(s => s.id === item.toId); x2 = toSeg?.x; y2 = toSeg?.y; } const isMovingNow = simTime >= item.startTime && simTime <= item.endTime; if (x1 !== undefined && y1 !== undefined && x2 !== undefined && y2 !== undefined) { return ( ); } } return null; }); })} ); }; // Error Boundary interface ErrorBoundaryProps { children?: ReactNode; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } class ErrorBoundary extends Component { state: ErrorBoundaryState = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error("ErrorBoundary caught an error", error, errorInfo); } render() { if (this.state.hasError) { return (

Application Error

โปรแกรมพบข้อผิดพลาดที่ไม่คาดคิด (An unexpected error occurred).

{this.state.error?.message}
); } return this.props.children; } } // --- Main App Component --- const App = () => { // Global State const [projectName, setProjectName] = useState("Chassis Project 1"); const [mode, setMode] = useState('MANUAL'); const [material, setMaterial] = useState('STEEL'); const [taktTime, setTaktTime] = useState(180); const [targetProcessMode, setTargetProcessMode] = useState("AUTO"); // Fixed Robot Counts const [fixedRobotsLeft, setFixedRobotsLeft] = useState(1); const [fixedRobotsRight, setFixedRobotsRight] = useState(1); // Balancing Strategy State const [separateSides, setSeparateSides] = useState(true); const [balancingAlgo, setBalancingAlgo] = useState<'SEQUENTIAL' | 'LPT'>('SEQUENTIAL'); // World Class Features State const [costSettings, setCostSettings] = useState({ wirePricePerKg: 45, gasPricePerLiter: 0.5, electricPricePerKw: 4.5, wireDiameter: 1.2, gasFlowRate: 15, powerSourceKw: 6, wireFeedSpeed: 8 }); // ROI & Financials const [roiSettings, setRoiSettings] = useState({ manualHourlyWage: 350, operatorsPerShift: 2, shiftsPerDay: 2, daysPerMonth: 24, robotSystemCost: 3500000, robotMaintenanceYearly: 50000 }); const [showReachZones, setShowReachZones] = useState(true); const [showInterferenceZone, setShowInterferenceZone] = useState(true); // Dynamic Positions State const [weldPositions, setWeldPositions] = useState(DEFAULT_POSITIONS); // Robot Settings State const [robotSettings, setRobotSettings] = useState({ moveSpeed: 2, toolChangeTime: 5, fixtureRotationTime: 8, includeMovementTime: true, optimizePath: true, includeReturnHome: true, // Default to true useFixedHomeTimes: false, // Default to false (calculate) fixedApproachTime: 3.0, fixedReturnTime: 3.0, maxReach: 1800, // mm interferenceZoneMinX: 40, // % interferenceZoneMaxX: 60, // % arcDelay: 0.8, avgMoveDist: 200, cleaningInterval: 20, cleaningTime: 6 }); // Manual Settings State const [manualSettings, setManualSettings] = useState({ handlingTime: 5, // Pickup torch, mask, start jigRotationTime: 10, jigRotationsPerCycle: 0, efficiencyFactor: 1.0 // 100% }); // Models State const [models, setModels] = useState([{ id: 'm1', name: 'Base Model', segments: [ { id: 'W001', name: 'Main Frame L', length: 150, travelSpeed: 6, approachTime: 3, liftTime: 0.5, position: 'FLAT', side: 'LEFT', groupId: 'G1', x: 20, y: 30 }, { id: 'W002', name: 'Main Frame R', length: 150, travelSpeed: 6, approachTime: 3, liftTime: 0.5, position: 'FLAT', side: 'RIGHT', groupId: 'G1', x: 60, y: 30 }, { id: 'W003', name: 'Cross Bar A', length: 80, travelSpeed: 6, approachTime: 3, liftTime: 0.5, position: 'HORIZONTAL', side: 'LEFT', groupId: 'G2', x: 40, y: 30 }, { id: 'W004', name: 'Cross Bar B', length: 80, travelSpeed: 6, approachTime: 3, liftTime: 0.5, position: 'HORIZONTAL', side: 'RIGHT', groupId: 'G2', x: 40, y: 60 }, ], weldGroups: DEFAULT_GROUPS, constraints: [], manualOverrides: {}, frameWidthMM: 2000, robotBases: DEFAULT_ROBOT_BASES, frameImage: null }]); // History State const [history, setHistory] = useState<{past: ModelData[][], future: ModelData[][]}>({ past: [], future: [] }); const [activeModelId, setActiveModelId] = useState('m1'); // Modals const [showAddModal, setShowAddModal] = useState(false); const [newModelName, setNewModelName] = useState(''); const [copyFromId, setCopyFromId] = useState('EMPTY'); const [showOverview, setShowOverview] = useState(false); const [showAIModal, setShowAIModal] = useState(false); const [showRobotSettingsModal, setShowRobotSettingsModal] = useState(false); const [showManualSettingsModal, setShowManualSettingsModal] = useState(false); const [aiPrompt, setAiPrompt] = useState(''); const [isAiLoading, setIsAiLoading] = useState(false); // UI State const [activeTab, setActiveTab] = useState<'FRAME_VIEW' | 'WELD_LIST' | 'GROUPS' | 'STANDARD_TIMES' | 'CONSTRAINTS' | 'RESULTS'>('WELD_LIST'); const [isDarkMode, setIsDarkMode] = useState(false); const [showPathFlow, setShowPathFlow] = useState(true); // Manual Balance UI State const [isManualBalanceMode, setIsManualBalanceMode] = useState(false); const [selectedManualProcess, setSelectedManualProcess] = useState(0); // 0-based index const [isDragMode, setIsDragMode] = useState(false); // For Yamazumi Drag & Drop // Simulation State const [simTime, setSimTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [simSpeed, setSimSpeed] = useState(1); const animationFrameRef = useRef(); // Balancing Results State const [processes, setProcesses] = useState([]); const [isBalanced, setIsBalanced] = useState(true); const [totalCost, setTotalCost] = useState(0); const [hasCollision, setHasCollision] = useState(false); // Visual Reference State (Now derived from Active Model) const [zoomLevel, setZoomLevel] = useState(100); const [draggingId, setDraggingId] = useState(null); const [draggingBase, setDraggingBase] = useState<'left'|'right'|null>(null); const fileInputRef = useRef(null); const loadFileInputRef = useRef(null); const imageContainerRef = useRef(null); const projectNameInputRef = useRef(null); const balAlgoRef = useRef(balancingAlgo); // List Filters const [selectedSegmentId, setSelectedSegmentId] = useState(null); // --- Computed Properties --- const activeModel = useMemo(() => models.find(m => m.id === activeModelId) || models[0], [models, activeModelId]); const segments = activeModel.segments; const weldGroups = activeModel.weldGroups; const constraints = activeModel.constraints; // Derive visual properties from activeModel with fallbacks const frameImage = activeModel.frameImage || null; const frameWidthMM = activeModel.frameWidthMM || DEFAULT_FRAME_WIDTH; const robotBases = activeModel.robotBases || DEFAULT_ROBOT_BASES; const calculateSegmentTime = useCallback((s: WeldSegment) => { // FIX: Safer calculation to prevent NaN const speed = s.travelSpeed > 0 ? s.travelSpeed : 1; const weldTime = s.length / speed; const lift = s.liftTime || 0; if (mode === 'MANUAL') { return Number((weldTime + s.approachTime + lift).toFixed(1)); } else { const moveSpeedMM = robotSettings.moveSpeed * 1000; const safeMoveSpeed = moveSpeedMM > 0 ? moveSpeedMM : 2000; const moveTime = robotSettings.includeMovementTime ? (robotSettings.avgMoveDist / safeMoveSpeed) * (robotSettings.optimizePath ? 0.8 : 1.0) : 0; const techTime = robotSettings.arcDelay; const cleaningFactor = robotSettings.cleaningTime / (robotSettings.cleaningInterval || 1); return Number((weldTime + moveTime + techTime + cleaningFactor + lift).toFixed(1)); } }, [mode, robotSettings]); // --- UNDO / REDO --- const pushHistory = (currentModels: ModelData[]) => { setHistory(prev => { const newPast = [...prev.past, currentModels]; const trimmedPast = newPast.length > 20 ? newPast.slice(newPast.length - 20) : newPast; return { past: trimmedPast, future: [] }; }); }; const undo = useCallback(() => { if (history.past.length === 0) return; const previous = history.past[history.past.length - 1]; const newPast = history.past.slice(0, -1); setHistory({ past: newPast, future: [models, ...history.future] }); setModels(previous); }, [history, models]); const redo = useCallback(() => { if (history.future.length === 0) return; const next = history.future[0]; const newFuture = history.future.slice(1); setHistory({ past: [...history.past, models], future: newFuture }); setModels(next); }, [history, models]); const handleModelUpdate = (newModels: ModelData[]) => { pushHistory(models); setModels(newModels); }; // --- SAVE / LOAD --- const handleSaveProject = async () => { const projectData = { version: "2.0", // Bump for new structure timestamp: new Date().toISOString(), projectName, mode, material, taktTime, targetProcessMode, fixedRobotsLeft, fixedRobotsRight, separateSides, weldPositions, robotSettings, manualSettings, costSettings, roiSettings, models }; const suggestedName = `${projectName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${new Date().toISOString().slice(0,10)}.json`; if ('showSaveFilePicker' in window) { try { const handle = await (window as any).showSaveFilePicker({ suggestedName: suggestedName, types: [{ description: 'JSON Project File', accept: { 'application/json': ['.json'] } }], }); // Ensure we pass arguments if required by user's setup, though {} is correct for File System Access API const writable = await handle.createWritable({}); await writable.write(JSON.stringify(projectData, null, 2)); await (writable as any).close(); alert("บันทึกโปรเจคสำเร็จ (Project Saved Successfully - Images Included)"); return; } catch (err: any) { if (err.name === 'AbortError') return; console.error("FS API failed", err); } } const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = suggestedName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert("บันทึกโปรเจคลงในโฟลเดอร์ Downloads สำเร็จ (พร้อมรูปภาพ)"); }; const handleExportCSV = () => { const headers = "ID,Name,Side,Length(mm),Position,Est.Time(s),Group,LiftTime(s)\n"; const rows = segments.map(s => `${s.id},"${s.name}",${s.side},${s.length},${s.position},${calculateSegmentTime(s)},${s.groupId},${s.liftTime||0}` ).join("\n"); const csvContent = "data:text/csv;charset=utf-8," + headers + rows; const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); link.setAttribute("href", encodedUri); link.setAttribute("download", `${projectName}_weld_data.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleLoadClick = () => loadFileInputRef.current?.click(); const handleFileLoad = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event: ProgressEvent) => { try { const json = JSON.parse(event.target?.result as string); if (!json.models || !Array.isArray(json.models)) throw new Error("Invalid project file"); if (json.projectName) setProjectName(json.projectName); if (json.mode) setMode(json.mode); if (json.material) setMaterial(json.material); if (json.taktTime) setTaktTime(json.taktTime); if (json.targetProcessMode) setTargetProcessMode(json.targetProcessMode); if (json.fixedRobotsLeft !== undefined) setFixedRobotsLeft(json.fixedRobotsLeft); if (json.fixedRobotsRight !== undefined) setFixedRobotsRight(json.fixedRobotsRight); if (json.separateSides !== undefined) setSeparateSides(json.separateSides); if (json.weldPositions) setWeldPositions(json.weldPositions); if (json.robotSettings) setRobotSettings(json.robotSettings); if (json.manualSettings) setManualSettings(json.manualSettings); if (json.costSettings) setCostSettings(json.costSettings); if (json.roiSettings) setRoiSettings(json.roiSettings); // Backward compatibility for old saves where frameWidth/bases were global const loadedModels = json.models.map((m: any) => ({ ...m, frameWidthMM: m.frameWidthMM || json.frameWidthMM || DEFAULT_FRAME_WIDTH, robotBases: m.robotBases || json.robotBases || DEFAULT_ROBOT_BASES, frameImage: m.frameImage || null // Images are now model specific })); pushHistory(models); setModels(loadedModels); setActiveModelId(loadedModels[0]?.id || ''); alert(`โหลดโปรเจค "${json.projectName || 'Unknown'}" สำเร็จ`); } catch (err) { console.error(err); alert("Error loading file."); } }; reader.readAsText(file); e.target.value = ''; }; const toggleDarkMode = () => setIsDarkMode(!isDarkMode); const getDefaultSpeed = useCallback(() => { return mode === 'MANUAL' ? MATERIAL_PRESETS[material].manual : MATERIAL_PRESETS[material].robot; }, [mode, material]); // --- AI --- const handleAIGenerate = async (e?: any) => { if (!aiPrompt || !aiPrompt.trim()) return; setIsAiLoading(true); try { const ai = new GoogleGenAI({ apiKey: process.env.API_KEY }); const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: { parts: [{ text: `Extract welding data from text: "${aiPrompt}"` }] }, config: { responseMimeType: "application/json", responseSchema: { type: Type.ARRAY, items: { type: Type.OBJECT, properties: { name: { type: Type.STRING }, length: { type: Type.NUMBER }, side: { type: Type.STRING }, position: { type: Type.STRING }, }, propertyOrdering: ["name", "length", "side", "position"], } } } }); const responseText = response.text; if (!responseText) throw new Error("No response text received"); const parsedData = JSON.parse(responseText); const currentDefaultSpeed = mode === 'MANUAL' ? MATERIAL_PRESETS[material].manual : MATERIAL_PRESETS[material].robot; const newSegments = parsedData.map((item: any, idx: number) => { // Calculate max ID to prevent duplicates from AI generation too const maxIdNum = segments.reduce((max, s) => { const match = s.id.match(/^W(\d+)$/); return match ? Math.max(max, parseInt(match[1], 10)) : max; }, 0); const idNum = maxIdNum + 1 + idx; const posDef = weldPositions.find(p => p.id === item.position) || weldPositions[0]; const stdTime = posDef.factor; const speed = stdTime > 0 ? 1 / stdTime : currentDefaultSpeed; return { id: `W${idNum.toString().padStart(3, '0')}`, name: item.name || `Weld ${idNum}`, length: Number(item.length) || 100, travelSpeed: speed, approachTime: mode === 'MANUAL' ? 5 : 2, liftTime: 0.5, position: posDef.id, side: ['LEFT', 'RIGHT'].includes(item.side) ? item.side : 'LEFT', groupId: '', }; }); setSegments([...segments, ...newSegments]); setShowAIModal(false); setAiPrompt(''); } catch (error: any) { alert("AI Generation failed: " + error.message); } finally { setIsAiLoading(false); } }; // --- Wrapper Setters --- const setSegments = (newVal: WeldSegment[] | ((prev: WeldSegment[]) => WeldSegment[])) => { const updatedModels = models.map(m => { if (m.id === activeModelId) { const updatedSegments = typeof newVal === 'function' ? newVal(m.segments) : newVal; return { ...m, segments: updatedSegments }; } return m; }); handleModelUpdate(updatedModels); }; const setWeldGroups = (newVal: WeldGroup[] | ((prev: WeldGroup[]) => WeldGroup[])) => { const updatedModels = models.map(m => { if (m.id === activeModelId) { const updated = typeof newVal === 'function' ? newVal(m.weldGroups) : newVal; return { ...m, weldGroups: updated }; } return m; }); handleModelUpdate(updatedModels); }; const setConstraints = (newVal: WeldConstraint[] | ((prev: WeldConstraint[]) => WeldConstraint[])) => { const updatedModels = models.map(m => { if (m.id === activeModelId) { const updated = typeof newVal === 'function' ? newVal(m.constraints) : newVal; return { ...m, constraints: updated }; } return m; }); handleModelUpdate(updatedModels); }; const setManualOverrides = (newOverrides: Record) => { const updatedModels = models.map(m => { if (m.id === activeModelId) { return { ...m, manualOverrides: newOverrides }; } return m; }); handleModelUpdate(updatedModels); }; const setFrameWidthMM = (val: number) => { const updatedModels = models.map(m => m.id === activeModelId ? { ...m, frameWidthMM: val } : m); setModels(updatedModels); // No history push for typing? Or maybe push. Let's not push on every keystroke, handle elsewhere or use blur. For now simple set. }; const setRobotBases = (val: {left: {x:number, y:number}, right: {x:number, y:number}} | ((prev: {left: {x:number, y:number}, right: {x:number, y:number}}) => {left: {x:number, y:number}, right: {x:number, y:number}})) => { const updatedModels = models.map(m => { if (m.id === activeModelId) { const currentBases = m.robotBases || DEFAULT_ROBOT_BASES; const newBases = typeof val === 'function' ? val(currentBases) : val; return { ...m, robotBases: newBases }; } return m; }); setModels(updatedModels); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); handleSaveProject(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [undo, redo, projectName, models, weldPositions, robotSettings, costSettings, frameWidthMM]); // --- Core Logic: Balancing Calculator with Timeline --- const calculateBalancingForModel = useCallback((model: ModelData): ModelStats => { const modelWidthMM = model.frameWidthMM || DEFAULT_FRAME_WIDTH; const modelBases = model.robotBases || DEFAULT_ROBOT_BASES; const adj = new Map(); const inDegree = new Map(); const predecessors = new Map(); model.segments.forEach(s => { adj.set(s.id, []); inDegree.set(s.id, 0); predecessors.set(s.id, []); }); model.constraints.forEach(c => { if (model.segments.some(s => s.id === c.firstWeldId) && model.segments.some(s => s.id === c.secondWeldId)) { adj.get(c.firstWeldId)?.push(c.secondWeldId); inDegree.set(c.secondWeldId, (inDegree.get(c.secondWeldId) || 0) + 1); predecessors.get(c.secondWeldId)?.push(c.firstWeldId); } }); const queue: string[] = []; inDegree.forEach((val, key) => { if (val === 0) queue.push(key); }); const sortQueue = (q: string[]) => { if (balAlgoRef.current === 'LPT') { q.sort((a, b) => { const segA = model.segments.find(s => s.id === a); const segB = model.segments.find(s => s.id === b); const timeA = segA ? segA.length/segA.travelSpeed : 0; const timeB = segB ? segB.length/segB.travelSpeed : 0; return timeB - timeA; }); } else { q.sort(); } }; if (balancingAlgo === 'LPT') sortQueue(queue); else queue.sort(); const sortedIds: string[] = []; const tempInDegree = new Map(inDegree); const tempAdj = adj; while (queue.length > 0) { const u = queue.shift()!; sortedIds.push(u); tempAdj.get(u)?.forEach(v => { tempInDegree.set(v, (tempInDegree.get(v) || 0) - 1); if (tempInDegree.get(v) === 0) queue.push(v); }); if (balancingAlgo === 'LPT') sortQueue(queue); else queue.sort(); } model.segments.forEach(s => { if (!sortedIds.includes(s.id)) sortedIds.push(s.id); }); const newProcesses: ProcessResult[] = []; const taskProcessMap = new Map(); const processLastPos = new Map(); let totalTime = 0; let totalCost = 0; const manualOverrides = model.manualOverrides || {}; let limitLeft = taktTime; let limitRight = taktTime; let limitGlobal = taktTime; if (targetProcessMode === 'FIXED') { const segsLeft = model.segments.filter(s => s.side === 'LEFT'); const segsRight = model.segments.filter(s => s.side === 'RIGHT'); const sumLeft = segsLeft.reduce((acc, s) => acc + calculateSegmentTime(s), 0); const sumRight = segsRight.reduce((acc, s) => acc + calculateSegmentTime(s), 0); if (separateSides) { limitLeft = fixedRobotsLeft > 0 ? Math.max(taktTime, (sumLeft / fixedRobotsLeft) * 1.1) : taktTime; limitRight = fixedRobotsRight > 0 ? Math.max(taktTime, (sumRight / fixedRobotsRight) * 1.1) : taktTime; } else { const totalSum = sumLeft + sumRight; const totalCount = fixedRobotsLeft + fixedRobotsRight; limitGlobal = totalCount > 0 ? Math.max(taktTime, (totalSum / totalCount) * 1.1) : taktTime; } } // Setup Overhead Calculation // For Robot: Tool Change + Fixture Rotation // For Manual: Handling Time (Torch pick up) + (Jig Rotation Time * Rotations) let overhead = 0; if (mode === 'ROBOT') { overhead = robotSettings.toolChangeTime + robotSettings.fixtureRotationTime; } else { overhead = manualSettings.handlingTime + (manualSettings.jigRotationTime * manualSettings.jigRotationsPerCycle); } sortedIds.forEach(id => { const seg = model.segments.find(s => s.id === id); if (!seg) return; const weldTime = seg.length / seg.travelSpeed; const techTime = mode === 'MANUAL' ? seg.approachTime : (robotSettings.arcDelay + (robotSettings.cleaningTime / robotSettings.cleaningInterval)); const liftTime = seg.liftTime || 0; const basicTime = weldTime + techTime + liftTime; // Cost const wireWeightKg = (seg.length / 1000) * (Math.pow(costSettings.wireDiameter/2, 2) * Math.PI * 7.85 / 1000); const gasUsageL = (weldTime / 60) * costSettings.gasFlowRate; const powerUsageKwh = (costSettings.powerSourceKw * (weldTime / 3600)); totalCost += ((wireWeightKg * costSettings.wirePricePerKg) + (gasUsageL * costSettings.gasPricePerLiter) + (powerUsageKwh * costSettings.electricPricePerKw)); const forcedProcessIdx = manualOverrides[id]; const preds = predecessors.get(id) || []; let minProcIndex = 0; for (const pId of preds) { const pIdx = taskProcessMap.get(pId); if (pIdx !== undefined) minProcIndex = Math.max(minProcIndex, pIdx); } let assignedIdx = -1; // Fit Function const tryAssign = (procIdx: number): boolean => { const proc = newProcesses[procIdx]; let moveTime = 0; let distTravelled = 0; // Calculate Move Time if (mode === 'ROBOT' && robotSettings.includeMovementTime) { const moveSpeedMM = robotSettings.moveSpeed * 1000; const safeMoveSpeed = moveSpeedMM > 0 ? moveSpeedMM : 1000; // prevent divide by zero const lastPos = processLastPos.get(procIdx); if (lastPos && seg.x !== undefined && seg.y !== undefined) { const distPct = Math.sqrt(Math.pow(seg.x - lastPos.x, 2) + Math.pow(seg.y - lastPos.y, 2)); const distMM = (distPct / 100) * modelWidthMM; moveTime = distMM / safeMoveSpeed; distTravelled = distMM; } else { moveTime = robotSettings.avgMoveDist / safeMoveSpeed; distTravelled = robotSettings.avgMoveDist; } if (robotSettings.optimizePath) moveTime *= 0.8; } const totalSegTime = basicTime + moveTime; let currentLimit = limitGlobal; if (targetProcessMode === 'FIXED') { if (separateSides) { const effectiveSide = proc.side === 'MIXED' ? seg.side : proc.side; currentLimit = effectiveSide === 'LEFT' ? limitLeft : limitRight; } else { currentLimit = limitGlobal; // Shared Pool Limit } } else if (targetProcessMode === 'AUTO') { currentLimit = taktTime; } if (proc.cycleTime + totalSegTime > currentLimit) return false; // Strict Side Check only if Separate Sides is ON if (separateSides && proc.side !== 'MIXED' && proc.side !== seg.side) return false; // Commit Assignment proc.weldIds.push(id); const startTime = proc.cycleTime; if (moveTime > 0) { proc.timeline.push({ type: 'MOVE', duration: moveTime, startTime: startTime, endTime: startTime + moveTime, fromId: proc.weldIds[proc.weldIds.length-2] || 'START', toId: id }); } const weldStart = startTime + moveTime; proc.timeline.push({ type: 'WELD', duration: techTime + weldTime, startTime: weldStart, endTime: weldStart + techTime + weldTime, targetId: id }); const liftStart = weldStart + techTime + weldTime; if (liftTime > 0) { proc.timeline.push({ type: 'IDLE', duration: liftTime, startTime: liftStart, endTime: liftStart + liftTime }); } proc.cycleTime += totalSegTime; proc.distanceTravelled = (proc.distanceTravelled || 0) + distTravelled; if (proc.side === 'MIXED') proc.side = seg.side; if (seg.x !== undefined && seg.y !== undefined) processLastPos.set(procIdx, {x: seg.x, y: seg.y}); totalTime += totalSegTime; return true; }; if (forcedProcessIdx !== undefined && forcedProcessIdx >= 0) { while (newProcesses.length <= forcedProcessIdx) { const tl: ScheduleItem[] = overhead > 0 ? [{type: 'SETUP', duration: overhead, startTime: 0, endTime: overhead}] : []; newProcesses.push({ id: newProcesses.length, weldIds: [], cycleTime: overhead, side: 'MIXED', distanceTravelled: 0, timeline: tl }); } const proc = newProcesses[forcedProcessIdx]; let moveTime = 0; if (mode === 'ROBOT' && robotSettings.includeMovementTime) { const moveSpeedMM = robotSettings.moveSpeed * 1000; const safeMoveSpeed = moveSpeedMM > 0 ? moveSpeedMM : 1000; const lastPos = processLastPos.get(forcedProcessIdx); if (lastPos && seg.x && seg.y) { const distPct = Math.sqrt(Math.pow(seg.x - lastPos.x, 2) + Math.pow(seg.y - lastPos.y, 2)); moveTime = (((distPct / 100) * modelWidthMM) / safeMoveSpeed) * (robotSettings.optimizePath ? 0.8 : 1); } else moveTime = (robotSettings.avgMoveDist / safeMoveSpeed); } const startTime = proc.cycleTime; if (moveTime > 0) proc.timeline.push({ type: 'MOVE', duration: moveTime, startTime: startTime, endTime: startTime + moveTime, fromId: proc.weldIds[proc.weldIds.length-1] || 'START', toId: id }); const weldStart = startTime + moveTime; proc.timeline.push({ type: 'WELD', duration: techTime + weldTime, startTime: weldStart, endTime: weldStart + techTime + weldTime, targetId: id }); const liftStart = weldStart + techTime + weldTime; if (liftTime > 0) proc.timeline.push({ type: 'IDLE', duration: liftTime, startTime: liftStart, endTime: liftStart + liftTime }); proc.weldIds.push(id); proc.cycleTime += (basicTime + moveTime); if (proc.side === 'MIXED') proc.side = seg.side; if (seg.x && seg.y) processLastPos.set(forcedProcessIdx, {x:seg.x, y:seg.y}); totalTime += (basicTime + moveTime); taskProcessMap.set(id, forcedProcessIdx); return; } for (let i = minProcIndex; i < newProcesses.length; i++) { if (tryAssign(i)) { assignedIdx = i; break; } } if (assignedIdx === -1) { assignedIdx = newProcesses.length; const tl: ScheduleItem[] = overhead > 0 ? [{type: 'SETUP', duration: overhead, startTime: 0, endTime: overhead}] : []; newProcesses.push({ id: assignedIdx, weldIds: [], cycleTime: overhead, side: 'MIXED', distanceTravelled: 0, timeline: tl }); tryAssign(assignedIdx); } taskProcessMap.set(id, assignedIdx); }); // --- SMART PATH OPTIMIZATION (POST-PROCESSING) --- if (mode === 'ROBOT') { newProcesses.forEach(proc => { // Optimization Logic if (robotSettings.optimizePath) { const originalIds = [...proc.weldIds]; if (originalIds.length > 1) { const optimizedIds: string[] = []; const remainingIds = [...originalIds]; let currentX = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].x; let currentY = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].y; while (remainingIds.length > 0) { const candidates = remainingIds.filter(id => { const preds = predecessors.get(id) || []; return preds.every(pId => optimizedIds.includes(pId) || !originalIds.includes(pId)); }); let bestId = candidates[0]; let minDist = Infinity; candidates.forEach(id => { const seg = model.segments.find(s => s.id === id); if (seg && seg.x !== undefined && seg.y !== undefined) { const dist = Math.sqrt(Math.pow(seg.x - currentX, 2) + Math.pow(seg.y - currentY, 2)); if (dist < minDist) { minDist = dist; bestId = id; } } }); if (!bestId) bestId = remainingIds[0]; optimizedIds.push(bestId); remainingIds.splice(remainingIds.indexOf(bestId), 1); const bestSeg = model.segments.find(s => s.id === bestId); if (bestSeg && bestSeg.x !== undefined && bestSeg.y !== undefined) { currentX = bestSeg.x; currentY = bestSeg.y; } } proc.weldIds = optimizedIds; } } // Re-calculate timeline completely proc.cycleTime = overhead; proc.timeline = overhead > 0 ? [{type: 'SETUP', duration: overhead, startTime: 0, endTime: overhead}] : []; let procLastX = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].x; let procLastY = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].y; proc.distanceTravelled = 0; proc.weldIds.forEach((id, idx) => { const seg = model.segments.find(s => s.id === id); if (!seg) return; const weldTime = seg.length / seg.travelSpeed; const techTime = robotSettings.arcDelay + (robotSettings.cleaningTime / robotSettings.cleaningInterval); const liftTime = seg.liftTime || 0; let moveTime = 0; if (robotSettings.includeMovementTime) { // Special Case: First move from Home/Base if (idx === 0 && robotSettings.useFixedHomeTimes) { moveTime = robotSettings.fixedApproachTime; // We can still calculate distance for tracking stats if needed, // but primarily override time here. if (seg.x !== undefined && seg.y !== undefined) { const distPct = Math.sqrt(Math.pow(seg.x - procLastX, 2) + Math.pow(seg.y - procLastY, 2)); const distMM = (distPct / 100) * modelWidthMM; proc.distanceTravelled = (proc.distanceTravelled || 0) + distMM; procLastX = seg.x; procLastY = seg.y; } } else { const moveSpeedMM = robotSettings.moveSpeed * 1000; const safeMoveSpeed = moveSpeedMM > 0 ? moveSpeedMM : 1000; if (seg.x !== undefined && seg.y !== undefined) { const distPct = Math.sqrt(Math.pow(seg.x - procLastX, 2) + Math.pow(seg.y - procLastY, 2)); const distMM = (distPct / 100) * modelWidthMM; moveTime = distMM / safeMoveSpeed; proc.distanceTravelled = (proc.distanceTravelled || 0) + distMM; procLastX = seg.x; procLastY = seg.y; } else { moveTime = robotSettings.avgMoveDist / safeMoveSpeed; proc.distanceTravelled = (proc.distanceTravelled || 0) + robotSettings.avgMoveDist; } } } const startTime = proc.cycleTime; if (moveTime > 0) proc.timeline.push({ type: 'MOVE', duration: moveTime, startTime: startTime, endTime: startTime + moveTime, fromId: idx === 0 ? 'START' : proc.weldIds[idx-1], toId: id }); const weldStart = startTime + moveTime; proc.timeline.push({ type: 'WELD', duration: techTime + weldTime, startTime: weldStart, endTime: weldStart + techTime + weldTime, targetId: id }); const liftStart = weldStart + techTime + weldTime; if (liftTime > 0) proc.timeline.push({ type: 'IDLE', duration: liftTime, startTime: liftStart, endTime: liftStart + liftTime }); proc.cycleTime += (moveTime + techTime + weldTime + liftTime); }); // --- RETURN TO HOME LOGIC --- if (robotSettings.includeReturnHome && proc.weldIds.length > 0) { const homeX = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].x; const homeY = modelBases[proc.side === 'RIGHT' ? 'right' : 'left'].y; let returnTime = 0; if (robotSettings.useFixedHomeTimes) { returnTime = robotSettings.fixedReturnTime; // Track distance if (robotSettings.includeMovementTime) { const distPct = Math.sqrt(Math.pow(homeX - procLastX, 2) + Math.pow(homeY - procLastY, 2)); const distMM = (distPct / 100) * modelWidthMM; proc.distanceTravelled = (proc.distanceTravelled || 0) + distMM; } } else if (robotSettings.includeMovementTime) { const moveSpeedMM = robotSettings.moveSpeed * 1000; const safeMoveSpeed = moveSpeedMM > 0 ? moveSpeedMM : 1000; const distPct = Math.sqrt(Math.pow(homeX - procLastX, 2) + Math.pow(homeY - procLastY, 2)); const distMM = (distPct / 100) * modelWidthMM; returnTime = distMM / safeMoveSpeed; proc.distanceTravelled = (proc.distanceTravelled || 0) + distMM; } if (returnTime > 0) { const startTime = proc.cycleTime; proc.timeline.push({ type: 'MOVE', duration: returnTime, startTime: startTime, endTime: startTime + returnTime, fromId: proc.weldIds[proc.weldIds.length - 1], toId: 'HOME' }); proc.cycleTime += returnTime; } } }); totalTime = newProcesses.reduce((acc, p) => acc + p.cycleTime, 0); } // --- COLLISION DETECTION --- let collisionDetected = false; if (mode === 'ROBOT') { const eventsInZone: {processId: number, startTime: number, endTime: number}[] = []; newProcesses.forEach(proc => { proc.timeline.forEach(item => { let isInZone = false; const seg = item.targetId ? model.segments.find(s=>s.id===item.targetId) : item.toId && item.toId !== 'HOME' ? model.segments.find(s=>s.id===item.toId) : null; if (seg && seg.x && seg.x >= robotSettings.interferenceZoneMinX && seg.x <= robotSettings.interferenceZoneMaxX) isInZone = true; if (isInZone) eventsInZone.push({ processId: proc.id, startTime: item.startTime, endTime: item.endTime }); }); }); for (let i = 0; i < eventsInZone.length; i++) { for (let j = i + 1; j < eventsInZone.length; j++) { const e1 = eventsInZone[i]; const e2 = eventsInZone[j]; if (e1.processId !== e2.processId && e1.startTime < e2.endTime && e2.startTime < e1.endTime) { collisionDetected = true; } } } } const maxCycleTime = newProcesses.length > 0 ? Math.max(...newProcesses.map(p => p.cycleTime)) : 0; const efficiency = newProcesses.length > 0 ? (totalTime / (newProcesses.length * maxCycleTime)) * 100 : 0; return { modelId: model.id, modelName: model.name, totalSegments: model.segments.length, totalTime, processes: newProcesses, maxCycleTime, efficiency, isBalanced: maxCycleTime <= taktTime, totalCost, collisionDetected }; }, [taktTime, calculateSegmentTime, separateSides, balancingAlgo, targetProcessMode, fixedRobotsLeft, fixedRobotsRight, mode, robotSettings, manualSettings, costSettings]); useEffect(() => { balAlgoRef.current = balancingAlgo; }, [balancingAlgo]); const currentStats = useMemo(() => calculateBalancingForModel(activeModel), [activeModel, calculateBalancingForModel]); const maxCycleTime = currentStats.maxCycleTime; const lineEfficiency = currentStats.efficiency; // Collision Real-time (Simple Check) const isCollisionNow = useMemo(() => { if (mode !== 'ROBOT' || !showInterferenceZone) return false; let count = 0; currentStats.processes.forEach(proc => { const active = proc.timeline.find(t => simTime >= t.startTime && simTime <= t.endTime); if (active && (active.targetId || active.toId)) { const segId = active.targetId || active.toId; const seg = segments.find(s => s.id === segId); if (seg && seg.x && seg.x >= robotSettings.interferenceZoneMinX && seg.x <= robotSettings.interferenceZoneMaxX) { count++; } } }); return count > 1; }, [simTime, currentStats, mode, showInterferenceZone, robotSettings, segments]); // Waste Data const wasteData = useMemo(() => { if (!currentStats) return []; let welding = 0, moving = 0, idle = 0, setup = 0; currentStats.processes.forEach(p => { p.timeline.forEach(t => { if (t.type === 'WELD') welding += t.duration; else if (t.type === 'MOVE') moving += t.duration; else if (t.type === 'IDLE') idle += t.duration; else if (t.type === 'SETUP') setup += t.duration; }); // Add idle time waiting for cycle completion if (p.cycleTime < maxCycleTime) idle += (maxCycleTime - p.cycleTime); }); return [ { name: 'Value Added (Weld)', value: welding, color: '#10b981' }, { name: 'Motion', value: moving, color: '#f59e0b' }, { name: 'Idle/Wait', value: idle, color: '#ef4444' }, { name: 'Setup/Change', value: setup, color: '#6366f1' } ].filter(d => d.value > 0); }, [currentStats, maxCycleTime]); // Yamazumi Data const yamazumiData = useMemo(() => { return currentStats.processes.map(proc => { const data: any = { name: `P${proc.id + 1}`, side: proc.side, dummy: 0 }; proc.timeline.forEach(t => { if (t.type === 'WELD' && t.targetId) { data[t.targetId] = t.duration; } else { data.dummy += t.duration; } }); return data; }); }, [currentStats]); // All Models Stats for Overview const allModelsStats = useMemo(() => models.map(m => calculateBalancingForModel(m)), [models, calculateBalancingForModel]); const minCycleTime = Math.min(...allModelsStats.map(m => m.maxCycleTime)); const minCost = Math.min(...allModelsStats.map(m => m.totalCost || 0)); const radarData = useMemo(() => { // Normalize based on best in class const bestEff = Math.max(...allModelsStats.map(s => s.efficiency)); const bestSpeed = minCycleTime; // lower is better const bestCost = minCost; // lower is better return allModelsStats.map(s => ({ subject: s.modelName, Efficiency: (s.efficiency / bestEff) * 100, Speed: (bestSpeed / s.maxCycleTime) * 100, Cost: (bestCost / (s.totalCost || 1)) * 100, fullMark: 100 })); }, [allModelsStats, minCycleTime, minCost]); // Unmapped Segments const unmappedSegments = useMemo(() => segments.filter(s => s.x === undefined || s.y === undefined), [segments]); // --- Actions --- const addSegment = () => { // Find max ID to ensure uniqueness even after deletions const maxIdNum = segments.reduce((max, seg) => { const match = seg.id.match(/^W(\d+)$/); return match ? Math.max(max, parseInt(match[1], 10)) : max; }, 0); const newId = `W${(maxIdNum + 1).toString().padStart(3, '0')}`; const newSeg: WeldSegment = { id: newId, name: `New Weld ${maxIdNum + 1}`, length: 100, travelSpeed: 10, approachTime: 2, liftTime: 0.5, position: 'FLAT', side: 'LEFT', groupId: 'G1' }; setSegments([...segments, newSeg]); }; const updateSegment = (id: string, field: keyof WeldSegment, value: any) => { setSegments(prev => prev.map(s => s.id === id ? { ...s, [field]: value } : s)); }; const removeSegment = (id: string) => { // Perform both updates on the model at once to avoid state overwrite race conditions const updatedModels = models.map(m => { if (m.id === activeModelId) { return { ...m, segments: m.segments.filter(s => s.id !== id), constraints: m.constraints.filter(c => c.firstWeldId !== id && c.secondWeldId !== id) }; } return m; }); handleModelUpdate(updatedModels); }; const duplicateSegment = (seg: WeldSegment) => { const maxIdNum = segments.reduce((max, s) => { const match = s.id.match(/^W(\d+)$/); return match ? Math.max(max, parseInt(match[1], 10)) : max; }, 0); const newId = `W${(maxIdNum + 1).toString().padStart(3, '0')}`; const newSeg = { ...seg, id: newId, name: `${seg.name} (Copy)`, x: (seg.x || 0) + 2, y: (seg.y || 0) + 2 }; setSegments([...segments, newSeg]); }; const addGroup = () => { const id = `G${weldGroups.length + 1}`; setWeldGroups([...weldGroups, { id, name: `Group ${id}`, color: PRESET_COLORS[weldGroups.length % PRESET_COLORS.length] }]); }; const updateGroup = (id: string, field: keyof WeldGroup, value: any) => { setWeldGroups(prev => prev.map(g => g.id === id ? { ...g, [field]: value } : g)); }; const removeGroup = (id: string) => { setWeldGroups(prev => prev.filter(g => g.id !== id)); }; const addPosition = () => { const id = `POS_${weldPositions.length + 1}`; setWeldPositions([...weldPositions, { id, label: 'New Position', factor: 0.1 }]); }; const updatePosition = (id: string, field: keyof WeldPositionDefinition, value: any) => { setWeldPositions(prev => prev.map(p => p.id === id ? { ...p, [field]: field === 'factor' ? Number(value) : value } : p)); }; const removePosition = (id: string) => { setWeldPositions(prev => prev.filter(p => p.id !== id)); }; const addConstraint = () => { const id = `C${constraints.length + 1}`; setConstraints([...constraints, { id, firstWeldId: '', secondWeldId: '', description: '' }]); }; const updateConstraint = (id: string, field: keyof WeldConstraint, value: any) => { setConstraints(prev => prev.map(c => c.id === id ? { ...c, [field]: value } : c)); }; const removeConstraint = (id: string) => { setConstraints(prev => prev.filter(c => c.id !== id)); }; const handleSaveNewModel = () => { if (!newModelName) return; let newModel: ModelData; if (copyFromId && copyFromId !== 'EMPTY') { const source = models.find(m => m.id === copyFromId); if (source) { newModel = JSON.parse(JSON.stringify(source)); newModel.id = `m${Date.now()}`; newModel.name = newModelName; } else { newModel = { ...models[0], id: `m${Date.now()}`, name: newModelName, segments: [] }; } } else { newModel = { ...models[0], id: `m${Date.now()}`, name: newModelName, segments: [] }; } setModels([...models, newModel]); setActiveModelId(newModel.id); setShowAddModal(false); setNewModelName(''); }; const clearManualOverrides = () => setManualOverrides({}); // --- Interaction Handlers --- const checkReachability = (seg: WeldSegment) => { if (!seg.x || !seg.y) return false; const base = seg.side === 'RIGHT' ? robotBases.right : robotBases.left; const distPct = Math.sqrt(Math.pow(seg.x - base.x, 2) + Math.pow(seg.y - base.y, 2)); const distMM = (distPct / 100) * frameWidthMM; return distMM <= robotSettings.maxReach; }; const getWeldStatus = (segId: string) => { // Find which process handles this weld const proc = currentStats.processes.find(p => p.weldIds.includes(segId)); if (!proc) return 'PENDING'; const weldItem = proc.timeline.find(t => t.type === 'WELD' && t.targetId === segId); if (!weldItem) return 'PENDING'; if (simTime >= weldItem.startTime && simTime <= weldItem.endTime) return 'WELDING'; if (simTime > weldItem.endTime) return 'COMPLETED'; // Check if moving to this weld const moveItem = proc.timeline.find(t => t.type === 'MOVE' && t.toId === segId); if (moveItem && simTime >= moveItem.startTime && simTime <= moveItem.endTime) return 'MOVING_TO'; return 'PENDING'; }; const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { const reader = new FileReader(); reader.onload = (evt) => { const updatedModels = models.map(m => m.id === activeModelId ? { ...m, frameImage: evt.target?.result as string } : m); handleModelUpdate(updatedModels); }; reader.readAsDataURL(file); } }; const handleZoom = (delta: number) => setZoomLevel(prev => Math.max(20, Math.min(300, prev + delta))); // Drag & Drop logic variables // const [draggingId, setDraggingId] = useState(null); // Already in state // const [draggingBase, setDraggingBase] = useState<'left'|'right'|null>(null); // Already in state const handleDragStart = (e: React.DragEvent, id: string) => { e.dataTransfer.setData('text/plain', id); setDraggingId(id); }; const handleFrameDrop = (e: React.DragEvent) => { e.preventDefault(); const id = e.dataTransfer.getData('text/plain'); if (!imageContainerRef.current) return; const rect = imageContainerRef.current.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; if (id) { setSegments(prev => prev.map(s => s.id === id ? { ...s, x, y } : s)); setDraggingId(null); } }; const handleFrameMouseMove = (e: React.MouseEvent) => { if (!imageContainerRef.current) return; const rect = imageContainerRef.current.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; if (draggingBase) { setRobotBases(prev => ({ ...prev, [draggingBase]: { x, y } })); } else if (draggingId && !unmappedSegments.find(s => s.id === draggingId)) { // Only if it's an existing point on map setSegments(prev => prev.map(s => s.id === draggingId ? { ...s, x, y } : s)); } }; const handleFrameMouseUp = () => { setDraggingId(null); setDraggingBase(null); }; const handlePointMouseDown = (e: React.MouseEvent, id: string) => { e.stopPropagation(); setDraggingId(id); }; const handlePointClick = (e: React.MouseEvent, id: string) => { e.stopPropagation(); if (isManualBalanceMode) { if (selectedManualProcess >= 0) { const currentOverrides = activeModel.manualOverrides || {}; // Toggle or Assign if (currentOverrides[id] === selectedManualProcess) { const newO = { ...currentOverrides }; delete newO[id]; setManualOverrides(newO); } else { setManualOverrides({ ...currentOverrides, [id]: selectedManualProcess }); } } else { // Auto mode (clear override) const newO = { ...activeModel.manualOverrides }; delete newO[id]; setManualOverrides(newO); } } else { setSelectedSegmentId(id); } }; const handlePointContextMenu = (e: React.MouseEvent, id: string, name: string) => { e.preventDefault(); // Optional: context menu }; const handleRobotBaseMouseDown = (e: React.MouseEvent, base: 'left' | 'right') => { e.stopPropagation(); setDraggingBase(base); }; const handleImageClick = (e: React.MouseEvent) => { if (isManualBalanceMode) return; // Feature: Click to Place Unmapped Weld if (selectedSegmentId) { const isUnmapped = unmappedSegments.find(s => s.id === selectedSegmentId); if (isUnmapped && imageContainerRef.current) { const rect = imageContainerRef.current.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; setSegments(prev => prev.map(s => s.id === selectedSegmentId ? { ...s, x, y } : s)); // Automatically select the next unmapped segment for rapid placement const currentIndex = unmappedSegments.findIndex(s => s.id === selectedSegmentId); if (currentIndex < unmappedSegments.length - 1) { setSelectedSegmentId(unmappedSegments[currentIndex + 1].id); } else { setSelectedSegmentId(null); } return; } } setSelectedSegmentId(null); }; const handleRenameModel = (id: string, name: string) => { setModels(prev => prev.map(m => m.id === id ? { ...m, name } : m)); }; const handleDeleteModel = (e: React.MouseEvent, id: string) => { e.stopPropagation(); if (models.length > 1) { if (confirm('Delete this model?')) { const remaining = models.filter(m => m.id !== id); setModels(remaining); if (activeModelId === id) setActiveModelId(remaining[0].id); } } else { alert('Cannot delete the last model.'); } }; const handleOpenAddModal = () => { setNewModelName(''); setCopyFromId('EMPTY'); setShowAddModal(true); }; // --- Render Helpers --- const renderInteractiveColumn = (proc: ProcessResult, maxVal: number, chartHeight: number) => { return (
Time: {proc.cycleTime.toFixed(1)}s ({(proc.cycleTime/taktTime*100).toFixed(0)}%)
{/* Stacked Bar */}
{/* Iterate timeline or weldIds to build stack */} {proc.weldIds.map((wid, idx) => { const seg = segments.find(s => s.id === wid); const h = seg ? ((calculateSegmentTime(seg) / proc.cycleTime) * 100) : 0; return (
{ e.dataTransfer.setData('text/plain', wid); setIsDragMode(true); }} > {wid}
); })} {/* Drop Zone Overlay */}
e.preventDefault()} onDrop={(e) => { e.preventDefault(); const draggedId = e.dataTransfer.getData('text/plain'); if (draggedId) { setManualOverrides({ ...(activeModel.manualOverrides || {}), [draggedId]: proc.id }); } }} />
P{proc.id + 1}
{proc.cycleTime > taktTime &&
+{ (proc.cycleTime - taktTime).toFixed(0) }s
}
); }; const renderProcessCard = (proc: ProcessResult, idx: number) => { const isOver = proc.cycleTime > taktTime; return (
P{proc.id + 1}

Process {proc.id + 1}

{proc.weldIds.length} welds • {proc.side}
{proc.cycleTime.toFixed(1)}s
Target: {taktTime}s
{proc.weldIds.map(wid => { const seg = segments.find(s => s.id === wid); return (
{wid} - {seg?.name} {seg ? calculateSegmentTime(seg).toFixed(1) : 0}s
); })}
); }; // --- Render (Modals) --- return (

WeldBalance Pro

setProjectName(e.target.value)} className="border rounded-lg px-4 py-2 font-medium text-slate-900 focus:ring-2 focus:ring-blue-500 outline-none w-64 text-sm" />
เวลา Takt Time
{taktTime}s
โมเดล: {models.map((m) => (
setActiveModelId(m.id)}> {activeModelId === m.id ? handleRenameModel(m.id, e.target.value)} onClick={(e) => e.stopPropagation()}/> : {m.name}} {models.length > 1 && }
))}
จุดเชื่อม: {segments.length} จุด
{[ { id: 'FRAME_VIEW', label: 'มุมมองภาพ (Frame View)', icon: ImageIcon }, { id: 'WELD_LIST', label: 'รายการเชื่อม (Weld List)', icon: Layers }, { id: 'GROUPS', label: 'กลุ่ม (Groups)', icon: Folder }, { id: 'STANDARD_TIMES', label: 'เวลามาตรฐาน (Std Times)', icon: Timer }, { id: 'CONSTRAINTS', label: 'เงื่อนไข (Constraints)', icon: LinkIcon }, { id: 'RESULTS', label: 'ผลลัพธ์ (Results)', icon: BarChart3 } ].map((tab) => ( ))}
{activeTab === 'WELD_LIST' && (
setTaktTime(Number(e.target.value))} className="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-2xl font-mono font-bold text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none" />
{mode === 'ROBOT' ? ( ) : ( )}
Balancing Scope:
setSeparateSides(!separateSides)}> Separate L/R
Shared Pool
{targetProcessMode === 'FIXED' && (

{mode === 'ROBOT' ? : } {mode === 'ROBOT' ? 'Robot Allocation' : 'Operator Allocation'}

{separateSides ? (
L
setFixedRobotsLeft(Math.max(0, Number(e.target.value)))} className="w-full text-2xl font-bold text-slate-900 dark:text-white outline-none border-b-2 border-slate-200 bg-transparent"/>
R
setFixedRobotsRight(Math.max(0, Number(e.target.value)))} className="w-full text-2xl font-bold text-slate-900 dark:text-white outline-none border-b-2 border-slate-200 bg-transparent"/>
) : (
{ setFixedRobotsLeft(Math.max(1, Number(e.target.value))); setFixedRobotsRight(0); }} className="w-full text-3xl font-bold outline-none border-b-2 border-slate-200 bg-transparent text-emerald-700"/>
)}
)}

รายการแนวเชื่อม (Weld Segments)

{segments.map((seg, idx) => ( setSelectedSegmentId(seg.id)} className={`cursor-pointer transition-colors ${selectedSegmentId === seg.id ? 'bg-blue-50 ring-1 ring-blue-500 z-10 relative' : 'hover:bg-slate-50'} print:hover:bg-white`}> ))}
#IDNameSideLen(mm)PosLift(s)Calc(s)Act
{idx + 1}
{seg.id} {!seg.x && No Map} {seg.x && !checkReachability(seg) &&
}
updateSegment(seg.id, 'name', e.target.value)} className="bg-transparent outline-none w-full" onClick={(e) => e.stopPropagation()}/> updateSegment(seg.id, 'length', Number(e.target.value))} className="bg-transparent outline-none w-20" onClick={(e) => e.stopPropagation()}/> updateSegment(seg.id, 'liftTime', Number(e.target.value))} className="bg-transparent outline-none w-16 text-center" onClick={(e) => e.stopPropagation()}/> {calculateSegmentTime(seg).toFixed(1)}
)} {activeTab === 'GROUPS' && (
{weldGroups.map((group) => (
{group.name.charAt(0)}
updateGroup(group.id, 'name', e.target.value)} className="font-bold bg-transparent outline-none border-b border-transparent focus:border-blue-500 text-slate-900"/>
updateGroup(group.id, 'color', e.target.value)} className="w-8 h-8 rounded cursor-pointer border-0 p-0 bg-transparent"/>
))}
)} {activeTab === 'STANDARD_TIMES' && (

Weld Position Standards

{weldPositions.map((pos) => ( ))}
Position NameTime Factor (s/mm)Action
updatePosition(pos.id, 'label', e.target.value)} className="w-full bg-transparent outline-none font-medium text-slate-900"/> updatePosition(pos.id, 'factor', e.target.value)} className="w-24 text-right bg-slate-100 rounded px-2 py-1 font-mono font-bold text-blue-600 outline-none focus:ring-2 focus:ring-blue-500"/>
)} {activeTab === 'CONSTRAINTS' && (

Sequence Constraints

{constraints.map((c, idx) => (
{idx+1}
))}
)} {activeTab === 'RESULTS' && (
{/* KPI Card 1: JPH */}
JPH
{maxCycleTime > 0 ? (3600 / maxCycleTime).toFixed(1) : 0}
Jobs Per Hour
{/* KPI Card 2: Output / Shift */}
Output
{(maxCycleTime > 0 ? (3600*8 / maxCycleTime) : 0).toFixed(0)}
Units per 8hr Shift
{/* KPI Card 3: Efficiency */}
Line Eff.
{lineEfficiency.toFixed(1)}%
Resource Utilization
{/* KPI Card 4: Cycle Time */}
Cycle Time
{maxCycleTime.toFixed(1)}s
Target: {taktTime}s
{/* Waste Analysis */}

Waste Analysis

{wasteData.length > 0 ? ( {wasteData.map((entry, index) => )} `${value.toFixed(1)}s`} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}/> ) : (
No data available
)}
Total Time
{(currentStats.totalTime).toFixed(0)}s
{/* Yamazumi Chart */}

Line Balance (Yamazumi)

{isDragMode && ( )}
{isDragMode ? (
{(() => { const maxVal = Math.max(taktTime * 1.2, currentStats.maxCycleTime * 1.1, 10); const chartHeight = 300; if (separateSides) { return (
LEFT LINE
{currentStats.processes.filter(p => p.side === 'LEFT').map(proc => renderInteractiveColumn(proc, maxVal, chartHeight))}
RIGHT LINE
{currentStats.processes.filter(p => p.side === 'RIGHT').map(proc => renderInteractiveColumn(proc, maxVal, chartHeight))}
); } else { return
{currentStats.processes.map(proc => renderInteractiveColumn(proc, maxVal, chartHeight))}
; } })()}
) : ( separateSides ? (

Left Line

d.side === 'LEFT')} margin={{top: 30, right: 10, left: 0, bottom: 5}}> { const maxVal = Math.max(dataMax || 0, taktTime); const step = maxVal > 100 ? 50 : 20; return Math.ceil((maxVal * 1.15) / step) * step; }]} allowDecimals={false} label={{ value: 'sec.', position: 'top', offset: 10, dy: -20, dx: 0, fill: '#334155', fontSize: 10, fontWeight: 'bold' }} /> {segments.map((seg, idx) => )}

Right Line

d.side === 'RIGHT')} margin={{top: 30, right: 10, left: 0, bottom: 5}}> { const maxVal = Math.max(dataMax || 0, taktTime); const step = maxVal > 100 ? 50 : 20; return Math.ceil((maxVal * 1.15) / step) * step; }]} allowDecimals={false} label={{ value: 'sec.', position: 'top', offset: 10, dy: -20, dx: 0, fill: '#334155', fontSize: 10, fontWeight: 'bold' }} /> {segments.map((seg, idx) => )}
) : (
{ const maxVal = Math.max(dataMax || 0, taktTime); const step = maxVal > 100 ? 50 : 20; return Math.ceil((maxVal * 1.15) / step) * step; }]} allowDecimals={false} label={{ value: 'sec.', position: 'top', offset: 10, dy: -20, dx: 0, fill: '#334155', fontSize: 10, fontWeight: 'bold' }} /> {segments.map((seg, idx) => )}
) )}
{separateSides ? (

Left Line Processes

{currentStats.processes.filter(p => p.side === 'LEFT').map((proc, idx) => renderProcessCard(proc, idx))}

Right Line Processes

{currentStats.processes.filter(p => p.side === 'RIGHT').map((proc, idx) => renderProcessCard(proc, idx))}
) : (
{currentStats.processes.map((proc, pIdx) => renderProcessCard(proc, pIdx))}
)}
)} {activeTab === 'FRAME_VIEW' && (
{/* --- Main Content Area (Image) --- */}
{!isManualBalanceMode && (<>)}
Width:setFrameWidthMM(Number(e.target.value))} className="w-16 bg-white border border-orange-300 rounded px-1 text-center outline-none"/>mm
{zoomLevel}%
{isManualBalanceMode && (
Assignment Tool:{[0, 1, 2, 3].map(idx => ())}
)} {selectedSegmentId && !isManualBalanceMode && unmappedSegments.find(s => s.id === selectedSegmentId) && (
Click anywhere on the image to place {unmappedSegments.find(s => s.id === selectedSegmentId)?.name}
)}
s.id === selectedSegmentId) ? 'cursor-crosshair' : 'cursor-default')}`} style={{ transform: `scale(${zoomLevel/100})` }} onClick={handleImageClick} onMouseMove={handleFrameMouseMove} onMouseUp={handleFrameMouseUp} onMouseLeave={handleFrameMouseUp} onContextMenu={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()} onDrop={handleFrameDrop} > {frameImage ? ( <> Frame
handleRobotBaseMouseDown(e, 'left')}>BASE L
handleRobotBaseMouseDown(e, 'right')}>BASE R
{segments.map((seg, idx) => { const status = getWeldStatus(seg.id); const isReachable = checkReachability(seg); let pointClass = 'bg-blue-600 border-white'; let scaleClass = ''; if (isManualBalanceMode) { const assignedProc = processes.find(p => p.weldIds.includes(seg.id)); pointClass = assignedProc ? `border-white ring-2 ring-white shadow-sm` : 'bg-slate-400 border-white opacity-50'; } else { if (status === 'WELDING') { pointClass = 'bg-red-500 border-red-200 welding-pulse z-30'; scaleClass = 'scale-150'; } else if (status === 'COMPLETED') { pointClass = 'bg-emerald-500 border-emerald-200'; } else if (status === 'MOVING_TO') { pointClass = 'bg-amber-400 border-white scale-125 z-20'; } else if (!isReachable && showReachZones) { pointClass = 'bg-slate-400 border-red-500 ring-2 ring-red-500 ring-offset-1 opacity-80'; } if (selectedSegmentId === seg.id) { pointClass = 'bg-orange-500 border-orange-200'; scaleClass = 'scale-110 ring-4 ring-orange-200/60'; } } const assignedProc = isManualBalanceMode ? processes.find(p => p.weldIds.includes(seg.id)) : null; return seg.x !== undefined && (
handlePointMouseDown(e, seg.id)} onClick={(e) => handlePointClick(e, seg.id)} onContextMenu={(e) => handlePointContextMenu(e, seg.id, seg.name)} title={`${seg.name}\n${isManualBalanceMode ? (assignedProc ? `Assigned to P${assignedProc.id+1}` : 'Unassigned') : status}`} > {idx + 1}
{selectedSegmentId === seg.id && !isManualBalanceMode && (
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
)}
); })} ) : (

Upload frame image to start

)}
{isCollisionNow &&
}
Process Simulation {isCollisionNow && ⚠ COLLISION WARNING}{simTime.toFixed(1)}s / {maxCycleTime.toFixed(1)}s
{ setSimTime(Number(e.target.value)); setIsPlaying(false); }} className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"/>
SPEED
{/* --- Unmapped Welds Sidebar --- */} {unmappedSegments.length > 0 && (

Unmapped Welds

{unmappedSegments.length} items missing coordinates. Click to select, then click on image to place.

{unmappedSegments.map((seg, idx) => { // Find original index in full segment list const originalIdx = segments.findIndex(s => s.id === seg.id); return (
handleDragStart(e, seg.id)} onClick={() => setSelectedSegmentId(seg.id)} className={`p-3 bg-white border rounded-lg shadow-sm hover:shadow-md cursor-pointer transition-all ${selectedSegmentId === seg.id ? 'ring-2 ring-blue-500 border-transparent bg-blue-50' : 'border-slate-200'}`} >
{originalIdx + 1} {seg.id}
{seg.name}
{selectedSegmentId === seg.id && (
Active: Click on map to place
)}
); })}
)}
)}
{/* Robot Settings Modal */} {showRobotSettingsModal && (

Robot Configuration

setRobotSettings({...robotSettings, moveSpeed: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>
setRobotSettings({...robotSettings, arcDelay: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>
setRobotSettings({...robotSettings, cleaningInterval: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>
Calculate Air Cut Time
Optimize Path (TSP)
Return to Home after Cycle
{/* New Home Position Timing Configuration */}
Home Position Timing
{robotSettings.useFixedHomeTimes ? (
setRobotSettings({...robotSettings, fixedApproachTime: Number(e.target.value)})} className="w-full border rounded px-2 py-1.5 text-sm font-mono focus:ring-1 focus:ring-indigo-500 outline-none"/> sec
Home → 1st Weld
setRobotSettings({...robotSettings, fixedReturnTime: Number(e.target.value)})} className="w-full border rounded px-2 py-1.5 text-sm font-mono focus:ring-1 focus:ring-indigo-500 outline-none"/> sec
Last Weld → Home
) : (
Timing calculated automatically based on distance & speed.
)}
)} {/* Manual Settings Modal */} {showManualSettingsModal && (

Manual Process Config

Initial Setup / Handling

Pick up torch, mask down, etc. (Per Cycle)
setManualSettings({...manualSettings, handlingTime: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>

Jig Operations

setManualSettings({...manualSettings, jigRotationTime: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>
setManualSettings({...manualSettings, jigRotationsPerCycle: Number(e.target.value)})} className="w-24 border rounded px-2 py-1 text-right font-mono"/>
Total Manual Overhead per Cycle:
{(manualSettings.handlingTime + (manualSettings.jigRotationTime * manualSettings.jigRotationsPerCycle)).toFixed(1)}s
This time is added as SETUP/IDLE time at the start of each process.
)} {/* Add Modal */} {showAddModal &&

New Welding Model

setNewModelName(e.target.value)} className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none" autoFocus/>
} {/* AI Modal */} {showAIModal &&

AI Weld Generation

Describe the welding points you want to add.
Example: "Add 4 welds on the left side, flat position, length 50mm each for brackets"

} {/* Overview Modal */} {showOverview && (
{/* Header */}

Model Overview & Comparison

Compare efficiency, cost, and balance across your scenarios.

{/* Left Column: Radar Chart & Summary */}

Performance Radar

Scores are normalized to 100 based on the best performing model in each category.
{/* Right Column: Model Cards Grid */}
{allModelsStats.map(stat => (
{ setActiveModelId(stat.modelId); setShowOverview(false); }} className={`group bg-white rounded-2xl p-6 shadow-sm border transition-all duration-200 relative cursor-pointer hover:shadow-lg hover:translate-y-[-2px] ${activeModelId === stat.modelId ? 'ring-2 ring-blue-500 border-transparent' : 'border-slate-200 hover:border-blue-300'}`}> {/* Badges */}
{stat.maxCycleTime === minCycleTime && ( FASTEST )} {stat.totalCost === minCost && ( CHEAPEST )} {activeModelId === stat.modelId && ( ACTIVE )}
{/* Card Header */}

{stat.modelName}

{stat.totalSegments} WELDS

{/* Stats Grid */}
Max Cycle Time taktTime ? 'text-red-500' : 'text-emerald-500'}`}> {stat.maxCycleTime.toFixed(1)}s
Efficiency {stat.efficiency.toFixed(1)}%
Est. Cost ${(stat.totalCost||0).toFixed(2)}
{/* Process Visualization (Simple Bars) */}

Process Times

{stat.processes.map((p, idx) => { const percentage = (p.cycleTime / (taktTime || 1)) * 100; const isOver = p.cycleTime > taktTime; return (
P{idx+1}
{/* Takt Line Marker */}
{p.cycleTime.toFixed(0)}s
); })}
{/* Hover Overlay for Switching */}
Switch to Model
))}
)}
); }; const container = document.getElementById('root'); if (container) { const root = createRoot(container); root.render( ); }