poke/html/map.ejs
2025-08-17 12:48:29 +02:00

197 lines
8.8 KiB
Plaintext

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>PokeMaps Beta</title>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/css/yt-ukraine.svg" />
<style>
:root{--vh:100vh;--pad:12px;--radius:14px;--fg:#fff;--bg:rgba(0,0,0,.6);--glass:blur(12px)}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{background:#000;font:14px/1.4 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial;color:var(--fg)}
.app{position:fixed;inset:0;display:grid;grid-template-rows:auto 1fr}
.bar{position:relative;z-index:5;display:flex;gap:8px;align-items:center;padding:8px var(--pad);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);background:var(--bg)}
.search{position:relative;flex:1;min-width:0}
.search input{width:100%;padding:10px 12px;border-radius:10px;border:1px solid #333;background:#111;color:#fff;outline:none}
.suggest{position:absolute;top: calc(100% + 6px);left:0;right:0;max-height:42vh;overflow:auto;margin:0;padding:6px 0;list-style:none;border:1px solid #333;border-radius:10px;background:#0b0b0b;display:none}
.suggest li{padding:10px 12px;cursor:pointer;border-top:1px solid #111}
.suggest li:first-child{border-top:none}
.suggest li:active{background:#1a1a1a}
.btns{display:flex;gap:8px;white-space:nowrap}
.btn{border:0;border-radius:10px;background:#222;color:#fff;padding:10px 12px}
.mapwrap{position:relative;height:calc(var(--vh) - 56px);overflow:hidden}
iframe#map{position:absolute;inset:0;border:0;width:100%;height:100%}
.marker{position:absolute;left:50%;top:50%;width:20px;height:20px;border-radius:50%;transform:translate(-50%,-50%);background:#e53935;box-shadow:0 0 0 3px rgba(229,57,53,.35);z-index:3;pointer-events:none}
.brand{position:absolute;bottom:10px;left:10px;padding:6px 10px;font-size:18px;font-weight:600;background:var(--bg);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border-radius:10px;z-index:4;pointer-events:none}
.fab{position:fixed;right:16px;bottom:16px;z-index:6;width:56px;height:56px;border-radius:50%;border:0;background:#111;color:#fff;box-shadow:0 6px 18px rgba(0,0,0,.45);font-size:28px;line-height:0}
@media (min-width: 768px){
.bar{padding:10px 14px}
.brand{font-size:20px}
}
@media (prefers-reduced-motion: reduce){
*{animation:none !important;transition:none !important}
}
</style>
</head>
<body>
<div class="app" id="app">
<div class="bar">
<div class="search">
<form id="form" autocomplete="off">
<input id="q" type="text" inputmode="search" placeholder="Search places…" aria-autocomplete="list" aria-expanded="false" aria-controls="suggestions" />
</form>
<ul id="suggestions" class="suggest" role="listbox"></ul>
</div>
<div class="btns">
<button class="btn" id="locate" type="button">📍 Locate</button>
<button class="btn" id="copy" type="button">📋 Copy</button>
<button class="btn" id="reset" type="button">🔁 Reset</button>
</div>
</div>
<div class="mapwrap" id="mapwrap">
<iframe id="map" title="Map"></iframe>
<div class="marker" aria-hidden="true"></div>
<div class="brand">PokeMaps</div>
</div>
</div>
<button class="fab" id="fab" title="More Tools">+</button>
<script>
;(()=> {
const OSM_EMBED="https://www.openstreetmap.org/export/embed.html"
const OSM_HOST="www.openstreetmap.org"
const NOMINATIM="https://nominatim.openstreetmap.org/search"
const LAYER="mapnik"
const DEFAULT={lat:30.41015625,lon:72.44879155730672,delta:.25}
const S=sel=>document.querySelector(sel)
const map=S("#map"), wrap=S("#mapwrap"), q=S("#q"), sug=S("#suggestions")
const locateBtn=S("#locate"), copyBtn=S("#copy"), resetBtn=S("#reset"), fab=S("#fab")
let aborter=null, lastQuery="", state={lat:DEFAULT.lat,lon:DEFAULT.lon,delta:DEFAULT.delta}
const setVH=()=>{const vh=window.innerHeight*0.01;document.documentElement.style.setProperty("--vh",`${vh*100}px`)}
setVH(); addEventListener("resize",()=>{setVH()},{passive:true})
const clamp=(n,min,max)=>Math.min(Math.max(n,min),max)
const cleanStr=s=>s.replace(/\s+/g," ").trim()
const bboxFrom=(lat,lon,d)=>({
left:(lon-d).toFixed(6),
bottom:(lat-d).toFixed(6),
right:(lon+d).toFixed(6),
top:(lat+d).toFixed(6)
})
const embedURL=({lat,lon,delta})=>{
const b=bboxFrom(lat,lon,delta)
return `${OSM_EMBED}?bbox=${b.left}%2C${b.bottom}%2C${b.right}%2C${b.top}&layer=${encodeURIComponent(LAYER)}`
}
const appURL=({lat,lon,delta})=>{
const p=new URLSearchParams({lat:lat.toFixed(6),lon:lon.toFixed(6),delta:delta.toFixed(4)})
return `${location.origin}${location.pathname}?${p.toString()}`
}
const parseURL=()=>{
const sp=new URLSearchParams(location.search)
const lat=parseFloat(sp.get("lat")), lon=parseFloat(sp.get("lon")), delta=parseFloat(sp.get("delta"))
if(Number.isFinite(lat)&&Number.isFinite(lon)) state.lat=clamp(lat,-90,90), state.lon=clamp(lon,-180,180)
if(Number.isFinite(delta)&&delta>0&&delta<45) state.delta=delta
}
const apply=push=>{
map.src=embedURL(state)
if(push) history.pushState(state,"",appURL(state))
}
const centerOn=(lat,lon,{push=true}={})=>{
state.lat=clamp(lat,-90,90)
state.lon=clamp(lon,-180,180)
apply(push)
}
const reset=()=>{
state={...DEFAULT}
q.value=""
sug.style.display="none"; q.setAttribute("aria-expanded","false")
apply(true)
}
const copyLink=async()=>{
try{
await navigator.clipboard.writeText(appURL(state))
alert("Map link copied!")
}catch{ alert("Could not copy.")}
}
const locate=()=>{
if(!("geolocation" in navigator)){ alert("Geolocation not supported."); return }
navigator.geolocation.getCurrentPosition(
pos=>centerOn(pos.coords.latitude,pos.coords.longitude,{push:true}),
err=>alert("Unable to retrieve location: "+err.message),
{enableHighAccuracy:true,timeout:10000,maximumAge:0}
)
}
const debounced=(fn,ms=250)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
const searchPlaces=debounced(async term=>{
term=cleanStr(term); if(!term){sug.style.display="none"; q.setAttribute("aria-expanded","false"); return}
if(aborter) aborter.abort()
aborter=new AbortController()
try{
const url=`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=7&addressdetails=0&accept-language=en`
const r=await fetch(url,{signal:aborter.signal,headers:{}})
if(!r.ok) throw new Error("HTTP "+r.status)
const data=await r.json()
sug.innerHTML=""
data.forEach((p,i)=>{
const li=document.createElement("li")
li.role="option"; li.id="opt"+i
li.textContent=p.display_name
li.onclick=()=>{ q.value=p.display_name; sug.style.display="none"; q.setAttribute("aria-expanded","false"); centerOn(parseFloat(p.lat),parseFloat(p.lon),{push:true}) }
sug.appendChild(li)
})
sug.style.display=data.length?"block":"none"; q.setAttribute("aria-expanded", String(!!data.length))
}catch(e){
if(e.name!=="AbortError"){ sug.style.display="none"; q.setAttribute("aria-expanded","false") }
}
},200)
q.addEventListener("input",e=>{
const v=e.target.value
if(v===lastQuery) return
lastQuery=v
searchPlaces(v)
},{passive:true})
S("#form").addEventListener("submit",async e=>{
e.preventDefault()
const term=cleanStr(q.value); if(!term) return
try{
const r=await fetch(`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=1`)
const d=await r.json()
if(d[0]) centerOn(parseFloat(d[0].lat),parseFloat(d[0].lon),{push:true})
}catch{}
})
locateBtn.onclick=locate
copyBtn.onclick=copyLink
resetBtn.onclick=reset
fab.onclick=()=>alert("More features coming soon!")
addEventListener("popstate",e=>{
if(e.state && typeof e.state.lat==="number"){ state=e.state; apply(false) }
else { parseURL(); apply(false) }
})
addEventListener("click",e=>{
const t=e.target
if(t.tagName==="A" && t.href.includes(OSM_HOST)){ e.preventDefault(); map.src=t.href; history.pushState(state,"",t.href) }
},{capture:true})
parseURL(); apply(false)
})();
</script>
<script src="/static/data-mobile.js" defer></script>
</body>
</html>