<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>旋转圆模型 - 教案演示专用版 (v6.2 精修+数值显示)</title>
<style>
/* --- 样式表 (视觉增强版 - 精简UI) --- */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif; background-color: #050510; color: #e0e0e0; overflow: hidden; height: 100vh; width: 100vw; }
#canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; background: radial-gradient(circle at center, #1a1a3a 0%, #050510 100%); }
canvas { display: block; width: 100%; height: 100%; }
/* --- 修改处:控制面板样式调整 --- */
/* 去掉了 max-height 和 overflow-y: auto,使面板能根据内容自动撑开高度,并不再显示滚动条 */
.control-panel { position: absolute; top: 20px; right: 20px; width: 380px; background: rgba(20, 20, 45, 0.95); backdrop-filter: blur(15px); border-radius: 18px; padding: 25px; border: 2px solid rgba(100, 100, 150, 0.4); box-shadow: 0 15px 50px rgba(0, 0, 0, 0.7); z-index: 10; }
/* --- 修改处:去掉了原来的滚动条样式 --- */
/* .control-panel::-webkit-scrollbar { width: 6px; } */
/* .control-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.3); border-radius: 3px; } */
.panel-header { margin-bottom: 25px; border-bottom: 2px solid rgba(255,255,255,0.15); padding-bottom: 15px; }
h1 { font-size: 1.6em; font-weight: 800; background: linear-gradient(90deg, #34d399, #3b82f6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 8px; letter-spacing: 1px; }
.subtitle { font-size: 0.9em; color: #bbbec7; font-weight: 500; }
.control-group { margin-bottom: 22px; }
.control-group label { display: block; font-size: 1em; color: #e2e8f0; margin-bottom: 10px; font-weight: 600; }
/* 滑块加粗 */
input[type="range"] { width: 100%; height: 8px; border-radius: 4px; background: rgba(255,255,255,0.15); outline: none; -webkit-appearance: none; cursor: pointer; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; border-radius: 50%; background: #34d399; cursor: pointer; box-shadow: 0 0 15px rgba(52, 211, 153, 0.7); border: 3px solid #fff; }
/* 按钮加粗 */
button { width: 100%; padding: 12px; border: none; border-radius: 10px; font-family: inherit; font-size: 1.05em; font-weight: 700; cursor: pointer; transition: all 0.2s; display: flex; justify-content: center; align-items: center; gap: 10px; letter-spacing: 1px; }
.btn-sweep { background: linear-gradient(135deg, #10b981, #059669); color: white; margin-bottom: 18px; box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); }
.btn-sweep.active { background: linear-gradient(135deg, #ef4444, #dc2626); box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); }
.btn-primary { background: #34d399; color: #000; box-shadow: 0 6px 20px rgba(52, 211, 153, 0.4); }
.btn-secondary { background: rgba(255,255,255,0.15); color: #fff; margin-top: 12px; border: 2px solid rgba(255,255,255,0.1); }
.preset-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.preset-btn { padding: 10px; font-size: 0.9em; background: rgba(255,255,255,0.1); color: #d1d5db; border: 2px solid transparent; }
.preset-btn:hover { background: rgba(255,255,255,0.2); color: #fff; border-color: rgba(52, 211, 153, 0.5); }
.checkbox-group { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 15px; }
.checkbox-item { display: flex; align-items: center; gap: 10px; font-size: 0.95em; color: #e2e8f0; cursor: pointer; font-weight: 600; }
/* 复选框变大 */
.checkbox-item input { accent-color: #34d399; width: 18px; height: 18px; }
.info-box { background: rgba(0,0,0,0.4); padding: 15px; border-radius: 12px; margin-top: 20px; font-size: 0.95em; border: 2px solid rgba(52, 211, 153, 0.2); }
.info-row { display: flex; justify-content: space-between; margin-bottom: 8px; color: #bbbec7; font-weight: 600; }
.info-val { color: #34d399; font-family: 'JetBrains Mono', monospace; font-size: 1.1em; }
</style>
</head>
<body>
<div id="canvas-container">
<canvas id="canvas"></canvas>
</div>
<div class="control-panel">
<div class="panel-header">
<h1>教案演示专用版 </h1>
<p class="subtitle">莆田一中物理组陈青妹(版权所有)</p>
</div>
<button class="btn-sweep" id="sweepBtn" onclick="toggleSweep()">
<span id="sweepIcon">🔄</span> <span id="sweepText">启动逆时针扫描</span>
</button>
<div class="control-group">
<label>动画速度</label>
<input type="range" id="speedSlider" min="0.1" max="3.0" step="0.1" value="1.0">
</div>
<div class="control-group">
<label>发射角度 θ <span id="angleValue" style="float: right; color: #34d399; font-family: monospace;">0°</span></label>
<input type="range" id="angleSlider" min="-180" max="180" value="0">
</div>
<div class="control-group">
<label>S 到板距离 L <span id="distanceValue" style="float: right; color: #34d399; font-family: monospace;">1.00 r</span></label>
<input type="range" id="distanceSlider" min="0.3" max="2.1" step="0.05" value="1.0">
</div>
<div class="control-group">
<label style="color: #34d399;">演示选项 (核心功能)</label>
<div class="checkbox-group">
<label class="checkbox-item" style="grid-column: span 2; color: #fff; background: rgba(52, 211, 153, 0.2); padding: 8px; border-radius: 8px;">
<input type="checkbox" id="showFullCircle"> 🟢 补全完整圆 (优先显示)
</label>
<label class="checkbox-item"><input type="checkbox" id="showEnvelope" checked> 包络圆 (2R)</label>
<label class="checkbox-item"><input type="checkbox" id="showHitRange" checked> 打中区域</label>
<label class="checkbox-item"><input type="checkbox" id="showCenterLocus" checked> 圆心轨迹</label>
<label class="checkbox-item"><input type="checkbox" id="showRealOnly" checked> 撞墙即停</label>
</div>
</div>
<button class="btn-primary" id="playBtn" onclick="togglePlay()">
<span id="playIcon">▶</span> <span id="playText">播放粒子</span>
</button>
<button class="btn-secondary" onclick="resetAnimation()">↺ 重置</button>
<div class="info-box">
<div class="info-row"><span>落点高度 Y</span><span class="info-val" id="landingY">—</span></div>
<div class="info-row"><span>打中区域长度</span><span class="info-val" id="hitLength">—</span></div>
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let r = 100;
let L = 1.0;
let centerX, centerY, scale;
let S = { x: 0, y: 0 };
let emissionAngle = 0;
let isPlaying = false;
let isSweeping = false;
let animationId, sweepId;
let currentProgress = 0;
let speedMult = 1.0;
// let sweepDir = 1; // 修改:不再需要反向变量
function init() {
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
document.getElementById('speedSlider').addEventListener('input', e => {
speedMult = parseFloat(e.target.value);
});
document.getElementById('angleSlider').addEventListener('input', e => {
if(isSweeping) stopSweep();
emissionAngle = parseFloat(e.target.value);
updateUI(); resetAnimation(); draw();
});
document.getElementById('distanceSlider').addEventListener('input', e => {
L = parseFloat(e.target.value);
// 当 L 改变时,如果当前是相切状态,需要重新计算相切角
// 这里简单处理,直接更新UI和重绘
updateUI(); resetAnimation(); draw();
});
['showFullCircle', 'showEnvelope', 'showHitRange', 'showCenterLocus', 'showRealOnly'].forEach(id => {
document.getElementById(id).addEventListener('change', draw);
});
updateUI(); draw();
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
centerX = canvas.width * 0.4;
centerY = canvas.height * 0.5;
const minDim = Math.min(canvas.width * 0.7, canvas.height * 0.8);
r = minDim / 5;
draw();
}
function toScreen(x, y) { return { x: centerX + x, y: centerY - y }; }
// 逆时针物理模型
function getCircleCenter(theta) {
const rad = theta * Math.PI / 180;
return {
x: r * Math.cos(rad + Math.PI/2),
y: r * Math.sin(rad + Math.PI/2)
};
}
function calculatePath() {
const center = getCircleCenter(emissionAngle);
const abX = L * r;
const startAng = Math.atan2(0 - center.y, 0 - center.x);
let intersection = null;
let sweepAngle = 2 * Math.PI;
const dx = abX - center.x;
if (Math.abs(dx) <= r) {
const dy = Math.sqrt(r*r - dx*dx);
const y1 = center.y + dy; const y2 = center.y - dy;
let ang1 = Math.atan2(y1 - center.y, abX - center.x);
let ang2 = Math.atan2(y2 - center.y, abX - center.x);
let diff1 = ang1 - startAng; while(diff1 <= 0) diff1 += 2*Math.PI;
let diff2 = ang2 - startAng; while(diff2 <= 0) diff2 += 2*Math.PI;
if (diff1 < diff2) { sweepAngle = diff1; intersection = { x: abX, y: y1 }; }
else { sweepAngle = diff2; intersection = { x: abX, y: y2 }; }
}
return { center, startAng, sweepAngle, intersection };
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawMagneticField();
drawBoundaryAB();
if(document.getElementById('showEnvelope').checked) drawEnvelope();
if(document.getElementById('showCenterLocus').checked) drawCenterLocus();
if(document.getElementById('showHitRange').checked) drawHitRange();
const path = calculatePath();
drawTrajectory(path);
drawSource();
drawParticle(path);
updateInfoPanel(path);
}
function drawTrajectory(path) {
const cPos = toScreen(path.center.x, path.center.y);
const showFull = document.getElementById('showFullCircle').checked;
const showRealOnly = document.getElementById('showRealOnly').checked;
if (showFull) {
ctx.beginPath();
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 6;
ctx.arc(cPos.x, cPos.y, r, 0, Math.PI * 2);
ctx.stroke();
} else {
if (!showRealOnly || path.intersection) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 3;
ctx.setLineDash([8, 8]);
ctx.arc(cPos.x, cPos.y, r, 0, Math.PI*2);
ctx.stroke();
ctx.setLineDash([]);
}
// --- 绘制实线轨迹 (移除这里的箭头) ---
ctx.beginPath();
ctx.strokeStyle = '#34d399';
ctx.lineWidth = 6;
const steps = 100;
let lastP;
for(let i=0; i<=steps; i++) {
const ang = path.startAng + (i/steps) * path.sweepAngle;
const px = path.center.x + r * Math.cos(ang);
const py = path.center.y + r * Math.sin(ang);
const p = toScreen(px, py);
if (i===0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
lastP = p;
}
ctx.stroke();
// 修改:已移除绿色轨迹末端的箭头绘制代码
}
if (path.intersection && !showFull) {
const hitP = toScreen(path.intersection.x, path.intersection.y);
ctx.beginPath(); ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 4;
ctx.arc(hitP.x, hitP.y, 8, 0, Math.PI*2); ctx.stroke();
}
ctx.fillStyle = '#f97316'; ctx.beginPath(); ctx.arc(cPos.x, cPos.y, 6, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = 'rgba(249, 115, 22, 0.6)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(cPos.x, cPos.y); ctx.lineTo(toScreen(0,0).x, toScreen(0,0).y); ctx.stroke();
}
// --- 绘制箭头的辅助函数 ---
function drawArrowHead(x, y, angle, color) {
const headLen = 15; // 箭头长度
ctx.beginPath();
ctx.fillStyle = color;
// 屏幕坐标系Y轴向下,角度需要取反
const screenAngle = -angle;
ctx.moveTo(x, y);
ctx.lineTo(x - headLen * Math.cos(screenAngle - Math.PI/6), y - headLen * Math.sin(screenAngle - Math.PI/6));
ctx.lineTo(x - headLen * Math.cos(screenAngle + Math.PI/6), y - headLen * Math.sin(screenAngle + Math.PI/6));
ctx.fill();
}
function drawParticle(path) {
if(!isPlaying && currentProgress === 0) return;
const curAng = path.startAng + currentProgress * path.sweepAngle;
const pPos = toScreen(path.center.x + r * Math.cos(curAng), path.center.y + r * Math.sin(curAng));
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(pPos.x, pPos.y, 8, 0, Math.PI*2); ctx.fill();
const grad = ctx.createRadialGradient(pPos.x, pPos.y, 0, pPos.x, pPos.y, 25);
grad.addColorStop(0, 'rgba(52, 211, 153, 0.8)'); grad.addColorStop(1, 'rgba(52, 211, 153, 0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(pPos.x, pPos.y, 25, 0, Math.PI*2); ctx.fill();
if (currentProgress >= 1 && path.intersection) {
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(pPos.x, pPos.y, 15, 0, Math.PI*2); ctx.stroke();
}
}
function drawHitRange() {
const abX = L * r; if(L > 2) return;
const yTop = Math.sqrt(2*L - L*L) * r;
const yBottom = -Math.sqrt(4 - L*L) * r;
const pTop = toScreen(abX, yTop); const pBottom = toScreen(abX, yBottom);
ctx.strokeStyle = 'rgba(251, 191, 36, 0.9)';
ctx.lineWidth = 16;
ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(pTop.x, pTop.y); ctx.lineTo(pBottom.x, pBottom.y); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(pTop.x, pTop.y, 6, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(pBottom.x, pBottom.y, 6, 0, Math.PI*2); ctx.fill();
}
function drawSource() {
const sPos = toScreen(0,0);
ctx.fillStyle = '#f472b6'; ctx.beginPath(); ctx.arc(sPos.x, sPos.y, 12, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.textBaseline='middle'; ctx.fillText('S', sPos.x-20, sPos.y);
const rad = emissionAngle * Math.PI / 180;
ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 5;
// 计算线的终点
const lineLen = 50;
const endX = sPos.x + lineLen * Math.cos(rad);
const endY = sPos.y - lineLen * Math.sin(rad);
ctx.beginPath(); ctx.moveTo(sPos.x, sPos.y);
ctx.lineTo(endX, endY); ctx.stroke();
// --- 修改:在紫色线的末端添加箭头 ---
drawArrowHead(endX, endY, rad, '#a78bfa');
}
function drawMagneticField() {
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.font = 'bold 16px sans-serif';
for(let x=0; x<canvas.width; x+=60) for(let y=0; y<canvas.height; y+=60) ctx.fillText('×', x, y);
}
function drawBoundaryAB() {
const abX = L * r; const p1 = toScreen(abX, 1000); const p2 = toScreen(abX, -1000);
ctx.strokeStyle = '#ec4899'; ctx.lineWidth = 8;
ctx.beginPath(); ctx.moveTo(p1.x, 0); ctx.lineTo(p2.x, canvas.height); ctx.stroke();
}
function drawEnvelope() {
const sPos = toScreen(0,0); ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
ctx.lineWidth = 4;
ctx.setLineDash([15, 15]);
ctx.arc(sPos.x, sPos.y, 2 * r, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
}
function drawCenterLocus() {
const sPos = toScreen(0,0);
ctx.strokeStyle = 'rgba(251, 146, 60, 0.6)';
ctx.lineWidth = 4;
ctx.setLineDash([12, 12]);
ctx.beginPath(); ctx.arc(sPos.x, sPos.y, r, 0, Math.PI*2); ctx.stroke(); ctx.setLineDash([]);
}
function togglePlay() { if(isSweeping) stopSweep(); isPlaying = !isPlaying; document.getElementById('playText').textContent = isPlaying ? "暂停" : "播放粒子"; if(isPlaying) loopAnim(); else cancelAnimationFrame(animationId); }
function loopAnim() { if(!isPlaying) return; currentProgress += 0.015 * speedMult; if(currentProgress >= 1) { currentProgress = 1; isPlaying = false; document.getElementById('playText').textContent = "播放粒子"; } draw(); if(isPlaying) animationId = requestAnimationFrame(loopAnim); }
function resetAnimation() { isPlaying = false; currentProgress = 0; document.getElementById('playText').textContent = "播放粒子"; cancelAnimationFrame(animationId); draw(); }
function toggleSweep() {
isSweeping = !isSweeping;
const btn = document.getElementById('sweepBtn');
if(isSweeping) {
isPlaying = false;
btn.classList.add('active');
document.getElementById('sweepText').textContent = "停止扫描"; // 修改按钮文字
loopSweep();
} else {
stopSweep();
}
}
function stopSweep() {
isSweeping = false;
cancelAnimationFrame(sweepId);
document.getElementById('sweepBtn').classList.remove('active');
document.getElementById('sweepText').textContent = "启动逆时针扫描"; // 恢复按钮文字
}
// --- 修改:单向逆时针扫描逻辑 ---
function loopSweep() {
if(!isSweeping) return;
emissionAngle += 0.5 * speedMult; // 永远增加
// 可以选择让角度在 -180 到 180 之间循环,或者一直增加
// 这里让它一直增加,滑块会自动跟随
updateUI(); draw();
sweepId = requestAnimationFrame(loopSweep);
}
function updateUI() {
document.getElementById('angleSlider').value = emissionAngle;
// --- 修改:恢复了更新数字显示的逻辑 ---
document.getElementById('angleValue').textContent = emissionAngle.toFixed(0) + "°";
document.getElementById('distanceValue').textContent = L.toFixed(2) + " r";
}
function setAngle(v) { if(isSweeping) stopSweep(); emissionAngle = v; updateUI(); resetAnimation(); draw(); }
// --- 新增:精确计算相切角度的函数 ---
function setTangentAngle() {
if(isSweeping) stopSweep();
// 相切条件:圆心到直线的距离等于半径 R
// 圆心 x坐标 cx = L - R
// cx = R * cos(theta + 90) = -R * sin(theta)
// 所以 -R * sin(theta) = L - R => sin(theta) = 1 - L/R
let targetAngleRad;
if (L <= 2*r) {
targetAngleRad = Math.asin(1 - L/r);
} else {
targetAngleRad = Math.asin(-1); // L>2R 时,取 -90度
}
emissionAngle = targetAngleRad * 180 / Math.PI;
updateUI(); resetAnimation(); draw();
}
function updateInfoPanel(path) {
if(path.intersection) document.getElementById('landingY').textContent = (path.intersection.y / r).toFixed(2) + " r"; else document.getElementById('landingY').textContent = "未打中";
if(L <= 2) { const tVal = 2*L - L*L; const top = (tVal>=0) ? Math.sqrt(tVal) : 0; document.getElementById('hitLength').textContent = "+" + top.toFixed(2) + " / -" + Math.sqrt(4-L*L).toFixed(2) + " r"; } else { document.getElementById('hitLength').textContent = "0.00 r"; }
}
init();
</script>
</body>
</html>