|
|
@@ -1,57 +1,41 @@
|
|
|
<template>
|
|
|
- <!-- 主内容容器 -->
|
|
|
<div class="content">
|
|
|
- <!-- CAD图纸查看区域 -->
|
|
|
<div class="viewer-area">
|
|
|
- <!-- DWG图纸渲染画布,id用于Viewer2d初始化 -->
|
|
|
<div :id="containerId" class="viewer-canvas"></div>
|
|
|
|
|
|
- <!-- 坐标状态栏:显示鼠标在世界坐标系中的位置 -->
|
|
|
<div v-if="viewerReady" class="coord-bar">
|
|
|
<span>X: {{ currentCoord.x.toFixed(2) }}</span>
|
|
|
<span>Y: {{ currentCoord.y.toFixed(2) }}</span>
|
|
|
- <!-- 布点模式下显示提示信息,Esc可取消 -->
|
|
|
<span v-if="placingMode" class="placing-hint">| 放置: {{ placingDevice?.strname }} (Esc取消)</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 右侧设备列表面板,支持折叠展开 -->
|
|
|
<div class="device-panel" :class="{ collapsed: panelCollapsed }">
|
|
|
- <!-- 面板头部:点击切换展开/收起状态 -->
|
|
|
<div class="panel-header" @click="panelCollapsed = !panelCollapsed">
|
|
|
<span class="panel-title">设备列表</span>
|
|
|
<span class="panel-toggle">{{ panelCollapsed ? '展开' : '收起' }}</span>
|
|
|
</div>
|
|
|
- <!-- 面板内容:设备表格,显示名称、状态、布点操作 -->
|
|
|
<div v-show="!panelCollapsed" class="panel-body">
|
|
|
<a-table :columns="columns" :data-source="devices" :pagination="false" row-key="id" size="small" :scroll="{ y: 360 }">
|
|
|
<template #bodyCell="{ column, record }">
|
|
|
- <!-- 状态列:根据netStatus显示在线/离线标签 -->
|
|
|
<template v-if="column.key === 'netStatus'">
|
|
|
<a-tag :color="statusColor(record.netStatus)">{{ statusText(record.netStatus) }}</a-tag>
|
|
|
</template>
|
|
|
- <!-- 操作列:未布点显示布点按钮,已布点显示已布点和撤销按钮 -->
|
|
|
<template v-if="column.key === 'action'">
|
|
|
- <!-- <a-button
|
|
|
- v-if="!isDevicePlaced(record)"
|
|
|
- type="primary"
|
|
|
- size="small"
|
|
|
- :disabled="placingMode"
|
|
|
- @click="enterPlacingMode(record)"
|
|
|
- >布点</a-button> -->
|
|
|
- <a-button
|
|
|
+ <a-button
|
|
|
v-if="!isDevicePlaced(record)"
|
|
|
type="primary"
|
|
|
size="small"
|
|
|
@click="enterPlacingMode(record)"
|
|
|
>布点</a-button>
|
|
|
- <template v-else>
|
|
|
- <!-- <div style="display: flex; align-items: center; justify-content: center;">
|
|
|
- <a-button size="small" disabled style="margin-right: 4px">已布点</a-button>
|
|
|
- <a-button type="link" size="small" danger :disabled="placingMode" @click="revokePlacement(record)">撤销</a-button>
|
|
|
- </div> -->
|
|
|
- <a-button type="link" size="small" primary :disabled="placingMode" @click="revokePlacement(record)">撤销</a-button>
|
|
|
- </template>
|
|
|
+ <a-button
|
|
|
+ v-else
|
|
|
+ type="link"
|
|
|
+ size="small"
|
|
|
+ danger
|
|
|
+ :disabled="placingMode"
|
|
|
+ @click="revokePlacement(record)"
|
|
|
+ >撤销</a-button>
|
|
|
</template>
|
|
|
</template>
|
|
|
</a-table>
|
|
|
@@ -106,20 +90,33 @@
|
|
|
|
|
|
const containerId = 'dwg-viewer-canvas';
|
|
|
const dwgSrc = '/dwg/2026年2月总平面布置图_无高程.dwg';
|
|
|
+ const MARKER_ICON_SRC = '/texture/safetymonitor3D.png';
|
|
|
/** 布点持久化存储 key(按图纸区分) */
|
|
|
const MARKER_STORAGE_KEY = 'dwg-viewer-markers:main-plan';
|
|
|
/** 正常状态布点颜色(绿色) */
|
|
|
const MARKER_COLOR_NORMAL = 0x52c41a;
|
|
|
/** 报警状态布点颜色(红色) */
|
|
|
const MARKER_COLOR_ALARM = 0xff4d4f;
|
|
|
+ /** 布点圆点几何半径(世界坐标) */
|
|
|
+ const MARKER_MESH_RADIUS = 100;
|
|
|
+ /** 信息框相对布点右边缘的间距(px) */
|
|
|
+ const INFO_PANEL_GAP = 15;
|
|
|
+ /** 信息框字号基准:布点屏幕半宽为该值时,字号为 12px */
|
|
|
+ const INFO_PANEL_BASE_HALF = 36;
|
|
|
+ /** 信息框字号基准(px) */
|
|
|
+ const INFO_PANEL_BASE_FONT = 12;
|
|
|
+ /**
|
|
|
+ * 初始化仅加载必要字体,减少 setFont 阻塞时间
|
|
|
+ * (图纸中文依赖 shx + 一份 TrueType/typeface)
|
|
|
+ */
|
|
|
const fontFiles = [
|
|
|
'/dwg/simplex.shx',
|
|
|
'/dwg/hztxt.shx',
|
|
|
- '/dwg/arial.ttf',
|
|
|
- '/dwg/Microsoft_YaHei.ttf',
|
|
|
'/dwg/Microsoft_YaHei_Regular.typeface.json',
|
|
|
];
|
|
|
let markerIdCounter = 0;
|
|
|
+ /** 共享圆点几何体,避免每个布点重复创建 */
|
|
|
+ let sharedCircleGeom: THREE.CircleGeometry | null = null;
|
|
|
|
|
|
// ==================== 表格 ====================
|
|
|
|
|
|
@@ -210,10 +207,7 @@
|
|
|
}
|
|
|
|
|
|
/** 应用布点颜色(圆点背景 + 图标着色) */
|
|
|
- function applyMarkerColor(
|
|
|
- marker: { mesh: THREE.Mesh; iconSprite: THREE.Sprite },
|
|
|
- color: number
|
|
|
- ) {
|
|
|
+ function applyMarkerColor(marker: { mesh: THREE.Mesh; iconSprite: THREE.Sprite }, color: number) {
|
|
|
(marker.mesh.material as THREE.MeshBasicMaterial).color.setHex(color);
|
|
|
(marker.iconSprite.material as THREE.SpriteMaterial).color.setHex(color);
|
|
|
}
|
|
|
@@ -225,7 +219,7 @@
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- /** 获取信息框展示内容:设备名称、安装位置、运行状态、报警状态、实时温度 */
|
|
|
+ /** 获取信息框展示内容 */
|
|
|
function getMarkerInfoData(deviceId: string): MarkerInfoData {
|
|
|
const device = findDevice(deviceId);
|
|
|
const temperature = device?.readData?.temperature ?? device?.temperature;
|
|
|
@@ -233,28 +227,20 @@
|
|
|
deviceName: device?.strname || '未知',
|
|
|
installPos: device?.strinstallpos || '-',
|
|
|
runStatus: device?.netStatus == 1 ? '在线' : device?.netStatus == 0 ? '离线' : '-',
|
|
|
- alarmStatus: device?.syswarnLevel ? '报警' : '正常',
|
|
|
+ alarmStatus: isDeviceAlarm(device) ? '报警' : '正常',
|
|
|
temperature: temperature === undefined || temperature === null || temperature === '' ? '-' : String(temperature),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
// ==================== 状态 ====================
|
|
|
|
|
|
- /** 2D查看器是否已完成初始化 */
|
|
|
const viewerReady = ref(false);
|
|
|
- /** Viewer2d实例,用于操作CAD图纸 */
|
|
|
let viewer: Viewer2d | null = null;
|
|
|
-
|
|
|
- /** 右侧设备面板是否折叠 */
|
|
|
const panelCollapsed = ref(true);
|
|
|
- /** 是否处于布点模式(点击图纸可放置设备标记) */
|
|
|
const placingMode = ref(false);
|
|
|
- /** 当前待布点的设备对象 */
|
|
|
const placingDevice = ref<DeviceRecord | null>(null);
|
|
|
- /** 当前鼠标在世界坐标系中的位置(用于坐标栏显示) */
|
|
|
const currentCoord = reactive({ x: 0, y: 0, z: 0 });
|
|
|
|
|
|
- // 存储 marker:markerId → { deviceId, worldPos, mesh, iconSprite }
|
|
|
const markersMap = new Map<
|
|
|
string,
|
|
|
{
|
|
|
@@ -265,16 +251,47 @@
|
|
|
}
|
|
|
>();
|
|
|
const textureLoader = new THREE.TextureLoader();
|
|
|
+ /** 布点精灵图标共享纹理(预加载后复用) */
|
|
|
+ let markerIconTexture: THREE.Texture | null = null;
|
|
|
+ /** 信息框跟随事件是否已挂载 */
|
|
|
+ let infoPanelFollowBound = false;
|
|
|
+ /** mousemove 坐标更新 rAF */
|
|
|
+ let mouseMoveRaf = 0;
|
|
|
+
|
|
|
+ function getSharedCircleGeom() {
|
|
|
+ if (!sharedCircleGeom) {
|
|
|
+ // 分段数降至 24,足够圆滑且创建更快
|
|
|
+ sharedCircleGeom = new THREE.CircleGeometry(MARKER_MESH_RADIUS, 24);
|
|
|
+ }
|
|
|
+ return sharedCircleGeom;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 获取/预加载布点图标纹理 */
|
|
|
+ function ensureMarkerIconTexture() {
|
|
|
+ if (markerIconTexture) return markerIconTexture;
|
|
|
+ markerIconTexture = textureLoader.load(MARKER_ICON_SRC, () => {
|
|
|
+ if (viewer && markersMap.size > 0) forceViewerRender();
|
|
|
+ });
|
|
|
+ return markerIconTexture;
|
|
|
+ }
|
|
|
+
|
|
|
+ function forceViewerRender() {
|
|
|
+ if (!viewer) return;
|
|
|
+ viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
+ viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 缓存 appStore,避免每次坐标换算重复取 store */
|
|
|
+ const appStore = useAppStoreWithOut();
|
|
|
|
|
|
// ==================== 核心:坐标转换 ====================
|
|
|
|
|
|
- // 画布容器有强制缩放 (widthScale/heightScale),需校准坐标
|
|
|
function scaledScreenCoord(e: { clientX: number; clientY: number }, container: HTMLElement): THREE.Vector2 {
|
|
|
- const appStore = useAppStoreWithOut();
|
|
|
- const ws = appStore.getWidthScale;
|
|
|
- const hs = appStore.getHeightScale;
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
- return new THREE.Vector2((e.clientX - rect.left) / ws, (e.clientY - rect.top) / hs);
|
|
|
+ return new THREE.Vector2(
|
|
|
+ (e.clientX - rect.left) / appStore.getWidthScale,
|
|
|
+ (e.clientY - rect.top) / appStore.getHeightScale
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
function getHitResult(event: MouseEvent): { x: number; y: number } | null {
|
|
|
@@ -287,16 +304,22 @@
|
|
|
return { x: worldPos.x, y: worldPos.y };
|
|
|
}
|
|
|
|
|
|
+ function updateCurrentCoord(event: MouseEvent, container: HTMLElement) {
|
|
|
+ if (!viewer) return;
|
|
|
+ const sc = scaledScreenCoord(event, container);
|
|
|
+ const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
|
|
|
+ if (wp) {
|
|
|
+ currentCoord.x = wp.x;
|
|
|
+ currentCoord.y = wp.y;
|
|
|
+ currentCoord.z = wp.z;
|
|
|
+ }
|
|
|
+ return sc;
|
|
|
+ }
|
|
|
+
|
|
|
// ==================== 放置模式 ====================
|
|
|
|
|
|
- /** 布点模式下的悬浮圆点DOM元素(跟随鼠标移动) */
|
|
|
let floatingDot: HTMLElement | null = null;
|
|
|
|
|
|
- /**
|
|
|
- * 进入布点模式
|
|
|
- * 创建悬浮圆点,清除设备原有标记,设置布点状态
|
|
|
- * @param device 待布点的设备记录
|
|
|
- */
|
|
|
function enterPlacingMode(device: DeviceRecord) {
|
|
|
if (!viewer) return;
|
|
|
if (device.markerId) removeMarker(device.markerId);
|
|
|
@@ -314,23 +337,24 @@
|
|
|
const cont = document.getElementById(containerId);
|
|
|
if (cont) cont.appendChild(floatingDot);
|
|
|
}
|
|
|
- if (floatingDot) floatingDot.style.display = '';
|
|
|
+ floatingDot.style.display = '';
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 退出布点模式
|
|
|
- * 隐藏悬浮圆点,重置布点状态
|
|
|
- */
|
|
|
function exitPlacingMode() {
|
|
|
placingMode.value = false;
|
|
|
placingDevice.value = null;
|
|
|
if (floatingDot) floatingDot.style.display = 'none';
|
|
|
}
|
|
|
|
|
|
- function placeMarker(device: DeviceRecord, location: { x: number; y: number }, options?: { showInfo?: boolean; persist?: boolean }) {
|
|
|
+ function placeMarker(
|
|
|
+ device: DeviceRecord,
|
|
|
+ location: { x: number; y: number },
|
|
|
+ options?: { showInfo?: boolean; persist?: boolean; skipRender?: boolean }
|
|
|
+ ) {
|
|
|
if (!viewer) return;
|
|
|
const showInfo = options?.showInfo !== false;
|
|
|
const persist = options?.persist !== false;
|
|
|
+ const skipRender = !!options?.skipRender;
|
|
|
|
|
|
markerIdCounter++;
|
|
|
const markerId = `m${markerIdCounter}`;
|
|
|
@@ -339,15 +363,14 @@
|
|
|
const frustumH = cam.top - cam.bottom;
|
|
|
const markerColor = getMarkerColor(device);
|
|
|
|
|
|
- // THREE.Mesh 实心圆点 — 作为报警状态背景色,并用于拾取点击
|
|
|
- const geom = new THREE.CircleGeometry(100, 32);
|
|
|
+ // THREE.Mesh 实心圆点 — 共享几何体,加快批量恢复
|
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
|
color: markerColor,
|
|
|
side: THREE.DoubleSide,
|
|
|
depthTest: false,
|
|
|
depthWrite: false,
|
|
|
});
|
|
|
- const mesh = new THREE.Mesh(geom, mat);
|
|
|
+ const mesh = new THREE.Mesh(getSharedCircleGeom(), mat);
|
|
|
mesh.position.set(location.x, location.y, 0);
|
|
|
mesh.renderOrder = 999;
|
|
|
mesh.matrixAutoUpdate = true;
|
|
|
@@ -356,10 +379,9 @@
|
|
|
mesh.userData = { markerId, deviceId };
|
|
|
viewer.scene.add(mesh);
|
|
|
|
|
|
- // 精灵图标(按报警状态着色)
|
|
|
- const tex = textureLoader.load('/texture/safetymonitor3D.png');
|
|
|
+ // 精灵图标(使用预加载纹理)
|
|
|
const spriteMat = new THREE.SpriteMaterial({
|
|
|
- map: tex,
|
|
|
+ map: ensureMarkerIconTexture(),
|
|
|
color: markerColor,
|
|
|
depthTest: false,
|
|
|
depthWrite: false,
|
|
|
@@ -372,6 +394,7 @@
|
|
|
icon.renderOrder = 1000;
|
|
|
icon.matrixAutoUpdate = true;
|
|
|
icon.updateMatrixWorld();
|
|
|
+ icon.userData = { markerId, deviceId };
|
|
|
viewer.scene.add(icon);
|
|
|
|
|
|
markersMap.set(markerId, {
|
|
|
@@ -384,6 +407,7 @@
|
|
|
const next = new Set(placedDeviceIds.value);
|
|
|
next.add(deviceId);
|
|
|
placedDeviceIds.value = next;
|
|
|
+ if (!skipRender) forceViewerRender();
|
|
|
if (persist) savePersistedMarkers();
|
|
|
if (showInfo) {
|
|
|
exitPlacingMode();
|
|
|
@@ -391,42 +415,39 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** 从本地存储恢复布点(刷新页面后还原) */
|
|
|
+ /** 从本地存储恢复布点(批量创建,最后统一渲染) */
|
|
|
function restoreMarkers() {
|
|
|
if (!viewer) return;
|
|
|
const list = loadPersistedMarkers();
|
|
|
if (!list.length) return;
|
|
|
+ const placedIds = new Set([...markersMap.values()].map((m) => m.deviceId));
|
|
|
list.forEach((item) => {
|
|
|
+ if (placedIds.has(item.deviceId)) return;
|
|
|
const device = findDevice(item.deviceId) || ({ deviceID: item.deviceId, strname: '未知' } as DeviceRecord);
|
|
|
if (device.markerId && markersMap.has(device.markerId)) return;
|
|
|
- if ([...markersMap.values()].some((m) => m.deviceId === item.deviceId)) return;
|
|
|
- placeMarker(device, { x: item.x, y: item.y }, { showInfo: false, persist: false });
|
|
|
+ placeMarker(device, { x: item.x, y: item.y }, { showInfo: false, persist: false, skipRender: true });
|
|
|
+ placedIds.add(item.deviceId);
|
|
|
});
|
|
|
syncPlacedStateToDevices();
|
|
|
+ forceViewerRender();
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 移除指定标记点
|
|
|
- * 从场景中移除mesh和图标精灵,释放THREE.js资源,更新状态并持久化
|
|
|
- * @param markerId 标记点ID
|
|
|
- */
|
|
|
function removeMarker(markerId: string) {
|
|
|
if (!viewer) return;
|
|
|
const m = markersMap.get(markerId);
|
|
|
if (!m) return;
|
|
|
- const dev = deviceList.value.find((d) => (d as DeviceRecord).markerId === markerId) as DeviceRecord | undefined;
|
|
|
- if (dev) dev.markerId = null;
|
|
|
+ const dev = findDevice(m.deviceId);
|
|
|
+ if (dev && dev.markerId === markerId) dev.markerId = null;
|
|
|
if (m.deviceId) {
|
|
|
const next = new Set(placedDeviceIds.value);
|
|
|
next.delete(m.deviceId);
|
|
|
placedDeviceIds.value = next;
|
|
|
}
|
|
|
- // 从场景移除并释放几何体和材质资源
|
|
|
viewer.scene.remove(m.mesh);
|
|
|
- m.mesh.geometry.dispose();
|
|
|
+ // 共享几何体不 dispose
|
|
|
(m.mesh.material as THREE.Material).dispose();
|
|
|
viewer.scene.remove(m.iconSprite);
|
|
|
- (m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
|
|
|
+ // 共享纹理不 dispose,只释放材质
|
|
|
m.iconSprite.material.dispose();
|
|
|
markersMap.delete(markerId);
|
|
|
savePersistedMarkers();
|
|
|
@@ -464,6 +485,8 @@
|
|
|
/** 当前打开的信息框对应的 markerId,用于缩放/平移时更新位置 */
|
|
|
let activeInfoMarkerId: string | null = null;
|
|
|
let infoPanelCloseHandler: ((e: MouseEvent) => void) | null = null;
|
|
|
+ /** 信息框跟随更新的 rAF 标记,避免同一帧重复计算 */
|
|
|
+ let infoPanelFollowRaf = 0;
|
|
|
|
|
|
function hideInfoPanel() {
|
|
|
infoPanel?.remove();
|
|
|
@@ -473,27 +496,139 @@
|
|
|
document.removeEventListener('click', infoPanelCloseHandler);
|
|
|
infoPanelCloseHandler = null;
|
|
|
}
|
|
|
+ if (infoPanelFollowRaf) {
|
|
|
+ cancelAnimationFrame(infoPanelFollowRaf);
|
|
|
+ infoPanelFollowRaf = 0;
|
|
|
+ }
|
|
|
+ unbindInfoPanelFollow();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 仅在信息框打开时绑定跟随事件,降低空闲时开销 */
|
|
|
+ function bindInfoPanelFollow() {
|
|
|
+ if (!viewer || infoPanelFollowBound) return;
|
|
|
+ viewer.addEventListener(ViewerEvent.CameraChange, scheduleInfoPanelFollow);
|
|
|
+ viewer.addEventListener(ViewerEvent.ControlChange, scheduleInfoPanelFollow);
|
|
|
+ viewer.addEventListener(ViewerEvent.AfterRender, scheduleInfoPanelFollow);
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ container?.addEventListener('wheel', scheduleInfoPanelFollow, { passive: true });
|
|
|
+ infoPanelFollowBound = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function unbindInfoPanelFollow() {
|
|
|
+ if (!viewer || !infoPanelFollowBound) return;
|
|
|
+ viewer.removeEventListener(ViewerEvent.CameraChange, scheduleInfoPanelFollow);
|
|
|
+ viewer.removeEventListener(ViewerEvent.ControlChange, scheduleInfoPanelFollow);
|
|
|
+ viewer.removeEventListener(ViewerEvent.AfterRender, scheduleInfoPanelFollow);
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ container?.removeEventListener('wheel', scheduleInfoPanelFollow);
|
|
|
+ infoPanelFollowBound = false;
|
|
|
}
|
|
|
|
|
|
- /** 根据 marker 世界坐标刷新信息框屏幕位置 */
|
|
|
- function updateInfoPanelPosition() {
|
|
|
- if (!infoPanel || !activeInfoMarkerId || !viewer) return;
|
|
|
+ /** 将世界坐标转为容器内屏幕坐标 */
|
|
|
+ function worldToContainerScreen(x: number, y: number) {
|
|
|
+ if (!viewer) return null;
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ if (!container) return null;
|
|
|
+ return CoordinateUtils.world2Screen(new THREE.Vector3(x, y, 0), viewer.camera, container);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算布点在屏幕上的半宽(取圆点与图标较大值)
|
|
|
+ * 随滚轮缩放变化,用于信息框避让与字号缩放
|
|
|
+ */
|
|
|
+ function getMarkerScreenHalfSize(marker: { worldPos: { x: number; y: number }; iconSprite: THREE.Sprite }) {
|
|
|
+ const { x, y } = marker.worldPos;
|
|
|
+ const iconHalf = Math.max(marker.iconSprite.scale.x, marker.iconSprite.scale.y) / 2;
|
|
|
+ const worldHalf = Math.max(MARKER_MESH_RADIUS, iconHalf);
|
|
|
+ const center = worldToContainerScreen(x, y);
|
|
|
+ const edge = worldToContainerScreen(x + worldHalf, y);
|
|
|
+ if (!center || !edge) return 12;
|
|
|
+ return Math.max(Math.abs(edge.x - center.x), 8);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 按布点当前屏幕尺寸,更新信息框位置与字号
|
|
|
+ * - 位置:始终在布点右边缘外侧 15px,垂直居中
|
|
|
+ * - 字号/内边距:随布点放大缩小同步变化
|
|
|
+ */
|
|
|
+ function applyInfoPanelPosition() {
|
|
|
+ if (!infoPanel || !activeInfoMarkerId) return;
|
|
|
const m = markersMap.get(activeInfoMarkerId);
|
|
|
if (!m) return;
|
|
|
- const container = document.getElementById(containerId);
|
|
|
- if (!container) return;
|
|
|
- const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
|
|
|
+ const sp = worldToContainerScreen(m.worldPos.x, m.worldPos.y);
|
|
|
if (!sp) return;
|
|
|
- infoPanel.style.left = `${sp.x + 16}px`;
|
|
|
- infoPanel.style.top = `${sp.y - 10}px`;
|
|
|
+
|
|
|
+ const half = getMarkerScreenHalfSize(m);
|
|
|
+ const left = sp.x + half + INFO_PANEL_GAP;
|
|
|
+ const top = sp.y;
|
|
|
+
|
|
|
+ // 字号随布点屏幕尺寸缩放,限制在合理范围,避免过小/过大
|
|
|
+ const scale = Math.min(2.2, Math.max(0.7, half / INFO_PANEL_BASE_HALF));
|
|
|
+ const fontSize = +(INFO_PANEL_BASE_FONT * scale).toFixed(1);
|
|
|
+ const padY = +(6 * scale).toFixed(1);
|
|
|
+ const padX = +(8 * scale).toFixed(1);
|
|
|
+
|
|
|
+ infoPanel.style.left = `${left}px`;
|
|
|
+ infoPanel.style.top = `${top}px`;
|
|
|
+ infoPanel.style.transform = 'translateY(-50%)';
|
|
|
+ infoPanel.style.fontSize = `${fontSize}px`;
|
|
|
+ infoPanel.style.padding = `${padY}px ${padX}px`;
|
|
|
+ infoPanel.style.borderRadius = `${Math.max(4, 6 * scale)}px`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 调度信息框跟随更新 */
|
|
|
+ function scheduleInfoPanelFollow() {
|
|
|
+ if (!infoPanel || !activeInfoMarkerId) return;
|
|
|
+ if (infoPanelFollowRaf) return;
|
|
|
+ infoPanelFollowRaf = requestAnimationFrame(() => {
|
|
|
+ infoPanelFollowRaf = 0;
|
|
|
+ applyInfoPanelPosition();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从拾取对象或其父级上读取 markerId */
|
|
|
+ function getMarkerIdFromObject(object: any): string | null {
|
|
|
+ let obj = object;
|
|
|
+ while (obj) {
|
|
|
+ if (obj.userData?.markerId) return obj.userData.markerId as string;
|
|
|
+ obj = obj.parent;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 显示设备信息浮窗
|
|
|
- * 在标记点附近创建信息面板,显示设备名称、安装位置、运行状态、报警状态、实时温度
|
|
|
- * 点击面板外区域或关闭按钮可隐藏
|
|
|
- * @param markerId 标记点ID
|
|
|
+ * 根据屏幕坐标查找被点击的布点
|
|
|
+ * 优先使用 pickObject,失败时按与布点中心的屏幕距离判定(保证点中图标也能弹出)
|
|
|
*/
|
|
|
+ function findClickedMarkerId(sc: THREE.Vector2): string | null {
|
|
|
+ if (!viewer) return null;
|
|
|
+
|
|
|
+ const hit = viewer.pickObject(sc);
|
|
|
+ const pickedId = getMarkerIdFromObject(hit?.object);
|
|
|
+ if (pickedId && markersMap.has(pickedId)) return pickedId;
|
|
|
+
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ if (!container) return null;
|
|
|
+
|
|
|
+ let bestId: string | null = null;
|
|
|
+ let bestDist = Infinity;
|
|
|
+ markersMap.forEach((m, id) => {
|
|
|
+ const sp = worldToContainerScreen(m.worldPos.x, m.worldPos.y);
|
|
|
+ if (!sp) return;
|
|
|
+ const dx = sp.x - sc.x;
|
|
|
+ const dy = sp.y - sc.y;
|
|
|
+ const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
+ // 点击半径:布点屏幕半宽,至少 24px,便于点选
|
|
|
+ const hitRadius = Math.max(getMarkerScreenHalfSize(m), 24);
|
|
|
+ if (dist <= hitRadius && dist < bestDist) {
|
|
|
+ bestDist = dist;
|
|
|
+ bestId = id;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return bestId;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 显示设备信息浮窗(布点右侧 15px,尺寸随缩放自适应) */
|
|
|
function showMarkerInfo(markerId: string) {
|
|
|
const m = markersMap.get(markerId);
|
|
|
if (!m || !viewer) return;
|
|
|
@@ -503,15 +638,14 @@
|
|
|
|
|
|
hideInfoPanel();
|
|
|
|
|
|
- const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
|
|
|
-
|
|
|
infoPanel = document.createElement('div');
|
|
|
activeInfoMarkerId = markerId;
|
|
|
infoPanel.style.cssText = `
|
|
|
- position:absolute; left:${sp.x + 16}px; top:${sp.y - 10}px;
|
|
|
+ position:absolute; left:0; top:0;
|
|
|
+ transform:translateY(-50%);
|
|
|
background:rgba(0,0,0,0.88); color:#fff; font-size:12px;
|
|
|
border:1px solid rgba(255,255,255,0.2); border-radius:6px;
|
|
|
- padding:8px 10px; white-space:nowrap; z-index:600;
|
|
|
+ padding:6px 8px; white-space:nowrap; z-index:600;
|
|
|
pointer-events:auto; line-height:1.8;
|
|
|
`;
|
|
|
infoPanel.innerHTML = `
|
|
|
@@ -525,65 +659,69 @@
|
|
|
<div>实时温度: ${info.temperature}${info.temperature !== '-' ? '℃' : ''}</div>
|
|
|
`;
|
|
|
container.appendChild(infoPanel);
|
|
|
+ bindInfoPanelFollow();
|
|
|
+ applyInfoPanelPosition();
|
|
|
infoPanel.querySelector('#info-close')?.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
|
hideInfoPanel();
|
|
|
});
|
|
|
infoPanelCloseHandler = (e: MouseEvent) => {
|
|
|
- if (!infoPanel?.contains(e.target as Node)) {
|
|
|
- hideInfoPanel();
|
|
|
- }
|
|
|
+ if (!infoPanel?.contains(e.target as Node)) hideInfoPanel();
|
|
|
};
|
|
|
setTimeout(() => {
|
|
|
if (infoPanelCloseHandler) document.addEventListener('click', infoPanelCloseHandler);
|
|
|
}, 100);
|
|
|
}
|
|
|
|
|
|
+ /** 首屏绘制完成后再恢复历史布点,避免阻塞图纸首次可见 */
|
|
|
+ function scheduleRestoreMarkers() {
|
|
|
+ const run = () => restoreMarkers();
|
|
|
+ if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
|
|
|
+ (window as any).requestIdleCallback(run, { timeout: 800 });
|
|
|
+ } else {
|
|
|
+ requestAnimationFrame(() => setTimeout(run, 0));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// ==================== 初始化 ====================
|
|
|
|
|
|
- /**
|
|
|
- * 组件挂载时初始化
|
|
|
- * 1. 创建Viewer2d实例加载DWG图纸
|
|
|
- * 2. 注册鼠标点击/移动事件(坐标显示、布点、信息查看)
|
|
|
- * 3. 监听相机变化更新信息框位置
|
|
|
- */
|
|
|
+ const onKeydown = (e: KeyboardEvent) => {
|
|
|
+ if (e.key === 'Escape' && placingMode.value) exitPlacingMode();
|
|
|
+ };
|
|
|
+
|
|
|
onMounted(async () => {
|
|
|
+ window.addEventListener('keydown', onKeydown);
|
|
|
+ // 与 Viewer 初始化并行预加载图标,缩短首次布点等待
|
|
|
+ ensureMarkerIconTexture();
|
|
|
+
|
|
|
try {
|
|
|
viewer = new Viewer2d({
|
|
|
containerId,
|
|
|
enableSpinner: true,
|
|
|
enableProgressBar: true,
|
|
|
- enableLayoutBar: true,
|
|
|
+ // 本页不需要布局切换栏,关闭可减少初始化与 UI 开销
|
|
|
+ enableLayoutBar: false,
|
|
|
});
|
|
|
await viewer.setFont(fontFiles);
|
|
|
await viewer.loadModel({ modelId: 'main-plan', name: '总平面布置图', src: dwgSrc });
|
|
|
|
|
|
viewer.goToHomeView();
|
|
|
viewerReady.value = true;
|
|
|
+ forceViewerRender();
|
|
|
|
|
|
- restoreMarkers();
|
|
|
- viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
- viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
-
|
|
|
+ // 事件尽早绑定,保证可交互;历史布点延后恢复
|
|
|
viewer.addEventListener(ViewerEvent.MouseClick, (data: any) => {
|
|
|
if (!viewer) return;
|
|
|
-
|
|
|
const container = document.getElementById(containerId);
|
|
|
- if (data?.event && container) {
|
|
|
- const sc = scaledScreenCoord(data.event, container);
|
|
|
- const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
|
|
|
- if (wp) {
|
|
|
- currentCoord.x = wp.x;
|
|
|
- currentCoord.y = wp.y;
|
|
|
- currentCoord.z = wp.z;
|
|
|
- }
|
|
|
- }
|
|
|
+ if (!data?.event || !container) return;
|
|
|
+
|
|
|
+ const sc = updateCurrentCoord(data.event, container);
|
|
|
|
|
|
- if (!placingMode.value && data?.event && container) {
|
|
|
- const sc2 = scaledScreenCoord(data.event, container);
|
|
|
- const hit = viewer.pickObject(sc2);
|
|
|
- if (hit?.object?.userData?.markerId) {
|
|
|
- showMarkerInfo(hit.object.userData.markerId as string);
|
|
|
+ if (!placingMode.value && sc) {
|
|
|
+ const markerId = findClickedMarkerId(sc);
|
|
|
+ if (markerId) {
|
|
|
+ showMarkerInfo(markerId);
|
|
|
+ forceViewerRender();
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
@@ -595,44 +733,34 @@
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
- viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
+ forceViewerRender();
|
|
|
});
|
|
|
|
|
|
const container = document.getElementById(containerId);
|
|
|
if (container) {
|
|
|
+ let lastMoveEvent: MouseEvent | null = null;
|
|
|
container.addEventListener('mousemove', (e: MouseEvent) => {
|
|
|
if (!viewer) return;
|
|
|
- const sc = scaledScreenCoord(e, container);
|
|
|
- if (floatingDot && placingMode.value) {
|
|
|
- floatingDot.style.left = sc.x + 'px';
|
|
|
- floatingDot.style.top = sc.y + 'px';
|
|
|
- }
|
|
|
- const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
|
|
|
- if (wp) {
|
|
|
- currentCoord.x = wp.x;
|
|
|
- currentCoord.y = wp.y;
|
|
|
- currentCoord.z = wp.z;
|
|
|
- }
|
|
|
+ lastMoveEvent = e;
|
|
|
+ if (mouseMoveRaf) return;
|
|
|
+ mouseMoveRaf = requestAnimationFrame(() => {
|
|
|
+ mouseMoveRaf = 0;
|
|
|
+ if (!lastMoveEvent || !viewer) return;
|
|
|
+ const sc = updateCurrentCoord(lastMoveEvent, container);
|
|
|
+ if (sc && floatingDot && placingMode.value) {
|
|
|
+ floatingDot.style.left = sc.x + 'px';
|
|
|
+ floatingDot.style.top = sc.y + 'px';
|
|
|
+ }
|
|
|
+ });
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- // 滚轮缩放 / 平移时,信息悬浮框跟随布点
|
|
|
- viewer.addEventListener(ViewerEvent.CameraChange, () => {
|
|
|
- updateInfoPanelPosition();
|
|
|
- });
|
|
|
- viewer.addEventListener(ViewerEvent.AfterRender, () => {
|
|
|
- if (infoPanel && activeInfoMarkerId) updateInfoPanelPosition();
|
|
|
- });
|
|
|
+ scheduleRestoreMarkers();
|
|
|
} catch (err) {
|
|
|
console.error('[DWG] 初始化失败:', err);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- /**
|
|
|
- * 监听设备列表变化
|
|
|
- * 当外部传入的设备数据更新时,同步布点状态到列表(保证按钮状态正确)
|
|
|
- */
|
|
|
watch(
|
|
|
deviceList,
|
|
|
() => {
|
|
|
@@ -642,31 +770,25 @@
|
|
|
{ deep: true }
|
|
|
);
|
|
|
|
|
|
- /** 键盘事件处理:Esc键退出布点模式 */
|
|
|
- const onKeydown = (e: KeyboardEvent) => {
|
|
|
- if (e.key === 'Escape' && placingMode.value) exitPlacingMode();
|
|
|
- };
|
|
|
- onMounted(() => window.addEventListener('keydown', onKeydown));
|
|
|
-
|
|
|
- /**
|
|
|
- * 组件卸载时清理资源
|
|
|
- * 移除事件监听、销毁Viewer实例、释放所有THREE.js资源
|
|
|
- */
|
|
|
onUnmounted(() => {
|
|
|
window.removeEventListener('keydown', onKeydown);
|
|
|
+ if (mouseMoveRaf) cancelAnimationFrame(mouseMoveRaf);
|
|
|
+ unbindInfoPanelFollow();
|
|
|
hideInfoPanel();
|
|
|
floatingDot?.remove();
|
|
|
floatingDot = null;
|
|
|
if (viewer) {
|
|
|
markersMap.forEach((m) => {
|
|
|
viewer!.scene.remove(m.mesh);
|
|
|
- m.mesh.geometry.dispose();
|
|
|
(m.mesh.material as THREE.Material).dispose();
|
|
|
viewer!.scene.remove(m.iconSprite);
|
|
|
- (m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
|
|
|
m.iconSprite.material.dispose();
|
|
|
});
|
|
|
markersMap.clear();
|
|
|
+ sharedCircleGeom?.dispose();
|
|
|
+ sharedCircleGeom = null;
|
|
|
+ markerIconTexture?.dispose();
|
|
|
+ markerIconTexture = null;
|
|
|
viewer.destroy?.();
|
|
|
viewer = null;
|
|
|
}
|
|
|
@@ -674,7 +796,6 @@
|
|
|
</script>
|
|
|
|
|
|
<style lang="less" scoped>
|
|
|
- /* 最外层容器:占满父级、隐藏溢出 */
|
|
|
.content {
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
@@ -682,20 +803,13 @@
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
- /* 视图区域:canvas 的父容器 */
|
|
|
- .viewer-area {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- position: relative;
|
|
|
- }
|
|
|
- /* 图纸渲染 canvas 容器 */
|
|
|
+ .viewer-area,
|
|
|
.viewer-canvas {
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
position: relative;
|
|
|
}
|
|
|
|
|
|
- /* 底部坐标栏:显示当前鼠标在世界坐标中的位置 */
|
|
|
.coord-bar {
|
|
|
position: absolute;
|
|
|
bottom: 8px;
|
|
|
@@ -708,17 +822,16 @@
|
|
|
font-size: 13px;
|
|
|
font-family: Consolas, Monaco, monospace;
|
|
|
z-index: 200;
|
|
|
- pointer-events: none; /* 不阻挡鼠标事件,让事件穿透到 canvas */
|
|
|
+ pointer-events: none;
|
|
|
white-space: nowrap;
|
|
|
> span {
|
|
|
margin: 0 4px;
|
|
|
}
|
|
|
.placing-hint {
|
|
|
- color: #faad14; /* 放置模式下的提示文字颜色 */
|
|
|
+ color: #faad14;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /* 右侧设备列表面板(毛玻璃效果) */
|
|
|
.device-panel {
|
|
|
position: absolute;
|
|
|
top: 8px;
|
|
|
@@ -732,11 +845,9 @@
|
|
|
backdrop-filter: blur(14px) saturate(1.35);
|
|
|
-webkit-backdrop-filter: blur(14px) saturate(1.35);
|
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
|
|
- /* 折叠状态:宽度自适应 */
|
|
|
&.collapsed {
|
|
|
width: auto;
|
|
|
}
|
|
|
- /* 面板头部:标题 + 展开/收起按钮 */
|
|
|
.panel-header {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
@@ -755,7 +866,6 @@
|
|
|
color: rgba(255, 255, 255, 0.5);
|
|
|
}
|
|
|
}
|
|
|
- /* 面板内容区:设备表格 */
|
|
|
.panel-body {
|
|
|
padding: 6px;
|
|
|
background: transparent;
|