Files
pokemon/index.html
timmy 473211c86c 將資料來源從 spots.json 切換到後端 /scan API
- 於檔頭新增 API_URL / SCAN_CENTER 常數,方便之後切換 IP / port
- fetch 改 POST,body 帶 lat/lng(後端要求的 schema,值本身會被忽略)
- 空 items 改顯示「目前無資料」提示,處理 API stale-while-revalidate
  快取剛過期時首打會回空陣列的情境
- 保留 toList() 對頂層陣列的相容、保留座標欄位三種別名 fallback

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

475 lines
17 KiB
HTML
Raw 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; }
}
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// 後端 API 端點;改 IP / port 只改這一行
const API_URL = 'http://192.168.88.108:8008/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}`);
});
function showLoadError(msg) {
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);
}
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 = [];
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
map.on('click', function() {
if (pinnedPopup) {
pinnedPopup._source.closePopup();
pinnedPopup._source._icon.classList.remove('hovered');
pinnedPopup = null;
}
});
if (markers.length > 0) {
const group = L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.2));
} else {
// API stale-while-revalidate快取剛過期時會先回空陣列幾十秒後再打才有資料
showLoadError('目前無資料,稍後再試');
}
})
.catch(err => {
console.error('讀取掃描資料失敗:', err);
showLoadError(`掃描資料載入失敗:${err.message}`);
});
</script>
</body>
</html>