import React, { useState, useEffect, useRef } from 'react'; import { Play, Pause, Thermometer, FlaskConical, Wind, History, Trash2, Timer, Settings, BarChart3, Droplets, RefreshCw, Zap, FastForward, AlertCircle, Globe, Layers, CheckSquare, Square, X, RotateCw, Scale, Download } from 'lucide-react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; export default function ReactionRateLab() { // --- 語言設定 --- const [lang, setLang] = useState('zh'); const translations = { zh: { appTitle: "反應速率實驗室:氫氯酸 + 制酸劑藥片(主要成分:碳酸鈣)", designParams: "實驗變因", initialMass: "藥片質量 (固定)", temperature: "起始溫度", acidVol: "氫氯酸體積", acidConc: "氫氯酸濃度", surfaceArea: "藥片磨碎程度 (表面積)", stirringSpeed: "攪拌速度", rpm: "轉/分 (rpm)", simSpeed: "模擬速度", reset: "重設", start: "開始反應", stop: "停止", resetFirst: "請先重設", reactionCurve: "反應曲線", timeAxis: "時間 (s)", massAxis: "剩餘藥片質量 (g)", concAxis: "濃度 HCl (M)", tempAxis: "溫度 (°C)", remaining: "剩餘", finished: "反應結束", progress: "反應進度", history: "實驗紀錄 (勾選以比較)", clearAll: "全部清除", confirmClear: "確定清除?", noRecords: "尚無紀錄", colCount: "次數", colTemp: "溫度", colVol: "體積", colConc: "濃度", colArea: "表面積", colStir: "攪拌", colTime: "時間", colRate: "平均速率", colSelect: "比較", colAction: "刪除", yes: "有", no: "無", currentRun: "目前實驗", trial: "實驗", btnMass: "質量", btnConc: "濃度", btnTemp: "溫度", exportCSV: "導出 CSV" // 新增翻譯 }, en: { appTitle: "Reaction Rate Lab: HCl + Antacid Tablet (CaCO₃)", designParams: "Parameters", initialMass: "Tablet Mass (Fixed)", temperature: "Temperature", acidVol: "HCl Volume", acidConc: "HCl Concentration", surfaceArea: "Tablet Crushing (Surface Area)", stirringSpeed: "Stirring Speed", rpm: "rpm", simSpeed: "Sim Speed", reset: "Reset", start: "Start", stop: "Stop", resetFirst: "Reset First", reactionCurve: "Reaction Curve", timeAxis: "Time (s)", massAxis: "Remaining Tablet (g)", concAxis: "Conc. HCl (M)", tempAxis: "Temp (°C)", remaining: "Remaining", finished: "Done", progress: "Progress", history: "History (Select to Compare)", clearAll: "Clear All", confirmClear: "Confirm?", noRecords: "No records yet", colCount: "Trial", colTemp: "Temp", colVol: "Vol", colConc: "Conc", colArea: "Area", colStir: "Stir", colTime: "Time", colRate: "Avg Rate", colSelect: "Compare", colAction: "Del", yes: "Yes", no: "No", currentRun: "Current Run", trial: "Trial", btnMass: "Mass", btnConc: "Conc.", btnTemp: "Temp.", exportCSV: "Export CSV" // 新增翻譯 } }; const t = (key) => translations[lang][key]; // --- 狀態管理 --- const INITIAL_MASS = 5.0; const [temperature, setTemperature] = useState(25); const [acidVolume, setAcidVolume] = useState(100); const [acidConcentration, setAcidConcentration] = useState(1.0); const [surfaceArea, setSurfaceArea] = useState(20); // 攪拌速度 (0-500 rpm) const [stirringSpeed, setStirringSpeed] = useState(0); const [speed, setSpeed] = useState(1); const [isRunning, setIsRunning] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [reactedAmount, setReactedAmount] = useState(0); const [simulationData, setSimulationData] = useState([]); const [finalTime, setFinalTime] = useState(null); // 圖表顯示控制 const [graphType, setGraphType] = useState('mass'); // 'mass', 'conc', 'temp' // 多選比較功能 const [selectedHistoryIds, setSelectedHistoryIds] = useState([]); // 氣泡系統專用狀態 const [bubbles, setBubbles] = useState([]); const bubbleIdCounter = useRef(0); const rateRef = useRef(0); // 隨機誤差因子 (Random Error Factor) const randomErrorRef = useRef(1.0); const [history, setHistory] = useState([]); const [confirmClear, setConfirmClear] = useState(false); const intervalRef = useRef(null); const bubbleIntervalRef = useRef(null); const confirmTimeoutRef = useRef(null); const isFinished = !isRunning && finalTime !== null; const hasStarted = simulationData.length > 0; // 比較用的顏色庫 const COMPARE_COLORS = [ '#ea580c', // Orange '#0891b2', // Cyan '#db2777', // Pink '#65a30d', // Lime '#7c3aed', // Violet '#059669', // Emerald ]; // --- 抑制 ResizeObserver 錯誤 --- useEffect(() => { const originalError = console.error; console.error = (...args) => { if (/ResizeObserver loop completed with undelivered notifications/.test(args[0])) { return; } originalError.call(console, ...args); }; return () => { console.error = originalError; }; }, []); // --- 化學反應速率計算 (含隨機誤差) --- const calculateInstantRate = (currentReacted) => { let k = 0.05; const tempFactor = Math.pow(1.8, (temperature - 20) / 10); const concFactor = Math.pow(acidConcentration, 1.5); // 攪拌因子 const stirFactor = 1.0 + (Math.log10(stirringSpeed + 1) / Math.log10(501)) * 1.5; const areaFactor = 1.0 + (surfaceArea / 100) * 4.0; const remainingRatio = (INITIAL_MASS - currentReacted) / INITIAL_MASS; const surfaceFactor = Math.max(0.0, Math.pow(remainingRatio, 0.66)); // 應用隨機誤差因子 return k * tempFactor * concFactor * stirFactor * areaFactor * surfaceFactor * randomErrorRef.current; }; // --- 計算當前濃度 --- const calculateCurrentConcentration = (reactedMass) => { const molesCaCO3Reacted = reactedMass / 100.09; const molesHClConsumed = molesCaCO3Reacted * 2; const initialMolesHCl = (acidVolume / 1000) * acidConcentration; const remainingMolesHCl = Math.max(0, initialMolesHCl - molesHClConsumed); return remainingMolesHCl / (acidVolume / 1000); }; // --- 主模擬迴圈 (Physics Loop) --- useEffect(() => { if (isRunning) { intervalRef.current = setInterval(() => { const timeStep = 0.1 * speed; setCurrentTime((prevTime) => { const newTime = prevTime + timeStep; setReactedAmount((prevReacted) => { const instantRate = calculateInstantRate(prevReacted); rateRef.current = instantRate; let stepReact = instantRate * timeStep; let newReacted = prevReacted + stepReact; if (newReacted >= INITIAL_MASS) { newReacted = INITIAL_MASS; finishSimulation(newTime); } else { const recordInterval = speed >= 5 ? 1.0 : 0.2; if (Math.abs(newTime % recordInterval) < timeStep) { const currentConc = calculateCurrentConcentration(newReacted); const noise = (Math.random() - 0.5) * 0.2; const tempRise = (newReacted / INITIAL_MASS) * 2.5; setSimulationData(prevData => [ ...prevData, { time: parseFloat(newTime.toFixed(1)), remaining: parseFloat(Math.max(0, INITIAL_MASS - newReacted).toFixed(2)), conc: parseFloat(currentConc.toFixed(3)), temp: parseFloat((temperature + tempRise + noise).toFixed(1)) } ]); } } return newReacted; }); return newTime; }); }, 100); } else { rateRef.current = 0; } return () => clearInterval(intervalRef.current); }, [isRunning, INITIAL_MASS, temperature, acidVolume, acidConcentration, surfaceArea, stirringSpeed, speed]); // --- 氣泡生成系統 --- useEffect(() => { if (isRunning) { bubbleIntervalRef.current = setInterval(() => { const currentRate = rateRef.current; if (currentRate <= 0) return; const baseSpawnRate = currentRate * 5; const spawnCount = Math.floor(baseSpawnRate) + (Math.random() < (baseSpawnRate % 1) ? 1 : 0); const MAX_BUBBLES = 150; if (spawnCount > 0) { setBubbles(prev => { const now = Date.now(); const cleanPrev = prev.filter(b => now - b.createdAt < 3000); if (cleanPrev.length > MAX_BUBBLES) return cleanPrev; const newBubbles = []; for (let i = 0; i < spawnCount; i++) { bubbleIdCounter.current += 1; const size = 6 + Math.random() * 10; const duration = (2.0 + Math.random()) * (10 / size) / Math.sqrt(speed); // 攪拌對氣泡路徑的影響 const stirEffectX = (stirringSpeed / 500) * 100 * (Math.random() - 0.5); newBubbles.push({ id: bubbleIdCounter.current, createdAt: now, left: 5 + Math.random() * 90, size: size, duration: duration, delay: Math.random() * 0.2, wiggle: (Math.random() - 0.5) * 40 + stirEffectX, opacity: 0.4 + Math.random() * 0.5 }); } return [...cleanPrev, ...newBubbles]; }); } else { setBubbles(prev => { const now = Date.now(); if (prev.length > 0 && now - prev[0].createdAt > 3000) { return prev.filter(b => now - b.createdAt < 3000); } return prev; }); } }, 50); } else { setBubbles([]); } return () => clearInterval(bubbleIntervalRef.current); }, [isRunning, speed, stirringSpeed]); useEffect(() => { return () => { if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current); }; }, []); const startSimulation = () => { if (isRunning) return; if (isFinished) return; // 初始化數據 if (simulationData.length === 0) { randomErrorRef.current = 0.95 + Math.random() * 0.10; setSimulationData([{ time: 0, remaining: INITIAL_MASS, conc: acidConcentration, temp: temperature }]); } setIsRunning(true); }; const stopSimulation = () => { clearInterval(intervalRef.current); clearInterval(bubbleIntervalRef.current); setIsRunning(false); }; const finishSimulation = (endTime) => { clearInterval(intervalRef.current); clearInterval(bubbleIntervalRef.current); setIsRunning(false); setFinalTime(endTime); const finalConc = calculateCurrentConcentration(INITIAL_MASS); const finalTemp = temperature + 2.5; const finalDataPoint = { time: parseFloat(endTime.toFixed(1)), remaining: 0, conc: parseFloat(finalConc.toFixed(3)), temp: parseFloat(finalTemp.toFixed(1)) }; setSimulationData(prev => { const newData = [...prev, finalDataPoint]; // 儲存至歷史紀錄 setHistory(hPrev => [{ id: Date.now(), count: hPrev.length + 1, temp: temperature, vol: acidVolume, conc: acidConcentration, area: surfaceArea, stir: stirringSpeed, time: endTime.toFixed(1), fullData: newData, // 儲存完整數據以供後續計算不同速率 errorFactor: randomErrorRef.current }, ...hPrev]); return newData; }); }; const resetSimulation = () => { clearInterval(intervalRef.current); clearInterval(bubbleIntervalRef.current); setIsRunning(false); setCurrentTime(0); setReactedAmount(0); setSimulationData([]); setBubbles([]); setFinalTime(null); }; const deleteHistoryItem = (id, e) => { e.stopPropagation(); setHistory(prev => prev.filter(item => item.id !== id)); setSelectedHistoryIds(prev => prev.filter(sid => sid !== id)); }; const toggleHistorySelection = (id) => { setSelectedHistoryIds(prev => { if (prev.includes(id)) return prev.filter(sid => sid !== id); return [...prev, id]; }); }; const handleClearAllClick = () => { if (confirmClear) { setHistory([]); setSelectedHistoryIds([]); setConfirmClear(false); if (confirmTimeoutRef.current) clearTimeout(confirmTimeoutRef.current); } else { setConfirmClear(true); confirmTimeoutRef.current = setTimeout(() => { setConfirmClear(false); }, 3000); } }; // --- 導出 CSV 功能 --- const exportToCSV = () => { if (history.length === 0) return; // 定義 CSV 表頭 const headers = [ t('colCount'), t('colTemp') + " (°C)", t('colVol') + " (ml)", t('colConc') + " (M)", t('colArea') + " (cm²/g)", t('colStir') + " (rpm)", t('colTime') + " (s)", t('colRate') + " (g/s)" ]; // 轉換數據行 const rows = history.map(item => { const duration = parseFloat(item.time); const rate = duration > 0 ? (INITIAL_MASS / duration).toFixed(4) : "0"; return [ item.count, item.temp, item.vol, item.conc, item.area, item.stir, item.time, rate ]; }); // 組合 CSV 內容 // \uFEFF 是 BOM (Byte Order Mark),讓 Excel 能正確識別 UTF-8 編碼的中文 const csvContent = "\uFEFF" + [ headers.join(","), ...rows.map(r => r.join(",")) ].join("\n"); // 建立下載連結 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.setAttribute("href", url); link.setAttribute("download", `reaction_lab_data_${new Date().toISOString().slice(0,10)}.csv`); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; // --- 決定圖表 Y 軸設定 --- const getGraphConfig = () => { switch(graphType) { case 'conc': return { dataKey: 'conc', label: t('concAxis'), domain: [0, 3.0], unit: 'M' }; case 'temp': return { dataKey: 'temp', label: t('tempAxis'), domain: ['dataMin - 2', 'dataMax + 2'], unit: '°C' }; case 'mass': default: return { dataKey: 'remaining', label: t('massAxis'), domain: [0, INITIAL_MASS], unit: 'g' }; } }; const graphConfig = getGraphConfig(); // --- 計算並格式化要在表格中顯示的速率 --- const getRateDisplay = (record) => { const duration = parseFloat(record.time); if (!duration || duration === 0) return "-"; if (graphType === 'conc') { const finalConc = record.fullData[record.fullData.length - 1]?.conc || 0; const rate = (record.conc - finalConc) / duration; return ( <> {rate.toFixed(4)} M/s ); } else if (graphType === 'temp') { const finalTemp = record.fullData[record.fullData.length - 1]?.temp || record.temp; const rate = (finalTemp - record.temp) / duration; return ( <> {rate.toFixed(3)} °C/s ); } else { const rate = INITIAL_MASS / duration; return ( <> {rate.toFixed(3)} g/s ); } }; // --- 格式化剩餘質量顯示 --- const getFormattedMass = () => { const remaining = INITIAL_MASS - reactedAmount; if (remaining <= 0) return "0.00"; if (remaining < 0.01) return "< 0.01"; return remaining.toFixed(2); }; // --- 視覺渲染 (液體與粒子) --- const getLiquidColor = () => { const opacity = 0.15 + (acidConcentration / 3.0) * 0.25; return `rgba(180, 215, 250, ${opacity})`; }; const getLiquidHeight = () => { const minHeight = 40; const maxHeight = 90; const percentage = minHeight + ((acidVolume - 50) / 150) * (maxHeight - minHeight); return `${percentage}%`; }; const renderParticles = () => { const remainingRatio = Math.max(0, (INITIAL_MASS - reactedAmount) / INITIAL_MASS); if (remainingRatio <= 0) return null; const baseCount = 40; const maxCount = 200; const totalParticles = Math.floor(baseCount + (surfaceArea / 100) * (maxCount - baseCount)); const currentParticles = Math.ceil(totalParticles * remainingRatio); const maxSize = 10; const minSize = 2; let baseParticleSize = Math.max(minSize, maxSize - (surfaceArea / 100) * (maxSize - minSize)); baseParticleSize = baseParticleSize * (0.5 + 0.5 * remainingRatio); const particles = []; const seededRandom = (seed) => { const x = Math.sin(seed++) * 10000; return x - Math.floor(x); }; for (let i = 0; i < currentParticles; i++) { const borderRadius = surfaceArea > 80 ? '50%' : '2px'; const randomX = seededRandom(i * 13); const baseLeft = 10 + randomX * 80; const randomY = seededRandom(i * 7); const stackHeight = 20; const normalizedIndex = i / totalParticles; const heightOffset = (randomY * 0.4 + normalizedIndex * 0.6) * stackHeight; const baseTop = 94 - heightOffset; let animationStyle = {}; // 根據 rpm 決定是否 swirl if (isRunning && stirringSpeed > 0) { const radius = 20 + seededRandom(i) * 50; const speedFactor = 1 + (temperature / 100) * 0.5; const visualSpeed = Math.min(speed, 3); // 攪拌越快,粒子旋轉越快 const rpmFactor = stirringSpeed / 200; const duration = ((1.2 + seededRandom(i * 2) * 1.5) / speedFactor) / (visualSpeed * rpmFactor); const delay = seededRandom(i * 3) * -5; animationStyle = { animation: `swirl ${Math.max(0.2, duration)}s linear infinite`, animationDelay: `${delay}s`, '--swirl-radius': `${radius}px`, '--swirl-y': `${(seededRandom(i*4) - 0.5) * 80}px`, transformBox: 'fill-box', left: '50%', top: '55%', }; } else { animationStyle = { left: `${baseLeft}%`, top: `${baseTop}%`, transition: `all ${0.8 / speed}s ease-out` }; } particles.push(
); } return particles; }; return (
{/* 頂部標題列 */}

{t('appTitle')}

{/* 主要內容區 Grid */}
{/* 左側:設計參數 */}

{t('designParams')}

{/* 初始質量顯示 (固定) */}
{INITIAL_MASS.toFixed(1)}g
{/* 明顯的分割線 */}
{/* 溫度拉桿 */}
{temperature}°C
setTemperature(parseInt(e.target.value))} disabled={hasStarted} className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-red-500 hover:accent-red-600 transition-all disabled:opacity-50" />
{/* 酸體積拉桿 */}
{acidVolume}ml
setAcidVolume(parseInt(e.target.value))} disabled={hasStarted} className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:accent-blue-600 transition-all disabled:opacity-50" />
{/* 酸濃度拉桿 */}
{acidConcentration.toFixed(1)}M
setAcidConcentration(parseFloat(e.target.value))} disabled={hasStarted} className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-500 hover:accent-emerald-600 transition-all disabled:opacity-50" />
{/* 表面積拉桿 (藥片磨碎程度) */}
{surfaceArea} cm²/g
setSurfaceArea(parseInt(e.target.value))} disabled={hasStarted} className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-stone-500 hover:accent-stone-600 transition-all disabled:opacity-50" />
{/* 攪拌速度控制 (RPM Slider) */}
{stirringSpeed} {t('rpm')}
setStirringSpeed(parseInt(e.target.value))} disabled={hasStarted} className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-500 hover:accent-indigo-600 transition-all disabled:opacity-50" />
{/* 速度控制 */}
{[1, 5, 10].map((s) => ( ))}
{/* 底部按鈕區 */}
{/* 中間:實驗視覺化 */}
{speed > 1 && (
{speed}x
)}
{/* 攪拌棒視覺效果 */} {stirringSpeed > 0 && (
)}
{temperature > 60 && (
)} {renderParticles()} {bubbles.map(b => (
))}
{[...Array(6)].map((_, i) =>
)}
{currentTime.toFixed(1)}s
{temperature}°C {acidVolume}ml {acidConcentration}M
{/* 右側:圖表 */}

{t('reactionCurve')}

{/* 圖表類型切換 Tabs */}
[`${val}${graphConfig.unit}`, name]} labelFormatter={(l) => `${l}s`} /> {/* 當前實驗曲線 */} {simulationData.length > 0 && ( )} {/* 勾選的歷史紀錄曲線 */} {history.filter(h => selectedHistoryIds.includes(h.id)).map((h, index) => { const color = COMPARE_COLORS[index % COMPARE_COLORS.length]; return ( ); })}
{t('massAxis')}
{getFormattedMass()} g
{t('progress')}
{(reactedAmount / INITIAL_MASS * 100).toFixed(0)}%
{/* 底部:歷史紀錄 */}

{t('history')}

{history.length > 0 && ( )} {history.length > 0 && ( )}
{history.map((r, index) => { const isSelected = selectedHistoryIds.includes(r.id); // 取得對應的顏色 const rowColor = isSelected ? COMPARE_COLORS[history.filter(h => selectedHistoryIds.includes(h.id)).findIndex(h => h.id === r.id) % COMPARE_COLORS.length] : 'transparent'; return ( )})} {history.length === 0 && ( )}
{t('colCount')} {t('colTemp')} {t('colVol')} {t('colConc')} {t('colArea')} {t('colStir')} {t('colTime')} {t('colRate')} {t('colSelect')} {t('colAction')}
{r.count} {r.temp}°C {r.vol}ml {r.conc}M {r.area} cm²/g {r.stir} rpm {r.time}s {getRateDisplay(r)}
{t('noRecords')}
); }