fix: 修正首頁地圖展開卡頓

This commit is contained in:
MJM_2025_05\polly 2025-09-23 13:17:25 +08:00
parent 5cc0dcbf56
commit 538d00d070
15 changed files with 290 additions and 243 deletions

View File

@ -14,7 +14,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>諾亞克 U-ARK 戰情中心</title>
</head>
<body class="w-screen h-[1200px] bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter font-noto text-brand-black">
<body class="font-noto text-brand-black">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

View File

@ -1,5 +1,5 @@
<template>
<section>
<section class="bg-gradient-to-br from-brand-green-lighter via-brand-gray-lighter to-brand-purple-lighter">
<!-- 依據 route.meta.layout 動態載入對應 Layout -->
<component :is="layoutComponent" />
</section>

View File

@ -258,7 +258,7 @@
</div>
</div>
<!-- 中央 Modal病患資訊 -->
<!-- 中央 Modal住民資訊 -->
<div
v-if="modalOpen"
class="fixed inset-0 z-[120] flex items-center justify-center"
@ -276,7 +276,7 @@
<!-- 標題 -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold tracking-widest">病患資訊</h3>
<h3 class="text-lg font-semibold tracking-widest">住民資訊</h3>
</div>
<div class="flex justify-end items-center gap-4">
<a
@ -342,7 +342,7 @@ import {
} from "@/constants/mocks/facilityData";
let INITIAL_1F_STATE = null; // 1F //
let INITIAL_1F_CAMERA = null; //
let INITIAL_1F_CAMERA = null; //
/** ===================== Tabs樓層/狀態/分區 ===================== */
const floorTabItems = [
@ -476,6 +476,7 @@ const activeFloor = ref("1F");
let unbindSpriteClick = null;
let THREE = null;
let _resizeObserver = null; // ResizeObserver
const {
updateDataVisualization: dvInit,
@ -527,10 +528,11 @@ try {
function loadResidentStore() {
try {
return new Map(
JSON.parse(localStorage.getItem(RESIDENT_STORE_KEY) || "[]")
);
} catch {
const raw = localStorage.getItem(RESIDENT_STORE_KEY);
if (!raw) return new Map();
return new Map(JSON.parse(raw));
} catch (err) {
console.warn("[Forge] loadResidentStore failed:", err);
return new Map();
}
}
@ -561,8 +563,6 @@ function bedKeyFromNodeName(name, fallbackCode3) {
function ensureResidentFor(bedKey, genderHint) {
const key = String(bedKey || "").trim();
if (!key) return null;
// localStorage
const fixed = RESIDENTS_BY_BED && RESIDENTS_BY_BED[key];
if (fixed) {
const rec = {
@ -580,15 +580,11 @@ function ensureResidentFor(bedKey, genderHint) {
saveResidentStore(RESIDENT_BY_BED);
return rec;
}
// localStorage
const existed = RESIDENT_BY_BED.get(key);
if (existed) {
if (!existed.residentsSex && genderHint) existed.residentsSex = genderHint;
return existed;
}
//
const g = genderHint === "女" ? "女" : "男";
const pool = g === "男" ? NAME_POOL_MALE : NAME_POOL_FEMALE;
const idx = hashFNV1a(key) % pool.length;
@ -610,7 +606,6 @@ function ensureResidentFor(bedKey, genderHint) {
/** ===================== 分區映射(房號 → 一般/氧氣) ===================== */
const ROOM_ZONE_MAP = { "1F": new Map(), "2F": new Map() };
// 3 沿
const ZONE_STORE_KEY = "uark-room-zone-v1";
function loadZoneStore() {
try {
@ -624,14 +619,11 @@ function saveZoneStore(obj) {
localStorage.setItem(ZONE_STORE_KEY, JSON.stringify(obj));
} catch {}
}
// ROOM_ZONE_MAP
function ensureZoneMapForFloor(model, floorKey, floorRootId) {
const store = loadZoneStore();
const floorStore = store[floorKey] || {};
const tree = model.getInstanceTree?.();
if (!tree || floorRootId == null) return ROOM_ZONE_MAP[floorKey];
// 3
const seen = new Set();
const rooms = [];
const code3For = (name) => {
@ -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;
// 1xx1F2xx2F
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);

View File

@ -145,25 +145,32 @@
"
tabindex="-1"
>
<!-- 標題列 + Tabs -->
<div class="flex items-center justify-between mb-4 gap-3">
<div class="flex justify-start items-center gap-6">
<h3
:id="modalTitleId"
class="text-2xl font-semibold tracking-widest"
>
{{ titleLabel }}
</h3>
<!-- 標題列 + Tabs置中 -->
<div class="relative flex items-center justify-between mb-4 gap-3">
<!-- 標題 -->
<h3
:id="modalTitleId"
class="text-2xl font-semibold tracking-widest"
>
{{ titleLabel }}
</h3>
<Tabs
v-model="menuTab"
:items="tabItems"
:showPrefix="false"
:defaultSelectFirst="true"
aria-label="菜單篩選"
/>
<!-- Tabs 絕對置中 -->
<div
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<div class="pointer-events-auto">
<Tabs
v-model="menuTab"
:items="tabItems"
:showPrefix="false"
:defaultSelectFirst="true"
aria-label="菜單篩選"
/>
</div>
</div>
<!-- 關閉按鈕 -->
<button
ref="closeBtnRef"
class="px-3 py-1 rounded-md hover:bg-gray-100"

View File

@ -2,8 +2,8 @@
<section
class="relative bg-white/30 rounded-md shadow p-3 min-h-0 lg:h-full flex flex-col overscroll-contain"
>
<!-- 顯示資訊切換條置頂置中 -->
<div class="sticky top-6 z-[50] h-0">
<!-- 顯示資訊切換條固定置頂置中 -->
<div class="fixed top-[100px] left-1/2 -translate-x-1/2 z-[50]">
<div class="relative pointer-events-none flex justify-center w-full">
<Tabs
:items="tabItems"
@ -24,11 +24,12 @@
<!-- 地圖本體 -->
<div
ref="mapEl"
class="relative z-[0] w-full flex-1 rounded-md overflow-hidden min-h-[300px] md:min-h-[360px] lg:min-h-[420px] overscroll-contain touch-none select-none"
class="relative z-[0] w-full flex-1 rounded-md overflow-hidden min-h-[300px] md:min-h-[360px] lg:min-h-[420px] overscroll-contain touch-none select-none contain-layout contain-paint"
></div>
</section>
</template>
<script setup>
/**
* MapPane.vue
@ -141,6 +142,29 @@ let map = null;
let markers = [];
let ro = null; // ResizeObserver
let rafId = 0; // rAF id
let coolId = 0;
function doInvalidate() {
if (map) {
map.invalidateSize();
refreshAllTooltips();
}
}
function onHostResize() {
// ~48ms
if (coolId) clearTimeout(coolId);
coolId = setTimeout(() => {
coolId = 0;
// rAF
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
rafId = 0;
doInvalidate();
});
}, 48);
}
defineExpose({ invalidate: doInvalidate });
// Leaflet icon
const base = import.meta.env.BASE_URL ?? "/";
@ -348,25 +372,6 @@ function syncTooltips() {
}
}
// 6) invalidate
function doInvalidate() {
if (map) {
map.invalidateSize();
refreshAllTooltips();
}
}
//
defineExpose({ invalidate: doInvalidate });
function onHostResize() {
// rAF RO transition
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
doInvalidate();
});
}
// 7) Lifecycle
onMounted(() => {
if (!mapEl.value) return;
@ -405,6 +410,9 @@ onMounted(() => {
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 20,
updateWhenIdle: true, // 使
updateWhenZooming: false, //
keepBuffer: 4, // tile
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
@ -437,12 +445,8 @@ onMounted(() => {
// invalidate
requestAnimationFrame(() => {
const m = map;
m?.invalidateSize?.();
requestAnimationFrame(() => {
m?.invalidateSize?.();
refreshAllTooltips();
});
map?.invalidateSize?.();
refreshAllTooltips();
});
// tooltip
@ -450,14 +454,6 @@ onMounted(() => {
map.on("moveend", refreshAllTooltips);
map.on("resize", refreshAllTooltips);
//
const onResize = () => {
map?.invalidateSize?.();
refreshAllTooltips();
};
window.addEventListener("resize", onResize);
map.__onResize = onResize;
// transition
if (mapEl.value && "ResizeObserver" in window) {
ro = new ResizeObserver(onHostResize);
@ -466,7 +462,18 @@ onMounted(() => {
});
onBeforeUnmount(() => {
if (map?.__onResize) window.removeEventListener("resize", map.__onResize);
if (coolId) {
clearTimeout(coolId);
coolId = 0;
}
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
if (ro) {
ro.disconnect();
ro = null;
}
if (map) {
map.off("zoomend", refreshAllTooltips);
map.off("moveend", refreshAllTooltips);
@ -474,14 +481,6 @@ onBeforeUnmount(() => {
map.remove();
map = null;
}
if (ro) {
ro.disconnect();
ro = null;
}
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
});
// 8) Watchers tooltip

View File

@ -30,7 +30,7 @@
></progress>
<span
class="w-full absolute top-1 bottom-2 flex justify-between items-center text-[24px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
class="w-full absolute top-1 bottom-2 flex justify-between items-center text-[22px] font-nats pe-5 text-brand-gray group-hover:text-brand-purple-dark left-2"
>
{{ animatedValueLocale }} / {{ totalLocale }}
<span aria-hidden="true">

View File

@ -100,7 +100,7 @@ const closeCalendar = () => (isCalendarOpen.value = false);
>{{ currentPage }} / {{ totalPages }}</span
>
<button
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-brand-gray disabled:cursor-not-allowed"
class="btn btn-sm btn-outline border-brand-purple-dark text-brand-purple-dark hover:bg-brand-purple hover:text-white hover:border-brand-purple disabled:!opacity-100 disabled:!text-gray-300 disabled:!border-gray-300 disabled:cursor-not-allowed"
:disabled="currentPage === totalPages"
@click="currentPage = Math.min(totalPages, currentPage + 1)"
>
@ -111,7 +111,6 @@ const closeCalendar = () => (isCalendarOpen.value = false);
<!-- 行事曆 ModalTeleport body避免影響布局 -->
<Teleport to="body">
<!-- 這裡若曾因 <transition> 影響佈局已用 Teleport 解耦不會壓縮原區塊 -->
<transition name="fade">
<div
v-if="isCalendarOpen"

View File

@ -1,7 +1,11 @@
<template>
<!-- 左中圖表 -->
<section class="row-span-4 bg-white/30 backdrop-blur-sm rounded-md shadow min-h-0 flex items-stretch">
<div class="w-full h-full p-3 sm:p-4 md:p-6 min-h-[200px] sm:min-h-[240px] md:min-h-[300px]">
<section
class="row-span-4 bg-white/30 backdrop-blur-sm rounded-md shadow min-h-0 flex items-stretch"
>
<div
class="w-full h-full min-h-[200px] p-8 sm:min-h-[240px] md:min-h-[300px]"
>
<div ref="chartEl" class="w-full h-full"></div>
</div>
</section>
@ -21,6 +25,7 @@ const props = defineProps({
const chartEl = ref(null);
let chart = null;
let ro = null; // ResizeObserver
// 7
function last7DaysLabels() {

View File

@ -1,3 +1,4 @@
// ========= 型別註解 =========
/** @typedef {Object} ResidentFixed
* @property {string} name
* @property {('男'|'女')} sex
@ -7,10 +8,9 @@
* @property {string} medicationStatus
* @property {string} specialEvent
* @property {string} note
*
*/
// ========== 固定姓名池A 機構床號 74 → 男/女各 37==========
// ========= 固定姓名池A 機構床號 74 → 男/女各 37 =========
export const NAME_POOL_MALE = Object.freeze([
"王建中",
"張家豪",
@ -91,13 +91,8 @@ export const NAME_POOL_FEMALE = Object.freeze([
"簡琬淳",
]); // 長度必為 37
// ========== 每床固定住民:設定區(請依模型實際床位調整) ==========
// 目標:男/女各 37 張床 → 總計 74 床。以下僅為範例配置:
// - 1F 男101/103/107/109/111/113/115各 3 床 = 21
// - 2F 男201/203/205/207/209各 3 床 = 15+ 211-1補 1 床)
// - 1F 女102/105/112/114/116各 3 床 = 15
// - 2F 女202/206/208/210/212/216/204各 3 床 = 21+ 214-1補 1 床)
// ========= 每床固定住民:設定區(請依模型實際床位調整) =========
// 目標:男/女各 37 張床 → 總計 74 床。
export const A_FACILITY_BEDS_MALE = Object.freeze([
// 1F
"101-1",
@ -182,7 +177,7 @@ export const A_FACILITY_BEDS_FEMALE = Object.freeze([
"214-1",
]); // 37
// 可選:錯誤防護(開發期)
// ========= DEV 警告 =========
const __DEV__ =
(typeof import.meta !== "undefined" &&
import.meta.env &&
@ -213,7 +208,7 @@ if (__DEV__) {
);
}
// ========== 多樣化健康狀況詞庫(循環取用) ==========
// ========= 多樣化健康狀況詞庫(循環取用) =========
export const HEALTH_STATUS_POOL = Object.freeze([
"一般",
"慢性病監測",
@ -227,14 +222,13 @@ export const HEALTH_STATUS_POOL = Object.freeze([
"營養補充",
]);
// ========== 穩定年齡產生器(非隨機、可預測) ==========
// ========= 穩定年齡產生器(非隨機、可預測) =========
export function ageFromBedKey(key, base = 66) {
const n = parseInt(String(key).replace("-", ""), 10) || 0;
return base + (n % 30); // 66..95
}
// ========== 建立固定住民表 ==========
// 工具:格式化成 YYYY/MM/DD
// ========= 小工具:日期 =========
function fmtYMD(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@ -242,15 +236,14 @@ function fmtYMD(d) {
return `${y}/${m}/${day}`;
}
// 工具:依床位與索引產生「穩定且分散」的入住日期
// 依床位與索引產生「穩定且分散」的入住日期
// 規則:以 2025/01/01 為起點,往後偏移 0~540 天(約 18 個月)
// 若超過今天,會往回扣 (n % 60 + 10) 天,確保不會是未來日期
// 若超過今天,往回扣 (n % 60 + 10) 天,避免未來日期
function seededStartTime(bedKey, idx) {
const n = parseInt(String(bedKey).replace("-", ""), 10) || 0;
const base = new Date(2025, 0, 1); // 2025/01/01
const OFFSET_DAYS = (n * 13 + idx * 17) % 540; // 分散 18 個月內
let d = new Date(base.getTime() + OFFSET_DAYS * 86400000);
const today = new Date();
if (d > today) {
const back = (n % 60) + 10; // 至少退 10 天,最多 ~70 天
@ -269,7 +262,7 @@ function buildFixedResidents(keys, names, sex) {
sex,
age: ageFromBedKey(k, sex === "女" ? 65 : 66),
healthStatus: HEALTH_STATUS_POOL[n % HEALTH_STATUS_POOL.length],
startTime: seededStartTime(k, i), // ← 用分散演算法產生
startTime: seededStartTime(k, i), // 分散演算法產生
medicationStatus: "規律服藥",
specialEvent: "-",
note: "-",
@ -287,42 +280,44 @@ export const RESIDENTS_BY_BED = Object.freeze({
...buildFixedResidents(A_FACILITY_BEDS_FEMALE, NAME_POOL_FEMALE, "女"),
});
// ========== 總部與 A~F 機構資料(固定對齊 progress/charts==========
// ========= 總部與 A~F 機構資料(對齊 progress/charts 最後一天) =========
export const BASE_FACILITY_DATA = {
ALL: {
desc: "OO長期照顧集團——在全台各地皆有眾多據點,整合醫護團隊與專業照護人員,提供高齡照護服務總部。",
progress: {
residents: { current: 1216, total: 3600 },
vacancy: { current: 2384, total: 3600 },
hospitalized: { current: 8, total: 50 },
movein: { current: 3, total: 50 },
moveout: { current: 8, total: 50 },
residents: { current: 269, total: 361 },
vacancy: { current: 58, total: 361 },
hospitalized: { current: 5, total: 89 },
movein: { current: 13, total: 63 },
moveout: { current: 10, total: 64 },
},
charts: {
// 住民與空床b 固定為立案床數361a 最後一筆對齊 current
residents: {
legends: ["現在住民", "全立案床數"],
a: [1188, 1195, 1201, 1206, 1210, 1213, 1216],
b: Array(7).fill(3600),
a: [263, 264, 265, 266, 267, 268, 269],
b: Array(7).fill(361),
},
vacancy: {
legends: ["目前空床數", "全立案床數"],
a: [2412, 2405, 2399, 2394, 2390, 2387, 2384],
b: Array(7).fill(3600),
a: [64, 63, 62, 61, 60, 59, 58],
b: Array(7).fill(361),
},
// 累積圖b 最後一筆對齊 totala 最後一筆對齊 current解讀為「今日」
hospitalized: {
legends: ["今日住院", "當月累積住院"],
a: [2, 1, 3, 2, 1, 3, 8],
b: [8, 14, 21, 27, 33, 42, 50],
a: [0, 1, 1, 2, 1, 2, 5],
b: [10, 20, 35, 45, 60, 75, 89],
},
movein: {
legends: ["今日入住", "當月累積入住"],
a: [1, 0, 2, 1, 1, 2, 3],
b: [6, 10, 16, 21, 28, 41, 50],
a: [1, 1, 2, 2, 2, 3, 13],
b: [10, 20, 30, 40, 50, 55, 63],
},
moveout: {
legends: ["今日結案", "當月累積結案"],
a: [1, 2, 1, 2, 3, 3, 8],
b: [7, 12, 18, 23, 31, 42, 50],
a: [1, 1, 1, 2, 2, 3, 10],
b: [8, 15, 25, 35, 45, 55, 64],
},
},
diet: {
@ -343,7 +338,6 @@ export const BASE_FACILITY_DATA = {
moveout: { current: 3, total: 20 },
},
charts: {
// 圖表最後一天需對齊 progress住民 64空床 10
residents: {
legends: ["現在住民", "立案床數"],
a: [60, 61, 62, 63, 63, 64, 64],
@ -370,8 +364,6 @@ export const BASE_FACILITY_DATA = {
b: [4, 6, 9, 11, 13, 17, 20],
},
},
// diet 會在頁面端以 normalize 工具依 residents.current=64 切分60/40 基準)
// 這裡先給一組可讀值以利右側對照
diet: {
meat: { total: 38, normal: 33, soft: 2, tube: 3 },
veg: { total: 26, normal: 22, soft: 2, tube: 2 },
@ -419,6 +411,7 @@ export const BASE_FACILITY_DATA = {
veg: { total: 61, normal: 53, soft: 5, tube: 3 },
},
},
"C 機構": {
desc: "C 機構強調復健與社會參與陪伴長者建立日常節奏Lorem ipsum dolor sit amet consectetur adipisicingelit。",
progress: {
@ -460,6 +453,7 @@ export const BASE_FACILITY_DATA = {
veg: { total: 71, normal: 62, soft: 5, tube: 4 },
},
},
"D 機構": {
desc: "D 機構導入智慧照護設備提升照護品質與效率Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
progress: {
@ -501,6 +495,7 @@ export const BASE_FACILITY_DATA = {
veg: { total: 51, normal: 44, soft: 3, tube: 5 },
},
},
"E 機構": {
desc: "E 機構著重個別化照護計畫維護長者尊嚴與生活品質Lorem ipsum dolor sit amet, consectetur adipisicing elit。",
progress: {
@ -542,6 +537,7 @@ export const BASE_FACILITY_DATA = {
veg: { total: 57, normal: 49, soft: 5, tube: 3 },
},
},
"F 機構": {
desc: "F 機構以社區融入為核心串連日照與居家照護資源Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, enim。",
progress: {
@ -587,14 +583,11 @@ export const BASE_FACILITY_DATA = {
// ====== Nursing 待辦:與 Forge 對齊的示範假資料(用 RESIDENTS_BY_BED 反查姓名) ======
/** @typedef {{date:string, bed:string, name:string, type:string, desc:string}} NursingTodoSeed */
export const NURSING_TODOS_SEED = Object.freeze(
(() => {
const getName = (bed) => RESIDENTS_BY_BED?.[bed]?.name || "-";
/** @type {NursingTodoSeed[]} */
const seeds = [
// (a) 你指定的三筆,姓名會由 RESIDENTS_BY_BED 對應床位自動帶入,確保與 Forge 一致
{
date: "2025/09/21",
bed: "201-1",

View File

@ -1,5 +1,5 @@
<template>
<section id="app" class="flex flex-col min-h-screen">
<section id="app" class="flex flex-col min-h-screen overflow-y-hidden">
<NavBar class="fixed" />
<main
class="w-full p-4 pt-[80px]"

View File

@ -1,8 +1,9 @@
<template>
<section id="app" class="flex flex-col min-h-screen">
<section id="app" class="flex flex-col min-h-[1200px]">
<NavBar />
<main class="w-full p-4 pt-[80px] overflow-x-hidden">
<RouterView class="h-[calc(100vh-72px-40px)]" />
<main class="w-full p-4 pt-[80px] overflow-x-hidden flex-1 min-h-0">
<!-- 這裡改成 min-h 而不是 h避免固定死 -->
<RouterView class="block min-h-[calc(100vh-72px-40px)]" />
</main>
</section>
</template>

View File

@ -17,7 +17,6 @@ async function bootstrap() {
const app = createApp(App);
app.use(createPinia());
app.use(createPinia());
app.use(router);
app.mount("#app");

View File

@ -82,7 +82,7 @@
<div class="h-full min-h-0 flex flex-col lg:flex-row gap-2">
<!-- 地圖flex-basis 做寬度動畫 -->
<div
class="relative h-[420px] lg:h-full min-w-0 will-change-[flex-basis] transition-[flex-basis] duration-400 ease-in-out basis-full lg:basis-1/2"
class="relative h-[420px] lg:h-full min-w-0 will-change-[flex-basis] transition-[flex-basis] duration-300 ease-out"
:class="isRightCollapsed ? 'lg:basis-full' : 'lg:basis-1/2'"
>
<!-- caret 按鈕 -->
@ -134,13 +134,12 @@
<transition name="slide-pane" appear>
<aside
v-show="!isRightCollapsed"
class="h-full min-h-0 overflow-hidden min-w-0 will-change-[flex-basis,opacity] transition-[flex-basis,opacity] duration-400 ease-in-out basis-full lg:basis-1/2"
class="h-full min-h-0 overflow-hidden min-w-0 will-change-[flex-basis,opacity] transition-[flex-basis,opacity] duration-300 ease-out"
:class="
isRightCollapsed
? 'lg:basis-0 opacity-0 pointer-events-none'
: 'lg:basis-1/2 opacity-100 pointer-events-auto'
"
:aria-hidden="isRightCollapsed ? 'true' : 'false'"
>
<div
class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-2 h-full"

View File

@ -156,6 +156,10 @@ body,
#app {
height: 100%;
}
html {
scrollbar-gutter: stable both-edges;
}
body {
margin: 0;
width: 100%; /* 別用 100vw */

View File

@ -1,45 +1,65 @@
// src/utils/versionGuard.js
const VERSION_URL = '/version.json';
const LS_KEY = '__app_version__';
const SESSION_REFRESH_FLAG = '__just_refreshed__';
const VERSION_URL = "/version.json";
const LS_KEY = "__app_version__";
const SESSION_REFRESH_FLAG = "__just_refreshed__";
async function fetchRemoteVersion() {
const res = await fetch(VERSION_URL, { cache: 'no-store' }); // ★ 關鍵:不快取
if (!res.ok) throw new Error('version.json fetch failed');
// 關鍵no-store確保抓到最新
const res = await fetch(VERSION_URL, { cache: "no-store" });
if (!res.ok) throw new Error("version.json fetch failed");
return res.json();
}
async function unregisterAllSW() {
if (!('serviceWorker' in navigator)) return;
if (!("serviceWorker" in navigator)) return;
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister()));
await Promise.all(regs.map((r) => r.unregister()));
}
async function clearCacheStorage() {
if (!('caches' in window)) return;
if (!("caches" in window)) return;
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
await Promise.all(keys.map((k) => caches.delete(k)));
}
export async function ensureFreshAssets() {
try {
// 若剛做過強制刷新,這次進來先把旗標清掉,避免反覆觸發
if (sessionStorage.getItem(SESSION_REFRESH_FLAG) === "1") {
sessionStorage.removeItem(SESSION_REFRESH_FLAG);
// 不直接 return仍然讓它跑一次 fetch 以同步 LS 版本(更保險)
}
const prev = localStorage.getItem(LS_KEY);
const remote = await fetchRemoteVersion();
const ver = remote.version || remote.buildId || remote.commit;
if (!ver) return;
if (prev && prev !== ver) {
await unregisterAllSW();
await clearCacheStorage();
sessionStorage.setItem(SESSION_REFRESH_FLAG, '1');
// 用 replace 避免回上一頁又載到舊 index.html
location.replace(location.href.split('#')[0]);
if (!prev) {
// 首次載入:寫入版本後直接使用
localStorage.setItem(LS_KEY, ver);
return;
}
if (!prev) localStorage.setItem(LS_KEY, ver);
if (prev !== ver) {
// 在重整前,先把「新版本」寫回 localStorage
localStorage.setItem(LS_KEY, ver);
// 清 SW 與 Cache若有
await unregisterAllSW();
await clearCacheStorage();
// 打一個旗標避免連續重整
sessionStorage.setItem(SESSION_REFRESH_FLAG, "1");
// 用 replace 避免使用者回上一頁又遇到舊頁
location.replace(location.href.split("#")[0]);
return;
}
// 版本相同 → 不做事
} catch (e) {
console.warn('[versionGuard] check failed:', e);
// 若抓不到 version.json避免造成空白頁循環
console.warn("[versionGuard] check failed:", e);
}
}