diff --git a/index.html b/index.html
index 9e8a9ad..6d09eed 100644
--- a/index.html
+++ b/index.html
@@ -14,7 +14,7 @@
諾亞克 U-ARK 戰情中心
-
+
diff --git a/src/App.vue b/src/App.vue
index d0fdf44..c7884a8 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/src/components/common/forge/Forge.vue b/src/components/common/forge/Forge.vue
index 946f491..9bc31de 100644
--- a/src/components/common/forge/Forge.vue
+++ b/src/components/common/forge/Forge.vue
@@ -258,7 +258,7 @@
-
+
-
病患資訊
+ 住民資訊
{
@@ -665,8 +657,6 @@ function ensureZoneMapForFloor(model, floorKey, floorRootId) {
};
walk(floorRootId);
rooms.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
-
- // 填滿 floorStore:沒存過的用交錯(偶數索引→一般;奇數索引→氧氣)
rooms.forEach((code, idx) => {
if (floorStore[code] !== "一般" && floorStore[code] !== "氧氣") {
floorStore[code] = idx % 2 === 0 ? "一般" : "氧氣";
@@ -674,12 +664,9 @@ function ensureZoneMapForFloor(model, floorKey, floorRootId) {
});
store[floorKey] = floorStore;
saveZoneStore(store);
-
- // 寫回記憶體的 MAP
ROOM_ZONE_MAP[floorKey] = new Map(Object.entries(floorStore));
return ROOM_ZONE_MAP[floorKey];
}
-
function getZoneForRoom(floorKey, code3) {
return ROOM_ZONE_MAP[floorKey]?.get(String(code3)) ?? null;
}
@@ -744,10 +731,53 @@ function resolveInitialFloor() {
return "1F";
}
-/** ===================== Forge 初始化 / 模型載入 ===================== */
-function initViewer(container) {
+/** ===================== 等容器有尺寸再啟動 Viewer ===================== */
+async function waitForContainerSize(
+ el,
+ { minW = 2, minH = 2, timeout = 4000 } = {}
+) {
+ if (!el) return false;
+ const ok = () => el.clientWidth >= minW && el.clientHeight >= minH;
+ if (ok()) return true;
return new Promise((resolve) => {
- Autodesk.Viewing.Initializer({ env: "Local", language: "en" }, () => {
+ let done = false;
+ const t = setTimeout(() => {
+ if (!done) {
+ done = true;
+ ro.disconnect();
+ resolve(ok());
+ }
+ }, timeout);
+ const ro = new ResizeObserver(() => {
+ if (done) return;
+ if (ok()) {
+ done = true;
+ clearTimeout(t);
+ ro.disconnect();
+ requestAnimationFrame(() => resolve(true));
+ }
+ });
+ ro.observe(el);
+ requestAnimationFrame(() => {
+ if (!done && ok()) {
+ done = true;
+ clearTimeout(t);
+ ro.disconnect();
+ resolve(true);
+ }
+ });
+ });
+}
+
+/** ===================== Forge 初始化 / 模型載入 ===================== */
+async function initViewer(container) {
+ return new Promise((resolve) => {
+ const initOpts = { env: "Local", language: "en" };
+ try {
+ initOpts.useADP = false;
+ initOpts.experimental = { disableIndexedDb: true };
+ } catch {}
+ Autodesk.Viewing.Initializer(initOpts, () => {
const config = {
extensions: ["Autodesk.DataVisualization", "Autodesk.DocumentBrowser"],
};
@@ -757,10 +787,23 @@ function initViewer(container) {
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);
+ const once = () => {
+ try {
+ const r = viewer.impl?.renderer?.();
+ if (r?.setClearColor) r.setClearColor(0x000000, 0);
+ if (r?.setClearAlpha) r.setClearAlpha(0);
+ viewer.impl.invalidate(true);
+ } catch (err) {
+ console.warn("[Forge] invalidate skipped:", err);
+ }
+ viewer.removeEventListener(
+ Autodesk.Viewing.RENDER_PRESENTED_EVENT,
+ once
+ );
+ };
+ viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once, {
+ once: true,
+ });
resolve();
});
});
@@ -979,19 +1022,19 @@ function clearOurTheming(model) {
} catch {}
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;
clearOurTheming(viewer.model);
if (selectedZones.value.size === 0) {
- viewer.impl.invalidate(true);
+ try {
+ viewer.impl.invalidate(true);
+ } catch {}
return;
}
const floorKey = activeFloor.value;
@@ -1038,29 +1081,25 @@ function rebuildZoneThemingForCurrentFloor() {
const sortedRooms = [...rooms].sort((a, b) => a.codeN - b.codeN);
ensureZoneMapForFloor(viewer.model, floorKey, floorRootId);
const zoneMap = ROOM_ZONE_MAP[floorKey];
-
const chosen = selectedZones.value;
for (const r of sortedRooms) {
const z = zoneMap.get(r.code3);
if (!z || !chosen.has(z)) continue;
setRoomTheming(r.id, z === "一般" ? THEME_PURPLE : THEME_YELLOW);
}
- viewer.impl.invalidate(true, true, true);
+ try {
+ viewer.impl.invalidate(true, true, true);
+ } catch {}
}
/** ===================== 返回模型初始視角 ===================== */
async function goHome() {
if (!viewer) return;
-
- // 確保樓層先回到 1F(會重建 sprites 與隔離)
await applyFloor("1F");
-
- // 套回初始快照(相機/隔離/等),第三參數 immediate=true 立即更新
if (INITIAL_1F_STATE) {
try {
viewer.restoreState(INITIAL_1F_STATE, undefined, true);
} catch (e) {
- // 萬一還原失敗就退回 fitToView 方案
const id = FLOOR_DBIDS["1F"];
if (id != null) {
const ids = collectLeafsUnder(viewer.model, id);
@@ -1070,8 +1109,6 @@ async function goHome() {
}
}
}
-
- // 依舊重算 popover 與區塊上色(避免狀態還原與你自訂邏輯不同步)
rebuildLabelsAfterNextRender();
rebuildZoneThemingForCurrentFloor();
}
@@ -1106,11 +1143,10 @@ function publishForgeSnapshot() {
window.dispatchEvent(new CustomEvent("forge:snapshot", { detail: rows }));
}
-// 產生指定樓層的快照(不改視圖)
+/** ===================== 產生指定樓層的快照(不改視圖) ===================== */
async function snapshotRowsForFloor(floorKey) {
const floorRootId = FLOOR_DBIDS[floorKey];
if (!viewer || !viewer.model || floorRootId == null) return [];
-
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
const bedGroupIds = collectDbIdsByNameUnder(
@@ -1118,7 +1154,6 @@ async function snapshotRowsForFloor(floorKey) {
floorRootId,
BED_GROUP_KEYWORD
);
-
let bedNodes = [];
getDirectChildren(viewer.model, floorRootId); // 讓樹穩定
bedGroupIds.forEach((groupId) => {
@@ -1137,7 +1172,6 @@ async function snapshotRowsForFloor(floorKey) {
}
});
bedNodes = Array.from(new Set(bedNodes));
-
const THREE = getThree();
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
const sortedBeds = bedNodes
@@ -1149,7 +1183,6 @@ async function snapshotRowsForFloor(floorKey) {
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
})
.sort((a, b) => a.id - b.id);
-
const plan = NON_OCC_BY_FLOOR[floorKey] || {
vacant: 0,
hospitalized: 0,
@@ -1165,7 +1198,6 @@ async function snapshotRowsForFloor(floorKey) {
const leaveIds = new Set(
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
);
-
const rows = [];
let globalIndex = 0;
for (const { id: bedId, box: bedBox } of sortedBeds) {
@@ -1174,16 +1206,13 @@ async function snapshotRowsForFloor(floorKey) {
const roomGender = roomMatch?.gender ?? fallbackGen;
const roomLabel = roomMatch?.name ?? "?";
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
-
let status = "occupied";
if (vacantIds.has(bedId)) status = "vacant";
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
else if (leaveIds.has(bedId)) status = "leave";
-
const bedNodeName =
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
const bedKey = bedKeyFromNodeName(bedNodeName, roomCode) || "?";
-
const base = ensureResidentFor(bedKey, roomGender) || {};
const resident =
status === "vacant"
@@ -1203,9 +1232,7 @@ async function snapshotRowsForFloor(floorKey) {
: status === "leave"
? { status: "leave", specialEvent: "請假中", ...base }
: { status: "occupied", specialEvent: "-", ...base };
-
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
-
rows.push({
bed: bedKey,
status: resident.status,
@@ -1225,7 +1252,7 @@ async function snapshotRowsForFloor(floorKey) {
return rows;
}
-// 暴露到 global,供 operation 調用
+// 暴露 API
window.FORGE_API = {
getSnapshotForFloor: snapshotRowsForFloor,
getActiveFloor: () => activeFloor.value,
@@ -1234,36 +1261,23 @@ window.FORGE_API = {
await applyFloor(fk);
},
};
-
-// === 新增:聚焦指定床位並顯示 popover ===
window.FORGE_API.focusBed = async function focusBed(bedKey, opts = {}) {
try {
if (!viewer || !__staticDevices?.length) return false;
-
const bed = String(bedKey || "").trim();
if (!bed) return false;
-
- // 確認樓層,必要時切換(1xx→1F,2xx→2F)
const targetFloor = bed.charAt(0) === "2" ? "2F" : "1F";
if (activeFloor.value !== targetFloor) {
await applyFloor(targetFloor);
}
-
- // 找到對應 sprite
const dev = __staticDevices.find(
(d) => d.bedKey === bed || d.name?.includes(bed)
);
if (!dev) return false;
-
- // 讓 popover 篩選顯示到它(selectedInfo 要對上該 sprite 的 status)
- selectedInfo.value = dev.status; // 'occupied' | 'vacant' | 'hospitalized' | 'leave'
+ selectedInfo.value = dev.status;
soloSpriteId.value = dev.spriteDbId;
bringToFrontById(dev.spriteDbId);
-
- // 重新算 popover 位置
rebuildLabelsAfterNextRender();
-
- // 視角帶到該 sprite(可用 opts.fit=false 關掉)
if (opts.fit !== false) {
await cardfitToView({
forge_dbid: dev.forge_dbid,
@@ -1282,7 +1296,7 @@ window.FORGE_API.focusBed = async function focusBed(bedKey, opts = {}) {
function spriteColorBy(status, gender) {
if (status === "occupied") return gender === "男" ? BRAND_GREEN : BRAND_RED;
if (status === "vacant") return BRAND_GRAY;
- return BRAND_WHITE; // 住院/請假
+ return BRAND_WHITE;
}
function labelDotColor(L) {
const s = L?.data?.status;
@@ -1321,7 +1335,9 @@ async function showLevel(levelKey) {
if (levelDbId == null) {
viewer.showAll();
viewer.fitToView();
- viewer.impl.invalidate(true);
+ try {
+ viewer.impl.invalidate(true);
+ } catch {}
return;
}
const ids = collectLeafsUnder(viewer.model, levelDbId);
@@ -1335,7 +1351,9 @@ async function showLevel(levelKey) {
viewer.fitToView?.(ids, viewer.model);
}
viewer.setGhosting(false);
- viewer.impl.invalidate(true);
+ try {
+ viewer.impl.invalidate(true);
+ } catch {}
} catch (e) {
const all = new Set(allLeafIds(viewer.model));
ids.forEach((id) => all.delete(id));
@@ -1344,7 +1362,9 @@ async function showLevel(levelKey) {
toHide.forEach((id) => viewer.hide(id));
viewer.fitToView?.(ids, viewer.model);
viewer.setGhosting(false);
- viewer.impl.invalidate(true);
+ try {
+ viewer.impl.invalidate(true);
+ } catch {}
}
}
@@ -1363,11 +1383,7 @@ async function rebuildSpritesForFloor(floorKey) {
await createSprites();
return;
}
-
- // 1) 收集『房』資料(決定性別 & 分區)
const ROOMS = collectRoomsForFloor(viewer.model, floorKey, floorRootId);
-
- // 2) 找『【崇恩護家】床位』群組 → 展開每一張床
const BED_GROUP_KEYWORD = "【崇恩護家】床位";
const bedGroupIds = collectDbIdsByNameUnder(
viewer.model,
@@ -1390,8 +1406,6 @@ async function rebuildSpritesForFloor(floorKey) {
});
});
bedNodes = Array.from(new Set(bedNodes));
-
- // 3) 只留本層高度範圍 & 穩定排序
const floorBox = getNodeWorldBounds(viewer.model, floorRootId);
const sortedBeds = bedNodes
.map((id) => ({ id, box: getNodeWorldBounds(viewer.model, id) }))
@@ -1402,8 +1416,6 @@ async function rebuildSpritesForFloor(floorKey) {
return c.z >= floorBox.min.z - 0.5 && c.z <= floorBox.max.z + 0.5;
})
.sort((a, b) => a.id - b.id);
-
- // 4) 決定本層非佔床名單:vacant/hospitalized/leave
const plan = NON_OCC_BY_FLOOR[floorKey] || {
vacant: 0,
hospitalized: 0,
@@ -1419,8 +1431,6 @@ async function rebuildSpritesForFloor(floorKey) {
const leaveIds = new Set(
nonOccIds.slice(plan.vacant + plan.hospitalized, nonOccCount)
);
-
- // 5) 建立 sprites(以 bedKey + ensureResidentFor 寫死對應)
__staticDevices.length = 0;
let globalIndex = 0;
for (const { id: bedId, box: bedBox } of sortedBeds) {
@@ -1428,26 +1438,22 @@ async function rebuildSpritesForFloor(floorKey) {
bedBox.getCenter(c);
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);
-
const roomMatch = resolveRoomByBox(ROOMS, bedBox);
const fallbackGen = globalIndex % 2 === 0 ? "男" : "女";
const roomGender = roomMatch?.gender ?? fallbackGen;
const roomLabel = roomMatch?.name ?? "?";
const roomCode = roomMatch?.code3 ?? roomCodeOf(roomLabel);
-
let status = "occupied";
if (vacantIds.has(bedId)) status = "vacant";
else if (hospitalizedIds.has(bedId)) status = "hospitalized";
else if (leaveIds.has(bedId)) status = "leave";
-
const bedNodeName =
viewer.model.getInstanceTree()?.getNodeName(bedId) || "";
const m = String(bedNodeName).match(/\b(\d{3})(?:-(\d))?\b/);
const bedKey = m ? (m[2] ? `${m[1]}-${m[2]}` : m[1]) : roomCode || "?";
-
const base = ensureResidentFor(bedKey, roomGender) || {};
let resident;
- if (status === "vacant") {
+ if (status === "vacant")
resident = {
status: "vacant",
...base,
@@ -1459,18 +1465,14 @@ async function rebuildSpritesForFloor(floorKey) {
medicationStatus: "-",
note: "-",
};
- } else if (status === "hospitalized") {
+ else if (status === "hospitalized")
resident = { status: "hospitalized", specialEvent: "住院中", ...base };
- } else if (status === "leave") {
+ else if (status === "leave")
resident = { status: "leave", specialEvent: "請假中", ...base };
- } else {
- resident = { status: "occupied", specialEvent: "-", ...base };
- }
-
+ else resident = { status: "occupied", specialEvent: "-", ...base };
const zone = roomCode ? getZoneForRoom(floorKey, roomCode) ?? null : null;
const color = spriteColorBy(resident.status, roomGender);
const spriteDbId = (floorKey === "1F" ? 91100 : 92100) + globalIndex++;
-
__staticDevices.push({
name: `床號 ${bedKey}`,
forge_dbid: floorRootId,
@@ -1492,7 +1494,6 @@ async function rebuildSpritesForFloor(floorKey) {
zone,
});
}
-
await createSprites();
publishForgeSnapshot();
}
@@ -1589,7 +1590,6 @@ const filteredLabels = computed(() => {
});
});
const isVacantWithoutSpecial = (L) => L?.data?.status === "vacant";
-
const emit = defineEmits(["change"]);
const selectInfo = (opt) => {
soloSpriteId.value = null;
@@ -1597,7 +1597,6 @@ const selectInfo = (opt) => {
infoOpen.value = false;
emit("change", opt.value);
};
-
const infoTriggerRef = ref(null);
const infoPanelRef = ref(null);
const onClickOutsideInfo = (e) => {
@@ -1615,10 +1614,14 @@ const onKeydownInfo = (e) => {
async function applyFloor(next) {
soloSpriteId.value = null;
activeFloor.value = next;
- localStorage.setItem("uark-floor", next);
+ try {
+ localStorage.setItem("uark-floor", next);
+ } catch {}
await showLevel(next);
await rebuildSpritesForFloor(next);
- viewer.impl.invalidate(true);
+ try {
+ viewer.impl.invalidate(true);
+ } catch {}
rebuildLabelsAfterNextRender();
rebuildZoneThemingForCurrentFloor();
}
@@ -1633,7 +1636,6 @@ onMounted(async () => {
(function migrateResidentStore() {
let changed = false;
for (const [k, v] of RESIDENT_BY_BED.entries()) {
- // 只清理舊備援資料或 startTime 落在 2023/01/15 的
if (!v || v.startTime === "2023/01/15") {
const fixed = RESIDENTS_BY_BED?.[k];
if (fixed) {
@@ -1649,7 +1651,6 @@ onMounted(async () => {
note: fixed.note,
});
} else {
- // 沒有寫死表就用新的 seededStartTime
const g = v?.residentsSex || "男";
RESIDENT_BY_BED.set(k, {
...v,
@@ -1662,10 +1663,11 @@ onMounted(async () => {
}
if (changed) saveResidentStore(RESIDENT_BY_BED);
})();
-
document.addEventListener("click", onClickOutsideInfo);
document.addEventListener("keydown", onKeydownInfo);
activeFloor.value = resolveInitialFloor();
+ await nextTick();
+ await waitForContainerSize(forgeDom.value, { timeout: 4000 });
await initViewer(forgeDom.value);
const model = await loadModel(MODEL_SVF_PATH);
await waitObjectTree(model);
@@ -1683,9 +1685,20 @@ onMounted(async () => {
});
});
attachCameraEventsForLabels();
+ // 監聽容器 resize,避免 0x0 導致的 FBO incomplete
+ try {
+ _resizeObserver = new ResizeObserver(() => {
+ if (!viewer) return;
+ requestAnimationFrame(() => {
+ try {
+ viewer.resize();
+ viewer.impl.invalidate(true);
+ } catch {}
+ });
+ });
+ _resizeObserver.observe(forgeDom.value);
+ } catch {}
await applyFloor("1F");
-
- // 等一禎,確保 fitToView/隔離/labels 都完成再抓狀態(更穩定)
await new Promise((r) => {
const once = () => {
viewer.removeEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
@@ -1693,9 +1706,7 @@ onMounted(async () => {
};
viewer.addEventListener(Autodesk.Viewing.RENDER_PRESENTED_EVENT, once);
});
-
- INITIAL_1F_STATE = viewer.getState(); // 記錄整體 viewer 狀態做為 Home
-
+ INITIAL_1F_STATE = viewer.getState();
viewerReady.value = true;
});
@@ -1711,7 +1722,7 @@ watch(
onUnmounted(() => {
try {
- clearOurTheming(viewer.model);
+ clearOurTheming(viewer?.model);
} catch {}
try {
unbindSpriteClick?.();
@@ -1720,14 +1731,24 @@ onUnmounted(() => {
try {
clearSprites();
} catch {}
+ try {
+ _resizeObserver?.disconnect();
+ _resizeObserver = null;
+ } catch {}
if (viewer) {
try {
const ext = viewer.getExtension?.("Autodesk.DataVisualization");
ext?.removeAllViewables?.();
viewer.unloadExtension?.("Autodesk.DataVisualization");
} catch {}
- viewer.tearDown?.();
- viewer.finish?.();
+ try {
+ viewer.tearDown?.();
+ viewer.finish?.();
+ } catch {}
+ try {
+ viewer?.impl?.purgeVertexBufferPool?.();
+ viewer?.impl?.purgeCachedMaterials?.();
+ } catch {}
viewer = null;
}
document.removeEventListener("click", onClickOutsideInfo);
diff --git a/src/components/home/DietSummary.vue b/src/components/home/DietSummary.vue
index aaf9a7e..40177c0 100644
--- a/src/components/home/DietSummary.vue
+++ b/src/components/home/DietSummary.vue
@@ -145,25 +145,32 @@
"
tabindex="-1"
>
-
-
-
-
- {{ titleLabel }}
-
+
+
+
+
+ {{ titleLabel }}
+
-
+
+
+