poke/html/map.ejs
2025-08-19 18:37:43 +02:00

774 lines
37 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 </title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" href="/css/yt-ukraine.svg" />
<meta name="color-scheme" content="dark light" />
<style>
/* Root tokens */
:root{
--bg:#0b0b0b; --fg:#f5f5f5; --muted:#b4b4b4;
--panel:#121212; --border:#1f1f1f; --accent:#0ea5e9;
--pad:12px; --r:12px; --bar-h:56px; --sidebar-w:320px;
}
/* Reset-ish */
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Noto Sans",Arial}
/* App shell */
#app-shell{min-height:100%;display:grid;grid-template-rows:auto 1fr auto}
/* Top bar */
#topbar{
position:sticky;top:0;display:grid;grid-template-columns:1fr auto auto;gap:8px;
align-items:center;padding:8px var(--pad);background:rgba(0,0,0,.55);
backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);z-index:10;border-bottom:1px solid var(--border)
}
#brand{font-weight:700;letter-spacing:.2px}
#search-form{display:flex}
#search-input{
width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:10px;
background:#0e0e0e;color:var(--fg);outline:none
}
#search-input:focus{border-color:#2a2a2a;box-shadow:0 0 0 3px color-mix(in oklab,var(--accent) 24%, transparent)}
#topbar-actions{display:flex;gap:8px}
#topbar-actions button{
border:1px solid var(--border);background:#111;color:var(--fg);border-radius:10px;
padding:10px 12px;cursor:pointer
}
/* Autosuggest */
#autosuggest{
position:absolute;left:var(--pad);right:var(--pad);top:calc(var(--bar-h) - 8px);
max-height:48vh;overflow:auto;margin:0;padding:0;list-style:none;display:none;
background:#0f0f0f;border:1px solid var(--border);border-radius:10px;z-index:12
}
#autosuggest[data-open="1"]{display:block}
#autosuggest li{padding:10px 12px;border-top:1px solid #151515;cursor:pointer}
#autosuggest li:first-child{border-top:0}
#autosuggest li[aria-selected="true"]{background:#161616}
/* Workspace */
#workspace{position:relative;display:grid;grid-template-columns:1fr;grid-template-rows:1fr}
/* Map area */
#map-area{position:relative;min-height:calc(100vh - var(--bar-h))}
#map-frame{position:absolute;inset:0;border:0;width:100%;height:100%}
#map-marker{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);pointer-events:none}
#map-marker-dot{display:block;width:12px;height:12px;border-radius:50%;background:#e53935;box-shadow:0 0 0 3px rgba(229,57,53,.35)}
#brand-badge{
position:absolute;left:10px;bottom:10px;padding:6px 10px;border:1px solid var(--border);
background:rgba(0,0,0,.5);backdrop-filter:blur(8px);border-radius:10px;font-weight:600
}
/* Floating actions (mobile) */
#float-actions{
position:absolute;left:10px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:5
}
#float-actions button{
border:1px solid var(--border);background:#0f0f0f;color:var(--fg);border-radius:10px;padding:8px 10px
}
/* Coords */
#coords-panel{
position:absolute;right:10px;bottom:10px;min-width:220px;text-align:right;
padding:6px 10px;border:1px solid var(--border);border-radius:10px;
background:rgba(0,0,0,.5);backdrop-filter:blur(8px);font-family:ui-monospace,monospace
}
/* Panels (dialog-like) */
[id^="panel-"]{
position:fixed;right:10px;left:10px;bottom:10px;top:auto;max-height:60vh;overflow:auto;
background:var(--panel);border:1px solid var(--border);border-radius:12px;z-index:20;padding-bottom:8px
}
[id^="panel-"][hidden]{display:none}
[id^="panel-"] header{
position:sticky;top:0;display:flex;justify-content:space-between;align-items:center;
gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);background:#0f0f0f
}
[id^="panel-"] h2{margin:0;font-size:16px}
[id^="panel-"] footer, [id^="panel-"] div, [id^="panel-"] section{padding:10px 12px}
#pin-list{list-style:none;margin:0;padding:0}
#pin-list li{padding:10px 12px;border-top:1px solid #151515}
#pin-list li:first-child{border-top:0}
/* Desktop sidebar */
#desk-sidebar{
display:none; /* hidden on mobile */
position:relative;padding:12px;background:var(--panel);border-right:1px solid var(--border)
}
#desk-sidebar section{border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-bottom:12px}
#desk-sidebar section header,
#desk-sidebar h2{margin:0;padding:10px 12px;border-bottom:1px solid var(--border);font-size:15px}
#desk-sidebar section > div,
#desk-sidebar textarea{padding:8px 12px}
#desk-sidebar input[type="text"],
#desk-sidebar select,
#desk-sidebar input[type="color"]{
width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:#0f0f0f;color:var(--fg)
}
#desk-sidebar input[type="range"]{width:100%}
#desk-sidebar button{
border:1px solid var(--border);background:#111;color:var(--fg);border-radius:10px;padding:8px 10px;cursor:pointer
}
#desk-user-css{width:100%;min-height:110px;border:1px solid var(--border);border-radius:10px;background:#0f0f0f;color:var(--fg);resize:vertical}
/* Footer */
#app-footer{padding:10px var(--pad);border-top:1px solid var(--border);color:var(--muted);text-align:center}
/* Utility */
.visually-hidden{position:absolute !important;height:1px;width:1px;overflow:hidden;clip:rect(1px,1px,1px,1px);white-space:nowrap}
/* Desktop layout */
@media (min-width: 1024px){
#workspace{
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: 1fr;
}
#desk-sidebar{display:block}
#float-actions{display:none}
/* Panels prefer right column on desktop */
[id^="panel-"]{left:auto;top:72px;right:16px;bottom:auto;max-height:70vh;width:min(460px,40vw)}
}
/* Buttons + links subtle hover */
button,a{transition:background .15s,border-color .15s,opacity .15s,color .15s}
button:hover{background:#161616}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
/* Color-scheme support */
@media (prefers-color-scheme: light){
:root{--bg:#fafafa;--fg:#111;--panel:#fff;--border:#e5e5e5;--muted:#555}
#topbar,#brand-badge,#coords-panel{background:rgba(255,255,255,.6)}
#search-input{background:#fff}
#autosuggest{background:#fff}
}
/* Scrollbars (webkit) */
*::-webkit-scrollbar{height:10px;width:10px}
*::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:10px}
*::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<!-- App Shell -->
<div id="app-shell" data-app="pokemaps">
<!-- Top Bar -->
<header id="topbar" role="banner">
<div id="brand" aria-label="App name">PokeMaps</div>
<!-- Search -->
<form id="search-form" role="search" aria-label="Place search" autocomplete="off">
<label for="search-input" class="visually-hidden">Search places or paste lat,lon</label>
<input id="search-input" name="q" type="search" placeholder="Search places or paste lat,lon" />
</form>
<!-- Quick actions -->
<div id="topbar-actions" role="group" aria-label="Quick actions">
<button id="action-locate" type="button" aria-label="Locate">📍</button>
<button id="action-menu" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="panel-menu">⋯</button>
</div>
<!-- Suggestions (hidden by default) -->
<ul id="autosuggest" role="listbox" aria-label="Suggestions" aria-live="polite">
<!-- <li role="option" id="sug-0" data-type="place">…</li> -->
</ul>
</header>
<!-- Main Area -->
<main id="workspace" role="main" aria-label="Map workspace">
<!-- Desktop Sidebar (hidden on mobile until styled) -->
<aside id="desk-sidebar" aria-label="Controls">
<!-- Overview -->
<section id="desk-overview" aria-labelledby="desk-overview-title">
<h2 id="desk-overview-title">Overview</h2>
<div>
<input id="desk-search" type="text" placeholder="Search or lat,lon" />
</div>
<div>
<label for="desk-layer">Layer</label>
<select id="desk-layer" name="layer">
<option value="mapnik">Standard</option>
<option value="cyclemap">Cycle</option>
<option value="transportmap">Transport</option>
<option value="hot">Humanitarian</option>
</select>
</div>
<div role="group" aria-label="Navigation">
<button id="desk-locate" type="button">Locate</button>
<button id="desk-follow" type="button" aria-pressed="false">Follow</button>
<button id="desk-reset" type="button">Reset</button>
</div>
<div role="group" aria-label="Sharing">
<button id="desk-copy-link" type="button">Copy Link</button>
<button id="desk-copy-coord" type="button">Copy Coords</button>
<button id="desk-open-osm" type="button">Open OSM</button>
<button id="desk-open-gmaps" type="button">Google Maps</button>
</div>
</section>
<!-- Marker Controls -->
<section id="desk-marker" aria-labelledby="desk-marker-title">
<h2 id="desk-marker-title">Marker</h2>
<div>
<label><input id="desk-marker-visible" type="checkbox" checked /> Visible</label>
</div>
<div>
<label for="desk-marker-style">Style</label>
<select id="desk-marker-style">
<option value="dot">Dot</option>
<option value="crosshair">Crosshair</option>
</select>
</div>
<div>
<label for="desk-marker-size">Size</label>
<input id="desk-marker-size" type="range" min="8" max="64" value="20" />
</div>
<div>
<label for="desk-marker-color">Color</label>
<input id="desk-marker-color" type="color" value="#e53935" />
</div>
<div>
<label><input id="desk-marker-ring" type="checkbox" checked /> Ring glow</label>
</div>
<div>
<label for="desk-ring-width">Ring width</label>
<input id="desk-ring-width" type="range" min="0" max="16" value="3" />
</div>
</section>
<!-- Pins -->
<section id="desk-pins" aria-labelledby="desk-pins-title">
<h2 id="desk-pins-title">Pins</h2>
<div role="group" aria-label="Pins actions">
<button id="desk-pin-save" type="button">Save current</button>
<button id="desk-pin-manage" type="button" aria-controls="panel-pins" aria-expanded="false">Manage</button>
</div>
<div id="desk-pins-recent" aria-live="polite" aria-label="Recent pins">
<!-- recent pins preview -->
</div>
</section>
<!-- Display -->
<section id="desk-display" aria-labelledby="desk-display-title">
<h2 id="desk-display-title">Display</h2>
<div>
<label><input id="desk-show-coords" type="checkbox" /> Always show coords</label>
</div>
<div>
<label for="desk-coord-fmt">Coordinates</label>
<select id="desk-coord-fmt">
<option value="dec">DD (Decimal)</option>
<option value="dms">DMS</option>
</select>
</div>
<div>
<label for="desk-precision">Precision</label>
<input id="desk-precision" type="range" min="0" max="8" value="6" />
</div>
<div>
<label for="desk-accent">Accent</label>
<input id="desk-accent" type="color" value="#0ea5e9" />
<button id="desk-accent-reset" type="button">Reset</button>
</div>
<div>
<label for="desk-theme">Theme</label>
<select id="desk-theme">
<option value="auto">Auto</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
</section>
<!-- Custom CSS -->
<section id="desk-custom-css" aria-labelledby="desk-custom-css-title">
<h2 id="desk-custom-css-title">Custom CSS</h2>
<textarea id="desk-user-css" placeholder="/* Your CSS here */"></textarea>
<div role="group" aria-label="Custom CSS actions">
<button id="desk-apply-css" type="button">Apply</button>
<button id="desk-clear-css" type="button">Clear</button>
</div>
</section>
<!-- About -->
<section id="desk-about" aria-labelledby="desk-about-title">
<h2 id="desk-about-title">About</h2>
<p>Data © OpenStreetMap contributors • Viewer: PokeMaps Public Beta</p>
</section>
</aside>
<!-- Map Area -->
<section id="map-area" aria-label="Map">
<iframe id="map-frame" title="OSM map"></iframe>
<!-- Marker placeholder -->
<div id="map-marker" aria-hidden="true">
<span id="map-marker-dot"></span>
</div>
<!-- Brand -->
<div id="brand-badge" aria-hidden="true">PokeMaps Public Beta</div>
<!-- Floating actions (mobile/tablet) -->
<nav id="float-actions" aria-label="Floating actions">
<button id="fa-pin-save" type="button">⭐ Save Pin</button>
<button id="fa-pins-open" type="button" aria-controls="panel-pins" aria-expanded="false">📒 Pins</button>
<button id="fa-settings" type="button" aria-controls="panel-settings" aria-expanded="false">⚙ Settings</button>
</nav>
<!-- Coordinates panel -->
<output id="coords-panel" aria-live="polite" aria-atomic="true"></output>
</section>
</main>
<!-- Mobile/Tablet Panels (hidden by default; no styling here) -->
<!-- Menu Panel -->
<section id="panel-menu" role="dialog" aria-modal="false" aria-labelledby="panel-menu-title" hidden>
<header>
<h2 id="panel-menu-title">Menu</h2>
<button id="panel-menu-close" type="button" aria-label="Close">✕</button>
</header>
<div>
<label for="menu-layer">Layer</label>
<select id="menu-layer" name="layer">
<option value="mapnik">Standard</option>
<option value="cyclemap">Cycle</option>
<option value="transportmap">Transport</option>
<option value="hot">Humanitarian</option>
</select>
</div>
<div role="group" aria-label="View">
<button id="menu-follow" type="button" aria-pressed="false">🛰️ Follow</button>
<button id="menu-reset" type="button">🔁 Reset</button>
</div>
<div role="group" aria-label="Shortcuts">
<button id="menu-near" type="button">📍 Near me</button>
<button id="menu-clear" type="button">🧹 Clear search</button>
</div>
<div role="group" aria-label="Share">
<button id="menu-copy" type="button">📋 Copy Link</button>
<button id="menu-copycoord" type="button">📐 Coords</button>
<button id="menu-share" type="button">🔗 Share</button>
</div>
<div role="group" aria-label="External">
<button id="menu-open-osm" type="button">🌐 Open OSM</button>
<button id="menu-open-gmaps" type="button">🛰️ Google Maps</button>
</div>
<footer>
<a id="menu-osm-terms" href="https://wiki.osmfoundation.org/wiki/Terms_of_Use" target="_blank" rel="noopener">OSM Terms</a>
<a id="menu-osm-copyright" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">Copyright</a>
<a id="menu-osm-nominatim" href="https://operations.osmfoundation.org/policies/nominatim/" target="_blank" rel="noopener">Nominatim Policy</a>
</footer>
</section>
<!-- Pins Panel -->
<section id="panel-pins" role="dialog" aria-modal="false" aria-labelledby="panel-pins-title" hidden>
<header>
<h2 id="panel-pins-title">Saved Pins</h2>
<button id="panel-pins-close" type="button" aria-label="Close">✕</button>
</header>
<ul id="pin-list" aria-label="Pins list">
<!-- <li data-lat="" data-lon=""><strong>Name</strong><span>lat, lon · layer</span><div role="group">...</div></li> -->
</ul>
</section>
<!-- Settings Panel -->
<section id="panel-settings" role="dialog" aria-modal="false" aria-labelledby="panel-settings-title" hidden>
<header>
<h2 id="panel-settings-title">Settings</h2>
<button id="panel-settings-close" type="button" aria-label="Close">✕</button>
</header>
<section aria-labelledby="set-general-title">
<h3 id="set-general-title">General</h3>
<label><input id="set-autofollow" type="checkbox" /> Auto-follow on load</label>
<label><input id="set-show-coords" type="checkbox" /> Always show coordinates</label>
<div>
<label for="set-coord-fmt">Coordinates format</label>
<select id="set-coord-fmt">
<option value="dec">DD (Decimal)</option>
<option value="dms">DMS</option>
</select>
</div>
<div>
<label for="set-precision">Coordinate precision</label>
<input id="set-precision" type="range" min="0" max="8" step="1" value="6" />
</div>
<div>
<label for="set-theme">Theme</label>
<select id="set-theme">
<option value="auto">Auto</option>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
</section>
<section aria-labelledby="set-marker-title">
<h3 id="set-marker-title">Marker</h3>
<label><input id="set-marker-visible" type="checkbox" checked /> Marker visible</label>
<div>
<label for="set-marker-style">Marker style</label>
<select id="set-marker-style">
<option value="dot">Dot</option>
<option value="crosshair">Crosshair</option>
</select>
</div>
<div>
<label for="set-marker-size">Marker size</label>
<input id="set-marker-size" type="range" min="8" max="64" step="1" value="20" />
</div>
<div>
<label for="set-marker-color">Marker color</label>
<input id="set-marker-color" type="color" value="#e53935" />
</div>
<label><input id="set-marker-ring" type="checkbox" checked /> Ring glow</label>
<div>
<label for="set-ring-width">Ring width</label>
<input id="set-ring-width" type="range" min="0" max="16" step="1" value="3" />
</div>
</section>
<section aria-labelledby="set-links-title">
<h3 id="set-links-title">View & Links</h3>
<label><input id="set-share-delta" type="checkbox" checked /> Include Δ in shared links</label>
<label><input id="set-confirm-delete" type="checkbox" checked /> Confirm before deleting pins</label>
<div>
<label for="set-accent">Accent color</label>
<input id="set-accent" type="color" value="#0ea5e9" />
<button id="set-accent-reset" type="button">Reset</button>
</div>
</section>
<section aria-labelledby="set-css-title">
<h3 id="set-css-title">Custom CSS</h3>
<textarea id="set-user-css" placeholder="/* Write CSS to inject */"></textarea>
<div role="group" aria-label="Custom CSS controls">
<button id="set-apply-css" type="button">Apply</button>
<button id="set-clear-css" type="button">Clear</button>
</div>
</section>
</section>
<!-- Footer (optional) -->
<footer id="app-footer" role="contentinfo">
<small>Map tiles/data © OpenStreetMap contributors.</small>
</footer>
<script>
(()=>{"use strict";
/* ========= Constants ========= */
const OSM_EMBED="https://www.openstreetmap.org/export/embed.html";
const OSM_VIEW ="https://www.openstreetmap.org";
const NOMI="https://nominatim.openstreetmap.org/search";
const LAYERS=["mapnik","cyclemap","transportmap","hot"];
const DEF={lat:30.410156,lon:72.448792,delta:.25,layer:"mapnik"};
const LIM={dmin:0.01,dmax:45};
const LS_PREFS="pm_prefs_v1", LS_PINS="pm_pins_v1", LS_SRCH="pm_hist_v1";
/* ========= Shortcuts ========= */
const $=q=>document.querySelector(q);
const map=$("#map-frame"), brand=$("#brand"), q=$("#search-input"), sug=$("#autosuggest");
const tbLocate=$("#action-locate"), tbMenu=$("#action-menu");
const panelMenu=$("#panel-menu"), panelPins=$("#panel-pins"), panelSet=$("#panel-settings");
const mClose=$("#panel-menu-close"), pClose=$("#panel-pins-close"), sClose=$("#panel-settings-close");
const mLayer=$("#menu-layer"), mFollow=$("#menu-follow"), mReset=$("#menu-reset");
const mNear=$("#menu-near"), mClear=$("#menu-clear");
const mCopy=$("#menu-copy"), mCopyC=$("#menu-copycoord"), mShare=$("#menu-share");
const mOSM=$("#menu-open-osm"), mG=$("#menu-open-gmaps");
const faSave=$("#fa-pin-save"), faPins=$("#fa-pins-open"), faSet=$("#fa-settings");
const pinList=$("#pin-list"), coordsOut=$("#coords-panel");
const marker=$("#map-marker"), markerDot=$("#map-marker-dot");
/* desktop mirrors */
const dSearch=$("#desk-search"), dLayer=$("#desk-layer"), dLocate=$("#desk-locate"), dFollow=$("#desk-follow"),
dReset=$("#desk-reset"), dCopy=$("#desk-copy-link"), dCopyC=$("#desk-copy-coord"),
dOSM=$("#desk-open-osm"), dG=$("#desk-open-gmaps");
/* settings (panel) */
const sAuto=$("#set-autofollow"), sShowC=$("#set-show-coords"), sFmt=$("#set-coord-fmt"),
sPrec=$("#set-precision"), sTheme=$("#set-theme"), sAccent=$("#set-accent"), sAccentR=$("#set-accent-reset"),
sMV=$("#set-marker-visible"), sMSel=$("#set-marker-style"), sMSize=$("#set-marker-size"),
sMColor=$("#set-marker-color"), sMR=$("#set-marker-ring"), sRW=$("#set-ring-width"),
sShareD=$("#set-share-delta"), sConfirm=$("#set-confirm-delete"),
sCSS=$("#set-user-css"), sCSSApply=$("#set-apply-css"), sCSSClear=$("#set-clear-css");
/* ========= State ========= */
let state={...DEF}, prefs=load(LS_PREFS)||{}, watchId=null, lastSug=-1, aborter=null, lastCoordStr="";
/* ========= Utils ========= */
const clamp=(n,min,max)=>Math.min(Math.max(n,min),max);
const clampD=d=>clamp(d,LIM.dmin,LIM.dmax);
const isDesktop=()=>matchMedia("(min-width:1024px)").matches&&matchMedia("(pointer:fine)").matches;
const dd=(x,p)=>Number(x).toFixed(p);
const toDMS=(v,lat)=>{const d=Math.floor(Math.abs(v)), m=Math.floor((Math.abs(v)-d)*60), s=(((Math.abs(v)-d)*60-m)*60).toFixed(2);
return `${d}°${m}${s}″ ${lat?(v>=0?"N":"S"):(v>=0?"E":"W")}`};
const fmtCoords=()=> (prefs.coordFmt==="dms")
? `${toDMS(state.lat,true)} ${toDMS(state.lon,false)}`
: `${dd(state.lat,prefs.prec??6)}, ${dd(state.lon,prefs.prec??6)}`;
const zToDelta=z=>Math.min(LIM.dmax,Math.max(LIM.dmin,Math.pow(2,(10-z))*0.02));
const dToZ=d=>Math.round(10-Math.log2(d/0.02));
const bbox=(lat,lon,d)=>({l:(lon-d).toFixed(6),b:(lat-d).toFixed(6),r:(lon+d).toFixed(6),t:(lat+d).toFixed(6)});
const embed=({lat,lon,delta,layer})=>{const b=bbox(lat,lon,clampD(delta));const lyr=LAYERS.includes(layer)?layer:DEF.layer;
return `${OSM_EMBED}?bbox=${b.l}%2C${b.b}%2C${b.r}%2C${b.t}&layer=${encodeURIComponent(lyr)}`};
const appURL=({lat,lon,delta,layer})=>{const sp=new URLSearchParams({lat:dd(lat,prefs.prec??6),lon:dd(lon,prefs.prec??6),layer});
if(prefs.shareDelta!==false) sp.set("delta",clampD(delta).toFixed(4)); return `${location.origin}${location.pathname}?${sp.toString()}`};
const osmURL=({lat,lon,delta,layer})=>`${OSM_VIEW}/?mlat=${dd(lat,6)}&mlon=${dd(lon,6)}#map=${dToZ(clampD(delta))}/${dd(lat,6)}/${dd(lon,6)}&layers=${layer}`;
const gURL =({lat,lon,delta})=>`https://www.google.com/maps/@${dd(lat,6)},${dd(lon,6)},${Math.max(3,Math.min(20,dToZ(clampD(delta))))}z`;
const save=(k,v)=>localStorage.setItem(k,JSON.stringify(v));
function load(k){try{return JSON.parse(localStorage.getItem(k)||"null")}catch{return null}}
const deb=(fn,ms=160)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}};
/* ========= Core Apply ========= */
function apply(push=true){
map.src=embed(state);
if(push) history.pushState(state,"",appURL(state));
}
function center(lat,lon,{push=true}={}){ state.lat=clamp(lat,-90,90); state.lon=clamp(lon,-180,180); apply(push) }
/* ========= URL Init ========= */
(function parseURL(){ const sp=new URLSearchParams(location.search);
const lat=parseFloat(sp.get("lat")), lon=parseFloat(sp.get("lon")), d=parseFloat(sp.get("delta")), 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(d)&&d>0) state.delta=clampD(d);
if(layer&&LAYERS.includes(layer)) state.layer=layer;
})();
/* ========= Theme / Accent / CSS ========= */
function setAccent(hex){ document.documentElement.style.setProperty("--accent",hex) }
function setTheme(mode){ document.documentElement.style.colorScheme = (mode==="auto")?"":mode }
function applyUserCSS(css){ let tag=$("#user-css"); if(!tag){tag=document.createElement("style");tag.id="user-css";document.head.appendChild(tag)} tag.textContent=css||"" }
/* ========= Marker (inline styles from settings) ========= */
function applyMarker(){
marker.hidden = prefs.markerVisible===false;
markerDot.style.background = prefs.markerColor||"#e53935";
const px = (prefs.markerSize||20)+"px";
markerDot.style.width=px; markerDot.style.height=px;
markerDot.style.borderRadius="50%";
markerDot.style.boxShadow = (prefs.markerRing===false) ? "none" : `0 0 0 ${(prefs.ringWidth??3)}px rgba(229,57,53,.35)`;
marker.dataset.style = prefs.markerStyle||"dot"; // for CSS if needed
}
/* ========= Coords ticker ========= */
function tick(){ if(prefs.showCoords){
const s = `${fmtCoords()} · Δ ${clampD(state.delta).toFixed(4)} · ${state.layer}`;
if(s!==lastCoordStr){coordsOut.textContent=s; lastCoordStr=s}
coordsOut.hidden=false;
}else{ coordsOut.hidden=true }
setTimeout(tick,120);
}
/* ========= Search & Suggestions ========= */
function renderSug(items,term=""){
if(!items.length){sug.removeAttribute("data-open");sug.innerHTML="";lastSug=-1;return}
sug.innerHTML=items.map((it,i)=>`<li id="sug-${i}" role="option" data-type="${it.type}" data-lat="${it.lat??""}" data-lon="${it.lon??""}" data-label="${it.label?.replace(/"/g,"&quot;")||""}">${it.label}</li>`).join("");
sug.setAttribute("data-open","1"); lastSug=-1;
}
function chooseSug(li){
const type=li.dataset.type, lat=parseFloat(li.dataset.lat), lon=parseFloat(li.dataset.lon), label=li.dataset.label;
if(type==="coords"||type==="place"){ q.value=label; center(lat,lon,{push:true}); pushHist(label); sug.removeAttribute("data-open") }
if(type==="history"){ q.value=label; doSearch(label) }
}
function pushHist(s){ const h=load(LS_SRCH)||[]; const i=h.indexOf(s); if(i!==-1)h.splice(i,1); h.unshift(s); save(LS_SRCH,h.slice(0,12)) }
function parseCoord(str){ const m=String(str).trim().match(/^\s*([+-]?\d+(?:\.\d+)?)\s*[, ]\s*([+-]?\d+(?:\.\d+)?)\s*$/); if(!m) return null;
const lat=+m[1], lon=+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 searchPlaces=deb(async term=>{
term=term.trim(); if(!term){ const h=load(LS_SRCH)||[]; return renderSug(h.map(x=>({type:"history",label:x}))) }
const coord=parseCoord(term); if(coord) return renderSug([{type:"coords",label:`${coord.lat}, ${coord.lon}`,lat:coord.lat,lon:coord.lon}]);
aborter&&aborter.abort(); aborter=new AbortController();
try{ const r=await fetch(`${NOMI}?q=${encodeURIComponent(term)}&format=json&limit=8&addressdetails=0&accept-language=en`,{signal:aborter.signal});
const d=await r.json();
renderSug(d.map(p=>({type:"place",label:p.display_name,lat:+p.lat,lon:+p.lon})),term);
}catch{ const h=load(LS_SRCH)||[]; renderSug(h.map(x=>({type:"history",label:x}))) }
},140);
async function doSearch(term){
term=term.trim(); if(!term) return;
const coord=parseCoord(term); if(coord){ center(coord.lat,coord.lon,{push:true}); pushHist(`${coord.lat}, ${coord.lon}`); return }
try{ const r=await fetch(`${NOMI}?q=${encodeURIComponent(term)}&format=json&limit=1`); const d=await r.json();
if(d[0]){ center(+d[0].lat,+d[0].lon,{push:true}); pushHist(term) }
}catch{}
}
/* ========= Geolocation ========= */
function locate(){
if(!("geolocation" in navigator)){alert("Geolocation not supported.");return}
navigator.geolocation.getCurrentPosition(p=>center(p.coords.latitude,p.coords.longitude,{push:true}),e=>alert("Unable to retrieve location: "+e.message),{enableHighAccuracy:true,timeout:10000,maximumAge:0});
}
function startFollow(){
if(!("geolocation" in navigator)){alert("Geolocation not supported.");return}
if(watchId!==null) return;
dFollow?.setAttribute("aria-pressed","true"); mFollow?.setAttribute("aria-pressed","true");
watchId=navigator.geolocation.watchPosition(p=>center(p.coords.latitude,p.coords.longitude,{push:false}),_=>stopFollow(),{enableHighAccuracy:true,timeout:15000,maximumAge:1000});
}
function stopFollow(){
if(watchId!==null){ navigator.geolocation.clearWatch(watchId); watchId=null }
dFollow?.setAttribute("aria-pressed","false"); mFollow?.setAttribute("aria-pressed","false");
}
function toggleFollow(){ watchId===null?startFollow():stopFollow() }
/* ========= Pins ========= */
function pins(){ return load(LS_PINS)||[] }
function savePins(p){ save(LS_PINS,p.slice(0,100)) }
function nameFromState(){ return q.value?.trim() || new Date().toLocaleString() }
function renderPins(){
const arr=pins(); pinList.innerHTML="";
if(!arr.length){ pinList.innerHTML=`<li>No pins yet.</li>`; return }
arr.forEach((p,idx)=>{
const li=document.createElement("li");
li.innerHTML=`<strong>${p.name}</strong><div>${dd(p.lat,5)}, ${dd(p.lon,5)} · ${p.layer}</div>`;
const g=document.createElement("div"); g.setAttribute("role","group");
const bGo=btn("Go",()=>{ panelPins.hidden=true; state={...state,lat:p.lat,lon:p.lon,delta:p.delta,layer:p.layer}; dLayer&&(dLayer.value=p.layer); apply(true) });
const bS =btn("Share",async()=>{ const url=appURL(p); if(navigator.share){try{await navigator.share({title:"PokeMaps",url})}catch{}} else {try{await navigator.clipboard.writeText(url); alert("Link copied!") }catch{}} });
const bC =btn("Copy Coords",()=>navigator.clipboard.writeText(`${dd(p.lat,6)},${dd(p.lon,6)}`).then(()=>alert("Copied!")));
const bR =btn("Rename",()=>{ const nv=prompt("New name:",p.name||""); if(nv!==null){ const arr2=pins(); arr2[idx].name=(nv||"").trim()||new Date().toLocaleString(); savePins(arr2); renderPins() }});
const bD =btn("Del",()=>{ if(prefs.confirmDelete!==false && !confirm("Delete pin?")) return; const arr2=pins(); arr2.splice(idx,1); savePins(arr2); renderPins() });
g.append(bGo,bS,bC,bR,bD); li.append(g); pinList.append(li);
});
}
function btn(t,fn){ const b=document.createElement("button"); b.textContent=t; b.type="button"; b.addEventListener("click",fn); return b }
/* ========= Panels ========= */
function toggle(el,open){ el.hidden = (open===undefined) ? !el.hidden : !open; }
faPins?.addEventListener("click",()=>{ renderPins(); toggle(panelPins,true) });
faSet ?.addEventListener("click",()=> toggle(panelSet,true));
faSave?.addEventListener("click",saveCurrentPin);
pClose?.addEventListener("click",()=>toggle(panelPins,false));
sClose?.addEventListener("click",()=>toggle(panelSet,false));
tbMenu?.addEventListener("click",()=>toggle(panelMenu,true));
mClose?.addEventListener("click",()=>toggle(panelMenu,false));
/* ========= Actions ========= */
function resetAll(){ stopFollow(); state={...DEF}; q.value=""; apply(true) }
async function copyLink(){ try{ await navigator.clipboard.writeText(appURL(state)); alert("Link copied!") }catch{ alert("Could not copy.") } }
async function copyCoords(){ const t=fmtCoords(); try{ await navigator.clipboard.writeText(t); alert("Coordinates copied!") }catch{ alert(t) } }
async function shareLink(){ const url=appURL(state); if(navigator.share){ try{ await navigator.share({title:"PokeMaps",url}) }catch{} } else { copyLink() } }
function saveCurrentPin(){ const arr=pins(); arr.unshift({name:nameFromState(), lat:state.lat, lon:state.lon, delta:state.delta, layer:state.layer}); savePins(arr); renderPins(); toggle(panelPins,true) }
/* ========= Settings Bind ========= */
function bindSettings(){
/* load defaults */
if(prefs.shareDelta===undefined) prefs.shareDelta=true;
setTheme(prefs.theme||"auto"); sTheme.value=prefs.theme||"auto";
setAccent(prefs.accent||"#0ea5e9"); sAccent.value=prefs.accent||"#0ea5e9";
sAccentR.addEventListener("click",()=>{ const def="#0ea5e9"; sAccent.value=def; setAccent(def); prefs.accent=def; save(LS_PREFS,prefs) });
sAccent.addEventListener("input",()=>{ setAccent(sAccent.value); prefs.accent=sAccent.value; save(LS_PREFS,prefs) });
sTheme.addEventListener("change",()=>{ prefs.theme=sTheme.value; setTheme(prefs.theme); save(LS_PREFS,prefs) });
sAuto.checked=!!prefs.autoFollow;
sShowC.checked=!!prefs.showCoords;
sFmt.value=prefs.coordFmt||"dec";
sPrec.value=prefs.prec??6;
sAuto.addEventListener("change",()=>{prefs.autoFollow=sAuto.checked;save(LS_PREFS,prefs)});
sShowC.addEventListener("change",()=>{prefs.showCoords=sShowC.checked;save(LS_PREFS,prefs)});
sFmt .addEventListener("change",()=>{prefs.coordFmt=sFmt.value;save(LS_PREFS,prefs)});
sPrec.addEventListener("input",()=>{prefs.prec=+sPrec.value;save(LS_PREFS,prefs)});
sShareD.checked=prefs.shareDelta!==false;
sConfirm.checked=prefs.confirmDelete!==false;
sShareD.addEventListener("change",()=>{prefs.shareDelta=sShareD.checked;save(LS_PREFS,prefs)});
sConfirm.addEventListener("change",()=>{prefs.confirmDelete=sConfirm.checked;save(LS_PREFS,prefs)});
sMV.checked=prefs.markerVisible!==false;
sMSel.value=prefs.markerStyle||"dot";
sMSize.value=prefs.markerSize||20;
sMColor.value=prefs.markerColor||"#e53935";
sMR.checked=prefs.markerRing!==false;
sRW.value=prefs.ringWidth!=null?prefs.ringWidth:3;
[sMV,sMSel,sMSize,sMColor,sMR,sRW].forEach(el=>el.addEventListener("input",()=>{
prefs.markerVisible=sMV.checked;
prefs.markerStyle=sMSel.value;
prefs.markerSize=+sMSize.value;
prefs.markerColor=sMColor.value;
prefs.markerRing=sMR.checked;
prefs.ringWidth=+sRW.value;
save(LS_PREFS,prefs); applyMarker();
}));
sCSS.value=prefs.userCSS||"";
sCSSApply.addEventListener("click",()=>{ prefs.userCSS=sCSS.value||""; applyUserCSS(prefs.userCSS); save(LS_PREFS,prefs) });
sCSSClear.addEventListener("click",()=>{ sCSS.value=""; prefs.userCSS=""; applyUserCSS(""); save(LS_PREFS,prefs) });
/* menu mirrors */
mLayer.value=state.layer; mLayer.addEventListener("change",()=>{ state.layer=mLayer.value; dLayer&&(dLayer.value=mLayer.value); apply(true) });
mNear .addEventListener("click",locate);
mClear.addEventListener("click",()=>{ q.value=""; sug.removeAttribute("data-open"); q.focus() });
mReset.addEventListener("click",resetAll);
mFollow.addEventListener("click",toggleFollow);
mCopy .addEventListener("click",copyLink);
mCopyC .addEventListener("click",copyCoords);
mShare .addEventListener("click",shareLink);
mOSM .addEventListener("click",()=>open(osmURL(state),"_blank"));
mG .addEventListener("click",()=>open(gURL(state),"_blank"));
/* desktop mirrors */
if(dLayer){ dLayer.value=state.layer; dLayer.addEventListener("change",()=>{ state.layer=dLayer.value; mLayer&&(mLayer.value=dLayer.value); apply(true) })}
dLocate?.addEventListener("click",locate);
dFollow?.addEventListener("click",toggleFollow);
dReset ?.addEventListener("click",resetAll);
dCopy ?.addEventListener("click",copyLink);
dCopyC ?.addEventListener("click",copyCoords);
dOSM ?.addEventListener("click",()=>open(osmURL(state),"_blank"));
dG ?.addEventListener("click",()=>open(gURL(state),"_blank"));
}
/* ========= Search wiring ========= */
q?.addEventListener("input",e=>searchPlaces(e.target.value));
q?.addEventListener("focus",()=>{ if(!q.value){ const h=load(LS_SRCH)||[]; renderSug(h.map(x=>({type:"history",label:x}))) }});
document.addEventListener("click",e=>{
const within = e.target.closest("#topbar") || e.target.closest("#autosuggest");
if(!within){ sug.removeAttribute("data-open") }
});
$("#search-form")?.addEventListener("submit",e=>{ e.preventDefault(); doSearch(q.value); sug.removeAttribute("data-open") });
sug?.addEventListener("mousedown",e=>{ const li=e.target.closest("li"); if(li){ e.preventDefault(); chooseSug(li) }});
document.addEventListener("keydown",e=>{
if(sug.getAttribute("data-open")!=="1") return;
const items=[...sug.querySelectorAll("li")]; if(!items.length) return;
if(e.key==="ArrowDown"){ e.preventDefault(); lastSug=(lastSug+1)%items.length }
if(e.key==="ArrowUp"){ e.preventDefault(); lastSug=(lastSug-1+items.length)%items.length }
if(e.key==="Enter"){ e.preventDefault(); if(lastSug>=0) chooseSug(items[lastSug]) }
items.forEach((li,i)=>li.setAttribute("aria-selected", i===lastSug?"true":"false"));
});
/* ========= Topbar actions ========= */
tbLocate?.addEventListener("click",locate);
tbMenu ?.addEventListener("click",()=>toggle(panelMenu,true));
/* ========= History / Popstate ========= */
addEventListener("popstate",e=>{
stopFollow();
if(e.state && typeof e.state.lat==="number"){ state=e.state }
apply(false);
});
/* ========= Init ========= */
applyMarker();
apply(false);
bindSettings();
tick();
/* If no URL coords, try locate once */
if(!new URLSearchParams(location.search).has("lat")) locate();
/* Auto-follow if pref */
if(prefs.autoFollow) startFollow();
/* ========= Small helpers to expose optional programmatic hooks ========= */
window.PokeMaps={center,apply,shareLink,copyLink,copyCoords,pins,savePins};
})();
</script>
</div>
</body>
</html>