Day 88 · 交互桥接 — Canvas 事件 → emit:hover 详情的响应式化
入向桥通了,今天拆出向:Canvas 的命中检测事件如何变成 Vue 状态。核心技术点是 emit 桥(Day 85 出向契约)与 hoverNode 存 id 不存对象(Day 85 反例推演 4.2 的正解)。还要迁 pan/zoom 交互——它纯粹是画布内事务(视口数据 Day 85 裁决归画布),今天只需确认它在 Vue 宿主里无感存活。
目录
- 一、出向桥:emit 事件契约
- 二、hover 详情面板:响应式消费
- 三、为什么 hoverNode 存 id 不存对象
- 四、pan/zoom:画布自治事务的确认
- 五、常见坑点
- 六、自测挑战
- 七、总结
一、出向桥:emit 事件契约
1.1 TopoCanvas 追加事件接线
<!-- TopoCanvas.vue(追加出向桥与交互绑定) -->
<script setup lang="ts">
// Day 85 出向契约的组件化落地
const emit = defineEmits<{
(e: "hover", id: string | null): void; // hover 变化:设备 id 或 null(离开)
(e: "select", id: string): void; // 节点点击(本周预留,未来跳详情)
}>();
onMounted(() => {
renderer = new TopoRenderer({ /* Day 87 同 */ });
// —— 交互事件的画布内处理 → 桥出 ——
// renderer 的交互回调(第 7 周命中检测,Day 68 保留版)改为桥出:
renderer.onHover = (id: string | null) => emit("hover", id);
renderer.onSelect = (id: string) => emit("select", id);
});
</script>
1.2 renderer 侧的命中检测回调(迁移核对)
// core/topo-renderer.ts(第 7 周 Day 45 命中检测的展示态保留版)
export class TopoRenderer {
/** 桥出回调:由宿主组件注入(渲染器不认识 Vue) */
onHover: (id: string | null) => void = () => {};
onSelect: (id: string) => void = () => {};
/** 绑定在节点层 canvas 的 mousemove(init 时 addEventListener) */
private handleMove = (e: MouseEvent): void => {
const hit = this.hitTest(e.offsetX, e.offsetY); // 屏幕坐标 → 世界坐标 → 命中
// 命中变化才上报(事件级去抖——鼠标在节点内滑动不重复 emit)
if (hit?.id !== this.lastHoverId) {
this.lastHoverId = hit?.id ?? null;
this.onHover(this.lastHoverId);
this.setCanvasCursor(hit ? "pointer" : "default");
}
};
}
设计要点:renderer 报告事实(“hover 到了 f2”),不携带渲染对象——id 是跨世界的最小硬通货(Day 85 桥原则)。
二、hover 详情面板:响应式消费
2.1 面板组件(纯 Vue 世界)
<!-- src/components/TopoDetailPanel.vue -->
<script setup lang="ts">
/**
* 拓扑节点详情面板(hover 驱动)
* 纯 Vue 组件:只消费 id,数据全部回 store 取——画布世界零渗漏
*/
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
const props = defineProps<{ deviceId: string | null }>();
const store = useDashboardStore();
const { latest } = storeToRefs(store);
// 按 id 从快照取设备详情(id → 数据的唯一映射点)
const device = computed(() =>
latest.value?.devices.find((d) => d.deviceId === props.deviceId) ?? null
);
// 该设备的温度窗口尾巴(迷你趋势,最近 20 点)
const recentTemps = computed(() =>
(store.tempWindows.get(props.deviceId ?? "") ?? []).slice(-20)
);
const STATE_LABEL = { ok: "运行正常", warn: "温度偏高", danger: "危险", offline: "离线" } as const;
</script>
<template>
<!-- v-if 挂载卸载即可:详情面板无重状态,不需要 KeepAlive -->
<div v-if="device" class="topo-detail" :data-state="device.state">
<div class="topo-detail__title">{{ device.name }}</div>
<div class="topo-detail__row">
<span>状态</span><span>{{ STATE_LABEL[device.state] }}</span>
</div>
<div class="topo-detail__row">
<span>当前温度</span><span class="num">{{ device.temperature.toFixed(1) }}℃</span>
</div>
<!-- 迷你趋势:内联 SVG 折线(20 点,纯展示不需要 ECharts) -->
<svg class="topo-detail__spark" viewBox="0 0 100 24" preserveAspectRatio="none">
<polyline
:points="recentTemps.map((p, i) =>
`${(i / Math.max(recentTemps.length - 1, 1)) * 100},${24 - (p.v - 60) / 40 * 24}`
).join(' ')"
/>
</svg>
</div>
</template>
2.2 屏 C 的组装
<!-- ScreenC.vue(本周逐步成形,今天完成交互部分) -->
<script setup lang="ts">
import { ref } from "vue";
import TopoCanvas from "../components/TopoCanvas.vue";
import TopoDetailPanel from "../components/TopoDetailPanel.vue";
defineOptions({ name: "ScreenC" });
// hover 状态:屏级 Vue 状态(Day 85 裁决 #8——存 id!)
const hoverId = ref<string | null>(null);
</script>
<template>
<section class="screen screen-c">
<TopoCanvas @hover="(id) => (hoverId = id)" />
<!-- 面板浮在画布右上:只响应 hoverId,与画布渲染零耦合 -->
<TopoDetailPanel :device-id="hoverId" />
</section>
</template>
数据流全景(值得截图进笔记):
鼠标移动 → renderer 命中检测(画布内)
→ emit("hover", "f2")(桥出)
→ hoverId.value = "f2"(Vue 状态)
→ TopoDetailPanel props 变化
→ device computed 重算(回 store 取数)
→ 面板重渲(Vue 世界)
一次 hover 穿越两个世界,每个环节都有名字。
三、为什么 hoverNode 存 id 不存对象
Day 85 反例 4.2 的正面论证:
| 存对象(❌) | 存 id(✅) | |
|---|---|---|
| 引用稳定性 | 命中检测可能返回拷贝/重建对象 → watch 误触发 | 字符串恒等,同 id 不触发 |
| 面板取数 | 直接读对象字段(快照过期风险) | 回 store 实时取(永远最新) |
| 序列化/调试 | 闭包引用难打印 | id 一眼可读 |
| 世界隔离 | 画布内部结构泄漏进 Vue | 只有 id 过桥 |
通用原则:跨世界(Canvas/Vue、Worker/主线程、前后端)传数据,传 id 传值,不传对象引用——今天是这条原则的第一次自觉实践(前面 Day 81 的 sink 协议其实是同款思想)。
四、pan/zoom:画布自治事务的确认
4.1 迁移核对清单
pan/zoom(第 7 周 Day 44 视口变换)是画布内闭环,理论上"零迁移成本",逐项确认:
□ 鼠标滚轮缩放:以鼠标点为中心(世界坐标锚定)——正常
□ 拖拽平移:光标变化(grab/grabbing)——正常
□ 视口状态:scale/offset 存在 renderer 内部——Console 检查不进 Vue
□ 拖拽期间:零 watch 触发(Day 87 的 update 打点不增长)——边界干净的证据
□ resize 后:视口保持(offset 不漂移)——DPR 与视口的正交性确认
4.2 HUD 的诱惑(记待办,不做)
"画布角落显示当前缩放比"是常见需求——它要求视口数据部分过桥(scale 只读投影)。方案:renderer 提供 onViewportChange 回调 + 节流 emit。今天是边界课,不做;记入待办,作为"边界扩桥"的练习题。
五、常见坑点
坑 1:emit 高频风暴
mousemove 每帧触发 → 每帧 emit → hoverId 虽不变但回调函数每次新建(@hover="(id) => ..." 内联箭头)。规范:renderer 侧"变化才上报"(1.2 的 lastHoverId 门闩)是主闸;组件侧内联箭头无碍(hoverId 同值赋值不触发响应)。
坑 2:详情面板闪烁
v-if 挂卸载的面板每次 hover 都重新入场动画 → 快速划过节点群时闪烁。修复:面板常驻 + v-show,或 hover 离开加 200ms 延迟卸载。本周选前者(大屏详情面板常驻是惯例)。
坑 3:hitTest 的坐标换算漏了视口
迁移时直接用了 offsetX——但第 7 周的命中检测是世界坐标(要过视口逆变换)。症状:缩放后 hover 命中错位。核对 renderer.hitTest 里 screenToWorld 的调用没被"简化"掉。
坑 4:面板盖住了画布交互
面板浮层挡住下层 canvas 的鼠标事件。修复:面板容器 pointer-events: none,内部交互元素再恢复 pointer-events: auto。
六、自测挑战
T1 · 出向桥 + 详情面板(70 分钟)
完成第一至二节:hover 节点出面板(状态/温度/迷你趋势)、离开消失、快速划过不闪烁。面板数据与屏 B 曲线同源同帧(都是 store)。
T2 · 边界纯净度验证(30 分钟)
拖拽视口(pan)连续 10 秒:Day 87 的 update 打点零增长 + Vue Devtools 组件树零高亮(无重渲)——边界干净的双重证据,录屏存档。
T3 · 存对象反例复现(20 分钟)
把 hoverId 临时改为存命中检测返回的对象,Devtools 观察 hoverId 的引用每次变化、面板 computed 连环重算;恢复存 id。坑 1 的肌肉记忆成型。
七、总结
| 环节 | 要点 |
|---|---|
| emit 桥 | 渲染器报告事实(id),不携带对象 |
| 变化才上报 | lastHoverId 门闩拦住每帧鼠标噪声 |
| 存 id 原则 | 跨世界传 id 传值不传引用——通用原则第一次自觉实践 |
| 面板取数 | 回 store 实时取,永远最新 |
| pan/zoom | 画布自治零迁移——边界干净的验证样本 |
双向桥全部通车。明天补最后一块:告警脉冲动画与它的 rAF 生命周期。