471 lines
15 KiB
Vue
471 lines
15 KiB
Vue
<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> |