1660 lines
52 KiB
Vue
1660 lines
52 KiB
Vue
<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>
|