2026-01-05 11:09:24 +08:00

974 lines
28 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 { ref, computed, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
import DeterminationMachine from './components/DeterminationMachine.vue';
//@ts-ignore
import SilverKnob from './components/SilverKnob.vue';
import Oscilloscope from './components/Oscilloscope.vue';
import SignalGen from './components/SignalGen.vue';
import Notebook from './components/Notebook.vue';
import ea from './assets/sounds';
import guidePng from './assets/入门引导.webp';
const distance = ref(20);
// Signal generator frequency (kHz)
const signalFrequencyKHz = ref(35.702);
// =========================
// 埋点POST /api/tracker
// =========================
const trackerSessionId = (() => {
try {
const key = 'sound-speed:tracker-session';
const existed = window.localStorage.getItem(key);
if (existed) return existed;
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
window.localStorage.setItem(key, id);
return id;
} catch {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
})();
const postTracker = (event: string, data?: unknown) => {
const body = JSON.stringify({
event,
data,
ts: Date.now(),
sessionId: trackerSessionId,
step: currentStepTitle.value,
});
try {
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
const blob = new Blob([body], { type: 'application/json' });
// @ts-ignore
navigator.sendBeacon('/api/tracker', blob);
return;
}
} catch {
// ignore
}
fetch('/api/tracker', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
}).catch(() => { /* ignore */ });
};
const makeThrottle = <T extends any[]>(fn: (...args: T) => void, ms: number) => {
let last = 0;
let timer: number | null = null;
let pending: T | null = null;
return (...args: T) => {
const now = Date.now();
const remain = ms - (now - last);
if (remain <= 0) {
last = now;
fn(...args);
return;
}
pending = args;
if (timer !== null) return;
timer = window.setTimeout(() => {
timer = null;
last = Date.now();
if (pending) fn(...pending);
pending = null;
}, remain);
};
};
const trackDistanceChange = makeThrottle((v: number) => {
postTracker('transducer_distance_change', { distanceMm: v });
}, 300);
const onDistanceChange = (v: number) => {
trackDistanceChange(v);
scheduleUpdateDmRightCablePaths();
};
const trackFrequencyChange = makeThrottle((v: number) => {
postTracker('signal_frequency_change', { frequencyKHz: v });
}, 300);
watch(signalFrequencyKHz, (v, prev) => {
// 初始化时避免打一条“变更”
if (prev === undefined) return;
trackFrequencyChange(v);
});
// 仪器位置状态
interface InstrumentState {
left: number; // 百分比
bottom: number; // 百分比
baseScale: number; // 基础缩放
zIndex: number; // 层级
}
const machine = ref<InstrumentState>({ left: 35, bottom: 40, baseScale: 0.8, zIndex: 0 });
const oscilloscope = ref<InstrumentState>({ left: 10, bottom: 23, baseScale: 1.2, zIndex: 0 });
const signalGen = ref<InstrumentState>({ left: 65, bottom: 17, baseScale: 0.95, zIndex: 0 });
const notebook = ref<InstrumentState>({ left: 12, bottom: 40, baseScale: 0.9, zIndex: 0 });
// 基于bottom值自动计算zIndexbottom越小越靠下zIndex越大
const getAutoZIndex = (instrument: InstrumentState) => {
// 将bottom值反转使得bottom=0时zIndex最大
return Math.round(1000 - instrument.bottom * 10);
};
// 透视计算bottom越小越靠下scale应该越大近大远小
// bottom范围: 0-100%, 映射到scale: 1.5-0.5倍
const getPerspectiveScale = (instrument: InstrumentState) => {
// bottom越小越靠下scale越大
const perspectiveFactor = 1.5 - (instrument.bottom / 100) * 1.0; // bottom=0时1.5, bottom=100时0.5
return instrument.baseScale * perspectiveFactor;
};
const machineStyle = computed(() => ({
left: `${machine.value.left}%`,
bottom: `${machine.value.bottom}%`,
transform: `scale(${getPerspectiveScale(machine.value)})`,
zIndex: getAutoZIndex(machine.value)
}));
const oscilloscopeStyle = computed(() => ({
left: `${oscilloscope.value.left}%`,
bottom: `${oscilloscope.value.bottom}%`,
transform: `scale(${getPerspectiveScale(oscilloscope.value)})`,
zIndex: getAutoZIndex(oscilloscope.value)
}));
const signalGenStyle = computed(() => ({
left: `${signalGen.value.left}%`,
bottom: `${signalGen.value.bottom}%`,
transform: `scale(${getPerspectiveScale(signalGen.value)})`,
zIndex: getAutoZIndex(signalGen.value)
}));
const notebookStyle = computed(() => ({
left: `${notebook.value.left}%`,
bottom: `${notebook.value.bottom}%`,
transform: `scale(${getPerspectiveScale(notebook.value)})`,
zIndex: getAutoZIndex(notebook.value)
}));
// 拖动功能
interface DragState {
isDragging: boolean;
startX: number;
startY: number;
startLeft: number;
startBottom: number;
}
const dragState = ref<DragState>({
isDragging: false,
startX: 0,
startY: 0,
startLeft: 0,
startBottom: 0
});
const currentInstrument = ref<InstrumentState | null>(null);
const startDrag = (instrument: InstrumentState, event: PointerEvent) => {
hideTooltip();
dragState.value = {
isDragging: true,
startX: event.clientX,
startY: event.clientY,
startLeft: instrument.left,
startBottom: instrument.bottom
};
currentInstrument.value = instrument;
// 防止文本选择和默认触摸行为
event.preventDefault();
event.stopPropagation();
};
const onPointerMove = (event: PointerEvent) => {
if (tooltip.visible && tooltipTargetEl.value) {
updateTooltipPosition(tooltipTargetEl.value);
}
if (!dragState.value.isDragging || !currentInstrument.value) return;
const tableWidth = 1600;
const tableHeight = 900;
// 计算移动距离(像素)
const deltaX = event.clientX - dragState.value.startX;
const deltaY = event.clientY - dragState.value.startY;
// 转换为百分比
const deltaLeftPercent = (deltaX / tableWidth) * 100;
const deltaBottomPercent = -(deltaY / tableHeight) * 100; // Y轴向下为正bottom向上为正
// 更新位置
currentInstrument.value.left = Math.max(0, Math.min(100, dragState.value.startLeft + deltaLeftPercent));
currentInstrument.value.bottom = Math.max(0, Math.min(55, dragState.value.startBottom + deltaBottomPercent));
scheduleUpdateCablePaths();
};
const onPointerUp = () => {
dragState.value.isDragging = false;
currentInstrument.value = null;
};
// =========================
// 入门引导(全屏图片)
// =========================
const showGuide = ref(true);
const guideImgRef = ref<HTMLImageElement | null>(null);
const closeGuide = () => {
showGuide.value = false;
};
const onGuideClick = (e: MouseEvent) => {
const img = guideImgRef.value;
if (!img) return;
const rect = img.getBoundingClientRect();
// 仅当点击在图片范围内,且位于图片底部 1/8 区域时关闭
const x = e.clientX;
const y = e.clientY;
const inImg = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (!inImg) return;
const yIn = y - rect.top;
if (yIn >= rect.height * 7 / 8) {
closeGuide();
}
};
// =========================
// 任务追踪(示波器/李萨如)
// =========================
const oscStatus = reactive({
vdch1: 320,
vdch2: 320,
currentModeIndex: 0,
xyMode: false,
power: false,
});
const onOscSettings = (payload: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
oscStatus.vdch1 = payload.vdch1;
oscStatus.vdch2 = payload.vdch2;
oscStatus.currentModeIndex = payload.currentModeIndex;
oscStatus.xyMode = payload.xyMode;
oscStatus.power = payload.power;
};
const trackOscSettings = makeThrottle((p: { vdch1: number; vdch2: number; currentModeIndex: number; xyMode: boolean; power: boolean }) => {
postTracker('osc_settings_change', p);
}, 300);
watch(() => ({
vdch1: oscStatus.vdch1,
vdch2: oscStatus.vdch2,
currentModeIndex: oscStatus.currentModeIndex,
xyMode: oscStatus.xyMode,
power: oscStatus.power,
}), (p) => {
trackOscSettings(p);
}, { deep: true });
const lissajousLineStreak = ref(0);
const lastLineK = ref<number | null>(null);
const onLissajousLine = (payload: { k: number; phaseRadTotal: number }) => {
// 仅在 X-Y 模式下计数
if (!oscStatus.xyMode) return;
if (lastLineK.value === null) {
lissajousLineStreak.value = 1;
lastLineK.value = payload.k;
return;
}
// 连续k 必须递增 1否则从 1 重新计数
if (payload.k === lastLineK.value + 1) {
lissajousLineStreak.value += 1;
} else {
lissajousLineStreak.value = 1;
}
lastLineK.value = payload.k;
};
watch(lissajousLineStreak, (v, prev) => {
if (v !== prev) postTracker('lissajous_line_streak', { streak: v });
});
// =========================
// 接线仿真pin 点击连线)
// =========================
type PinId =
| 'osc:ch1'
| 'osc:ch2'
| 'sg:tx-wave'
| 'sg:tx-transducer'
| 'sg:rx-wave'
| 'sg:rx-transducer'
| 'dm:left'
| 'dm:right';
type WireKey =
| 'txWave_to_ch1'
| 'txTransducer_to_left'
| 'rxWave_to_ch2'
| 'rxTransducer_to_right';
// App.vue 中的 4 条线是否接上的追踪变量
const wiringConnected = reactive<Record<WireKey, boolean>>({
txWave_to_ch1: false,
txTransducer_to_left: false,
rxWave_to_ch2: false,
rxTransducer_to_right: false,
});
// =========================
// 步骤标题document.title
// =========================
const inRange = (v: number, a: number, b: number) => v >= a && v <= b;
const stage1Done = computed(() => {
return wiringConnected.txWave_to_ch1
&& wiringConnected.txTransducer_to_left
&& wiringConnected.rxWave_to_ch2
&& wiringConnected.rxTransducer_to_right;
});
const stage2Done = computed(() => {
return oscStatus.power
&& inRange(oscStatus.vdch1, 140, 220)
&& inRange(oscStatus.vdch2, 140, 220)
&& oscStatus.currentModeIndex === 2;
});
const stage3Done = computed(() => inRange(signalFrequencyKHz.value, 35.98, 36.02));
const stage4Done = computed(() => {
return oscStatus.xyMode && lissajousLineStreak.value >= 4;
});
const currentStepTitle = computed(() => {
if (!stage1Done.value) return '步骤一:仪器接线';
if (!stage2Done.value) return '步骤二:调整示波器设置';
if (!stage3Done.value) return '步骤三:找到固有频率';
if (!stage4Done.value) return '步骤四:相位比较法测声速';
return '全部完成';
});
watch(currentStepTitle, (t) => {
document.title = `声速测量实验 - ${t}`;
}, { immediate: true });
const tableRef = ref<HTMLDivElement | null>(null);
// =========================
// 悬停引导Tooltip
// =========================
type TooltipSide = 'right' | 'left';
const tooltip = reactive({
visible: false,
text: '',
x: 0,
y: 0,
side: 'right' as TooltipSide,
});
const tooltipTargetEl = ref<HTMLElement | null>(null);
const hideTooltip = () => {
tooltip.visible = false;
tooltipTargetEl.value = null;
};
const updateTooltipPosition = (el: HTMLElement) => {
const table = tableRef.value;
if (!table) return;
const tableRect = table.getBoundingClientRect();
const r = el.getBoundingClientRect();
const margin = 10;
const approxBubbleW = 280; // 用于判断是否需要翻转到左侧
// 锚点:元素右上角(必要时翻转到左上角)
const anchorY = r.top - tableRect.top - margin;
const rightX = r.right - tableRect.left + margin;
const leftX = r.left - tableRect.left - margin;
const hasRoomOnRight = rightX + approxBubbleW <= tableRect.width;
tooltip.side = hasRoomOnRight ? 'right' : 'left';
tooltip.x = hasRoomOnRight ? rightX : leftX;
// y 以上方为主,避免贴边
tooltip.y = Math.max(10, Math.min(tableRect.height - 10, anchorY));
};
const showTooltipForEl = (el: HTMLElement) => {
const raw = (el.dataset.tooltip ?? '').trim();
// 模板里为了可读性可能写成 "\\n",这里统一转换成真实换行
const text = raw.replace(/\\n/g, '\n');
if (!text) return;
tooltipTargetEl.value = el;
tooltip.text = text;
tooltip.visible = true;
updateTooltipPosition(el);
};
const findTooltipEl = (target: EventTarget | null): HTMLElement | null => {
if (!target) return null;
const t = target as HTMLElement;
if (!t?.closest) return null;
const el = t.closest<HTMLElement>('[data-tooltip]');
if (!el) return null;
const table = tableRef.value;
if (table && !table.contains(el)) return null;
return el;
};
const onTooltipPointerOver = (e: PointerEvent) => {
if (showGuide.value) return;
const el = findTooltipEl(e.target);
if (!el) return;
if (tooltipTargetEl.value === el && tooltip.visible) return;
showTooltipForEl(el);
};
const onTooltipPointerOut = (e: PointerEvent) => {
const current = tooltipTargetEl.value;
if (!current) return;
const to = e.relatedTarget as HTMLElement | null;
if (to && (current === to || current.contains(to))) return;
const nextEl = findTooltipEl(to);
if (nextEl) {
showTooltipForEl(nextEl);
return;
}
hideTooltip();
};
const tooltipStyle = computed(() => {
const base: Record<string, string> = {
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
};
base.transform = tooltip.side === 'right' ? 'translate(0, -50%)' : 'translate(-100%, -50%)';
return base;
});
const pinEls = new Map<PinId, HTMLElement>();
const selectedPin = ref<PinId | null>(null);
const syncPinConnectingClass = () => {
const selected = selectedPin.value;
for (const [id, el] of pinEls) {
el.classList.toggle('pin-connecting', selected === id);
}
};
type Cable = {
key: WireKey;
a: PinId;
b: PinId;
d: string;
};
const cables = ref<Cable[]>([]);
let cablesRaf: number | null = null;
let pendingCableUpdateAll = false;
let pendingCableUpdatePins: Set<PinId> | null = null;
const flushCablePathUpdate = () => {
const doAll = pendingCableUpdateAll;
const pins = pendingCableUpdatePins;
pendingCableUpdateAll = false;
pendingCableUpdatePins = null;
if (doAll || !pins) {
updateCablePaths();
} else {
updateCablePaths(pins);
}
};
const scheduleUpdateCablePaths = () => {
pendingCableUpdateAll = true;
pendingCableUpdatePins = null;
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
const scheduleUpdateCablePathsForPins = (pins: Iterable<PinId>) => {
if (pendingCableUpdateAll) return;
if (!pendingCableUpdatePins) pendingCableUpdatePins = new Set<PinId>();
for (const p of pins) pendingCableUpdatePins.add(p);
if (cablesRaf !== null) return;
cablesRaf = requestAnimationFrame(() => {
cablesRaf = null;
flushCablePathUpdate();
});
};
let dmRightCableLast = 0;
let dmRightCableTimer: number | null = null;
const scheduleUpdateDmRightCablePaths = () => {
const now = Date.now();
const remain = 1000 - (now - dmRightCableLast);
if (remain <= 0) {
dmRightCableLast = now;
scheduleUpdateCablePathsForPins(['dm:right']);
return;
}
if (dmRightCableTimer !== null) return;
dmRightCableTimer = window.setTimeout(() => {
dmRightCableTimer = null;
dmRightCableLast = Date.now();
scheduleUpdateCablePathsForPins(['dm:right']);
}, remain);
};
const normalizePair = (a: PinId, b: PinId) => {
return a < b ? `${a}|${b}` : `${b}|${a}`;
};
const REQUIRED: Array<{ a: PinId; b: PinId; key: WireKey }> = [
{ a: 'sg:tx-wave', b: 'osc:ch1', key: 'txWave_to_ch1' },
{ a: 'sg:tx-transducer', b: 'dm:left', key: 'txTransducer_to_left' },
{ a: 'sg:rx-wave', b: 'osc:ch2', key: 'rxWave_to_ch2' },
{ a: 'sg:rx-transducer', b: 'dm:right', key: 'rxTransducer_to_right' },
];
const requiredByPair = new Map<string, WireKey>();
for (const c of REQUIRED) {
requiredByPair.set(normalizePair(c.a, c.b), c.key);
}
const registerPin = (payload: { id: PinId; el: HTMLElement }) => {
pinEls.set(payload.id, payload.el);
// 新注册的 pin 也要立即同步“正在连接”样式
payload.el.classList.toggle('pin-connecting', selectedPin.value === payload.id);
if (payload.id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const unregisterPin = (id: PinId) => {
pinEls.delete(id);
if (id === 'dm:right') scheduleUpdateDmRightCablePaths();
else scheduleUpdateCablePaths();
};
const addCableIfNeeded = (key: WireKey, a: PinId, b: PinId) => {
if (cables.value.some((c) => c.key === key)) return;
cables.value.push({ key, a, b, d: '' });
scheduleUpdateCablePaths();
};
const alertWiringError = () => {
window.alert('接线错误:请按接线示意连接对应端口。');
};
const onPinClick = (id: PinId) => {
ea.play('线缆插入')
postTracker('pin_click', { pinId: id, hasSelected: selectedPin.value !== null });
if (selectedPin.value === null) {
selectedPin.value = id;
syncPinConnectingClass();
return;
}
const first = selectedPin.value;
selectedPin.value = null;
syncPinConnectingClass();
if (first === id) return;
const key = requiredByPair.get(normalizePair(first, id));
if (!key) {
postTracker('wire_error', { a: first, b: id });
alertWiringError();
return;
}
// 已经连过就不重复画线,但依然视为已接
const was = wiringConnected[key];
wiringConnected[key] = true;
addCableIfNeeded(key, first, id);
// 已存在的线也刷新一次,避免 DOM/布局变动导致的偏差
scheduleUpdateCablePaths();
if (!was) {
postTracker('wire_connected', { key, a: first, b: id });
}
};
const getPinCenterInTable = (pin: HTMLElement) => {
const table = tableRef.value;
if (!table) return null;
const tableRect = table.getBoundingClientRect();
const r = pin.getBoundingClientRect();
const x = r.left + r.width / 2 - tableRect.left;
const y = r.top + r.height / 2 - tableRect.top;
return { x, y };
};
const buildSagCablePath = (p1: { x: number; y: number }, p2: { x: number; y: number }) => {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.hypot(dx, dy);
const sag = Math.min(220, Math.max(50, dist * 0.18));
const c1x = p1.x + dx * 0.25;
const c1y = p1.y + sag;
const c2x = p1.x + dx * 0.75;
const c2y = p2.y + sag;
return `M ${p1.x.toFixed(2)} ${p1.y.toFixed(2)} C ${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
};
const updateCablePaths = (onlyPins?: Set<PinId>) => {
for (const cable of cables.value) {
if (onlyPins && !onlyPins.has(cable.a) && !onlyPins.has(cable.b)) continue;
const aEl = pinEls.get(cable.a);
const bEl = pinEls.get(cable.b);
if (!aEl || !bEl) {
cable.d = '';
continue;
}
const p1 = getPinCenterInTable(aEl);
const p2 = getPinCenterInTable(bEl);
if (!p1 || !p2) {
cable.d = '';
continue;
}
cable.d = buildSagCablePath(p1, p2);
}
};
onMounted(() => {
scheduleUpdateCablePaths();
window.addEventListener('resize', scheduleUpdateCablePaths);
});
onBeforeUnmount(() => {
if (cablesRaf !== null) cancelAnimationFrame(cablesRaf);
window.removeEventListener('resize', scheduleUpdateCablePaths);
if (dmRightCableTimer !== null) window.clearTimeout(dmRightCableTimer);
});
</script>
<template>
<div ref="tableRef" class="table" @pointermove="onPointerMove"
@pointerover="onTooltipPointerOver" @pointerout="onTooltipPointerOut"
@pointerup="onPointerUp" @pointerleave="onPointerUp(); hideTooltip()">
<Transition name="guide">
<div v-if="showGuide" class="guide-overlay" @click="onGuideClick">
<img ref="guideImgRef" class="guide-img" :src="guidePng" alt="入门引导" draggable="false" />
</div>
</Transition>
<div v-if="tooltip.visible" class="tooltip" :class="`tooltip--${tooltip.side}`" :style="tooltipStyle">
<div class="tooltip-inner">{{ tooltip.text }}</div>
</div>
<svg class="cables" :width="1600" :height="900" viewBox="0 0 1600 900"
aria-hidden="true">
<defs>
<filter id="cableShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2"
flood-color="rgba(0,0,0,0.55)" />
</filter>
</defs>
<g filter="url(#cableShadow)">
<g v-for="c in cables" :key="c.key">
<path v-if="c.d" :d="c.d" class="cable-base" />
<path v-if="c.d" :d="c.d" class="cable-highlight" />
</g>
</g>
</svg>
<div class="instrument-wrapper" :style="machineStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(machine, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<DeterminationMachine v-model="distance" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" @change="onDistanceChange" />
</div>
<div class="instrument-wrapper" :style="notebookStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(notebook, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Notebook class="machine" :wiring="wiringConnected"
:osc="{ vdch1: oscStatus.vdch1, vdch2: oscStatus.vdch2, currentModeIndex: oscStatus.currentModeIndex, xyMode: oscStatus.xyMode, power: oscStatus.power }"
:signal-frequency-k-hz="signalFrequencyKHz"
:lissajous-line-streak="lissajousLineStreak" />
</div>
<div class="instrument-wrapper" :style="oscilloscopeStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(oscilloscope, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<Oscilloscope :distance="distance" :frequency="signalFrequencyKHz * 1000"
class="machine" @register-pin="registerPin"
@unregister-pin="unregisterPin" @pin-click="onPinClick"
@settings="onOscSettings" @lissajous-line="onLissajousLine" />
</div>
<div class="instrument-wrapper" :style="signalGenStyle">
<div class="drag-handle" data-tooltip="拖动把手可移动仪器位置" @pointerdown="startDrag(signalGen, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="19" r="1" />
<circle cx="8" cy="5" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="8" cy="19" r="1" />
<circle cx="16" cy="5" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="16" cy="19" r="1" />
</svg>
</div>
<SignalGen v-model="signalFrequencyKHz" class="machine"
@register-pin="registerPin" @unregister-pin="unregisterPin"
@pin-click="onPinClick" />
</div>
</div>
</template>
<style scoped>
.table {
width: 1700px;
height: 900px;
margin: 0 auto;
position: relative;
overflow: hidden;
background-image: url(./assets/桌面.webp);
background-size: cover;
background-position: center;
user-select: none;
}
.guide-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
}
.guide-img {
width: 100vw;
height: 100vh;
object-fit: contain;
user-select: none;
}
.guide-enter-active,
.guide-leave-active {
transition: opacity 0.35s ease, transform 0.35s ease;
}
.guide-enter-from,
.guide-leave-to {
opacity: 0;
transform: translateY(10px);
}
.cables {
position: absolute;
inset: 0;
z-index: 850;
pointer-events: none;
}
.cable-base {
fill: none;
stroke: rgba(35, 35, 35, 0.95);
stroke-width: 10;
stroke-linecap: round;
stroke-linejoin: round;
}
.cable-highlight {
fill: none;
stroke: rgba(255, 255, 255, 0.16);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.instrument-wrapper {
position: absolute;
filter: drop-shadow(0 10px 10px black);
transition: filter 0.2s;
}
.machine {
pointer-events: auto;
}
.drag-handle {
touch-action: none;
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 24px;
background: linear-gradient(135deg, rgba(100, 100, 100, 0.9), rgba(60, 60, 60, 0.95));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.2);
opacity: 0.7;
transition: all 0.2s;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
}
.drag-handle:hover {
opacity: 1;
background: linear-gradient(135deg, rgba(120, 120, 120, 0.95), rgba(80, 80, 80, 1));
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5), inset 0 1px 2px rgba(255, 255, 255, 0.3);
transform: translateX(-50%) scale(1.1);
}
.drag-handle:active {
cursor: grabbing;
background: linear-gradient(135deg, rgba(80, 80, 80, 1), rgba(50, 50, 50, 1));
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6), inset 0 1px 2px rgba(255, 255, 255, 0.2);
}
.drag-handle svg {
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
}
.instrument-wrapper:hover .drag-handle {
opacity: 0.9;
}
.tooltip {
position: absolute;
z-index: 9000;
pointer-events: none;
max-width: 280px;
}
.tooltip-inner {
background:
linear-gradient(rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.96)),
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.035) 0px,
rgba(0, 0, 0, 0.035) 1px,
rgba(0, 0, 0, 0.0) 10px,
rgba(0, 0, 0, 0.0) 18px
);
color: rgba(20, 20, 20, 0.95);
border: 2px solid rgba(20, 20, 20, 0.70);
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.3;
box-shadow: 4px 5px 0 rgba(0, 0, 0, 0.18), 0 12px 18px rgba(0, 0, 0, 0.18);
white-space: pre-line;
transform: rotate(-0.6deg);
}
.tooltip::before {
content: '';
position: absolute;
z-index: 1;
bottom: 10px;
width: 10px;
height: 10px;
background: rgba(255, 255, 255, 0.97);
border: 2px solid rgba(20, 20, 20, 0.70);
transform: rotate(45deg);
}
.tooltip--right::before {
left: -5px;
border-right: 0;
border-top: 0;
}
.tooltip--left::before {
right: -5px;
border-left: 0;
border-bottom: 0;
}
</style>