poke/html/map.ejs
2025-08-17 13:33:38 +02:00

592 lines
32 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 Beta</title>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/css/yt-ukraine.svg" />
<style>
:root{
--vh:100vh;--pad:12px;--radius:14px;--fg:#fff;--bg:rgba(0,0,0,.6);--glass:blur(12px);
--marker-size:20px;--marker-color:#e53935;--marker-ring:rgba(229,57,53,.35);--ring-width:3px;
--panel:rgba(0,0,0,.85)
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{background:#000;font:14px/1.4 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial;color:var(--fg)}
.app{position:fixed;inset:0;display:grid;grid-template-rows:auto 1fr}
.bar{position:relative;z-index:5;display:flex;gap:8px;align-items:center;padding:8px var(--pad);
backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);background:var(--bg)}
.search{position:relative;flex:1;min-width:0}
.search input{width:100%;padding:10px 12px;border-radius:10px;border:1px solid #333;background:#111;color:#fff;outline:none}
.suggest{position:absolute;top: calc(100% + 6px);left:0;right:0;max-height:42vh;overflow:auto;margin:0;padding:6px 0;list-style:none;border:1px solid #333;border-radius:10px;background:#0b0b0b;display:none}
.suggest li{padding:10px 12px;cursor:pointer;border-top:1px solid #111}
.suggest li:first-child{border-top:none}
.iconbtn{border:0;border-radius:10px;background:#222;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 (dot by default) */
.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}
/* Crosshair variant */
.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.crosshair .dot{display:none}
.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-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}
/* Bottom-left dock above PokeMaps */
.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 #333;display:none;min-width:240px;text-align:right}
.menu{position:fixed;top:56px;right:10px;z-index:8;min-width:260px;max-width:92vw;
background:var(--panel);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);
border:1px solid #333;border-radius:12px;display:none;overflow:hidden}
.menu header{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid #222}
.menu .row{display:flex;gap:8px;align-items:center;padding:8px 12px;border-top:1px solid #111}
.menu .row:first-of-type{border-top:0}
.menu .row button,.menu .row select,.menu .row input{flex:1}
.select{appearance:none;-webkit-appearance:none;border:1px solid #333;background:#111;color:#fff;border-radius:10px;padding:10px 12px}
.pins{position:fixed;left:10px;bottom:110px;z-index:8;min-width:260px;max-width:92vw;max-height:50vh;overflow:auto;background:var(--panel);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border:1px solid #333;border-radius:12px;display:none}
.pins header{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-bottom:1px solid #222}
.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}
.pins li:first-child{border-top:none}
.pins .row{display:flex;gap:8px;flex-wrap:wrap}
.tag{display:inline-block;font-size:12px;opacity:.8}
.settings{position:fixed;left:10px;bottom:110px;z-index:8;min-width:300px;max-width:92vw;
background:var(--panel);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);
border:1px solid #333;border-radius:12px;display:none}
.settings header{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-bottom:1px solid #222}
.settings .row{display:flex;gap:10px;align-items:center;padding:10px;border-top:1px solid #111}
.settings .row:first-of-type{border-top:0}
.settings label{flex:1}
.settings input[type="range"]{flex:1}
.settings input[type="color"]{width:42px;height:32px;border:0;background:none}
/* Desktop variant */
.desktop .bar{grid-column:2}
.desktop .app{grid-template-columns:320px 1fr;grid-template-rows:auto 1fr}
.desktop .sidebar{position:fixed;left:0;top:0;bottom:0;width:320px;background:var(--panel);border-right:1px solid #333;display:flex;flex-direction:column;gap:10px;padding:12px;z-index:9}
.desktop .sidegroup{border:1px solid #222;border-radius:12px;overflow:hidden}
.desktop .sidegroup header{padding:10px 12px;border-bottom:1px solid #222;font-weight:600}
.desktop .sidegroup .row{display:flex;gap:8px;align-items:center;padding:8px 12px;border-top:1px solid #111}
.desktop .sidegroup .row:first-of-type{border-top:0}
.desktop .mapwrap{grid-column:2}
.desktop .menu,.desktop .pins,.desktop .settings{display:none !important}
@media (min-width:768px){ .bar{padding:10px 14px} .brand{font-size:20px} }
@media (prefers-reduced-motion:reduce){ *{animation:none !important;transition:none !important} }
</style>
</head>
<body>
<div class="app" id="app">
<div class="bar">
<div class="search">
<form id="form" autocomplete="off">
<input id="q" type="text" inputmode="search" placeholder="Search places…" aria-autocomplete="list" aria-expanded="false" aria-controls="suggestions" />
</form>
<ul id="suggestions" class="suggest" role="listbox"></ul>
</div>
<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>
<!-- Center marker stays perfectly centered -->
<div id="marker" class="marker" aria-hidden="true"><div class="dot"></div></div>
<div class="brand">PokeMaps</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>
<!-- Mobile overlay panels -->
<div class="menu" id="menu">
<header><strong>Menu</strong><button class="iconbtn" id="closeMenu" title="Close">✕</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="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>
<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>Display & Marker</strong><button id="closesettings" class="iconbtn">✕</button></header>
<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>Marker visible</label><input type="checkbox" id="markerVisible" checked></div>
<div class="row"><label>Marker style</label>
<select id="markerStyle" class="select">
<option value="dot">Dot</option>
<option value="crosshair">Crosshair</option>
</select>
</div>
<div class="row"><label>Marker size</label><input type="range" id="markerSize" min="8" max="48" step="1" value="20"><span id="markerSizeVal">20px</span></div>
<div class="row"><label>Marker 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="12" step="1" value="3"><span id="ringWidthVal">3px</span></div>
</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 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")
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 toggleCoords=S("#toggleCoords"), coordsEl=S("#coords")
const autoFollowEl=S("#autoFollow")
const coordFmtEl=S("#coordFmt")
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 PREFS_KEY="pokemaps_prefs_v3"
const LS_PINS="pokemaps_pins_v1"
let aborter=null, lastQuery="", watchId=null
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(6),
lon:lon.toFixed(6),
delta:clampDelta(delta).toFixed(4),
layer:layer
})
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 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))
if(prefs.showCoords) updateCoords()
}
const updateCoords=()=>{
const fmt=prefs.coordFmt||"dec"
const dec=`${state.lat.toFixed(6)}, ${state.lon.toFixed(6)}`
const dms=toDMS(state.lat,true)+" "+toDMS(state.lon,false)
const txt=(fmt==="dms"?dms:dec)+` · Δ ${state.delta.toFixed(4)} · ${state.layer}`
coordsEl.textContent = txt
}
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 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}); if(prefs.showCoords) updateCoords() },
_=>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 fmt=prefs.coordFmt||"dec"
const msg= fmt==="dms" ? (toDMS(state.lat,true)+" "+toDMS(state.lon,false)) : `${state.lat.toFixed(6)},${state.lon.toFixed(6)}`
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", url}) }catch{} } else { copyLink() } }
const reset=()=>{ stopFollow(); state={...DEFAULT}; q.value=""; sug.style.display="none"; q.setAttribute("aria-expanded","false"); layerSel && (layerSel.value=state.layer); apply(true) }
const debounced=(fn,ms=250)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
const searchPlaces=debounced(async term=>{
term=cleanStr(term); if(!term){sug.style.display="none"; q.setAttribute("aria-expanded","false"); return}
if(aborter) aborter.abort()
aborter=new AbortController()
try{
const url=`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=7&addressdetails=0&accept-language=en`
const r=await fetch(url,{signal:aborter.signal})
if(!r.ok) throw new Error("HTTP "+r.status)
const data=await r.json()
sug.innerHTML=""
data.forEach((p,i)=>{
const li=document.createElement("li")
li.role="option"; li.id="opt"+i
li.textContent=p.display_name
li.onclick=()=>{ q.value=p.display_name; sug.style.display="none"; q.setAttribute("aria-expanded","false"); centerOn(parseFloat(p.lat),parseFloat(p.lon),{push:true}) }
sug.appendChild(li)
})
sug.style.display=data.length?"block":"none"; q.setAttribute("aria-expanded", String(!!data.length))
}catch(e){
if(e.name!=="AbortError"){ sug.style.display="none"; q.setAttribute("aria-expanded","false") }
}
},200)
q.addEventListener("input",e=>{
const v=e.target.value
if(v===lastQuery) return
lastQuery=v
searchPlaces(v)
},{passive:true})
S("#form").addEventListener("submit",async e=>{
e.preventDefault()
const term=cleanStr(q.value); if(!term) return
try{
const r=await fetch(`${NOMINATIM}?q=${encodeURIComponent(term)}&format=json&limit=1`)
const d=await r.json()
if(d[0]) centerOn(parseFloat(d[0].lat),parseFloat(d[0].lon),{push:true})
}catch{}
})
locateBtn.onclick=locate
layerSel && (layerSel.onchange=()=>setLayer(layerSel.value))
followBtn && (followBtn.onclick=toggleFollow)
resetBtn.onclick=reset
shareBtn.onclick=shareLink
copyBtn.onclick=copyLink
copyCoordsBtn.onclick=copyCoords
openBtn.onclick=()=>{ window.open(osmViewURL(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=btn("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=btn("Share",async()=>{ const url=appURL(p); if(navigator.share){ try{ await navigator.share({title:p.name,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",()=>{ 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 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" }
/* Panels open/close (disabled on desktop where sidebar exists) */
menuBtn.onclick=()=>{ if(isDesktop()) return; menu.style.display="block" }
closeMenu.onclick=()=>{ menu.style.display="none" }
opensettings.onclick=()=>{ if(isDesktop()) return; settings.style.display="block" }
closesettings.onclick=()=>{ settings.style.display="none" }
/* Prefs -> UI sync */
toggleCoords.checked = !!prefs.showCoords
coordsEl.style.display = prefs.showCoords ? "block" : "none"
coordFmtEl.value = prefs.coordFmt || "dec"
autoFollowEl.checked = !!prefs.autoFollow
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)
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"; if(prefs.showCoords) updateCoords(); savePrefs() }
coordFmtEl.onchange=()=>{ prefs.coordFmt = coordFmtEl.value; savePrefs(); if(prefs.showCoords) updateCoords() }
autoFollowEl.onchange=()=>{ prefs.autoFollow = autoFollowEl.checked; 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==="Escape"){ menu.style.display="none"; settings.style.display="none"; pinsPane.style.display="none" }
})
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")
}
function applyMarkerStyle(style){
markerEl.classList.toggle("crosshair", style==="crosshair")
markerEl.classList.toggle("dotstyle", style==="dot")
}
parseURL()
apply(false)
// initial location logic
const urlHasLat = new URLSearchParams(location.search).has("lat")
if(!urlHasLat){ locate() }
if(prefs.showCoords) updateCoords()
if(prefs.autoFollow) startFollow()
/* Desktop sidebar build */
if(isDesktop()) buildDesktopSidebar()
function buildDesktopSidebar(){
const side=document.createElement("div"); side.className="sidebar"
side.innerHTML=`
<div class="sidegroup">
<header>Layers</header>
<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_openosm">Open in OSM</button><button class="iconbtn" id="d_copy">Copy Link</button></div>
</div>
<div class="sidegroup">
<header>Location</header>
<div class="row">
<label><input type="checkbox" id="d_autofollow"> Auto-follow on load</label>
</div>
<div class="row"><button class="iconbtn" id="d_locate">Locate</button><button class="iconbtn" id="d_follow">Follow</button></div>
<div class="row"><button class="iconbtn" id="d_reset">Reset</button><button class="iconbtn" id="d_share">Share</button></div>
</div>
<div class="sidegroup">
<header>Marker</header>
<div class="row"><label>Visible</label><input type="checkbox" id="d_markerv"></div>
<div class="row"><label>Style</label>
<select class="select" id="d_style">
<option value="dot">Dot</option>
<option value="crosshair">Crosshair</option>
</select>
</div>
<div class="row"><label>Size</label><input type="range" id="d_ms" min="8" max="48"><span id="d_msv"></span></div>
<div class="row"><label>Color</label><input type="color" id="d_mc"></div>
<div class="row"><label>Glow</label><input type="checkbox" id="d_mr"></div>
<div class="row"><label>Ring width</label><input type="range" id="d_rw" min="0" max="12"><span id="d_rwv"></span></div>
</div>
<div class="sidegroup">
<header>Coordinates</header>
<div class="row"><label>Always show</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><button class="iconbtn" id="d_copyc">Copy</button></div>
</div>
<div class="sidegroup">
<header>View & Pins</header>
<div class="row"><button class="iconbtn" id="d_pins">Open Pins</button><button class="iconbtn" id="d_savepin">Save Pin</button></div>
</div>
`
document.body.appendChild(side)
const d_layer=side.querySelector("#d_layer")
const d_openosm=side.querySelector("#d_openosm")
const d_copy=side.querySelector("#d_copy")
const d_locate=side.querySelector("#d_locate")
const d_follow=side.querySelector("#d_follow")
const d_reset=side.querySelector("#d_reset")
const d_share=side.querySelector("#d_share")
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")
const d_showc=side.querySelector("#d_showc")
const d_fmt=side.querySelector("#d_fmt")
const d_copyc=side.querySelector("#d_copyc")
const d_pins=side.querySelector("#d_pins")
const d_savepin=side.querySelector("#d_savepin")
const d_autofollow=side.querySelector("#d_autofollow")
d_layer.value=state.layer
d_layer.onchange=()=>setLayer(d_layer.value)
d_openosm.onclick=()=>window.open(osmViewURL(state),"_blank")
d_copy.onclick=copyLink
d_locate.onclick=locate
d_follow.onclick=toggleFollow
d_reset.onclick=reset
d_share.onclick=shareLink
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_showc.checked = !!prefs.showCoords
d_fmt.value = prefs.coordFmt || "dec"
d_autofollow.checked = !!prefs.autoFollow
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" }
d_showc.onchange=()=>{ toggleCoords.checked=d_showc.checked; toggleCoords.onchange() }
d_fmt.onchange=()=>{ coordFmtEl.value=d_fmt.value; coordFmtEl.onchange() }
d_copyc.onclick=copyCoords
d_pins.onclick=()=>{ renderPins(); pinsPane.style.display="block" }
d_savepin.onclick=()=>savepin.click()
d_autofollow.onchange=()=>{ autoFollowEl.checked=d_autofollow.checked; autoFollowEl.onchange() }
}
})();
</script>
<script src="/static/data-mobile.js" defer></script>
</body>
</html>