poke/html/map.ejs
2025-08-17 21:57:06 +02:00

888 lines
52 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>PokeMaps Public Beta</title>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0ea5e9" id="themeColorMeta"/>
<link rel="icon" href="/css/yt-ukraine.svg" />
<style>
:root{
--vh:100vh;--pad:12px;--radius:14px;--fg:#fff;--bg:rgba(0,0,0,.6);--glass:blur(12px);
--marker-size:20px;--marker-color:#e53935;--marker-ring:rgba(229,57,53,.35);--ring-width:3px;
--panel:rgba(0,0,0,.85);--accent:#0ea5e9;--chip:#111;--chipb:#222;--tip:#888;
--surface:#0b0b0b;--border:#333
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{background:#000;color:var(--fg);font:14px/1.4 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial}
.app{position:fixed;inset:0;display:grid;grid-template-rows:auto 1fr}
/* Top bar (mobile/tablet primary) */
.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)}
/* Suggestions */
.suggest{position:absolute;top:calc(100% + 6px);left:0;right:0;max-height:46vh;overflow:auto;margin:0;padding:0;list-style:none;border:1px solid var(--border);border-radius:12px;background:var(--surface);display:none}
.suggest .head{position:sticky;top:0;background:#0d0d0d;border-bottom:1px solid #1a1a1a;padding:6px 8px;display:flex;justify-content:space-between;align-items:center;z-index:1}
.suggest .head strong{font-size:12px;opacity:.8}
.suggest .head button{border:0;border-radius:8px;background:#1a1a1a;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:#222;color:#fff;padding:10px 12px;min-width:44px;cursor:pointer}
.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-size:18px;font-weight:600;background:var(--bg);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border-radius:10px;z-index:4;pointer-events:none}
.dock{position:absolute;left:10px;bottom:52px;z-index:7;display:flex;flex-direction:column;gap:8px}
.dock button{border:0;border-radius:10px;background:#111;color:#fff;padding:8px 10px}
.coords{position:absolute;bottom:10px;right:10px;z-index:5;padding:6px 10px;border-radius:10px;background:var(--bg);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border:1px solid var(--border);display:none;min-width:260px;text-align:right;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","DejaVu Sans Mono",monospace}
/* Popovers (mobile/tablet) */
.menu{position:fixed;top:56px;right:10px;z-index:8;min-width:290px;max-width:92vw;background:var(--panel);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border:1px solid var(--border);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;flex-wrap:wrap}
.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 var(--border);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 var(--border);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 .row{display:flex;gap:8px;flex-wrap:wrap}
.settings{position:fixed;left:10px;bottom:110px;z-index:8;min-width:320px;max-width:92vw;background:var(--panel);backdrop-filter:var(--glass);-webkit-backdrop-filter:var(--glass);border:1px solid var(--border);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 .badge{font-size:11px;opacity:.8;border:1px solid #444;border-radius:999px;padding:2px 8px;margin-left:8px}
.settings .group{border-top:1px solid #111}
.settings .ghead{display:flex;justify-content:space-between;align-items:center;padding:10px 12px}
.settings .ghead button{background:#111;border:1px solid var(--border);border-radius:8px;padding:6px 10px;cursor:pointer}
.settings .section{display:none;padding:10px 12px;border-top:1px solid #111}
.settings .row{display:flex;gap:10px;align-items:center;padding:8px 0}
.settings label{flex:1}
.settings input[type="range"]{flex:1}
.settings input[type="color"]{width:42px;height:32px;border:0;background:none}
.settings textarea{width:100%;min-height:120px;border-radius:10px;border:1px solid var(--border);background:#0a0a0a;color:#eee;padding:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.settings .about{font-size:13px;opacity:.92;line-height:1.5}
/* Desktop layout — Google Maps-like */
.desktop .app{grid-template-columns:360px 1fr;grid-template-rows:1fr}
.desktop .bar{display:none} /* hide mobile top bar on desktop */
.desktop .mapwrap{grid-column:2}
.desktop .sidebar{position:fixed;left:0;top:0;bottom:0;width:360px;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;background:#0c0c0c}
.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 .body{padding:10px 12px;display:flex;flex-direction:column;gap:8px}
.desktop .sidecard .row{display:flex;gap:8px;align-items:center}
.desktop .sidecard .hint{font-size:12px;opacity:.75}
.desktop .slot-search .search{margin:4px 0}
.desktop .menu,.desktop .pins,.desktop .settings{display:none !important} /* popovers off on desktop */
.desktop .dock{display:none} /* no floating duplicates on desktop */
/* Floating map controls (desktop only) */
.controls{display:none}
.desktop .controls{display:flex;position:absolute;right:10px;top:80px;z-index:8;flex-direction:column;gap:8px}
.ctrl-btn{border:1px solid #222;background:#0e0e0e;color:#fff;border-radius:12px;padding:10px 12px;cursor:pointer;min-width:42px;min-height:42px;display:flex;align-items:center;justify-content:center}
.layer-pop{position:absolute;right:56px;top:0;background:#0e0e0e;border:1px solid #222;border-radius:12px;padding:8px;display:none;gap:6px;flex-direction:column}
.layer-pop button{border:1px solid #222;background:#121212;color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;text-align:left}
.layer-pop button.active{outline:2px solid var(--accent)}
/* Minor */
@media (min-width:768px){ .brand{font-size:20px} }
@media (prefers-reduced-motion:reduce){ *{animation:none !important;transition:none !important} }
</style>
</head>
<body>
<div class="app" id="app">
<!-- Top bar (mobile/tablet primary UI) -->
<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>
<!-- Map -->
<div class="mapwrap" id="mapwrap">
<iframe id="map" title="Map"></iframe>
<div id="marker" class="marker" aria-hidden="true"><div class="dot"></div></div>
<!-- Floating controls (desktop only) -->
<div class="controls" id="controls">
<button id="zoomIn" class="ctrl-btn" title="Zoom in">+</button>
<button id="zoomOut" class="ctrl-btn" title="Zoom out"></button>
<button id="ctrlLocate" class="ctrl-btn" title="Locate">📍</button>
<div style="position:relative">
<button id="layersBtn" class="ctrl-btn" title="Layers">🗺️</button>
<div class="layer-pop" id="layerPop">
<button data-layer="mapnik">Standard</button>
<button data-layer="cyclemap">Cycle</button>
<button data-layer="transportmap">Transport</button>
<button data-layer="hot">Humanitarian</button>
</div>
</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>
<!-- Mobile/Tablet popover menu -->
<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="quick-near">📍 Near me</button>
<button class="iconbtn" id="quick-clear">🧹 Clear search</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 (not recommended)</button>
</div>
<div class="row">
<a class="iconbtn" href="https://wiki.osmfoundation.org/wiki/Terms_of_Use" target="_blank" rel="noopener">OSM Terms</a>
<a class="iconbtn" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">Copyright</a>
<a class="iconbtn" href="https://operations.osmfoundation.org/policies/nominatim/" target="_blank" rel="noopener">Nominatim Policy</a>
</div>
</div>
<!-- Mobile/Tablet pins popover -->
<div class="pins" id="pins">
<header>
<strong>Saved Pins</strong>
<button id="closepins" class="iconbtn" type="button">✕</button>
</header>
<ul id="pinlist"></ul>
</div>
<!-- Mobile/Tablet settings popover -->
<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">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>Coordinate 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">Toggle</button></div>
<div class="section" data-sec="mrk">
<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="64" 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="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">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 color</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">Toggle</button></div>
<div class="section" data-sec="css">
<div class="row" style="flex-direction:column;align-items:stretch">
<textarea id="customCSS" placeholder="/* Write CSS that will be injected into the page. Example:
.brand{font-weight:800}
*/"></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 class="group">
<div class="ghead"><div><strong>About PokeMaps (Public Beta)</strong></div><button data-t="about">Toggle</button></div>
<div class="section" data-sec="about">
<div class="about">
<p><strong>What is this?</strong> PokeMaps Public Beta is a lightweight viewer for exploring and sharing map positions with quick pin management. It focuses on speed, privacy-friendly defaults, and portability of links.</p>
<p><strong>Map credits:</strong> © <a href="https://www.openstreetmap.org/" target="_blank" rel="noopener">OpenStreetMap.org</a> contributors. Tiles/data from the OSM community. <em>Powered by openstreetmap.org.</em></p>
<p><strong>Important disclaimer:</strong> Geographic names, boundaries, labels, and depictions may be incomplete, outdated, or disputed. Displayed borders and place names are for reference only and must not be interpreted as statements of recognition or endorsement. The map does <em>not</em> reflect the politics of the Poke project nor the opinions of its contributors. Always verify critical details independently.</p>
<p><strong>Privacy & usage:</strong> If you enable location, your browser may share approximate GPS/Wi-Fi data with the page via standard Web APIs. Search uses public OSM services (subject to their policies and rate limits).</p>
<p><strong>Interoperability:</strong> A convenience action is provided to open the same view in Google Maps for satellite imagery (not recommended) and may have different data policies.</p>
</div>
</div>
</div>
</div>
<script>
;(()=> {
/* ---------- Constants & helpers ---------- */
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");
/* Desktop-only floating controls */
const controls=S("#controls"), zoomIn=S("#zoomIn"), zoomOut=S("#zoomOut"), ctrlLocate=S("#ctrlLocate"), layersBtn=S("#layersBtn"), layerPop=S("#layerPop");
const PREFS_KEY="pokemaps_prefs_v7";
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;
/* Move the main search block between bar and sidebar depending on layout */
let sidebarEl=null; // DOM node for desktop sidebar
let searchHomeBar=null; // original parent for search (bar)
let sidebarSearchSlot=null; // slot inside sidebar
function ensureSearchPlacement(){
const body=document.body;
if(isDesktop()){
if(!sidebarEl){ buildDesktopSidebar(); }
if(searchHomeBar==null){ searchHomeBar = document.querySelector(".bar .search")?.parentElement; }
// Move .search (with #q and #suggestions) into the sidebar slot
sidebarSearchSlot = sidebarEl.querySelector(".slot-search");
const searchNode = document.querySelector(".search");
if(searchNode && sidebarSearchSlot && searchNode.parentElement!==sidebarSearchSlot){
sidebarSearchSlot.innerHTML="";
sidebarSearchSlot.appendChild(searchNode);
sug.style.top = "calc(100% + 6px)"; // keep dropdown right under moved search
}
}else{
// Move back into the top bar
const bar = document.querySelector(".bar");
const searchNode = document.querySelector(".search");
if(bar && searchNode && searchNode.parentElement!==bar){
bar.insertBefore(searchNode, bar.firstChild);
}
}
document.body.classList.toggle("desktop", isDesktop());
}
const setVH=()=>{const vh=window.innerHeight*0.01;document.documentElement.style.setProperty("--vh",`${vh*100}px`)};
setVH(); addEventListener("resize",()=>{ setVH(); ensureSearchPlacement() },{passive:true});
ensureSearchPlacement();
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;
highlightLayerButton();
};
const apply=(push)=>{
map.src=embedURL(state);
if(push) history.pushState(state,"",appURL(state));
highlightLayerButton();
};
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;
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{
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, mobile)
quickNear && (quickNear.onclick=locate);
quickClear && (quickClear.onclick=()=>{ q.value=""; searchPlaces(""); q.focus() });
locateBtn && (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 && (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 && (showpins.onclick=()=>{ renderPins(); pinsPane.style.display="block" });
closepins && (closepins.onclick=()=>{ pinsPane.style.display="none" });
// Panels (mobile only; desktop has sidebar)
menuBtn && (menuBtn.onclick=()=>{ if(isDesktop()) return; menu.style.display="block" });
closeMenu && (closeMenu.onclick=()=>{ menu.style.display="none" });
opensettings && (opensettings.onclick=()=>{ if(isDesktop()) return; settings.style.display="block" });
closesettings && (closesettings.onclick=()=>{ settings.style.display="none" });
// Prefs -> UI
toggleCoords && (toggleCoords.checked = !!prefs.showCoords);
coordsEl.style.display = prefs.showCoords ? "block" : "none";
coordFmtEl && (coordFmtEl.value = prefs.coordFmt || "dec");
coordPrecEl && (coordPrecEl.value = prefs.prec ?? 6);
coordPrecVal && (coordPrecVal.textContent = String(prefs.prec ?? 6));
themeModeEl && (themeModeEl.value = prefs.theme || "auto");
autoFollowEl && (autoFollowEl.checked = !!prefs.autoFollow);
shareDeltaEl && (shareDeltaEl.checked = prefs.includeDelta!==false);
confirmDeleteEl && (confirmDeleteEl.checked = prefs.confirmDelete!==false);
markerVisibleEl && (markerVisibleEl.checked = !prefs.markerHidden);
markerSizeEl && (markerSizeEl.value = prefs.markerSize || 20);
markerSizeVal && (markerSizeVal.textContent = (markerSizeEl?.value || 20) + "px");
markerColorEl && (markerColorEl.value = prefs.markerColor || "#e53935");
markerRingEl && (markerRingEl.checked = prefs.markerRing !== false);
ringWidthEl && (ringWidthEl.value = prefs.ringWidth != null ? prefs.ringWidth : 3);
ringWidthVal && (ringWidthVal.textContent = (ringWidthEl?.value || 3) + "px");
markerStyleEl && (markerStyleEl.value = prefs.markerStyle || "dot");
applyMarkerStyle(markerStyleEl?.value || "dot");
accentColorEl && (accentColorEl.value = prefs.accent || "#0ea5e9");
setAccent(prefs.accent || "#0ea5e9");
customCSSEl && (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 && (markerVisibleEl.onchange=()=>{ markerEl.classList.toggle("hidden", !markerVisibleEl.checked); prefs.markerHidden = !markerVisibleEl.checked; savePrefs() });
markerSizeEl && (markerSizeEl.oninput=()=>{ document.documentElement.style.setProperty("--marker-size", markerSizeEl.value+"px"); markerSizeVal.textContent=markerSizeEl.value+"px"; prefs.markerSize=+markerSizeEl.value; savePrefs() });
markerColorEl && (markerColorEl.oninput=()=>{ document.documentElement.style.setProperty("--marker-color", markerColorEl.value); prefs.markerColor=markerColorEl.value; savePrefs() });
markerRingEl && (markerRingEl.onchange=()=>{ document.documentElement.style.setProperty("--marker-ring", markerRingEl.checked ? "rgba(229,57,53,.35)" : "transparent"); prefs.markerRing = markerRingEl.checked; savePrefs() });
ringWidthEl && (ringWidthEl.oninput=()=>{ document.documentElement.style.setProperty("--ring-width", ringWidthEl.value+"px"); ringWidthVal.textContent=ringWidthEl.value+"px"; prefs.ringWidth=+ringWidthEl.value; savePrefs() });
markerStyleEl && (markerStyleEl.onchange=()=>{ applyMarkerStyle(markerStyleEl.value); prefs.markerStyle=markerStyleEl.value; savePrefs() });
toggleCoords && (toggleCoords.onchange=()=>{ prefs.showCoords = toggleCoords.checked; coordsEl.style.display = prefs.showCoords ? "block" : "none"; savePrefs() });
coordFmtEl && (coordFmtEl.onchange=()=>{ prefs.coordFmt = coordFmtEl.value; savePrefs() });
coordPrecEl && (coordPrecEl.oninput=()=>{ prefs.prec = +coordPrecEl.value; coordPrecVal.textContent=String(prefs.prec); savePrefs() });
autoFollowEl && (autoFollowEl.onchange=()=>{ prefs.autoFollow = autoFollowEl.checked; savePrefs() });
shareDeltaEl && (shareDeltaEl.onchange=()=>{ prefs.includeDelta = shareDeltaEl.checked; savePrefs() });
confirmDeleteEl && (confirmDeleteEl.onchange=()=>{ prefs.confirmDelete = confirmDeleteEl.checked; savePrefs() });
themeModeEl && (themeModeEl.onchange=()=>{ prefs.theme = themeModeEl.value; applyTheme(prefs.theme); savePrefs() });
accentColorEl && (accentColorEl.oninput=()=>{ setAccent(accentColorEl.value); prefs.accent=accentColorEl.value; savePrefs() });
accentResetEl && (accentResetEl.onclick=()=>{ const def="#0ea5e9"; accentColorEl.value=def; setAccent(def); prefs.accent=def; savePrefs() });
applyCSSEl && (applyCSSEl.onclick=()=>{ const css=customCSSEl.value||""; prefs.userCSS=css; applyUserCSS(css); savePrefs() });
clearCSSEl && (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 && (menu.style.display="none"); settings && (settings.style.display="none"); pinsPane && (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");
}
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||"";
}
/* ---------- Desktop Sidebar (Google Maps-like) ---------- */
function buildDesktopSidebar(){
// If it exists, reuse
if(sidebarEl){ sidebarEl.remove(); sidebarEl=null; }
sidebarEl=document.createElement("aside");
sidebarEl.className="sidebar";
sidebarEl.innerHTML=`
<div class="sidecard">
<header>Search</header>
<div class="body slot-search"></div>
</div>
<div class="sidecard">
<header>Actions</header>
<div class="body">
<div class="row"><button class="iconbtn" id="d_save">⭐ Save current</button><button class="iconbtn" id="d_manage">Manage pins</button></div>
<div class="row"><button class="iconbtn" id="d_copy">📋 Copy Link</button><button class="iconbtn" id="d_copyc">📐 Coords</button></div>
<div class="row"><button class="iconbtn" id="d_share">🔗 Share</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="row"><button class="iconbtn" id="d_reset">🔁 Reset</button></div>
<div class="hint">Shortcuts: / focus • 14 layers • S save • R reset • L locate</div>
</div>
</div>
<div class="sidecard">
<header>Marker & Display</header>
<div class="body">
<div class="row"><label style="flex:1">Marker 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>
<hr style="border:0;border-top:1px solid #222;width:100%">
<div class="row"><label style="flex:1">Always show coords</label><input type="checkbox" id="d_showc"></div>
<div class="row"><label style="flex:1">Format</label><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>
</div>
<div class="sidecard">
<header>About</header>
<div class="body">
<small>Powered by openstreetmap.org • Data © OSM contributors • Public Beta</small>
</div>
</div>
`;
document.body.appendChild(sidebarEl);
// Wire actions (no duplicates elsewhere on desktop)
sidebarEl.querySelector("#d_save").onclick=()=>savepin?.click();
sidebarEl.querySelector("#d_manage").onclick=()=>{ renderPins(); pinsPane.style.display="block" };
sidebarEl.querySelector("#d_copy").onclick=copyLink;
sidebarEl.querySelector("#d_copyc").onclick=copyCoords;
sidebarEl.querySelector("#d_share").onclick=shareLink;
sidebarEl.querySelector("#d_osm").onclick=()=>window.open(osmViewURL(state),"_blank");
sidebarEl.querySelector("#d_gmaps").onclick=()=>window.open(gmapsURL(state),"_blank");
sidebarEl.querySelector("#d_reset").onclick=reset;
// Marker & display mirror controls
const d_markerv=sidebarEl.querySelector("#d_markerv");
const d_style=sidebarEl.querySelector("#d_style");
const d_ms=sidebarEl.querySelector("#d_ms"), d_msv=sidebarEl.querySelector("#d_msv");
const d_mc=sidebarEl.querySelector("#d_mc");
const d_mr=sidebarEl.querySelector("#d_mr");
const d_rw=sidebarEl.querySelector("#d_rw"), d_rwv=sidebarEl.querySelector("#d_rwv");
const d_showc=sidebarEl.querySelector("#d_showc");
const d_fmt=sidebarEl.querySelector("#d_fmt");
const d_prec=sidebarEl.querySelector("#d_prec"), d_precv=sidebarEl.querySelector("#d_precv");
const d_accent=sidebarEl.querySelector("#d_accent"), d_accent_reset=sidebarEl.querySelector("#d_accent_reset");
d_markerv.checked=!prefs.markerHidden;
d_style.value=prefs.markerStyle||"dot";
d_ms.value=markerSizeEl?.value || 20; d_msv.textContent=d_ms.value+"px";
d_mc.value=markerColorEl?.value || "#e53935";
d_mr.checked = prefs.markerRing !== false;
d_rw.value = ringWidthEl?.value || 3; d_rwv.textContent=d_rw.value+"px";
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_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_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 };
}
/* ---------- Floating controls (desktop) ---------- */
function highlightLayerButton(){
if(!layerPop) return;
[...layerPop.querySelectorAll("button")].forEach(b=>{
b.classList.toggle("active", b.dataset.layer===state.layer);
});
}
if(zoomIn){ zoomIn.onclick=()=> setDelta(state.delta*0.6, {push:true}) }
if(zoomOut){ zoomOut.onclick=()=> setDelta(state.delta/0.6, {push:true}) }
if(ctrlLocate){ ctrlLocate.onclick=locate }
if(layersBtn){
layersBtn.onclick=()=>{
if(!isDesktop()) return; // desktop-only
const visible = layerPop.style.display==="flex";
layerPop.style.display = visible ? "none" : "flex";
}
}
if(layerPop){
layerPop.addEventListener("click",e=>{
const btn=e.target.closest("button[data-layer]"); if(!btn) return;
setLayer(btn.dataset.layer);
layerPop.style.display="none";
});
document.addEventListener("click",e=>{
if(!layerPop.contains(e.target) && e.target!==layersBtn){ layerPop.style.display="none" }
});
}
/* ---------- Init ---------- */
parseURL(); apply(false);
const urlHasLat = new URLSearchParams(location.search).has("lat");
if(!urlHasLat){ locate() }
if(prefs.autoFollow) startFollow();
ensureSearchPlacement();
function tickCoords(){ updateCoordsFast(); setTimeout(tickCoords,120) }
tickCoords();
})();
</script>
<script src="/static/data-mobile.js" defer></script>
</body>
</html>