diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62334fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +db.sqlite3 +*.pyc +/staticfiles +.vscode/settings.json +.vscode/launch.json +venv/ +/media +.env* +backup.json +.notes +ansible/inventory.yml +education-website-*.json +*.sql diff --git a/index.html b/index.html new file mode 100644 index 0000000..9e3a43f --- /dev/null +++ b/index.html @@ -0,0 +1,780 @@ + + + + + + + Alpha One Labs - Instrument + + + + + + + + + + + + + +
+
+
+
+
+
+ Loading System +
+
+
+
+
+
+ +
+ + + + + + + + diff --git a/static/img/image.png b/static/img/image.png new file mode 100644 index 0000000..bdb6de1 Binary files /dev/null and b/static/img/image.png differ diff --git a/static/virtual_lab/js/chemistry/ph_indicator.js b/static/virtual_lab/js/chemistry/ph_indicator.js new file mode 100644 index 0000000..42149bd --- /dev/null +++ b/static/virtual_lab/js/chemistry/ph_indicator.js @@ -0,0 +1,138 @@ +// static/virtual_lab/js/chemistry/ph_indicator.js + +const width = 600, height = 400; + +// Canvases & contexts +const indCv = document.getElementById('indicator-canvas'), + indCtx = indCv.getContext('2d'); +const dropCv = document.getElementById('drop-canvas'), + dropCtx = dropCv.getContext('2d'); +const confCv = document.getElementById('confetti-canvas'), + confCtx = confCv.getContext('2d'); + +const updateBtn = document.getElementById('update-btn'), + resetBtn = document.getElementById('reset-btn'), + hintEl = document.getElementById('hint'), + propEl = document.getElementById('property'), + phInput = document.getElementById('solution-ph'); + +let confettiParticles = []; + +// 1. pH → color +function getColor(ph) { + if (ph < 3) return '#ff0000'; + if (ph < 6) return '#ff9900'; + if (ph < 8) return '#ffff00'; + if (ph < 11) return '#66ff66'; + return '#0000ff'; +} + +// 2. pH → property +function getProperty(ph) { + if (ph < 7) return '{% trans "Acidic" %}'; + if (ph === 7) return '{% trans "Neutral" %}'; + return '{% trans "Basic" %}'; +} + +// Draw the main indicator rectangle + pH label +function drawIndicator(ph) { + indCtx.clearRect(0, 0, width, height); + // Fill + indCtx.fillStyle = getColor(ph); + indCtx.fillRect(100, 75, 400, 250); + // Border pulse + indCtx.lineWidth = 8; + indCtx.strokeStyle = '#333'; + indCtx.strokeRect(100, 75, 400, 250); + // Label + indCtx.fillStyle = '#000'; + indCtx.font = '24px Arial'; + indCtx.fillText(`pH: ${ph.toFixed(1)}`, 260, 360); +} + +// Animate a single drop falling & splashing +function animateDrop(ph) { + dropCtx.clearRect(0,0,width,height); + const dropX = 300, startY = 0, groundY = 75; + let y = startY; + const id = setInterval(() => { + dropCtx.clearRect(0,0,width,height); + dropCtx.fillStyle = '#66b3ff'; + dropCtx.beginPath(); + dropCtx.arc(dropX, y, 6, 0, 2*Math.PI); + dropCtx.fill(); + y += 8; + if (y > groundY) { + clearInterval(id); + // Splash circle + let r = 0; + const sid = setInterval(()=>{ + dropCtx.clearRect(0,0,width,height); + dropCtx.beginPath(); + dropCtx.arc(dropX, groundY, r, 0, 2*Math.PI); + dropCtx.strokeStyle = '#66b3ff'; + dropCtx.lineWidth = 2; + dropCtx.stroke(); + r += 2; + if (r > 40) { + clearInterval(sid); + dropCtx.clearRect(0,0,width,height); + } + }, 30); + } + }, 30); +} + +// Create confetti particles when neutral +function spawnConfetti() { + confettiParticles = Array.from({length:100}, () => ({ + x: Math.random()*width, + y: -10, + vy: 2 + Math.random()*2, + color: `hsl(${Math.random()*360},70%,60%)` + })); + requestAnimationFrame(updateConfetti); +} + +function updateConfetti() { + confCtx.clearRect(0,0,width,height); + confettiParticles.forEach(p => { + confCtx.fillStyle = p.color; + confCtx.fillRect(p.x, p.y, 6, 6); + p.y += p.vy; + }); + confettiParticles = confettiParticles.filter(p => p.y < height); + if (confettiParticles.length) requestAnimationFrame(updateConfetti); +} + +// Update entire UI on pH change +function updateUI(ph) { + drawIndicator(ph); + animateDrop(ph); + hintEl.innerText = `🎉 Indicator shows ${getProperty(ph)}!`; + propEl.innerText = `{% trans "Solution is" %} ${getProperty(ph)}.`; + if (ph === 7) spawnConfetti(); +} + +// Button handlers +updateBtn.addEventListener('click', ()=>{ + let ph = parseFloat(phInput.value); + if (isNaN(ph) || ph < 0 || ph > 14) { + hintEl.innerText = '{% trans "Please enter a valid pH between 0 and 14." %}'; + return; + } + updateUI(ph); +}); + +resetBtn.addEventListener('click', ()=>{ + indCtx.clearRect(0,0,width,height); + dropCtx.clearRect(0,0,width,height); + confCtx.clearRect(0,0,width,height); + phInput.value = 7; + hintEl.innerText = '{% trans "Enter a pH value (0–14) and click Update to see the color change." %}'; + propEl.innerText = ''; + drawIndicator(7); +}); + +// Initial draw +window.addEventListener('load', ()=> drawIndicator(7)); diff --git a/static/virtual_lab/js/chemistry/precipitation.js b/static/virtual_lab/js/chemistry/precipitation.js new file mode 100644 index 0000000..15bd316 --- /dev/null +++ b/static/virtual_lab/js/chemistry/precipitation.js @@ -0,0 +1,134 @@ +// static/virtual_lab/js/chemistry/precipitation.js + +// Canvases +const beakerCanvas = document.getElementById('beaker-canvas'); +const bctx = beakerCanvas.getContext('2d'); +const swirlCanvas = document.getElementById('swirl-canvas'); +const sctx = swirlCanvas.getContext('2d'); +const precipCanvas = document.getElementById('precip-canvas'); +const pctx = precipCanvas.getContext('2d'); + +// Controls & status +const addBtn = document.getElementById('add-reagent'); +const stirBtn = document.getElementById('stir-btn'); +const resetBtn = document.getElementById('reset-btn'); +const hintEl = document.getElementById('hint'); +const propEl = document.getElementById('property'); + +let swirlAngle = 0, + swirlRAF, + precipParticles = [], + stage = 0; // 0=init,1=reagent,2=stirring,3=precipitating,4=done + +// Draw beaker outline +function drawBeaker() { + bctx.clearRect(0,0,600,400); + bctx.strokeStyle = '#333'; + bctx.lineWidth = 3; + bctx.strokeRect(200,100,200,250); +} + +// Swirl animation +function drawSwirl() { + sctx.clearRect(0,0,600,400); + const cx=300, cy=300, r=100; + sctx.strokeStyle = 'rgba(128,0,128,0.5)'; + sctx.lineWidth = 4; + sctx.beginPath(); + sctx.arc(cx,cy,r, swirlAngle, swirlAngle + Math.PI*1.5); + sctx.stroke(); + swirlAngle += 0.05; + swirlRAF = requestAnimationFrame(drawSwirl); +} + +// Start stirring +function startStir() { + stage = 2; + updateHint('🌀 Stirring to initiate precipitation...'); + drawBeaker(); + drawSwirl(); + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); + setTimeout(stopStir, 2000); +} + +// Stop swirling and spawn precipitate +function stopStir() { + cancelAnimationFrame(swirlRAF); + sctx.clearRect(0,0,600,400); + stage = 3; + updateHint('🌧️ Precipitate forming...'); + spawnParticles(); +} + +// Spawn and animate precipitate +function spawnParticles() { + precipParticles = []; + for (let i=0; i<150; i++) { + precipParticles.push({ + x: 220 + Math.random()*160, + y: 110, + vy: 1 + Math.random()*1.5 + }); + } + animateParticles(); +} + +// Animate particles falling +function animateParticles() { + pctx.clearRect(0,0,600,400); + precipParticles.forEach(p => { + pctx.fillStyle = '#666'; + pctx.beginPath(); + pctx.arc(p.x, p.y, 4, 0,2*Math.PI); + pctx.fill(); + p.y += p.vy; + }); + precipParticles = precipParticles.filter(p => p.y < 350); + if (precipParticles.length) { + requestAnimationFrame(animateParticles); + } else { + stage = 4; + updateHint('✅ Precipitation complete!'); + propEl.innerText = 'Precipitate Present'; + } +} + +// Update hint text +function updateHint(txt) { + hintEl.innerText = txt; +} + +// Reset everything +function resetAll() { + cancelAnimationFrame(swirlRAF); + precipParticles = []; + sctx.clearRect(0,0,600,400); + pctx.clearRect(0,0,600,400); + propEl.innerText = ''; + stage = 0; + updateHint('Click Add Reagent to begin.'); + addBtn.disabled = false; + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); + drawBeaker(); +} + +// Event handlers +addBtn.addEventListener('click', () => { + stage = 1; + updateHint('➕ Reagent added! Now stir the solution.'); + addBtn.disabled = true; + stirBtn.disabled = false; + stirBtn.classList.remove('opacity-50'); +}); +stirBtn.addEventListener('click', startStir); +resetBtn.addEventListener('click', resetAll); + +// Initial setup +window.addEventListener('load', () => { + drawBeaker(); + updateHint('Click Add Reagent to begin.'); + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); +}); diff --git a/static/virtual_lab/js/chemistry/reaction_rate.js b/static/virtual_lab/js/chemistry/reaction_rate.js new file mode 100644 index 0000000..d3c7f79 --- /dev/null +++ b/static/virtual_lab/js/chemistry/reaction_rate.js @@ -0,0 +1,82 @@ +// static/virtual_lab/js/chemistry/reaction_rate.js + +const canvas = document.getElementById('reaction-canvas'); +const ctx = canvas.getContext('2d'); + +let initialConc, conc, time = 0, loopID; + +// Draws a test-tube shape and fills it proportional to conc +function drawTestTube(c) { + ctx.clearRect(0,0,600,400); + // Tube outline + ctx.strokeStyle = '#555'; + ctx.lineWidth = 4; + ctx.strokeRect(250,50,100,300); + // Liquid fill + const fillHeight = Math.max(0, Math.min(300, (c/initialConc)*300)); + ctx.fillStyle = '#a6f6a6'; + ctx.fillRect(254,350-fillHeight,92,fillHeight); + // Label concentration + ctx.fillStyle = '#000'; + ctx.font = '16px Arial'; + ctx.fillText(`[A]: ${c.toFixed(2)} M`, 20, 380); +} + +// Provides textual hints based on current conc +function updateHint(c) { + const hint = document.getElementById('hint'); + if (c <= 0) hint.innerText = '{% trans "Reaction has completed!" %}'; + else if (c <= initialConc/2) hint.innerText = '{% trans "Half-life reached." %}'; + else hint.innerText = '{% trans "Reaction proceeding..." %}'; +} + +// Displays final “Reaction Complete” message +function displayProperty() { + document.getElementById('property').innerText = '{% trans "Reaction Complete" %}'; +} + +// Advances the reaction in small time steps +function startReaction() { + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + time = 0; + clearInterval(loopID); + document.getElementById('property').innerText = ''; + + loopID = setInterval(() => { + if (conc <= 0.01) { + clearInterval(loopID); + conc = 0; + drawTestTube(0); + document.getElementById('elapsed-time').innerText = time; + updateHint(0); + displayProperty(); + return; + } + // Simple first-order decay: dc/dt = -k * c + const k = 0.1; // rate constant + conc -= k * conc * 0.5; // ∆t = 0.5 s + time += 0.5; + drawTestTube(conc); + document.getElementById('elapsed-time').innerText = time.toFixed(1); + updateHint(conc); + }, 500); +} + +// Resets simulation +function resetReaction() { + clearInterval(loopID); + document.getElementById('elapsed-time').innerText = '0'; + document.getElementById('hint').innerText = ''; + document.getElementById('property').innerText = ''; + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + drawTestTube(conc); +} + +// Initialize on load +window.addEventListener('load', () => { + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + drawTestTube(conc); +}); diff --git a/static/virtual_lab/js/chemistry/solubility.js b/static/virtual_lab/js/chemistry/solubility.js new file mode 100644 index 0000000..f5e570c --- /dev/null +++ b/static/virtual_lab/js/chemistry/solubility.js @@ -0,0 +1,73 @@ +// static/virtual_lab/js/chemistry/solubility.js + +const canvas = document.getElementById('solubility-canvas'); +const ctx = canvas.getContext('2d'); + +let dissolved = 0; +const limit = 10; // grams + +// Draws beaker and liquid fill based on dissolved amount +function drawBeaker() { + ctx.clearRect(0, 0, 600, 400); + // Beaker outline + ctx.strokeStyle = '#333'; + ctx.lineWidth = 2; + ctx.strokeRect(200, 100, 200, 250); + // Liquid fill + const height = Math.min((dissolved / limit) * 240, 240); + ctx.fillStyle = '#f3e6b8'; + ctx.fillRect(202, 350 - height, 196, height); + // Label + ctx.fillStyle = '#000'; + ctx.font = '16px Arial'; + ctx.fillText(`${dissolved.toFixed(1)}g / ${limit}g`, 250, 380); +} + +// Updates hint text based on current dissolved amount +function updateHint() { + const hintEl = document.getElementById('hint'); + if (dissolved < limit) { + hintEl.innerText = '🔽 Solution is unsaturated. Keep adding solute.'; + } else if (dissolved === limit) { + hintEl.innerText = '✅ Saturation point reached!'; + } else { + hintEl.innerText = '⚠️ Supersaturated! Excess solute will precipitate.'; + } +} + +// Displays final property once user is done +function displayProperty() { + const propEl = document.getElementById('property'); + if (dissolved < limit) { + propEl.innerText = 'Solution is Unsaturated'; + } else if (dissolved === limit) { + propEl.innerText = 'Solution is Saturated'; + } else { + propEl.innerText = 'Solution is Supersaturated'; + } +} + +// Called when user clicks “Add Solute” +function addSolute() { + const amtInput = parseFloat(document.getElementById('solute-amt').value) || 0; + dissolved += amtInput; + document.getElementById('dissolved-amt').innerText = dissolved.toFixed(1); + drawBeaker(); + updateHint(); + displayProperty(); +} + +// Called when user clicks “Reset” +function resetSolubility() { + dissolved = 0; + document.getElementById('dissolved-amt').innerText = '0'; + document.getElementById('property').innerText = ''; + drawBeaker(); + updateHint(); +} + +// Initial render on page load +window.addEventListener('load', () => { + drawBeaker(); + updateHint(); +}); diff --git a/static/virtual_lab/js/chemistry/titration.js b/static/virtual_lab/js/chemistry/titration.js new file mode 100644 index 0000000..a5704d0 --- /dev/null +++ b/static/virtual_lab/js/chemistry/titration.js @@ -0,0 +1,117 @@ +// static/virtual_lab/js/chemistry/titration.js + +const beakerCanvas = document.getElementById('titration-canvas'); +const bctx = beakerCanvas.getContext('2d'); +const dropCanvas = document.getElementById('drop-canvas'); +const dctx = dropCanvas.getContext('2d'); + +let titrantVolume = 0, loopID; + +// Draw beaker + liquid +function drawBeaker(pH) { + bctx.clearRect(0,0,600,400); + bctx.fillStyle = '#ccc'; bctx.fillRect(200,100,200,250); + bctx.fillStyle = getColor(pH); bctx.fillRect(205,105,190,240); + bctx.strokeStyle = '#333'; bctx.strokeRect(200,100,200,250); + bctx.fillStyle = '#000'; bctx.font = '16px Arial'; + bctx.fillText(`pH: ${pH.toFixed(2)}`, 240, 380); +} + +// Indicator color mapping +function getColor(pH) { + if (pH < 4) return '#ff4d4d'; + if (pH < 6) return '#ff944d'; + if (pH < 7.5) return '#ffff66'; + if (pH < 9) return '#66ff99'; + return '#66b3ff'; +} + +// Compute pH +function computePH(aC,bC,aV,bV) { + const molA = aC*aV/1000, molB = bC*bV/1000; + const net = molB - molA, totV = (aV + bV)/1000; + if (Math.abs(net) < 1e-6) return 7; + return net < 0 + ? -Math.log10(-net/totV) + : 14 + Math.log10(net/totV); +} + +// Update the hint message +function updateHint(pH) { + const hint = document.getElementById('hint'); + if (pH < 3) hint.innerText = '🔴 Very acidic! Add more base slowly.'; + else if (pH < 6) hint.innerText = '🟠 Still acidic—keep titrant coming.'; + else if (pH < 6.5) hint.innerText = '🟡 Approaching endpoint—go slow!'; + else if (pH < 7.5) hint.innerText = '🟢 Almost neutral—nice!'; + else if (pH < 9) hint.innerText = '🔵 Slightly basic now.'; + else hint.innerText = '💙 Basic—endpoint passed.'; +} + +// Display final property +function displayProperty(pH) { + const propEl = document.getElementById('property'); + let prop; + if (pH < 7) prop = '{% trans "Acidic" %}'; + else if (pH === 7) prop = '{% trans "Neutral" %}'; + else prop = '{% trans "Basic" %}'; + propEl.innerText = `{% trans "Solution is" %} ` + prop; +} + +// Animate one drop +function animateDrop() { + dctx.clearRect(0,0,600,400); + let y = 0, x = 300; + const id = setInterval(()=>{ + dctx.clearRect(0,0,600,400); + dctx.fillStyle = '#66b3ff'; + dctx.beginPath(); dctx.arc(x,y,6,0,2*Math.PI); dctx.fill(); + y += 5; + if (y > 110) { + clearInterval(id); + setTimeout(()=> dctx.clearRect(0,0,600,400), 50); + } + }, 20); +} + +// Start titration +function startTitration() { + const aC = parseFloat(document.getElementById('acid-conc').value); + const bC = parseFloat(document.getElementById('base-conc').value); + const aV = parseFloat(document.getElementById('acid-vol').value); + titrantVolume = 0; + clearInterval(loopID); + + loopID = setInterval(()=>{ + titrantVolume += 0.2; + if (titrantVolume > 50) { + clearInterval(loopID); + const finalPH = computePH(aC,bC,aV,titrantVolume); + displayProperty(finalPH); + return; + } + animateDrop(); + setTimeout(()=>{ + const pH = computePH(aC,bC,aV,titrantVolume); + drawBeaker(pH); + document.getElementById('titrant-volume').innerText = titrantVolume.toFixed(1); + updateHint(pH); + }, 400); + }, 600); +} + +// Reset everything +function resetTitration() { + clearInterval(loopID); + dctx.clearRect(0,0,600,400); + titrantVolume = 0; + document.getElementById('titrant-volume').innerText = '0'; + document.getElementById('hint').innerText = ''; + document.getElementById('property').innerText = ''; + drawBeaker(1); +} + +// Initial draw +window.addEventListener('load', () => { + drawBeaker(1); + updateHint(1); +}); diff --git a/static/virtual_lab/js/code_editor.js b/static/virtual_lab/js/code_editor.js new file mode 100644 index 0000000..94f9c2e --- /dev/null +++ b/static/virtual_lab/js/code_editor.js @@ -0,0 +1,68 @@ +// static/virtual_lab/js/code_editor.js + +// Simple CSRF helper +function getCookie(name) { + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); + return match ? decodeURIComponent(match[2]) : null; +} + +// Bootstrap Ace +const editor = ace.edit("editor"); +editor.setTheme("ace/theme/github"); +editor.session.setMode("ace/mode/python"); +editor.setOptions({ fontSize: "14px", showPrintMargin: false }); + +const runBtn = document.getElementById("run-btn"); +const outputEl = document.getElementById("output"); +const stdinEl = document.getElementById("stdin-input"); +const langSel = document.getElementById("language-select"); + +runBtn.addEventListener("click", () => { + const code = editor.getValue(); + const stdin = stdinEl.value; + const language = langSel.value; + + if (!code.trim()) { + outputEl.textContent = "🛑 Please type some code first."; + return; + } + outputEl.textContent = "Running…"; + runBtn.disabled = true; + + const langExtMap = { + python: "py", + javascript: "js", + c: "c", + cpp: "cpp", + }; + const ext = langExtMap[language] || "txt"; + + fetch("https://emkc.org/api/v2/piston/execute", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language: language, + version: "*", + files: [{ name: `main.${ext}`, content: code }], + stdin: stdin, + args: [], + }), + }) + .then((res) => res.json()) + .then((data) => { + let out = ""; + const run = data.run || {}; + if (run.stderr) out += `ERROR:\n${run.stderr}\n`; + if (run.stdout) out += run.stdout; + if (!out && run.output) out += run.output; + outputEl.textContent = out || "[no output]"; + }) + .catch((err) => { + outputEl.textContent = `Request failed: ${err.message}`; + }) + .finally(() => { + runBtn.disabled = false; + }); +}); diff --git a/static/virtual_lab/js/common.js b/static/virtual_lab/js/common.js new file mode 100644 index 0000000..f7b3915 --- /dev/null +++ b/static/virtual_lab/js/common.js @@ -0,0 +1,41 @@ +// Helper to get the CSRF token from cookies: +function getCSRFToken() { + const name = 'csrftoken'; + const cookies = document.cookie.split(';'); + for (let c of cookies) { + c = c.trim(); + if (c.startsWith(name + '=')) { + return decodeURIComponent(c.substring(name.length + 1)); + } + } + return ''; +} + +// A simple wrapper for POSTing JSON with CSRF: +function ajaxPost(url, data, onSuccess, onError) { + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken(), + }, + body: JSON.stringify(data), + }) + .then((resp) => { + if (!resp.ok) throw new Error('Network response was not OK'); + return resp.json(); + }) + .then(onSuccess) + .catch(onError); +} + +// A simple wrapper for GETting JSON: +function ajaxGet(url, onSuccess, onError) { + fetch(url) + .then((resp) => { + if (!resp.ok) throw new Error('Network response was not OK'); + return resp.json(); + }) + .then(onSuccess) + .catch(onError); +} diff --git a/static/virtual_lab/js/physics_electrical_circuit.js b/static/virtual_lab/js/physics_electrical_circuit.js new file mode 100644 index 0000000..a3eb268 --- /dev/null +++ b/static/virtual_lab/js/physics_electrical_circuit.js @@ -0,0 +1,404 @@ +// web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Use the sliders to set battery voltage \(V_0\), resistor \(R\), and capacitor \(C\)."], + ["Click “Start” to begin charging the capacitor through \(R\). Watch \(V_C(t)\) rise."], + ["Observe how \(I(t)\) decreases as the capacitor charges."], + ["After \(5\ tau\), answer the quiz questions."] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("circuit-canvas"); + const ctx = canvas.getContext("2d"); + + const VSlider = document.getElementById("V-slider"); + const VValue = document.getElementById("V-value"); + const RSlider = document.getElementById("R-slider"); + const RValue = document.getElementById("R-value"); + const CSlider = document.getElementById("C-slider"); + const CValue = document.getElementById("C-value"); + + const startBtn = document.getElementById("start-circuit"); + const stopBtn = document.getElementById("stop-circuit"); + const resetBtn = document.getElementById("reset-circuit"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutT = document.getElementById("readout-t"); + const readoutVc = document.getElementById("readout-vc"); + const readoutI = document.getElementById("readout-i"); + const readoutTau = document.getElementById("readout-tau"); + + const vcCtx = document.getElementById("vc-chart").getContext("2d"); + const iCtx = document.getElementById("i-chart").getContext("2d"); + + // Physical parameters (will update on slider changes) + let V0 = parseFloat(VSlider.value); // battery voltage in volts + let R = parseFloat(RSlider.value); // resistance in ohms + let C = parseFloat(CSlider.value) * 1e-6; // convert µF → F + + let tau = R * C; // time constant (s) + + // Simulation state + let t0 = null; + let animId = null; + let running = false; + let maxTime = 5 * tau; // simulate up to 5τ for quiz reveal + + // Chart.js setups + const vcData = { + labels: [], + datasets: [{ + label: "Vc(t) (V)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const vcChart = new Chart(vcCtx, { + type: "line", + data: vcData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "Vc (V)" }, suggestedMax: V0 } + }, + plugins: { legend: { display: false } } + } + }); + + const iData = { + labels: [], + datasets: [{ + label: "I(t) (A)", + data: [], + borderColor: "#DC2626", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const iChart = new Chart(iCtx, { + type: "line", + data: iData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "I (A)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + VSlider.disabled = true; + RSlider.disabled = true; + CSlider.disabled = true; + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + VSlider.disabled = false; + RSlider.disabled = false; + CSlider.disabled = false; + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + drawCircuit(0); // initial draw at t=0 + } + + // Draw the schematic of battery→resistor→capacitor and a current arrow + function drawCircuit(t) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Coordinates for schematic + const leftX = 50; + const midX = 250; + const rightX = 450; + const centerY = canvas.height / 2; + + // 1) Draw battery as two plates + ctx.fillStyle = "#333"; + ctx.fillRect(leftX - 10, centerY - 40, 10, 80); // negative plate + ctx.fillRect(leftX + 10, centerY - 20, 5, 40); // positive plate + ctx.fillStyle = "#000"; + ctx.font = "14px sans-serif"; + ctx.fillText("Battery", leftX - 20, centerY - 50); + ctx.fillText(`V₀ = ${V0.toFixed(1)}V`, leftX - 30, centerY + 60); + + // 2) Draw resistor as zig-zag between leftX+20 and midX + drawResistor(leftX + 20, centerY, midX, centerY); + ctx.fillStyle = "#000"; + ctx.fillText(`R = ${R.toFixed(0)}Ω`, (leftX + 20 + midX) / 2 - 20, centerY - 20); + + // 3) Draw capacitor between midX+20 and midX+20 horizontally (two parallel plates) + ctx.strokeStyle = "#333"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(midX + 20, centerY - 30); + ctx.lineTo(midX + 20, centerY + 30); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX + 45, centerY - 30); + ctx.lineTo(midX + 45, centerY + 30); + ctx.stroke(); + ctx.fillStyle = "#000"; + ctx.fillText(`C = ${(C * 1e6).toFixed(0)}µF`, midX + 5, centerY + 60); + + // 4) Draw wires connecting ends + ctx.strokeStyle = "#555"; + ctx.lineWidth = 2; + ctx.beginPath(); + // Top wire: battery positive → resistor start + ctx.moveTo(leftX + 10, centerY - 20); + ctx.lineTo(leftX + 20, centerY - 20); + // Continue through resistor zigzag (already drawn), then to capacitor start + ctx.lineTo(midX, centerY - 20); + ctx.lineTo(midX + 20, centerY - 20); + // From capacitor top plate back to battery negative via right wire + ctx.lineTo(midX + 45, centerY - 20); + ctx.lineTo(rightX, centerY - 20); + ctx.lineTo(rightX, centerY + 60); + // Bottom wire: capacitor bottom plate back to battery negative + ctx.lineTo(midX + 45, centerY + 60); + ctx.lineTo(midX + 20, centerY + 60); + ctx.lineTo(midX, centerY + 60); + ctx.lineTo(leftX + 20, centerY + 60); + ctx.lineTo(leftX + 10, centerY + 60); + ctx.stroke(); + + // 5) Compute Vc(t) and I(t) + const VC = V0 * (1 - Math.exp(-t / tau)); + const I = (V0 - VC) / R; + + // 6) Draw current arrow on top wire (direction: left→right). Length proportional to I. + const arrowLength = Math.min(I * 1000, 100); // scale for visibility + if (running) { + ctx.strokeStyle = "#DC2626"; + ctx.fillStyle = "#DC2626"; + ctx.lineWidth = 2; + // arrow shaft + ctx.beginPath(); + ctx.moveTo(leftX + 20, centerY - 20); + ctx.lineTo(leftX + 20 + arrowLength, centerY - 20); + ctx.stroke(); + // arrowhead + const tipX = leftX + 20 + arrowLength; + const tipY = centerY - 20; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(tipX - 8, tipY - 5); + ctx.lineTo(tipX - 8, tipY + 5); + ctx.closePath(); + ctx.fill(); + } + + // 7) Update numeric readouts + readoutT.textContent = t.toFixed(2); + readoutVc.textContent = VC.toFixed(2); + readoutI.textContent = I.toFixed(3); + readoutTau.textContent = tau.toFixed(3); + } + + // Draw a zig-zag resistor between (x1,y) and (x2,y) + function drawResistor(x1, y, x2, y2) { + const totalLength = x2 - x1; + const peaks = 6; + const segment = totalLength / (peaks * 2); + ctx.strokeStyle = "#555"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(x1, y); + for (let i = 0; i < peaks * 2; i++) { + const dx = x1 + segment * (i + 1); + const dy = y + (i % 2 === 0 ? -10 : 10); + ctx.lineTo(dx, dy); + } + ctx.lineTo(x2, y); + ctx.stroke(); + } + + // ==== 4. Slider Handlers ==== + VSlider.addEventListener("input", () => { + V0 = parseFloat(VSlider.value); + VValue.textContent = V0.toFixed(1); + // Update chart Y‐axis max + vcChart.options.scales.y.suggestedMax = V0; + vcChart.update("none"); + if (!running) { + drawCircuit(0); + } + }); + + RSlider.addEventListener("input", () => { + R = parseFloat(RSlider.value); + RValue.textContent = R.toFixed(0); + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + if (!running) { + drawCircuit(0); + } + }); + + CSlider.addEventListener("input", () => { + C = parseFloat(CSlider.value) * 1e-6; + CValue.textContent = (C * 1e6).toFixed(0); + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + if (!running) { + drawCircuit(0); + } + }); + + // ==== 5. Animation Loop & Launch ==== + function step(timestamp) { + if (!t0) t0 = timestamp; + const elapsed = (timestamp - t0) / 1000; // seconds + if (elapsed >= maxTime) { + drawCircuit(maxTime); + revealQuiz(); + return; + } + drawCircuit(elapsed); + + // Update graphs + // Vc(t) + const VC = V0 * (1 - Math.exp(-elapsed / tau)); + vcChart.data.labels.push(elapsed.toFixed(2)); + vcChart.data.datasets[0].data.push(VC.toFixed(2)); + vcChart.update("none"); + + // I(t) + const I = (V0 - VC) / R; + iChart.data.labels.push(elapsed.toFixed(2)); + iChart.data.datasets[0].data.push(I.toFixed(3)); + iChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + // Disable controls + VSlider.disabled = true; + RSlider.disabled = true; + CSlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + tau = R * C; + maxTime = 5 * tau; + t0 = null; + + // Clear graphs + vcChart.data.labels = []; + vcChart.data.datasets[0].data = []; + vcChart.update("none"); + iChart.data.labels = []; + iChart.data.datasets[0].data = []; + iChart.update("none"); + + running = true; + animId = requestAnimationFrame(step); + }); + + stopBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + }); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + t0 = null; + // Re-enable controls + VSlider.disabled = false; + RSlider.disabled = false; + CSlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear graphs + vcChart.data.labels = []; + vcChart.data.datasets[0].data = []; + vcChart.update("none"); + iChart.data.labels = []; + iChart.data.datasets[0].data = []; + iChart.update("none"); + + drawCircuit(0); + }); + + // ==== 6. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + // ==== 7. Initial Draw ==== + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + drawCircuit(0); +}); diff --git a/static/virtual_lab/js/physics_inclined.js b/static/virtual_lab/js/physics_inclined.js new file mode 100644 index 0000000..576a8dc --- /dev/null +++ b/static/virtual_lab/js/physics_inclined.js @@ -0,0 +1,497 @@ +// web/virtual_lab/static/virtual_lab/js/physics_inclined.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Drag the block along the ramp to set its starting position."], + ["Adjust angle, friction, and mass to see how physics changes."], + ["Click “Launch” and watch live readouts, force vectors, and energy bars."], + ["Observe the real-time Position vs Time graph and answer the quiz!"] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("inclined-canvas"); + const ctx = canvas.getContext("2d"); + if(!ctx) + { + console.error("Failed to get 2D context for canvas"); + return; + } + + const angleSlider = document.getElementById("angle-slider"); + const angleValue = document.getElementById("angle-value"); + const frictionSlider = document.getElementById("friction-slider"); + const frictionValue = document.getElementById("friction-value"); + const massSlider = document.getElementById("mass-slider"); + const massValue = document.getElementById("mass-value"); + + const startBtn = document.getElementById("start-inclined"); + const stopBtn = document.getElementById("stop-inclined"); + const resetBtn = document.getElementById("reset-inclined"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutS = document.getElementById("readout-s"); + const readoutV = document.getElementById("readout-v"); + const readoutA = document.getElementById("readout-a"); + const readoutPE = document.getElementById("readout-pe"); + const readoutKE = document.getElementById("readout-ke"); + + const barPE = document.getElementById("bar-pe"); + const barKE = document.getElementById("bar-ke"); + + const positionCtx = document.getElementById("position-chart").getContext("2d"); + + // Physical constants & scaling + const G = 9.81; // m/s² + const pxToM = 0.01; // 1 px = 0.01 m + const mToPx = 1 / pxToM; // inverse + const rampPx = 300; // 300 px → 3 m + const rampLen = rampPx * pxToM; // 3 m + + // Canvas dimensions & ramp origin + const W = canvas.width; // 600 px + const H = canvas.height; // 400 px + const originX = 50; // left margin + const originY = H - 50; // bottom margin + + // Experiment state + let alphaDeg = Number.parseFloat(angleSlider.value); // initial angle + let alphaRad = (alphaDeg * Math.PI) / 180; + + let mu = Number.parseFloat(frictionSlider.value); // friction coefficient + let mass = Number.parseFloat(massSlider.value); // mass in kg + + let aAcc = G * Math.sin(alphaRad) // net accel down-ramp + - mu * G * Math.cos(alphaRad); + + // Block state + let s = 0; // distance along plane (m) from top + let v = 0; // speed (m/s) + let t0 = null; // timestamp at launch + let animId = null; // requestAnimationFrame id + let running = false; // true while animating + + // Ramp geometry (recompute whenever angle changes) + let basePx, heightPx; + function updateRampGeometry() { + basePx = rampPx * Math.cos(alphaRad); + heightPx = rampPx * Math.sin(alphaRad); + } + updateRampGeometry(); + + // Chart.js: Position vs Time + const posData = { + labels: [], + datasets: [{ + label: "Position (m)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const posChart = new Chart(positionCtx, { + type: "line", + data: posData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "s (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + angleSlider.disabled = true; + frictionSlider.disabled = true; + massSlider.disabled = true; + + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + angleSlider.disabled = false; + frictionSlider.disabled = false; + massSlider.disabled = false; + drawScene(); + } + + // Draw wedge, block, forces, and update UI + function drawScene() { + ctx.clearRect(0, 0, W, H); + + // --- 3a) Draw ramp as a 3D‐style wedge with gradient --- + const grad = ctx.createLinearGradient( + originX, originY - heightPx, + originX + basePx, originY + ); + grad.addColorStop(0, "#F3F4F6"); + grad.addColorStop(1, "#E5E7EB"); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(originX + basePx, originY); + ctx.lineTo(originX, originY - heightPx); + ctx.closePath(); + ctx.fill(); + + // Outline the triangle + ctx.strokeStyle = "#4B5563"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(originX + basePx, originY); + ctx.lineTo(originX, originY - heightPx); + ctx.closePath(); + ctx.stroke(); + + // --- 3b) Compute block’s pixel position on ramp --- + const topX = originX; + const topY = originY - heightPx; + const d_px = (s / rampLen) * rampPx; // px from top down the plane + const blockX = topX + d_px * Math.cos(alphaRad); + const blockY = topY + d_px * Math.sin(alphaRad); + + // Draw block as rotated square (12×12 px) + const blkSize = 12; + ctx.save(); + ctx.translate(blockX, blockY); + ctx.rotate(-alphaRad); + ctx.fillStyle = "#DC2626"; + ctx.fillRect(-blkSize / 2, -blkSize / 2, blkSize, blkSize); + ctx.strokeStyle = "#991B1B"; + ctx.lineWidth = 1; + ctx.strokeRect(-blkSize / 2, -blkSize / 2, blkSize, blkSize); + ctx.restore(); + + // --- 3c) Draw force vectors at block center --- + drawForceVectors(blockX, blockY); + + // --- 3d) Update numeric readouts --- + readoutS.textContent = s.toFixed(2); + readoutV.textContent = v.toFixed(2); + readoutA.textContent = aAcc.toFixed(2); + + const heightM = (rampLen - s) * Math.sin(alphaRad); + const PE = mass * G * heightM; + const KE = 0.5 * mass * v * v; + readoutPE.textContent = PE.toFixed(2); + readoutKE.textContent = KE.toFixed(2); + + // --- 3e) Update energy bars --- + const maxPE = mass * G * (rampLen * Math.sin(alphaRad)); + const peFrac = maxPE > 0 ? Math.min(PE / maxPE, 1) : 0; + const keFrac = maxPE > 0 ? Math.min(KE / maxPE, 1) : 0; + barPE.style.height = `${peFrac * 100}%`; + barKE.style.height = `${keFrac * 100}%`; + } + + // Draw mg sinα (red), mg cosα (blue), friction (yellow) at block pos + function drawForceVectors(cx, cy) { + ctx.save(); + ctx.translate(cx, cy); + + // 1) mg sin α (red) + const redLen = 30; // px + ctx.strokeStyle = "#DC2626"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(redLen * Math.cos(alphaRad), redLen * Math.sin(alphaRad)); + ctx.stroke(); + drawArrowhead(redLen * Math.cos(alphaRad), redLen * Math.sin(alphaRad), alphaRad); + + // 2) mg cos α (blue) → perpendicular + const blueLen = 20; + const perpAngle = alphaRad - Math.PI / 2; + ctx.strokeStyle = "#3B82F6"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(blueLen * Math.cos(perpAngle), blueLen * Math.sin(perpAngle)); + ctx.stroke(); + drawArrowhead(blueLen * Math.cos(perpAngle), blueLen * Math.sin(perpAngle), perpAngle); + + // 3) friction (yellow) uphill (opposite motion) if μ>0 + if (mu > 0) { + const yellowLen = 25; + const fricAngle = alphaRad + Math.PI; // uphill + ctx.strokeStyle = "#FBBF24"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(yellowLen * Math.cos(fricAngle), yellowLen * Math.sin(fricAngle)); + ctx.stroke(); + drawArrowhead(yellowLen * Math.cos(fricAngle), yellowLen * Math.sin(fricAngle), fricAngle); + } + + ctx.restore(); + } + + // Draw an arrowhead at (x,y), pointing angle θ + function drawArrowhead(x, y, θ) { + const size = 6; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(θ); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-size, size / 2); + ctx.lineTo(-size, -size / 2); + ctx.closePath(); + ctx.fillStyle = ctx.strokeStyle; + ctx.fill(); + ctx.restore(); + } + + + // ==== 4. Slider Change Handlers ==== + angleSlider.addEventListener("input", () => { + alphaDeg = Number.parseFloat(angleSlider.value); + alphaRad = (alphaDeg * Math.PI) / 180; + angleValue.textContent = `${alphaDeg}°`; + updateDynamics(); + updateRampGeometry(); + if (!running) { + s = 0; + drawScene(); + } + }); + + frictionSlider.addEventListener("input", () => { + mu = Number.parseFloat(frictionSlider.value); + frictionValue.textContent = mu.toFixed(2); + updateDynamics(); + if (!running) drawScene(); + }); + + massSlider.addEventListener("input", () => { + mass = Number.parseFloat(massSlider.value); + massValue.textContent = `${mass.toFixed(1)} kg`; + if (!running) drawScene(); + }); + + function updateDynamics() { + aAcc = G * Math.sin(alphaRad) - mu * G * Math.cos(alphaRad); + } + + + // ==== Helper: Map MouseEvent → Canvas Coordinates ==== + function getMousePos(evt) { + const rect = canvas.getBoundingClientRect(); + // How many canvas pixels correspond to one CSS px? + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (evt.clientX - rect.left) * scaleX, + y: (evt.clientY - rect.top) * scaleY + }; + } + + + // ==== 5. Drag-to-Place Logic (with scaling) ==== + let isDragging = false; + + canvas.addEventListener("mousedown", (e) => { + const mouse = getMousePos(e); + + // Block’s pixel position (in internal 600×400 coordinate): + const topX = originX; + const topY = originY - heightPx; + const d_px = (s / rampLen) * rampPx; + const bx = topX + d_px * Math.cos(alphaRad); + const by = topY + d_px * Math.sin(alphaRad); + + // If click is within ~10 px (in canvas coords) of the block, start dragging: + if (Math.hypot(mouse.x - bx, mouse.y - by) < 10) { + isDragging = true; + stopAnimation(); + } + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isDragging) return; + const mouse = getMousePos(e); + + // Project onto the ramp line to find new s: + const topX = originX; + const topY = originY - heightPx; + const dx = mouse.x - topX; + const dy = mouse.y - topY; + // t* = ( (mx-topX)cosα + (my-topY)sinα ), in px along ramp + const tStar = dx * Math.cos(alphaRad) + dy * Math.sin(alphaRad); + const tClamped = Math.min(Math.max(tStar, 0), rampPx); + + s = (tClamped / rampPx) * rampLen; // convert px→m + drawScene(); + }); + + canvas.addEventListener("mouseup", () => { + if (isDragging) isDragging = false; + }); + + + // ==== 6. Animation Loop & Launch (with scaled coords) ==== + let s0 = 0; + + function step(timestamp) { + if (!t0) { + t0 = timestamp; + v = 0; + } + const t = (timestamp - t0) / 1000; // seconds + const sNew = s0 + 0.5 * aAcc * t * t; + + // If net acceleration ≤ 0, it will not move + if (aAcc <= 0) { + cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + return; + } + + if (sNew >= rampLen) { + s = rampLen; + v = aAcc * t; + drawScene(); + revealQuiz(); + return; + } + + s = sNew; + v = aAcc * t; + drawScene(); + + // Update Position vs Time chart + posChart.data.labels.push(t.toFixed(2)); + posChart.data.datasets[0].data.push(s.toFixed(2)); + posChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + angleSlider.disabled = true; + frictionSlider.disabled = true; + massSlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + s0 = s; + v = 0; + t0 = null; + running = true; + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + animId = requestAnimationFrame(step); + }); + + function stopAnimation() { + if (animId) { + cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + } + } + + stopBtn.addEventListener("click", stopAnimation); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + s = 0; + v = 0; + t0 = null; + angleSlider.disabled = false; + frictionSlider.disabled = false; + massSlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + drawScene(); + }); + + + // ==== 7. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + + // ==== 8. Initial Draw ==== + drawScene(); +}); diff --git a/static/virtual_lab/js/physics_mass_spring.js b/static/virtual_lab/js/physics_mass_spring.js new file mode 100644 index 0000000..e04f881 --- /dev/null +++ b/static/virtual_lab/js/physics_mass_spring.js @@ -0,0 +1,363 @@ +// web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Drag the mass horizontally to set its initial displacement \(A\)."], + ["Adjust the spring constant \(k\) and mass \(m\)."], + ["Click “Start” to watch \(x(t) = A \cos(\omega t)\) with \(\omega = \sqrt{k/m}\)."], + ["Observe the Position vs. Time graph and answer the quiz!"] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("mass-spring-canvas"); + const ctx = canvas.getContext("2d"); + + const kSlider = document.getElementById("k-slider"); + const kValue = document.getElementById("k-value"); + const mSlider = document.getElementById("m-slider"); + const mValue = document.getElementById("m-value"); + const ASlider = document.getElementById("A-slider"); + const AValue = document.getElementById("A-value"); + + const startBtn = document.getElementById("start-mass-spring"); + const stopBtn = document.getElementById("stop-mass-spring"); + const resetBtn = document.getElementById("reset-mass-spring"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutT = document.getElementById("readout-t"); + const readoutX = document.getElementById("readout-x"); + const readoutV = document.getElementById("readout-v"); + const readoutA = document.getElementById("readout-a"); + const readoutPE = document.getElementById("readout-pe"); + const readoutKE = document.getElementById("readout-ke"); + + const barPE = document.getElementById("bar-pe"); + const barKE = document.getElementById("bar-ke"); + + const positionCtx = document.getElementById("position-chart").getContext("2d"); + + // Physical constants & scaling + const pxToM = 0.01; // 1 px = 0.01 m + const mToPx = 1 / pxToM; // invert + const equilibriumX = canvas.width / 2; // center pixel is equilibrium (x=0) + + // DOM Element–related state + let k = Number.parseFloat(kSlider.value); // N/m + let m = Number.parseFloat(mSlider.value); // kg + let A = Number.parseFloat(ASlider.value); // m + let omega = Math.sqrt(k / m); // rad/s + + // Simulation state + let t0 = null; // timestamp at “Start” + let animId = null; // requestAnimationFrame ID + let running = false; // true while animating + + // Derived state for this run + let maxT = (2 * Math.PI) / omega; // one full period + + // Chart.js: Position vs Time + const posData = { + labels: [], + datasets: [{ + label: "x(t) (m)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const posChart = new Chart(positionCtx, { + type: "line", + data: posData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "x (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + kSlider.disabled = true; + mSlider.disabled = true; + ASlider.disabled = true; + + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + kSlider.disabled = false; + mSlider.disabled = false; + ASlider.disabled = false; + drawScene(0); // draw mass at initial x = +A + } + + // Draw mass‐spring system at time t (in seconds) + function drawScene(t) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 3a) Parameters at current run + const kVal = k; + const mVal = m; + const omegaVal = Math.sqrt(kVal / mVal); + + // 3b) Position x(t) in meters: x = A cos(omega t) + const x_m = A * Math.cos(omegaVal * t); + const v_m = -A * omegaVal * Math.sin(omegaVal * t); + const a_m = -A * omegaVal * omegaVal * Math.cos(omegaVal * t); + + // 3c) Convert x_m to pixel: 0 m → equilibriumX px, positive → right + const x_px = equilibriumX + x_m * mToPx; + const massY = canvas.height / 2; // vertical position for the block + const massSize = 20; // 20×20 px block + + // Draw horizontal line (spring baseline) + ctx.strokeStyle = "#4B5563"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, massY); + ctx.lineTo(canvas.width, massY); + ctx.stroke(); + + // Draw spring “coils” from left wall (10 px) to mass position + drawSpring(10, massY, x_px - massSize / 2, massY); + + // Draw mass as a square + ctx.fillStyle = "#DC2626"; + ctx.fillRect(x_px - massSize / 2, massY - massSize / 2, massSize, massSize); + ctx.strokeStyle = "#991B1B"; + ctx.strokeRect(x_px - massSize / 2, massY - massSize / 2, massSize, massSize); + + // 3d) Update numeric readouts + readoutT.textContent = t.toFixed(2); + readoutX.textContent = x_m.toFixed(2); + readoutV.textContent = v_m.toFixed(2); + readoutA.textContent = a_m.toFixed(2); + + const PE = 0.5 * kVal * x_m * x_m; // ½ k x² + const KE = 0.5 * mVal * v_m * v_m; // ½ m v² + readoutPE.textContent = PE.toFixed(2); + readoutKE.textContent = KE.toFixed(2); + + // 3f) Update energy bars + // Max total energy = ½ k A² + const Emax = 0.5 * kVal * A * A; + const peFrac = Emax > 0 ? Math.min(PE / Emax, 1) : 0; + const keFrac = Emax > 0 ? Math.min(KE / Emax, 1) : 0; + barPE.style.height = `${peFrac * 100}%`; + barKE.style.height = `${keFrac * 100}%`; + } + + // Draw a coiled spring from (x1,y1) to (x2,y2) + function drawSpring(x1, y1, x2, y2) { + const totalLength = x2 - x1; + const coilCount = 12; // number of loops + const coilSpacing = totalLength / (coilCount + 1); + const amplitude = 10; // amplitude of the sine wave (px) + + ctx.strokeStyle = "#4A5568"; + ctx.lineWidth = 2; + ctx.beginPath(); + + // Start at x1,y1 + ctx.moveTo(x1, y1); + + // For each coil, draw a half sine wave segment + for (let i = 1; i <= coilCount; i++) { + const cx = x1 + coilSpacing * i; + const phase = (i % 2 === 0) ? Math.PI : 0; // alternate phase for up/down + const px = cx; + const py = y1 + Math.sin(phase) * amplitude; + + // Instead of a straight line, approximate with a small sine curve + const steps = 10; // subdivide each coil into 10 segments + for (let j = 0; j <= steps; j++) { + const t = (j / steps); + const sx = x1 + coilSpacing * (i - 1) + t * coilSpacing; + const angle = ((i - 1 + t) * Math.PI); + const sy = y1 + Math.sin(angle) * amplitude * ((i - 1 + t) % 2 === 0 ? 1 : -1); + ctx.lineTo(sx, sy); + } + } + + // Finally connect to x2,y2 + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + + // ==== 4. Slider Change Handlers ==== + kSlider.addEventListener("input", () => { + k = Number.parseFloat(kSlider.value); + kValue.textContent = k.toFixed(0); + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + if (!running) { + drawScene(0); + } + }); + + mSlider.addEventListener("input", () => { + m = Number.parseFloat(mSlider.value); + mValue.textContent = `${m.toFixed(1)} kg`; + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + if (!running) { + drawScene(0); + } + }); + + ASlider.addEventListener("input", () => { + A = Number.parseFloat(ASlider.value); + AValue.textContent = A.toFixed(2); + if (!running) { + drawScene(0); + } + }); + + + // ==== 5. Animation Loop & Launch ==== + function step(timestamp) { + if (!t0) { + t0 = timestamp; + } + let elapsed = (timestamp - t0) / 1000; // seconds since start + if (elapsed >= maxT) { + // One full oscillation complete → reveal quiz + drawScene(maxT); + revealQuiz(); + return; + } + drawScene(elapsed); + + // Update Position vs Time chart + posChart.data.labels.push(elapsed.toFixed(2)); + const x_m = A * Math.cos(omega * elapsed); + posChart.data.datasets[0].data.push(x_m.toFixed(2)); + posChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + // Disable controls while animating + kSlider.disabled = true; + mSlider.disabled = true; + ASlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + // Prepare for launch + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + t0 = null; + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + running = true; + animId = requestAnimationFrame(step); + }); + + stopBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + }); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + t0 = null; + // Re-enable sliders + kSlider.disabled = false; + mSlider.disabled = false; + ASlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + // Redraw at t=0 + drawScene(0); + }); + + + // ==== 6. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + + // ==== 7. Initial Draw ==== + drawScene(0); +}); diff --git a/static/virtual_lab/js/physics_pendulum.js b/static/virtual_lab/js/physics_pendulum.js new file mode 100644 index 0000000..48874e4 --- /dev/null +++ b/static/virtual_lab/js/physics_pendulum.js @@ -0,0 +1,396 @@ +// web/virtual_lab/static/virtual_lab/js/physics_pendulum.js + +document.addEventListener("DOMContentLoaded", () => { + // -------- Tutorial Logic with Animated Steps -------- // + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + // Each step is an array of bullet‐point strings + const steps = [ + ["A pendulum consists of a mass (bob) attached to a string, swinging under gravity."], + [ + "The length (L) of the string determines how fast it swings.", + "Longer pendulum → slower oscillation; shorter → faster." + ], + [ + "The angular frequency ω = √(g / L), where g ≈ 9.81 m/s².", + "ω tells us how quickly the pendulum oscillates." + ], + [ + "The motion follows θ(t) = θ₀ · cos(ω t),", + "where θ₀ is the initial amplitude in radians." + ], + [ + "After one period T = 2π / ω, the pendulum returns to its start.", + "Click 'Next' to begin the experiment!" + ] + ]; + + let currentStep = 0; + + function showStep(index) { + stepNumberElem.textContent = index + 1; + stepList.innerHTML = ""; + steps[index].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = index === 0; + nextBtn.textContent = index === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.classList.add("hidden"); + enableControls(); + } + }); + + skipBtn.addEventListener("click", () => { + tutorialOverlay.classList.add("hidden"); + enableControls(); + }); + // -------- End Animated Tutorial Logic -------- // + + // -------- Simulation, Chart, Energy, Trail & Readouts -------- // + const canvas = document.getElementById("pendulum-canvas"); + const ctx = canvas.getContext("2d"); + const lengthSlider = document.getElementById("length-slider"); + const lengthValue = document.getElementById("length-value"); + const startButton = document.getElementById("start-pendulum"); + const stopButton = document.getElementById("stop-pendulum"); + const postlabQuiz = document.getElementById("postlab-quiz"); + + // Energy bars + const peBar = document.getElementById("pe-bar"); + const keBar = document.getElementById("ke-bar"); + + // Numeric readouts + const timeReadout = document.getElementById("time-readout"); + const angleReadout = document.getElementById("angle-readout"); + const speedReadout = document.getElementById("speed-readout"); + + // Chart.js mini‐graph + const chartCanvas = document.getElementById("angle-chart").getContext("2d"); + + // Physical constants and state + const g = 9.81; // m/s² + const originX = canvas.width / 2; + const originY = 50; // pivot-point y-coordinate + const pixelsPerMeter = 100; // 1 m → 100 px + const bobRadius = 15; // px + + let L = Number.parseFloat(lengthSlider.value); // length in meters + let omega = Math.sqrt(g / L); // angular frequency + let theta0 = 0.3; // initial amplitude (rad) + let currentAngle = theta0; + let animationId = null; + let startTime = null; + + // For drag-and-release + let isDragging = false; + let dragAngle = 0; + + // Initialize Chart.js + const angleChart = new Chart(chartCanvas, { + type: "line", + data: { + labels: [], + datasets: [{ + label: "θ(t) (rad)", + data: [], + borderColor: "#FF6633", + borderWidth: 2, + fill: false, + pointRadius: 0, + }] + }, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "Angle (rad)" }, min: -theta0, max: theta0 } + }, + plugins: { legend: { display: false } } + } + }); + + // Draw pendulum with a "trail" effect + function drawPendulum(angle, length) { + ctx.fillStyle = "rgba(255, 255, 255, 0.1)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const r = length * pixelsPerMeter; + const bobX = originX + r * Math.sin(angle); + const bobY = originY + r * Math.cos(angle); + + // Draw rod + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(bobX, bobY); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw bob + ctx.beginPath(); + ctx.arc(bobX, bobY, bobRadius, 0, 2 * Math.PI); + ctx.fillStyle = "#007BFF"; + ctx.fill(); + ctx.strokeStyle = "#0056b3"; + ctx.stroke(); + } + + // Convert mouse coords → angle from vertical + function computeAngleFromMouse(mouseX, mouseY, length) { + const dx = mouseX - originX; + const dy = mouseY - originY; + const r = length * pixelsPerMeter; + const dist = Math.hypot(dx, dy); + const scale = r / dist; + const px = dx * scale; + const py = dy * scale; + return Math.atan2(px, py); + } + + // Animation loop: updates everything each frame + function animatePendulum(timestamp) { + if (!startTime) startTime = timestamp; + const elapsedSec = (timestamp - startTime) / 1000; // ms → s + + // Angle: θ(t) = θ₀ cos(ω t) + const angle = theta0 * Math.cos(omega * elapsedSec); + currentAngle = angle; + + // 1) Draw pendulum with trail + drawPendulum(angle, L); + + // 2) Update mini-graph + if (angleChart.data.labels.length > 100) { + angleChart.data.labels.shift(); + angleChart.data.datasets[0].data.shift(); + } + angleChart.data.labels.push(elapsedSec.toFixed(2)); + angleChart.data.datasets[0].data.push(angle); + angleChart.update("none"); + + // 3) Compute energies (m = 1 kg) + const h = L * (1 - Math.cos(angle)); // height above bottom + const pe = g * h; // PE = m g h (m=1) + // Velocity: v = L * (dθ/dt) = L * (−θ₀ ω sin(ωt)) + const v = -L * theta0 * omega * Math.sin(omega * elapsedSec); + const ke = 0.5 * v * v; // KE = ½ m v² (m=1) + const E = pe + ke; + const pePct = E ? (pe / E) * 100 : 0; + const kePct = E ? (ke / E) * 100 : 0; + peBar.style.width = `${pePct.toFixed(1)}%`; + keBar.style.width = `${kePct.toFixed(1)}%`; + + // 4) Update numeric readouts + timeReadout.textContent = elapsedSec.toFixed(2); + angleReadout.textContent = (angle * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = Math.abs(v).toFixed(2); + + // 5) Reveal quiz after one full period + const period = (2 * Math.PI) / omega; + if (elapsedSec >= period && postlabQuiz.classList.contains("hidden")) { + postlabQuiz.classList.remove("hidden"); + } + + animationId = requestAnimationFrame(animatePendulum); + } + + // Enable controls after tutorial ends + function enableControls() { + startButton.disabled = false; + stopButton.disabled = false; + lengthSlider.disabled = false; + currentAngle = theta0; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + } + + // Initially disable controls + startButton.disabled = true; + stopButton.disabled = true; + lengthSlider.disabled = true; + + // Update length L & ω on slider input + lengthSlider.addEventListener("input", () => { + L = Number.parseFloat(lengthSlider.value); + lengthValue.textContent = `${L.toFixed(1)} m`; + omega = Math.sqrt(g / L); + // Adjust chart y-axis + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + if (!isDragging && !animationId) { + currentAngle = theta0; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + } + }); + + // Start button: reset chart, energy bars, trail + startButton.addEventListener("click", () => { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + postlabQuiz.classList.add("hidden"); + } + + // Clear canvas fully + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Reset chart + angleChart.data.labels = []; + angleChart.data.datasets[0].data = []; + angleChart.update("none"); + + // Reset energy bars + peBar.style.width = "0%"; + keBar.style.width = "0%"; + + // Reset numeric readouts + timeReadout.textContent = "0.00"; + angleReadout.textContent = (theta0 * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = "0.00"; + + // Use currentAngle (maybe from drag) as θ₀ + theta0 = currentAngle; + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + startTime = null; + animationId = requestAnimationFrame(animatePendulum); + }); + + // Stop button: cancel animation + stopButton.addEventListener("click", () => { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + }); + + // -------- Drag & Release Logic (Mouse + Touch) -------- // + + // Helper function to get coordinates from mouse or touch event + function getEventCoords(e) { + const rect = canvas.getBoundingClientRect(); + if (e.type.startsWith('touch')) { + return { + x: e.touches[0].clientX - rect.left, + y: e.touches[0].clientY - rect.top + }; + } else { + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + } + + // Helper function to start dragging + function startDrag(e) { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + + const coords = getEventCoords(e); + const r = L * pixelsPerMeter; + const bobX = originX + r * Math.sin(currentAngle); + const bobY = originY + r * Math.cos(currentAngle); + const distToBob = Math.hypot(coords.x - bobX, coords.y - bobY); + + if (distToBob <= bobRadius + 3) { + isDragging = true; + dragAngle = currentAngle; + e.preventDefault(); // Prevent default for touch events + } + } + + // Helper function to handle dragging + function handleDrag(e) { + if (!isDragging) return; + + const coords = getEventCoords(e); + dragAngle = computeAngleFromMouse(coords.x, coords.y, L); + currentAngle = dragAngle; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + + // Update numeric readouts during drag + timeReadout.textContent = "0.00"; + angleReadout.textContent = (currentAngle * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = "0.00"; + + // Reset energy bars (PE only) + const h = L * (1 - Math.cos(currentAngle)); + const pe = g * h; + const total = pe; + peBar.style.width = total ? `${((pe / total) * 100).toFixed(1)}%` : "0%"; + keBar.style.width = "0%"; + + e.preventDefault(); // Prevent scrolling on touch + } + + // Helper function to end dragging + function endDrag() { + if (!isDragging) return; + isDragging = false; + theta0 = dragAngle; + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + startTime = null; + animationId = requestAnimationFrame(animatePendulum); + } + + // Mouse events + canvas.addEventListener("mousedown", startDrag); + canvas.addEventListener("mousemove", handleDrag); + canvas.addEventListener("mouseup", endDrag); + canvas.addEventListener("mouseleave", endDrag); + + // Touch events for mobile + canvas.addEventListener("touchstart", startDrag); + canvas.addEventListener("touchmove", handleDrag); + canvas.addEventListener("touchend", endDrag); + canvas.addEventListener("touchcancel", endDrag); + + // Prevent default drag behavior + canvas.addEventListener("dragstart", (e) => { + e.preventDefault(); + }); + + // -------- End Drag & Release Logic -------- // +}); diff --git a/static/virtual_lab/js/physics_projectile.js b/static/virtual_lab/js/physics_projectile.js new file mode 100644 index 0000000..a48e456 --- /dev/null +++ b/static/virtual_lab/js/physics_projectile.js @@ -0,0 +1,412 @@ +// web/virtual_lab/static/virtual_lab/js/physics_projectile.js + +document.addEventListener("DOMContentLoaded", () => { + // -------- 1. Tutorial Overlay Logic (animated bullet points) -------- // + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Click-and-drag FROM the launch pad (white circle at bottom-left) to set initial speed and angle."], + [ + "Drag length sets speed (longer drag → higher speed), direction sets angle.", + "Release to start the simulation." + ], + [ + "Adjust gravity and wind sliders BEFORE dragging; they affect the predicted trajectory.", + "After release, the full path animates." + ], + [ + "During flight, small x/y axes are drawn on the ball, showing velocity components.", + "Watch the “y vs x” plot updating in real time." + ], + ["Click “Begin Experiment” when ready, then drag from the launch pad!"] + ]; + + let currentStep = 0; + function showStep(index) { + stepNumberElem.textContent = index + 1; + stepList.innerHTML = ""; + steps[index].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = index === 0; + nextBtn.textContent = index === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // -------- End Tutorial Logic -------- // + + + // -------- 2. DOM References & State -------- // + const canvas = document.getElementById("projectile-canvas"); + const ctx = canvas.getContext("2d"); + + const trajectoryCanvas = document.getElementById("trajectory-chart").getContext("2d"); + + const gravitySlider = document.getElementById("gravity-slider"); + const gravityValue = document.getElementById("gravity-value"); + const windSlider = document.getElementById("wind-slider"); + const windValue = document.getElementById("wind-value"); + const resetButton = document.getElementById("reset-button"); + + const timeReadout = document.getElementById("time-readout"); + const xReadout = document.getElementById("x-readout"); + const yReadout = document.getElementById("y-readout"); + const vxReadout = document.getElementById("vx-readout"); + const vyReadout = document.getElementById("vy-readout"); + + let g = Number.parseFloat(gravitySlider.value); + let windAccel = Number.parseFloat(windSlider.value); + + let originY = canvas.height - 10; + let pixelsPerMeter = 10; + + let v0 = 0, thetaRad = 0, vx = 0, vy0 = 0; + let maxRange = 0, maxHeight = 0; + + // Stores trajectory points and velocities + let trajectoryPoints = []; // { x_m, y_m, vx_t, vy_t } + let currentFrame = 0; + let animationId = null; + + const trajData = { + labels: [], + datasets: [{ + label: "y vs x (m)", + data: [], + borderColor: "#3182CE", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const trajChart = new Chart(trajectoryCanvas, { + type: "line", + data: trajData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "x (m)" } }, + y: { title: { display: true, text: "y (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + gravitySlider.disabled = true; + windSlider.disabled = true; + resetButton.disabled = true; + + + // -------- 3. Enable Controls & Initial Drawing -------- // + function enableControls() { + gravitySlider.disabled = false; + windSlider.disabled = false; + resetButton.disabled = false; + drawScene(); + } + + function drawAxes() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + ctx.moveTo(0, originY); + ctx.lineTo(canvas.width, originY); + ctx.strokeStyle = "#2D3748"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function drawLaunchPad() { + const padX = 10, padY = originY, padR = 8; + ctx.beginPath(); + ctx.arc(padX, padY, padR, 0, 2 * Math.PI); + ctx.fillStyle = "#FFFFFF"; + ctx.fill(); + ctx.strokeStyle = "#4A5568"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function drawScene() { + drawAxes(); + drawLaunchPad(); + } + + // -------- 4. Gravity & Wind Slider Handlers -------- // + gravitySlider.addEventListener("input", () => { + g = Number.parseFloat(gravitySlider.value); + gravityValue.textContent = g.toFixed(2); + }); + windSlider.addEventListener("input", () => { + windAccel = Number.parseFloat(windSlider.value); + windValue.textContent = windAccel.toFixed(2); + }); + + + // -------- 5. Aim-by-Drag Logic -------- // + let isAiming = false; + let aimStartX = 0, aimStartY = 0; + let aimCurrentX = 0, aimCurrentY = 0; + + canvas.addEventListener("mousedown", (e) => { + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const padX = 10, padY = originY, padR = 8; + const dist = Math.hypot(mouseX - padX, mouseY - padY); + if (dist <= padR + 4) { + isAiming = true; + aimStartX = padX; + aimStartY = padY; + aimCurrentX = mouseX; + aimCurrentY = mouseY; + + trajChart.data.labels = []; + trajChart.data.datasets[0].data = []; + trajChart.update("none"); + + timeReadout.textContent = "0.00"; + xReadout.textContent = "0.00"; + yReadout.textContent = "0.00"; + vxReadout.textContent = "0.00"; + vyReadout.textContent = "0.00"; + + drawScene(); + } + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isAiming) return; + const rect = canvas.getBoundingClientRect(); + aimCurrentX = e.clientX - rect.left; + aimCurrentY = e.clientY - rect.top; + + drawScene(); + + ctx.beginPath(); + ctx.moveTo(aimStartX, aimStartY); + ctx.lineTo(aimCurrentX, aimCurrentY); + ctx.strokeStyle = "#DD6B20"; + ctx.lineWidth = 3; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + }); + + canvas.addEventListener("mouseup", (e) => { + if (!isAiming) return; + isAiming = false; + + const dx_px = aimCurrentX - aimStartX; + const dy_px = aimCurrentY - aimStartY; + if (dx_px <= 0) { + drawScene(); + return; + } + + // Convert drag to meters using temporary scale: 1 px → 0.1 m + const tmpScale = 0.1; + const dx_m = dx_px * tmpScale; + const dy_m = (originY - aimCurrentY) * tmpScale; // invert y-axis + + v0 = Math.hypot(dx_m, dy_m); + thetaRad = Math.atan2(dy_m, dx_m); + vx = v0 * Math.cos(thetaRad); + vy0 = v0 * Math.sin(thetaRad); + + // Compute theoretical max range & height + maxRange = (v0 * v0 * Math.sin(2 * thetaRad)) / g; + maxHeight = (v0 * v0 * Math.sin(thetaRad) * Math.sin(thetaRad)) / (2 * g); + + // Recalculate pixelsPerMeter so full path fits + const marginX = 60, marginY = 60; + const availableWidth = canvas.width - marginX; + const availableHeight = originY - marginY; + const scaleX = availableWidth / (maxRange + 1); + const scaleY = availableHeight / (maxHeight + 1); + pixelsPerMeter = Math.min(scaleX, scaleY); + + // Build the discrete trajectory points + buildTrajectoryPoints(); + + // Clear and draw static full trajectory faintly + drawScene(); + drawStaticTrajectory(); + + // Start animation loop + currentFrame = 0; + if (animationId) cancelAnimationFrame(animationId); + animationId = requestAnimationFrame(animateBall); + }); + + + // -------- 6. Build Trajectory Points & Velocities -------- // + function buildTrajectoryPoints() { + trajectoryPoints = []; + const timeOfFlight = (2 * vy0) / g; + const steps = 200; + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * timeOfFlight; + const x_m = vx * t + 0.5 * windAccel * t * t; + const y_m = vy0 * t - 0.5 * g * t * t; + if (y_m < 0) { + trajectoryPoints.push({ x_m, y_m: 0, vx_t: vx + windAccel * t, vy_t: 0 }); + break; + } + const vx_t = vx + windAccel * t; + const vy_t = vy0 - g * t; + trajectoryPoints.push({ x_m, y_m, vx_t, vy_t }); + } + } + + // -------- 7. Draw Static Full Trajectory (faint) -------- // + function drawStaticTrajectory() { + ctx.beginPath(); + trajectoryPoints.forEach((pt, idx) => { + const px = pt.x_m * pixelsPerMeter + 10; + const py = originY - pt.y_m * pixelsPerMeter; + if (idx === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.strokeStyle = "rgba(221, 107, 32, 0.3)"; // faint orange + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.stroke(); + } + + // -------- 8. Animation Loop: Move Ball & Draw Axes-On-Ball -------- // + function animateBall() { + if (currentFrame >= trajectoryPoints.length) return; + + // Clear and redraw background & static path + drawScene(); + drawStaticTrajectory(); + + const pt = trajectoryPoints[currentFrame]; + const canvasX = pt.x_m * pixelsPerMeter + 10; + const canvasY = originY - pt.y_m * pixelsPerMeter; + + // Draw the ball + ctx.beginPath(); + ctx.arc(canvasX, canvasY, 6, 0, 2 * Math.PI); + ctx.fillStyle = "#E53E3E"; + ctx.fill(); + ctx.strokeStyle = "#9B2C2C"; + ctx.stroke(); + + // Draw small x/y axes on the ball, and show velocity components + drawAxesOnBall(canvasX, canvasY, pt.vx_t, pt.vy_t); + + // Update numeric readouts + const t = (currentFrame / (trajectoryPoints.length - 1)) * ((2 * vy0) / g); + timeReadout.textContent = t.toFixed(2); + xReadout.textContent = pt.x_m.toFixed(2); + yReadout.textContent = pt.y_m.toFixed(2); + vxReadout.textContent = pt.vx_t.toFixed(2); + vyReadout.textContent = pt.vy_t.toFixed(2); + + // Update live plot with just this point + trajChart.data.labels.push(pt.x_m.toFixed(2)); + trajChart.data.datasets[0].data.push(pt.y_m.toFixed(2)); + trajChart.update("none"); + + currentFrame++; + animationId = requestAnimationFrame(animateBall); + } + + function drawAxesOnBall(cx, cy, vx_t, vy_t) { + // Draw a small cross (x and y axes) centered on the ball + const axisLen = 12; // total length of each axis line + ctx.strokeStyle = "#000000"; + ctx.lineWidth = 1; + ctx.beginPath(); + // Horizontal axis line + ctx.moveTo(cx - axisLen / 2, cy); + ctx.lineTo(cx + axisLen / 2, cy); + // Vertical axis line + ctx.moveTo(cx, cy - axisLen / 2); + ctx.lineTo(cx, cy + axisLen / 2); + ctx.stroke(); + + // Now plot velocity components as small dots on those axes: + // Scale velocities so they fit within half-axis length + const vScale = 0.2; // 1 m/s → 0.2 px + let vx_px = vx_t * vScale; + let vy_px = vy_t * vScale; + // Clamp so dot stays on axis line + vx_px = Math.max(Math.min(vx_px, axisLen / 2), -axisLen / 2); + vy_px = Math.max(Math.min(vy_px, axisLen / 2), -axisLen / 2); + + // Draw x-velocity dot (blue) on horizontal axis + ctx.beginPath(); + ctx.arc(cx + vx_px, cy, 2.5, 0, 2 * Math.PI); + ctx.fillStyle = "#3182CE"; + ctx.fill(); + + // Draw y-velocity dot (green) on vertical axis + ctx.beginPath(); + ctx.arc(cx, cy - vy_px, 2.5, 0, 2 * Math.PI); + ctx.fillStyle = "#38A169"; + ctx.fill(); + } + + // -------- 9. Reset Handler -------- // + resetButton.addEventListener("click", () => { + if (animationId) cancelAnimationFrame(animationId); + trajectoryPoints = []; + currentFrame = 0; + trajChart.data.labels = []; + trajChart.data.datasets[0].data = []; + trajChart.update("none"); + + drawScene(); + + g = 9.81; + windAccel = 0; + gravitySlider.value = "9.81"; + windSlider.value = "0"; + gravityValue.textContent = "9.81"; + windValue.textContent = "0.00"; + + timeReadout.textContent = "0.00"; + xReadout.textContent = "0.00"; + yReadout.textContent = "0.00"; + vxReadout.textContent = "0.00"; + vyReadout.textContent = "0.00"; + }); + + + // -------- 10. Initial Draw -------- // + drawScene(); +}); diff --git a/virtual_lab/chemistry/index.html b/virtual_lab/chemistry/index.html new file mode 100644 index 0000000..f9d808c --- /dev/null +++ b/virtual_lab/chemistry/index.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/chemistry/ph-indicator/index.html b/virtual_lab/chemistry/ph-indicator/index.html new file mode 100644 index 0000000..cee0209 --- /dev/null +++ b/virtual_lab/chemistry/ph-indicator/index.html @@ -0,0 +1,661 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

pH Indicator

+
+ +
+ + + +
+ Enter a pH value (0–14) and click Update to see the color change. +
+
+
+ +
+ + + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/precipitation/index.html b/virtual_lab/chemistry/precipitation/index.html new file mode 100644 index 0000000..9da1d22 --- /dev/null +++ b/virtual_lab/chemistry/precipitation/index.html @@ -0,0 +1,659 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Precipitation Reaction

+
+ +
+ + + +
+ Click Add Reagent to begin. +
+
+
+ +
+ + + + + + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/reaction-rate/index.html b/virtual_lab/chemistry/reaction-rate/index.html new file mode 100644 index 0000000..a67ac30 --- /dev/null +++ b/virtual_lab/chemistry/reaction-rate/index.html @@ -0,0 +1,656 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Reaction Rate

+
+ +
+ + + +
+ Elapsed Time: + 0 s +
+
+ Set the concentration and start to see the reaction proceed... +
+
+ +
+
+ + +
+
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/solubility/index.html b/virtual_lab/chemistry/solubility/index.html new file mode 100644 index 0000000..d82d45c --- /dev/null +++ b/virtual_lab/chemistry/solubility/index.html @@ -0,0 +1,656 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Solubility & Saturation

+
+ +
+ + + +
+ Dissolved: + 0 g +
+
+ Solution is unsaturated. Add more solute to test saturation. +
+
+ +
+
+ + +
+
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/titration/index.html b/virtual_lab/chemistry/titration/index.html new file mode 100644 index 0000000..0d73cbc --- /dev/null +++ b/virtual_lab/chemistry/titration/index.html @@ -0,0 +1,680 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Acid-Base Titration

+
+ +
+ + + + + +
+ Titrant Added: + 0 mL +
+
+ Adjust the controls and start titration to see hints here... +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/virtual_lab/code-editor/index.html b/virtual_lab/code-editor/index.html new file mode 100644 index 0000000..f4a71fb --- /dev/null +++ b/virtual_lab/code-editor/index.html @@ -0,0 +1,684 @@ + + + + + + + + + + + + + + + + + Code Editor – Alpha Science Lab + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Interactive Code Editor

+ +
print("Hello, world!")
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+

Output:

+

+    
+
+ +
+ + +
+ + + + + + + + + + + + + + diff --git a/virtual_lab/index.html b/virtual_lab/index.html new file mode 100644 index 0000000..6cde3e0 --- /dev/null +++ b/virtual_lab/index.html @@ -0,0 +1,657 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/circuit/index.html b/virtual_lab/physics/circuit/index.html new file mode 100644 index 0000000..c37b788 --- /dev/null +++ b/virtual_lab/physics/circuit/index.html @@ -0,0 +1,763 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Basic Electrical Circuit

+

+ Build a simple RC circuit: a battery \(V_0\), a resistor \(R\), and a capacitor \(C\). Adjust \(V_0\), \(R\), and \(C\), then click “Start” to watch the capacitor charge. Observe real‐time \(V_C(t)\), \(I(t)\), and a live graph of capacitor voltage over time. After \(5\tau\), a quiz appears. +

+
+
+ +
+ +
+
+ + + 5.0 V +
+
+ + + 100 Ω +
+
+ + + 100 µF +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Voltage \(V_C\): 0.00 V +

+

+ Current \(I\): 0.00 A +

+

+ Time Constant \(\tau\): 0.01 s +

+
+
+ + +
+ +
+ +
+

Capacitor Voltage vs Time

+ +
+ +
+

Current vs Time

+ +
+
+
+
+
+
+ + + + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/inclined/index.html b/virtual_lab/physics/inclined/index.html new file mode 100644 index 0000000..ecd7677 --- /dev/null +++ b/virtual_lab/physics/inclined/index.html @@ -0,0 +1,785 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Inclined Plane Dynamics

+

+ Drag the block to any starting point, adjust angle, friction, and mass. + Click “Launch” to let it slide, watch live readouts, energy bars, force vectors, and a real-time graph. + Once it reaches the bottom, a short quiz will appear. +

+
+
+
+
+
+ + + 30° +
+
+ + + 0.00 +
+
+ + + 1.0 kg +
+
+ + + +
+
+ +
+ + +
+

+ Distance ↓: 0.00 m +

+

+ Speed: 0.00 m/s +

+

+ Accel: 0.00 m/s² +

+

+ PE: 0.00 J +

+

+ KE: 0.00 J +

+
+
+ +
+
+
+ mg sin α +
+
+
+ Normal (mg cos α) +
+
+
+ Friction (μ mg cos α) +
+
+
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+ + +
+
+
+
+
+ + + + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/mass_spring/index.html b/virtual_lab/physics/mass_spring/index.html new file mode 100644 index 0000000..87cdd28 --- /dev/null +++ b/virtual_lab/physics/mass_spring/index.html @@ -0,0 +1,784 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Mass–Spring Oscillation

+

+ Drag the mass horizontally to set its initial displacement. Adjust the spring constant \(k\) and mass \(m\). + Click “Start” to see the mass oscillate. A live Position vs. Time graph and numeric readouts will update in real time. + After one full oscillation, a post-lab quiz will appear. +

+
+
+ +
+ +
+
+ + + 25 +
+
+ + + 1.0 kg +
+
+ + + 0.20 m +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Position: 0.00 m +

+

+ Velocity: 0.00 m/s +

+

+ Acceleration: 0.00 m/s² +

+

+ Potential (½kx²): 0.00 J +

+

+ Kinetic (½mv²): 0.00 J +

+
+
+ + +
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+
+
+
+
+
+ + + + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/pendulum/index.html b/virtual_lab/physics/pendulum/index.html new file mode 100644 index 0000000..f1e033f --- /dev/null +++ b/virtual_lab/physics/pendulum/index.html @@ -0,0 +1,733 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 5 +

+
    + +
+
+ + + +
+
+
+ +
+
+ +
+

Pendulum Motion

+

+ Follow the animated tutorial above, then start the simulation. Watch the pendulum swing and see its trail. Numerical readouts appear at top‐left. +

+
+ +
+ + + +
+
+ t = 0.00 s +
+
+ θ = 0.0° +
+
+ v = 0.0 m/s +
+
+ +
+

θ vs t

+ + +
+
+ + +
+
+
Potential Energy
+
+
+
+
+
+
Kinetic Energy
+
+
+
+
+
+ + +
+
+ + + 1.0 m +
+ + +
+ + +
+
+
+ + + +
+ + +
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/projectile/index.html b/virtual_lab/physics/projectile/index.html new file mode 100644 index 0000000..e497aa2 --- /dev/null +++ b/virtual_lab/physics/projectile/index.html @@ -0,0 +1,764 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 5 +

+
    + +
+
+ + + +
+
+
+ +
+
+ +
+

Projectile Motion

+

+ Click‐and‐drag from the launch pad (white circle) at left to set speed and angle. +
+ Release to fire. Adjust gravity or wind on the fly. + Watch the path and vectors, and see “y vs x” plotted live. +

+
+ +
+ +
+ + + +
+ + Launch Pad +
+ +
+
+ t = 0.00 s +
+
+ x = 0.00 m +
+
+ y = 0.00 m +
+
+ vₓ = 0.00 m/s +
+
+ v_y = 0.00 m/s +
+
+ +
+ +
+

Trajectory: y vs x

+ + +
+
+ + +
+
Mid-Flight Controls:
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + +
+
+
+ + + +
+ + +
+ + + + + + + + + + + +