974 lines
28 KiB
Vue
974 lines
28 KiB
Vue
<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值自动计算zIndex:bottom越小(越靠下),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>
|