Spaces:
Running
Running
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Sơ Đồ Dòng Điện - Trình Mô Phỏng Tương Tác</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: #0f172a; | |
| color: #e2e8f0; | |
| overflow-x: hidden; | |
| } | |
| .mono { | |
| font-family: 'JetBrains Mono', monospace; | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #1e293b; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #475569; | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #64748b; | |
| } | |
| /* Glassmorphism */ | |
| .glass { | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| /* Neon Glows */ | |
| .glow-text { | |
| text-shadow: 0 0 10px rgba(56, 189, 248, 0.5); | |
| } | |
| .glow-border { | |
| box-shadow: 0 0 15px rgba(56, 189, 248, 0.2); | |
| } | |
| /* Canvas Container */ | |
| #canvas-container { | |
| background-image: | |
| radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 0); | |
| background-size: 20px 20px; | |
| cursor: crosshair; | |
| } | |
| /* Range Slider Styling */ | |
| input[type=range] { | |
| -webkit-appearance: none; | |
| background: transparent; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| height: 16px; | |
| width: 16px; | |
| border-radius: 50%; | |
| background: #38bdf8; | |
| cursor: pointer; | |
| margin-top: -6px; | |
| box-shadow: 0 0 10px #38bdf8; | |
| } | |
| input[type=range]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 4px; | |
| cursor: pointer; | |
| background: #334155; | |
| border-radius: 2px; | |
| } | |
| /* Animations */ | |
| @keyframes pulse-ring { | |
| 0% { transform: scale(0.8); opacity: 0.5; } | |
| 100% { transform: scale(1.2); opacity: 0; } | |
| } | |
| .animate-pulse-ring::before { | |
| content: ''; | |
| position: absolute; | |
| left: 0; top: 0; right: 0; bottom: 0; | |
| border-radius: 50%; | |
| border: 2px solid #38bdf8; | |
| animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite; | |
| } | |
| .component-btn { | |
| transition: all 0.2s ease; | |
| } | |
| .component-btn:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.3); | |
| border-color: #38bdf8; | |
| } | |
| .component-btn.active { | |
| background: #38bdf8; | |
| color: #0f172a; | |
| border-color: #38bdf8; | |
| } | |
| </style> | |
| </head> | |
| <body class="h-screen flex flex-col"> | |
| <!-- Header --> | |
| <header class="h-16 border-b border-slate-700 bg-slate-900/90 flex items-center justify-between px-6 z-20 relative"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 bg-blue-500 rounded flex items-center justify-center shadow-[0_0_15px_rgba(59,130,246,0.5)]"> | |
| <i class="fa-solid fa-bolt text-white"></i> | |
| </div> | |
| <h1 class="text-xl font-bold tracking-tight text-white glow-text">Circuit<span class="text-blue-400">Sim</span> <span class="text-xs font-normal text-slate-400 ml-2 border border-slate-700 px-2 py-0.5 rounded">v2.0</span></h1> | |
| </div> | |
| <div class="hidden md:flex items-center gap-6 text-sm text-slate-400"> | |
| <div class="flex items-center gap-2"> | |
| <span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> | |
| <span>Hệ thống sẵn sàng</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="hover:text-blue-400 transition-colors text-xs border-b border-dashed border-slate-600 pb-0.5"> | |
| Built with anycoder | |
| </a> | |
| </div> | |
| <div class="flex gap-3"> | |
| <button onclick="app.clearCanvas()" class="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 rounded transition-colors"> | |
| <i class="fa-solid fa-trash-can mr-2"></i>Xóa | |
| </button> | |
| <button onclick="app.toggleSimulation()" id="sim-btn" class="px-4 py-2 text-sm font-bold bg-blue-600 hover:bg-blue-500 text-white rounded shadow-[0_0_15px_rgba(37,99,235,0.4)] transition-all flex items-center gap-2"> | |
| <i class="fa-solid fa-play"></i> <span id="sim-text">Chạy Mô Phỏng</span> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Workspace --> | |
| <div class="flex-1 flex overflow-hidden relative"> | |
| <!-- Left Sidebar: Components --> | |
| <aside class="w-64 bg-slate-900 border-r border-slate-700 flex flex-col z-10"> | |
| <div class="p-4 border-b border-slate-800"> | |
| <h2 class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Thư viện linh kiện</h2> | |
| <p class="text-[10px] text-slate-600">Kéo thả hoặc click để thêm</p> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-4 space-y-3"> | |
| <!-- Component Items --> | |
| <div class="component-btn group cursor-pointer bg-slate-800 p-3 rounded border border-slate-700 flex items-center gap-3" onclick="app.setTool('wire')"> | |
| <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center group-hover:bg-slate-600"> | |
| <i class="fa-solid fa-minus text-slate-300"></i> | |
| </div> | |
| <div> | |
| <div class="text-sm font-medium text-slate-200">Dây dẫn</div> | |
| <div class="text-[10px] text-slate-500">Nối các linh kiện</div> | |
| </div> | |
| </div> | |
| <div class="component-btn group cursor-pointer bg-slate-800 p-3 rounded border border-slate-700 flex items-center gap-3" onclick="app.setTool('resistor')"> | |
| <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center group-hover:bg-slate-600"> | |
| <span class="font-serif font-bold text-slate-300">R</span> | |
| </div> | |
| <div> | |
| <div class="text-sm font-medium text-slate-200">Điện trở</div> | |
| <div class="text-[10px] text-slate-500">Cản trở dòng điện</div> | |
| </div> | |
| </div> | |
| <div class="component-btn group cursor-pointer bg-slate-800 p-3 rounded border border-slate-700 flex items-center gap-3" onclick="app.setTool('battery')"> | |
| <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center group-hover:bg-slate-600 text-yellow-400"> | |
| <i class="fa-solid fa-car-battery"></i> | |
| </div> | |
| <div> | |
| <div class="text-sm font-medium text-slate-200">Nguồn điện</div> | |
| <div class="text-[10px] text-slate-500">Cung cấp hiệu điện thế</div> | |
| </div> | |
| </div> | |
| <div class="component-btn group cursor-pointer bg-slate-800 p-3 rounded border border-slate-700 flex items-center gap-3" onclick="app.setTool('bulb')"> | |
| <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center group-hover:bg-slate-600 text-yellow-200"> | |
| <i class="fa-regular fa-lightbulb"></i> | |
| </div> | |
| <div> | |
| <div class="text-sm font-medium text-slate-200">Bóng đèn</div> | |
| <div class="text-[10px] text-slate-500">Tải tiêu thụ điện</div> | |
| </div> | |
| </div> | |
| <div class="component-btn group cursor-pointer bg-slate-800 p-3 rounded border border-slate-700 flex items-center gap-3" onclick="app.setTool('switch')"> | |
| <div class="w-8 h-8 rounded bg-slate-700 flex items-center justify-center group-hover:bg-slate-600 text-green-400"> | |
| <i class="fa-solid fa-toggle-off"></i> | |
| </div> | |
| <div> | |
| <div class="text-sm font-medium text-slate-200">Công tắc</div> | |
| <div class="text-[10px] text-slate-500">Đóng/ngắt mạch</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-slate-800 text-[10px] text-slate-600 text-center"> | |
| Click để chọn công cụ<br>Double click linh kiện để xóa | |
| </div> | |
| </aside> | |
| <!-- Center: Canvas --> | |
| <main class="flex-1 relative bg-slate-950" id="canvas-wrapper"> | |
| <div id="canvas-container" class="w-full h-full relative"> | |
| <canvas id="circuitCanvas"></canvas> | |
| <!-- Floating Info Overlay --> | |
| <div class="absolute top-4 left-4 glass px-4 py-2 rounded text-xs text-slate-300 pointer-events-none select-none"> | |
| <div class="flex items-center gap-2 mb-1"> | |
| <div class="w-2 h-2 rounded-full bg-yellow-400"></div> | |
| <span>Dòng điện chạy: + -> -</span> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <div class="w-2 h-2 rounded-full bg-blue-400"></div> | |
| <span>Electron chạy: - -> +</span> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Right Sidebar: Properties --> | |
| <aside class="w-72 bg-slate-900 border-l border-slate-700 flex flex-col z-10 transition-transform duration-300" id="properties-panel"> | |
| <div class="p-4 border-b border-slate-800"> | |
| <h2 class="text-xs font-bold text-slate-500 uppercase tracking-wider">Thông số kỹ thuật</h2> | |
| </div> | |
| <div id="empty-state" class="flex-1 flex flex-col items-center justify-center text-slate-600 p-6 text-center"> | |
| <i class="fa-solid fa-arrow-pointer text-3xl mb-3 opacity-50"></i> | |
| <p class="text-sm">Chọn một linh kiện trên sơ đồ để chỉnh sửa thông số</p> | |
| </div> | |
| <div id="properties-content" class="hidden flex-1 overflow-y-auto p-4 space-y-6"> | |
| <!-- Dynamic Content Injected Here --> | |
| <div> | |
| <label class="block text-xs font-medium text-slate-400 mb-2">Loại linh kiện</label> | |
| <div id="prop-type" class="text-lg font-bold text-white capitalize">Resistor</div> | |
| </div> | |
| <div id="prop-control-resistance" class="space-y-2"> | |
| <div class="flex justify-between text-xs text-slate-400"> | |
| <label>Điện trở (R)</label> | |
| <span id="val-resistance" class="mono text-blue-400">100 Ω</span> | |
| </div> | |
| <input type="range" min="10" max="1000" step="10" id="input-resistance" class="w-full"> | |
| </div> | |
| <div id="prop-control-voltage" class="space-y-2"> | |
| <div class="flex justify-between text-xs text-slate-400"> | |
| <label>Hiệu điện thế (V)</label> | |
| <span id="val-voltage" class="mono text-yellow-400">9 V</span> | |
| </div> | |
| <input type="range" min="1" max="24" step="1" id="input-voltage" class="w-full"> | |
| </div> | |
| <div class="p-3 bg-slate-800/50 rounded border border-slate-700/50"> | |
| <h3 class="text-[10px] uppercase text-slate-500 font-bold mb-2">Phân tích mạch</h3> | |
| <div class="space-y-2"> | |
| <div class="flex justify-between text-xs"> | |
| <span class="text-slate-400">Cường độ dòng điện (I):</span> | |
| <span id="analysis-current" class="mono text-white">0.00 A</span> | |
| </div> | |
| <div class="flex justify-between text-xs"> | |
| <span class="text-slate-400">Công suất (P):</span> | |
| <span id="analysis-power" class="mono text-white">0.00 W</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="pt-4 border-t border-slate-800"> | |
| <button onclick="app.deleteSelected()" class="w-full py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 rounded text-xs transition-colors"> | |
| Xóa linh kiện này | |
| </button> | |
| </div> | |
| </div> | |
| </aside> | |
| </div> | |
| <script> | |
| /** | |
| * Circuit Simulator Logic | |
| * Implements a simple grid-based circuit editor with visual simulation of current flow. | |
| */ | |
| class CircuitApp { | |
| constructor() { | |
| this.canvas = document.getElementById('circuitCanvas'); | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.container = document.getElementById('canvas-container'); | |
| // State | |
| this.components = []; | |
| this.wires = []; // Connections between components | |
| this.selectedTool = 'wire'; // wire, resistor, battery, bulb, switch | |
| this.isSimulating = false; | |
| this.selectedComponent = null; | |
| this.draggedComponent = null; | |
| this.hoveredComponent = null; | |
| // Interaction State | |
| this.isDrawingWire = false; | |
| this.wireStartNode = null; | |
| this.mousePos = { x: 0, y: 0 }; | |
| // Animation | |
| this.electrons = []; // Array of electron particles | |
| this.animationFrame = null; | |
| // Constants | |
| this.gridSize = 20; | |
| this.componentSize = 40; | |
| this.init(); | |
| } | |
| init() { | |
| this.resize(); | |
| window.addEventListener('resize', () => this.resize()); | |
| // Mouse Events | |
| this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e)); | |
| this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); | |
| this.canvas.addEventListener('mouseup', (e) => this.handleMouseUp(e)); | |
| this.canvas.addEventListener('dblclick', (e) => this.handleDoubleClick(e)); | |
| // UI Bindings | |
| this.bindPropertiesPanel(); | |
| // Start Loop | |
| this.loop(); | |
| } | |
| resize() { | |
| this.canvas.width = this.container.clientWidth; | |
| this.canvas.height = this.container.clientHeight; | |
| } | |
| // --- Core Logic: Components & Wires --- | |
| addComponent(type, x, y) { | |
| // Snap to grid | |
| const snapX = Math.round(x / this.gridSize) * this.gridSize; | |
| const snapY = Math.round(y / this.gridSize) * this.gridSize; | |
| const comp = { | |
| id: Date.now(), | |
| type: type, | |
| x: snapX, | |
| y: snapY, | |
| rotation: 0, // 0, 1, 2, 3 (x90 deg) | |
| value: type === 'resistor' ? 100 : (type === 'battery' ? 9 : 0), | |
| state: type === 'switch' ? false : true, // false = open, true = closed | |
| nodes: this.calculateNodes(snapX, snapY, 0) | |
| }; | |
| this.components.push(comp); | |
| this.selectComponent(comp); | |
| } | |
| calculateNodes(x, y, rotation) { | |
| // Define connection points relative to center | |
| // 0 deg: Left (-1,0) and Right (1,0) | |
| const offset = this.componentSize / 2; | |
| return [ | |
| { x: x - offset, y: y, id: `n-${x}-${y}-l` }, // Left | |
| { x: x + offset, y: y, id: `n-${x}-${y}-r` } // Right | |
| ]; | |
| } | |
| getComponentAt(x, y) { | |
| // Check wires first (line segment check) | |
| for (let w of this.wires) { | |
| const dist = this.pointToLineDistance(x, y, w.x1, w.y1, w.x2, w.y2); | |
| if (dist < 5) return { type: 'wire', obj: w }; | |
| } | |
| // Check components | |
| const size = this.componentSize / 2 + 5; // tolerance | |
| return this.components.find(c => | |
| x >= c.x - size && x <= c.x + size && | |
| y >= c.y - size && y <= c.y + size | |
| ); | |
| } | |
| pointToLineDistance(x, y, x1, y1, x2, y2) { | |
| const A = x - x1; | |
| const B = y - y1; | |
| const C = x2 - x1; | |
| const D = y2 - y1; | |
| const dot = A * C + B * D; | |
| const len_sq = C * C + D * D; | |
| let param = -1; | |
| if (len_sq !== 0) param = dot / len_sq; | |
| let xx, yy; | |
| if (param < 0) { | |
| xx = x1; yy = y1; | |
| } else if (param > 1) { | |
| xx = x2; yy = y2; | |
| } else { | |
| xx = x1 + param * C; | |
| yy = y1 + param * D; | |
| } | |
| const dx = x - xx; | |
| const dy = y - yy; | |
| return Math.sqrt(dx * dx + dy * dy); | |
| } | |
| // --- Interaction Handlers --- | |
| handleMouseDown(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const clicked = this.getComponentAt(x, y); | |
| if (this.selectedTool === 'wire') { | |
| if (clicked && clicked.type !== 'wire') { | |
| // Start wire from component node | |
| const node = this.getClosestNode(clicked.obj, x, y); | |
| this.isDrawingWire = true; | |
| this.wireStartNode = { x: node.x, y: node.y, comp: clicked.obj, nodeIndex: clicked.obj.nodes.indexOf(node) }; | |
| } else if (!clicked) { | |
| // Start wire from empty space (less common but supported) | |
| this.isDrawingWire = true; | |
| this.wireStartNode = { x: Math.round(x/10)*10, y: Math.round(y/10)*10, comp: null }; | |
| } | |
| } else { | |
| // Placing components or selecting | |
| if (!clicked) { | |
| if (this.selectedTool !== 'wire') { | |
| this.addComponent(this.selectedTool, x, y); | |
| } else { | |
| this.selectComponent(null); | |
| } | |
| } else if (clicked.type !== 'wire') { | |
| this.draggedComponent = clicked.obj; | |
| this.selectComponent(clicked.obj); | |
| } | |
| } | |
| } | |
| handleMouseMove(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| this.mousePos.x = e.clientX - rect.left; | |
| this.mousePos.y = e.clientY - rect.top; | |
| if (this.draggedComponent) { | |
| const snapX = Math.round(this.mousePos.x / this.gridSize) * this.gridSize; | |
| const snapY = Math.round(this.mousePos.y / this.gridSize) * this.gridSize; | |
| this.draggedComponent.x = snapX; | |
| this.draggedComponent.y = snapY; | |
| this.draggedComponent.nodes = this.calculateNodes(snapX, snapY, 0); | |
| // Update connected wires | |
| this.wires.forEach(w => { | |
| if (w.from.comp === this.draggedComponent) { | |
| const node = this.draggedComponent.nodes[w.from.nodeIndex]; | |
| w.x1 = node.x; w.y1 = node.y; | |
| } | |
| if (w.to.comp === this.draggedComponent) { | |
| const node = this.draggedComponent.nodes[w.to.nodeIndex]; | |
| w.x2 = node.x; w.y2 = node.y; | |
| } | |
| }); | |
| } | |
| // Hover effect | |
| const hovered = this.getComponentAt(this.mousePos.x, this.mousePos.y); | |
| this.hoveredComponent = hovered ? hovered.obj : null; | |
| } | |
| handleMouseUp(e) { | |
| if (this.isDrawingWire && this.wireStartNode) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const target = this.getComponentAt(x, y); | |
| if (target && target.type !== 'wire' && target.obj !== this.wireStartNode.comp) { | |
| // Connect to component | |
| const node = this.getClosestNode(target.obj, x, y); | |
| this.wires.push({ | |
| x1: this.wireStartNode.x, y1: this.wireStartNode.y, | |
| x2: node.x, y2: node.y, | |
| from: this.wireStartNode, | |
| to: { x: node.x, y: node.y, comp: target.obj, nodeIndex: target.obj.nodes.indexOf(node) } | |
| }); | |
| } else if (!target) { | |
| // Connect to empty space (junction) | |
| this.wires.push({ | |
| x1: this.wireStartNode.x, y1: this.wireStartNode.y, | |
| x2: Math.round(x/10)*10, y2: Math.round(y/10)*10, | |
| from: this.wireStartNode, | |
| to: null // Floating end? No, let's just snap to grid for now | |
| }); | |
| } | |
| this.isDrawingWire = false; | |
| this.wireStartNode = null; | |
| } | |
| this.draggedComponent = null; | |
| } | |
| handleDoubleClick(e) { | |
| const rect = this.canvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const clicked = this.getComponentAt(x, y); | |
| if (clicked) { | |
| if (clicked.type === 'wire') { | |
| this.wires = this.wires.filter(w => w !== clicked.obj); | |
| } else { | |
| // Toggle switch | |
| if (clicked.obj.type === 'switch') { | |
| clicked.obj.state = !clicked.obj.state; | |
| } | |
| // Delete others | |
| else { | |
| this.components = this.components.filter(c => c !== clicked.obj); | |
| // Remove attached wires | |
| this.wires = this.wires.filter(w => w.from.comp !== clicked.obj && w.to.comp !== clicked.obj); | |
| if (this.selectedComponent === clicked.obj) this.selectComponent(null); | |
| } | |
| } | |
| } | |
| } | |
| getClosestNode(comp, x, y) { | |
| // Simple distance check to nodes | |
| let closest = comp.nodes[0]; | |
| let minDist = Infinity; | |
| comp.nodes.forEach(n => { | |
| const d = Math.hypot(n.x - x, n.y - y); | |
| if (d < minDist) { | |
| minDist = d; | |
| closest = n; | |
| } | |
| }); | |
| return closest; | |
| } | |
| // --- Simulation Logic --- | |
| toggleSimulation() { | |
| this.isSimulating = !this.isSimulating; | |
| const btn = document.getElementById('sim-btn'); | |
| const txt = document.getElementById('sim-text'); | |
| const icon = btn.querySelector('i'); | |
| if (this.isSimulating) { | |
| btn.classList.replace('bg-blue-600', 'bg-red-600'); | |
| btn.classList.replace('hover:bg-blue-500', 'hover:bg-red-500'); | |
| txt.innerText = "Dừng Mô Phỏng"; | |
| icon.classList.replace('fa-play', 'fa-stop'); | |
| this.startSimulation(); | |
| } else { | |
| btn.classList.replace('bg-red-600', 'bg-blue-600'); | |
| btn.classList.replace('hover:bg-red-500', 'hover:bg-blue-500'); | |
| txt.innerText = "Chạy Mô Phỏng"; | |
| icon.classList.replace('fa-stop', 'fa-play'); | |
| this.electrons = []; | |
| } | |
| } | |
| startSimulation() { | |
| // 1. Find Battery | |
| const battery = this.components.find(c => c.type === 'battery'); | |
| if (!battery) return; | |
| // 2. Trace Path (Simplified: assume single loop for this demo) | |
| // In a real app, we'd use MNA or Nodal Analysis. | |
| // Here we find paths from battery + to battery -. | |
| const path = this.findPath(battery, 0); // Start at node 0 (left) | |
| if (path) { | |
| this.currentPath = path; | |
| // Calculate Total Resistance | |
| let totalR = 0; | |
| path.forEach(item => { | |
| if (item.comp.type === 'resistor') totalR += item.comp.value; | |
| if (item.comp.type === 'bulb') totalR += 50; // Fixed bulb resistance | |
| if (item.comp.type === 'switch' && !item.comp.state) totalR += 999999; // Open switch | |
| }); | |
| const current = battery.value / totalR; | |
| this.circuitCurrent = current; // Amps | |
| // Spawn electrons based on current | |
| // We visualize electrons moving along the path | |
| this.spawnElectrons(path, current); | |
| } else { | |
| this.circuitCurrent = 0; | |
| } | |
| } | |
| findPath(startComp, startNodeIndex) { | |
| // DFS to find loop back to battery | |
| // This is a very basic pathfinder for visual demo purposes | |
| // It assumes a simple series circuit layout. | |
| let path = []; | |
| let visited = new Set(); | |
| const traverse = (comp, entryNodeIdx) => { | |
| if (visited.has(comp.id)) return false; // Loop detected or dead end | |
| visited.add(comp.id); | |
| // Determine exit node (the other node) | |
| const exitNodeIdx = entryNodeIdx === 0 ? 1 : 0; | |
| path.push({ comp: comp, entry: entryNodeIdx, exit: exitNodeIdx }); | |
| if (comp.type === 'battery' && path.length > 1) { | |
| return true; // Completed loop | |
| } | |
| // Find wire connected to exit node | |
| const exitNode = comp.nodes[exitNodeIdx]; | |
| const wire = this.wires.find(w => | |
| (w.x1 === exitNode.x && w.y1 === exitNode.y) || | |
| (w.x2 === exitNode.x && w.y2 === exitNode.y) | |
| ); | |
| if (!wire) return false; | |
| // Find next component connected to other end of wire | |
| const otherEnd = (wire.x1 === exitNode.x && wire.y1 === exitNode.y) ? | |
| { x: wire.x2, y: wire.y2 } : { x: wire.x1, y: wire.y1 }; | |
| // Find component at other end | |
| // Check wires connected to this point | |
| const nextWire = this.wires.find(w => | |
| (w.x1 === otherEnd.x && w.y1 === otherEnd.y && w !== wire) || | |
| (w.x2 === otherEnd.x && w.y2 === otherEnd.y && w !== wire) | |
| ); | |
| // Find component connected to this wire | |
| let nextComp = null; | |
| let nextNodeIdx = -1; | |
| // Check all components | |
| for (let c of this.components) { | |
| if (c === comp) continue; | |
| c.nodes.forEach((n, idx) => { | |
| if (n.x === otherEnd.x && n.y === otherEnd.y) { | |
| nextComp = c; | |
| nextNodeIdx = idx; | |
| } | |
| }); | |
| } | |
| if (nextComp) { | |
| return traverse(nextComp, nextNodeIdx); | |
| } | |
| return false; | |
| }; | |
| const found = traverse(startComp, startNodeIndex); | |
| return found ? path : null; | |
| } | |
| spawnElectrons(path, current) { | |
| this.electrons = []; | |
| // Create segments from path | |
| this.pathSegments = []; | |
| for (let i = 0; i < path.length; i++) { | |
| const item = path[i]; | |
| const nextItem = path[(i + 1) % path.length]; | |
| // Segment 1: Inside component (Entry to Exit) | |
| // Segment 2: Wire (Exit to Next Entry) | |
| // For visual simplicity, we treat the whole path as a continuous line | |
| // We need coordinates. | |
| const startNode = item.comp.nodes[item.entry]; | |
| const endNode = item.comp.nodes[item.exit]; | |
| // Add wire to next component | |
| const wire = this.wires.find(w => | |
| (w.x1 === endNode.x && w.y1 === endNode.y) || | |
| (w.x2 === endNode.x && w.y2 === endNode.y) | |
| ); | |
| if (wire) { | |
| const wireStart = (wire.x1 === endNode.x && wire.y1 === endNode.y) ? | |
| {x: wire.x1, y: wire.y1} : {x: wire.x2, y: wire.y2}; | |
| const wireEnd = (wire.x1 === endNode.x && wire.y1 === endNode.y) ? | |
| {x: wire.x2, y: wire.y2} : {x: wire.x1, y: wire.y1}; | |
| this.pathSegments.push({ | |
| from: {x: startNode.x, y: startNode.y}, | |
| to: {x: endNode.x, y: endNode.y}, | |
| type: 'comp', | |
| comp: item.comp | |
| }); | |
| this.pathSegments.push({ | |
| from: {x: wireStart.x, y: wireStart.y}, | |
| to: {x: wireEnd.x, y: wireEnd.y}, | |
| type: 'wire' | |
| }); | |
| } | |
| } | |
| // Spawn initial electrons | |
| const speed = Math.min(Math.max(current * 2, 0.5), 5); // Visual speed cap | |
| for(let i=0; i<20; i++) { | |
| this.electrons.push({ | |
| segmentIndex: 0, | |
| progress: i / 20, // 0 to 1 along segment | |
| speed: speed | |
| }); | |
| } | |
| } | |
| updateElectrons() { | |
| if (!this.isSimulating || !this.pathSegments) return; | |
| // Check if circuit is broken (switch open) | |
| const broken = this.currentPath && this.currentPath.some(p => p.comp.type === 'switch' && !p.comp.state); | |
| if (broken) { | |
| this.electrons = []; // Stop electrons | |
| return; | |
| } | |
| this.electrons.forEach(e => { | |
| e.progress += 0.005 * e.speed; | |
| if (e.progress >= 1) { | |
| e.progress = 0; | |
| e.segmentIndex = (e.segmentIndex + 1) % this.pathSegments.length; | |
| } | |
| }); | |
| } | |
| // --- Rendering --- | |
| loop() { | |
| this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
| this.drawGrid(); | |
| this.drawWires(); | |
| this.drawComponents(); | |
| this.drawUIOverlay(); | |
| if (this.isSimulating) { | |
| this.updateElectrons(); | |
| this.drawElectrons(); | |
| } | |
| requestAnimationFrame(() => this.loop()); | |
| } | |
| drawGrid() { | |
| this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; | |
| this.ctx.lineWidth = 1; | |
| this.ctx.beginPath(); | |
| for (let x = 0; x < this.canvas.width; x += this.gridSize) { | |
| this.ctx.moveTo(x, 0); | |
| this.ctx.lineTo(x, this.canvas.height); | |
| } | |
| for (let y = 0; y < this.canvas.height; y += this.gridSize) { | |
| this.ctx.moveTo(0, y); | |
| this.ctx.lineTo(this.canvas.width, y); | |
| } | |
| this.ctx.stroke(); | |
| } | |
| drawWires() { | |
| this.ctx.lineCap = 'round'; | |
| this.ctx.lineJoin = 'round'; | |
| this.wires.forEach(w => { | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(w.x1, w.y1); | |
| this.ctx.lineTo(w.x2, w.y2); | |
| // Style | |
| if (this.hoveredComponent === w) { | |
| this.ctx.strokeStyle = '#38bdf8'; | |
| this.ctx.lineWidth = 4; | |
| this.ctx.shadowBlur = 10; | |
| this.ctx.shadowColor = '#38bdf8'; | |
| } else { | |
| this.ctx.strokeStyle = '#64748b'; | |
| this.ctx.lineWidth = 3; | |
| this.ctx.shadowBlur = 0; | |
| } | |
| this.ctx.stroke(); | |
| this.ctx.shadowBlur = 0; // Reset | |
| // Draw junction dots | |
| this.drawJunction(w.x1, w.y1); | |
| this.drawJunction(w.x2, w.y2); | |
| }); | |
| // Drawing line while dragging | |
| if (this.isDrawingWire && this.wireStartNode) { | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.wireStartNode.x, this.wireStartNode.y); | |
| // Orthogonal routing hint | |
| const midX = (this.wireStartNode.x + this.mousePos.x) / 2; | |
| this.ctx.lineTo(midX, this.wireStartNode.y); | |
| this.ctx.lineTo(midX, this.mousePos.y); | |
| this.ctx.lineTo(this.mousePos.x, this.mousePos.y); | |
| this.ctx.strokeStyle = '#38bdf8'; | |
| this.ctx.lineWidth = 2; | |
| this.ctx.setLineDash([5, 5]); | |
| this.ctx.stroke(); | |
| this.ctx.setLineDash([]); | |
| } | |
| } | |
| drawJunction(x, y) { | |
| this.ctx.fillStyle = '#94a3b8'; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(x, y, 3, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| } | |
| drawComponents() { | |
| this.components.forEach(c => { | |
| this.ctx.save(); | |
| this.ctx.translate(c.x, c.y); | |
| // Highlight selected | |
| if (this.selectedComponent === c) { | |
| this.ctx.shadowColor = '#38bdf8'; | |
| this.ctx.shadowBlur = 15; | |
| } else if (this.hoveredComponent === c) { | |
| this.ctx.shadowColor = 'rgba(255,255,255,0.3)'; | |
| this.ctx.shadowBlur = 10; | |
| } | |
| // Draw Component Body | |
| this.ctx.fillStyle = '#1e293b'; | |
| this.ctx.strokeStyle = this.selectedComponent === c ? '#38bdf8' : '#94a3b8'; | |
| this.ctx.lineWidth = 2; | |
| // Rotation | |
| // this.ctx.rotate(c.rotation * Math.PI / 2); | |
| // Draw based on type | |
| const s = this.componentSize / 2; // half size | |
| // Connection points (visual only, logic handled by nodes) | |
| this.ctx.fillStyle = '#94a3b8'; | |
| this.ctx.beginPath(); this.ctx.arc(-s, 0, 4, 0, Math.PI*2); this.ctx.fill(); | |
| this.ctx.beginPath(); this.ctx.arc(s, 0, 4, 0, Math.PI*2); this.ctx.fill(); | |
| this.ctx.beginPath(); | |
| if (c.type === 'resistor') { | |
| // Zigzag | |
| this.ctx.moveTo(-s, 0); | |
| this.ctx.lineTo(-s + 5, 0); | |
| this.ctx.lineTo(-s + 10, -8); | |
| this.ctx.lineTo(-s + 20, 8); | |
| this.ctx.lineTo(-s + 30, -8); | |
| this.ctx.lineTo(-s + 40, 8); | |
| this.ctx.lineTo(s - 5, 0); | |
| this.ctx.lineTo(s, 0); | |
| this.ctx.stroke(); | |
| // Text | |
| this.ctx.fillStyle = '#fff'; | |
| this.ctx.font = '10px Inter'; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.fillText(`${c.value}Ω`, 0, -12); | |
| } else if (c.type === 'battery') { | |
| // Long and short line | |
| // Left (Neg) | |
| this.ctx.moveTo(-s, 0); this.ctx.lineTo(-10, 0); | |
| this.ctx.moveTo(-10, -15); this.ctx.lineTo(-10, 15); // Long | |
| // Right (Pos) | |
| this.ctx.moveTo(10, -8); this.ctx.lineTo(10, 8); // Short | |
| this.ctx.moveTo(10, 0); this.ctx.lineTo(s, 0); | |
| this.ctx.stroke(); | |
| // Signs | |
| this.ctx.fillStyle = '#38bdf8'; | |
| this.ctx.font = 'bold 14px sans-serif'; | |
| this.ctx.fillText('-', -18, 4); | |
| this.ctx.fillStyle = '#ef4444'; | |
| this.ctx.fillText('+', 18, 4); | |
| this.ctx.fillStyle = '#fbbf24'; | |
| this.ctx.font = '10px Inter'; | |
| this.ctx.fillText(`${c.value}V`, 0, 25); | |
| } else if (c.type === 'bulb') { | |
| // Circle | |
| this.ctx.beginPath(); | |
| this.ctx.arc(0, 0, 15, 0, Math.PI * 2); | |
| this.ctx.fillStyle = '#334155'; | |
| this.ctx.fill(); | |
| this.ctx.stroke(); | |
| // Cross | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(-8, -8); this.ctx.lineTo(8, 8); | |
| this.ctx.moveTo(8, -8); this.ctx.lineTo(-8, 8); | |
| this.ctx.stroke(); | |
| // Glow if simulating and current flowing | |
| if (this.isSimulating && this.circuitCurrent > 0) { | |
| const alpha = Math.min(this.circuitCurrent / 0.5, 1); | |
| this.ctx.shadowColor = `rgba(250, 204, 21, ${alpha})`; | |
| this.ctx.shadowBlur = 20 + (alpha * 20); | |
| this.ctx.fillStyle = `rgba(250, 204, 21, ${alpha * 0.5})`; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(0, 0, 12, 0, Math.PI*2); | |
| this.ctx.fill(); | |
| } | |
| } else if (c.type === 'switch') { | |
| // Line with break | |
| this.ctx.moveTo(-s, 0); this.ctx.lineTo(-10, 0); | |
| this.ctx.moveTo(10, 0); this.ctx.lineTo(s, 0); | |
| this.ctx.stroke(); | |
| // Lever | |
| this.ctx.beginPath(); | |
| if (c.state) { // Closed | |
| this.ctx.moveTo(-10, 0); | |
| this.ctx.lineTo(10, 0); | |
| this.ctx.strokeStyle = '#4ade80'; | |
| } else { // Open | |
| this.ctx.moveTo(-10, 0); | |
| this.ctx.lineTo(8, -15); | |
| } | |
| this.ctx.stroke(); | |
| // Dots | |
| this.ctx.fillStyle = c.state ? '#4ade80' : '#94a3b8'; | |
| this.ctx.beginPath(); this.ctx.arc(-10, 0, 3, 0, Math.PI*2); this.ctx.fill(); | |
| this.ctx.beginPath(); this.ctx.arc(10, 0, 3, 0, Math.PI*2); this.ctx.fill(); | |
| } | |
| this.ctx.restore(); | |
| }); | |
| } | |
| drawElectrons() { | |
| if (!this.pathSegments) return; | |
| this.ctx.fillStyle = '#facc15'; // Electron yellow | |
| this.ctx.shadowColor = '#facc15'; | |
| this.ctx.shadowBlur = 5; | |
| this.electrons.forEach(e => { | |
| const seg = this.pathSegments[e.segmentIndex]; | |
| if (!seg) return; | |
| const x = seg.from.x + (seg.to.x - seg.from.x) * e.progress; | |
| const y = seg.from.y + (seg.to.y - seg.from.y) * e.progress; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(x, y, 3, 0, Math.PI * 2); | |
| this.ctx.fill(); | |
| }); | |
| this.ctx.shadowBlur = 0; | |
| } | |
| drawUIOverlay() { | |
| // Tooltip for hover | |
| if (this.hoveredComponent && typeof this.hoveredComponent === 'object') { | |
| // Handled by cursor mostly, but could add tooltips here | |
| } | |
| } | |
| // --- UI Logic --- | |
| setTool(tool) { | |
| this.selectedTool = tool; | |
| // Update UI classes | |
| document.querySelectorAll('.component-btn').forEach(btn => btn.classList.remove('active')); | |
| // Find button with onclick containing tool name (simple hack) | |
| const btns = document.querySelectorAll('.component-btn'); | |
| if(tool === 'wire') btns[0].classList.add('active'); | |
| if(tool === 'resistor') btns[1].classList.add('active'); | |
| if(tool === 'battery') btns[2].classList.add('active'); | |
| if(tool === 'bulb') btns[3].classList.add('active'); | |
| if(tool === 'switch') btns[4].classList.add('active'); | |
| } | |
| selectComponent(comp) { | |
| this.selectedComponent = comp; | |
| const panel = document.getElementById('properties-panel'); | |
| const empty = document.getElementById('empty-state'); | |
| const content = document.getElementById('properties-content'); | |
| if (!comp) { | |
| empty.classList.remove('hidden'); | |
| content.classList.add('hidden'); | |
| return; | |
| } | |
| empty.classList.add('hidden'); | |
| content.classList.remove('hidden'); | |
| // Populate fields | |
| document.getElementById('prop-type').innerText = comp.type; | |
| // Show/Hide relevant controls | |
| const rControl = document.getElementById('prop-control-resistance'); | |
| const vControl = document.getElementById('prop-control-voltage'); | |
| if (comp.type === 'resistor') { | |
| rControl.classList.remove('hidden'); | |
| vControl.classList.add('hidden'); | |
| document.getElementById('input-resistance').value = comp.value; | |
| document.getElementById('val-resistance').innerText = comp.value + ' Ω'; | |
| } else if (comp.type === 'battery') { | |
| rControl.classList.add('hidden'); | |
| vControl.classList.remove('hidden'); | |
| document.getElementById('input-voltage').value = comp.value; | |
| document.getElementById('val-voltage').innerText = comp.value + ' V'; | |
| } else { |