Files
sound-speed-determination/src/components/Oscilloscope.vue
T

471 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, onMounted, watch, onUnmounted, nextTick } from 'vue';
import RetroKnob from './RetroKnob.vue';
//@ts-ignore
import SwitchLever from './SwitchLever.vue';
import ea from '../assets/sounds';
// 40 -> 320 bigger is smaller volts, 40 means 5V/div, 320 means 1mV/div, not linear
const vdch1 = ref(320)
const vdch2 = ref(320)
const xymode = ref(false)
const power = ref(false)
const currentModeIndex = ref(0);
const labels = ['CH1', 'CH2', '双踪', '叠加'];
const modeText = computed(() => labels[currentModeIndex.value]);
const handleModeChange = (val: string) => {
console.log('模式切换为:', val);
ea.play('换挡')
};
const props = defineProps({
// 接收器距离发射器的距离,单位mm
distance: { type: Number, default: 0 },
// 信号频率,单位Hz
frequency: { type: Number, default: 36000 },
// 声速,单位m/s
soundSpeed: { type: Number, default: 340 },
// 接线状态:控制通道是否真正有信号
// CH1:仅当 [信号发生器 发射端 波形] -> [示波器 CH1] 接入时为 true
ch1HasSignal: { type: Boolean, default: false },
// CH2:仅当三条线都接入时为 true
// [信号发生器 发射端 换能器] -> [换能器 左侧输入]
// [信号发生器 接收端 波形] -> [示波器 CH2]
// [信号发生器 接收端 换能器] -> [换能器 右侧输出]
ch2HasSignal: { type: Boolean, default: false },
});
const emit = defineEmits<{
(e: 'register-pin', payload: { id: 'osc:ch1' | 'osc:ch2'; el: HTMLElement }): void;
(e: 'unregister-pin', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'pin-click', id: 'osc:ch1' | 'osc:ch2'): void;
(e: 'settings', payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }): void;
(e: 'lissajous-line', payload: { k: number; phaseRadTotal: number }): void;
}>();
const emitSettings = () => {
emit('settings', {
vdch1: vdch1.value,
vdch2: vdch2.value,
currentModeIndex: currentModeIndex.value,
xyMode: xymode.value,
power: power.value,
});
};
watch([vdch1, vdch2, currentModeIndex, xymode, power], () => {
emitSettings();
}, { immediate: true });
const lastWasLine = ref(false);
const lastLineK = ref<number | null>(null);
const pinCh1El = ref<HTMLElement | null>(null);
const pinCh2El = ref<HTMLElement | null>(null);
const registerPins = () => {
if (pinCh1El.value) emit('register-pin', { id: 'osc:ch1', el: pinCh1El.value });
if (pinCh2El.value) emit('register-pin', { id: 'osc:ch2', el: pinCh2El.value });
};
const unregisterPins = () => {
emit('unregister-pin', 'osc:ch1');
emit('unregister-pin', 'osc:ch2');
};
const handlePinClick = (id: 'osc:ch1' | 'osc:ch2') => {
emit('pin-click', id);
};
// Canvas相关
const canvasRef = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let animationId: number | null = null;
let time = 0;
// 固有频率和品质因数
const NATURAL_FREQ = 36000; // Hz,与props.frequency保持一致
const Q_FACTOR = 100; // 品质因数(越大则频率影响越强:偏离固有频率时衰减更快)
// 核心物理计算
function calculatePhysics() {
// 将距离从mm转换为cm,再转换为m
const distanceM = props.distance / 1000; // mm -> m
// 计算波长 (m)
const wavelengthM = props.soundSpeed / props.frequency;
// 计算相位差
const numCycles = distanceM / wavelengthM;
const phaseRadTotal = numCycles * 2 * Math.PI;
const phaseRadEffective = phaseRadTotal % (2 * Math.PI);
// 计算谐振幅度(接收器在固有频率附近时信号最强)
const f_ratio = props.frequency / NATURAL_FREQ;
const f_term = (f_ratio - (1 / f_ratio));
let resonanceFactor = 1 / Math.sqrt(1 + (Q_FACTOR * Q_FACTOR * f_term * f_term));
if (isNaN(resonanceFactor)) resonanceFactor = 0;
return {
wavelengthM,
phaseRadTotal,
phaseRadEffective,
resonanceFactor
};
}
// 判断当前显示模式
const isXYMode = computed(() => xymode.value);
const isDualTrace = computed(() => currentModeIndex.value === 2); // 双踪模式
// 根据旋钮值计算增益(40=最小,320=最大,非线性)
const getGain = (knobValue: number) => {
// 将40-320映射到0.01-5.0的增益范围(非线性)
const normalized = (knobValue - 40) / 280; // 0-1
return 0.01 + normalized * normalized * 4.99; // 平方关系,使低端更敏感
};
// 绘制示波器屏幕
function drawOscilloscope() {
if (!ctx || !canvasRef.value) {
return;
}
// 清空画布
const w = canvasRef.value.width;
const h = canvasRef.value.height;
ctx.clearRect(0, 0, w, h);
// 如果未开机,直接返回(保持黑屏)
if (!power.value) {
return;
}
const cx = w / 2;
const cy = h / 2;
const scale = Math.min(w, h) * 0.35;
const phy = calculatePhysics();
const gainCH1 = getGain(vdch1.value);
const gainCH2 = getGain(vdch2.value);
// X-Y 模式下检测“李萨如图形出现直线”的进入事件(需两路都有信号)
if (power.value && isXYMode.value && props.ch1HasSignal && props.ch2HasSignal) {
const phase = phy.phaseRadTotal;
const isLineNow = Math.abs(Math.sin(phase)) < 0.10 && phy.resonanceFactor > 0.05;
if (isLineNow && !lastWasLine.value) {
const k = Math.round(phase / Math.PI);
// 去重:同一个 k 不重复触发
if (lastLineK.value !== k) {
lastLineK.value = k;
emit('lissajous-line', { k, phaseRadTotal: phase });
}
}
lastWasLine.value = isLineNow;
} else {
lastWasLine.value = false;
}
// 公共属性
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isXYMode.value) {
// ================= X-Y Mode (李萨如图形) =================
const hasAnySignal = props.ch1HasSignal || props.ch2HasSignal;
const opacity = hasAnySignal ? (0.3 + 0.7 * phy.resonanceFactor) : 0.15;
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
// 强辉光效果 - 多层叠加
ctx.shadowBlur = 15 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
ctx.beginPath();
const phase = phy.phaseRadTotal;
const ampX = props.ch1HasSignal ? gainCH1 : 0;
const ampY = props.ch2HasSignal ? (phy.resonanceFactor * gainCH2) : 0;
for (let t = 0; t <= 2 * Math.PI; t += 0.03) {
const x = scale * ampX * Math.cos(t);
const y = scale * ampY * Math.cos(t - phase);
if (t === 0) ctx.moveTo(cx + x, cy - y);
else ctx.lineTo(cx + x, cy - y);
}
ctx.stroke();
// 第二层辉光(更强)
ctx.shadowBlur = 25 * phy.resonanceFactor;
ctx.stroke();
ctx.shadowBlur = 0;
// 两路都没信号时,画一个中心点(避免完全不可见)
if (!props.ch1HasSignal && !props.ch2HasSignal) {
ctx.fillStyle = 'rgba(0, 255, 0, 0.25)';
ctx.fillRect(cx - 1.5, cy - 1.5, 3, 3);
}
// 中心点装饰
ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
ctx.fillRect(cx - 1, cy - 5, 2, 10);
ctx.fillRect(cx - 5, cy - 1, 10, 2);
} else {
// ================= Y-t Mode (CH1/CH2/双踪/叠加波形) =================
// 绘制中心轴
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
ctx.stroke();
const cyclesToShow = 2.5; // 显示2.5个周期
const pixPerCycle = w / cyclesToShow;
ctx.lineWidth = 3;
// 叠加模式:直接绘制 CH1 + CH2 的合成波形(形状更复杂)
if (currentModeIndex.value === 3) {
const opacity = 0.45 + 0.55 * phy.resonanceFactor;
ctx.beginPath();
// 用 CH1 的黄色线条 + CH2 的绿色辉光,便于与单通道/双踪区分
ctx.strokeStyle = `rgba(255, 230, 0, ${opacity})`;
ctx.shadowBlur = 14 * (0.4 + 0.6 * phy.resonanceFactor);
ctx.shadowColor = '#00ff00';
const phaseShift = phy.phaseRadTotal;
const amp1 = gainCH1 * 0.8;
const amp2 = phy.resonanceFactor * gainCH2 * 0.8;
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const y1 = props.ch1HasSignal ? (amp1 * Math.sin(t + time)) : 0;
const y2 = props.ch2HasSignal ? (amp2 * Math.sin(t - phaseShift + time)) : 0;
const yVal = scale * (y1 + y2);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
ctx.stroke();
// 第二层辉光
ctx.shadowBlur = 22 * (0.4 + 0.6 * phy.resonanceFactor);
ctx.stroke();
}
// CH1 (Source) - 黄色参考信号(在CH1、双踪模式下显示)
if (currentModeIndex.value === 0 || currentModeIndex.value === 2) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 230, 0, 0.9)';
ctx.shadowBlur = 12;
ctx.shadowColor = '#ffff00';
if (props.ch1HasSignal) {
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * gainCH1 * 0.8 * Math.sin(t + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
} else {
// 未接线:显示中心直线
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
}
ctx.stroke();
// 第二层辉光
ctx.shadowBlur = 20;
ctx.stroke();
}
// CH2 (Receiver) - 绿色接收信号(在CH2、双踪模式下显示)
if (currentModeIndex.value === 1 || currentModeIndex.value === 2) {
const opacity = 0.3 + 0.7 * phy.resonanceFactor;
ctx.beginPath();
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
ctx.shadowBlur = 12 * phy.resonanceFactor;
ctx.shadowColor = '#00ff00';
const phaseShift = phy.phaseRadTotal;
const ampY = phy.resonanceFactor * gainCH2 * 0.8;
if (props.ch2HasSignal) {
for (let x = 0; x < w; x += 2) {
const t = (x / pixPerCycle) * 2 * Math.PI;
const yVal = scale * ampY * Math.sin(t - phaseShift + time);
if (x === 0) ctx.moveTo(x, cy - yVal);
else ctx.lineTo(x, cy - yVal);
}
} else {
// 未接线:显示中心直线
ctx.shadowBlur = 0;
ctx.moveTo(0, cy);
ctx.lineTo(w, cy);
}
ctx.stroke();
// 第二层辉光
ctx.shadowBlur = 20 * phy.resonanceFactor;
ctx.stroke();
}
ctx.shadowBlur = 0;
}
}
// 动画循环
function animate() {
time += 0.05;
drawOscilloscope();
animationId = requestAnimationFrame(animate);
}
// 初始化Canvas
function initCanvas() {
const canvas = canvasRef.value;
if (!canvas) return;
ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置Canvas尺寸(保持高分辨率)
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = 1072;
canvas.height = 812;
// 启动动画
animate();
}
// 监听相关参数变化
watch([() => props.distance, () => props.frequency, () => props.soundSpeed,
isXYMode, currentModeIndex, power], () => {
// 参数变化时会在下一帧自动重绘
});
onMounted(() => {
initCanvas();
nextTick().then(registerPins);
});
onUnmounted(() => {
if (animationId !== null) {
cancelAnimationFrame(animationId);
}
unregisterPins();
});
</script>
<template>
<div class="osc">
<img src="../assets/示波器.webp" alt="" draggable="false">
<RetroKnob v-model="vdch1" class="btn" data-tooltip="CH1 垂直灵敏度(Volts/Div):调到合适幅度便于观察"
style="left: 34.3%; top: 28.4%;" />
<RetroKnob v-model="vdch2" class="btn" data-tooltip="CH2 垂直灵敏度(Volts/Div):调到合适幅度便于观察"
style="left: 47.3%; top: 28.4%;" />
<div class="powerl" draggable="false" :style="{ opacity: power ? 0.8 : 0 }"></div>
<img class="power-btn" data-tooltip="电源开关:开机后才会显示波形/李萨如图形" @click="power = !power; ea.play('电源按钮')"
:style="{ opacity: power ? '80%' : '0' }" src="../assets/按钮按下.webp">
<!-- <div>{{ vdch1 }} {{ vdch2 }}</div> -->
<SwitchLever v-model="currentModeIndex" data-tooltip="显示模式:CH1 / CH2 / 双踪 / 叠加(步骤二需要切到“双踪”)" @change="handleModeChange"
scale="0.20" class="mode-swtc" />
<div class="xymode" data-tooltip="X-Y(李萨如)模式:用于相位比较法(步骤四)" @click="xymode = !xymode; ea.play('电源按钮')"></div>
<canvas ref="canvasRef" class="screen" width="1072"
height="812"></canvas>
<!-- CH1 输入 -->
<div ref="pinCh1El" class="pin" data-tooltip="CH1 输入端口:建议连接信号发生器“发射端-波形”\n提示:先点一个端口,再点另一个端口完成连线" style="left: 49.8%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch1')"></div>
<!-- CH2 输入 -->
<div ref="pinCh2El" class="pin" data-tooltip="CH2 输入端口:建议连接信号发生器“接收端-波形”\n提示:先点一个端口,再点另一个端口完成连线" style="left: 62.7%; top: 87.5%;"
@click.stop="handlePinClick('osc:ch2')"></div>
</div>
</template>
<style lang="scss" scoped>
.pin {
height: 30px;
width: 30px;
position: absolute;
background-color: black;
border-radius: 50%;
cursor: pointer;
}
.osc {
position: relative;
height: 300px;
width: fit-content;
}
.screen {
position: absolute;
left: 7.5%;
top: 31.2%;
width: 32.2%;
background-image:
linear-gradient(rgba(0, 255, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 0, 0.1) 1px, transparent 1px);
background-size: calc(1072px / 10.72) calc(812px / 8.12);
border-radius: 4px;
}
.xymode {
position: absolute;
left: 80.2%;
top: 44.4%;
width: 20px;
height: 17px;
cursor: pointer;
}
img {
height: 100%;
}
.mode-swtc {
position: absolute;
left: 57.8%;
top: 42.4%;
}
.btn {
position: absolute;
transform: scale(0.164);
inset: 0;
}
.powerl {
background-color: orangered;
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
left: 36.55%;
top: 93.8%;
transform: translate(-50%, -50%);
box-shadow: 0 0 8px 2px orangered;
transition: all 0.3s ease;
opacity: .8;
}
.power-btn {
position: absolute;
height: 29px;
left: 42.32%;
top: 90.7%;
transform: translate(-50%, -50%);
cursor: pointer;
}
</style>