在地圖右下角顯示後端 /scan response 新增的 generated_at 欄位, 以中文相對時間呈現(剛剛 / N 分鐘前 / N 小時前 / 絕對日期), 每 10 秒自動重算顯示,資料空或缺欄位時保留上次值。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
619 lines
22 KiB
HTML
619 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="zh-Hant" translate="no">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Leaflet JSON 標記範例(hover+固定+靜默複製)</title>
|
||
<meta http-equiv="Content-Language" content="zh-Hant" />
|
||
<meta name="google" content="notranslate" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<style>
|
||
@font-face {
|
||
font-family: 'LINE Seed TW';
|
||
src: url('https://cdn.jsdelivr.net/npm/@fontpkg/line-seed-tw-otf@1.400.0/LINESeedTW_OTF_Rg.otf') format('opentype');
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
}
|
||
@font-face {
|
||
font-family: 'LINE Seed TW';
|
||
src: url('https://cdn.jsdelivr.net/npm/@fontpkg/line-seed-tw-otf@1.400.0/LINESeedTW_OTF_Bd.otf') format('opentype');
|
||
font-weight: 700;
|
||
font-display: swap;
|
||
}
|
||
|
||
html, body, #map { height: 100%; margin: 0; }
|
||
body, .leaflet-container {
|
||
font-family: 'LINE Seed TW', 'PingFang TC', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
|
||
}
|
||
|
||
/* Popup 外框:圓角 + 柔和陰影,去掉預設白邊 */
|
||
.leaflet-popup-content-wrapper {
|
||
border-radius: 14px;
|
||
box-shadow: 0 10px 32px rgba(15, 23, 42, 0.14), 0 2px 6px rgba(15, 23, 42, 0.06);
|
||
padding: 2px;
|
||
overflow: hidden;
|
||
}
|
||
/* 完美體 popup:淡金色漸層背景 + 細金邊 */
|
||
.leaflet-popup.perfect-popup .leaflet-popup-content-wrapper {
|
||
background: linear-gradient(155deg, #fffbe6 0%, #fff 55%);
|
||
box-shadow: 0 10px 32px rgba(180, 138, 0, 0.22), 0 2px 6px rgba(180, 138, 0, 0.08), inset 0 0 0 1px rgba(245, 196, 78, 0.5);
|
||
}
|
||
.leaflet-popup.perfect-popup .leaflet-popup-tip {
|
||
background: #fffbe6;
|
||
}
|
||
.leaflet-popup-tip {
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||
}
|
||
.leaflet-popup-content {
|
||
margin: 14px 18px 12px;
|
||
font-size: 13.5px;
|
||
line-height: 1.55;
|
||
min-width: 220px;
|
||
color: #1f2937;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.popup-title {
|
||
font-size: 15.5px;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 10px;
|
||
padding-right: 24px;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
color: #0f172a;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.popup-title .avatar {
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle at 50% 45%, #e0f2fe 0%, #bae6fd 100%);
|
||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5), 0 2px 8px rgba(56, 189, 248, 0.25);
|
||
flex-shrink: 0;
|
||
object-fit: contain;
|
||
padding: 2px;
|
||
}
|
||
.leaflet-popup.perfect-popup .popup-title .avatar {
|
||
background: radial-gradient(circle at 50% 50%, #fff4c2 0%, #ffe38a 100%);
|
||
box-shadow: 0 0 0 1px #f5c44e, 0 2px 8px rgba(245, 196, 78, 0.4);
|
||
}
|
||
.popup-title .name {
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6px;
|
||
line-height: 1.2;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.popup-title .name .gender { color: #64748b; font-weight: 400; }
|
||
|
||
/* Leaflet 預設關閉鈕太擠在角落,往內縮一點並柔化顏色 */
|
||
.leaflet-popup-close-button {
|
||
top: 8px !important;
|
||
right: 10px !important;
|
||
width: 22px !important;
|
||
height: 22px !important;
|
||
font-size: 20px !important;
|
||
line-height: 20px !important;
|
||
color: #94a3b8 !important;
|
||
font-weight: 400 !important;
|
||
border-radius: 50%;
|
||
transition: color 0.12s, background 0.12s;
|
||
}
|
||
.leaflet-popup-close-button:hover {
|
||
color: #1f2937 !important;
|
||
background: #f1f5f9;
|
||
}
|
||
.popup-title .id {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
}
|
||
.popup-row {
|
||
display: grid;
|
||
grid-template-columns: 72px 1fr;
|
||
column-gap: 8px;
|
||
align-items: baseline;
|
||
margin: 3px 0;
|
||
}
|
||
.popup-row .k { color: #6b7280; font-weight: 700; }
|
||
.popup-row .v { color: #111827; }
|
||
.iv-100 { color: #b45309; font-weight: 700; }
|
||
.iv-high { color: #047857; font-weight: 700; }
|
||
.iv-mid { color: #b45309; }
|
||
.iv-low { color: #6b7280; }
|
||
.expired { color: #dc2626; font-weight: 700; }
|
||
.coord-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 6px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid #e5e7eb;
|
||
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
|
||
font-size: 12.5px;
|
||
}
|
||
.copy-btn {
|
||
cursor: pointer;
|
||
padding: 3px 9px;
|
||
border-radius: 6px;
|
||
background: #f1f5f9;
|
||
color: #334155;
|
||
font-size: 11.5px;
|
||
font-family: 'LINE Seed TW', 'PingFang TC', sans-serif;
|
||
user-select: none;
|
||
transition: all 0.15s ease;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
.copy-btn:hover { background: #e2e8f0; }
|
||
.copy-btn.copied {
|
||
background: #10b981;
|
||
color: #fff;
|
||
border-color: #10b981;
|
||
}
|
||
|
||
/* hover 時放大效果 */
|
||
.leaflet-marker-icon.hovered {
|
||
transform: scale(1.2);
|
||
z-index: 9999 !important;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
|
||
/* 一般個體:藍色光暈;100% IV 完美體:金色光暈。兩者結構對稱,飽和度相當。保持單一 <img> 以避免事件冒泡問題 */
|
||
.leaflet-marker-icon {
|
||
filter: drop-shadow(0 0 3px #60a5fa) drop-shadow(0 0 6px #3b82f6);
|
||
}
|
||
.leaflet-marker-icon.perfect-marker {
|
||
filter: drop-shadow(0 0 3px #ffd700) drop-shadow(0 0 6px #ffb300);
|
||
}
|
||
|
||
/* 自訂合併的 zoom + locate 控制列,取代 Leaflet 預設兩條 bar */
|
||
.custom-bar {
|
||
border: none !important;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
.custom-bar a {
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex !important;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #333;
|
||
background: #fff;
|
||
border-bottom: 1px solid #eee;
|
||
font-size: 20px;
|
||
font-weight: 400;
|
||
line-height: 1;
|
||
text-decoration: none;
|
||
transition: background 0.12s ease;
|
||
}
|
||
.custom-bar a:last-child { border-bottom: none; }
|
||
.custom-bar a:hover { background: #f2f4f7; color: #111; }
|
||
.custom-bar a:active { background: #e4e8ee; }
|
||
.custom-bar a.leaflet-disabled { color: #bbb; background: #fafafa; cursor: default; }
|
||
.custom-bar a.locating svg { animation: locate-pulse 1s ease-in-out infinite; color: #1976D2; }
|
||
@keyframes locate-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
/* 右下角資料更新時間 badge:半透明深色、小字、不搶視覺 */
|
||
#updated-badge {
|
||
position: absolute;
|
||
right: 12px;
|
||
bottom: 12px;
|
||
z-index: 500;
|
||
padding: 6px 10px;
|
||
background: rgba(15, 23, 42, 0.72);
|
||
color: #f1f5f9;
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||
pointer-events: none;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
#updated-badge[hidden] { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="map"></div>
|
||
<div id="updated-badge" hidden></div>
|
||
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
|
||
<script>
|
||
// 後端 API 端點;改 IP / port 只改這一行
|
||
const API_URL = 'https://poke-api.lotimmy.com/scan';
|
||
// 掃描中心座標(後端目前會忽略此值,純 schema 相容)
|
||
const SCAN_CENTER = { lat: 25.0478, lng: 121.5170 };
|
||
|
||
const map = L.map('map', { worldCopyJump: true, zoomControl: false }).setView([23.7, 121], 7);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
attribution: '© OpenStreetMap contributors'
|
||
}).addTo(map);
|
||
|
||
const genderIcon = g => g === 'male' ? '♂' : (g === 'female' ? '♀' : '—');
|
||
const isNum = v => typeof v === 'number' && Number.isFinite(v);
|
||
const toList = data => Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : []);
|
||
|
||
// 以 expire_time(unix 秒)為準即時計算剩餘秒數,避免用 JSON 內的靜態 remaining
|
||
function liveRemaining(item) {
|
||
if (isNum(item.expire_time)) {
|
||
return Math.max(0, Math.floor(item.expire_time - Date.now() / 1000));
|
||
}
|
||
return isNum(item.remaining) ? item.remaining : null;
|
||
}
|
||
|
||
// 跳脫 HTML,避免 name 等字串欄位被注入
|
||
function esc(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
// icon 快取:以 (id, 是否完美體) 為 key 共用 L.icon;統一用單一 <img>,避免子元素事件冒泡弄亂 hover/pin 狀態
|
||
const iconCache = new Map();
|
||
function makeIcon(item) {
|
||
const isPerfect = !!item.is_perfect || item.iv_pct === 100;
|
||
const key = `${item.id}:${isPerfect ? 'p' : 'n'}`;
|
||
let icon = iconCache.get(key);
|
||
if (!icon) {
|
||
icon = L.icon({
|
||
iconUrl: `img/${item.id}.png`,
|
||
iconSize: [48, 48],
|
||
iconAnchor: [24, 24],
|
||
popupAnchor: [0, -20],
|
||
className: isPerfect ? 'perfect-marker' : ''
|
||
});
|
||
iconCache.set(key, icon);
|
||
}
|
||
return icon;
|
||
}
|
||
|
||
let pinnedPopup = null; // 目前固定 popup
|
||
|
||
// 🔹 一鍵複製(靜默 + 視覺提示)
|
||
function copyToClipboard(text, btn) {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
btn.classList.add("copied");
|
||
btn.textContent = "已複製";
|
||
setTimeout(() => {
|
||
btn.classList.remove("copied");
|
||
btn.textContent = "複製";
|
||
}, 1000);
|
||
});
|
||
}
|
||
|
||
function ivClass(pct) {
|
||
if (!isNum(pct)) return 'iv-low';
|
||
if (pct >= 100) return 'iv-100';
|
||
if (pct >= 80) return 'iv-high';
|
||
if (pct >= 50) return 'iv-mid';
|
||
return 'iv-low';
|
||
}
|
||
|
||
function fmtRemaining(sec) {
|
||
if (sec === null) return { text: '—', cls: '' };
|
||
if (sec <= 0) return { text: '已過期', cls: 'expired' };
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
return { text: m > 0 ? `${m} 分 ${s} 秒` : `${s} 秒`, cls: '' };
|
||
}
|
||
|
||
function buildPopup(item) {
|
||
const lat = item.latitude ?? item.Latitude_Adjusted ?? item.Latitude_Raw;
|
||
const lng = item.longitude ?? item.Longitude_Adjusted ?? item.Longitude_Raw;
|
||
const coord = (isNum(lat) && isNum(lng)) ? `${lat.toFixed(6)}, ${lng.toFixed(6)}` : '—';
|
||
|
||
const ivPct = isNum(item.iv_pct) ? item.iv_pct : null;
|
||
const iv = ivPct === null ? '—' : `${ivPct}%`;
|
||
const atk = isNum(item.iv_atk) ? item.iv_atk : '—';
|
||
const def = isNum(item.iv_def) ? item.iv_def : '—';
|
||
const sta = isNum(item.iv_sta) ? item.iv_sta : '—';
|
||
const cp = isNum(item.cp) ? item.cp : '—';
|
||
const lvl = isNum(item.level) ? item.level : '—';
|
||
const rem = fmtRemaining(liveRemaining(item));
|
||
const perfect = !!item.is_perfect || item.iv_pct === 100;
|
||
const perfectText = perfect ? '✨ 完美體' : '一般';
|
||
const ivCls = ivClass(ivPct);
|
||
|
||
return `
|
||
<div>
|
||
<div class="popup-title">
|
||
<img class="avatar" src="img/${esc(item.id)}.png" alt="" onerror="this.style.visibility='hidden'">
|
||
<div class="name">
|
||
<span>${esc(item.name) || '—'}</span>
|
||
<span class="gender">${genderIcon(item.gender)}</span>
|
||
<span class="id">#${esc(item.id)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="popup-row"><span class="k">CP / 等級</span><span class="v">${cp} ・ Lv.${lvl}</span></div>
|
||
<div class="popup-row"><span class="k">IV</span><span class="v ${ivCls}">${iv}<span style="color:#94a3b8;font-weight:400;margin-left:8px">攻${atk} / 防${def} / 體${sta}</span></span></div>
|
||
<div class="popup-row"><span class="k">品質</span><span class="v">${perfectText}</span></div>
|
||
<div class="popup-row"><span class="k">剩餘</span><span class="v ${rem.cls}">${rem.text}</span></div>
|
||
<div class="coord-line">
|
||
<span>${coord}</span>
|
||
<span class="copy-btn" onclick="copyToClipboard('${coord}', this)">複製</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 合併 zoom + locate 到單一 bar,統一視覺
|
||
const CustomBar = L.Control.extend({
|
||
options: { position: 'topleft' },
|
||
onAdd() {
|
||
const bar = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-bar');
|
||
|
||
const mkBtn = (label, title, onClick) => {
|
||
const a = L.DomUtil.create('a', '', bar);
|
||
a.href = '#';
|
||
a.title = title;
|
||
a.setAttribute('role', 'button');
|
||
a.innerHTML = label;
|
||
L.DomEvent.on(a, 'click', (e) => { L.DomEvent.stop(e); onClick(a); });
|
||
return a;
|
||
};
|
||
|
||
this._in = mkBtn('+', '放大', () => map.zoomIn());
|
||
this._out = mkBtn('−', '縮小', () => map.zoomOut());
|
||
this._locate = mkBtn(
|
||
'<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z"/></svg>',
|
||
'定位到我的位置',
|
||
(a) => {
|
||
a.classList.add('locating');
|
||
map.locate({ setView: true, maxZoom: 16, enableHighAccuracy: true, timeout: 10000 });
|
||
}
|
||
);
|
||
|
||
L.DomEvent.disableClickPropagation(bar);
|
||
this._updateDisabled();
|
||
map.on('zoomend', () => this._updateDisabled());
|
||
return bar;
|
||
},
|
||
_updateDisabled() {
|
||
const z = map.getZoom();
|
||
this._in.classList.toggle('leaflet-disabled', z >= map.getMaxZoom());
|
||
this._out.classList.toggle('leaflet-disabled', z <= map.getMinZoom());
|
||
},
|
||
stopSpinner() { this._locate && this._locate.classList.remove('locating'); }
|
||
});
|
||
const locateCtrl = new CustomBar();
|
||
map.addControl(locateCtrl);
|
||
|
||
let locateLayer = null;
|
||
map.on('locationfound', (e) => {
|
||
locateCtrl.stopSpinner();
|
||
if (locateLayer) map.removeLayer(locateLayer);
|
||
locateLayer = L.layerGroup([
|
||
L.circle(e.latlng, { radius: e.accuracy, color: '#1976D2', fillColor: '#1976D2', fillOpacity: 0.1, weight: 1 }),
|
||
L.circleMarker(e.latlng, { radius: 6, color: '#fff', fillColor: '#1976D2', fillOpacity: 1, weight: 2 })
|
||
]).addTo(map);
|
||
});
|
||
map.on('locationerror', (e) => {
|
||
locateCtrl.stopSpinner();
|
||
console.error('定位失敗:', e);
|
||
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');
|
||
div.style.cssText = 'background:#fff;border:1px solid #c00;color:#c00;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);
|
||
errorControl = box;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function clearStatus() {
|
||
if (statusControl) { map.removeControl(statusControl); statusControl = null; }
|
||
}
|
||
|
||
// ---- 空資料自動重試(處理 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;
|
||
|
||
function clearRetry() {
|
||
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
||
}
|
||
|
||
function scheduleRetry() {
|
||
clearRetry();
|
||
retryTimer = setTimeout(() => {
|
||
retryTimer = null;
|
||
loadScan();
|
||
}, RETRY_INTERVAL_MS);
|
||
}
|
||
|
||
// 頁面關閉/離開時清 timer,避免 leak
|
||
window.addEventListener('beforeunload', clearRetry);
|
||
|
||
// ---- 資料更新時間 badge ----
|
||
const updatedBadgeEl = document.getElementById('updated-badge');
|
||
let lastGeneratedAtMs = null; // 暫存 ISO parsed 後的 ms,供每 10s tick 重算相對時間
|
||
|
||
// 中文相對時間:<30s 剛剛;<60m N 分鐘前;<24h N 小時前;否則絕對日期
|
||
function formatRelativeTime(isoString) {
|
||
const ms = Date.parse(isoString);
|
||
if (!Number.isFinite(ms)) return null;
|
||
const diffSec = Math.max(0, Math.floor((Date.now() - ms) / 1000));
|
||
if (diffSec < 30) return '剛剛';
|
||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分鐘前`;
|
||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} 小時前`;
|
||
const d = new Date(ms);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
}
|
||
|
||
function renderUpdatedBadge() {
|
||
if (lastGeneratedAtMs === null) {
|
||
updatedBadgeEl.hidden = true;
|
||
return;
|
||
}
|
||
const iso = new Date(lastGeneratedAtMs).toISOString();
|
||
const rel = formatRelativeTime(iso);
|
||
if (rel === null) {
|
||
updatedBadgeEl.hidden = true;
|
||
return;
|
||
}
|
||
const d = new Date(lastGeneratedAtMs);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
const hhmmss = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||
updatedBadgeEl.textContent = `資料更新於 ${hhmmss}(${rel})`;
|
||
updatedBadgeEl.hidden = false;
|
||
}
|
||
|
||
function updateGeneratedAt(iso) {
|
||
if (!iso) {
|
||
// 保留上次值即可,不主動清;若從未設過則維持隱藏
|
||
return;
|
||
}
|
||
const ms = Date.parse(iso);
|
||
if (!Number.isFinite(ms)) return;
|
||
lastGeneratedAtMs = ms;
|
||
renderUpdatedBadge();
|
||
}
|
||
|
||
// 每 10 秒重算一次相對時間(不重打 API)
|
||
setInterval(renderUpdatedBadge, 10000);
|
||
|
||
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;
|
||
|
||
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();
|
||
pinnedPopup._source._icon.classList.remove('hovered');
|
||
pinnedPopup = null;
|
||
}
|
||
});
|
||
mapClickBound = true;
|
||
}
|
||
|
||
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 })
|
||
})
|
||
.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;
|
||
updateGeneratedAt(json.generated_at);
|
||
renderItems(list);
|
||
})
|
||
.catch(err => {
|
||
// 真正的 fetch 失敗(網路/HTTP/JSON parse)→ 紅框,不自動 retry
|
||
clearRetry();
|
||
console.error('讀取掃描資料失敗:', err);
|
||
showLoadError(`掃描資料載入失敗:${err.message}`);
|
||
});
|
||
}
|
||
|
||
loadScan();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|