Files
pokemon/index.html
timmy 08b456a96e UI 新增資料更新時間 badge
在地圖右下角顯示後端 /scan response 新增的 generated_at 欄位,
以中文相對時間呈現(剛剛 / N 分鐘前 / N 小時前 / 絕對日期),
每 10 秒自動重算顯示,資料空或缺欄位時保留上次值。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:17:36 +08:00

619 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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: '&copy; 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_timeunix 秒)為準即時計算剩餘秒數,避免用 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>