poke/html/map.ejs
2025-08-17 23:33:40 +02:00

120 lines
34 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;
}
.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);
}
/* Sidebar resizing and scrolling */
.desktop .sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 320px;
max-width: 100%;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
z-index: 9;
overflow-y: auto;
resize: horizontal;
}
/* Hide dock buttons on desktop */
.desktop .dock { display: none; }
/* Ensure mobile UI also in sidebar */
.menu, .pins, .settings {
display: block !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 suggestions">✕</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">⭐ Save Pin</button>
<button id="showpins" title="Show saved pins">📒 Pins</button>
<button id="opensettings" title="Marker & display settings">⚙ Settings</button>
</div>
<div class="coords" id="coords"></div>
</div>
</div>
<script src="/static/data-mobile.js" defer></script>
<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"), closeMenu=S("#closeMenu"); const layerSel=S("#layer"), followBtn=S("#follow"), resetBtn=S("#reset"), shareBtn=S("#share"); const copyBtn=S("#copy"), copyCoordsBtn=S("#copycoords"), openBtn=S("#openosm"), openGmapsBtn=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"); const quickNear=S("#quick-near"), quickClear=S("#quick-clear"); const themeColorMeta=S("#themeColorMeta"); const autoFollowEl=S("#autoFollow"); const toggleCoords=S("#toggleCoords"); const coordFmtEl=S("#coordFmt"); const coordPrecEl=S("#coordPrec"); const coordPrecVal=S("#coordPrecVal"); const 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"); const accentColorEl=S("#accentColor"), accentResetEl=S("#accentReset"); const customCSSEl=S("#customCSS"), applyCSSEl=S("#applyCSS"), clearCSSEl=S("#clearCSS"); /* Pro mode */ 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 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,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; 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 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 startFollow=()=>{ if(!("geolocation" in navigator)){ alert("Geolocation not supported."); return } if(watchId!==null) return; followBtn && (followBtn.textContent="🛰️ Following"); watchId = navigator.geolocation.watchPosition( pos=>{ centerOn(pos.coords.latitude,pos.coords.longitude,{push:false}) }, _=>stopFollow(), {enableHighAccuracy:true,timeout:15000,maximumAge:1000} ) }; const stopFollow=()=>{ if(watchId!==null){ navigator.geolocation.clearWatch(watchId); watchId=null } ; 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 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 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; // keep the sticky head; rebuild the rest 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)=>{ if(force){ sug.style.display="none"; q.setAttribute("aria-expanded","false"); activeIndex=-1; return } 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); return } }; 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 } if(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("HTTP "+r.status); const data=await r.json(); renderSuggest(data.map(p=>({type:"place",label:p.display_name,lat:+p.lat,lon:+p.lon,raw:p})), term); }catch(e){ const hist=loadSearchHist(); renderSuggest(hist.map(x=>({type:"history",label:x})), ""); } },140); 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{} }; // Suggestion UX 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 (Menu) 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); openBtn && (openBtn.onclick=()=>window.open(osmViewURL(state), "_blank")); openGmapsBtn && (openGmapsBtn.onclick=()=>window.open(gmapsURL(state), "_blank")); // Pins const loadPins=()=>{ try{ return JSON.parse(localStorage.getItem(LS_PINS)||"[]") }catch{ return [] } }; const savePins=(pins)=>{ localStorage.setItem(LS_PINS, JSON.stringify(pins.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 actions=document.createElement("div"); actions.className="row"; const go=button("Go",()=>{ pinsPane.style.display="none"; state.lat=p.lat; state.lon=p.lon; state.delta=p.delta; state.layer=p.layer; layerSel && (layerSel.value=p.layer); apply(true) }); const share=button("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=button("Copy Coords",()=>navigator.clipboard.writeText(${p.lat.toFixed(6)},${p.lon.toFixed(6)}).then(()=>alert("Copied!"))); const ren=button("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=button("Del",()=>{ if(confirmDeleteEl.checked){ if(!confirm("Delete pin?")) return } const arr=loadPins(); arr.splice(idx,1); savePins(arr); renderPins() }); actions.append(go,share,copyc,ren,del); li.append(head,actions); pinlist.appendChild(li); }); }; function button(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" }; // Panels (mobile/tablet only; desktop has sidebar) 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 toggleCoords.checked = !!prefs.showCoords; coordsEl.style.display = prefs.showCoords ? "block" : "none"; 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"; applyMarkerStyle(markerStyleEl.value); accentColorEl.value = prefs.accent || "#0ea5e9"; setAccent(prefs.accent || "#0ea5e9"); customCSSEl.value = prefs.userCSS || ""; applyUserCSS(prefs.userCSS||""); 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" } }); // Pref listeners 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=()=>{ applyMarkerStyle(markerStyleEl.value); 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() }; addEventListener("keydown",(e)=>{ if(e.target.matches("input, textarea")) return; if(e.key.toLowerCase()==="l"){ e.preventDefault(); locate() } if(e.key.toLowerCase()==="s"){ e.preventDefault(); savepin.click() } if(e.key.toLowerCase()==="h"){ e.preventDefault(); toggleFollow() } if(e.key.toLowerCase()==="r"){ e.preventDefault(); reset() } if(e.key.toLowerCase()==="1"){ e.preventDefault(); setLayer("mapnik") } if(e.key.toLowerCase()==="2"){ e.preventDefault(); setLayer("cyclemap") } if(e.key.toLowerCase()==="3"){ e.preventDefault(); setLayer("transportmap") } if(e.key.toLowerCase()==="4"){ e.preventDefault(); setLayer("hot") } if(e.key==="/"){ e.preventDefault(); q.focus() } if(e.key==="Escape"){ menu.style.display="none"; settings.style.display="none"; pinsPane.style.display="none"; hideSuggest(true) } }); 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 loadPrefs(){ try{ return JSON.parse(localStorage.getItem(PREFS_KEY)||"{}") }catch{ return {} } } function savePrefs(){ localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } function applyPrefs(){ if(prefs.markerSize) document.documentElement.style.setProperty("--marker-size", prefs.markerSize+"px"); if(prefs.markerColor) document.documentElement.style.setProperty("--marker-color", prefs.markerColor); 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"); applyMarkerStyle(prefs.markerStyle||"dot"); setAccent(prefs.accent||"#0ea5e9"); applyTheme(prefs.theme||"auto"); // apply pro mode class on body document.body.classList.toggle("pro-enabled", !!prefs.proMode); } function applyMarkerStyle(style){ markerEl.classList.toggle("crosshair", style==="crosshair") } function setAccent(hex){ document.documentElement.style.setProperty("--accent",hex); themeColorMeta?.setAttribute("content",hex) } function applyTheme(mode){ if(mode==="dark"){ document.documentElement.style.colorScheme="dark" } else if(mode==="light"){ document.documentElement.style.colorScheme="light" } else { document.documentElement.style.colorScheme="" } } // PWA manifest (runtime-injected, no service worker) (function injectManifest(){ const manifest={ name:"PokeMaps Public Beta", short_name:"PokeMaps", start_url: location.pathname, display:"standalone", background_color:"#000000", 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); })(); // Custom CSS injector 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||""; } // Init navigation parseURL(); apply(false); const urlHasLat = new URLSearchParams(location.search).has("lat"); if(!urlHasLat){ locate() } if(prefs.autoFollow) startFollow(); // Desktop sidebar builder (clean, with Pro toggle) if(isDesktop()) buildDesktopSidebar(); function buildDesktopSidebar(){ const side=document.createElement("div"); side.className="sidebar"; side.innerHTML= <div class="sidecard"> <header> <span>Overview</span> <label style="font-weight:400;font-size:12px;display:flex;align-items:center;gap:6px;"> <input type="checkbox" id="pro_toggle" ${prefs.proMode?'checked':''}/> Pro </label> </header> <div class="row"><input id="d_searchmirror" type="text" placeholder="Search or lat,lon…" class="select" style="width:100%;background:#111;border:1px solid var(--border);border-radius:10px;padding:10px 12px"></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 in OSM</button><button class="iconbtn" id="d_gmaps">Google Maps</button></div> <div class="hint">Tip: / to focus search • 14 change layers • S save pin • R reset</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 pro-only"><label style="flex:1">Glow</label><input type="checkbox" id="d_mr"></div> <div class="row pro-only"><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 pro-only"><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 pro-only"><button class="iconbtn" id="d_share">Share</button><button class="iconbtn" id="d_copy2">Copy Link</button></div> </div> <div class="sidecard pro-only"> <header>Custom CSS</header> <div class="row" style="flex-direction:column;align-items:stretch"> <textarea id="d_usercss" style="width:100%;min-height:100px;border-radius:10px;border:1px solid var(--border);background:#0a0a0a;color:#eee;padding:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" placeholder="/* Your CSS here */"></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>Powered by openstreetmap.org • Data © OSM contributors • Public Beta</small></div> </div> ; document.body.appendChild(side); // Mirror search const d_searchmirror=side.querySelector("#d_searchmirror"); d_searchmirror.value=q.value||""; d_searchmirror.addEventListener("input",()=>{ q.value=d_searchmirror.value; q.dispatchEvent(new Event("input",{bubbles:true})) }); // Pro toggle const proToggle=side.querySelector("#pro_toggle"); const updatePro=()=>{ prefs.proMode = proToggle.checked; savePrefs(); document.body.classList.toggle("pro-enabled", !!prefs.proMode) }; proToggle.addEventListener("change",updatePro); document.body.classList.toggle("pro-enabled", !!prefs.proMode); const d_layer=side.querySelector("#d_layer"); side.querySelector("#d_osm").onclick=()=>window.open(osmViewURL(state),"_blank"); side.querySelector("#d_copy").onclick=copyLink; side.querySelector("#d_copy2").onclick=copyLink; side.querySelector("#d_share").onclick=shareLink; side.querySelector("#d_locate").onclick=locate; side.querySelector("#d_follow").onclick=toggleFollow; side.querySelector("#d_reset").onclick=reset; side.querySelector("#d_gmaps").onclick=()=>window.open(gmapsURL(state), "_blank"); d_layer.value=state.layer; d_layer.onchange=()=>setLayer(d_layer.value); // Marker group const d_markerv=side.querySelector("#d_markerv"); const d_style=side.querySelector("#d_style"); const d_ms=side.querySelector("#d_ms"), d_msv=side.querySelector("#d_msv"); const d_mc=side.querySelector("#d_mc"); const d_mr=side.querySelector("#d_mr"); const 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 && (d_mr.checked=markerRingEl.checked); d_rw && (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 && (d_mr.onchange=()=>{ markerRingEl.checked=d_mr.checked; markerRingEl.onchange() }); d_rw && (d_rw.oninput=()=>{ ringWidthEl.value=d_rw.value; ringWidthEl.oninput(); d_rwv.textContent=d_rw.value+"px" }); // Pins mini const d_save=side.querySelector("#d_save"); const d_showpins=side.querySelector("#d_showpins"); const 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,idx)=>{ 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.lat=p.lat; state.lon=p.lon; state.delta=p.delta; state.layer=p.layer; layerSel && (layerSel.value=p.layer); apply(true) }; row.append(txt,go); d_pinlistmini.appendChild(row); }); }; renderPinsMini(); // Display group const d_showc=side.querySelector("#d_showc"); const d_fmt=side.querySelector("#d_fmt"); const d_prec=side.querySelector("#d_prec"), d_precv=side.querySelector("#d_precv"); const d_accent=side.querySelector("#d_accent"), d_accent_reset=side.querySelector("#d_accent_reset"); d_showc.checked=!!prefs.showCoords; d_fmt.value=prefs.coordFmt||"dec"; d_prec && (d_prec.value=prefs.prec??6, d_precv.textContent=String(prefs.prec??6)); d_accent.value=prefs.accent||"#0ea5e9"; d_showc.onchange=()=>{ toggleCoords.checked=d_showc.checked; toggleCoords.onchange() }; d_fmt.onchange=()=>{ coordFmtEl.value=d_fmt.value; coordFmtEl.onchange() }; d_prec && (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 }; // Custom CSS const d_usercss=side.querySelector("#d_usercss"); const d_applycss=side.querySelector("#d_applycss"); const d_clearcss=side.querySelector("#d_clearcss"); if(d_usercss){ d_usercss.value=prefs.userCSS||""; d_applycss.onclick=()=>applyCSSEl.onclick(); d_clearcss.onclick=()=>clearCSSEl.onclick() } } function tickCoords(){ updateCoordsFast(); setTimeout(tickCoords,120) } tickCoords(); })(); </body>
</html>