Initial commit: Leaflet map showing Pokémon GO spots in Taiwan

Features:
- Static single-page Leaflet map with pokémon markers
- LINE Seed TW font, custom-styled popups
- Blue glow for normal pokémon, gold glow for 100% IV
- Custom zoom + locate control bar
- Live remaining-time calculation from expire_time
This commit is contained in:
timmy
2026-04-20 08:41:26 +08:00
commit aa89c77d52
1006 changed files with 2368 additions and 0 deletions

462
index.html Normal file
View File

@@ -0,0 +1,462 @@
<!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>
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('spots.json', { cache: 'no-cache' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return res.json();
})
.then(json => {
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 {
showLoadError('沒有可顯示的座標');
}
})
.catch(err => {
console.error('讀取 spots.json 失敗:', err);
showLoadError(`spots.json 載入失敗:${err.message}`);
});
</script>
</body>
</html>