poke/html/map.ejs
2025-08-17 22:06:46 +02:00

394 lines
17 KiB
Plaintext
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="en">
<head>
<meta charset="utf-8" />
<title>PokeMaps Public Beta</title>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0ea5e9" id="themeColorMeta"/>
<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);
--marker-size: 20px; --marker-color: #e53935;
--marker-ring: rgba(229,57,53,.35); --ring-width: 3px;
--panel: rgba(0,0,0,.85); --accent: #0ea5e9;
--chip:#111; --chipb:#222; --tip:#888;
--surface:#0b0b0b; --border:#333;
}
* { box-sizing: border-box }
html,body { height:100%; margin:0 }
body {
background:#000; color:var(--fg);
font:14px/1.4 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial;
}
.app { position:fixed; inset:0; display:grid; grid-template-rows:auto 1fr }
/* Top search + bar */
.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:12px 14px; border-radius:12px;
border:1px solid var(--border); background:var(--surface);
color:#fff; outline:none;
}
.search input:focus {
border-color:#444;
box-shadow:0 0 0 3px color-mix(in oklab,var(--accent) 20%,transparent)
}
/* Suggestion dropdown */
.suggest {
position:absolute; top:calc(100% + 6px); left:0; right:0;
max-height:46vh; overflow:auto; margin:0; padding:0;
list-style:none; border:1px solid var(--border);
border-radius:12px; background:var(--surface); display:none
}
.suggest .head {
position:sticky; top:0; background:#0d0d0d;
border-bottom:1px solid #1a1a1a; padding:6px 8px;
display:flex; justify-content:space-between; align-items:center; z-index:1
}
.suggest li {
padding:10px 12px; cursor:pointer; border-top:1px solid #111;
display:flex; gap:8px; align-items:center
}
.suggest li.active { background:#141414 }
.suggest .pill {
font-size:11px; border:1px solid #333;
border-radius:999px; padding:2px 6px; opacity:.85
}
.suggest mark { background:transparent; color:var(--accent); font-weight:600 }
/* Map wrapper */
.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:var(--marker-size); height:var(--marker-size);
border-radius:50%; transform:translate(-50%,-50%);
background:var(--marker-color);
box-shadow:0 0 0 var(--ring-width) var(--marker-ring);
z-index:3; pointer-events:none
}
.marker.hidden { display:none }
.marker.crosshair { background:transparent; border-radius:0;
box-shadow:0 0 0 var(--ring-width) var(--marker-ring)
}
.marker .dot {
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:calc(var(--marker-size) * .35); height:calc(var(--marker-size) * .35);
background:var(--marker-color); border-radius:50%
}
/* Brand + dock */
.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
}
.dock {
position:absolute; left:10px; bottom:52px; z-index:7;
display:flex; flex-direction:column; gap:8px
}
.dock button {
border:0; border-radius:10px; background:#111; color:#fff; padding:8px 10px
}
.coords {
position:absolute; bottom:10px; right:10px; z-index:5;
padding:6px 10px; border-radius:10px; background:var(--bg);
backdrop-filter:var(--glass); -webkit-backdrop-filter:var(--glass);
border:1px solid var(--border); display:none;
min-width:260px; text-align:right;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace
}
/* Sidebar for desktop */
.desktop .app { grid-template-columns:340px 1fr; grid-template-rows:auto 1fr }
.desktop .bar { grid-column:2 }
.desktop .sidebar {
position:fixed; left:0; top:0; bottom:0; width:340px;
background:var(--panel); border-right:1px solid var(--border);
display:flex; flex-direction:column; gap:12px; padding:12px; z-index:9
}
.desktop .mapwrap { grid-column:2 }
/* hide mobile menus when desktop */
.desktop .menu,.desktop .pins,.desktop .settings { display:none !important }
@media (min-width:768px){ .bar{padding:10px 14px} .brand{font-size:20px} }
</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, or paste lat,lon…"
aria-autocomplete="list" aria-expanded="false"
aria-controls="suggestions" />
</form>
<ul id="suggestions" class="suggest" role="listbox">
<li class="head" aria-hidden="true">
<strong>Suggestions</strong>
<button type="button" id="closeSuggestBtn" title="Close">✕</button>
</li>
</ul>
</div>
<button class="iconbtn" id="locate" title="Locate">📍</button>
<button class="iconbtn" id="menuBtn" title="Menu">⋯</button>
</div>
<div class="mapwrap" id="mapwrap">
<iframe id="map" title="Map"></iframe>
<div id="marker" class="marker"><div class="dot"></div></div>
<div class="brand">PokeMaps Public Beta</div>
<div class="dock">
<button id="savepin">⭐ Save Pin</button>
<button id="showpins">📒 Pins</button>
<button id="opensettings">⚙ Settings</button>
</div>
<div class="coords" id="coords"></div>
</div>
</div>
<!-- Mobile menus -->
<div class="menu" id="menu"></div>
<div class="pins" id="pins"></div>
<div class="settings" id="settings"></div>
<script>
(()=> {
const OSM_EMBED="https://www.openstreetmap.org/export/embed.html";
const OSM_VIEW ="https://www.openstreetmap.org";
const NOMINATIM="https://nominatim.openstreetmap.org/search";
const LAYERS=["mapnik","cyclemap","transportmap","hot"];
const DEFAULT={lat:30.410156,lon:72.448792,delta:.25,layer:"mapnik"};
const LIMITS={deltaMin:0.01, deltaMax:45};
const S=sel=>document.querySelector(sel);
const map=S("#map"), q=S("#q"), sug=S("#suggestions");
const closeSuggestBtn=S("#closeSuggestBtn");
const locateBtn=S("#locate"), menuBtn=S("#menuBtn"), menu=S("#menu");
const pinsPane=S("#pins"), showpins=S("#showpins"), savepin=S("#savepin");
const opensettings=S("#opensettings"), settings=S("#settings");
const coordsEl=S("#coords");
const themeColorMeta=S("#themeColorMeta");
const markerEl=S("#marker");
const PREFS_KEY="pokemaps_prefs_v6";
const LS_PINS="pokemaps_pins_v1";
const LS_SEARCH="pokemaps_search_hist_v1";
let aborter=null, lastQuery="", watchId=null, activeIndex=-1, lastCoordsStr="";
let state={...DEFAULT};
const prefs=loadPrefs(); applyPrefs();
const isDesktop=()=> matchMedia("(min-width: 1024px)").matches && matchMedia("(pointer: fine)").matches;
const applyDesktopClass=()=>{ document.body.classList.toggle("desktop", isDesktop()) };
applyDesktopClass(); addEventListener("resize",applyDesktopClass,{passive:true});
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 clampDelta=d=>clamp(d, LIMITS.deltaMin, LIMITS.deltaMax);
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,layer})=>{
const b=bboxFrom(lat,lon,clampDelta(delta));
const lyr = LAYERS.includes(layer)?layer:DEFAULT.layer;
return `${OSM_EMBED}?bbox=${b.left}%2C${b.bottom}%2C${b.right}%2C${b.top}&layer=${encodeURIComponent(lyr)}`;
};
const appURL=({lat,lon,delta,layer})=>{
const p=new URLSearchParams({
lat:lat.toFixed(prefs.prec??6),
lon:lon.toFixed(prefs.prec??6),
delta:(prefs.includeDelta!==false?clampDelta(delta).toFixed(4):undefined),
layer
});
if(p.get("delta")==="undefined") p.delete("delta");
return `${location.origin}${location.pathname}?${p.toString()}`;
};
const osmViewURL=({lat,lon,delta,layer})=>{
const z = deltaToZoom(clampDelta(delta));
return `${OSM_VIEW}/?mlat=${lat.toFixed(6)}&mlon=${lon.toFixed(6)}#map=${z}/${lat.toFixed(6)}/${lon.toFixed(6)}&layers=${layer}`;
};
const gmapsURL=({lat,lon,delta})=>{
const z = deltaToZoom(clampDelta(delta));
return `https://www.google.com/maps/@${lat.toFixed(6)},${lon.toFixed(6)},${Math.max(3,Math.min(20,z))}z`;
};
const parseURL=()=>{
const sp=new URLSearchParams(location.search);
const lat=parseFloat(sp.get("lat")), lon=parseFloat(sp.get("lon")), delta=parseFloat(sp.get("delta"));
const layer=sp.get("layer");
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) state.delta=clampDelta(delta);
if(layer && LAYERS.includes(layer)) state.layer=layer;
};
const apply=(push)=>{
map.src=embedURL(state);
if(push) history.pushState(state,"",appURL(state));
};
const toDMS=(v,isLat)=>{
const dir=isLat?(v>=0?"N":"S"):(v>=0?"E":"W");
const av=Math.abs(v); const d=Math.floor(av); const m=Math.floor((av-d)*60); const s=((av-d)*60 - m)*60;
return `${d}°${m}${s.toFixed(2)}″ ${dir}`;
};
const coordString=()=>{
const fmt=prefs.coordFmt||"dec";
const pr=prefs.prec??6;
const dec=`${state.lat.toFixed(pr)}, ${state.lon.toFixed(pr)}`;
const dms=toDMS(state.lat,true)+" "+toDMS(state.lon,false);
return fmt==="dms"?dms:dec;
};
const updateCoordsFast=()=>{
if(!prefs.showCoords) return;
const s=coordString()+` · Δ ${state.delta.toFixed(4)} · ${state.layer}`;
if(s!==lastCoordsStr){ coordsEl.textContent=s; lastCoordsStr=s }
};
const centerOn=(lat,lon,{push=true}={})=>{ state.lat=clamp(lat,-90,90); state.lon=clamp(lon,-180,180); apply(push) };
const setLayer=(layer)=>{ state.layer = LAYERS.includes(layer)?layer:DEFAULT.layer; apply(true) };
const setDelta=(delta,{push=true}={})=>{ state.delta = clampDelta(delta); apply(push) };
const zoomToDelta=z=>Math.min(LIMITS.deltaMax, Math.max(LIMITS.deltaMin, Math.pow(2,(10 - z)) * 0.02));
const deltaToZoom=d=>Math.round(10 - Math.log2(d/0.02));
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 reset=()=>{ state={...DEFAULT}; q.value=""; hideSuggest(true); apply(true) };
const debounced=(fn,ms=250)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}};
const parseCoordInput=(str)=>{
str=str.trim();
const m=str.match(/^\s*([+-]?\d+(?:\.\d+)?)\s*[, ]\s*([+-]?\d+(?:\.\d+)?)\s*$/);
if(!m) return null;
const lat=parseFloat(m[1]), lon=parseFloat(m[2]);
if(!Number.isFinite(lat)||!Number.isFinite(lon)) return null;
if(Math.abs(lat)>90||Math.abs(lon)>180) return null;
return {lat,lon};
};
const highlight=(text,term)=>{
term=term.trim(); if(!term) return text;
const esc=term.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");
return text.replace(new RegExp(esc,"ig"),m=>`<mark>${m}</mark>`);
};
const loadSearchHist=()=>{ try{ return JSON.parse(localStorage.getItem(LS_SEARCH)||"[]") }catch{ return [] } };
const saveSearchHist=(arr)=>{ localStorage.setItem(LS_SEARCH, JSON.stringify(arr.slice(0,12))) };
const renderSuggest=(items, term="")=>{
const head = sug.querySelector(".head");
sug.innerHTML=""; if(head) sug.appendChild(head);
if(!items.length){ sug.style.display="none"; q.setAttribute("aria-expanded","false"); activeIndex=-1; return }
items.forEach((it,i)=>{
const li=document.createElement("li");
li.role="option"; li.id="opt"+i;
li.innerHTML=`<span class="pill">${it.type}</span><span class="txt">${highlight(it.label,term)}</span>`;
li.addEventListener("mousedown",e=>{ e.preventDefault(); chooseItem(it) });
sug.appendChild(li);
});
sug.style.display="block"; q.setAttribute("aria-expanded","true");
};
const hideSuggest=()=>{ sug.style.display="none"; q.setAttribute("aria-expanded","false"); activeIndex=-1 };
const chooseItem=(it)=>{
if(it.type==="coords"){ centerOn(it.lat,it.lon,{push:true}); saveSearchHist([it.label]); hideSuggest(); return }
if(it.type==="place"){ q.value=it.label; centerOn(it.lat,it.lon,{push:true}); hideSuggest(); return }
};
const searchPlaces=debounced(async term=>{
term=term.trim(); activeIndex=-1;
if(!term){ renderSuggest(loadSearchHist().map(x=>({type:"history",label:x}))); return }
const coord=parseCoordInput(term);
if(coord){ renderSuggest([{type:"coords",label:`${coord.lat}, ${coord.lon}`, lat:coord.lat, lon:coord.lon}]); return }
if(aborter) aborter.abort(); aborter=new AbortController();
try{
const url=`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=8`;
const r=await fetch(url,{signal:aborter.signal}); if(!r.ok) throw new Error();
const data=await r.json();
renderSuggest(data.map(p=>({type:"place",label:p.display_name,lat:+p.lat,lon:+p.lon})), term);
}catch{ renderSuggest([]) }
},140);
q.addEventListener("input",e=>{ const v=e.target.value; if(v!==lastQuery){ lastQuery=v; searchPlaces(v) } });
q.addEventListener("focus",()=>{ if(!q.value) renderSuggest(loadSearchHist().map(x=>({type:"history",label:x}))) });
closeSuggestBtn.addEventListener("click",()=>hideSuggest());
document.addEventListener("click",e=>{ if(!e.target.closest(".search")&&!e.target.closest("#suggestions")) hideSuggest() });
S("#form").addEventListener("submit",async e=>{ e.preventDefault(); hideSuggest() });
locateBtn.onclick=locate;
menuBtn.onclick=()=>{ if(!isDesktop()) menu.style.display="block" };
showpins.onclick=()=>{ pinsPane.style.display="block" };
savepin.onclick=()=>alert("Pin saved (demo)");
opensettings.onclick=()=>{ if(!isDesktop()) settings.style.display="block" };
// prefs
function loadPrefs(){ try{ return JSON.parse(localStorage.getItem(PREFS_KEY)||"{}") }catch{ return {} } }
function savePrefs(){ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) }
function applyPrefs(){ setAccent(prefs.accent||"#0ea5e9"); }
function setAccent(hex){ document.documentElement.style.setProperty("--accent",hex); themeColorMeta?.setAttribute("content",hex) }
// build sidebar on desktop
if(isDesktop()) buildSidebar();
function buildSidebar(){
const side=document.createElement("div");
side.className="sidebar";
side.innerHTML=`
<div style="display:flex;flex-direction:column;gap:10px">
<input id="d_search" type="text" placeholder="Search or lat,lon…" style="padding:10px;border-radius:8px;background:#111;color:#fff;border:1px solid var(--border)">
<select id="d_layer" style="padding:10px;border-radius:8px;background:#111;color:#fff;border:1px solid var(--border)">
<option value="mapnik">Standard</option>
<option value="cyclemap">Cycle</option>
<option value="transportmap">Transport</option>
<option value="hot">Humanitarian</option>
</select>
<button id="d_locate" class="iconbtn">📍 Locate</button>
<button id="d_reset" class="iconbtn">🔁 Reset</button>
<button id="d_gmaps" class="iconbtn">🌐 Google Maps</button>
</div>
`;
document.body.appendChild(side);
side.querySelector("#d_search").addEventListener("input",e=>{
q.value=e.target.value; q.dispatchEvent(new Event("input",{bubbles:true}))
});
side.querySelector("#d_locate").onclick=locate;
side.querySelector("#d_reset").onclick=reset;
side.querySelector("#d_layer").onchange=e=>setLayer(e.target.value);
side.querySelector("#d_gmaps").onclick=()=>window.open(gmapsURL(state),"_blank");
}
parseURL(); apply(false);
function tickCoords(){ updateCoordsFast(); setTimeout(tickCoords,200) }
tickCoords();
})();
</script>
</body>
</html>