Files
menchuang/frontend/src/components/CombineCanvas.vue
T
HHY c780618a17 fix: v2.12 全部 2D 画布上下颠倒修复(组合/编辑/效果图/AI变体)
- 根因: 四画布 ty 按 y 向上渲染, 而模型坐标 y 向下(v2.11 实证) → 样板内部布局颠倒
- 统一去翻转: ty 直接映射 + toModel 反演同步 + 顶底序归位
- WindoorCanvas: 尺寸手柄(右下角)/中梃竖向拖动 dy 符号/扇符号与标注位置同步修正
- 验证: 组合页 2D 与 3D 方向一致, 设计图纸 704 布局=官方平面图, ui-shots 6 页无报错
2026-09-23 04:23:04 -07:00

553 lines
21 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.
<template>
<div class="cc-wrap" ref="wrapRef">
<canvas ref="cvRef" @wheel.prevent="onWheel" @mousedown="onDown" @mousemove="onMove"
@mouseup="onUp" @mouseleave="onUp" @dblclick="reset"
@contextmenu.prevent="onContextMenu" />
<div class="hud">
<span>{{ hint }}</span>
<el-button size="small" text @click="reset">重置视图</el-button>
</div>
<!-- 右键操作菜单 -->
<transition name="fade">
<div v-if="ctxMenu.visible" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
<div class="ctx-title">单元 #{{ ctxMenu.uid }}</div>
<div class="ctx-item" data-action="drag-move"><el-icon><Rank /></el-icon>拖拽移动此单元</div>
<div class="ctx-item" data-action="rotate-cw"><el-icon><RefreshRight /></el-icon>顺时针旋转 90°</div>
<div class="ctx-item" data-action="rotate-ccw"><el-icon><RefreshLeft /></el-icon>逆时针旋转 90°</div>
<div class="ctx-item" data-action="mirror"><el-icon><Switch /></el-icon>水平镜像</div>
<div class="ctx-item" data-action="bottom"><el-icon><Bottom /></el-icon>底边对齐</div>
<div class="ctx-item ctx-danger" data-action="delete"><el-icon><Delete /></el-icon>删除单元</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
/**
* 样板组合画布(自研,交互方式对齐原站画图编辑器:库面板拖拽 → 画布摆放 → 自由拖移 + 边缘吸附)
* props.shapes 为共享引用:画布直接平移形状几何(同 uid 的形状作为整体移动),
* 变更后 emit('change') 通知父组件;expose refresh()/modelAt() 供父组件投放与读取。
*/
import { onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { bboxOf, parseDrawing, regionPoints, replayShape, type Pt } from '../types/drawing'
interface BBox { x0: number; y0: number; x1: number; y1: number }
const props = withDefaults(defineProps<{ shapes: any[]; editable?: boolean }>(), { editable: true })
const emit = defineEmits<{
(e: 'change'): void
(e: 'select', uid: number | null): void
(e: 'ctx', action: string, uid: number): void
}>()
const cvRef = ref<HTMLCanvasElement>()
const wrapRef = ref<HTMLDivElement>()
const hint = ref('拖入样板开始组合')
const selectedUid = ref<number | null>(null)
const ctxMenu = reactive({ visible: false, x: 0, y: 0, uid: null as number | null })
let view = { scale: 1, cx: 0, cy: 0 }
let lastT = { scale: 1, midX: 0, midY: 0, W: 0, H: 0 }
let pan: { x: number; y: number } | null = null
let dragUnit: null | { uid: number; grabX: number; grabY: number; startBb: BBox; moved: boolean } = null
let guides: { v: number[]; h: number[] } = { v: [], h: [] }
function toModel(sx: number, sy: number): Pt {
const { scale, midX, midY, W, H } = lastT
return { x: (sx - W / 2 - view.cx) / scale + midX, y: (sy - H / 2 - view.cy) / scale + midY }
}
// ---------- 几何 ----------
function unitBBox(uid: number): BBox | null {
const pts = props.shapes.filter(s => s.__uid === uid).flatMap(shapePts)
return pts.length ? bboxOf(pts) : null
}
function shapePts(shape: any): Pt[] {
const out: Pt[] = []
for (const r of (shape.polygon?.polygon ?? []))
for (const seg of r) {
if (seg.ps && Number.isFinite(seg.ps.x) && Number.isFinite(seg.ps.y)) out.push(seg.ps)
if (seg.pe && Number.isFinite(seg.pe.x) && Number.isFinite(seg.pe.y)) out.push(seg.pe)
}
for (const sp of (shape.mm?.sp ?? [])) {
if (sp.line?.pt && Number.isFinite(sp.line.pt.x) && Number.isFinite(sp.line.pt.y)) out.push(sp.line.pt)
}
return out
}
function translateUid(uid: number, dx: number, dy: number) {
const mv = (p: any) => { p.x += dx; p.y += dy }
for (const shape of props.shapes) {
if (shape.__uid !== uid) continue
for (const r of (shape.polygon?.polygon ?? [])) for (const seg of r) { mv(seg.ps); mv(seg.pe) }
for (const sp of (shape.mm?.sp ?? [])) mv(sp.line.pt)
for (const sub of (shape.sm ?? [])) if (sub.bd) { mv(sub.bd.from); mv(sub.bd.to) }
}
}
function uids(): number[] {
return [...new Set(props.shapes.map(s => s.__uid))]
}
function bbOfLocal(shapes: any[]): BBox | null {
const pts = shapes.flatMap(shapePts)
return pts.length ? bboxOf(pts) : null
}
// ---------- 旋转/镜像编辑 ----------
/** 旋转整个单元(绕其几何中心),同步旋转分割线法向与开向角度 */
function rotateUnit(uid: number, deg: 90 | -90 | 180) {
const target = props.shapes.filter(s => s.__uid === uid)
const bb = bbOfLocal(target)
if (!bb) return
const cx2 = (bb.x0 + bb.x1) / 2, cy2 = (bb.y0 + bb.y1) / 2
const rad = (deg * Math.PI) / 180
const cos = Math.cos(rad), sin = Math.sin(rad)
const rotPt = (p: any) => {
const dx = p.x - cx2, dy = p.y - cy2
p.x = cx2 + dx * cos - dy * sin
p.y = cy2 + dx * sin + dy * cos
}
const rotVec = (v: any) => {
const nx = v.x ?? 1, ny = v.y ?? 0
v.x = nx * cos - ny * sin
v.y = nx * sin + ny * cos
}
for (const s of target) {
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { rotPt(seg.ps); rotPt(seg.pe) }
for (const sp of (s.mm?.sp ?? [])) { rotPt(sp.line.pt); if (sp.line.norm) rotVec(sp.line.norm) }
for (const sub of (s.sm ?? [])) {
if (sub.bd) { rotPt(sub.bd.from); rotPt(sub.bd.to) }
if (typeof sub.od === 'number') sub.od = (sub.od + deg + 360) % 360
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
if (typeof e?.od === 'number') e.od = (e.od + deg + 360) % 360
}
}
}
refresh()
emit('change')
}
/** 水平镜像整个单元(开向同步镜像) */
function mirrorUnit(uid: number) {
const target = props.shapes.filter(s => s.__uid === uid)
const bb = bbOfLocal(target)
if (!bb) return
const cx2 = (bb.x0 + bb.x1) / 2
const mirPt = (p: any) => { p.x = 2 * cx2 - p.x }
for (const s of target) {
for (const r of (s.polygon?.polygon ?? [])) for (const seg of r) { mirPt(seg.ps); mirPt(seg.pe) }
for (const sp of (s.mm?.sp ?? [])) {
mirPt(sp.line.pt)
if (sp.line.norm) sp.line.norm.x = -(sp.line.norm.x ?? 1)
}
for (const sub of (s.sm ?? [])) {
if (sub.bd) { mirPt(sub.bd.from); mirPt(sub.bd.to) }
if (typeof sub.od === 'number') sub.od = (180 - sub.od + 360) % 360
if (Array.isArray(sub.sashes)) for (const e of sub.sashes) {
if (typeof e?.od === 'number') e.od = (180 - e.od + 360) % 360
}
}
}
refresh()
emit('change')
}
// 吸附: 单元边缘贴近其他单元边缘时对齐(返回修正后的位置与辅助线)
/** 六边缘对齐吸附(标准画布编辑器模式): 拖动单元的左/中/右、上/中/下与其余单元对应边比对, 取最近者吸附 */
function snap(bb: BBox, excludeUid: number): { dx: number; dy: number; guides: { v: number[]; h: number[] } } {
const others = uids().filter(u => u !== excludeUid)
.map(u => unitBBox(u)).filter(b => b) as BBox[]
const gv: number[] = [], gh: number[] = []
let dx = 0, dy = 0
const myX = [bb.x0, (bb.x0 + bb.x1) / 2, bb.x1]
const myY = [bb.y0, (bb.y0 + bb.y1) / 2, bb.y1]
let bestX: { d: number; from: number; to: number } | null = null
let bestY: { d: number; from: number; to: number } | null = null
for (const o of others) {
const oX = [o.x0, (o.x0 + o.x1) / 2, o.x1]
const oY = [o.y0, (o.y0 + o.y1) / 2, o.y1]
for (const a of myX) for (const b of oX) {
const d = Math.abs(a - b)
if (d < 12 && (!bestX || d < bestX.d)) bestX = { d, from: a, to: b }
}
for (const a of myY) for (const b of oY) {
const d = Math.abs(a - b)
if (d < 12 && (!bestY || d < bestY.d)) bestY = { d, from: a, to: b }
}
}
if (bestX) { dx = bestX.to - bestX.from; gv.push(bestX.to) }
if (bestY) { dy = bestY.to - bestY.from; gh.push(bestY.to) }
return { dx, dy, guides: { v: gv, h: gh } }
}
// ---------- 渲染 ----------
function render() {
const cv = cvRef.value, wrap = wrapRef.value
if (!cv || !wrap) return
const dpr = window.devicePixelRatio || 1
cv.width = wrap.clientWidth * dpr
cv.height = wrap.clientHeight * dpr
const ctx = cv.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = '#eef1f4'
ctx.fillRect(0, 0, cv.width, cv.height)
const W = wrap.clientWidth, H = wrap.clientHeight
const pts = props.shapes.flatMap(shapePts)
if (!pts.length) { hint.value = '拖入样板开始组合'; return }
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const pad = 50
const fit = Math.min((W - pad * 2) / (gw || 1), (H - pad * 2) / (gh || 1))
const scale = fit * view.scale
const midX = (gb.x0 + gb.x1) / 2, midY = (gb.y0 + gb.y1) / 2
lastT = { scale, midX, midY, W, H }
const tx = (x: number) => W / 2 + (x - midX) * scale + view.cx
// 模型坐标 y 向下(屏幕系): 直接映射, 不做上下翻转
const ty = (y: number) => H / 2 + (y - midY) * scale + view.cy
ctx.lineJoin = 'round'
for (const shape of props.shapes) {
const sel = selectedUid.value !== null && shape.__uid === selectedUid.value
for (const region of replayShape(shape).regions) {
const rpts = regionPoints(region)
if (rpts.length < 3) continue
ctx.beginPath()
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = 'rgba(211,221,218,0.6)'
ctx.fill()
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
ctx.lineWidth = 1.6
ctx.stroke()
}
// 外框
const rs = replayShape(shape).regions.map(regionPoints).filter(p => p.length >= 3)
if (rs.length) {
const bb = bboxOf(rs.flat())
ctx.strokeStyle = sel ? '#409eff' : '#5c6166'
ctx.lineWidth = 3
ctx.strokeRect(tx(bb.x0), ty(bb.y0), (bb.x1 - bb.x0) * scale, (bb.y1 - bb.y0) * scale)
}
// 中梃(按重放区域裁剪近似:画在其基底 bbox 内)
const rb = rs.length ? bboxOf(rs.flat()) : null
for (const sp of (shape.mm?.sp ?? [])) {
if (!sp.line?.pt || !rb) continue
const n = sp.line.norm ?? { x: 1, y: 0 }
const pos = Math.abs(n.x) >= Math.abs(n.y) ? sp.line.pt.x : sp.line.pt.y
ctx.strokeStyle = sel ? '#66aaff' : '#7c828a'
ctx.lineWidth = 2
ctx.beginPath()
if (Math.abs(n.x) >= Math.abs(n.y)) { ctx.moveTo(tx(pos), ty(rb.y0)); ctx.lineTo(tx(pos), ty(rb.y1)) }
else { ctx.moveTo(tx(rb.x0), ty(pos)); ctx.lineTo(tx(rb.x1), ty(pos)) }
ctx.stroke()
}
}
// 吸附辅助线
ctx.strokeStyle = '#f56c6c'
ctx.setLineDash([5, 4])
ctx.lineWidth = 1
for (const v of guides.v) { ctx.beginPath(); ctx.moveTo(tx(v), 0); ctx.lineTo(tx(v), H); ctx.stroke() }
for (const h of guides.h) { ctx.beginPath(); ctx.moveTo(0, ty(h)); ctx.lineTo(W, ty(h)); ctx.stroke() }
ctx.setLineDash([])
// 选中单元: 蓝色虚线框 + 角点手柄 + 尺寸标注
if (selectedUid.value !== null) {
const bb = unitBBox(selectedUid.value)
if (bb) {
const x0 = tx(bb.x0), x1 = tx(bb.x1), y0 = ty(bb.y0), y1 = ty(bb.y1)
ctx.strokeStyle = '#409eff'
ctx.setLineDash([6, 4])
ctx.lineWidth = 1.5
ctx.strokeRect(x0 - 4, y0 - 4, (x1 - x0) + 8, (y1 - y0) + 8)
ctx.setLineDash([])
ctx.fillStyle = '#409eff'
for (const [hx, hy] of [[x0, y0], [x1, y0], [x0, y1], [x1, y1]]) {
ctx.fillRect(hx - 4, hy - 4, 8, 8)
}
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${Math.round(bb.x1 - bb.x0)} × ${Math.round(bb.y1 - bb.y0)} mm`, (x0 + x1) / 2, y0 - 10)
ctx.textAlign = 'left'
}
}
hint.value = `${uids().length} 个单元 · 总尺寸 ${Math.round(gw)} × ${Math.round(gh)} mm`
}
// ---------- 交互 ----------
function hitUnit(p: Pt): number | null {
let best: { uid: number; area: number } | null = null
for (const uid of uids()) {
const bb = unitBBox(uid)
if (!bb) continue
if (p.x >= bb.x0 && p.x <= bb.x1 && p.y >= bb.y0 && p.y <= bb.y1) {
const area = (bb.x1 - bb.x0) * (bb.y1 - bb.y0)
if (!best || area < best.area) best = { uid, area }
}
}
return best?.uid ?? null
}
function onDown(e: MouseEvent) {
closeMenu()
const rect = cvRef.value!.getBoundingClientRect()
// 拖拽移动模式: 点击落下
if (moveModeUid.value !== null) {
moveModeUid.value = null
moveLast = null
hint.value = '已落下'
render()
emit('change')
return
}
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const uid = hitUnit(p)
if (props.editable && uid !== null) {
selectedUid.value = uid
const bb = unitBBox(uid)!
dragUnit = { uid, grabX: p.x, grabY: p.y, startBb: { ...bb }, moved: false }
emit('select', uid)
render()
return
}
selectedUid.value = null
emit('select', null)
pan = { x: e.clientX, y: e.clientY }
render()
}
function onMove(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
// 拖拽移动模式: 鼠标移动即拖动单元
if (moveModeUid.value !== null) {
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
if (moveLast) {
translateUid(moveModeUid.value, p.x - moveLast.x, p.y - moveLast.y)
moveLast = p
render()
}
return
}
if (dragUnit) {
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const bb = unitBBox(dragUnit.uid)
if (!bb) return
let dx = p.x - dragUnit.grabX, dy = p.y - dragUnit.grabY
// 以拖动前 bbox 计算吸附
const cand = { ...bb }
cand.x0 += dx; cand.x1 += dx; cand.y0 += dy; cand.y1 += dy
const s = snap(cand, dragUnit.uid)
dx += s.dx; dy += s.dy
guides = s.guides
if (dx || dy) translateUid(dragUnit.uid, dx, dy)
dragUnit.grabX = p.x; dragUnit.grabY = p.y
dragUnit.moved = true
render()
return
}
if (pan) {
view.cx += e.clientX - pan.x
view.cy += e.clientY - pan.y
pan = { x: e.clientX, y: e.clientY }
render()
}
}
function onUp() {
if (dragUnit) {
const moved = dragUnit.moved
guides = { v: [], h: [] }
dragUnit = null
render()
if (moved) emit('change')
return
}
pan = null
}
function onWheel(e: WheelEvent) {
view.scale = Math.min(20, Math.max(0.15, view.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12)))
render()
}
function reset() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
/** 屏幕坐标 → 模型坐标(供父组件处理拖放落点) */
function modelAt(clientX: number, clientY: number): Pt {
const rect = cvRef.value!.getBoundingClientRect()
return toModel(clientX - rect.left, clientY - rect.top)
}
/** 右键菜单: 命中单元弹出操作菜单 */
function onContextMenu(e: MouseEvent) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(e.clientX - rect.left, e.clientY - rect.top)
const uid = hitUnit(p)
if (uid === null) { closeMenu(); return }
selectedUid.value = uid
ctxMenu.uid = uid
ctxMenu.visible = true
ctxMenu.x = e.clientX - rect.left + 6
ctxMenu.y = e.clientY - rect.top + 6
emit('select', uid)
render()
}
function closeMenu() { ctxMenu.visible = false }
function ctxAction(a: string) {
const uid = ctxMenu.uid
closeMenu()
if (uid === null) return
// 拖拽移动模式: 后续鼠标移动拖动该单元, 点击落下; Esc 取消还原
if (a === 'drag-move') {
moveModeUid.value = uid
moveOriginBb = unitBBox(uid)
moveLast = null
hint.value = '拖拽移动模式:移动鼠标拖动单元,点击落下;Esc 取消'
return
}
// 动作统一 emit 给父组件执行(父组件持有 canvasRef 与 shapes 权威状态)
emit('ctx', a, uid)
}
const moveModeUid = ref<number | null>(null)
let moveLast: Pt | null = null
let moveOriginBb: BBox | null = null
function refresh() { render() }
/** 选中单元缩放到目标尺寸(几何按比例缩放,锚定自身左下角) */
function resizeSelected(newW: number, newH: number) {
if (selectedUid.value === null) return
const target = props.shapes.filter(s => s.__uid === selectedUid.value)
const bb = bbOfLocal(target)
if (!bb) return
const w = bb.x1 - bb.x0, h = bb.y1 - bb.y0
if (w < 10 || h < 10) return
const sx = Math.max(0.05, newW / w), sy = Math.max(0.05, newH / h)
const scX = (p: any) => { p.x = bb.x0 + (p.x - bb.x0) * sx }
const scY = (p: any) => { p.y = bb.y0 + (p.y - bb.y0) * sy }
for (const s of target) {
for (const r of (s.polygon?.polygon ?? [])) {
for (const seg of r) { scX(seg.ps); scY(seg.ps); scX(seg.pe); scY(seg.pe) }
}
for (const sp of (s.mm?.sp ?? [])) { scX(sp.line.pt); scY(sp.line.pt) }
for (const sub of (s.sm ?? [])) if (sub.bd) { scX(sub.bd.from); scY(sub.bd.from); scX(sub.bd.to); scY(sub.bd.to) }
}
refresh()
emit('change')
}
/** 方向键微调(mm) */
function nudgeSelected(dx: number, dy: number) {
if (selectedUid.value === null) return
translateUid(selectedUid.value, dx, dy)
refresh()
emit('change')
}
/** 单元底边对齐到其余单元最低底边 */
function bottomAlignUnit(uid: number) {
const bb = unitBBox(uid)
const others = uids().filter(u => u !== uid).map(u => unitBBox(u)).filter(b => b) as BBox[]
if (!bb || !others.length) return
const target = Math.min(...others.map(o => o.y0))
translateUid(uid, 0, target - bb.y0)
refresh()
emit('change')
}
/** 自动排列: 全部单元按 X 序紧贴排布、底边对齐(可选的整理工具,不强制) */
function autoArrange() {
const ids = uids().filter(u => unitBBox(u)).sort((a, b) =>
(unitBBox(a)!.x0) - (unitBBox(b)!.x0))
if (!ids.length) return
let bottom = Infinity
for (const id of ids) {
const bb = unitBBox(id)!
bottom = Math.min(bottom, bb.y0)
}
let x = ids.length ? unitBBox(ids[0])!.x0 : 0
for (const id of ids) {
const bb = unitBBox(id)!
translateUid(id, x - bb.x0, bottom - bb.y0)
x += bb.x1 - bb.x0
}
refresh()
emit('change')
}
function exportDrawing(): string | null {
if (!props.shapes.length) return null
return JSON.stringify({
s: { cs: 60, cjs: 100 }, cp: [], sm: props.shapes,
epi: [], sbf: [], wlc: '', wall: [], scale: 0, note: [],
})
}
// 临时调试钩子(定位选中问题,验证后移除)
function debugHit(clientX: number, clientY: number) {
const rect = cvRef.value!.getBoundingClientRect()
const p = toModel(clientX - rect.left, clientY - rect.top)
const uid = hitUnit(p)
return { p, uid, lastT: { ...lastT }, rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height } }
}
;(window as any).__ccDebug = { debugHit, getSelected: () => selectedUid.value, units: uids }
defineExpose({ refresh, modelAt, exportDrawing, rotateUnit, mirrorUnit, resizeSelected, nudgeSelected, autoArrange, bottomAlignUnit, debugHit })
onMounted(() => {
watch(() => props.shapes, () => { selectedUid.value = null; closeMenu(); rebuild() }, { immediate: true })
window.addEventListener('resize', render)
// Esc 取消拖拽移动(还原到模式进入时的位置)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && moveModeUid.value !== null) {
const bb = unitBBox(moveModeUid.value)
if (bb && moveOriginBb) {
translateUid(moveModeUid.value,
(moveOriginBb.x0 + moveOriginBb.x1) / 2 - (bb.x0 + bb.x1) / 2,
(moveOriginBb.y0 + moveOriginBb.y1) / 2 - (bb.y0 + bb.y1) / 2)
}
moveModeUid.value = null
render()
}
}
window.addEventListener('keydown', onKey)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
// 点击菜单外关闭 + 菜单项动作(在 mousedown 阶段触发,真实点击必有效)
document.addEventListener('mousedown', (e: MouseEvent) => {
const item = (e.target as HTMLElement).closest?.('.ctx-item')
if (item && ctxMenu.visible) {
const action = (item as HTMLElement).dataset.action
ctxMenu.visible = false
if (action) ctxAction(action)
return
}
if (ctxMenu.visible && !(e.target as HTMLElement).closest('.ctx-menu')) closeMenu()
})
})
function rebuild() {
view = { scale: 1, cx: 0, cy: 0 }
render()
}
onBeforeUnmount(() => window.removeEventListener('resize', render))
</script>
<style scoped>
.cc-wrap { position: relative; width: 100%; height: 100%; min-height: 320px; }
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas:active { cursor: grabbing; }
.hud { position: absolute; left: 10px; top: 10px; background: rgba(255,255,255,0.9);
border-radius: 6px; padding: 3px 10px; font-size: 12px; color: #606266; }
.ctx-menu { position: absolute; z-index: 40; background: #fff; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.16); padding: 4px 0; min-width: 150px; }
.ctx-title { font-size: 11px; color: #909399; padding: 4px 12px; border-bottom: 1px solid #f0f0f0; }
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 7px 12px; font-size: 13px;
color: #303133; cursor: pointer; }
.ctx-item:hover { background: #f5f7fa; }
.ctx-danger { color: #f56c6c; }
</style>