first commit

This commit is contained in:
2026-01-05 11:09:24 +08:00
commit 8f673e0095
47 changed files with 4317 additions and 0 deletions
+414
View File
@@ -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>