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();