fix: 修正文字顏色錯誤套用瀏覽器 darkMode 問題
This commit is contained in:
parent
c4d78bd5b6
commit
cb74fdb0f2
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@ -1,4 +1,3 @@
|
|||||||
<!-- src/App.vue -->
|
|
||||||
<template>
|
<template>
|
||||||
<section id="app" class="flex flex-col min-h-screen">
|
<section id="app" class="flex flex-col min-h-screen">
|
||||||
<NavBar />
|
<NavBar />
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
export const GET_FORGETOKEN_API = `/api/forge/oauth/token`;
|
|
||||||
export const GET_FORGEURN_API = `/api/Device/GetBuild`
|
|
@ -1,33 +0,0 @@
|
|||||||
// import instance from "@/util/request";
|
|
||||||
import { GET_FORGETOKEN_API, GET_FORGEURN_API } from "./apis";
|
|
||||||
// import apihandler from "@/util/apihandler";
|
|
||||||
|
|
||||||
// 取得 URN 清單(維持你的行為)
|
|
||||||
export async function getUrn() {
|
|
||||||
const res = await instance.post(GET_FORGEURN_API);
|
|
||||||
return apihandler(res.code, res.data, { msg: res.msg, code: res.code });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取得 Token(Promise 版)
|
|
||||||
export async function fetchForgeToken() {
|
|
||||||
const resp = await instance.get(GET_FORGETOKEN_API);
|
|
||||||
// 兼容兩種常見回傳格式:
|
|
||||||
// A) { dictionary: { access_token, expires_in } }
|
|
||||||
// B) { access_token, expires_in }
|
|
||||||
const dict = resp?.dictionary ?? resp;
|
|
||||||
if (!dict?.access_token || !dict?.expires_in) {
|
|
||||||
throw new Error("Invalid token response shape");
|
|
||||||
}
|
|
||||||
return { access_token: dict.access_token, expires_in: dict.expires_in };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容舊的 callback 界面(給 Viewer 的 getAccessToken 用)
|
|
||||||
export async function getAccessToken(callback) {
|
|
||||||
try {
|
|
||||||
const { access_token, expires_in } = await fetchForgeToken();
|
|
||||||
callback(access_token, expires_in);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert("Could not obtain access token. See the console for more details.");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { fetchForgeToken } from "./index";
|
|
||||||
|
|
||||||
let cached = null;
|
|
||||||
|
|
||||||
export async function getCachedToken() {
|
|
||||||
const now = Date.now();
|
|
||||||
if (cached && now < cached.expireAt) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
const { access_token, expires_in } = await fetchForgeToken();
|
|
||||||
// 提前 30 秒更新,避免臨界點過期
|
|
||||||
cached = {
|
|
||||||
access_token,
|
|
||||||
expires_in,
|
|
||||||
expireAt: now + (expires_in - 30) * 1000,
|
|
||||||
};
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供給 Viewer 的介面
|
|
||||||
export async function viewerGetAccessToken(onTokenReady) {
|
|
||||||
const { access_token, expires_in } = await getCachedToken();
|
|
||||||
onTokenReady(access_token, expires_in);
|
|
||||||
}
|
|
@ -1,10 +1,17 @@
|
|||||||
<!-- src/components/forge/Forge.vue -->
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import useForgeSprite from "@/hooks/forge/useForgeSprite";
|
import useForgeSprite from "@/hooks/forge/useForgeSprite";
|
||||||
|
|
||||||
|
// ====== 基本變數 / 注入 ======
|
||||||
|
const route = useRoute();
|
||||||
|
const forgeDom = ref(null);
|
||||||
|
let viewer = null;
|
||||||
|
const viewerReady = ref(false);
|
||||||
|
const activeFloor = ref("9F"); // 預設 9F
|
||||||
|
|
||||||
let unbindSpriteClick = null;
|
let unbindSpriteClick = null;
|
||||||
|
let THREE = null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
updateDataVisualization: dvInit,
|
updateDataVisualization: dvInit,
|
||||||
@ -15,9 +22,13 @@ const {
|
|||||||
__staticDevices, // 用來塞模擬資料
|
__staticDevices, // 用來塞模擬資料
|
||||||
} = useForgeSprite();
|
} = useForgeSprite();
|
||||||
|
|
||||||
let THREE = null;
|
// ====== 常數(樓層 / 顏色 / 模型路徑) ======
|
||||||
|
const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
|
||||||
|
const BRAND_GREEN = "#34D5C8";
|
||||||
|
const BRAND_RED = "#FF8678";
|
||||||
|
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
|
||||||
|
|
||||||
// 取得 Viewer 內建 THREE(避免版本衝突)
|
// ====== THREE 入口(避免版本衝突) ======
|
||||||
function getThree() {
|
function getThree() {
|
||||||
if (THREE) return THREE;
|
if (THREE) return THREE;
|
||||||
THREE =
|
THREE =
|
||||||
@ -32,23 +43,7 @@ function getThree() {
|
|||||||
return THREE;
|
return THREE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== 寫死樓層 dbId(你提供的 Log) ======
|
// ====== Resident Modal 狀態 ======
|
||||||
const FLOOR_DBIDS = { "9F": 14973, "8F": 14262 };
|
|
||||||
|
|
||||||
// ====== 顏色 / Sprite數量(可調) ======
|
|
||||||
const BRAND_GREEN = "#34D5C8";
|
|
||||||
const BRAND_RED = "#FF8678";
|
|
||||||
|
|
||||||
// (可調)本地 SVF 路徑(相對於 public)
|
|
||||||
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const forgeDom = ref(null);
|
|
||||||
let viewer = null;
|
|
||||||
const viewerReady = ref(false);
|
|
||||||
const activeFloor = ref("9F"); // 預設 9F
|
|
||||||
|
|
||||||
// ---- Resident Modal 狀態 ----
|
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const modalData = ref(null);
|
const modalData = ref(null);
|
||||||
|
|
||||||
@ -69,7 +64,7 @@ function buildResidentModalData(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openResidentModal(L, e) {
|
function openResidentModal(L, e) {
|
||||||
e?.stopPropagation?.(); // 避免事件冒泡
|
e?.stopPropagation?.();
|
||||||
bringToFrontById(L.id);
|
bringToFrontById(L.id);
|
||||||
modalData.value = buildResidentModalData(L.data);
|
modalData.value = buildResidentModalData(L.data);
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
@ -79,7 +74,7 @@ function closeResidentModal() {
|
|||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 嘗試從 ?floor=8F|9F 或 localStorage 還原
|
// ====== 初始化樓層還原 ======
|
||||||
function resolveInitialFloor() {
|
function resolveInitialFloor() {
|
||||||
const q = (route.query?.floor || "").toString().toUpperCase();
|
const q = (route.query?.floor || "").toString().toUpperCase();
|
||||||
if (q === "8F" || q === "9F") return q;
|
if (q === "8F" || q === "9F") return q;
|
||||||
@ -88,7 +83,7 @@ function resolveInitialFloor() {
|
|||||||
return "9F";
|
return "9F";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化 Forge Viewer
|
// ====== Forge Viewer 初始化 / 載入 ======
|
||||||
function initViewer(container) {
|
function initViewer(container) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
|
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
|
||||||
@ -108,14 +103,12 @@ function initViewer(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 載入模型
|
|
||||||
function loadModel(filePath) {
|
function loadModel(filePath) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
viewer.loadModel(filePath, {}, (model) => resolve(model), reject);
|
viewer.loadModel(filePath, {}, (model) => resolve(model), reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隱藏 ViewCube + Home(右上角)
|
|
||||||
function hideViewCubeAndHome() {
|
function hideViewCubeAndHome() {
|
||||||
const tryHide = () => {
|
const tryHide = () => {
|
||||||
const ext = viewer?.getExtension?.("Autodesk.ViewCubeUi");
|
const ext = viewer?.getExtension?.("Autodesk.ViewCubeUi");
|
||||||
@ -127,7 +120,6 @@ function hideViewCubeAndHome() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待物件樹就緒
|
|
||||||
function waitObjectTree(model) {
|
function waitObjectTree(model) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (model.getData().instanceTree) return resolve();
|
if (model.getData().instanceTree) return resolve();
|
||||||
@ -142,7 +134,7 @@ function waitObjectTree(model) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取得某節點(樓層)的世界包圍盒
|
// ====== 幾何工具 ======
|
||||||
function getNodeWorldBounds(model, dbId) {
|
function getNodeWorldBounds(model, dbId) {
|
||||||
const THREE = getThree();
|
const THREE = getThree();
|
||||||
const tree = model.getInstanceTree?.();
|
const tree = model.getInstanceTree?.();
|
||||||
@ -161,7 +153,6 @@ function getNodeWorldBounds(model, dbId) {
|
|||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把 [0~1] 比例座標轉成實際 3D 位置(固定佈局用)
|
|
||||||
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
|
function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
|
||||||
const THREE = getThree();
|
const THREE = getThree();
|
||||||
const { min, max } = box;
|
const { min, max } = box;
|
||||||
@ -175,7 +166,6 @@ function positionsFromRatios(box, ratios, zLiftRatio = 0.05) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 每層的「不工整」固定佈局(相對比例)
|
// 每層的「不工整」固定佈局(相對比例)
|
||||||
// 9F: 6 顆(刻意拉開間距與方向),8F: 2 顆
|
|
||||||
const LAYOUT_RATIOS = {
|
const LAYOUT_RATIOS = {
|
||||||
"9F": [
|
"9F": [
|
||||||
[0.18, 0.26],
|
[0.18, 0.26],
|
||||||
@ -191,11 +181,75 @@ const LAYOUT_RATIOS = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 顯示指定樓層(只 isolate + fit)
|
// ====== 床號序列(避開數字 4) ======
|
||||||
|
function hasDigit4(n) {
|
||||||
|
return n.toString().includes("4");
|
||||||
|
}
|
||||||
|
function generateBedNumbers(count, start = 1001) {
|
||||||
|
const out = [];
|
||||||
|
let n = start;
|
||||||
|
while (out.length < count) {
|
||||||
|
if (!hasDigit4(n)) out.push(n);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const BED_SERIES_ALL = generateBedNumbers(8, 1001);
|
||||||
|
const BED_SERIES_BY_FLOOR = {
|
||||||
|
"9F": BED_SERIES_ALL.slice(0, 6),
|
||||||
|
"8F": BED_SERIES_ALL.slice(6, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ====== 假資料 / 權重工具 ======
|
||||||
|
function seededRand(seed) {
|
||||||
|
const s = Math.sin(seed * 9301 + 49297) * 233280;
|
||||||
|
return s - Math.floor(s);
|
||||||
|
}
|
||||||
|
function pick(arr, seed) {
|
||||||
|
return arr[Math.floor(seededRand(seed) * arr.length)];
|
||||||
|
}
|
||||||
|
function ageFromSeed(seed) {
|
||||||
|
return 65 + Math.floor(seededRand(seed + 7) * 31);
|
||||||
|
} // 65~95
|
||||||
|
function startDateFromSeed(seed) {
|
||||||
|
const now = new Date();
|
||||||
|
const daysAgo = 30 + Math.floor(seededRand(seed + 13) * 365); // 1~12 個月內
|
||||||
|
const d = new Date(now.getTime() - daysAgo * 86400000);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}/${mm}/${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_POOL = [
|
||||||
|
"王雅雯",
|
||||||
|
"陳志明",
|
||||||
|
"林冠廷",
|
||||||
|
"張淑芬",
|
||||||
|
"黃子涵",
|
||||||
|
"李建宏",
|
||||||
|
"吳佩珊",
|
||||||
|
"周育賢",
|
||||||
|
"趙雅筑",
|
||||||
|
"許庭瑋",
|
||||||
|
"郭怡君",
|
||||||
|
"洪嘉文",
|
||||||
|
];
|
||||||
|
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
|
||||||
|
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
|
||||||
|
const SPECIAL_POOL = ["-"];
|
||||||
|
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
|
||||||
|
const SEX_POOL = ["男", "女"];
|
||||||
|
|
||||||
|
function pickAreaLetter(seed) {
|
||||||
|
const r = seededRand(seed);
|
||||||
|
if (r < 0.4) return "A";
|
||||||
|
return "B";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 樓層顯示 / Sprites 建立 ======
|
||||||
async function showLevel(levelKey) {
|
async function showLevel(levelKey) {
|
||||||
const levelDbId = FLOOR_DBIDS[levelKey];
|
const levelDbId = FLOOR_DBIDS[levelKey];
|
||||||
if (!viewer || !viewer.model || !levelDbId) return;
|
if (!viewer || !viewer.model || !levelDbId) return;
|
||||||
// 取所有葉節點 → isolate
|
|
||||||
const ids = (() => {
|
const ids = (() => {
|
||||||
const tree = viewer.model.getInstanceTree();
|
const tree = viewer.model.getInstanceTree();
|
||||||
const leafs = [];
|
const leafs = [];
|
||||||
@ -212,16 +266,14 @@ async function showLevel(levelKey) {
|
|||||||
viewer.setGhosting(false);
|
viewer.setGhosting(false);
|
||||||
viewer.impl.invalidate(true);
|
viewer.impl.invalidate(true);
|
||||||
}
|
}
|
||||||
// 依指定樓層,重新產生該樓層 6 顆 sprites 並繪製
|
|
||||||
async function rebuildSpritesForFloor(floorKey) {
|
async function rebuildSpritesForFloor(floorKey) {
|
||||||
const box = getNodeWorldBounds(viewer.model, FLOOR_DBIDS[floorKey]);
|
const box = getNodeWorldBounds(viewer.model, FLOOR_DBIDS[floorKey]);
|
||||||
const is9F = floorKey === "9F";
|
const is9F = floorKey === "9F";
|
||||||
|
|
||||||
// 固定不工整的位置(由比例轉 3D)
|
|
||||||
const ratios = LAYOUT_RATIOS[floorKey] || [];
|
const ratios = LAYOUT_RATIOS[floorKey] || [];
|
||||||
const poses = positionsFromRatios(box, ratios);
|
const poses = positionsFromRatios(box, ratios);
|
||||||
|
|
||||||
// 依樓層取對應的「避 4」病床號序列
|
|
||||||
const bedNos =
|
const bedNos =
|
||||||
BED_SERIES_BY_FLOOR[floorKey] || generateBedNumbers(poses.length, 1001);
|
BED_SERIES_BY_FLOOR[floorKey] || generateBedNumbers(poses.length, 1001);
|
||||||
|
|
||||||
@ -251,7 +303,7 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
residentsAge: ageFromSeed(spriteDbId + 2),
|
residentsAge: ageFromSeed(spriteDbId + 2),
|
||||||
startTime: startDateFromSeed(spriteDbId + 3),
|
startTime: startDateFromSeed(spriteDbId + 3),
|
||||||
|
|
||||||
avatarUrl: Math.random() > 0.4 ? pick(AVATAR_POOL, spriteDbId + 4) : null,
|
avatarUrl: Math.random() > 0.4 ? pick(spriteDbId + 4) : null,
|
||||||
healthStatus: pick(HEALTH_POOL, spriteDbId + 5),
|
healthStatus: pick(HEALTH_POOL, spriteDbId + 5),
|
||||||
medicationStatus: pick(MEDICATION_POOL, spriteDbId + 6),
|
medicationStatus: pick(MEDICATION_POOL, spriteDbId + 6),
|
||||||
specialEvent:
|
specialEvent:
|
||||||
@ -262,7 +314,7 @@ async function rebuildSpritesForFloor(floorKey) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await createSprites(); // 清舊加新(不改 scale,維持 1.0)
|
await createSprites(); // 清舊加新
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== Popover(常駐在每顆 sprite 上方) ======
|
// ====== Popover(常駐在每顆 sprite 上方) ======
|
||||||
@ -280,7 +332,6 @@ function worldToScreen(pos3) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rebuildLabelsForCurrentFloor() {
|
function rebuildLabelsForCurrentFloor() {
|
||||||
// 依 __staticDevices 建立對應的 label
|
|
||||||
labels.value = __staticDevices.map((d) => {
|
labels.value = __staticDevices.map((d) => {
|
||||||
const { x, y } = worldToScreen(
|
const { x, y } = worldToScreen(
|
||||||
new (getThree().Vector3)(
|
new (getThree().Vector3)(
|
||||||
@ -330,7 +381,6 @@ function attachCameraEventsForLabels() {
|
|||||||
|
|
||||||
function rebuildLabelsAfterNextRender() {
|
function rebuildLabelsAfterNextRender() {
|
||||||
const once = () => {
|
const once = () => {
|
||||||
// 渲染完成後,用最新相機矩陣重建並立刻再校正一次
|
|
||||||
rebuildLabelsForCurrentFloor();
|
rebuildLabelsForCurrentFloor();
|
||||||
updateLabelPositions();
|
updateLabelPositions();
|
||||||
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||||
@ -338,7 +388,97 @@ function rebuildLabelsAfterNextRender() {
|
|||||||
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整合:套樓層(不再處理 sprites,sprites 交給 hook)
|
// ====== 分區切換(多選|預設不選=顯示全部) ======
|
||||||
|
const zones = ["VIP", "換管", "失能", "失智"];
|
||||||
|
const selectedZones = ref(new Set());
|
||||||
|
const toggleZone = (zone) => {
|
||||||
|
const set = new Set(selectedZones.value);
|
||||||
|
if (set.has(zone)) set.delete(zone);
|
||||||
|
else set.add(zone);
|
||||||
|
selectedZones.value = set;
|
||||||
|
};
|
||||||
|
const isZoneSelected = (zone) => selectedZones.value.has(zone);
|
||||||
|
|
||||||
|
// ====== 床位資訊選單 / 顯示控制 ======
|
||||||
|
const soloSpriteId = ref(null); // 單獨顯示:被點擊 sprite 的 id(null 表全域/多顯示模式)
|
||||||
|
const infoOptions = [
|
||||||
|
{ label: "無顯示", value: "none" },
|
||||||
|
{ label: "有住民", value: "occupied" },
|
||||||
|
{ label: "空床", value: "vacant" },
|
||||||
|
{ label: "住院中", value: "hospitalized" },
|
||||||
|
{ label: "請假中", value: "leave" }, // ← 新增
|
||||||
|
];
|
||||||
|
const infoOpen = ref(false);
|
||||||
|
const selectedInfo = ref(infoOptions[0].value); // 'none'
|
||||||
|
|
||||||
|
const filteredLabels = computed(() => {
|
||||||
|
// 單獨顯示模式
|
||||||
|
if (soloSpriteId.value != null) {
|
||||||
|
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
|
||||||
|
return [];
|
||||||
|
return labels.value.filter((L) => L.id === soloSpriteId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// none / leave = 全隱
|
||||||
|
if (selectedInfo.value === "none" || selectedInfo.value === "leave") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = selectedInfo.value; // 'occupied' | 'vacant' | 'hospitalized'
|
||||||
|
const selectedSet = selectedZones.value; // Set
|
||||||
|
|
||||||
|
return labels.value.filter((L) => {
|
||||||
|
// ===== 分區多選判斷 =====
|
||||||
|
const inZone =
|
||||||
|
selectedSet.size === 0
|
||||||
|
? true
|
||||||
|
: L.data?.zone
|
||||||
|
? selectedSet.has(L.data.zone)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// ===== 燈號狀態判斷 =====
|
||||||
|
const isRed = L.data.state === "offnormal";
|
||||||
|
const isHosp = isRed && !!L.data.special; // 住院中:紅 + special
|
||||||
|
const isGreen = !isRed && !L.data.special; // 有住民:綠
|
||||||
|
|
||||||
|
let stateOk = true;
|
||||||
|
if (info === "occupied") stateOk = isGreen;
|
||||||
|
if (info === "vacant") stateOk = isRed; // 空床 = 只要是紅色,都包含(含住院)
|
||||||
|
if (info === "hospitalized") stateOk = isHosp;
|
||||||
|
|
||||||
|
return inZone && stateOk;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVacantWithoutSpecial = (L) =>
|
||||||
|
L?.data?.state === "offnormal" && !L?.data?.special;
|
||||||
|
|
||||||
|
const emit = defineEmits(["change"]);
|
||||||
|
const selectInfo = (opt) => {
|
||||||
|
// 切換全域顯示 → 關閉單獨顯示
|
||||||
|
soloSpriteId.value = null;
|
||||||
|
|
||||||
|
selectedInfo.value = opt.value;
|
||||||
|
infoOpen.value = false;
|
||||||
|
emit("change", opt.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoTriggerRef = ref(null);
|
||||||
|
const infoPanelRef = ref(null);
|
||||||
|
|
||||||
|
const onClickOutsideInfo = (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (!infoTriggerRef.value || !infoPanelRef.value) return;
|
||||||
|
const insideTrigger = infoTriggerRef.value.contains(t);
|
||||||
|
const insidePanel = infoPanelRef.value.contains(t);
|
||||||
|
if (!insideTrigger && !insidePanel) infoOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeydownInfo = (e) => {
|
||||||
|
if (e.key === "Escape") infoOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ====== 套用樓層 / 切換 ======
|
||||||
async function applyFloor(next) {
|
async function applyFloor(next) {
|
||||||
// 進新樓層前清空單獨顯示
|
// 進新樓層前清空單獨顯示
|
||||||
soloSpriteId.value = null;
|
soloSpriteId.value = null;
|
||||||
@ -346,19 +486,18 @@ async function applyFloor(next) {
|
|||||||
activeFloor.value = next;
|
activeFloor.value = next;
|
||||||
localStorage.setItem("uark-floor", next);
|
localStorage.setItem("uark-floor", next);
|
||||||
await showLevel(next);
|
await showLevel(next);
|
||||||
// 只畫當前樓層的 sprites
|
await rebuildSpritesForFloor(next); // 只畫當前樓層的 sprites
|
||||||
await rebuildSpritesForFloor(next);
|
|
||||||
viewer.impl.invalidate(true); // 強制要求一個新幀
|
viewer.impl.invalidate(true); // 強制要求一個新幀
|
||||||
rebuildLabelsAfterNextRender(); // 等下一次渲染完成再對齊 labels
|
rebuildLabelsAfterNextRender(); // 等下一次渲染完成再對齊 labels
|
||||||
}
|
}
|
||||||
|
|
||||||
// 點擊切換樓層
|
|
||||||
async function onClickFloor(next) {
|
async function onClickFloor(next) {
|
||||||
if (next === activeFloor.value) return;
|
if (next === activeFloor.value) return;
|
||||||
if (!viewerReady.value) return;
|
if (!viewerReady.value) return;
|
||||||
await applyFloor(next);
|
await applyFloor(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 生命週期 ======
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
activeFloor.value = resolveInitialFloor();
|
activeFloor.value = resolveInitialFloor();
|
||||||
|
|
||||||
@ -367,10 +506,10 @@ onMounted(async () => {
|
|||||||
await waitObjectTree(model);
|
await waitObjectTree(model);
|
||||||
hideViewCubeAndHome();
|
hideViewCubeAndHome();
|
||||||
|
|
||||||
// ① 讓 DataVisualization 綁定這個 viewer
|
// 讓 DataVisualization 綁定這個 viewer
|
||||||
await dvInit(viewer);
|
await dvInit(viewer);
|
||||||
|
|
||||||
// ④ 綁定 sprite 點擊(聚焦 + scale=2)
|
// 綁定 sprite 點擊
|
||||||
unbindSpriteClick = forgeClickListener(async ({ data }) => {
|
unbindSpriteClick = forgeClickListener(async ({ data }) => {
|
||||||
// 進入「單獨顯示」模式,只顯示被點到那顆 popover
|
// 進入「單獨顯示」模式,只顯示被點到那顆 popover
|
||||||
soloSpriteId.value =
|
soloSpriteId.value =
|
||||||
@ -389,7 +528,11 @@ onMounted(async () => {
|
|||||||
viewerReady.value = true; // 放在 applyFloor 之後較穩
|
viewerReady.value = true; // 放在 applyFloor 之後較穩
|
||||||
});
|
});
|
||||||
|
|
||||||
// 監聽 ?floor=8F|9F
|
onMounted(() => {
|
||||||
|
document.addEventListener("click", onClickOutsideInfo);
|
||||||
|
document.addEventListener("keydown", onKeydownInfo);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query?.floor,
|
() => route.query?.floor,
|
||||||
async (v) => {
|
async (v) => {
|
||||||
@ -421,209 +564,7 @@ onUnmounted(() => {
|
|||||||
viewer.finish?.();
|
viewer.finish?.();
|
||||||
viewer = null;
|
viewer = null;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const zones = ["VIP", "換管", "失能", "失智"];
|
|
||||||
|
|
||||||
// 用 Set 做好操作與效能
|
|
||||||
const selectedZones = ref(new Set());
|
|
||||||
|
|
||||||
// 切換某個分區的選取狀態
|
|
||||||
const toggleZone = (zone) => {
|
|
||||||
const set = new Set(selectedZones.value);
|
|
||||||
if (set.has(zone)) set.delete(zone);
|
|
||||||
else set.add(zone);
|
|
||||||
selectedZones.value = set;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isZoneSelected = (zone) => selectedZones.value.has(zone);
|
|
||||||
|
|
||||||
const activeZone = ref("全部");
|
|
||||||
const onClickZone = (zone) => {
|
|
||||||
activeZone.value = zone;
|
|
||||||
console.log("選到分區:", zone);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 產生不含數字 4 的連號
|
|
||||||
function hasDigit4(n) {
|
|
||||||
return n.toString().includes("4");
|
|
||||||
}
|
|
||||||
function generateBedNumbers(count, start = 1001) {
|
|
||||||
const out = [];
|
|
||||||
let n = start;
|
|
||||||
while (out.length < count) {
|
|
||||||
if (!hasDigit4(n)) out.push(n);
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
// 整體序列:9F 6 個 + 8F 2 個(固定不變)
|
|
||||||
const BED_SERIES_ALL = generateBedNumbers(8, 1001);
|
|
||||||
const BED_SERIES_BY_FLOOR = {
|
|
||||||
"9F": BED_SERIES_ALL.slice(0, 6),
|
|
||||||
"8F": BED_SERIES_ALL.slice(6, 8),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 權重選區
|
|
||||||
function pickAreaLetter(seed) {
|
|
||||||
const r = seededRand(seed);
|
|
||||||
if (r < 0.4) return "A";
|
|
||||||
return "B";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 假資料池(長照住民)
|
|
||||||
const NAME_POOL = [
|
|
||||||
"王雅雯",
|
|
||||||
"陳志明",
|
|
||||||
"林冠廷",
|
|
||||||
"張淑芬",
|
|
||||||
"黃子涵",
|
|
||||||
"李建宏",
|
|
||||||
"吳佩珊",
|
|
||||||
"周育賢",
|
|
||||||
"趙雅筑",
|
|
||||||
"許庭瑋",
|
|
||||||
"郭怡君",
|
|
||||||
"洪嘉文",
|
|
||||||
];
|
|
||||||
|
|
||||||
const AVATAR_POOL = [
|
|
||||||
"/img/avatars/m1.png",
|
|
||||||
"/img/avatars/m2.png",
|
|
||||||
"/img/avatars/f1.png",
|
|
||||||
"/img/avatars/f2.png",
|
|
||||||
]; // 可自行準備,若沒有檔案也沒關係,模板有 fallback
|
|
||||||
|
|
||||||
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
|
|
||||||
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
|
|
||||||
const SPECIAL_POOL = ["住院中", "跌倒觀察", "復健中", "門診追蹤"];
|
|
||||||
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
|
|
||||||
|
|
||||||
const SEX_POOL = ["男", "女"];
|
|
||||||
function seededRand(seed) {
|
|
||||||
const s = Math.sin(seed * 9301 + 49297) * 233280;
|
|
||||||
return s - Math.floor(s);
|
|
||||||
}
|
|
||||||
function pick(arr, seed) {
|
|
||||||
return arr[Math.floor(seededRand(seed) * arr.length)];
|
|
||||||
}
|
|
||||||
function ageFromSeed(seed) {
|
|
||||||
return 65 + Math.floor(seededRand(seed + 7) * 31);
|
|
||||||
} // 65~95
|
|
||||||
function startDateFromSeed(seed) {
|
|
||||||
const now = new Date();
|
|
||||||
const daysAgo = 30 + Math.floor(seededRand(seed + 13) * 365); // 1~12 個月內
|
|
||||||
const d = new Date(now.getTime() - daysAgo * 86400000);
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}/${mm}/${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 選單選項
|
|
||||||
const infoOptions = [
|
|
||||||
{ label: "無顯示", value: "none" },
|
|
||||||
{ label: "有住民", value: "occupied" },
|
|
||||||
{ label: "空床", value: "vacant" },
|
|
||||||
{ label: "住院中", value: "hospitalized" },
|
|
||||||
{ label: "請假中", value: "leave" }, // ← 新增
|
|
||||||
];
|
|
||||||
|
|
||||||
const filteredLabels = computed(() => {
|
|
||||||
// 單獨顯示模式(保留你原本的行為)
|
|
||||||
if (soloSpriteId.value != null) {
|
|
||||||
if (selectedInfo.value === "none" || selectedInfo.value === "leave")
|
|
||||||
return [];
|
|
||||||
return labels.value.filter((L) => L.id === soloSpriteId.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// none / leave = 全隱(依你前面需求)
|
|
||||||
if (selectedInfo.value === "none" || selectedInfo.value === "leave") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = selectedInfo.value; // 'occupied' | 'vacant' | 'hospitalized'
|
|
||||||
const selectedSet = selectedZones.value; // Set
|
|
||||||
|
|
||||||
return labels.value.filter((L) => {
|
|
||||||
// ===== 分區多選判斷 =====
|
|
||||||
// 有選任何分區 → 必須命中其中之一
|
|
||||||
// 沒選任何分區 → 不過濾(視為全部)
|
|
||||||
const inZone =
|
|
||||||
selectedSet.size === 0
|
|
||||||
? true
|
|
||||||
: L.data?.zone
|
|
||||||
? selectedSet.has(L.data.zone)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
// ===== 原本的狀態判斷(略調整)=====
|
|
||||||
const isRed = L.data.state === "offnormal";
|
|
||||||
const isHosp = isRed && !!L.data.special; // 住院中:紅 + special
|
|
||||||
const isGreen = !isRed && !L.data.special; // 有住民:綠
|
|
||||||
|
|
||||||
let stateOk = true;
|
|
||||||
if (info === "occupied") stateOk = isGreen;
|
|
||||||
if (info === "vacant") stateOk = isRed; // 空床 = 只要是紅色,都包含(含住院)
|
|
||||||
if (info === "hospitalized") stateOk = isHosp;
|
|
||||||
|
|
||||||
return inZone && stateOk;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 單獨顯示:被點擊 sprite 的 id(null 表全域/多顯示模式)
|
|
||||||
const soloSpriteId = ref(null);
|
|
||||||
|
|
||||||
// 判斷是否為空床(紅色 & 無 special)
|
|
||||||
const isVacantWithoutSpecial = (L) =>
|
|
||||||
L?.data?.state === "offnormal" && !L?.data?.special;
|
|
||||||
|
|
||||||
// 對外通知(可接父層)
|
|
||||||
const emit = defineEmits(["change"]);
|
|
||||||
//
|
|
||||||
// 狀態:是否開啟 / 當前選中
|
|
||||||
const infoOpen = ref(false);
|
|
||||||
const selectedInfo = ref(infoOptions[0].value); // 'none'
|
|
||||||
|
|
||||||
const infoDisplay = computed(() => {
|
|
||||||
return (
|
|
||||||
infoOptions.find((o) => o.value === selectedInfo.value)?.label ??
|
|
||||||
infoOptions[0].label
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 切換
|
|
||||||
const toggleInfo = () => (infoOpen.value = !infoOpen.value);
|
|
||||||
|
|
||||||
// 點選項目
|
|
||||||
const selectInfo = (opt) => {
|
|
||||||
// 切換全域顯示 → 關閉單獨顯示
|
|
||||||
soloSpriteId.value = null;
|
|
||||||
|
|
||||||
selectedInfo.value = opt.value; // 只存 value
|
|
||||||
infoOpen.value = false;
|
|
||||||
emit("change", opt.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 點外面關閉 / Esc 關閉(與檔案A相同)
|
|
||||||
const infoTriggerRef = ref(null);
|
|
||||||
const infoPanelRef = ref(null);
|
|
||||||
|
|
||||||
const onClickOutsideInfo = (e) => {
|
|
||||||
const t = e.target;
|
|
||||||
if (!infoTriggerRef.value || !infoPanelRef.value) return;
|
|
||||||
const insideTrigger = infoTriggerRef.value.contains(t);
|
|
||||||
const insidePanel = infoPanelRef.value.contains(t);
|
|
||||||
if (!insideTrigger && !insidePanel) infoOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeydownInfo = (e) => {
|
|
||||||
if (e.key === "Escape") infoOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener("click", onClickOutsideInfo);
|
|
||||||
document.addEventListener("keydown", onKeydownInfo);
|
|
||||||
});
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener("click", onClickOutsideInfo);
|
document.removeEventListener("click", onClickOutsideInfo);
|
||||||
document.removeEventListener("keydown", onKeydownInfo);
|
document.removeEventListener("keydown", onKeydownInfo);
|
||||||
});
|
});
|
||||||
|
@ -1,115 +0,0 @@
|
|||||||
import useSelectedFloor from "@/hooks/useSelectedFloor";
|
|
||||||
import { watch, ref, inject } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
// import useSystemShowData from "@/hooks/useSystemShowData";
|
|
||||||
|
|
||||||
function useForgeFloor() {
|
|
||||||
const route = useRoute();
|
|
||||||
const levelList = ref([]);
|
|
||||||
const { selectedFloor } = useSelectedFloor();
|
|
||||||
const { subscribeData } = inject("system_deviceList");
|
|
||||||
|
|
||||||
const forgeViewer = ref(null);
|
|
||||||
const dataVizExtn = ref(null);
|
|
||||||
const updateViewerFloor = (viewer, dataVisualization) => {
|
|
||||||
forgeViewer.value = viewer;
|
|
||||||
dataVizExtn.value = dataVisualization;
|
|
||||||
};
|
|
||||||
|
|
||||||
const findLevels = () => {
|
|
||||||
forgeViewer.value.model.search(
|
|
||||||
"layer",
|
|
||||||
(nodeIds) => {
|
|
||||||
let levels = [];
|
|
||||||
const tree = forgeViewer.value.model.getInstanceTree();
|
|
||||||
for (let i = 0; i < nodeIds.length; i++) {
|
|
||||||
const dbId = nodeIds[i];
|
|
||||||
const name = tree.getNodeName(dbId);
|
|
||||||
if (!name || name.includes("<沒有層級>")) continue;
|
|
||||||
levels.push({
|
|
||||||
guid: dbId,
|
|
||||||
name,
|
|
||||||
dbId,
|
|
||||||
extension: {
|
|
||||||
buildingStory: true,
|
|
||||||
structure: false,
|
|
||||||
computationHeight: 0,
|
|
||||||
groundPlane: false,
|
|
||||||
hasAssociatedViewPlans: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
levels = levels.sort((a, b) => b.elevation - a.elevation);
|
|
||||||
console.log(levels);
|
|
||||||
levelList.value = levels;
|
|
||||||
},
|
|
||||||
(e) => {
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(forgeViewer, () => {
|
|
||||||
findLevels();
|
|
||||||
});
|
|
||||||
|
|
||||||
const hideDbIdFn = () => {
|
|
||||||
const tree = forgeViewer.value?.model.getInstanceTree();
|
|
||||||
const allDbIdsStr = Object.keys(tree.nodeAccess.dbIdToIndex);
|
|
||||||
for (var i = 0; i < allDbIdsStr.length; i++) {
|
|
||||||
forgeViewer.value.hide(parseInt(allDbIdsStr[i]));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { flatSubData } = useSystemShowData();
|
|
||||||
const showDbIdFn = () => {
|
|
||||||
hideDbIdFn();
|
|
||||||
flatSubData.value.forEach((value, index) => {
|
|
||||||
forgeViewer.value.show(value.forge_dbid);
|
|
||||||
});
|
|
||||||
|
|
||||||
forgeViewer.value.impl.invalidate(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showLevels = () => {
|
|
||||||
if (forgeViewer.value) {
|
|
||||||
const currentFloorName =
|
|
||||||
selectedFloor.value?.title?.replaceAll(/U/gi, "") || "";
|
|
||||||
|
|
||||||
const level = levelList.value.find(({ name }) =>
|
|
||||||
name.includes(currentFloorName)
|
|
||||||
);
|
|
||||||
console.log(currentFloorName, level);
|
|
||||||
|
|
||||||
if (!level) {
|
|
||||||
forgeViewer.value?.impl.toggleGhosting(true);
|
|
||||||
forgeViewer.value?.fitToView([forgeViewer.value.model.getRootId()]);
|
|
||||||
showDbIdFn();
|
|
||||||
} else {
|
|
||||||
showDbIdFn();
|
|
||||||
// forgeViewer.value.clearSelection();
|
|
||||||
// forgeViewer.value.model.setAllVisibility(0);
|
|
||||||
forgeViewer.value.impl.toggleGhosting(false);
|
|
||||||
// forgeViewer.value.impl.toggleGroundShadow(false);
|
|
||||||
forgeViewer.value.show(level.dbId);
|
|
||||||
forgeViewer.value.impl.invalidate(true);
|
|
||||||
forgeViewer.value.fitToView([level.dbId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route,
|
|
||||||
(newValue) => {
|
|
||||||
console.log(newValue);
|
|
||||||
newValue && showLevels();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
deep: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return { findLevels, showLevels, updateViewerFloor };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useForgeFloor;
|
|
@ -1,141 +0,0 @@
|
|||||||
import { watch, inject, markRaw, ref, computed, onMounted } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import useHeatmapBarStore from "@/stores/useHeatmapBarStore";
|
|
||||||
// import useSystemShowData from "@/hooks/useSystemShowData";
|
|
||||||
|
|
||||||
export default function useForgeHeatmap() {
|
|
||||||
const route = useRoute();
|
|
||||||
const { subscribeData, realtimeData } = inject("system_deviceList");
|
|
||||||
|
|
||||||
const store = useHeatmapBarStore();
|
|
||||||
|
|
||||||
const forgeViewer = ref(null);
|
|
||||||
const dataVizExtn = ref(null);
|
|
||||||
const updateViewExtension = (viewer, dataVisualization) => {
|
|
||||||
forgeViewer.value = viewer;
|
|
||||||
dataVizExtn.value = dataVisualization;
|
|
||||||
};
|
|
||||||
|
|
||||||
//create the heatmap
|
|
||||||
function getSensorValue(device, sensorType, pointData) {
|
|
||||||
const dev = realtimeData.value.find(
|
|
||||||
({ device_number }) => device_number === device.id
|
|
||||||
);
|
|
||||||
if (dev) {
|
|
||||||
const [min, max] = store.heatmapConfig?.range;
|
|
||||||
const point = dev.data.find(({ point }) => point === route.query?.gas);
|
|
||||||
console.log(9, device, dev, point, (point?.value - min || 0) / max);
|
|
||||||
|
|
||||||
return Math.random();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { flatSubData } = useSystemShowData();
|
|
||||||
|
|
||||||
const data = computed(() =>
|
|
||||||
flatSubData.value?.map((d) => {
|
|
||||||
const pointsMap = d.points ? Object.fromEntries(d.points.map(({ point, value }) => [point, 0])) : {};
|
|
||||||
return {
|
|
||||||
...d,
|
|
||||||
...pointsMap,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => realtimeData,
|
|
||||||
() => {
|
|
||||||
dataVizExtn.value &&
|
|
||||||
Object.keys(dataVizExtn.value?.surfaceShading)?.length &&
|
|
||||||
dataVizExtn.value.updateSurfaceShading(getSensorValue);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
deep: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const createHeatMap = async () => {
|
|
||||||
if (route.query?.gas === "all" || !route.query?.gas || !dataVizExtn.value) return;
|
|
||||||
const heatMapName = `iot_heatmap_${route.query?.gas}`;
|
|
||||||
console.log("createHeatMap", heatMapName);
|
|
||||||
const {
|
|
||||||
SurfaceShadingData,
|
|
||||||
SurfaceShadingPoint,
|
|
||||||
SurfaceShadingNode,
|
|
||||||
SurfaceShadingGroup,
|
|
||||||
} = Autodesk.DataVisualization.Core;
|
|
||||||
const shadingGroup = new SurfaceShadingGroup(`${heatMapName}`);
|
|
||||||
const rooms = new Map();
|
|
||||||
|
|
||||||
const roomSet = new Set(data.value.filter(({ device_coordinate_3d }) => device_coordinate_3d).map(({ room_dbid }) => room_dbid));
|
|
||||||
// 每個room是一個node
|
|
||||||
[...roomSet].forEach((roomDbId) => {
|
|
||||||
if (!roomDbId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const room = new SurfaceShadingNode(`room_${roomDbId}`, roomDbId);
|
|
||||||
|
|
||||||
//相同room內的設備
|
|
||||||
data.value
|
|
||||||
.filter(({ room_dbid }) => room_dbid === roomDbId)
|
|
||||||
.forEach(
|
|
||||||
({
|
|
||||||
device_number: id,
|
|
||||||
device_coordinate_3d: position,
|
|
||||||
sensorTypes,
|
|
||||||
}) =>
|
|
||||||
room.addPoint(new SurfaceShadingPoint(id, position, sensorTypes))
|
|
||||||
);
|
|
||||||
|
|
||||||
shadingGroup.addChild(room);
|
|
||||||
});
|
|
||||||
|
|
||||||
// data.value.forEach(
|
|
||||||
// ({
|
|
||||||
// device_number: id,
|
|
||||||
// room_dbid: roomDbId,
|
|
||||||
// device_coordinate_3d: position,
|
|
||||||
// sensorTypes,
|
|
||||||
// }) => {
|
|
||||||
// if (!id || roomDbId == -1 || !roomDbId) {
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// if (!rooms.has(roomDbId)) {
|
|
||||||
// const room = new SurfaceShadingNode(id, roomDbId);
|
|
||||||
// shadingGroup.addChild(room);
|
|
||||||
// rooms.set(roomDbId, room);
|
|
||||||
// }
|
|
||||||
// const room = rooms.get(roomDbId);
|
|
||||||
// room.addPoint(new SurfaceShadingPoint(id, position, route.query.gas));
|
|
||||||
// }
|
|
||||||
// );
|
|
||||||
const shadingData = new SurfaceShadingData(`${heatMapName}`);
|
|
||||||
shadingData.addChild(shadingGroup);
|
|
||||||
shadingData.initialize(forgeViewer.value?.model);
|
|
||||||
await dataVizExtn.value.setupSurfaceShading(
|
|
||||||
forgeViewer.value.model,
|
|
||||||
shadingData
|
|
||||||
);
|
|
||||||
dataVizExtn.value.registerSurfaceShadingColors(
|
|
||||||
route.query?.gas,
|
|
||||||
store.heatmapConfig?.color
|
|
||||||
);
|
|
||||||
dataVizExtn.value.renderSurfaceShading(
|
|
||||||
heatMapName,
|
|
||||||
route.query?.gas,
|
|
||||||
getSensorValue
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
data,
|
|
||||||
(newValue, oldValue) => {
|
|
||||||
dataVizExtn.value?.removeSurfaceShading();
|
|
||||||
createHeatMap(route.query.gas);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
return { createHeatMap, updateViewExtension };
|
|
||||||
}
|
|
@ -1,7 +1,6 @@
|
|||||||
// src/hooks/forge/useForgeSprite.js
|
|
||||||
import { ref, markRaw } from "vue";
|
import { ref, markRaw } from "vue";
|
||||||
|
|
||||||
// 裡寫死你的設備資料(可先放幾個測試點)
|
// 寫死設備資料(可先放幾個測試點)
|
||||||
// forge_dbid:模型元件 dbId(用於 fitToView / show)
|
// forge_dbid:模型元件 dbId(用於 fitToView / show)
|
||||||
// spriteDbId:sprite 的識別 id(要唯一)
|
// spriteDbId:sprite 的識別 id(要唯一)
|
||||||
// device_coordinate_3d:點在模型座標中的位置(務必是模型的 x,y,z 座標系)
|
// device_coordinate_3d:點在模型座標中的位置(務必是模型的 x,y,z 座標系)
|
||||||
@ -130,7 +129,7 @@ export default function useForgeSprite() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 小工具:平移相機到某個世界座標,但保留目前縮放(相機與目標的距離不變)
|
// --- 平移相機到某個世界座標,但保留目前縮放(相機與目標的距離不變)
|
||||||
function panToWorldPointKeepZoom(viewer, targetWorld) {
|
function panToWorldPointKeepZoom(viewer, targetWorld) {
|
||||||
const THREE = viewer.impl.THREE || ensureTHREE();
|
const THREE = viewer.impl.THREE || ensureTHREE();
|
||||||
const nav = viewer.navigation;
|
const nav = viewer.navigation;
|
||||||
@ -151,7 +150,7 @@ export default function useForgeSprite() {
|
|||||||
viewer.impl.invalidate(true, true, true);
|
viewer.impl.invalidate(true, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 小工具:沿視線方向微量「後退/前進」,不改變目標點
|
// --- 視線方向微量「後退/前進」,不改變目標點
|
||||||
function dollyAlongView(viewer, distance = 0) {
|
function dollyAlongView(viewer, distance = 0) {
|
||||||
if (!distance) return;
|
if (!distance) return;
|
||||||
const THREE = viewer.impl.THREE || ensureTHREE();
|
const THREE = viewer.impl.THREE || ensureTHREE();
|
||||||
@ -168,7 +167,6 @@ export default function useForgeSprite() {
|
|||||||
viewer.impl.invalidate(true, true, true);
|
viewer.impl.invalidate(true, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 改寫:保留縮放版 cardfitToView ---
|
|
||||||
const cardfitToView = async ({ forge_dbid, spriteDbId, back = 0 }) => {
|
const cardfitToView = async ({ forge_dbid, spriteDbId, back = 0 }) => {
|
||||||
const viewer = forgeViewer.value;
|
const viewer = forgeViewer.value;
|
||||||
if (!viewer) return;
|
if (!viewer) return;
|
||||||
@ -176,7 +174,7 @@ export default function useForgeSprite() {
|
|||||||
const THREE = viewer.impl.THREE || ensureTHREE();
|
const THREE = viewer.impl.THREE || ensureTHREE();
|
||||||
const nav = viewer.navigation;
|
const nav = viewer.navigation;
|
||||||
|
|
||||||
// 1) 找到該 sprite 的世界座標(從 STATIC_SUB_DEVICES 取)
|
// 找到該 sprite 的世界座標(從 STATIC_SUB_DEVICES 取)
|
||||||
const d = STATIC_SUB_DEVICES.find(
|
const d = STATIC_SUB_DEVICES.find(
|
||||||
(x) => x.spriteDbId === spriteDbId || x.forge_dbid === forge_dbid
|
(x) => x.spriteDbId === spriteDbId || x.forge_dbid === forge_dbid
|
||||||
);
|
);
|
||||||
@ -188,16 +186,16 @@ export default function useForgeSprite() {
|
|||||||
d.device_coordinate_3d.z
|
d.device_coordinate_3d.z
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) 僅平移到目標點,保留目前縮放
|
// 僅平移到目標點,保留目前縮放
|
||||||
panToWorldPointKeepZoom(viewer, p);
|
panToWorldPointKeepZoom(viewer, p);
|
||||||
|
|
||||||
// 3) 若需要微退/前進(back > 0 代表退後一點,單位與模型座標一致)
|
// 若需要微退/前進(back > 0 代表退後一點,單位與模型座標一致)
|
||||||
if (back) {
|
if (back) {
|
||||||
// 注意:若你以前的 back 是「正數=退後」,下面這行就符合直覺。
|
// 注意:若你以前的 back 是「正數=退後」,下面這行就符合直覺。
|
||||||
dollyAlongView(viewer, back);
|
dollyAlongView(viewer, back);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) 若有縮放樣式控制(避免被放大/縮小),在這裡把上一次點到的 sprite 還原
|
// 若有縮放樣式控制(避免被放大/縮小),在這裡把上一次點到的 sprite 還原
|
||||||
if (lastClickedDbId && lastClickedDbId !== spriteDbId) {
|
if (lastClickedDbId && lastClickedDbId !== spriteDbId) {
|
||||||
try {
|
try {
|
||||||
dataVizExtn.value?.invalidateViewables?.([lastClickedDbId], () => ({
|
dataVizExtn.value?.invalidateViewables?.([lastClickedDbId], () => ({
|
||||||
@ -210,7 +208,7 @@ export default function useForgeSprite() {
|
|||||||
lastClickedDbId = spriteDbId;
|
lastClickedDbId = spriteDbId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 只顯示寫死資料對應的 dbId(效能快、可見度好)
|
// 只顯示寫死資料對應的 dbId
|
||||||
const isolateSubSystemObjects = () => {
|
const isolateSubSystemObjects = () => {
|
||||||
const ids = STATIC_SUB_DEVICES.map((d) => d.forge_dbid).filter(Boolean);
|
const ids = STATIC_SUB_DEVICES.map((d) => d.forge_dbid).filter(Boolean);
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
@ -235,7 +233,7 @@ export default function useForgeSprite() {
|
|||||||
cardfitToView,
|
cardfitToView,
|
||||||
isolateSubSystemObjects,
|
isolateSubSystemObjects,
|
||||||
clear,
|
clear,
|
||||||
// 給你動態調整測試資料用(可選)
|
// 動態調整測試資料用(可選)
|
||||||
__staticDevices: STATIC_SUB_DEVICES,
|
__staticDevices: STATIC_SUB_DEVICES,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
import { useRoute } from "vue-router";
|
|
||||||
import { computed, inject, ref, watch } from "vue";
|
|
||||||
|
|
||||||
function useSelectedFloor() {
|
|
||||||
const { currentFloor } = inject("system_deviceList");
|
|
||||||
const route = useRoute();
|
|
||||||
const selectedFloor = computed(() =>
|
|
||||||
currentFloor.value?.find(({ key }) => key == route.params.floor_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedFloor,
|
|
||||||
currentFloor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useSelectedFloor;
|
|
@ -44,7 +44,7 @@
|
|||||||
class="absolute top-16 left-0 z-50 w-64 rounded-xl border border-gray-100 bg-white shadow-lg p-2"
|
class="absolute top-16 left-0 z-50 w-64 rounded-xl border border-gray-100 bg-white shadow-lg p-2"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<ul class="max-h-48 overflow-y-auto">
|
<ul class="max-h-48 overflow-y-auto text-brand-black">
|
||||||
<li
|
<li
|
||||||
v-for="item in facilities"
|
v-for="item in facilities"
|
||||||
:key="item"
|
:key="item"
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">A 區</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">A 區</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
@ -125,7 +125,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">B 區</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">B 區</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
@ -159,7 +159,7 @@
|
|||||||
<div v-else-if="modalType === 'inpatients'">
|
<div v-else-if="modalType === 'inpatients'">
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">A 區</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">A 區</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
@ -125,7 +125,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">B 區</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">B 區</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
@ -159,7 +159,7 @@
|
|||||||
<div v-else-if="modalType === 'inpatients'">
|
<div v-else-if="modalType === 'inpatients'">
|
||||||
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
|
<h4 class="text-lg font-semibold text-gray-700 mb-3">清單</h4>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full text-sm">
|
<table class="min-w-full text-sm text-brand-black">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left bg-gray-50">
|
<tr class="text-left bg-gray-50">
|
||||||
<th class="px-3 py-2">床位</th>
|
<th class="px-3 py-2">床位</th>
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import { defineStore } from "pinia";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
import { computed, ref, onMounted } from "vue";
|
|
||||||
|
|
||||||
const useHeatmapBarStore = defineStore("heatmap", () => {
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const allHeatMaps = ref({});
|
|
||||||
const heatmapConfig = computed(() => allHeatMaps.value[route.query?.gas]);
|
|
||||||
|
|
||||||
const getConfig = async () => {
|
|
||||||
const api =
|
|
||||||
import.meta.env.MODE === "production"
|
|
||||||
? "/dist/config.json"
|
|
||||||
: "/config.json";
|
|
||||||
const res = await axios.get(api);
|
|
||||||
console.log(res);
|
|
||||||
allHeatMaps.value = res.data.heatmap;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
const heat_bar_isShow = computed(() => Boolean(heatmapConfig.value));
|
|
||||||
|
|
||||||
return { heatmapConfig, heat_bar_isShow };
|
|
||||||
});
|
|
||||||
|
|
||||||
export default useHeatmapBarStore;
|
|
@ -1,5 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: false, // 關閉深色模式
|
||||||
|
darkMode: "class",
|
||||||
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
@ -72,5 +74,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
"light",
|
"light",
|
||||||
],
|
],
|
||||||
|
base: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user