first commit
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
<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 },
|
||||
|
||||
});
|
||||
|
||||
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) {
|
||||
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 opacity = 0.3 + 0.7 * phy.resonanceFactor;
|
||||
ctx.strokeStyle = `rgba(0, 255, 0, ${opacity})`;
|
||||
|
||||
// 强辉光效果 - 多层叠加
|
||||
ctx.shadowBlur = 15 * phy.resonanceFactor;
|
||||
ctx.shadowColor = '#00ff00';
|
||||
|
||||
ctx.beginPath();
|
||||
const phase = phy.phaseRadTotal;
|
||||
const ampX = gainCH1;
|
||||
const ampY = phy.resonanceFactor * gainCH2;
|
||||
|
||||
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;
|
||||
|
||||
// 中心点装饰
|
||||
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 (Source) - 黄色参考信号(在CH1、双踪、叠加模式下显示)
|
||||
if (currentModeIndex.value === 0 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 230, 0, 0.9)';
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.shadowColor = '#ffff00';
|
||||
|
||||
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);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// 第二层辉光
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// CH2 (Receiver) - 绿色接收信号(在CH2、双踪、叠加模式下显示)
|
||||
if (currentModeIndex.value === 1 || currentModeIndex.value === 2 || currentModeIndex.value === 3) {
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
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>
|
||||
Reference in New Issue
Block a user