uark_front/src/components/forge/Forge.vue

1660 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="relative w-full h-full min-h-full">
<div id="forge-preview" ref="forgeDom" class="absolute inset-0"></div>
<!-- Popovers永遠顯示在每顆 sprite 上方跟著相機更新 -->
<div class="absolute inset-0 z-20 pointer-events-none opacity-90">
<div
v-for="L in filteredLabels"
:key="L.id"
class="absolute -translate-x-1/2 -translate-y-full cursor-pointer"
:style="{
left: L.x + 'px',
top: L.y - 10 + 'px',
zIndex: activeLabelId === L.id ? 40 : 30,
}"
@click="bringToFrontById(L.id)"
>
<div
class="pointer-events-auto relative bg-white/95 border border-gray-300 rounded-md shadow px-3 py-2 text-sm cursor-pointer w-[180px] break-words leading-relaxed"
style="will-change: transform"
@click.stop="openResidentModal(L, $event)"
role="button"
tabindex="0"
@keydown.enter.prevent.stop="openResidentModal(L, $event)"
@keydown.space.prevent.stop="openResidentModal(L, $event)"
:aria-label="`查看 ${L.data.name} 詳細資訊`"
>
<!-- 箭頭(下方置中,指向 sprite -->
<span
class="absolute left-1/2 -bottom-2 -translate-x-1/2 w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-white"
aria-hidden="true"
></span>
<ul class="list-none">
<!-- 第一行:床號永遠顯示-->
<li class="flex justify-between items-center gap-1">
<!-- 左側:性別圖示 + 狀態符號 + 床號 -->
<div class="flex items-center">
<!-- 性別圖示 -->
<span
v-if="(L.data.roomGender || L.data.residentsSex) === '男'"
class="inline-flex items-center justify-center mr-2 align-middle"
:style="{ color: BRAND_GREEN }"
aria-label="男生房"
title="男生房"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="17"
height="17"
viewBox="0 0 256 256"
>
<path
fill="currentColor"
d="M152 140a36 36 0 1 1-36-36a36 36 0 0 1 36 36m64-100v176a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V40a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16m-24 32a8 8 0 0 0-8-8h-32a8 8 0 0 0 0 16h12.69l-18 18A52.08 52.08 0 1 0 158 109.35l18-18V104a8 8 0 0 0 16 0Z"
/>
</svg>
</span>
<span
v-else-if="
(L.data.roomGender || L.data.residentsSex) === '女'
"
class="inline-flex items-center justify-center mr-2 align-middle"
:style="{ color: BRAND_RED }"
aria-label="女生房"
title="女生房"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
>
<path
fill="currentColor"
d="M128 144a40 40 0 1 1 40-40a40 40 0 0 1-40 40m88-104v176a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V40a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16m-80 136v-16.58a56 56 0 1 0-16 0V176H96a8 8 0 0 0 0 16h24v16a8 8 0 0 0 16 0v-16h24a8 8 0 0 0 0-16Z"
/>
</svg>
</span>
<!-- 狀態符號hospitalized=黃三角leave=灰三角vacant=空心圓occupied=dot -->
<template v-if="L.data.status === 'hospitalized'">
<span
class="inline-flex items-center justify-center text-brand-yellow-dark mr-2 align-middle"
aria-label="住院中"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
</template>
<template v-else-if="L.data.status === 'leave'">
<span
class="inline-flex items-center justify-center text-brand-gray mr-2 align-middle"
aria-label="請假中"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
</template>
<template v-else-if="L.data.status === 'vacant'">
<!-- 空床:空心圓 SVG-->
<span
class="inline-flex items-center justify-center text-brand-gray mr-2 align-middle"
aria-label="空床"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M11.5 3a9.5 9.5 0 0 1 9.5 9.5a9.5 9.5 0 0 1-9.5 9.5A9.5 9.5 0 0 1 2 12.5A9.5 9.5 0 0 1 11.5 3m0 1A8.5 8.5 0 0 0 3 12.5a8.5 8.5 0 0 0 8.5 8.5a8.5 8.5 0 0 0 8.5-8.5A8.5 8.5 0 0 0 11.5 4"
/>
</svg>
</span>
</template>
<template v-else>
<!-- 有住民:顯示 dot在「選到有住民」篩選時dot 一律綠 -->
<span
class="inline-block w-2 h-2 rounded-full mr-2 align-middle"
:style="{
backgroundColor:
selectedInfo === 'occupied' &&
L.data.status === 'occupied'
? BRAND_GREEN
: labelDotColor(L),
}"
></span>
</template>
<!-- 床號 -->
<span class="align-middle">{{ L.data.name }}</span>
</div>
<!-- 右側:眼睛 icon -->
<div class="text-gray-400" title="查看詳細" aria-label="查看詳細">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 9a3 3 0 0 0-3 3a3 3 0 0 0 3 3a3 3 0 0 0 3-3a3 3 0 0 0-3-3m0 8a5 5 0 0 1-5-5a5 5 0 0 1 5-5a5 5 0 0 1 5 5a5 5 0 0 1-5 5m0-12.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5"
/>
</svg>
</span>
</div>
</li>
<!-- 第二行:住民基本資料(只有空床才顯示 - -->
<li>
{{
isVacantWithoutSpecial(L)
? "-"
: `${L.data.residentsName}${L.data.residentsSex}${L.data.residentsAge}`
}}
</li>
</ul>
</div>
</div>
</div>
<!-- 樓層切換 -->
<div
class="absolute top-4 left-4 text-sm flex justify-center items-center z-30 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">樓層|</p>
<button
:class="getFloorBtnClass('1F')"
@click="onClickFloor('1F')"
:aria-pressed="activeFloor === '1F'"
>
1F
</button>
<button
:class="getFloorBtnClass('2F')"
@click="onClickFloor('2F')"
:aria-pressed="activeFloor === '2F'"
>
2F
</button>
</div>
<!-- 床位資訊篩選 -->
<div
class="absolute top-16 left-4 text-sm flex justify-center items-center z-30 bg-white border rounded-md shadow ps-4"
>
<p class="py-2">床位資訊|</p>
<div class="flex justify-start items-center">
<!-- 無顯示 -->
<button
:class="getInfoBtnClass('none')"
@click="selectInfo({ label: '無顯示', value: 'none' })"
:aria-pressed="selectedInfo === 'none'"
>
<span
class="inline-flex items-center justify-center leading-none align-middle [&>svg]:block shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 32 32"
>
<path
fill="currentColor"
d="M24.879 2.879A3 3 0 1 1 29.12 7.12l-8.79 8.79a.125.125 0 0 0 0 .177l8.79 8.79a3 3 0 1 1-4.242 4.243l-8.79-8.79a.125.125 0 0 0-.177 0l-8.79 8.79a3 3 0 1 1-4.243-4.242l8.79-8.79a.125.125 0 0 0 0-.177l-8.79-8.79A3 3 0 0 1 7.12 2.878l8.79 8.79a.125.125 0 0 0 .177 0z"
/>
</svg>
</span>
<span class="leading-none align-middle">不顯示</span>
</button>
<!-- 有住民(綠/紅) -->
<button
:class="getInfoBtnClass('occupied')"
@click="selectInfo({ label: '有住民', value: 'occupied' })"
:aria-pressed="selectedInfo === 'occupied'"
>
<span
class="text-brand-green inline-flex items-center justify-center leading-none align-middle [&>svg]:block shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 16 16"
>
<path fill="currentColor" d="M8 4a4 4 0 1 1 0 8a4 4 0 0 1 0-8" />
</svg>
</span>
<span class="leading-none align-middle">有住民</span>
</button>
<!-- 空床(白色 sprite + 灰 dot -->
<button
:class="getInfoBtnClass('vacant')"
@click="selectInfo({ label: '空床', value: 'vacant' })"
:aria-pressed="selectedInfo === 'vacant'"
>
<span
class="text-brand-gray inline-flex items-center justify-center leading-none align-middle [&>svg]:block shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M11.5 3a9.5 9.5 0 0 1 9.5 9.5a9.5 9.5 0 0 1-9.5 9.5A9.5 9.5 0 0 1 2 12.5A9.5 9.5 0 0 1 11.5 3m0 1A8.5 8.5 0 0 0 3 12.5a8.5 8.5 0 0 0 8.5 8.5a8.5 8.5 0 0 0 8.5-8.5A8.5 8.5 0 0 0 11.5 4"
/>
</svg>
</span>
<span class="leading-none align-middle">空床</span>
</button>
<!-- 住院中(白色 sprite + 黃三角) -->
<button
:class="getInfoBtnClass('hospitalized')"
@click="selectInfo({ label: '住院中', value: 'hospitalized' })"
:aria-pressed="selectedInfo === 'hospitalized'"
>
<span
class="inline-flex items-center justify-center leading-none align-middle [&>svg]:block shrink-0 text-brand-yellow-dark"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span class="leading-none align-middle">住院中</span>
</button>
<!-- 請假中(白色 sprite + 灰三角) -->
<button
:class="getInfoBtnClass('leave')"
@click="selectInfo({ label: '請假中', value: 'leave' })"
:aria-pressed="selectedInfo === 'leave'"
>
<span
class="inline-flex items-center justify-center leading-none align-middle [&>svg]:block shrink-0 text-brand-gray"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 1.67a2.91 2.91 0 0 0-2.492 1.403L1.398 16.61a2.914 2.914 0 0 0 2.484 4.385h16.225a2.914 2.914 0 0 0 2.503-4.371L14.494 3.078A2.92 2.92 0 0 0 12 1.67"
/>
</svg>
</span>
<span class="leading-none align-middle">請假中</span>
</button>
</div>
</div>
<!-- 分區切換(多選|預設不選=顯示全部) -->
<div
class="absolute bottom-12 left-4 text-sm flex justify-center items-center gap-2 z-30 bg-white border rounded-md shadow"
>
<p class="ps-4 py-2">查看分區|</p>
<button
v-for="zone in zones"
:key="zone"
:class="getZoneBtnClass(zone)"
@click="toggleZone(zone)"
:aria-pressed="isZoneSelected(zone)"
type="button"
>
{{ zone }}
</button>
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<!-- 背景遮罩 -->
<div class="absolute inset-0 bg-black/40" @click="closeResidentModal"></div>
<!-- Modal 內容 -->
<div
class="relative z-[130] w-[min(92vw,500px)] max-h-[84vh] overflow-auto bg-white rounded-2xl shadow-xl p-6"
@click.stop
>
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
</div>
<div class="flex justify-end items-center gap-4">
<a
href="http://service2.humetrics.ai:3456/9054/?token=UARK"
target="_blank"
>
<button
class="btn btn-sm bg-brand-purple-dark text-white hover:bg-brand-purple border-none shadow rounded px-3 py-2 tracking-wider"
>
健康偵測系統
</button>
</a>
<button
class="px-3 py-1 rounded-md hover:bg-gray-100"
@click="closeResidentModal"
aria-label="關閉"
>
</button>
</div>
</div>
<!-- 內容清單 -->
<ul class="list-none text-sm space-y-2">
<li><span class="text-gray-500">姓名:</span>{{ modalData?.name }}</li>
<li><span class="text-gray-500">性別:</span>{{ modalData?.sex }}</li>
<li><span class="text-gray-500">年齡:</span>{{ modalData?.age }}</li>
<li>
<span class="text-gray-500">入住日期:</span
>{{ modalData?.startTime }}
</li>
<li>
<span class="text-gray-500">身體狀況:</span
>{{ modalData?.healthStatus }}
</li>
<li>
<span class="text-gray-500">服藥狀況:</span
>{{ modalData?.medicationStatus }}
</li>
<li>
<span class="text-gray-500">特殊事件:</span
>{{ modalData?.specialEvent }}
</li>
<li class="whitespace-pre-wrap break-words">
<span class="text-gray-500">備註</span>{{ modalData?.note }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import useForgeSprite from "@/hooks/forge/useForgeSprite";
/* ---------------------- 工具:樹/幾何 ---------------------- */
function getDirectChildren(model, parentId) {
const tree = model.getInstanceTree?.();
if (!tree) return [];
const out = [];
tree.enumNodeChildren(parentId, (id) => out.push(id));
return out;
}
function nodeHasFragments(model, dbId) {
const tree = model.getInstanceTree?.();
if (!tree) return false;
let has = false;
tree.enumNodeFragments(
dbId,
() => {
has = true;
},
true
);
return has;
}
/* ---------------------- 基本變數 ---------------------- */
const route = useRoute();
const forgeDom = ref(null);
let viewer = null;
const viewerReady = ref(false);
const activeFloor = ref("1F");
let unbindSpriteClick = null;
let THREE = null;
const {
updateDataVisualization: dvInit,
createSprites,
forgeClickListener,
cardfitToView,
clear: clearSprites,
__staticDevices,
} = useForgeSprite();
/* ---------------------- 常數 / 主題色 ---------------------- */
const FLOOR_DBIDS = { "1F": null, "2F": null };
const BRAND_RED = "#FF8678"; // 女(佔床)
const BRAND_GREEN = "#34D5C8"; // 男(佔床)
const BRAND_GRAY = "#CACACA"; // 空床 dot
const BRAND_WHITE = "#FFFFFF"; // 不在院內sprite 本體)
const MODEL_SVF_PATH = `/upload/forge/0.svf`;
const THEME_PURPLE = {
r: 170 / 255,
g: 90 / 255,
b: 255 / 255,
a: 0.9,
};
const THEME_YELLOW = { r: 250 / 255, g: 204 / 255, b: 21 / 255, a: 0.9 };
/* ---------------------- 分區映射(每層樓對半且不重疊) ---------------------- */
// 每層樓一個 Map key = 3 碼房號, value = '一般' | '氧氣'
const ROOM_ZONE_MAP = { "1F": new Map(), "2F": new Map() };
function getZoneForRoom(floorKey, code3) {
return ROOM_ZONE_MAP[floorKey]?.get(String(code3)) ?? null;
}
/* ======(可選)手動指定男女房;預設留空就會自動對半 ====== */
const FIXED_ROOM_GENDER = {
"1F": {
male: ["101", "103"],
female: ["102", "105"],
},
"2F": {
male: ["201", "203", "205", "207", "209", "211", "213", "215"],
female: ["202", "206", "208", "210", "212", "216"],
},
};
/* ---------------------- THREE 入口 ---------------------- */
function getThree() {
if (THREE) return THREE;
THREE =
(globalThis.Autodesk &&
globalThis.Autodesk.Viewing &&
(globalThis.Autodesk.Viewing.THREE ||
globalThis.Autodesk.Viewing.Private?.THREE)) ||
globalThis.THREE ||
null;
if (!THREE) throw new Error("[Forge] THREE 尚未就緒");
return THREE;
}
/* ---------------------- Resident Modal ---------------------- */
const modalOpen = ref(false);
const modalData = ref(null);
const activeLabelId = ref(null);
function bringToFrontById(id) {
activeLabelId.value = id;
}
function buildResidentModalData(d) {
const vacant = d?.status === "vacant";
return {
name: vacant ? "-" : d?.residentsName ?? "-",
sex: vacant ? "-" : d?.residentsSex ?? "-",
age: vacant ? "-" : d?.residentsAge ?? "-",
startTime: vacant ? "-" : d?.startTime ?? "-",
healthStatus: vacant ? "-" : d?.healthStatus ?? "一般",
medicationStatus: vacant ? "-" : d?.medicationStatus ?? "規律服藥",
specialEvent:
d?.status === "hospitalized"
? "住院中"
: d?.status === "leave"
? "請假中"
: "-",
note: vacant ? "-" : d?.note ?? "-",
};
}
function openResidentModal(L, e) {
e?.stopPropagation?.();
bringToFrontById(L.id);
modalData.value = buildResidentModalData(L.data);
modalOpen.value = true;
}
function closeResidentModal() {
modalOpen.value = false;
}
/* ---------------------- 初始樓層 ---------------------- */
function resolveInitialFloor() {
const q = (route.query?.floor || "").toString().toUpperCase();
if (q === "1F" || q === "2F") return q;
const saved = localStorage.getItem("uark-floor");
if (saved === "1F" || saved === "2F") return saved;
return "1F";
}
/* ---------------------- Forge 初始化 ---------------------- */
function initViewer(container) {
return new Promise((resolve) => {
Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
const config = {
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
};
viewer = new Autodesk.Viewing.GuiViewer3D(container, config);
Autodesk.Viewing.Private.InitParametersSetting.alpha = true;
viewer.start();
window.v = viewer;
getThree();
viewer.setGroundShadow(true);
const r = viewer.impl?.renderer?.();
if (r?.setClearColor) r.setClearColor(0x000000, 0);
if (r?.setClearAlpha) r.setClearAlpha(0);
viewer.impl.invalidate(true);
resolve();
});
});
}
function loadModel(filePath) {
return new Promise((resolve, reject) => {
viewer.loadModel(filePath, {}, (model) => resolve(model), reject);
});
}
function hideViewCubeAndHome() {
const tryHide = () => {
const ext = viewer?.getExtension?.("Autodesk.ViewCubeUi");
if (ext?.setVisible) ext.setVisible(false);
};
tryHide();
viewer.addEventListener(Autodesk.Viewing.EXTENSION_LOADED_EVENT, (e) => {
if (e.extensionId === "Autodesk.ViewCubeUi") tryHide();
});
}
function waitObjectTree(model) {
return new Promise((resolve) => {
if (model.getData().instanceTree) return resolve();
const onTree = () => {
viewer.removeEventListener(
Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT,
onTree
);
resolve();
};
viewer.addEventListener(Autodesk.Viewing.OBJECT_TREE_CREATED_EVENT, onTree);
});
}
/* ---------------------- 依名稱解析樓層 dbId ---------------------- */
function findTopNodeByTest(model, tester) {
const tree = model.getInstanceTree?.();
if (!tree) return null;
const root = tree.getRootId();
let hit = null;
tree.enumNodeChildren(root, (id) => {
if (hit != null) return;
const name = String(tree.getNodeName?.(id) || "");
if (tester(name)) hit = id;
});
return hit;
}
function findDbIdByNameUnderExact(model, rootDbId, targetName) {
const tree = model.getInstanceTree?.();
if (!tree || rootDbId == null) return null;
const target = String(targetName).trim().toUpperCase();
let hit = null;
const walk = (id) => {
if (hit != null) return;
const name = String(tree.getNodeName?.(id) || "")
.trim()
.toUpperCase();
if (name === target) {
hit = id;
return;
}
tree.enumNodeChildren(id, walk);
};
walk(rootDbId);
return hit;
}
function findDbIdByNameUnder(model, rootDbId, tester) {
const tree = model.getInstanceTree?.();
if (!tree || rootDbId == null) return null;
let hit = null;
const walk = (id) => {
if (hit != null) return;
const name = String(tree.getNodeName?.(id) || "");
if (tester(name)) {
hit = id;
return;
}
tree.enumNodeChildren(id, walk);
};
walk(rootDbId);
return hit;
}
function getNodeNameById(model, dbId) {
const tree = model.getInstanceTree?.();
return tree ? String(tree.getNodeName?.(dbId) || "") : "";
}
function resolveFloorDbIdsFromTree(model) {
const arc1Root = findTopNodeByTest(model, (n) => /\b1F\.nwc\b/i.test(n));
const arc2Root = findTopNodeByTest(model, (n) => /\b2F\.nwc\b/i.test(n));
const id1Exact = arc1Root
? findDbIdByNameUnderExact(model, arc1Root, "1F(360)")
: null;
const id1Like =
!id1Exact && arc1Root
? findDbIdByNameUnder(model, arc1Root, (n) =>
/(?:^|[^A-Z0-9])1F\s*\(360\)(?:[^A-Z0-9]|$)/i.test(String(n || ""))
)
: null;
const id2Exact = arc2Root
? findDbIdByNameUnderExact(model, arc2Root, "2F(360)")
: null;
const id2Like =
!id2Exact && arc2Root
? findDbIdByNameUnder(model, arc2Root, (n) =>
/(?:^|[^A-Z0-9])2F\s*\(360\)(?:[^A-Z0-9]|$)/i.test(String(n || ""))
)
: null;
FLOOR_DBIDS["1F"] = id1Exact ?? id1Like ?? null;
FLOOR_DBIDS["2F"] = id2Exact ?? id2Like ?? null;
if (FLOOR_DBIDS["1F"] != null)
console.log(
"[Forge] 1F 命中名稱 =",
getNodeNameById(model, FLOOR_DBIDS["1F"])
);
else console.warn("[Forge] 未在 1F.nwc 子樹找到 1F(360) 節點");
if (FLOOR_DBIDS["2F"] != null)
console.log(
"[Forge] 2F 命中名稱 =",
getNodeNameById(model, FLOOR_DBIDS["2F"])
);
else console.warn("[Forge] 未在 2F.nwc 子樹找到 2F(360) 節點");
console.log("[Forge] 解析樓層 dbIds", FLOOR_DBIDS);
}
/* ---------------------- 名稱搜尋 ---------------------- */
function collectDbIdsByNameUnder(model, rootDbId, keyword) {
const tree = model.getInstanceTree?.();
if (!tree) return [];
const out = [];
const walk = (id) => {
const name = tree.getNodeName ? tree.getNodeName(id) : "";
if (typeof name === "string" && name.includes(keyword)) out.push(id);
tree.enumNodeChildren(id, walk);
};
walk(rootDbId);
return out;
}
/* ---------------------- 幾何工具(單一定義) ---------------------- */
function getNodeWorldBounds(model, dbId) {
const THREE = getThree();
const tree = model.getInstanceTree?.();
const fragList = model.getFragmentList?.();
const box = new THREE.Box3();
if (!tree || !fragList) return box;
tree.enumNodeFragments(
dbId,
(fragId) => {
const fb = new THREE.Box3();
fragList.getWorldBounds(fragId, fb);
box.union(fb);
},
true
);
return box;
}
/* ---------------------- 找「房間」容器與掃描 ---------------------- */
function code3ForFloor(name, floorKey) {
const m = String(name || "").match(/\b(\d{3})\b/);
if (!m) return null;
const code = m[1];
const wantPrefix = String(floorKey || "").charAt(0); // '1' or '2'
if (wantPrefix && code.charAt(0) !== wantPrefix) return null;
if (code === "360") return null; // 排除 1F(360)/2F(360) 之類
return code;
}
function findRoomContainerId(model, floorRootId) {
const tree = model.getInstanceTree?.();
if (!tree || floorRootId == null) return null;
const candidates = [];
const walk = (id) => {
const name = String(tree.getNodeName?.(id) || "").trim();
const looksLikeRoomGroup = /房|病房|room|ROOM/i.test(name);
if (looksLikeRoomGroup) {
const children = [];
tree.enumNodeChildren(id, (cid) => children.push(cid));
const numericKids = children.filter((cid) =>
/\b\d{3}\b/.test(String(tree.getNodeName?.(cid) || ""))
);
candidates.push({
id,
total: children.length,
numeric: numericKids.length,
});
}
tree.enumNodeChildren(id, walk);
};
walk(floorRootId);
if (!candidates.length) return null;
candidates.sort((a, b) => b.numeric - a.numeric || b.total - a.total);
const hit = candidates[0];
if (!hit || hit.numeric < 4) return null; // 不可靠 → 改用掃描
return hit.id;
}
function scanRoomsUnderFloor(model, floorRootId, floorKey) {
const tree = model.getInstanceTree?.();
if (!tree || floorRootId == null) return [];
const out = [];
const seen = new Set();
const walk = (id) => {
const name = String(tree.getNodeName?.(id) || "");
let has = false;
tree.enumNodeFragments(
id,
() => {
has = true;
},
true
);
const code3 = code3ForFloor(name, floorKey);
if (code3 && has) {
if (!seen.has(id)) {
out.push({ id, name, code3 });
seen.add(id);
}
return;
}
tree.enumNodeChildren(id, walk);
};
walk(floorRootId);
return out;
}
function roomCodeOf(name) {
const m = String(name || "").match(/\b(\d{3})\b/);
return m ? m[1] : null;
}
function collectRoomsForFloor(model, floorKey, floorRootId) {
const tree = model.getInstanceTree?.();
if (!tree || floorRootId == null) return [];
const containerId = findRoomContainerId(model, floorRootId);
let roomNodes = [];
if (containerId != null) {
const children = [];
tree.enumNodeChildren(containerId, (id) => children.push(id));
roomNodes = children
.map((cid) => {
const n = String(tree.getNodeName?.(cid) || "");
const code3 = code3ForFloor(n, floorKey);
if (!code3) return null;
let has = false;
tree.enumNodeFragments(
cid,
() => {
has = true;
},
true
);
return has ? { id: cid, name: n, code3 } : null;
})
.filter(Boolean);
}
if (!roomNodes.length) {
roomNodes = scanRoomsUnderFloor(model, floorRootId, floorKey);
}
if (!roomNodes.length) {
console.warn("[Forge] 此層未找到任何符合 3 碼房號的節點");
return [];
}
const rooms = roomNodes.map(({ id, name, code3 }) => {
const box = getNodeWorldBounds(model, id);
return { id, name, code3, box, gender: null };
});
const sets = FIXED_ROOM_GENDER[floorKey] || {};
const maleSet = new Set(sets.male || []);
const femaleSet = new Set(sets.female || []);
rooms.forEach((r) => {
if (maleSet.has(r.code3)) r.gender = "男";
if (femaleSet.has(r.code3)) r.gender = "女";
});
const total = rooms.length;
const targetMale = Math.floor(total / 2);
const targetFemale = total - targetMale;
let maleCount = rooms.filter((r) => r.gender === "男").length;
let femaleCount = rooms.filter((r) => r.gender === "女").length;
const unresolved = rooms
.filter((r) => !r.gender)
.sort((a, b) => (a.code3 || "").localeCompare(b.code3 || ""));
for (const r of unresolved) {
if (maleCount < targetMale && femaleCount < targetFemale) {
if (maleCount <= femaleCount) {
r.gender = "男";
maleCount++;
} else {
r.gender = "女";
femaleCount++;
}
} else if (maleCount < targetMale) {
r.gender = "男";
maleCount++;
} else if (femaleCount < targetFemale) {
r.gender = "女";
femaleCount++;
} else {
r.gender = "男";
maleCount++;
}
}
console.table(
rooms.map((r) => ({ floor: floorKey, code: r.code3, gender: r.gender }))
);
// === 對半分區(一般/氧氣),兩組不重疊 ===
ROOM_ZONE_MAP[floorKey].clear();
const sorted = [...rooms]
.filter((r) => r.code3) // 只分配有 3 碼房號的
.sort((a, b) => String(a.code3).localeCompare(String(b.code3)));
// 偶數位 -> 一般;奇數位 -> 氧氣(確保不重疊、幾乎對半)
sorted.forEach((r, idx) => {
ROOM_ZONE_MAP[floorKey].set(r.code3, idx % 2 === 0 ? "一般" : "氧氣");
});
return rooms;
}
/* ---------------------- Box 幫手 & 由床盒對應到房 ---------------------- */
function expandBox(box, eps = 0.05) {
const THREE = getThree();
const b = box.clone();
b.min.sub(new THREE.Vector3(eps, eps, eps));
b.max.add(new THREE.Vector3(eps, eps, eps));
return b;
}
function resolveRoomByBox(rooms, bedBox) {
if (!rooms.length) return null;
const eb = expandBox(bedBox, 0.02);
for (const r of rooms) {
const rb = expandBox(r.box, 0.02);
if (rb.intersectsBox(eb) || rb.containsBox(eb)) return r;
}
const THREE = getThree();
const c = new THREE.Vector3();
bedBox.getCenter(c);
let best = null,
bestD2 = Infinity;
for (const r of rooms) {
const rc = new THREE.Vector3();
r.box.getCenter(rc);
const dx = c.x - rc.x,
dy = c.y - rc.y;
const d2 = dx * dx + dy * dy;
if (d2 < bestD2) {
bestD2 = d2;
best = r;
}
}
return best;
}
/* ---------------------- 分區 ---------------------- */
const ZONE_BY_NAME = { 一般: ["101", "102"], 氧氣: ["103", "105"] };
function judgeZoneByName(roomName) {
const code = roomCodeOf(roomName); // 抓 3 碼房號
if (!code) return null;
// 依房號前綴推回樓層1xx -> 1F、2xx -> 2F預設用當前樓層
const floorKey = code.startsWith("1")
? "1F"
: code.startsWith("2")
? "2F"
: activeFloor.value;
return getZoneForRoom(floorKey, code);
}
let lastThemedIds = new Set();
function clearOurTheming(model) {
try {
viewer.clearThemingColors(model);
} catch (e) {
console.warn("[Forge] clearThemingColors 失敗", e);
}
lastThemedIds.clear();
}
function setRoomTheming(id, rgba) {
const THREE = getThree();
const c = new THREE.Vector4(rgba.r, rgba.g, rgba.b, rgba.a);
viewer.setThemingColor(id, c, viewer.model, true);
lastThemedIds.add(id);
}
function rebuildZoneThemingForCurrentFloor() {
if (!viewer || !viewer.model) return;
// 先清乾淨我們加過的房間主題色(不動 sprites
clearOurTheming(viewer.model);
// 沒選任何分區=顯示全部(不上主題色)
if (selectedZones.value.size === 0) {
viewer.impl.invalidate(true);
return;
}
const floorKey = activeFloor.value; // "1F" | "2F"
const floorRootId = FLOOR_DBIDS[floorKey];
if (floorRootId == null) return;
const tree = viewer.model.getInstanceTree?.();
if (!tree) return;
// --- 幫手:從名稱抓 3 碼房號,且需符合該樓層前綴 ---
const code3For = (name) => {
const m = String(name || "").match(/\b(\d{3})\b/);
if (!m) return null;
const code = m[1];
if (code.charAt(0) !== floorKey.charAt(0)) return null; // 1xx/2xx
if (code === "360") return null; // 排除 1F(360)/2F(360)
return code;
};
// --- 找出本層所有「房間」節點(需有幾何) ---
let roomsGroupId =
findDbIdByNameUnderExact(viewer.model, floorRootId, "房間") ??
findDbIdByNameUnder(viewer.model, floorRootId, (n) =>
/房間/i.test(String(n || ""))
);
const rooms = [];
const seen = new Set();
const pushIfRoom = (id) => {
const name = String(tree.getNodeName?.(id) || "");
let has = false;
tree.enumNodeFragments(
id,
() => {
has = true;
},
true
);
if (!has) return;
const code = code3For(name);
if (!code) return;
if (!seen.has(id)) {
rooms.push({ id, name, code3: code, codeN: parseInt(code, 10) });
seen.add(id);
}
};
if (roomsGroupId != null) {
tree.enumNodeChildren(roomsGroupId, (cid) => pushIfRoom(cid));
} else {
const walk = (id) => {
pushIfRoom(id);
tree.enumNodeChildren(id, walk);
};
walk(floorRootId);
}
if (!rooms.length) {
console.warn(`[Forge] ${floorKey} 未找到任何房間節點,無法分區上色`);
viewer.impl.invalidate(true);
return;
}
// --- 依「數值」排序(避免字串排序陷阱) ---
const sortedRooms = [...rooms].sort((a, b) => a.codeN - b.codeN);
// --- 重新建立本層分區表(每次重建,避免舊 Map 汙染) ---
const zoneMap = new Map();
// ① 主要策略:交錯分配(偶數→一般;奇數→氧氣)
sortedRooms.forEach((r, idx) => {
zoneMap.set(r.code3, idx % 2 === 0 ? "一般" : "氧氣");
});
// ② 驗證:若不小心全落同組(理論上不會,但保險)
const cntGeneralA = sortedRooms.reduce(
(n, r, i) => n + (zoneMap.get(r.code3) === "一般"),
0
);
const cntO2A = sortedRooms.length - cntGeneralA;
if (cntGeneralA === 0 || cntO2A === 0) {
// ③ 後備策略:前半「一般」、後半「氧氣」
zoneMap.clear();
const half = Math.floor(sortedRooms.length / 2);
sortedRooms.forEach((r, idx) => {
zoneMap.set(r.code3, idx < half ? "一般" : "氧氣");
});
}
// 存回全域對應表
ROOM_ZONE_MAP[floorKey] = zoneMap;
// --- 套用主題色(只對被勾選的分區著色) ---
const chosen = selectedZones.value; // Set{"一般","氧氣"}
let countGeneral = 0,
countO2 = 0;
for (const r of sortedRooms) {
const z = zoneMap.get(r.code3);
if (!z || !chosen.has(z)) continue;
if (z === "一般") {
setRoomTheming(r.id, THEME_PURPLE);
countGeneral++;
} else if (z === "氧氣") {
setRoomTheming(r.id, THEME_YELLOW);
countO2++;
}
}
// ---- 除錯輸出 ----
console.table(
sortedRooms.map((r, i) => ({
floor: floorKey,
idx: i,
code: r.code3,
zone: zoneMap.get(r.code3),
}))
);
console.log(
`[Forge] ${floorKey} theming -> 一般:${countGeneral} | 氧氣:${countO2} | total:${
sortedRooms.length
} | chosen=${[...chosen].join(",")}`
);
viewer.impl.invalidate(true, true, true);
}
/* ---------------------- Sprites 顏色規則 ---------------------- */
function spriteColorBy(status, gender) {
// 在院內:依性別著色;不在院內(含空床/住院/請假):白色
if (status === "occupied") return gender === "男" ? BRAND_GREEN : BRAND_RED;
return BRAND_WHITE;
}
function labelDotColor(L) {
const s = L?.data?.status;
const g = L?.data?.residentsSex;
if (s === "occupied") return g === "男" ? BRAND_GREEN : BRAND_RED;
// 空床 / 住院中 / 請假中 → 灰色 dot
return BRAND_GRAY;
}
/* ---------------------- 樓層顯示(隔離) ---------------------- */
function collectLeafsUnder(model, rootDbId) {
const tree = model.getInstanceTree();
const leafs = [];
const walk = (id) => {
const c = tree.getChildCount(id);
if (c === 0) leafs.push(id);
else tree.enumNodeChildren(id, walk);
};
walk(rootDbId);
return leafs.length ? leafs : [rootDbId];
}
function allLeafIds(model) {
const tree = model.getInstanceTree();
const root = tree.getRootId();
const out = [];
const walk = (id) => {
const c = tree.getChildCount(id);
if (c === 0) out.push(id);
else tree.enumNodeChildren(id, walk);
};
walk(root);
return out;
}
async function showLevel(levelKey) {
if (!viewer || !viewer.model) return;
const levelDbId = FLOOR_DBIDS[levelKey];
if (levelDbId == null) {
console.warn(`[Forge] 找不到樓層 '${levelKey}' 對應的 dbId改為顯示全景`);
viewer.showAll();
viewer.fitToView();
viewer.impl.invalidate(true);
return;
}
const ids = collectLeafsUnder(viewer.model, levelDbId);
viewer.showAll();
try {
if (typeof viewer.setAggregateIsolation === "function") {
viewer.setAggregateIsolation([{ model: viewer.model, ids }]);
viewer.fitToView?.(ids, viewer.model);
} else {
viewer.isolate(ids, viewer.model);
viewer.fitToView?.(ids, viewer.model);
}
viewer.setGhosting(false);
viewer.impl.invalidate(true);
} catch (e) {
console.warn("[Forge] aggregate isolate 失敗,改用 hide-rest 方案", e);
const all = new Set(allLeafIds(viewer.model));
ids.forEach((id) => all.delete(id));
const toHide = Array.from(all);
viewer.showAll();
toHide.forEach((id) => viewer.hide(id));
viewer.fitToView?.(ids, viewer.model);
viewer.setGhosting(false);
viewer.impl.invalidate(true);
}
}
/* ---------------------- 假資料生成 ---------------------- */
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);
}
function startDateFromSeed(seed) {
const now = new Date();
const daysAgo = 30 + Math.floor(seededRand(seed + 13) * 365);
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_MALE = [
"王建中",
"張家豪",
"劉俊傑",
"黃柏翰",
"吳宗翰",
"蔡宗倫",
"楊偉傑",
"何彥廷",
"邱柏宇",
"鄭智凱",
"陳志明",
"林冠廷",
"李建宏",
"周育賢",
"許庭瑋",
"郭子豪",
"曾博翔",
"簡志豪",
"蘇冠宇",
"謝承翰",
"羅庭瑋",
"傅宥辰",
"潘柏睿",
"洪偉倫",
];
const NAME_POOL_FEMALE = [
"王雅雯",
"張淑芬",
"黃子涵",
"吳佩珊",
"趙雅筑",
"郭怡君",
"洪嘉文",
"陳怡君",
"林怡婷",
"李佳穎",
"周品萱",
"徐庭瑄",
"鄭雅婷",
"劉郁涵",
"楊靜怡",
"許淑華",
"蔡佩玲",
"何依婷",
"邱雅筑",
"曾婉婷",
"簡詩涵",
"謝采靈",
"羅琬淳",
"潘俞安",
];
const HEALTH_POOL = ["一般", "良好", "需觀察", "虛弱"];
const MEDICATION_POOL = ["規律服藥", "偶爾忘記", "暫停服藥", "需家屬協助"];
const NOTE_POOL = ["-", "對花生過敏", "家屬每週三探視", "夜間需加護理巡房"];
// 依種子回傳床位狀態(可調整機率)
function bedStatusFromSeed(seed) {
const r = seededRand(seed + 17);
// 分佈:佔床 62%|住院 10%|請假 8%|空床 20%
if (r < 0.62) return "occupied";
if (r < 0.72) return "hospitalized";
if (r < 0.8) return "leave";
return "vacant";
}
/* ---------------------- 依樓層建立 sprites佔床=性別色;不在院內=白) ---------------------- */
async function rebuildSpritesForFloor(floorKey) {
const THREE = getThree();
const floorRootId = FLOOR_DBIDS[floorKey];
if (floorRootId == null) {
console.warn("[Forge] 此樓層沒有對應的 root dbId", floorKey);
__staticDevices.length = 0;
await createSprites();
return;
}
// 1) 收集此層「所有房」並決定性別(平均對半)
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
// 2) 找「【崇恩護家】床位」群組,再拉出每一張床
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
const bedGroupIds = collectDbIdsByNameUnder(
viewer.model,
floorRootId,
BED_GROUP_KEYWORD
);
let bedNodes = [];
bedGroupIds.forEach((groupId) => {
const children = getDirectChildren(viewer.model, groupId);
const directBeds = children.filter((id) =>
nodeHasFragments(viewer.model, id)
);
if (directBeds.length) {
bedNodes.push(...directBeds);
} else {
children.forEach((cId) => {
const grand = getDirectChildren(viewer.model, cId);
bedNodes.push(
...grand.filter((id) => nodeHasFragments(viewer.model, id))
);
});
}
});
bedNodes = Array.from(new Set(bedNodes));
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
__staticDevices.length = 0;
let globalIndex = 0;
for (const dbId of bedNodes) {
const bedBox = getNodeWorldBounds(viewer.model, dbId);
if (bedBox.isEmpty()) continue;
const c = new (getThree().Vector3)();
bedBox.getCenter(c);
const zOk = c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
if (!zOk) continue;
const lift = Math.max(0.25, (bedBox.max.z - bedBox.min.z) * 0.2);
const pos = new THREE.Vector3(c.x, c.y, bedBox.max.z + lift);
// 用床的 box 對到房;若失敗,用交錯 fallback避免全回同色
const roomMatch = resolveRoomByBox(ROOMS, bedBox);
const fallbackGen = globalIndex % 2 === 0 ? "男" : "女";
const roomGender = roomMatch?.gender ?? fallbackGen;
const roomLabel = roomMatch?.name ?? "?";
// 四態occupied / hospitalized / leave / vacant
const spriteDbId = (floorKey === "1F" ? 91100 : 92100) + globalIndex++;
const status = bedStatusFromSeed(spriteDbId);
const hasResident = status !== "vacant"; // 住院/請假仍有住民資訊
const color = spriteColorBy(status, roomGender);
// 假資料(有住民才生成)
const namePool = roomGender === "男" ? NAME_POOL_MALE : NAME_POOL_FEMALE;
const residentsName = hasResident ? pick(namePool, spriteDbId) : "-";
const residentsAge = hasResident ? ageFromSeed(spriteDbId) : "-";
const startTime = hasResident ? startDateFromSeed(spriteDbId) : "-";
const healthStatus = hasResident ? pick(HEALTH_POOL, spriteDbId + 5) : "-";
const medicationStatus = hasResident
? pick(MEDICATION_POOL, spriteDbId + 6)
: "-";
const note = hasResident ? pick(NOTE_POOL, spriteDbId + 8) : "-";
const zone =
getZoneForRoom(floorKey, roomMatch?.code3 ?? roomCodeOf(roomLabel)) ??
null;
__staticDevices.push({
name: `床號 ${roomLabel}`,
forge_dbid: floorRootId,
spriteDbId,
device_coordinate_3d: { x: pos.x, y: pos.y, z: pos.z },
device_normal_color: color,
device_close_color: color,
status, // "occupied" | "hospitalized" | "leave" | "vacant"
// 房間性別(不論是否有住民都存在)
roomGender: roomGender,
// 住民資料vacant 時維持 "-"
residentsName,
residentsSex: hasResident ? roomGender : "-",
residentsAge,
startTime,
healthStatus,
medicationStatus,
specialEvent:
status === "hospitalized"
? "住院中"
: status === "leave"
? "請假中"
: "-",
note,
zone,
});
}
await createSprites(); // 每張床都有 sprite不在院內者白色
}
/* ---------------------- Popover跟相機更新 ---------------------- */
const labels = ref([]);
let camEvtCleanup = null;
function worldToScreen(pos3) {
const p = viewer.worldToClient(pos3);
return { x: p.x, y: p.y };
}
function rebuildLabelsForCurrentFloor() {
labels.value = __staticDevices.map((d) => {
const { x, y } = worldToScreen(
new (getThree().Vector3)(
d.device_coordinate_3d.x,
d.device_coordinate_3d.y,
d.device_coordinate_3d.z
)
);
return { id: d.spriteDbId, x, y, data: d };
});
}
function updateLabelPositions() {
if (!labels.value.length) return;
labels.value = labels.value.map((L) => {
const { x, y } = worldToScreen(
new (getThree().Vector3)(
L.data.device_coordinate_3d.x,
L.data.device_coordinate_3d.y,
L.data.device_coordinate_3d.z
)
);
return { ...L, x, y };
});
}
function attachCameraEventsForLabels() {
camEvtCleanup?.();
const onUpdate = () => requestAnimationFrame(updateLabelPositions);
viewer.addEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, onUpdate);
viewer.addEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
camEvtCleanup = () => {
viewer.removeEventListener(Autodesk.Viewing.CAMERA_CHANGE_EVENT, onUpdate);
viewer.removeEventListener(
Autodesk.Viewing.RENDER_PRESENTED_EVENT,
onUpdate
);
viewer.removeEventListener(Autodesk.Viewing.VIEWER_RESIZE_EVENT, onUpdate);
};
}
function rebuildLabelsAfterNextRender() {
const once = () => {
rebuildLabelsForCurrentFloor();
updateLabelPositions();
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
};
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
}
/* ---------------------- 分區選擇(多選) ---------------------- */
const zones = ["一般", "氧氣"];
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;
rebuildZoneThemingForCurrentFloor();
};
const isZoneSelected = (zone) => selectedZones.value.has(zone);
/* ---------------------- 床位資訊 選單(只影響 Popover不影響 sprites ---------------------- */
const soloSpriteId = ref(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);
const getFloorBtnClass = (val) => {
const isActive = activeFloor.value === val;
return [
// 共同樣式(不含固定 hover
"px-6 py-2 rounded-md",
// 依狀態切換底色
isActive ? "bg-brand-green-light" : "bg-white",
// 依狀態切換 hover 顏色
isActive ? "hover:bg-brand-green-light" : "hover:bg-gray-100",
];
};
const getInfoBtnClass = (val) => {
const isActive = selectedInfo.value === val;
return [
// 共同樣式(不含 hover避免與動態 hover 類別打架)
"inline-flex items-center gap-2 px-4 py-3 rounded-md",
// 依狀態切換底色/透明度
isActive ? "bg-brand-green-light bg-opacity-50" : "bg-white",
// 依狀態切換 hover 顏色(此處避免固定放 hover:bg-gray-100
isActive
? "hover:bg-brand-green-light hover:bg-opacity-50"
: "hover:bg-gray-100",
];
};
const getZoneBtnClass = (zone) => {
const isActive = isZoneSelected(zone);
return [
// 共同樣式(不含固定 hover避免撞到動態 hover
"px-4 py-2 rounded-md",
// 依狀態切換底色/透明度
isActive ? "bg-brand-green-light bg-opacity-50" : "bg-white",
// 依狀態切換 hover 類別
isActive
? "hover:bg-brand-green-light hover:bg-opacity-50"
: "hover:bg-gray-100",
];
};
const filteredLabels = computed(() => {
if (soloSpriteId.value != null) {
if (selectedInfo.value === "none") return [];
const L = labels.value.find((L) => L.id === soloSpriteId.value);
if (!L) return [];
return selectedInfo.value === L.data.status ? [L] : [];
}
if (selectedInfo.value === "none") return [];
const selectedSet = selectedZones.value;
return labels.value.filter((L) => {
const inZone =
selectedSet.size === 0
? true
: L.data?.zone
? selectedSet.has(L.data.zone)
: true;
return inZone && L.data.status === selectedInfo.value;
});
});
const isVacantWithoutSpecial = (L) => L?.data?.status === "vacant";
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) {
soloSpriteId.value = null;
activeFloor.value = next;
localStorage.setItem("uark-floor", next);
await showLevel(next);
await rebuildSpritesForFloor(next);
viewer.impl.invalidate(true);
rebuildLabelsAfterNextRender();
rebuildZoneThemingForCurrentFloor();
}
async function onClickFloor(next) {
if (next === activeFloor.value) return;
if (!viewerReady.value) return;
await applyFloor(next);
}
/* ---------------------- 生命週期 ---------------------- */
onMounted(async () => {
activeFloor.value = resolveInitialFloor();
await initViewer(forgeDom.value);
const model = await loadModel(MODEL_SVF_PATH);
await waitObjectTree(model);
hideViewCubeAndHome();
await dvInit(viewer);
resolveFloorDbIdsFromTree(model);
unbindSpriteClick = forgeClickListener(async ({ data }) => {
soloSpriteId.value =
soloSpriteId.value === data.spriteDbId ? null : data.spriteDbId;
bringToFrontById(data.spriteDbId);
await cardfitToView({
forge_dbid: data.forge_dbid,
spriteDbId: data.spriteDbId,
back: 30,
});
});
attachCameraEventsForLabels();
await applyFloor("1F");
viewerReady.value = true;
});
onMounted(() => {
document.addEventListener("click", onClickOutsideInfo);
document.addEventListener("keydown", onKeydownInfo);
});
watch(
() => route.query?.floor,
async (v) => {
if (!viewerReady.value) return;
const q = (v || "").toString().toUpperCase();
if (q === "1F" || q === "2F") await applyFloor(q);
attachCameraEventsForLabels();
}
);
onUnmounted(() => {
try {
clearOurTheming(viewer.model);
} catch {}
try {
unbindSpriteClick?.();
camEvtCleanup?.();
} catch {}
try {
clearSprites();
} catch {}
if (viewer) {
try {
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
ext?.removeAllViewables?.();
viewer.unloadExtension?.("Autodesk.DataVisualization");
} catch {}
viewer.tearDown?.();
viewer.finish?.();
viewer = null;
}
document.removeEventListener("click", onClickOutsideInfo);
document.removeEventListener("keydown", onKeydownInfo);
});
</script>
<style>
.adsk-viewing-viewer {
background-color: transparent !important;
}
#guiviewer3d-toolbar {
display: none;
}
.adsk-viewcube {
display: none !important;
}
</style>