<!DOCTYPE html>
<html>
<head>
<style>
/* 基礎樣式 */
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f0f0f0;
font-family: Arial, sans-serif;
}
.container {
position: relative;
width: 1200px;
height: 800px;
background: white;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.draggable {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: move;
transform: translate(-50%, -50%);
z-index: 500;
}
#source {
background: red;
}
#receiver {
background: blue;
}
canvas {
background: #f0f0f0;
position: absolute;
top: 0;
left: 0;
z-index: 100;
width: 1200px;
height: 800px;
}
/* 控制面板樣式 */
.controls {
position: absolute;
top: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
padding: 8px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
/* 主控制面板 */
.control-panel {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: #f8f8f8;
border-radius: 6px;
}
/* 各控制區段 */
.control-section {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-right: 1px solid #ddd;
}
.control-section:last-child {
border-right: none;
}
/* 按鈕樣式 */
button {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.9em;
}
/* 方向按鈕組 */
.direction-buttons {
display: flex;
gap: 2px;
margin-right: 4px;
}
.direction-buttons button {
padding: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
/* 滑塊樣式 */
input[type="range"] {
width: 100px;
}
/* 計時器 */
.timer {
font-family: monospace;
font-size: 1.2em;
font-weight: bold;
padding: 4px 8px;
background: white;
border-radius: 4px;
margin-left: auto;
}
/* 聲音控制 */
.sound-toggles {
display: flex;
flex-direction: column;
gap: 2px;
}
.sound-toggles label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9em;
}
/* 數據顯示 */
.data-display {
display: flex;
justify-content: center;
gap: 20px;
padding: 8px;
background: #f8f8f8;
border-radius: 6px;
}
.display-item {
font-size: 0.9em;
}
span[id$="Value"],
span[id$="Display"],
span[id="receivedFreq"] {
font-family: monospace;
padding: 2px 4px;
background: white;
border-radius: 3px;
border: 1px solid #ddd;
margin-left: 4px;
}
/* 響應式調整 */
@media (max-width: 1200px) {
.control-panel {
flex-wrap: wrap;
}
.control-section {
border-right: none;
padding: 4px 8px;
}
}
</style>
</head>
<body>
<div class="container">
<canvas id="canvas"></canvas>
<div id="source" class="draggable"></div>
<div id="receiver" class="draggable"></div>
<div class="controls">
<!-- 所有控制項在同一行 -->
<div class="control-panel">
<!-- 時間控制 -->
<div class="control-section">
<button id="pauseBtn">暫停</button>
<button id="resetBtn">重置</button><div>
<button id="stepBackward" disabled>←0.01s</button>
<button id="stepForward" disabled>0.01s→</button>
<!-- 時間顯示 -->
<div class="timer" id="timer">01:27.27
</div>
</div>
<!-- 頻率控制 -->
<div class="control-section">
<label>頻率:
<input type="range" id="frequency" min="1" max="10" step="0.1" value="2">
<span id="freqValue">2 Hz</span>
</label>
</div>
<!-- 聲源速度控制 -->
<div class="control-section">
<label>聲源速度
<div class="direction-buttons">
<button id="sourceLeft">←</button>
<button id="sourceUp">↑</button>
<button id="sourceDown">↓</button>
<button id="sourceRight">→</button>
</div>
<input type="range" id="sourceSpeed" min="0" max="200" value="0">
<span id="sourceSpeedValue">0</span>
</div>
<!-- 接收器速度控制 -->
<div class="control-section">
<label>接收器速度
<div class="direction-buttons">
<button id="receiverLeft">←</button>
<button id="receiverUp">↑</button>
<button id="receiverDown">↓</button>
<button id="receiverRight">→</button>
</div>
<input type="range" id="receiverSpeed" min="0" max="200" value="0">
<span id="receiverSpeedValue">0</span>
<button id="stopMovementBtn">停止運動</button>
</div>
<!-- 聲音控制 -->
<div class="control-section">
<div class="sound-toggles">
<label>
<input type="checkbox" id="sourceSound"> 聲源聲音
</label>
<label>
<input type="checkbox" id="receiverSound"> 接收器聲音
</label>
</div>
</div>
</div></div>
<!-- 數據顯示區 -->
<div class="data-display">
<div class="display-item">接收頻率: <span id="receivedFreq">2.00 Hz</span></div>
<div class="display-item">聲源速度: <span id="sourceVelDisplay">0.0 px/s</span></div>
<div class="display-item">接收器速度: <span id="receiverVelDisplay">0.0 px/s</span></div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const source = document.getElementById('source');
const receiver = document.getElementById('receiver');
const DEFAULT_SOURCE_POS = { x: 300, y: 400 }; // 聲源的預設位置
const DEFAULT_RECEIVER_POS = { x: 900, y: 400 }; // 接收器的預設位置
canvas.width = 1200;
canvas.height = 800;
const WAVE_SPEED = 150;
const GRID_SIZE = 50;
const TIME_STEP = 0.01; // 時間步長(秒)
let globalTime = 0; // 全域時間(秒)
let isPaused = false;
let startTime = Date.now();
let elapsedTime = 0;
let timerInterval;
let waves = [];
// 初始位置和當前位置分開存儲
let sourceInitialPos = { x: 300, y: 400 };
let receiverInitialPos = { x: 900, y: 400 };
let sourcePos = { ...sourceInitialPos };
let receiverPos = { ...receiverInitialPos };
let sourceVel = { x: 0, y: 0 };
let receiverVel = { x: 0, y: 0 };
let sourceStartTime = 0;
let receiverStartTime = 0;
function updateTimer() {
let totalTime;
if (!isPaused) {
// 正常計時模式:計算從開始到現在的總時間
const currentTime = Date.now();
totalTime = elapsedTime + (currentTime - startTime);
} else {
// 暫停/步進模式:直接使用 elapsedTime
totalTime = elapsedTime;
}
// 計算分、秒、百分秒
const minutes = Math.floor(totalTime / 60000);
const seconds = Math.floor((totalTime % 60000) / 1000);
const centiseconds = Math.floor((totalTime % 1000) / 10);
// 格式化顯示
document.getElementById('timer').textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
}
function startTimer() {
startTime = Date.now();
timerInterval = setInterval(updateTimer, 10);
}
function stopTimer() {
clearInterval(timerInterval);
}
function resetTimer() {
clearInterval(timerInterval);
elapsedTime = 0;
startTime = Date.now();
document.getElementById('timer').textContent = '00:00:00';
if (!isPaused) {
startTimer();
}
}
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
function createTone(frequency, volume = 0.3, duration = 0.1) {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
gainNode.gain.setValueAtTime(volume, audioContext.currentTime);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + duration);
}
function playSourceSound() {
if (document.getElementById('sourceSound').checked) {
createTone(440, 0.3);
}
}
function playReceiverSound() {
if (document.getElementById('receiverSound').checked) {
createTone(660, 0.3);
}
}
function drawGrid() {
ctx.beginPath();
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 0.5;
for (let x = 0; x <= canvas.width; x += GRID_SIZE) {
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
}
for (let y = 0; y <= canvas.height; y += GRID_SIZE) {
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
}
ctx.stroke();
}
function snapToGrid(pos) {
if (document.getElementById('snapToGrid').checked) {
return {
x: Math.round(pos.x / GRID_SIZE) * GRID_SIZE,
y: Math.round(pos.y / GRID_SIZE) * GRID_SIZE
};
}
return pos;
}
function updatePositions(time) {
if (sourceVel.x !== 0 || sourceVel.y !== 0) {
sourcePos.x = Math.max(0, Math.min(1200, sourceInitialPos.x + sourceVel.x * (time - sourceStartTime)));
sourcePos.y = Math.max(0, Math.min(800, sourceInitialPos.y + sourceVel.y * (time - sourceStartTime)));
}
if (receiverVel.x !== 0 || receiverVel.y !== 0) {
receiverPos.x = Math.max(0, Math.min(1200, receiverInitialPos.x + receiverVel.x * (time - receiverStartTime)));
receiverPos.y = Math.max(0, Math.min(800, receiverInitialPos.y + receiverVel.y * (time - receiverStartTime)));
}
}
function updatePositionDisplay() {
source.style.left = sourcePos.x + 'px';
source.style.top = sourcePos.y + 'px';
receiver.style.left = receiverPos.x + 'px';
receiver.style.top = receiverPos.y + 'px';
}
function enableDrag(element, pos, isSource) {
let isDragging = false;
let initialX, initialY;
element.addEventListener('mousedown', e => {
if (!isPaused) {
isDragging = true;
const rect = canvas.getBoundingClientRect();
initialX = e.clientX - rect.left - pos.x;
initialY = e.clientY - rect.top - pos.y;
// 拖動開始時重置速度
if (isSource) {
sourceVel = { x: 0, y: 0 };
} else {
receiverVel = { x: 0, y: 0 };
}
}
});
document.addEventListener('mousemove', e => {
if (isDragging && !isPaused) {
const rect = canvas.getBoundingClientRect();
let newPos = {
x: e.clientX - rect.left - initialX,
y: e.clientY - rect.top - initialY
};
newPos = snapToGrid(newPos);
if (isSource) {
sourcePos = { ...newPos };
sourceInitialPos = { ...newPos };
} else {
receiverPos = { ...newPos };
receiverInitialPos = { ...newPos };
}
updatePositionDisplay();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
}
function drawWaves(time) {
const maxDistance = Math.sqrt(1200 * 1200 + 800 * 800);
waves = waves.filter(wave => {
const waveAge = time - wave.birthTime;
const radius = WAVE_SPEED * waveAge;
if (radius > maxDistance) return false;
ctx.beginPath();
ctx.arc(wave.sourceX, wave.sourceY, radius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,0,0,1)';
ctx.stroke();
if (!wave.hasTriggeredReceiver) {
const dx = receiverPos.x - wave.sourceX;
const dy = receiverPos.y - wave.sourceY;
const distanceToReceiver = Math.sqrt(dx * dx + dy * dy);
if (Math.abs(distanceToReceiver - radius) < 5) {
wave.hasTriggeredReceiver = true;
playReceiverSound();
}
}
return true;
});
}
function animate() {
if (!isPaused) {
globalTime += TIME_STEP;
updatePositions(globalTime);
const frequency = Number(document.getElementById('frequency').value);
const period = 1 / frequency;
if (Math.floor(globalTime / period) > Math.floor((globalTime - TIME_STEP) / period)) {
waves.push({
birthTime: globalTime,
sourceX: sourcePos.x,
sourceY: sourcePos.y,
hasTriggeredReceiver: false
});
playSourceSound();
}
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawGrid();
drawWaves(globalTime);
updatePositionDisplay();
const sourceSpeed = Math.sqrt(sourceVel.x * sourceVel.x + sourceVel.y * sourceVel.y);
const receiverSpeed = Math.sqrt(receiverVel.x * receiverVel.x + receiverVel.y * receiverVel.y);
document.getElementById('sourceVelDisplay').textContent = sourceSpeed.toFixed(1);
document.getElementById('receiverVelDisplay').textContent = receiverSpeed.toFixed(1);
const frequency = Number(document.getElementById('frequency').value);
const c = WAVE_SPEED;
const v_s = sourceSpeed;
const v_r = receiverSpeed;
const dx = receiverPos.x - sourcePos.x;
const dy = receiverPos.y - sourcePos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const cos_theta_s = (dx * sourceVel.x + dy * sourceVel.y) / (distance * v_s || 1);
const cos_theta_r = (dx * receiverVel.x + dy * receiverVel.y) / (distance * v_r || 1);
const f = frequency * (c - v_r * cos_theta_r) / (c - v_s * cos_theta_s);
document.getElementById('receivedFreq').textContent = f.toFixed(2) + ' Hz';
requestAnimationFrame(animate);
}
function updateStepButtonsState() {
const stepBackward = document.getElementById('stepBackward');
const stepForward = document.getElementById('stepForward');
stepBackward.disabled = !isPaused;
stepForward.disabled = !isPaused;
}
// 修改事件監聽器
document.getElementById('pauseBtn').addEventListener('click', function() {
isPaused = !isPaused;
this.textContent = isPaused ? '繼續' : '暫停';
if (isPaused) {
stopTimer();
elapsedTime += Date.now() - startTime;
} else {
startTime = Date.now();
startTimer();
}
updateStepButtonsState(); // 添加這行
});
// 添加步進按鈕的事件監聽器
document.getElementById('stepForward').addEventListener('click', function() {
if (isPaused) {
const oldTime = globalTime;
globalTime += TIME_STEP;
// 檢查是否需要產生新的波
const frequency = Number(document.getElementById('frequency').value);
const period = 1 / frequency;
if (Math.floor(globalTime / period) > Math.floor(oldTime / period)) {
waves.push({
birthTime: globalTime,
sourceX: sourcePos.x,
sourceY: sourcePos.y,
hasTriggeredReceiver: false
});
playSourceSound();
}
// 更新位置和動畫
updatePositions(globalTime);
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawGrid();
drawWaves(globalTime);
updatePositionDisplay();
// 更新碼錶顯示
console.log('Before update, elapsedTime:', elapsedTime);
elapsedTime += TIME_STEP * 1000;
console.log('After update, elapsedTime:', elapsedTime);
updateTimer();
}
});
document.getElementById('stepBackward').addEventListener('click', function() {
if (isPaused && globalTime >= TIME_STEP) {
const oldTime = globalTime;
globalTime -= TIME_STEP;
// 計算在這個時間點應該存在的波
const frequency = Number(document.getElementById('frequency').value);
const period = 1 / frequency;
// 保留應該存在的波
waves = waves.filter(wave => {
// 保留在當前時間之前產生的波
return wave.birthTime <= globalTime;
});
// 強制更新一次動畫
updatePositions(globalTime);
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawGrid();
drawWaves(globalTime);
updatePositionDisplay();
// 更新碼錶顯示
console.log('Before update, elapsedTime:', elapsedTime);
elapsedTime -= TIME_STEP * 1000;
console.log('After update, elapsedTime:', elapsedTime);
updateTimer();
}
});
document.getElementById('resetBtn').addEventListener('click', function() {
globalTime = 0;
waves = [];
// 重置位置到預設值
sourcePos = { ...DEFAULT_SOURCE_POS };
receiverPos = { ...DEFAULT_RECEIVER_POS };
sourceInitialPos = { ...DEFAULT_SOURCE_POS };
receiverInitialPos = { ...DEFAULT_RECEIVER_POS };
updatePositionDisplay();
document.getElementById('sourceSpeed').value = 0;
document.getElementById('receiverSpeed').value = 0;
document.getElementById('sourceSpeedValue').textContent = '0';
document.getElementById('receiverSpeedValue').textContent = '0';
resetTimer();
updateStepButtonsState();
});
document.getElementById('stopMovementBtn').addEventListener('click', function() {
sourceVel = { x: 0, y: 0 };
receiverVel = { x: 0, y: 0 };
sourceInitialPos = { ...sourcePos };
receiverInitialPos = { ...receiverPos };
document.getElementById('sourceSpeed').value = 0;
document.getElementById('receiverSpeed').value = 0;
document.getElementById('sourceSpeedValue').textContent = '0';
document.getElementById('receiverSpeedValue').textContent = '0';
});
function setVelocity(isSource, direction) {
if (!isPaused) {
const speed = isSource ?
Number(document.getElementById('sourceSpeed').value) :
Number(document.getElementById('receiverSpeed').value);
if (isSource) {
sourceInitialPos = { ...sourcePos };
sourceStartTime = globalTime;
switch(direction) {
case 'left': sourceVel = { x: -speed, y: 0 }; break;
case 'right': sourceVel = { x: speed, y: 0 }; break;
case 'up': sourceVel = { x: 0, y: -speed }; break;
case 'down': sourceVel = { x: 0, y: speed }; break;
}
} else {
receiverInitialPos = { ...receiverPos };
receiverStartTime = globalTime;
switch(direction) {
case 'left': receiverVel = { x: -speed, y: 0 }; break;
case 'right': receiverVel = { x: speed, y: 0 }; break;
case 'up': receiverVel = { x: 0, y: -speed }; break;
case 'down': receiverVel = { x: 0, y: speed }; break;
}
}
}
}
document.getElementById('sourceLeft').onclick = () => setVelocity(true, 'left');
document.getElementById('sourceRight').onclick = () => setVelocity(true, 'right');
document.getElementById('sourceUp').onclick = () => setVelocity(true, 'up');
document.getElementById('sourceDown').onclick = () => setVelocity(true, 'down');
document.getElementById('receiverLeft').onclick = () => setVelocity(false, 'left');
document.getElementById('receiverRight').onclick = () => setVelocity(false, 'right');
document.getElementById('receiverUp').onclick = () => setVelocity(false, 'up');
document.getElementById('receiverDown').onclick = () => setVelocity(false, 'down');
document.getElementById('sourceSpeed').oninput = function() {
document.getElementById('sourceSpeedValue').textContent = this.value;
};
document.getElementById('receiverSpeed').oninput = function() {
document.getElementById('receiverSpeedValue').textContent = this.value;
};
document.getElementById('frequency').oninput = function() {
document.getElementById('freqValue').textContent = this.value + ' Hz';
};
enableDrag(source, sourcePos, true);
enableDrag(receiver, receiverPos, false);
document.addEventListener('click', () => {
if (audioContext.state === 'suspended') {
audioContext.resume();
}
}, { once: true });
startTimer();
animate();
</script>