diff --git a/index.html b/index.html
index 8550d3a..9c4861b 100644
--- a/index.html
+++ b/index.html
@@ -382,7 +382,13 @@
showLoadError(`定位失敗:${e.message}`);
});
+ // ---- 狀態提示:error(紅框,真正錯誤)與 status(中性灰框,暫態提示)分開 ----
+ let errorControl = null;
+ let statusControl = null;
+
function showLoadError(msg) {
+ clearStatus(); // 真錯誤時優先顯示,蓋掉中性 banner
+ if (errorControl) { map.removeControl(errorControl); errorControl = null; }
const box = L.control({ position: 'topright' });
box.onAdd = () => {
const div = L.DomUtil.create('div');
@@ -391,63 +397,91 @@
return div;
};
box.addTo(map);
+ errorControl = box;
}
- fetch(API_URL, {
- method: 'POST',
- cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ lat: SCAN_CENTER.lat, lng: SCAN_CENTER.lng })
- })
- .then(res => {
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
- return res.json();
- })
- .then(json => {
- // 後端回 {source, items: [...]}; toList() 也相容頂層陣列的舊格式
- const list = toList(json);
- const markers = [];
+ function showStatus(msg) {
+ if (statusControl) { map.removeControl(statusControl); statusControl = null; }
+ const box = L.control({ position: 'topright' });
+ box.onAdd = () => {
+ const div = L.DomUtil.create('div');
+ div.style.cssText = 'background:#fff;border:1px solid #cbd5e1;color:#334155;padding:8px 12px;border-radius:6px;font-size:14px;box-shadow:0 1px 4px rgba(0,0,0,.15)';
+ div.textContent = msg;
+ return div;
+ };
+ box.addTo(map);
+ statusControl = box;
+ }
- for (const item of list) {
- const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
- const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
- if (!isNum(lat) || !isNum(lng)) continue;
+ function clearStatus() {
+ if (statusControl) { map.removeControl(statusControl); statusControl = null; }
+ }
- const icon = makeIcon(item);
- const marker = L.marker([lat, lng], { icon, title: `${item.name ?? ''} (#${item.id})` }).addTo(map);
- const isPerfect = !!item.is_perfect || item.iv_pct === 100;
- // 延遲到打開時再 build,讓剩餘秒數每次開 popup 都是即時的
- marker.bindPopup(() => buildPopup(item), { className: isPerfect ? 'perfect-popup' : '' });
+ // ---- 空資料自動重試(處理 API stale-while-revalidate cold-start 暫態)----
+ const RETRY_INTERVAL_MS = 8000;
+ const RETRY_MAX = 10; // 總上限 80 秒
+ let retryCount = 0;
+ let retryTimer = null;
+ let mapClickBound = false;
- // hover 顯示
- marker.on('mouseover', function(e) {
- if (pinnedPopup && pinnedPopup === this.getPopup()) return;
- this.openPopup();
- e.target._icon.classList.add('hovered');
- });
+ function clearRetry() {
+ if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
+ }
- // 離開關閉(未固定時)
- marker.on('mouseout', function(e) {
- if (pinnedPopup && pinnedPopup === this.getPopup()) return;
- this.closePopup();
- e.target._icon.classList.remove('hovered');
- });
+ function scheduleRetry() {
+ clearRetry();
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ loadScan();
+ }, RETRY_INTERVAL_MS);
+ }
- // 點擊固定 popup
- marker.on('click', function(e) {
- if (pinnedPopup && pinnedPopup !== this.getPopup()) {
- pinnedPopup._source.closePopup();
- pinnedPopup._source._icon.classList.remove('hovered');
- }
- this.openPopup();
- e.target._icon.classList.add('hovered');
- pinnedPopup = this.getPopup();
- });
+ // 頁面關閉/離開時清 timer,避免 leak
+ window.addEventListener('beforeunload', clearRetry);
- markers.push(marker);
- }
+ function renderItems(list) {
+ const markers = [];
+ for (const item of list) {
+ const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
+ const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
+ if (!isNum(lat) || !isNum(lng)) continue;
- // 點擊地圖其他地方 → 關閉固定 popup
+ const icon = makeIcon(item);
+ const marker = L.marker([lat, lng], { icon, title: `${item.name ?? ''} (#${item.id})` }).addTo(map);
+ const isPerfect = !!item.is_perfect || item.iv_pct === 100;
+ // 延遲到打開時再 build,讓剩餘秒數每次開 popup 都是即時的
+ marker.bindPopup(() => buildPopup(item), { className: isPerfect ? 'perfect-popup' : '' });
+
+ // hover 顯示
+ marker.on('mouseover', function(e) {
+ if (pinnedPopup && pinnedPopup === this.getPopup()) return;
+ this.openPopup();
+ e.target._icon.classList.add('hovered');
+ });
+
+ // 離開關閉(未固定時)
+ marker.on('mouseout', function(e) {
+ if (pinnedPopup && pinnedPopup === this.getPopup()) return;
+ this.closePopup();
+ e.target._icon.classList.remove('hovered');
+ });
+
+ // 點擊固定 popup
+ marker.on('click', function(e) {
+ if (pinnedPopup && pinnedPopup !== this.getPopup()) {
+ pinnedPopup._source.closePopup();
+ pinnedPopup._source._icon.classList.remove('hovered');
+ }
+ this.openPopup();
+ e.target._icon.classList.add('hovered');
+ pinnedPopup = this.getPopup();
+ });
+
+ markers.push(marker);
+ }
+
+ // 點擊地圖其他地方 → 關閉固定 popup(整個頁面生命週期只綁一次)
+ if (!mapClickBound) {
map.on('click', function() {
if (pinnedPopup) {
pinnedPopup._source.closePopup();
@@ -455,19 +489,59 @@
pinnedPopup = null;
}
});
+ mapClickBound = true;
+ }
- if (markers.length > 0) {
- const group = L.featureGroup(markers);
- map.fitBounds(group.getBounds().pad(0.2));
- } else {
- // API stale-while-revalidate:快取剛過期時會先回空陣列,幾十秒後再打才有資料
- showLoadError('目前無資料,稍後再試');
- }
+ if (markers.length > 0) {
+ const group = L.featureGroup(markers);
+ map.fitBounds(group.getBounds().pad(0.2));
+ }
+ return markers.length;
+ }
+
+ function loadScan() {
+ fetch(API_URL, {
+ method: 'POST',
+ cache: 'no-cache',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ lat: SCAN_CENTER.lat, lng: SCAN_CENTER.lng })
})
- .catch(err => {
- console.error('讀取掃描資料失敗:', err);
- showLoadError(`掃描資料載入失敗:${err.message}`);
- });
+ .then(res => {
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
+ return res.json();
+ })
+ .then(json => {
+ // 後端回 {source, items: [...]}; toList() 也相容頂層陣列的舊格式
+ const list = toList(json);
+
+ if (list.length === 0) {
+ // 空資料視為暫態:Redis 剛過期、背景 fetcher 正在重建
+ retryCount++;
+ if (retryCount <= RETRY_MAX) {
+ showStatus(`正在更新資料,請稍候…(${retryCount}/${RETRY_MAX})`);
+ scheduleRetry();
+ } else {
+ clearRetry();
+ showStatus('後端仍無資料,請稍後重新整理');
+ }
+ return;
+ }
+
+ // 有資料:停 polling、清 banner、渲染
+ clearRetry();
+ clearStatus();
+ retryCount = 0;
+ renderItems(list);
+ })
+ .catch(err => {
+ // 真正的 fetch 失敗(網路/HTTP/JSON parse)→ 紅框,不自動 retry
+ clearRetry();
+ console.error('讀取掃描資料失敗:', err);
+ showLoadError(`掃描資料載入失敗:${err.message}`);
+ });
+ }
+
+ loadScan();