774 lines
37 KiB
Plaintext
774 lines
37 KiB
Plaintext
<!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,""")||""}">${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>
|