import { onMounted, ref, computed, watch, markRaw, inject } from "vue"; import { getDashboardDevice } from "@/apis/dashboard"; import useSearchParams from "@/hooks/useSearchParam"; import useSystemHeatmap from "./useSystemHeatmap"; export default function useSystemStatusByBaja(updateHeatBarIsShow) { const rawData = ref([]); const forgeViewer = ref(null); const urn = ref(""); const lightColorMap = { 1: "#009100", 2: "#c9bf00", 4: "#ff1212", }; const { searchParams } = useSearchParams(); // DataVisualization 擴充套件的全域變數 let dataVizExtn = null; let spriteAnimations = new Map(); // 用 Map 來追蹤多個動畫 {dbId: {interval, arrow, viewable}} let cameraEventAdded = false; // 追蹤是否已經添加了相機事件監聽器 const initialData = ref(null); const updateInitialData = (data = false) => { initialData.value = data; }; const { updateHeatMapData, updateTemp, initHeatMap } = useSystemHeatmap(updateHeatBarIsShow); const updateForgeViewer = (viewer) => { if (!viewer) { forgeViewer.value = null; return; } forgeViewer.value = markRaw(viewer); initHeatMap(viewer); }; const getSubPoint = (normal, close, error, sub_points = []) => { let points = { ...Object.fromEntries(sub_points.map((p) => [p, ""])), }; if (normal) points[normal] = ""; if (close) points[close] = ""; if (error) points[error] = ""; return points; }; const subscribeData = ref({}); watch(rawData, () => { let sub_data = {}; rawData.value.forEach((d) => { sub_data = { ...sub_data, ...Object.fromEntries( (d.device || []).map((dev) => [ dev.device_number, { ...dev, labelText: d.labelText, show_value: d.labelText, device_normal_point_name: d.device_normal_point_name, device_close_point_name: d.device_close_point_name, device_error_point_name: d.device_error_point_name, device_normal_point_value: d.device_normal_point_value, device_close_point_value: d.device_close_point_value, device_error_point_value: d.device_error_point_value, device_normal_color: d.device_normal_color, device_close_color: d.device_close_color, device_error_color: d.device_error_color, forge_dbid: parseInt(dev.forge_dbid), device_coordinate_3d: dev.device_coordinate_3d ? JSON.parse(dev.device_coordinate_3d) : { x: 0, y: 0 }, points: getSubPoint( d.device_normal_point_name, d.device_close_point_name, d.device_error_point_name, d.points || [] ), subSys: d.subSys, is_show: true, currentColor: d.device_normal_color, }, ]) ), }; }); subscribeData.value = sub_data; updateHeatMapData(sub_data); updateSubscribeDataFromBaja(sub_data); }); const visibleDbid = computed(() => { let visible = []; rawData.value.forEach((d) => { visible = [ ...visible, ...d.device.map((dev) => parseInt(dev.forge_dbid)), ]; }); // return visible; return [ 26, 32, 38, 43, 48, 53, 58, 63, 70, 76, 879, 1068, 1011, 1065, 855, 1109, 867, 963,1044,966,1139,936,1136,957,1133,954,610,1130,951,1041,939, 1145,987,999,1148,1002,1151,981, 813,1088,825,1091,804 ]; }); const getDevice = async (option = 1) => { const res = await getDashboardDevice({ option: parseInt(option), }); rawData.value = res.data.map((d) => ({ ...d, key: d.subSys, })); }; // subscribe from baja const booleanPointFacets = ref({}); const updateFacets = (point, facets) => { booleanPointFacets.value = { ...booleanPointFacets, [point]: facets, }; }; const updateDeviceData = (device_number, point, value) => { // 檢查 subscribeData.value[device_number] 是否存在 if (!subscribeData.value[device_number]) { console.log(`Device ${device_number} is not initialized.`); return; } const correspondPoint = initialData.value.points.find( ({ name }) => name === point ); // console.log("sub 回傳值 ", typeof value) const text = correspondPoint ? correspondPoint.values.find( ({ value: pValue }) => pValue === parseInt(value) )?.text || "" : value; // console.log("baja update data sub", correspondPoint, device_number, point, value); subscribeData.value[device_number].points[point] = text; if ( point.toLowerCase() === "temp" && parseInt(searchParams.value.option) > 1 ) { updateTemp(device_number, value); } // 當點位是 "light",且目前顏色不是錯誤顏色時,從 lightColorMap 取顏色 if ( point.toLowerCase() === "light" && subscribeData.value[device_number].currentColor !== subscribeData.value[device_number].device_error_color ) { subscribeData.value[device_number].currentColor = lightColorMap[Number(value)] || subscribeData.value[device_number].device_normal_color; } // 當點位是錯誤點時,判斷值是否等於錯誤值,決定是否使用錯誤顏色 if (point === subscribeData.value[device_number].device_error_point_name) { subscribeData.value[device_number].currentColor = value === subscribeData.value[device_number].device_error_point_value ? subscribeData.value[device_number].device_error_color : subscribeData.value[device_number].device_normal_color; } updateLabelText(device_number, point, text); }; const transformDeviceNumber = (device_number) => { const transformed = device_number.replaceAll("_", "/"); // 找到最後一個 / 的位置,並截取到該位置之前 const lastSlashIndex = transformed.lastIndexOf("/"); return lastSlashIndex !== -1 ? transformed.substring(0, lastSlashIndex) : transformed; }; const updateLabelText = (key, point, value) => { let text = subscribeData.value[key].labelText.replace(`%${point}`, value); Object.keys(subscribeData.value[key].points) .filter((p) => p !== point) .forEach((p) => { text = text.replace(`%${p}`, subscribeData.value[key].points[p]); }); subscribeData.value[key].show_value = text; }; const subComponents = ref(null); const updateSubscribeDataFromBaja = (data) => { for (let [key, value] of Object.entries(data)) { window.require && window.requirejs(["baja!"], (baja) => { console.log("進入 bajaSubscriber 準備執行BQL訂閱"); const ordKey = key; const ordPath = transformDeviceNumber(key); console.log("ordPath", ordPath, "ordKey", ordKey, value); const fullOrdPath = `local:|foxs:|station:|slot:/Drivers/NiagaraNetwork/PCCV/points/${ordPath}/${ordKey}`; // 完整路徑 console.log("嘗試訪問路徑:", fullOrdPath); // 打印完整路徑 baja.Ord.make(fullOrdPath) .get() .then((folder) => { console.log("成功獲取 folder:", folder); const batch = new baja.comm.Batch(); const sub = new baja.Subscriber(); sub.attach({ changed: function (prop, cx) { console.log("數據變更觸發:", prop.$getDisplayName()); if (prop.$getDisplayName() !== "Out") return; if ( Object.hasOwn( booleanPointFacets.value, prop.$complex.$propInParent.$slotName ) ) { const facets = booleanPointFacets.value[ prop.$complex.$propInParent.$slotName ]; for (let [facetKey, facetValue] of Object.entries(facets)) { if (facetValue === prop.$getValue().getValueDisplay()) { updateDeviceData( key, prop.$complex.$propInParent.$slotName, facetKey ); } } } else { updateDeviceData( key, prop.$complex.$propInParent.$slotName, prop.$getValue().getValueDisplay() ); } }, }); console.log("開始遍歷控制點"); folder .getSlots() .is("control:ControlPoint") .eachValue((point) => { console.log("找到控制點:", point.getDisplayName()); console.log("配置的點位:", Object.keys(value.points)); if ( Object.keys(value.points).includes(point.getDisplayName()) ) { console.log( "匹配到點位,開始訂閱:", point.getDisplayName() ); baja.Ord.make( `local:|foxs:|station:|slot:/Drivers/NiagaraNetwork/PCCV/points/${ordPath}/${ordKey}/${point.getDisplayName()}` ) .get() .then((component) => { console.log("獲取到 component:", component); if ( point.getType().getTypeSpec() === "control:BooleanWritable" ) { const facets = component.getFacets1().toObject(); updateFacets(point.getDisplayName(), facets); for (let [facetKey, facetValue] of Object.entries( facets )) { if ( facetValue === component.getOut().getValue().toString() ) { updateDeviceData( key, point.getDisplayName(), facetKey ); } } } else { updateDeviceData( key, point.getDisplayName(), component.getOut().getValue() ); } sub .subscribe({ comps: component, // Can also just be an singular Component instance batch, // if defined, any network calls will be batched into this object (optional) }) .then(() => { console.log("subscribed successfully"); subComponents.value = sub; }) .catch(function (err) { baja.error( "some components failed to subscribe: " + err ); }); }); } }); }); }); } }; const updateDbidPosition = (viewer, data) => { if (!viewer) return; if (!forgeViewer.value) forgeViewer.value = markRaw(viewer); const tree = viewer.model.getData().instanceTree; const fragList = viewer.model.getFragmentList(); for (let [key, value] of Object.entries(data)) { const nodebBox = new window.THREE.Box3(); // for each fragId on the list, get the bounding box tree.enumNodeFragments( value.forge_dbid, (fragId) => { const fragbBox = new window.THREE.Box3(); fragList.getWorldBounds(fragId, fragbBox); nodebBox.union(fragbBox); // create a unifed bounding box }, true ); subscribeData.value[key].device_coordinate_3d = viewer.worldToClient( nodebBox.getCenter() ); subscribeData.value[key].is_show = viewer.isNodeVisible(value.forge_dbid); } }; const fitToView = () => { const { x, y, z } = JSON.parse(searchParams.value.camera_position); const newPosition = new THREE.Vector3(x, y, z); //!<<< 相机的新位置 const { x: x1, y: y1, z: z1, } = JSON.parse(searchParams.value.target_position); //!<<< 计算新焦点位置 const newTarget = new THREE.Vector3(x1, y1, z1); //!<<< 焦點的新位置 forgeViewer.value.navigation.getCamera().setView({ position: newPosition.clone(), target: newTarget.clone(), }); setTimeout(() => { updateDbidPosition(forgeViewer.value, subscribeData.value); }, 700); }; const hideAllObjects = (instanceTree, filDbids = []) => { if (!forgeViewer.value || !instanceTree) return; const allDbIds = Object.keys(instanceTree.nodeAccess.dbIdToIndex).map( Number ); forgeViewer.value.hide(allDbIds); if (filDbids.length > 0) { forgeViewer.value.show(filDbids); } fitToView(); forgeViewer.value.impl.invalidate(true); }; const loadModel = (viewer, filePath) => { return new Promise(function (resolve, reject) { // 使用 loadModel 直接加載本地 .svf 文件 viewer.loadModel( filePath, {}, (model) => { viewer.setGroundShadow(false); viewer.setLightPreset(18); viewer.impl.renderer().setClearAlpha(0); // 清除 alpha 通道 viewer.impl.glrenderer().setClearColor(0xffffff, 0); // 設置透明背景,顏色碼無關緊要 viewer.impl.invalidate(true); // 觸發渲染 resolve(model); }, (error) => { console.error("模型加載失敗: ", error); reject(error); } ); }); }; const reloadModal = () => {}; let themingInterval = null; let blinkingInterval = null; // 計時器 ID 的引用 // 增強版:停止閃爍並徹底清理 const stopBlinking = () => { console.log("正在停止基於 Selection 的閃爍效果..."); if (blinkingInterval) { clearInterval(blinkingInterval); blinkingInterval = null; } if (forgeViewer.value) { // 核心清理:取消所有選取 forgeViewer.value.clearSelection(); } }; const startBlinking = (dbidToBlink) => { console.log(`正在為 dbId: ${dbidToBlink} 啟動基於 Selection 的閃爍...`); if (!forgeViewer.value) return; // 步驟 1: 設定一次我們希望的閃爍顏色和樣式 const blinkColor = new THREE.Color(0x00aaff); // 水藍色 forgeViewer.value.setSelectionColor( blinkColor, Autodesk.Viewing.SelectionType.OVERLAYED ); let isSelected = false; // 用一個旗標來追蹤目前的選取狀態 blinkingInterval = setInterval(() => { // 在計時器內部也要檢查 viewer 是否還存在 if (!forgeViewer.value) { stopBlinking(); return; } if (isSelected) { // 如果已選取,就取消選取 (閃爍 OFF) forgeViewer.value.clearSelection(); } else { // 如果未選取,就選取目標物件 (閃爍 ON) // 注意:select 方法需要傳入 model 物件 forgeViewer.value.select( dbidToBlink, forgeViewer.value.model, Autodesk.Viewing.SelectionType.OVERLAYED ); } // 切換狀態,為下一次循環做準備 isSelected = !isSelected; }, 700); // 我們可以稍微調整一下頻率,例如 700 毫秒 }; watch([forgeViewer, visibleDbid], ([viewer, dbids]) => { console.log("監聽到 forgeViewer 或 visibleDbid 的變化", viewer, dbids); stopBlinking(); clearSprites(); // 清理之前的 Sprites if (viewer) { hideAllObjects(viewer.model.getData().instanceTree, dbids); } // 判斷新狀態是否需要閃爍和 Sprites if (viewer && dbids.includes(879)) { // startBlinking([879, 1068, 1011, 1065, 855, 1109, 867,963,1044,966,1139,936,1136,957,1133,954,610,1130,951,1041,939,1145,987,999,1148,1002,1151,981,813,1088,825,1091,804]); const spriteConfigs = [ { dbId: 879, reverse: false }, { dbId: 1011, reverse: false }, { dbId: 855, reverse: true }, { dbId: 867, reverse: true }, { dbId: 963, reverse: false }, { dbId: 966, reverse: true }, { dbId: 936, reverse: true }, { dbId: 957, reverse: true }, { dbId: 954, reverse: true }, { dbId: 951, reverse: true }, { dbId: 939, reverse: true }, { dbId: 987, reverse: true }, { dbId: 999, reverse: true }, { dbId: 1002, reverse: true }, { dbId: 981, reverse: true }, { dbId: 813, reverse: true }, { dbId: 825, reverse: false }, { dbId: 804, reverse: false }, ]; spriteConfigs.forEach(({ dbId, reverse }) => { createSprites(viewer, dbId, reverse); }); } }); watch(initialData, (newValue) => { if (newValue) { getDevice(searchParams.value.option); } }); watch( searchParams, (newValue) => { getDevice(newValue.option); }, { deep: true, } ); // 動態Sprites創建函數 const createSprites = async (viewer, dbId, reverse = false) => { try { // 先清理這個 dbId 的舊 Sprite(如果存在) clearSingleSprite(dbId); // 1. 載入 DataVisualization 擴充套件 if (!dataVizExtn) { dataVizExtn = await viewer.loadExtension("Autodesk.DataVisualization"); } // 2. 建立 ViewableStyle const DataVizCore = Autodesk.DataVisualization.Core; const viewableType = DataVizCore.ViewableType.SPRITE; const spriteColor = new THREE.Color(0x00ffff); const spriteIconUrl = "spot.svg"; const style = new DataVizCore.ViewableStyle( viewableType, spriteColor, spriteIconUrl ); const viewableData = new DataVizCore.ViewableData(); viewableData.spriteSize = 12; // 3. 取得 dbId 的 3D 位置 viewer.getObjectTree(function (instanceTree) { const fragList = viewer.model.getFragmentList(); let fragIds = []; instanceTree.enumNodeFragments(dbId, function (fragId) { fragIds.push(fragId); }); if (fragIds.length > 0) { let box = new THREE.Box3(); fragIds.forEach((fragId) => { let fragBox = new THREE.Box3(); fragList.getWorldBounds(fragId, fragBox); box.union(fragBox); }); // 算出主軸方向 const size = new THREE.Vector3(); box.getSize(size); // 找出最長的軸 let axis = "x"; if (size.y >= size.x && size.y >= size.z) axis = "y"; if (size.z >= size.x && size.z >= size.y) axis = "z"; // 取得所有 fragment 的中心點 let centers = fragIds.map((fragId) => { let fragBox = new THREE.Box3(); fragList.getWorldBounds(fragId, fragBox); return fragBox.getCenter(new THREE.Vector3()); }); // 找出距離最遠的兩點作為動畫路徑 let maxDist = 0; let animStart = centers[0], animEnd = centers[0]; for (let i = 0; i < centers.length; i++) { for (let j = i + 1; j < centers.length; j++) { let dist = centers[i].distanceTo(centers[j]); if (dist > maxDist) { maxDist = dist; animStart = centers[i]; animEnd = centers[j]; } } } // fallback: 若只有一個 fragment 或所有中心點重疊,改用主軸方向的端點 if (centers.length < 2 || maxDist === 0) { const boxCenter = box.getCenter(new THREE.Vector3()); const halfSize = size.clone().multiplyScalar(0.5); animStart = boxCenter.clone(); animEnd = boxCenter.clone(); // 根據主軸設定起點和終點 if (axis === "x") { animStart.x -= halfSize.x; animEnd.x += halfSize.x; } else if (axis === "y") { animStart.y -= halfSize.y; animEnd.y += halfSize.y; } else { // z軸 animStart.z -= halfSize.z; animEnd.z += halfSize.z; } } // === 依主軸方向排序,確保動畫方向一致 === // 根據主軸來排序起點和終點,確保動畫方向一致 if (axis === "x" && animStart.x > animEnd.x) { const tmp = animStart; animStart = animEnd; animEnd = tmp; } if (axis === "y" && animStart.y > animEnd.y) { const tmp = animStart; animStart = animEnd; animEnd = tmp; } if (axis === "z" && animStart.z > animEnd.z) { const tmp = animStart; animStart = animEnd; animEnd = tmp; } // === 若 reverse 為 true,則反向 === if (reverse) { const tmp = animStart; animStart = animEnd; animEnd = tmp; } console.log(`dbId ${dbId} 動畫資訊:`, { axis: axis, size: size, animStart: animStart, animEnd: animEnd, distance: animStart.distanceTo(animEnd), reverse: reverse, }); // 4. 建立 SpriteViewable,初始在 animStart const viewable = new DataVizCore.SpriteViewable( animStart.clone(), style, dbId ); viewableData.addViewable(viewable); viewableData.finish().then(() => { dataVizExtn.addViewables(viewableData); // 5. 單向從起點移動到終點,然後重頭再來 // === 新增 HTML箭頭動畫,會跟隨Sprite移動並指向終點 === let arrow = document.createElement("img"); arrow.src = "arrow.png"; arrow.style.position = "absolute"; arrow.style.width = "22px"; arrow.style.height = "22px"; arrow.style.pointerEvents = "none"; arrow.style.zIndex = 10; arrow.className = `custom-3d-arrow-${dbId}`; // 加上 dbId 作為唯一識別 viewer.container.appendChild(arrow); function updateArrow(pos) { const pos2d = viewer.worldToClient(pos.clone()); const end2d = viewer.worldToClient(animEnd.clone()); arrow.style.left = `${pos2d.x - 16}px`; arrow.style.top = `${pos2d.y - 16}px`; // 算角度:pos 指向 animEnd const dx = end2d.x - pos2d.x; const dy = end2d.y - pos2d.y; const angle = (Math.atan2(dy, dx) * 180) / Math.PI; arrow.style.transform = `rotate(${angle}deg)`; } // camera 移動時也要更新所有箭頭(只添加一次事件監聽器) if (!cameraEventAdded) { viewer.addEventListener( Autodesk.Viewing.CAMERA_CHANGE_EVENT, () => { spriteAnimations.forEach((animation) => { animation.updateArrow(animation.currentPos); }); } ); cameraEventAdded = true; } // 動畫主程式 let t = 0; let currentPos = animStart.clone(); const animationInterval = setInterval(() => { t += 0.01; if (t > 1) t = 0; // 到終點就重頭 currentPos = animStart.clone().lerp(animEnd, t); dataVizExtn.invalidateViewables([dbId], (v) => { return { position: { x: currentPos.x, y: currentPos.y, z: currentPos.z, }, }; }); updateArrow(currentPos); // 更新保存的 currentPos if (spriteAnimations.has(dbId)) { spriteAnimations.get(dbId).currentPos = currentPos; } }, 30); // 保存這個 dbId 的動畫資訊 spriteAnimations.set(dbId, { interval: animationInterval, arrow: arrow, viewable: viewable, updateArrow: updateArrow, currentPos: currentPos, }); }); } }); } catch (error) { console.error("創建動態Sprites時發生錯誤:", error); } }; // 清理單個 Sprite 的函數 const clearSingleSprite = (dbId) => { if (spriteAnimations.has(dbId)) { const animation = spriteAnimations.get(dbId); // 清理動畫計時器 if (animation.interval) { clearInterval(animation.interval); } // 清理箭頭元素 if (animation.arrow && animation.arrow.parentNode) { animation.arrow.parentNode.removeChild(animation.arrow); } // 移除這個 dbId 的記錄 spriteAnimations.delete(dbId); console.log(`已清理 dbId ${dbId} 的 Sprite 資源`); } }; // 清理所有 Sprites 和相關資源的函數 const clearSprites = () => { // 清理所有動畫 spriteAnimations.forEach((animation, dbId) => { clearSingleSprite(dbId); }); // 清理所有 viewables if (dataVizExtn) { dataVizExtn.removeAllViewables(); } // 重置相機事件標記 cameraEventAdded = false; console.log("已清理所有 Sprites 和相關資源"); }; return { subscribeData, visibleDbid, updateDbidPosition, hideAllObjects, updateForgeViewer, forgeViewer, loadModel, urn, updateInitialData, subComponents, createSprites, clearSprites, clearSingleSprite, }; }