poke/html/map.ejs
2025-08-19 18:10:49 +02:00

480 lines
42 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:#0b0b0b;--accent:#0ea5e9;--chip:#111;--border:#222;--surface:#0a0a0a;
--sidebar-w:320px;--sidebar-min:260px;--sidebar-max:60vw
}
*{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}
.bar{display:flex;gap:8px;align-items:center;padding:8px var(--pad);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);background:var(--bg);z-index:5}
.search{position:relative;flex:1;min-width:0}
.search input{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:#0d0d0d;color:#fff;outline:none}
.search input:focus{border-color:#444;box-shadow:0 0 0 3px color-mix(in oklab,var(--accent) 20%,transparent)}
.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:#0d0d0d;display:none}
.suggest .head{position:sticky;top:0;background:#0f0f0f;border-bottom:1px solid #171717;padding:6px 8px;display:flex;justify-content:space-between;align-items:center;z-index:1}
.suggest .head strong{font-size:12px;opacity:.8}.suggest .head button{border:0;border-radius:8px;background:#151515;color:#fff;padding:6px 8px;cursor:pointer}
.suggest li{padding:10px 12px;cursor:pointer;border-top:1px solid #111;display:flex;gap:8px;align-items:center}
.suggest li:first-child{border-top:none}.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}
.iconbtn{border:0;border-radius:10px;background:#111;color:#fff;padding:10px 12px;min-width:44px}
.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.crosshair::before,.marker.crosshair::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--marker-color)}
.marker.crosshair::before{width:calc(var(--marker-size) * 1.6);height:2px}
.marker.crosshair::after{width:2px;height:calc(var(--marker-size) * 1.6)}
.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{position:absolute;bottom:10px;left:10px;padding:6px 10px;font-weight:600;background:var(--bg);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border-radius:10px;z-index:4;pointer-events:none}
.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:240px;text-align:right;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","DejaVu Sans Mono",monospace}
.dock{position:absolute;left:10px;bottom:52px;z-index:7;display:flex;flex-direction:column;gap:8px}
.menu,.pins,.settings{position:fixed;right:10px;z-index:8;min-width:300px;max-width:92vw;background:var(--panel);border:1px solid var(--border);border-radius:12px;display:none;overflow:hidden}
.menu{top:56px}.pins,.settings{bottom:110px;left:10px;right:auto}
.menu header,.settings header,.pins header{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid #222}
.menu .row,.settings .row{display:flex;gap:8px;align-items:center;padding:8px 12px;border-top:1px solid #111;flex-wrap:wrap}
.menu .row:first-of-type,.settings .row:first-of-type{border-top:0}
.select{appearance:none;-webkit-appearance:none;border:1px solid var(--border);background:#111;color:#fff;border-radius:10px;padding:10px 12px}
.pins ul{list-style:none;margin:0;padding:0}.pins li{display:flex;flex-direction:column;gap:8px;padding:10px;border-top:1px solid #111}
.settings .group{border-top:1px solid #111}.settings .ghead{display:flex;justify-content:space-between;align-items:center;padding:10px 12px}.settings .section{display:none;padding:10px 12px;border-top:1px solid #111}
.settings textarea{width:100%;min-height:110px;border-radius:10px;border:1px solid var(--border);background:#0a0a0a;color:#eee;padding:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.badge{font-size:11px;opacity:.8;border:1px solid #444;border-radius:999px;padding:2px 8px;margin-left:8px}
.desktop .app{grid-template-columns:var(--sidebar-w) 1fr;grid-template-rows:auto 1fr}
.desktop .bar{grid-column:2}
.desktop .sidebar{position:fixed;left:0;top:0;bottom:0;width:var(--sidebar-w);min-width:var(--sidebar-min);max-width:var(--sidebar-max);background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;gap:12px;padding:12px;z-index:9;overflow:auto}
.desktop .sidecard{border:1px solid #222;border-radius:12px;overflow:hidden}
.desktop .sidecard header{padding:10px 12px;border-bottom:1px solid #222;font-weight:600;display:flex;justify-content:space-between;align-items:center}
.desktop .sidecard .row{display:flex;gap:8px;align-items:center;padding:8px 12px;border-top:1px solid #111;flex-wrap:wrap}
.desktop .sidecard .row:first-of-type{border-top:0}
.desktop .mapwrap{grid-column:2}
.desktop .menu,.desktop .pins,.desktop .settings,.desktop .dock{display:none!important}
.side-resizer{position:fixed;top:0;bottom:0;left:calc(var(--sidebar-w) - 4px);width:8px;cursor:col-resize;z-index:10;background:linear-gradient(to right,transparent 0 6px,#ffffff10 6px 7px,transparent 7px 100%)}
.side-resizer.dragging{background:linear-gradient(to right,transparent 0 6px,#ffffff33 6px 7px,transparent 7px 100%)}
@media (min-width:768px){.bar{padding:10px 14px}}
@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, 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" aria-hidden="true"><div class="dot"></div></div>
<div class="brand">PokeMaps Public Beta</div>
<div class="dock"><button id="savepin" class="iconbtn">⭐ Save Pin</button><button id="showpins" class="iconbtn">📒 Pins</button><button id="opensettings" class="iconbtn">⚙ Settings</button></div>
<div class="coords" id="coords"></div>
</div>
</div>
<!-- Mobile/Tablet popovers -->
<div class="menu" id="menu">
<header><strong>Menu</strong><button class="iconbtn" id="closeMenu">✕</button></header>
<div class="row"><select class="select" id="layer"><option value="mapnik">Standard</option><option value="cyclemap">Cycle</option><option value="transportmap">Transport</option><option value="hot">Humanitarian</option></select></div>
<div class="row"><button class="iconbtn" id="follow">🛰️ Follow</button><button class="iconbtn" id="reset">🔁 Reset</button></div>
<div class="row"><button class="iconbtn" id="quick-near">📍 Near me</button><button class="iconbtn" id="quick-clear">🧹 Clear</button></div>
<div class="row"><button class="iconbtn" id="copy">📋 Copy Link</button><button class="iconbtn" id="copycoords">📐 Coords</button></div>
<div class="row"><button class="iconbtn" id="share">🔗 Share</button><button class="iconbtn" id="openosm">🌐 Open OSM</button></div>
<div class="row"><button class="iconbtn" id="openGmaps">🛰️ Google Maps</button></div>
</div>
<div class="pins" id="pins">
<header><strong>Saved Pins</strong><button id="closepins" class="iconbtn" type="button">✕</button></header>
<ul id="pinlist"></ul>
</div>
<div class="settings" id="settings">
<header><strong>Settings</strong><span class="badge">Beta</span><button id="closesettings" class="iconbtn">✕</button></header>
<div class="group">
<div class="ghead"><div><strong>General</strong></div><button data-t="gen" class="iconbtn">Toggle</button></div>
<div class="section" data-sec="gen">
<div class="row"><label><input type="checkbox" id="autoFollow"> Auto-follow on load</label></div>
<div class="row"><label><input type="checkbox" id="toggleCoords"> Always show coordinates</label></div>
<div class="row"><label>Coordinates format</label><select id="coordFmt" class="select"><option value="dec">DD (Decimal)</option><option value="dms">DMS</option></select></div>
<div class="row"><label>Precision</label><input type="range" id="coordPrec" min="0" max="8" step="1" value="6"><span id="coordPrecVal">6</span></div>
<div class="row"><label>Theme</label><select id="themeMode" class="select"><option value="auto">Auto</option><option value="dark">Dark</option><option value="light">Light</option></select></div>
</div>
</div>
<div class="group">
<div class="ghead"><div><strong>Marker</strong></div><button data-t="mrk" class="iconbtn">Toggle</button></div>
<div class="section" data-sec="mrk">
<div class="row"><label>Visible</label><input type="checkbox" id="markerVisible" checked></div>
<div class="row"><label>Style</label><select id="markerStyle" class="select"><option value="dot">Dot</option><option value="crosshair">Crosshair</option></select></div>
<div class="row"><label>Size</label><input type="range" id="markerSize" min="8" max="64" step="1" value="20"><span id="markerSizeVal">20px</span></div>
<div class="row"><label>Color</label><input type="color" id="markerColor" value="#e53935"></div>
<div class="row"><label>Ring glow</label><input type="checkbox" id="markerRing" checked></div>
<div class="row"><label>Ring width</label><input type="range" id="ringWidth" min="0" max="16" step="1" value="3"><span id="ringWidthVal">3px</span></div>
</div>
</div>
<div class="group">
<div class="ghead"><div><strong>View & Links</strong></div><button data-t="view" class="iconbtn">Toggle</button></div>
<div class="section" data-sec="view">
<div class="row"><label><input type="checkbox" id="shareDelta" checked> Include Δ in shared links</label></div>
<div class="row"><label><input type="checkbox" id="confirmDelete" checked> Confirm before deleting pins</label></div>
<div class="row"><label>Accent</label><input type="color" id="accentColor" value="#0ea5e9"><button class="iconbtn" id="accentReset">Reset</button></div>
</div>
</div>
<div class="group">
<div class="ghead"><div><strong>Custom CSS</strong></div><button data-t="css" class="iconbtn">Toggle</button></div>
<div class="section" data-sec="css">
<div class="row" style="flex-direction:column;align-items:stretch">
<textarea id="customCSS" placeholder="/* Your CSS here */"></textarea>
<div style="display:flex;gap:8px;margin-top:8px"><button class="iconbtn" id="applyCSS">Apply</button><button class="iconbtn" id="clearCSS">Clear</button></div>
</div>
</div>
</div>
</div>
<script>
(()=>{ "use strict";
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 PREFS_KEY="pokemaps_prefs_min";
const LS_PINS="pokemaps_pins_v1";
const LS_SEARCH="pokemaps_search_hist_v1";
const S=s=>document.querySelector(s), map=S("#map"), q=S("#q"), sug=S("#suggestions");
const locateBtn=S("#locate"), menuBtn=S("#menuBtn"), menu=S("#menu"), closeMenu=S("#closeMenu");
const layerSel=S("#layer"), followBtn=S("#follow"), resetBtn=S("#reset");
const copyBtn=S("#copy"), copyCoordsBtn=S("#copycoords"), shareBtn=S("#share");
const openOSM=S("#openosm"), openGmaps=S("#openGmaps");
const pinsPane=S("#pins"), pinlist=S("#pinlist"), showpins=S("#showpins"), closepins=S("#closepins"), savepin=S("#savepin");
const opensettings=S("#opensettings"), settings=S("#settings"), closesettings=S("#closesettings");
const coordsEl=S("#coords"), themeColorMeta=S("#themeColorMeta");
const closeSuggestBtn=S("#closeSuggestBtn");
const quickNear=S("#quick-near"), quickClear=S("#quick-clear");
const autoFollowEl=S("#autoFollow"), toggleCoords=S("#toggleCoords"), coordFmtEl=S("#coordFmt"), coordPrecEl=S("#coordPrec"), coordPrecVal=S("#coordPrecVal"), themeModeEl=S("#themeMode");
const markerEl=S("#marker"), markerVisibleEl=S("#markerVisible"), markerSizeEl=S("#markerSize"), markerSizeVal=S("#markerSizeVal"), markerColorEl=S("#markerColor"), markerRingEl=S("#markerRing"), ringWidthEl=S("#ringWidth"), ringWidthVal=S("#ringWidthVal"), markerStyleEl=S("#markerStyle");
const shareDeltaEl=S("#shareDelta"), confirmDeleteEl=S("#confirmDelete"), accentColorEl=S("#accentColor"), accentResetEl=S("#accentReset");
const customCSSEl=S("#customCSS"), applyCSSEl=S("#applyCSS"), clearCSSEl=S("#clearCSS");
let aborter=null,lastQuery="",watchId=null,activeIndex=-1,lastCoordsStr="";
let state={...DEFAULT};
const prefs=loadPrefs();
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=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 parseSize=v=>Math.max(0,parseFloat(String(v).replace(/[^\d.]/g,''))||0);
const bboxFrom=(lat,lon,d)=>({l:(lon-d).toFixed(6),b:(lat-d).toFixed(6),r:(lon+d).toFixed(6),t:(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.l}%2C${b.b}%2C${b.r}%2C${b.t}&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 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 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; if(layerSel)layerSel.value=state.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 locate=()=>{"geolocation" in navigator?navigator.geolocation.getCurrentPosition(p=>centerOn(p.coords.latitude,p.coords.longitude,{push:true}),e=>alert("Unable to retrieve location: "+e.message),{enableHighAccuracy:true,timeout:10000,maximumAge:0}):alert("Geolocation not supported.")};
const startFollow=()=>{"geolocation" in navigator?(watchId??(followBtn&&(followBtn.textContent="🛰️ Following"),watchId=navigator.geolocation.watchPosition(p=>centerOn(p.coords.latitude,p.coords.longitude,{push:false}),_=>stopFollow(),{enableHighAccuracy:true,timeout:15000,maximumAge:1000}))):alert("Geolocation not supported.")};
const stopFollow=()=>{if(watchId!==null){navigator.geolocation.clearWatch(watchId);watchId=null} if(followBtn)followBtn.textContent="🛰️ Follow"};
const toggleFollow=()=>watchId===null?startFollow():stopFollow();
const copyLink=async()=>{try{await navigator.clipboard.writeText(appURL(state));alert("Link copied!")}catch{alert("Could not copy.")}};
const copyCoords=async()=>{const msg=coordString();try{await navigator.clipboard.writeText(msg);alert("Coordinates copied!")}catch{alert(msg)}};
const shareLink=async()=>{const url=appURL(state);if(navigator.share){try{await navigator.share({title:"PokeMaps Public Beta",url})}catch{}}else copyLink()};
const reset=()=>{stopFollow();state={...DEFAULT};q.value="";hideSuggest(true);layerSel&&(layerSel.value=state.layer);apply(true)};
const debounced=(fn,ms=250)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}};
const cleanStr=s=>s.replace(/\s+/g," ").trim();
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=a=>localStorage.setItem(LS_SEARCH,JSON.stringify(a.slice(0,12)));
const pushSearchHist=label=>{const h=loadSearchHist();const i=h.indexOf(label);if(i!==-1)h.splice(i,1);h.unshift(label);saveSearchHist(h)};
const renderSuggest=(items,term="")=>{
if(!sug)return; 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=(force=false)=>{sug.style.display="none";q.setAttribute("aria-expanded","false");activeIndex=-1};
const chooseItem=it=>{if(it.type==="history"){q.value=it.label;searchNow(it.label);return} if(it.type==="coords"){centerOn(it.lat,it.lon,{push:true});pushSearchHist(it.label);hideSuggest(true);return} if(it.type==="place"){q.value=it.label;centerOn(it.lat,it.lon,{push:true});pushSearchHist(it.label);hideSuggest(true)}};
const searchPlaces=debounced(async term=>{
term=cleanStr(term);activeIndex=-1;
if(!term){const hist=loadSearchHist();renderSuggest(hist.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}
aborter&&aborter.abort(); aborter=new AbortController();
try{const url=`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=8&addressdetails=0&accept-language=en`;
const r=await fetch(url,{signal:aborter.signal}); if(!r.ok)throw new Error(r.status);
const data=await r.json();
renderSuggest(data.map(p=>({type:"place",label:p.display_name,lat:+p.lat,lon:+p.lon})),term)
}catch{const hist=loadSearchHist();renderSuggest(hist.map(x=>({type:"history",label:x})),"")}
},130);
const searchNow=async term=>{
term=cleanStr(term); if(!term)return;
const coord=parseCoordInput(term); if(coord){centerOn(coord.lat,coord.lon,{push:true});pushSearchHist(`${coord.lat}, ${coord.lon}`);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});pushSearchHist(term)}}catch{}
};
// events: search box + suggest
q.addEventListener("input",e=>{const v=e.target.value;if(v===lastQuery)return;lastQuery=v;searchPlaces(v)},{passive:true});
q.addEventListener("focus",()=>{if(!q.value){const hist=loadSearchHist();renderSuggest(hist.map(x=>({type:"history",label:x})))}else searchPlaces(q.value)});
document.addEventListener("click",e=>{const within=e.target.closest(".search")||e.target.closest("#suggestions");if(!within)hideSuggest(true)});
closeSuggestBtn?.addEventListener("click",()=>hideSuggest(true));
q.addEventListener("keydown",e=>{
const visible=sug.style.display==="block"; if(e.key==="Escape"){hideSuggest(true);return} if(!visible)return;
const items=[...sug.querySelectorAll("li")].filter(li=>!li.classList.contains("head")); if(!items.length)return;
if(e.key==="ArrowDown"){e.preventDefault();activeIndex=(activeIndex+1)%items.length;activate(items)}
if(e.key==="ArrowUp"){e.preventDefault();activeIndex=(activeIndex-1+items.length)%items.length;activate(items)}
if(e.key==="Enter"){e.preventDefault(); if(activeIndex>=0)items[activeIndex].dispatchEvent(new MouseEvent("mousedown")); else searchNow(q.value)}
});
const activate=items=>{items.forEach(x=>x.classList.remove("active")); if(activeIndex>=0){items[activeIndex].classList.add("active");items[activeIndex].scrollIntoView({block:"nearest"})}};
S("#form").addEventListener("submit",async e=>{e.preventDefault();await searchNow(q.value);hideSuggest(true)});
// quick actions
quickNear&&(quickNear.onclick=locate);
quickClear&&(quickClear.onclick=()=>{q.value="";searchPlaces("");q.focus()});
locateBtn.onclick=locate;
layerSel&&(layerSel.onchange=()=>setLayer(layerSel.value));
followBtn&&(followBtn.onclick=toggleFollow);
resetBtn&&(resetBtn.onclick=reset);
shareBtn&&(shareBtn.onclick=shareLink);
copyBtn&&(copyBtn.onclick=copyLink);
copyCoordsBtn&&(copyCoordsBtn.onclick=copyCoords);
openOSM&&(openOSM.onclick=()=>open(osmViewURL(state),"_blank"));
openGmaps&&(openGmaps.onclick=()=>open(gmapsURL(state),"_blank"));
// pins
const loadPins=()=>{try{return JSON.parse(localStorage.getItem(LS_PINS)||"[]")}catch{return[]}};
const savePins=p=>localStorage.setItem(LS_PINS,JSON.stringify(p.slice(0,100)));
const renderPins=()=>{const pins=loadPins();pinlist.innerHTML="";if(!pins.length){const li=document.createElement("li");li.textContent="No pins yet.";pinlist.appendChild(li);return}
pins.forEach((p,idx)=>{const li=document.createElement("li");
const head=document.createElement("div");head.innerHTML=`<strong>${p.name}</strong><div class="tag">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)} · ${p.layer}</div>`;
const row=document.createElement("div");row.className="row";
const go=btn("Go",()=>{pinsPane.style.display="none";state={...state,lat:p.lat,lon:p.lon,delta:p.delta,layer:p.layer};layerSel&&(layerSel.value=p.layer);apply(true)});
const share=btn("Share",async()=>{const url=appURL(p);if(navigator.share){try{await navigator.share({title:"PokeMaps Public Beta",url})}catch{}}else{try{await navigator.clipboard.writeText(url);alert("Link copied!") }catch{}}});
const copyc=btn("Copy Coords",()=>navigator.clipboard.writeText(`${p.lat.toFixed(6)},${p.lon.toFixed(6)}`).then(()=>alert("Copied!")));
const ren=btn("Rename",()=>{const nv=prompt("New name:",p.name||"");if(nv!==null){const arr=loadPins();arr[idx].name=(nv||"").trim()||new Date().toLocaleString();savePins(arr);renderPins() }});
const del=btn("Del",()=>{if(confirmDeleteEl.checked&&!confirm("Delete pin?"))return;const arr=loadPins();arr.splice(idx,1);savePins(arr);renderPins()});
row.append(go,share,copyc,ren,del); li.append(head,row); pinlist.appendChild(li);
})
};
const btn=(t,fn)=>{const b=document.createElement("button");b.textContent=t;b.className="iconbtn";b.onclick=fn;return b};
const nameFromState=()=>q.value?.trim()||new Date().toLocaleString();
savepin.onclick=()=>{const pins=loadPins();pins.unshift({name:nameFromState(),lat:state.lat,lon:state.lon,delta:state.delta,layer:state.layer});savePins(pins);renderPins();pinsPane.style.display="block"};
showpins.onclick=()=>{renderPins();pinsPane.style.display="block"};
closepins.onclick=()=>{pinsPane.style.display="none"};
// mobile panels
menuBtn.onclick=()=>{if(isDesktop())return;menu.style.display="block"};
closeMenu&&(closeMenu.onclick=()=>{menu.style.display="none"});
opensettings.onclick=()=>{if(isDesktop())return;settings.style.display="block"};
closesettings.onclick=()=>{settings.style.display="none"};
// prefs -> UI
function loadPrefs(){try{return JSON.parse(localStorage.getItem(PREFS_KEY)||"{}")}catch{return{}}}
function savePrefs(){localStorage.setItem(PREFS_KEY,JSON.stringify(prefs))}
function applyPrefs(){
document.documentElement.style.setProperty("--marker-size",(prefs.markerSize||20)+"px");
document.documentElement.style.setProperty("--marker-color",prefs.markerColor||"#e53935");
document.documentElement.style.setProperty("--marker-ring",(prefs.markerRing===false)?"transparent":"rgba(229,57,53,.35)");
document.documentElement.style.setProperty("--ring-width",(prefs.ringWidth!=null?prefs.ringWidth:3)+"px");
if(prefs.markerHidden)markerEl.classList.add("hidden");
markerEl.classList.toggle("crosshair",(prefs.markerStyle||"dot")==="crosshair");
setAccent(prefs.accent||"#0ea5e9");
applyTheme(prefs.theme||"auto");
coordsEl.style.display=prefs.showCoords?"block":"none";
if(prefs.sidebarW){const min=parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-min'));const max=innerWidth*0.6;const w=clamp(prefs.sidebarW,min,max);document.documentElement.style.setProperty("--sidebar-w",w+"px")}
}
function setAccent(hex){document.documentElement.style.setProperty("--accent",hex);themeColorMeta?.setAttribute("content",hex)}
function applyTheme(mode){document.documentElement.style.colorScheme=mode==="auto"?"":mode}
// settings bind
toggleCoords.checked=!!prefs.showCoords;
coordFmtEl.value=prefs.coordFmt||"dec";
coordPrecEl.value=prefs.prec??6; coordPrecVal.textContent=String(prefs.prec??6);
themeModeEl.value=prefs.theme||"auto";
autoFollowEl.checked=!!prefs.autoFollow;
shareDeltaEl.checked=prefs.includeDelta!==false;
confirmDeleteEl.checked=prefs.confirmDelete!==false;
markerVisibleEl.checked=!prefs.markerHidden;
markerSizeEl.value=prefs.markerSize||20; markerSizeVal.textContent=markerSizeEl.value+"px";
markerColorEl.value=prefs.markerColor||"#e53935";
markerRingEl.checked=prefs.markerRing!==false;
ringWidthEl.value=prefs.ringWidth!=null?prefs.ringWidth:3; ringWidthVal.textContent=ringWidthEl.value+"px";
markerStyleEl.value=prefs.markerStyle||"dot";
accentColorEl.value=prefs.accent||"#0ea5e9";
customCSSEl.value=prefs.userCSS||"";
// toggles
document.querySelectorAll(".settings .ghead button").forEach(b=>b.onclick=()=>{const t=b.getAttribute("data-t");const sec=document.querySelector(`.settings .section[data-sec="${t}"]`);sec.style.display=sec.style.display==="block"?"none":"block"});
markerVisibleEl.onchange=()=>{markerEl.classList.toggle("hidden",!markerVisibleEl.checked);prefs.markerHidden=!markerVisibleEl.checked;savePrefs()};
markerSizeEl.oninput=()=>{document.documentElement.style.setProperty("--marker-size",markerSizeEl.value+"px");markerSizeVal.textContent=markerSizeEl.value+"px";prefs.markerSize=+markerSizeEl.value;savePrefs()};
markerColorEl.oninput=()=>{document.documentElement.style.setProperty("--marker-color",markerColorEl.value);prefs.markerColor=markerColorEl.value;savePrefs()};
markerRingEl.onchange=()=>{document.documentElement.style.setProperty("--marker-ring",markerRingEl.checked?"rgba(229,57,53,.35)":"transparent");prefs.markerRing=markerRingEl.checked;savePrefs()};
ringWidthEl.oninput=()=>{document.documentElement.style.setProperty("--ring-width",ringWidthEl.value+"px");ringWidthVal.textContent=ringWidthEl.value+"px";prefs.ringWidth=+ringWidthEl.value;savePrefs()};
markerStyleEl.onchange=()=>{markerEl.classList.toggle("crosshair",markerStyleEl.value==="crosshair");prefs.markerStyle=markerStyleEl.value;savePrefs()};
toggleCoords.onchange=()=>{prefs.showCoords=toggleCoords.checked;coordsEl.style.display=prefs.showCoords?"block":"none";savePrefs()};
coordFmtEl.onchange=()=>{prefs.coordFmt=coordFmtEl.value;savePrefs()};
coordPrecEl.oninput=()=>{prefs.prec=+coordPrecEl.value;coordPrecVal.textContent=String(prefs.prec);savePrefs()};
autoFollowEl.onchange=()=>{prefs.autoFollow=autoFollowEl.checked;savePrefs()};
shareDeltaEl.onchange=()=>{prefs.includeDelta=shareDeltaEl.checked;savePrefs()};
confirmDeleteEl.onchange=()=>{prefs.confirmDelete=confirmDeleteEl.checked;savePrefs()};
themeModeEl.onchange=()=>{prefs.theme=themeModeEl.value;applyTheme(prefs.theme);savePrefs()};
accentColorEl.oninput=()=>{setAccent(accentColorEl.value);prefs.accent=accentColorEl.value;savePrefs()};
accentResetEl.onclick=()=>{const def="#0ea5e9";accentColorEl.value=def;setAccent(def);prefs.accent=def;savePrefs()};
applyCSSEl.onclick=()=>{const css=customCSSEl.value||"";prefs.userCSS=css;applyUserCSS(css);savePrefs()};
clearCSSEl.onclick=()=>{customCSSEl.value="";prefs.userCSS="";applyUserCSS("");savePrefs()};
function applyUserCSS(css){let tag=document.getElementById("user-css");if(!tag){tag=document.createElement("style");tag.id="user-css";document.head.appendChild(tag)}tag.textContent=css||""}
// history/nav/pwa
addEventListener("popstate",e=>{stopFollow(); if(e.state&&typeof e.state.lat==="number"){state=e.state; layerSel&&(layerSel.value=state.layer); apply(false)}else{parseURL(); layerSel&&(layerSel.value=state.layer); apply(false)}});
(function injectManifest(){const manifest={name:"PokeMaps Public Beta",short_name:"PokeMaps",start_url:location.pathname,display:"standalone",background_color:"#000",theme_color:getComputedStyle(document.documentElement).getPropertyValue("--accent").trim()||"#0ea5e9",icons:[{src:"/css/yt-ukraine.svg",sizes:"any",type:"image/svg+xml",purpose:"any"}]};const blob=new Blob([JSON.stringify(manifest)],{type:"application/manifest+json"});const url=URL.createObjectURL(blob);const link=document.createElement("link");link.rel="manifest";link.href=url;document.head.appendChild(link)})();
// desktop sidebar + resizer
if(isDesktop()){buildDesktopSidebar();setupSidebarResizer()}
function setupSidebarResizer(){
let handle=document.querySelector(".side-resizer"); if(!handle){handle=document.createElement("div");handle.className="side-resizer";document.body.appendChild(handle)}
const minPx=()=>parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-min'));
const maxPx=()=>Math.min(innerWidth*0.6,parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-max'))||innerWidth*0.6);
let dragging=false,startX=0,startW=0;
const down=e=>{if(!isDesktop())return;dragging=true;startX=e.clientX??(e.touches?.[0]?.clientX||0);startW=parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w'))||320;handle.classList.add("dragging");addEventListener("pointermove",move);addEventListener("pointerup",up);e.preventDefault()};
const move=e=>{if(!dragging)return;const x=e.clientX??(e.touches?.[0]?.clientX||0);const raw=startW+(x-startX);const clamped=clamp(raw,minPx(),maxPx());document.documentElement.style.setProperty('--sidebar-w',clamped+'px')};
const up=()=>{if(!dragging)return;dragging=false;handle.classList.remove("dragging");const w=parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w'))||320;prefs.sidebarW=Math.round(clamp(w,minPx(),maxPx()));savePrefs();removeEventListener("pointermove",move);removeEventListener("pointerup",up)};
handle.addEventListener("pointerdown",down);
addEventListener("resize",()=>{if(!isDesktop())return;const w=parseSize(getComputedStyle(document.documentElement).getPropertyValue('--sidebar-w'))||320;const clamped=clamp(w,minPx(),maxPx());document.documentElement.style.setProperty('--sidebar-w',clamped+'px')},{passive:true});
}
function buildDesktopSidebar(){
const side=document.createElement("div");side.className="sidebar";
side.innerHTML=`
<div class="sidecard">
<header><span>Overview</span></header>
<div class="row"><input id="d_search" type="text" placeholder="Search or lat,lon…" class="select" style="width:100%"></div>
<div class="row"><select class="select" id="d_layer"><option value="mapnik">Standard</option><option value="cyclemap">Cycle</option><option value="transportmap">Transport</option><option value="hot">Humanitarian</option></select></div>
<div class="row"><button class="iconbtn" id="d_locate">Locate</button><button class="iconbtn" id="d_follow">Follow</button><button class="iconbtn" id="d_reset">Reset</button></div>
<div class="row"><button class="iconbtn" id="d_copy">Copy Link</button><button class="iconbtn" id="d_copyc">Copy Coords</button></div>
<div class="row"><button class="iconbtn" id="d_osm">Open OSM</button><button class="iconbtn" id="d_gmaps">Google Maps</button></div>
</div>
<div class="sidecard">
<header>Marker</header>
<div class="row"><label style="flex:1">Visible</label><input type="checkbox" id="d_markerv"></div>
<div class="row"><label style="flex:1">Style</label><select class="select" id="d_style"><option value="dot">Dot</option><option value="crosshair">Crosshair</option></select></div>
<div class="row"><label style="flex:1">Size</label><input type="range" id="d_ms" min="8" max="64"><span id="d_msv"></span></div>
<div class="row"><label style="flex:1">Color</label><input type="color" id="d_mc"></div>
<div class="row"><label style="flex:1">Glow</label><input type="checkbox" id="d_mr"></div>
<div class="row"><label style="flex:1">Ring width</label><input type="range" id="d_rw" min="0" max="16"><span id="d_rwv"></span></div>
</div>
<div class="sidecard">
<header>Pins</header>
<div class="row"><button class="iconbtn" id="d_save">Save current</button><button class="iconbtn" id="d_showpins">Manage</button></div>
<div class="row" id="d_pinpeek" style="flex-direction:column;align-items:stretch;gap:6px"><small style="opacity:.75">Recent:</small><div id="d_pinlistmini" style="display:flex;flex-direction:column;gap:6px"></div></div>
</div>
<div class="sidecard">
<header>Display</header>
<div class="row"><label style="flex:1">Always show coords</label><input type="checkbox" id="d_showc"></div>
<div class="row"><select class="select" id="d_fmt"><option value="dec">DD (Decimal)</option><option value="dms">DMS</option></select></div>
<div class="row"><label style="flex:1">Precision</label><input type="range" id="d_prec" min="0" max="8"><span id="d_precv"></span></div>
<div class="row"><label style="flex:1">Accent</label><input type="color" id="d_accent"><button class="iconbtn" id="d_accent_reset">Reset</button></div>
<div class="row"><label style="flex:1">Theme</label><select id="d_theme" class="select"><option value="auto">Auto</option><option value="dark">Dark</option><option value="light">Light</option></select></div>
</div>
<div class="sidecard">
<header>Custom CSS</header>
<div class="row" style="flex-direction:column;align-items:stretch">
<textarea id="d_usercss" style="width:100%;min-height:100px"></textarea>
<div style="display:flex;gap:8px;margin-top:8px"><button class="iconbtn" id="d_applycss">Apply</button><button class="iconbtn" id="d_clearcss">Clear</button></div>
</div>
</div>
<div class="sidecard"><header>About</header><div class="row"><small>© OSM contributors • Viewer: PokeMaps Public Beta</small></div></div>
`;
document.body.appendChild(side);
const d_search=side.querySelector("#d_search");
d_search.value=q.value||""; d_search.addEventListener("input",()=>{q.value=d_search.value;q.dispatchEvent(new Event("input",{bubbles:true}))});
const d_layer=side.querySelector("#d_layer"); d_layer.value=state.layer; d_layer.onchange=()=>setLayer(d_layer.value);
side.querySelector("#d_locate").onclick=locate;
side.querySelector("#d_follow").onclick=toggleFollow;
side.querySelector("#d_reset").onclick=reset;
side.querySelector("#d_copy").onclick=copyLink;
side.querySelector("#d_copyc").onclick=copyCoords;
side.querySelector("#d_osm").onclick=()=>open(osmViewURL(state),"_blank");
side.querySelector("#d_gmaps").onclick=()=>open(gmapsURL(state),"_blank");
const d_markerv=side.querySelector("#d_markerv"),d_style=side.querySelector("#d_style"),d_ms=side.querySelector("#d_ms"),d_msv=side.querySelector("#d_msv"),d_mc=side.querySelector("#d_mc"),d_mr=side.querySelector("#d_mr"),d_rw=side.querySelector("#d_rw"),d_rwv=side.querySelector("#d_rwv");
d_markerv.checked=!prefs.markerHidden; d_style.value=prefs.markerStyle||"dot"; d_ms.value=markerSizeEl.value; d_msv.textContent=d_ms.value+"px"; d_mc.value=markerColorEl.value; d_mr.checked=markerRingEl.checked; d_rw.value=ringWidthEl.value; d_rwv.textContent=d_rw.value+"px";
d_markerv.onchange=()=>{markerVisibleEl.checked=d_markerv.checked;markerVisibleEl.onchange()};
d_style.onchange=()=>{markerStyleEl.value=d_style.value;markerStyleEl.onchange()};
d_ms.oninput=()=>{markerSizeEl.value=d_ms.value;markerSizeEl.oninput();d_msv.textContent=d_ms.value+"px"};
d_mc.oninput=()=>{markerColorEl.value=d_mc.value;markerColorEl.oninput()};
d_mr.onchange=()=>{markerRingEl.checked=d_mr.checked;markerRingEl.onchange()};
d_rw.oninput=()=>{ringWidthEl.value=d_rw.value;ringWidthEl.oninput();d_rwv.textContent=d_rw.value+"px"};
const d_save=side.querySelector("#d_save"),d_showpins=side.querySelector("#d_showpins"),d_pinlistmini=side.querySelector("#d_pinlistmini");
d_save.onclick=()=>savepin.click(); d_showpins.onclick=()=>{renderPins();pinsPane.style.display="block"};
const renderPinsMini=()=>{d_pinlistmini.innerHTML="";const pins=loadPins().slice(0,6);if(!pins.length){d_pinlistmini.innerHTML='<small style="opacity:.6">No pins yet.</small>';return}
pins.forEach(p=>{const row=document.createElement("div");row.style.display="flex";row.style.gap="6px";row.style.alignItems="center";row.style.justifyContent="space-between";const txt=document.createElement("div");txt.innerHTML=`<strong style="font-size:12px">${p.name}</strong><br><span style="opacity:.7;font-size:11px">${p.lat.toFixed(5)}, ${p.lon.toFixed(5)}</span>`;const go=document.createElement("button");go.className="iconbtn";go.textContent="Go";go.onclick=()=>{state={...state,lat:p.lat,lon:p.lon,delta:p.delta,layer:p.layer};layerSel&&(layerSel.value=p.layer);apply(true)};row.append(txt,go);d_pinlistmini.appendChild(row)})};
renderPinsMini();
const d_showc=side.querySelector("#d_showc"),d_fmt=side.querySelector("#d_fmt"),d_prec=side.querySelector("#d_prec"),d_precv=side.querySelector("#d_precv"),d_accent=side.querySelector("#d_accent"),d_accent_reset=side.querySelector("#d_accent_reset"),d_theme=side.querySelector("#d_theme");
d_showc.checked=!!prefs.showCoords; d_fmt.value=prefs.coordFmt||"dec"; d_prec.value=prefs.prec??6; d_precv.textContent=String(prefs.prec??6); d_accent.value=prefs.accent||"#0ea5e9"; d_theme.value=prefs.theme||"auto";
d_showc.onchange=()=>{toggleCoords.checked=d_showc.checked;toggleCoords.onchange()};
d_fmt.onchange=()=>{coordFmtEl.value=d_fmt.value;coordFmtEl.onchange()};
d_prec.oninput=()=>{coordPrecEl.value=d_prec.value;coordPrecEl.oninput();d_precv.textContent=d_prec.value};
d_accent.oninput=()=>{accentColorEl.value=d_accent.value;accentColorEl.oninput()};
d_accent_reset.onclick=()=>{accentResetEl.click();d_accent.value=accentColorEl.value};
d_theme.onchange=()=>{themeModeEl.value=d_theme.value;themeModeEl.onchange()};
const d_usercss=side.querySelector("#d_usercss"),d_applycss=side.querySelector("#d_applycss"),d_clearcss=side.querySelector("#d_clearcss");
d_usercss.value=prefs.userCSS||""; d_applycss.onclick=()=>applyCSSEl.onclick(); d_clearcss.onclick=()=>clearCSSEl.onclick();
}
// init
applyPrefs(); parseURL(); apply(false);
const urlHasLat=new URLSearchParams(location.search).has("lat");
if(!urlHasLat)locate();
if(prefs.autoFollow)startFollow();
(function tick(){updateCoordsFast();setTimeout(tick,120)})();
})();
</script>
</body>
</html>