<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水平边界模型 - 防遮挡布局版 (v14.0)</title>
<style>
/* --- 样式表 --- */
* { 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;
display: flex; /* Flex布局 */
}
/* 左侧画布区域 */
#canvas-wrapper {
flex: 1; /* 占据剩余空间 */
position: relative;
background: radial-gradient(circle at bottom center, #1e293b 0%, #0f172a 100%);
overflow: hidden;
}
canvas { display: block; width: 100%; height: 100%; }
.canvas-label { position: absolute; font-family: 'Microsoft YaHei', sans-serif; font-size: 16px; font-weight: bold; pointer-events: none; text-shadow: 0 2px 4px rgba(0,0,0,0.9); white-space: nowrap; transform: translate(-50%, -50%); }
.lbl-white { color: #f1f5f9; } .lbl-yellow { color: #fbbf24; } .lbl-green { color: #34d399; } .lbl-blue { color: #60a5fa; } .lbl-pink { color: #f472b6; }
/* 右侧控制面板 (固定宽度) */
.control-panel {
width: 360px;
height: 100%;
background: rgba(30, 41, 59, 0.95);
border-left: 1px solid rgba(148, 163, 184, 0.2);
padding: 25px;
overflow-y: auto;
box-shadow: -5px 0 20px rgba(0,0,0,0.5);
z-index: 10;
}
.control-panel::-webkit-scrollbar { width: 5px; } .control-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
.panel-header { margin-bottom: 20px; border-bottom: 2px solid rgba(255,255,255,0.1); padding-bottom: 15px; }
h1 { font-size: 1.5em; font-weight: 800; background: linear-gradient(90deg, #fbbf24, #f472b6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 5px; letter-spacing: 1px; }
.subtitle { font-size: 0.9em; color: #94a3b8; font-weight: 500; }
.control-group { margin-bottom: 20px; }
.control-group label { display: flex; justify-content: space-between; font-size: 0.95em; color: #e2e8f0; margin-bottom: 8px; font-weight: 600; }
.val-disp { font-family: 'JetBrains Mono', monospace; color: #fbbf24; font-weight: bold; }
input[type="range"] { width: 100%; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.1); outline: none; -webkit-appearance: none; cursor: pointer; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #fbbf24; cursor: pointer; box-shadow: 0 0 10px rgba(251, 191, 36, 0.6); border: 2px solid #fff; }
button { width: 100%; padding: 12px; border: none; border-radius: 10px; font-family: inherit; font-size: 1em; font-weight: 700; cursor: pointer; transition: all 0.2s; display: flex; justify-content: center; align-items: center; gap: 8px; }
.btn-play { background: linear-gradient(135deg, #10b981, #059669); color: white; margin-bottom: 15px; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4); }
.btn-play.playing { background: linear-gradient(135deg, #ef4444, #dc2626); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
.btn-play.paused { background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4); }
.btn-sweep { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; margin-bottom: 15px; box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4); }
.btn-sweep.active { background: linear-gradient(135deg, #ef4444, #dc2626); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); }
.btn-toggle { background: rgba(255,255,255,0.1); color: #fff; border: 2px solid rgba(255,255,255,0.2); margin-bottom: 15px; }
.btn-toggle.ccw { background: linear-gradient(135deg, #60a5fa, #3b82f6); border-color: transparent; }
.btn-toggle.cw { background: linear-gradient(135deg, #f472b6, #ec4899); border-color: transparent; }
.btn-reset { background: rgba(255,255,255,0.1); color: #cbd5e1; border: 1px solid rgba(255,255,255,0.1); }
.btn-reset:hover { background: rgba(255,255,255,0.2); color: #fff; }
.checkbox-group { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 10px; }
.cb-item { display: flex; align-items: center; gap: 10px; font-size: 0.9em; color: #cbd5e1; cursor: pointer; }
.cb-item input { accent-color: #fbbf24; width: 16px; height: 16px; }
.info-box { background: rgba(0,0,0,0.3); padding: 15px; border-radius: 10px; margin-top: 20px; border: 1px solid rgba(255,255,255,0.1); }
.info-row { display: flex; justify-content: space-between; margin-bottom: 6px; color: #94a3b8; font-size: 0.9em; }
.info-val { color: #fbbf24; font-family: monospace; font-size: 1.1em; }
</style>
</head>
<body>
<div id="canvas-wrapper">
<canvas id="canvas"></canvas>
<div id="labels-layer"></div>
</div>
<div class="control-panel">
<div class="panel-header">
<h1>水平边界模型 (v14.0)</h1>
<p class="subtitle">防遮挡布局 | 完美展示</p>
</div>
<button class="btn-play" id="playBtn" onclick="togglePlay()">
<span id="playIcon">▶</span> <span id="playText">播放粒子动画</span>
</button>
<div class="control-group">
<label>动画速度 <span class="val-disp" id="speedVal">1.0x</span></label>
<input type="range" id="speedSlider" min="0.2" max="3.0" step="0.1" value="1.0">
</div>
<button class="btn-sweep" id="sweepBtn" onclick="toggleSweep()">
<span id="sweepIcon">🔄</span> <span id="sweepText">启动角度扫描</span>
</button>
<div class="control-group">
<label>旋转方向</label>
<button id="dirBtn" class="btn-toggle ccw" onclick="toggleDirection()">
<span id="dirIcon">🔄</span> <span id="dirText">逆时针 (CCW) - 打左侧</span>
</button>
</div>
<div class="control-group">
<label>发射角度 (0°~180°) <span class="val-disp" id="angleVal">90°</span></label>
<input type="range" id="angleSlider" min="0" max="180" step="1" value="90">
</div>
<div class="control-group">
<label style="color:#fbbf24">显示选项</label>
<div class="checkbox-group">
<label class="cb-item"><input type="checkbox" id="showEnvelope" checked> 包络线 (绿色 2R)</label>
<label class="cb-item"><input type="checkbox" id="showLocus" checked> 圆心轨迹 (白色 R)</label>
<label class="cb-item"><input type="checkbox" id="showLimits" checked> 极限轨迹 (左/右)</label>
<label class="cb-item"><input type="checkbox" id="showHit" checked> 打中区域 (黄色实线)</label>
<label class="cb-item" style="color:#fbbf24"><input type="checkbox" id="showParticle" checked> 始终显示粒子</label>
</div>
</div>
<button class="btn-reset" onclick="reset()">↺ 重置视图</button>
<div class="info-box">
<div class="info-row"><span>当前落点 X</span><span class="info-val" id="landX">0.00 R</span></div>
<div class="info-row"><span>打中范围</span><span class="info-val">[-2R, +2R]</span></div>
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const wrapper = document.getElementById('canvas-wrapper');
const ctx = canvas.getContext('2d');
const labelsLayer = document.getElementById('labels-layer');
let R = 1.0;
let scale, originY, originX;
let angleDeg = 90;
let isSweeping = false;
let sweepDir = 1;
let rotationDir = 1;
let isAnimating = false;
let animProgress = 0;
let animId;
let simSpeed = 1.0;
function init() {
window.addEventListener('resize', resize);
resize(); // 初始调整大小
document.getElementById('angleSlider').addEventListener('input', e => {
if(isSweeping) stopSweep();
if(isAnimating) stopAnim(); // 拖动时暂停播放
isAnimating = false; // 确保状态更新
updatePlayButton(); // 更新按钮显示
animProgress = 0; // 重置进度
angleDeg = parseFloat(e.target.value);
updateUI(); draw();
});
document.getElementById('speedSlider').addEventListener('input', e => {
simSpeed = parseFloat(e.target.value);
document.getElementById('speedVal').textContent = simSpeed.toFixed(1) + "x";
});
['showEnvelope', 'showLocus', 'showLimits', 'showHit', 'showParticle'].forEach(id => {
document.getElementById(id).addEventListener('change', draw);
});
updateUI(); draw();
}
// 修改:根据 wrapper 大小计算
function resize() {
canvas.width = wrapper.clientWidth;
canvas.height = wrapper.clientHeight;
originX = canvas.width / 2;
originY = canvas.height * 0.75;
// 确保能放下 2R (宽度) 和 R (高度)
// 左右各需 2R,总宽 4R。上下需 R+余量。
// 增加一点边距缩放
scale = Math.min(canvas.width / 5, originY / 1.5);
draw();
}
function toScreen(x, y) {
return { x: originX + x * scale, y: originY - y * scale };
}
function toggleDirection() {
rotationDir *= -1;
// 切换方向时彻底重置动画状态
isAnimating = false;
animProgress = 0;
updatePlayButton();
updateUI(); draw();
}
function getTrajectoryData(deg) {
const t_eff = deg * Math.PI / 180;
let cx, cy;
if (rotationDir === 1) { // CCW
cx = -R * Math.sin(t_eff);
cy = R * Math.abs(Math.cos(t_eff));
} else { // CW
cx = R * Math.sin(t_eff);
cy = R * Math.abs(Math.cos(t_eff));
}
const startAng = Math.atan2(-cy, -cx);
const endAng = Math.atan2(-cy, cx);
let sweep;
if (rotationDir === 1) { // CCW
sweep = endAng - startAng;
while (sweep <= 0) sweep += 2 * Math.PI;
} else { // CW
sweep = endAng - startAng;
while (sweep >= 0) sweep -= 2 * Math.PI;
}
return { cx, cy, startAng, sweep, landingX: 2*cx };
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
labelsLayer.innerHTML = '';
drawMagneticField();
drawMNLine();
if(document.getElementById('showEnvelope').checked) drawEnvelope();
if(document.getElementById('showLocus').checked) drawLocus();
if(document.getElementById('showHit').checked) drawHitRange();
if(document.getElementById('showLimits').checked) drawLimits();
const traj = getTrajectoryData(angleDeg);
drawCurrentTrajectory(traj);
drawSourceO();
if (isAnimating || document.getElementById('showParticle').checked || animProgress > 0) {
drawMovingParticle(traj);
}
const landX = traj.landingX;
document.getElementById('landX').textContent = landX.toFixed(2) + " R";
}
function togglePlay() {
if(isSweeping) stopSweep();
if (isAnimating) {
isAnimating = false;
cancelAnimationFrame(animId);
} else {
if (animProgress >= 1) animProgress = 0;
isAnimating = true;
animLoop();
}
updatePlayButton();
}
function updatePlayButton() {
const btn = document.getElementById('playBtn');
const icon = document.getElementById('playIcon');
const text = document.getElementById('playText');
if (isAnimating) {
btn.className = 'btn-play playing';
icon.textContent = '⏸';
text.textContent = '暂停动画';
} else {
if (animProgress > 0 && animProgress < 1) {
btn.className = 'btn-play paused';
icon.textContent = '▶';
text.textContent = '继续播放';
} else {
btn.className = 'btn-play';
icon.textContent = '▶';
text.textContent = '播放粒子动画';
}
}
}
function stopAnim() {
isAnimating = false;
cancelAnimationFrame(animId);
updatePlayButton(); // 确保按钮状态正确
// 不重置 animProgress,允许定格
draw();
}
function animLoop() {
if(!isAnimating) return;
animProgress += 0.008 * simSpeed;
if(animProgress >= 1) {
animProgress = 1;
draw();
isAnimating = false;
updatePlayButton();
return;
}
draw();
animId = requestAnimationFrame(animLoop);
}
function drawMovingParticle(traj) {
const currentAng = traj.startAng + animProgress * traj.sweep;
const px = traj.cx + R * Math.cos(currentAng);
const py = traj.cy + R * Math.sin(currentAng);
const pPos = toScreen(px, py);
ctx.beginPath();
ctx.fillStyle = '#fff'; ctx.arc(pPos.x, pPos.y, 6, 0, Math.PI*2); ctx.fill();
const grad = ctx.createRadialGradient(pPos.x, pPos.y, 0, pPos.x, pPos.y, 25);
grad.addColorStop(0, 'rgba(251, 191, 36, 0.8)'); grad.addColorStop(1, 'rgba(251, 191, 36, 0)');
ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(pPos.x, pPos.y, 25, 0, Math.PI*2); ctx.fill();
if (animProgress >= 1) {
ctx.beginPath();
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 4;
ctx.arc(pPos.x, pPos.y, 15, 0, Math.PI*2); ctx.stroke();
}
}
function drawCurrentTrajectory(traj) {
const cPos = toScreen(traj.cx, traj.cy);
ctx.beginPath(); ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 4;
ctx.save(); ctx.rect(0, 0, canvas.width, originY); ctx.clip();
ctx.beginPath(); ctx.arc(cPos.x, cPos.y, R*scale, 0, Math.PI*2); ctx.stroke();
ctx.restore();
ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(cPos.x, cPos.y, 5, 0, Math.PI*2); ctx.fill();
}
function drawSourceO() {
const oPos = toScreen(0,0);
ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(oPos.x, oPos.y, 8, 0, Math.PI*2); ctx.fill();
addLabel(oPos.x, oPos.y+25, 'O', 'lbl-white');
if(!isAnimating && animProgress === 0) {
const rad = angleDeg * Math.PI / 180;
ctx.strokeStyle = '#fbbf24'; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(oPos.x, oPos.y);
ctx.lineTo(oPos.x + 50 * Math.cos(rad), oPos.y - 50 * Math.sin(rad)); ctx.stroke();
}
}
function drawMNLine() {
ctx.beginPath(); ctx.strokeStyle = '#e2e8f0'; ctx.lineWidth = 4;
ctx.moveTo(0, originY); ctx.lineTo(canvas.width, originY); ctx.stroke();
addLabel(canvas.width - 50, originY + 20, 'MN', 'lbl-white');
}
function drawEnvelope() {
const sPos = toScreen(0,0); ctx.beginPath(); ctx.strokeStyle = 'rgba(52, 211, 153, 0.4)'; ctx.lineWidth = 2; ctx.setLineDash([10,10]);
ctx.arc(sPos.x, sPos.y, 2*R*scale, Math.PI, 0); ctx.stroke(); ctx.setLineDash([]);
addLabel(sPos.x, sPos.y - 2*R*scale - 15, '包络线 2R', 'lbl-green');
}
function drawLocus() {
const sPos = toScreen(0,0); ctx.beginPath(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; ctx.lineWidth = 2; ctx.setLineDash([5,5]);
ctx.arc(sPos.x, sPos.y, R*scale, Math.PI, 0); ctx.stroke(); ctx.setLineDash([]);
}
function drawHitRange() {
const p1 = toScreen(-2*R, 0); const p2 = toScreen(2*R, 0);
ctx.beginPath(); ctx.strokeStyle = 'rgba(251, 191, 36, 0.8)'; ctx.lineWidth = 8; ctx.lineCap = 'round';
ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
addLabel(p1.x, p1.y + 25, '-2R', 'lbl-yellow'); addLabel(p2.x, p2.y + 25, '+2R', 'lbl-yellow');
}
function drawLimits() {
drawCircle(-R, 0, '#60a5fa', true); addLabel(toScreen(-R, R).x, toScreen(-R, R).y - 10, '左极限', 'lbl-blue');
drawCircle(R, 0, '#f472b6', true); addLabel(toScreen(R, R).x, toScreen(R, R).y - 10, '右极限', 'lbl-pink');
}
function drawCircle(cx, cy, color, dashed) {
const pos = toScreen(cx, cy); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 2;
if(dashed) ctx.setLineDash([8,8]);
ctx.save(); ctx.rect(0, 0, canvas.width, originY); ctx.clip();
ctx.arc(pos.x, pos.y, R*scale, 0, 2*Math.PI);
ctx.stroke(); ctx.restore(); ctx.setLineDash([]);
}
function drawMagneticField() {
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; ctx.font = 'bold 20px sans-serif';
for(let x=0; x<canvas.width; x+=80) for(let y=originY-80; y>0; y-=80) ctx.fillText('×', x, y);
}
function addLabel(x, y, txt, cls) {
const el = document.createElement('div'); el.className = `canvas-label ${cls}`; el.textContent = txt; el.style.left = x + 'px'; el.style.top = y + 'px'; labelsLayer.appendChild(el);
}
function toggleSweep() {
if(isSweeping) { isSweeping = false; cancelAnimationFrame(sweepId); document.getElementById('sweepBtn').classList.remove('active'); document.getElementById('sweepText').textContent = "启动扫描"; }
else { isSweeping = true; document.getElementById('sweepBtn').classList.add('active'); document.getElementById('sweepText').textContent = "停止扫描"; sweepLoop(); }
}
function sweepLoop() {
if(!isSweeping) return;
angleDeg += sweepDir * 0.8 * simSpeed;
if(angleDeg >= 180 || angleDeg <= 0) sweepDir *= -1;
if(angleDeg < 0) angleDeg = 0; if(angleDeg > 180) angleDeg = 180;
updateUI(); draw();
sweepId = requestAnimationFrame(sweepLoop);
}
function reset() {
isSweeping = false; isAnimating = false; animProgress = 0;
cancelAnimationFrame(sweepId); cancelAnimationFrame(animId);
document.getElementById('sweepBtn').classList.remove('active');
updatePlayButton();
angleDeg = 90; updateUI(); draw();
}
function updateUI() {
document.getElementById('angleSlider').value = angleDeg;
document.getElementById('angleVal').textContent = angleDeg.toFixed(0) + "°";
}
init();
</script>
</body>
</html>