1278 lines
51 KiB
HTML
1278 lines
51 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Map Maker</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10.6.1/ol.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/ol@10.6.1/dist/ol.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/ol-mapbox-style@13.5.1/dist/olms.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/proj4@2.19.10/dist/proj4.js"></script>
|
||
<style>
|
||
:root {
|
||
font-family: Inter, system-ui, sans-serif;
|
||
color: #25333a;
|
||
--blue: #234e68;
|
||
--blue-dark: #173749;
|
||
--border: #d4dfe5;
|
||
--footer-height: 13mm;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||
button, input, select, textarea { font: inherit; }
|
||
|
||
button {
|
||
cursor: pointer;
|
||
padding: 9px 11px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: white;
|
||
color: var(--blue-dark);
|
||
font-size: 12px;
|
||
font-weight: 650;
|
||
}
|
||
button:hover { background: #eaf1f5; }
|
||
button.primary { color: white; background: var(--blue); border-color: var(--blue); }
|
||
button.primary:hover { background: var(--blue-dark); }
|
||
|
||
input[type="text"], input[type="number"], input[type="date"], select, textarea {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 9px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: white;
|
||
color: #24343d;
|
||
font-size: 12px;
|
||
}
|
||
textarea { resize: vertical; min-height: 65px; }
|
||
input[type="checkbox"] { accent-color: var(--blue); }
|
||
label { display: block; margin-bottom: 5px; font-size: 11px; color: #60717c; }
|
||
.field { margin-bottom: 12px; }
|
||
.row { display: flex; align-items: end; gap: 8px; }
|
||
.row > * { flex: 1; min-width: 0; }
|
||
|
||
#map {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #e9edf0;
|
||
border: 1px solid #263b47;
|
||
}
|
||
|
||
.panel {
|
||
position: fixed;
|
||
z-index: 20;
|
||
top: 14px;
|
||
bottom: 58px;
|
||
left: 14px;
|
||
width: 310px;
|
||
max-width: calc(100vw - 28px);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
background: rgba(255, 255, 255, .97);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid #d7e0e5;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px #142b3a24;
|
||
padding: 18px;
|
||
transition: transform .2s ease, opacity .2s ease;
|
||
}
|
||
|
||
.sidebar-toggle {
|
||
position: fixed;
|
||
z-index: 30;
|
||
top: 20px;
|
||
left: 274px;
|
||
width: 30px;
|
||
height: 30px;
|
||
padding: 0;
|
||
font-size: 16px;
|
||
background: transparent;
|
||
border: 0;
|
||
}
|
||
|
||
body.sidebar-collapsed .panel {
|
||
transform: translateX(calc(-100% - 20px));
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
body.sidebar-collapsed .sidebar-toggle {
|
||
left: 18px;
|
||
top: 18px;
|
||
width: 38px;
|
||
height: 38px;
|
||
font-size: 20px;
|
||
background: white;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 2px 10px #142b3a25;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 18px;
|
||
padding-right: 30px;
|
||
}
|
||
.brand h1 { font-size: 20px; margin: 0; letter-spacing: -.7px; color: var(--blue-dark); }
|
||
.brand p { font-size: 11px; color: #71838d; margin: 4px 0 0; }
|
||
.brand-mark { font-size: 25px; color: var(--blue); }
|
||
|
||
.section { padding: 15px 0; border-top: 1px solid #e5ebef; }
|
||
.section h2 {
|
||
font-size: 10px;
|
||
letter-spacing: 1.2px;
|
||
text-transform: uppercase;
|
||
color: #617581;
|
||
margin: 0 0 13px;
|
||
}
|
||
.check {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
margin: 11px 0;
|
||
font-size: 12px;
|
||
color: #334954;
|
||
}
|
||
.check input { margin: 0; }
|
||
.full { width: 100%; }
|
||
.status { font-size: 11px; line-height: 1.5; color: #75858e; overflow-wrap: anywhere; margin-top: 8px; }
|
||
.status.warning { color: #9a3412; }
|
||
.location-results { display: grid; gap: 6px; margin-top: 8px; }
|
||
.location-results button { text-align: left; line-height: 1.4; font-weight: 500; }
|
||
.location-results button small { display: block; color: #71838d; font-size: 10px; margin-top: 3px; }
|
||
.error { color: #b42318; }
|
||
[hidden] { display: none !important; }
|
||
|
||
.declination-row {
|
||
display: flex;
|
||
align-items: end;
|
||
gap: 8px;
|
||
}
|
||
.declination-row .field {
|
||
flex: 1;
|
||
min-width: 0;
|
||
margin-bottom: 0;
|
||
}
|
||
.declination-row .field:has(#magDate) { flex: 1 1 auto; min-width: 0; }
|
||
.declination-row .field:has(#declination) { flex: 0 1 105px; max-width: 105px; }
|
||
|
||
.location-search-row { display: flex; align-items: end; gap: 8px; }
|
||
.location-search-row .field { flex: 1; min-width: 0; margin-bottom: 0; }
|
||
.location-search-row button {
|
||
flex: 0 0 42px;
|
||
width: 42px;
|
||
height: 36px;
|
||
padding: 0;
|
||
font-size: 17px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.declination-row button {
|
||
flex: 0 0 42px;
|
||
width: 42px;
|
||
height: 36px;
|
||
padding: 0;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.ol-zoom {
|
||
position: fixed !important;
|
||
top: auto !important;
|
||
right: 18px !important;
|
||
bottom: 18px !important;
|
||
left: auto !important;
|
||
}
|
||
.ol-control button { color: #234e68; background: rgba(255,255,255,.95); }
|
||
|
||
.compass {
|
||
position: fixed;
|
||
z-index: 10;
|
||
top: 18px;
|
||
right: 18px;
|
||
width: 94px;
|
||
height: 94px;
|
||
display: grid;
|
||
place-items: center;
|
||
pointer-events: none;
|
||
}
|
||
.compass svg { width: 100%; height: 100%; overflow: visible; }
|
||
.compass text { font-family: Georgia, serif; fill: #111; font-size: 9px; font-weight: 800; }
|
||
.print-page { display: none; }
|
||
|
||
@page { size: A4 landscape; margin: 0; }
|
||
|
||
@media (max-width: 600px) {
|
||
.panel { width: min(310px, calc(100vw - 28px)); }
|
||
body:not(.sidebar-collapsed) .sidebar-toggle { left: auto; right: 24px; }
|
||
}
|
||
|
||
@media print {
|
||
html, body {
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow: hidden !important;
|
||
background: white;
|
||
}
|
||
.panel, .sidebar-toggle, .ol-zoom, .ol-rotate, .ol-attribution { display: none !important; }
|
||
|
||
#map {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: calc(100% - var(--footer-height)) !important;
|
||
border: 1px solid #263d49 !important;
|
||
outline: none !important;
|
||
}
|
||
.compass {
|
||
display: grid !important;
|
||
position: absolute;
|
||
top: 3mm;
|
||
right: 3mm;
|
||
width: 22mm;
|
||
height: 22mm;
|
||
background: transparent !important;
|
||
border: 0;
|
||
box-shadow: none;
|
||
z-index: 10000;
|
||
}
|
||
.compass svg { width: 22mm; height: 22mm; }
|
||
|
||
.print-page {
|
||
position: absolute;
|
||
display: flex !important;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 4mm;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
height: var(--footer-height);
|
||
padding: 2mm 4mm;
|
||
border-top: .3mm solid #234e68;
|
||
background: white;
|
||
color: #25333a;
|
||
font-size: 8pt;
|
||
overflow: hidden;
|
||
}
|
||
.print-title { min-width: 0; width: 22%; }
|
||
.print-title strong { display: block; font-size: 12pt; overflow-wrap: anywhere; }
|
||
.print-title small { display: block; margin-top: 1mm; }
|
||
.print-notes { flex: 1; min-width: 0; align-self: stretch; overflow: hidden; }
|
||
.print-notes small { display: block; color: #637681; font-size: 6.5pt; text-transform: uppercase; margin-bottom: 1mm; }
|
||
.print-notes span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||
.print-metadata {
|
||
display: flex;
|
||
flex: 1;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: 4mm;
|
||
min-width: 0;
|
||
}
|
||
.print-item { min-width: 0; }
|
||
.print-item small {
|
||
display: block;
|
||
color: #637681;
|
||
font-size: 6.5pt;
|
||
text-transform: uppercase;
|
||
margin-bottom: 1mm;
|
||
}
|
||
.print-item strong { white-space: nowrap; font-weight: 600; }
|
||
* { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="map"></div>
|
||
<button class="sidebar-toggle" id="sidebarToggle" title="Toggle settings" aria-label="Toggle settings">×</button>
|
||
|
||
<aside class="panel" id="panel">
|
||
<div class="brand">
|
||
<div>
|
||
<h1>Map Maker</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Map details</h2>
|
||
<div class="field">
|
||
<label for="title">Map title</label>
|
||
<input id="title" type="text" value="Field Map">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Appearance</h2>
|
||
<div class="field" id="mapSourceField">
|
||
<label for="mapSource">Map source</label>
|
||
<select id="mapSource"></select>
|
||
</div>
|
||
<label class="check"><input id="monochrome" type="checkbox"> Black & white map</label>
|
||
<label class="check"><input id="contours" type="checkbox" checked> Contour lines</label>
|
||
<label class="check"><input id="labels" type="checkbox" checked> Contour labels</label>
|
||
<label class="check"><input id="grid" type="checkbox"> MGRS / UTM grid overlay</label>
|
||
<div class="field">
|
||
<label for="gridSpacing">Grid interval</label>
|
||
<select id="gridSpacing">
|
||
<option value="1000">1 km</option>
|
||
<option value="10000" selected>10 km</option>
|
||
<option value="100000">100 km</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="contourInterval">Major contour interval (m)</label>
|
||
<input id="contourInterval" type="number" value="100" min="1">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Find a location & view</h2>
|
||
<form id="locationSearchForm">
|
||
<div class="location-search-row">
|
||
<div class="field">
|
||
<label for="locationSearch">Place, address, or coordinates</label>
|
||
<input id="locationSearch" type="text" autocomplete="off" placeholder="Search a place…">
|
||
</div>
|
||
<button id="locationSearchButton" type="submit" title="Search location" aria-label="Search location">⌕</button>
|
||
</div>
|
||
</form>
|
||
<div id="locationStatus" class="status" role="status" aria-live="polite">Search requires an internet connection.</div>
|
||
<div id="locationResults" class="location-results" hidden></div>
|
||
<div class="row field" style="margin-top: 12px">
|
||
<div>
|
||
<label for="latitude">Latitude</label>
|
||
<input id="latitude" type="number" value="45.42" step=".0001">
|
||
</div>
|
||
<div>
|
||
<label for="longitude">Longitude</label>
|
||
<input id="longitude" type="number" value="-75.69" step=".0001">
|
||
</div>
|
||
</div>
|
||
<div class="row field">
|
||
<div>
|
||
<label for="zoom">Zoom</label>
|
||
<input id="zoom" type="number" value="10" min="0" max="22" step=".1">
|
||
</div>
|
||
<div>
|
||
<label for="rotation">Rotation (°)</label>
|
||
<input id="rotation" type="number" value="0" step=".1">
|
||
</div>
|
||
</div>
|
||
<button id="applyView" class="primary full">Apply view</button>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Compass & declination</h2>
|
||
<div class="declination-row">
|
||
<div class="field">
|
||
<label for="magDate">Date</label>
|
||
<input id="magDate" type="date">
|
||
</div>
|
||
<div class="field">
|
||
<label for="declination">Declination °</label>
|
||
<input id="declination" type="number" step=".01" value="0">
|
||
</div>
|
||
<button id="resetDeclination" type="button" title="Reset declination" aria-label="Reset declination">↺</button>
|
||
</div>
|
||
<div id="magStatus" class="status"></div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Map notes</h2>
|
||
<textarea id="notes" placeholder="Survey notes, datum, source, or other details…"></textarea>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>Print layout</h2>
|
||
<div class="field">
|
||
<label for="paper">Paper preset</label>
|
||
<select id="paper">
|
||
<option value="297,210">A4 Landscape</option>
|
||
<option value="210,297">A4 Portrait</option>
|
||
<option value="279.4,215.9">Letter Landscape</option>
|
||
<option value="215.9,279.4">Letter Portrait</option>
|
||
<option value="420,297">A3 Landscape</option>
|
||
<option value="297,420">A3 Portrait</option>
|
||
<option value="custom">Custom dimensions</option>
|
||
</select>
|
||
</div>
|
||
<div class="row field">
|
||
<div>
|
||
<label for="paperWidth">Width (mm)</label>
|
||
<input id="paperWidth" type="number" value="297" min="100" max="1000">
|
||
</div>
|
||
<div>
|
||
<label for="paperHeight">Height (mm)</label>
|
||
<input id="paperHeight" type="number" value="210" min="100" max="1000">
|
||
</div>
|
||
</div>
|
||
<button id="print" class="primary full">Print / Save PDF</button>
|
||
<p class="status">Select Actual size / 100% and disable browser headers and footers for accurate output.</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="compass" id="compass">
|
||
<svg viewBox="0 0 100 100" aria-label="Four-point navigation star with cardinal directions">
|
||
<text x="50" y="10" text-anchor="middle">N</text>
|
||
<text x="96" y="53" text-anchor="middle">E</text>
|
||
<text x="50" y="99" text-anchor="middle">S</text>
|
||
<text x="4" y="53" text-anchor="middle">W</text>
|
||
<g id="trueNeedle" fill="#fff" stroke="#111" stroke-width="1.5" stroke-linejoin="miter">
|
||
<path d="M50 14 L55 45 L50 50 L45 45 Z"/>
|
||
<path d="M50 86 L55 55 L50 50 L45 55 Z" fill="#111"/>
|
||
<path d="M14 50 L45 45 L50 50 L45 55 Z" fill="#111"/>
|
||
<path d="M86 50 L55 45 L50 50 L55 55 Z" fill="#fff"/>
|
||
</g>
|
||
<g id="magNeedle" fill="#c05b36">
|
||
<path d="M50 20 L53 47 L50 50 L47 47 Z"/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
|
||
<footer class="print-page">
|
||
<div class="print-title">
|
||
<strong id="footerTitle">Field Map</strong>
|
||
<small id="footerDate"></small>
|
||
</div>
|
||
<div class="print-notes">
|
||
<small>Notes</small>
|
||
<span id="footerNotes">—</span>
|
||
</div>
|
||
<div class="print-metadata">
|
||
<div class="print-item"><small>Declination</small><strong id="footerDeclination">—</strong></div>
|
||
<div class="print-item"><small>Zoom</small><strong id="footerZoom">—</strong></div>
|
||
<div class="print-item"><small>Center</small><strong id="footerCenter">—</strong></div>
|
||
</div>
|
||
</footer>
|
||
|
||
<script type="module">
|
||
import {magvar} from 'https://esm.sh/magvar@2.2.0';
|
||
import mgrs from 'https://esm.sh/mgrs@1.0.0';
|
||
|
||
let TILE_SERVER = 'https://tiles.zakscode.com'.replace('localhost', location.hostname);
|
||
const CONTOUR_TILE_URL = `${TILE_SERVER}/data/contours/{z}/{x}/{y}.pbf`;
|
||
const MAP_SOURCES = [
|
||
{
|
||
label: 'OSM Light (Offline)',
|
||
url: `${TILE_SERVER}/styles/light/style.json`,
|
||
type: 'style',
|
||
maxZoom: 19,
|
||
},
|
||
{
|
||
label: 'OSM Dark (Offline)',
|
||
url: `${TILE_SERVER}/styles/dark/style.json`,
|
||
type: 'style',
|
||
maxZoom: 19,
|
||
},
|
||
{
|
||
label: 'Satellite (High-Res)',
|
||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||
type: 'raster',
|
||
maxZoom: 19,
|
||
},
|
||
{
|
||
label: 'Satellite (Offline)',
|
||
url: `${TILE_SERVER}/data/satellite/{z}/{x}/{y}.jpg`,
|
||
type: 'raster',
|
||
maxZoom: 19,
|
||
},
|
||
];
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const SETTINGS_KEY = 'map-maker-settings-v1';
|
||
const SETTINGS_IDS = [
|
||
'title', 'mapSource', 'monochrome', 'contours', 'labels', 'grid', 'gridSpacing',
|
||
'contourInterval', 'latitude', 'longitude', 'zoom', 'rotation', 'magDate',
|
||
'declination', 'notes', 'paper', 'paperWidth', 'paperHeight'
|
||
];
|
||
let savedSettings = {};
|
||
try {
|
||
savedSettings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') || {};
|
||
} catch (error) {
|
||
console.warn('Could not restore saved map settings:', error);
|
||
}
|
||
for (const id of SETTINGS_IDS) {
|
||
const element = $(id);
|
||
const saved = savedSettings[id];
|
||
if (!element || saved === undefined) continue;
|
||
if (element.type === 'checkbox') element.checked = Boolean(saved);
|
||
else element.value = String(saved);
|
||
}
|
||
|
||
function saveSettings() {
|
||
try {
|
||
const settings = Object.fromEntries(SETTINGS_IDS.map(id => {
|
||
const element = $(id);
|
||
return [id, element.type === 'checkbox' ? element.checked : element.value];
|
||
}));
|
||
settings.view = {
|
||
center: view.getCenter(),
|
||
zoom: view.getZoom(),
|
||
rotation: view.getRotation()
|
||
};
|
||
settings.sidebarCollapsed = document.body.classList.contains('sidebar-collapsed');
|
||
settings.manualDeclination = manualDeclination;
|
||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
} catch (error) {
|
||
console.warn('Could not save map settings:', error);
|
||
}
|
||
}
|
||
|
||
const ol = window.ol;
|
||
const proj4 = window.proj4;
|
||
|
||
if (!ol || !proj4) throw new Error('OpenLayers or proj4 failed to load.');
|
||
ol.proj.proj4.register(proj4);
|
||
|
||
if (!$('magDate').value) {
|
||
const today = new Date();
|
||
$('magDate').value = [
|
||
today.getFullYear(),
|
||
String(today.getMonth() + 1).padStart(2, '0'),
|
||
String(today.getDate()).padStart(2, '0')
|
||
].join('-');
|
||
}
|
||
|
||
const view = new ol.View({
|
||
center: ol.proj.fromLonLat([-75.69, 45.42]),
|
||
zoom: 10,
|
||
maxZoom: 22,
|
||
rotation: 0
|
||
});
|
||
|
||
const map = new ol.Map({
|
||
target: 'map',
|
||
view,
|
||
layers: [],
|
||
controls: ol.control.defaults.defaults().extend([
|
||
new ol.control.ScaleLine({units: 'metric', bar: true, steps: 4, text: true})
|
||
])
|
||
});
|
||
|
||
if (savedSettings.view?.center?.length === 2 && savedSettings.view.center.every(Number.isFinite)) {
|
||
view.setCenter(savedSettings.view.center);
|
||
if (Number.isFinite(savedSettings.view.zoom)) view.setZoom(Math.max(0, Math.min(22, savedSettings.view.zoom)));
|
||
if (Number.isFinite(savedSettings.view.rotation)) view.setRotation(savedSettings.view.rotation);
|
||
}
|
||
if (savedSettings.sidebarCollapsed) {
|
||
document.body.classList.add('sidebar-collapsed');
|
||
$('sidebarToggle').textContent = '☰';
|
||
}
|
||
|
||
let tileSource = null;
|
||
let tileLayer = null;
|
||
let rasterLayer = null;
|
||
let styleLayers = [];
|
||
let sourceLoadId = 0;
|
||
let contourSource = null;
|
||
let contourLayer = null;
|
||
let gridLayer = null;
|
||
let gridTimer;
|
||
let manualDeclination = null;
|
||
let calculatedDeclination = 0;
|
||
let monochrome = false;
|
||
let contoursEnabled = true;
|
||
let labelsEnabled = true;
|
||
let gridEnabled = false;
|
||
let printPending = false;
|
||
|
||
const styleCache = new Map();
|
||
|
||
function styleFeature(feature) {
|
||
const p = feature.getProperties();
|
||
const isContour = String(p.layer ?? p.layer_name ?? '').toLowerCase().includes('contour') ||
|
||
String(p.class ?? p.type ?? p.natural ?? '').toLowerCase().includes('contour') ||
|
||
p.ele !== undefined || p.elevation !== undefined;
|
||
|
||
const elevation = Number(p.ele ?? p.elevation ?? p.height ?? 0);
|
||
|
||
if (isContour) {
|
||
if (!contoursEnabled) return [];
|
||
const interval = Math.max(1, Number($('contourInterval').value) || 100);
|
||
const major = elevation !== 0 &&
|
||
Math.abs(elevation / interval - Math.round(elevation / interval)) < .0001;
|
||
const underwater = elevation < 0;
|
||
const color = monochrome ? (major ? '#222' : '#999') : underwater
|
||
? (major ? '#285f7b' : '#82a9bc')
|
||
: (major ? '#805a37' : '#c3a477');
|
||
|
||
const key = `c-${major}-${underwater}-${labelsEnabled}-${elevation}-${monochrome}`;
|
||
if (styleCache.has(key)) return styleCache.get(key);
|
||
|
||
const styles = [new ol.style.Style({
|
||
stroke: new ol.style.Stroke({color, width: major ? 1.15 : .55})
|
||
})];
|
||
|
||
if (labelsEnabled && major && elevation !== 0) {
|
||
styles.push(new ol.style.Style({
|
||
text: new ol.style.Text({
|
||
text: `${elevation}m`,
|
||
font: '10px sans-serif',
|
||
fill: new ol.style.Fill({color}),
|
||
stroke: new ol.style.Stroke({color: '#fff', width: 3}),
|
||
placement: 'line',
|
||
maxAngle: .5,
|
||
overflow: true
|
||
})
|
||
}));
|
||
}
|
||
|
||
styleCache.set(key, styles);
|
||
return styles;
|
||
}
|
||
|
||
const kind = String(
|
||
p.class ?? p.kind ?? p.type ?? p.natural ?? p.waterway ?? p.highway ?? p.landuse ?? ''
|
||
).toLowerCase();
|
||
const color = value => monochrome ? '#555' : value;
|
||
|
||
if (/water|river|stream|lake|ocean|sea|canal/.test(kind)) {
|
||
return new ol.style.Style({
|
||
stroke: new ol.style.Stroke({color: color('#86b9d0'), width: 1}),
|
||
fill: new ol.style.Fill({color: monochrome ? '#e5e5e5' : 'rgba(169,207,221,.8)'})
|
||
});
|
||
}
|
||
if (/building/.test(kind)) {
|
||
return new ol.style.Style({
|
||
stroke: new ol.style.Stroke({color: color('#b7b7b7'), width: .5}),
|
||
fill: new ol.style.Fill({color: monochrome ? '#eee' : '#e4ded3'})
|
||
});
|
||
}
|
||
if (/motorway|trunk|primary/.test(kind)) {
|
||
return new ol.style.Style({stroke: new ol.style.Stroke({color: color('#b0a18d'), width: 2})});
|
||
}
|
||
if (/secondary|tertiary/.test(kind)) {
|
||
return new ol.style.Style({stroke: new ol.style.Stroke({color: color('#c3b8a8'), width: 1.4})});
|
||
}
|
||
if (/road|street|residential|service|track|path|footway|highway/.test(kind)) {
|
||
return new ol.style.Style({stroke: new ol.style.Stroke({color: color('#d0c7ba'), width: .8})});
|
||
}
|
||
if (/wood|forest|grass|park|meadow|orchard|heath/.test(kind)) {
|
||
return new ol.style.Style({
|
||
fill: new ol.style.Fill({color: monochrome ? '#f0f0f0' : 'rgba(197,216,184,.65)'})
|
||
});
|
||
}
|
||
if (/boundary|administrative/.test(kind)) {
|
||
return new ol.style.Style({
|
||
stroke: new ol.style.Stroke({color: color('#a89aa7'), width: 1, lineDash: [5, 4]})
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function refreshStyles() {
|
||
styleCache.clear();
|
||
tileLayer?.changed();
|
||
rasterLayer?.changed();
|
||
contourLayer?.changed();
|
||
updatePrintInfo();
|
||
gridLayer?.changed();
|
||
}
|
||
|
||
async function loadMapSource(index = 0) {
|
||
const config = MAP_SOURCES[index];
|
||
if (!config) return;
|
||
const loadId = ++sourceLoadId;
|
||
|
||
for (const layer of [...styleLayers, tileLayer, rasterLayer, contourLayer]) {
|
||
if (layer) map.removeLayer(layer);
|
||
}
|
||
styleLayers = [];
|
||
tileLayer = null;
|
||
rasterLayer = null;
|
||
contourLayer = null;
|
||
contourSource = null;
|
||
tileSource = null;
|
||
|
||
try {
|
||
if (config.type === 'raster') {
|
||
rasterLayer = new ol.layer.Tile({
|
||
source: new ol.source.XYZ({
|
||
url: config.url,
|
||
attributions: config.attributions || '',
|
||
maxZoom: config.maxZoom || 19
|
||
}),
|
||
zIndex: 0
|
||
});
|
||
map.addLayer(rasterLayer);
|
||
} else if (config.type === 'style') {
|
||
if (!window.olms?.apply) throw new Error('ol-mapbox-style failed to load.');
|
||
const existingLayers = new Set(map.getLayers().getArray());
|
||
await window.olms.apply(map, config.url);
|
||
styleLayers = map.getLayers().getArray().filter(layer => !existingLayers.has(layer));
|
||
|
||
if (loadId !== sourceLoadId) {
|
||
for (const layer of styleLayers) map.removeLayer(layer);
|
||
styleLayers = [];
|
||
return;
|
||
}
|
||
styleLayers.forEach((layer, i) => layer.setZIndex(i));
|
||
} else {
|
||
tileSource = new ol.source.VectorTile({
|
||
format: new ol.format.MVT(),
|
||
url: config.url,
|
||
maxZoom: config.maxZoom || 14
|
||
});
|
||
tileLayer = new ol.layer.VectorTile({
|
||
source: tileSource,
|
||
style: styleFeature,
|
||
zIndex: 0
|
||
});
|
||
tileSource.on('tileloaderror', () => console.error(
|
||
`Failed to load map source: ${config.label}`, config.url
|
||
));
|
||
map.addLayer(tileLayer);
|
||
}
|
||
|
||
if (loadId !== sourceLoadId) return;
|
||
|
||
contourSource = new ol.source.VectorTile({
|
||
format: new ol.format.MVT(),
|
||
url: CONTOUR_TILE_URL,
|
||
maxZoom: 14
|
||
});
|
||
contourLayer = new ol.layer.VectorTile({
|
||
source: contourSource,
|
||
style: styleFeature,
|
||
zIndex: 100,
|
||
visible: contoursEnabled
|
||
});
|
||
contourSource.on('tileloaderror', event => console.error(
|
||
'Failed to load contour tiles:', CONTOUR_TILE_URL, event
|
||
));
|
||
map.addLayer(contourLayer);
|
||
|
||
$('mapSource').value = String(index);
|
||
$('mapSourceField').hidden = MAP_SOURCES.length < 2;
|
||
refreshStyles();
|
||
rebuildGrid();
|
||
map.updateSize();
|
||
map.renderSync();
|
||
saveSettings();
|
||
} catch (error) {
|
||
console.error(`Failed to load map source: ${config.label}`, error);
|
||
$('locationStatus').classList.add('warning');
|
||
$('locationStatus').textContent = `Map style failed to load: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
$('mapSource').innerHTML = MAP_SOURCES.map((source, index) =>
|
||
`<option value="${index}">${source.label}</option>`
|
||
).join('');
|
||
$('mapSource').addEventListener('change', event => loadMapSource(Number(event.target.value)));
|
||
const initialSource = Number.isInteger(Number(savedSettings.mapSource)) &&
|
||
MAP_SOURCES[Number(savedSettings.mapSource)] ? Number(savedSettings.mapSource) : 0;
|
||
|
||
$('sidebarToggle').addEventListener('click', () => {
|
||
document.body.classList.toggle('sidebar-collapsed');
|
||
$('sidebarToggle').textContent = document.body.classList.contains('sidebar-collapsed') ? '☰' : '×';
|
||
saveSettings();
|
||
setTimeout(() => {
|
||
map.updateSize();
|
||
rebuildGrid();
|
||
}, 220);
|
||
});
|
||
|
||
function decimalYear(date) {
|
||
const year = date.getUTCFullYear();
|
||
const start = Date.UTC(year, 0, 1);
|
||
const end = Date.UTC(year + 1, 0, 1);
|
||
return year + (date.getTime() - start) / (end - start);
|
||
}
|
||
|
||
function centerLonLat() {
|
||
return ol.proj.toLonLat(view.getCenter());
|
||
}
|
||
|
||
function declination() {
|
||
if (manualDeclination !== null) return manualDeclination;
|
||
|
||
try {
|
||
const [lon, lat] = centerLonLat();
|
||
const date = new Date(`${$('magDate').value}T12:00:00Z`);
|
||
if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
|
||
|
||
calculatedDeclination = magvar(lat, lon, 0, decimalYear(date));
|
||
if (!Number.isFinite(calculatedDeclination)) throw new Error('Invalid result');
|
||
|
||
$('magStatus').textContent = '';
|
||
$('declination').value = calculatedDeclination.toFixed(2);
|
||
} catch (error) {
|
||
$('magStatus').textContent = `Declination error: ${error.message}`;
|
||
}
|
||
return calculatedDeclination;
|
||
}
|
||
|
||
function updatePrintInfo() {
|
||
const value = declination();
|
||
const [lon, lat] = centerLonLat();
|
||
const rotation = view.getRotation() * 180 / Math.PI;
|
||
|
||
$('footerTitle').textContent = $('title').value.trim() || 'Field Map';
|
||
$('footerDate').textContent = $('magDate').value;
|
||
$('footerDeclination').textContent = `${Math.abs(value).toFixed(2)}° ${value > 0 ? 'E' : value < 0 ? 'W' : ''}`;
|
||
$('footerZoom').textContent = view.getZoom()?.toFixed(1) ?? '—';
|
||
$('footerCenter').textContent = `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
|
||
$('footerNotes').textContent = $('notes').value.trim() || '—';
|
||
|
||
$('trueNeedle').setAttribute('transform', `rotate(${-rotation} 50 50)`);
|
||
$('magNeedle').setAttribute('transform', `rotate(${-rotation + value} 50 50)`);
|
||
}
|
||
|
||
function addUTMProjection(zone, north) {
|
||
const code = `EPSG:${(north ? 32600 : 32700) + zone}`;
|
||
if (!proj4.defs(code)) {
|
||
proj4.defs(code, `+proj=utm +zone=${zone} +datum=WGS84 +units=m +no_defs${north ? '' : ' +south'}`);
|
||
ol.proj.proj4.register(proj4);
|
||
}
|
||
return code;
|
||
}
|
||
|
||
function rebuildGrid() {
|
||
clearTimeout(gridTimer);
|
||
if (gridLayer) {
|
||
map.removeLayer(gridLayer);
|
||
gridLayer = null;
|
||
}
|
||
if (!gridEnabled) return;
|
||
|
||
gridTimer = setTimeout(() => {
|
||
try {
|
||
const size = map.getSize();
|
||
if (!size) return;
|
||
|
||
const extent = view.calculateExtent(size);
|
||
const corners = [
|
||
[extent[0], extent[1]], [extent[0], extent[3]],
|
||
[extent[2], extent[1]], [extent[2], extent[3]]
|
||
].map(point => ol.proj.toLonLat(point));
|
||
|
||
const [lon, lat] = centerLonLat();
|
||
const zone = Math.max(1, Math.min(60, Math.floor((lon + 180) / 6) + 1));
|
||
const projection = addUTMProjection(zone, lat >= 0);
|
||
const spacing = Number($('gridSpacing').value);
|
||
const projected = corners.map(point => proj4('EPSG:4326', projection, point));
|
||
const xs = projected.map(point => point[0]);
|
||
const ys = projected.map(point => point[1]);
|
||
const minX = Math.floor(Math.min(...xs) / spacing) * spacing;
|
||
const maxX = Math.ceil(Math.max(...xs) / spacing) * spacing;
|
||
const minY = Math.floor(Math.min(...ys) / spacing) * spacing;
|
||
const maxY = Math.ceil(Math.max(...ys) / spacing) * spacing;
|
||
const source = new ol.source.Vector();
|
||
const step = Math.min(spacing / 4, 5000);
|
||
const features = [];
|
||
const mapCoord = (x, y) => ol.proj.fromLonLat(proj4(projection, 'EPSG:4326', [x, y]));
|
||
const accuracy = Math.max(0, Math.min(5, 5 - Math.round(Math.log10(spacing))));
|
||
const width = size[0];
|
||
const height = size[1];
|
||
|
||
function gridRefAtPixel(pixel) {
|
||
const coord = map.getCoordinateFromPixel(pixel);
|
||
if (!coord) return null;
|
||
const [pointLon, pointLat] = ol.proj.toLonLat(coord);
|
||
if (pointLat < -80 || pointLat > 84) return null;
|
||
try {
|
||
const ref = mgrs.forward([pointLon, pointLat], accuracy).replace(/\s+/g, '');
|
||
const match = ref.match(/^(\d{1,2}[C-HJ-NP-X])([A-HJ-NP-Z]{2})(\d*)$/);
|
||
if (!match) return null;
|
||
const digits = match[3];
|
||
const half = digits.length / 2;
|
||
return {prefix: `${match[1]} ${match[2]}`, easting: digits.slice(0, half), northing: digits.slice(half)};
|
||
} catch { return null; }
|
||
}
|
||
|
||
function gridDigits(value) {
|
||
if (!accuracy) return '';
|
||
const withinSquare = ((Math.round(value) % 100000) + 100000) % 100000;
|
||
return String(Math.floor(withinSquare / spacing)).padStart(accuracy, '0');
|
||
}
|
||
|
||
function addEdgeLabel(pixel, text, side) {
|
||
if (!text) return;
|
||
const coord = map.getCoordinateFromPixel(pixel);
|
||
if (!coord) return;
|
||
const feature = new ol.Feature(new ol.geom.Point(coord));
|
||
feature.setProperties({gridLabel: text, gridSide: side});
|
||
features.push(feature);
|
||
}
|
||
|
||
for (let x = minX; x <= maxX; x += spacing) {
|
||
const coords = [];
|
||
for (let y = minY; y <= maxY; y += step) coords.push(mapCoord(x, y));
|
||
features.push(new ol.Feature(new ol.geom.LineString(coords)));
|
||
const mid = map.getPixelFromCoordinate(mapCoord(x, (minY + maxY) / 2));
|
||
if (mid && mid[0] >= -2 && mid[0] <= width + 2) {
|
||
const top = gridRefAtPixel([mid[0], 8]);
|
||
const bottom = gridRefAtPixel([mid[0], height - 8]);
|
||
if (top) addEdgeLabel([mid[0], 8], accuracy ? `${top.prefix} E ${gridDigits(x)}` : top.prefix, 'top');
|
||
if (bottom) addEdgeLabel([mid[0], height - 8], accuracy ? `${bottom.prefix} E ${gridDigits(x)}` : bottom.prefix, 'bottom');
|
||
}
|
||
}
|
||
|
||
for (let y = minY; y <= maxY; y += spacing) {
|
||
const coords = [];
|
||
for (let x = minX; x <= maxX; x += step) coords.push(mapCoord(x, y));
|
||
features.push(new ol.Feature(new ol.geom.LineString(coords)));
|
||
const mid = map.getPixelFromCoordinate(mapCoord((minX + maxX) / 2, y));
|
||
if (mid && mid[1] >= -2 && mid[1] <= height + 2) {
|
||
const left = gridRefAtPixel([8, mid[1]]);
|
||
const right = gridRefAtPixel([width - 8, mid[1]]);
|
||
if (left) addEdgeLabel([8, mid[1]], accuracy ? `${left.prefix} N ${gridDigits(y)}` : left.prefix, 'left');
|
||
if (right) addEdgeLabel([width - 8, mid[1]], accuracy ? `${right.prefix} N ${gridDigits(y)}` : right.prefix, 'right');
|
||
}
|
||
}
|
||
|
||
source.addFeatures(features);
|
||
|
||
gridLayer = new ol.layer.Vector({
|
||
source,
|
||
renderMode: 'vector',
|
||
style: feature => {
|
||
const text = feature.get('gridLabel');
|
||
|
||
if (text !== undefined) {
|
||
const side = feature.get('gridSide');
|
||
return new ol.style.Style({
|
||
text: new ol.style.Text({
|
||
text,
|
||
font: 'bold 11px sans-serif',
|
||
fill: new ol.style.Fill({color: '#142b3a'}),
|
||
stroke: new ol.style.Stroke({color: '#fff', width: 4}),
|
||
backgroundFill: new ol.style.Fill({color: 'rgba(255,255,255,.97)'}),
|
||
padding: [3, 4, 3, 4],
|
||
textAlign: side === 'left' ? 'left' : side === 'right' ? 'right' : 'center',
|
||
textBaseline: side === 'top' ? 'top' : side === 'bottom' ? 'bottom' : 'middle',
|
||
offsetX: side === 'left' ? 3 : side === 'right' ? -3 : 0,
|
||
offsetY: side === 'top' ? 3 : side === 'bottom' ? -3 : 0,
|
||
overflow: true
|
||
}),
|
||
zIndex: 10002
|
||
});
|
||
}
|
||
|
||
return new ol.style.Style({
|
||
stroke: new ol.style.Stroke({color: '#46545c', width: 1.1, lineDash: [5, 4]})
|
||
});
|
||
},
|
||
zIndex: 1000
|
||
});
|
||
|
||
map.addLayer(gridLayer);
|
||
gridLayer.setZIndex(1000);
|
||
gridLayer.changed();
|
||
map.render();
|
||
} catch (error) {
|
||
console.error('Grid error:', error);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function updatePrintDimensions() {
|
||
const width = Number($('paperWidth').value);
|
||
const height = Number($('paperHeight').value);
|
||
if (![width, height].every(Number.isFinite) || width < 100 || height < 100 ||
|
||
width > 1000 || height > 1000) return false;
|
||
|
||
const footerHeight = 20;
|
||
const style = $('printDimensions') || document.createElement('style');
|
||
style.id = 'printDimensions';
|
||
style.textContent = `
|
||
@page { size: ${width}mm ${height}mm; margin: 0; }
|
||
:root { --footer-height: ${footerHeight}mm; }
|
||
body.printing { width: ${width}mm; height: ${height}mm; overflow: hidden; }
|
||
body.printing #map { position: absolute; inset: 0 auto auto 0; width: ${width}mm; height: ${height - footerHeight}mm !important; }
|
||
body.printing .panel, body.printing .sidebar-toggle, body.printing .ol-zoom, body.printing .ol-rotate, body.printing .ol-attribution { display: none !important; }
|
||
body.printing .print-page { display: flex; position: absolute; left: 0; bottom: 0; width: ${width}mm; height: ${footerHeight}mm; }
|
||
@media print {
|
||
html, body { width: ${width}mm; height: ${height}mm; }
|
||
#map { width: ${width}mm; height: ${height - footerHeight}mm !important; }
|
||
.print-page { width: ${width}mm; height: ${footerHeight}mm; }
|
||
}
|
||
`;
|
||
if (!style.isConnected) document.head.append(style);
|
||
return true;
|
||
}
|
||
|
||
function applyPaper() {
|
||
const custom = $('paper').value === 'custom';
|
||
$('paperWidth').disabled = !custom;
|
||
$('paperHeight').disabled = !custom;
|
||
if (!custom) {
|
||
const [width, height] = $('paper').value.split(',').map(Number);
|
||
$('paperWidth').value = width;
|
||
$('paperHeight').value = height;
|
||
}
|
||
updatePrintDimensions();
|
||
}
|
||
|
||
function printMap() {
|
||
if (printPending || !updatePrintDimensions()) return;
|
||
printPending = true;
|
||
const originalCenter = view.getCenter().slice();
|
||
document.body.classList.add('printing');
|
||
map.updateSize();
|
||
view.setCenter(originalCenter);
|
||
updatePrintInfo();
|
||
rebuildGrid();
|
||
|
||
setTimeout(() => {
|
||
map.updateSize();
|
||
view.setCenter(originalCenter);
|
||
updatePrintInfo();
|
||
map.renderSync();
|
||
|
||
let finished = false;
|
||
const finish = () => {
|
||
if (finished) return;
|
||
finished = true;
|
||
clearTimeout(fallback);
|
||
map.un('rendercomplete', finish);
|
||
window.print();
|
||
};
|
||
|
||
const fallback = setTimeout(finish, 1800);
|
||
map.on('rendercomplete', finish);
|
||
map.renderSync();
|
||
}, 350);
|
||
}
|
||
|
||
$('monochrome').addEventListener('change', event => {
|
||
monochrome = event.target.checked;
|
||
$('map').style.filter = monochrome ? 'grayscale(1)' : '';
|
||
refreshStyles();
|
||
rebuildGrid();
|
||
saveSettings();
|
||
});
|
||
$('contours').addEventListener('change', event => {
|
||
contoursEnabled = event.target.checked;
|
||
contourLayer?.setVisible(contoursEnabled);
|
||
refreshStyles();
|
||
saveSettings();
|
||
});
|
||
$('labels').addEventListener('change', event => {
|
||
labelsEnabled = event.target.checked;
|
||
refreshStyles();
|
||
saveSettings();
|
||
});
|
||
$('contourInterval').addEventListener('change', () => { refreshStyles(); saveSettings(); });
|
||
$('grid').addEventListener('change', event => {
|
||
gridEnabled = event.target.checked;
|
||
rebuildGrid();
|
||
saveSettings();
|
||
});
|
||
$('gridSpacing').addEventListener('change', () => { rebuildGrid(); saveSettings(); });
|
||
|
||
$('applyView').addEventListener('click', () => {
|
||
const lat = Number($('latitude').value);
|
||
const lon = Number($('longitude').value);
|
||
const zoom = Number($('zoom').value);
|
||
const rotation = Number($('rotation').value);
|
||
|
||
if (![lat, lon, zoom, rotation].every(Number.isFinite) ||
|
||
Math.abs(lat) > 90 || Math.abs(lon) > 180) return;
|
||
|
||
view.setCenter(ol.proj.fromLonLat([lon, lat]));
|
||
view.setZoom(Math.max(0, Math.min(22, zoom)));
|
||
view.setRotation(rotation * Math.PI / 180);
|
||
updatePrintInfo();
|
||
rebuildGrid();
|
||
saveSettings();
|
||
});
|
||
|
||
view.on('change:center', () => {
|
||
const [lon, lat] = centerLonLat();
|
||
$('latitude').value = lat.toFixed(5);
|
||
$('longitude').value = lon.toFixed(5);
|
||
updatePrintInfo();
|
||
});
|
||
view.on('change:resolution', () => {
|
||
$('zoom').value = view.getZoom()?.toFixed(1) ?? 10;
|
||
updatePrintInfo();
|
||
});
|
||
view.on('change:rotation', () => {
|
||
$('rotation').value = (view.getRotation() * 180 / Math.PI).toFixed(1);
|
||
updatePrintInfo();
|
||
});
|
||
|
||
$('declination').addEventListener('input', () => {
|
||
const value = Number($('declination').value);
|
||
if (!Number.isFinite(value)) return;
|
||
manualDeclination = value;
|
||
$('magStatus').textContent = '';
|
||
updatePrintInfo();
|
||
saveSettings();
|
||
});
|
||
$('resetDeclination').addEventListener('click', () => {
|
||
manualDeclination = null;
|
||
updatePrintInfo();
|
||
saveSettings();
|
||
});
|
||
$('magDate').addEventListener('change', () => {
|
||
manualDeclination = null;
|
||
updatePrintInfo();
|
||
saveSettings();
|
||
});
|
||
|
||
$('paper').addEventListener('change', () => { applyPaper(); saveSettings(); });
|
||
['paperWidth', 'paperHeight'].forEach(id => $(id).addEventListener('change', () => {
|
||
$('paper').value = 'custom';
|
||
applyPaper();
|
||
saveSettings();
|
||
}));
|
||
['title', 'notes'].forEach(id => $(id).addEventListener('input', () => { updatePrintInfo(); saveSettings(); }));
|
||
for (const id of SETTINGS_IDS) {
|
||
if (['mapSource', 'monochrome', 'contours', 'labels', 'grid', 'contourInterval', 'gridSpacing',
|
||
'declination', 'magDate', 'paper', 'paperWidth', 'paperHeight', 'title', 'notes'].includes(id)) continue;
|
||
$(id)?.addEventListener('change', saveSettings);
|
||
}
|
||
$('print').addEventListener('click', printMap);
|
||
|
||
window.addEventListener('afterprint', () => {
|
||
printPending = false;
|
||
document.body.classList.remove('printing');
|
||
map.updateSize();
|
||
map.renderSync();
|
||
rebuildGrid();
|
||
});
|
||
|
||
map.on('moveend', () => {
|
||
if (gridEnabled) rebuildGrid();
|
||
saveSettings();
|
||
});
|
||
map.on('change:size', () => {
|
||
if (gridEnabled) rebuildGrid();
|
||
});
|
||
|
||
const locationForm = $('locationSearchForm');
|
||
const locationInput = $('locationSearch');
|
||
const locationButton = $('locationSearchButton');
|
||
const locationStatus = $('locationStatus');
|
||
const locationResults = $('locationResults');
|
||
|
||
function updateLocationAvailability() {
|
||
const online = navigator.onLine;
|
||
locationInput.disabled = !online;
|
||
locationButton.disabled = !online;
|
||
locationStatus.classList.toggle('warning', !online);
|
||
locationStatus.textContent = online
|
||
? 'Searches OpenStreetMap places; internet access is required.'
|
||
: 'Location search is unavailable while offline. Enter coordinates below instead.';
|
||
}
|
||
window.addEventListener('online', updateLocationAvailability);
|
||
window.addEventListener('offline', updateLocationAvailability);
|
||
|
||
function selectLocation(result) {
|
||
const lat = Number(result.lat);
|
||
const lon = Number(result.lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||
const zoom = 14;
|
||
$('latitude').value = lat.toFixed(5);
|
||
$('longitude').value = lon.toFixed(5);
|
||
$('zoom').value = zoom.toFixed(1);
|
||
view.animate({center: ol.proj.fromLonLat([lon, lat]), zoom, duration: 450});
|
||
locationInput.value = result.display_name;
|
||
locationResults.hidden = true;
|
||
locationResults.replaceChildren();
|
||
locationStatus.classList.remove('warning');
|
||
locationStatus.textContent = '';
|
||
saveSettings();
|
||
}
|
||
locationForm.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
const query = locationInput.value.trim();
|
||
if (!query) {
|
||
locationStatus.textContent = 'Enter a place name or address to search.';
|
||
return;
|
||
}
|
||
if (!navigator.onLine) {
|
||
updateLocationAvailability();
|
||
return;
|
||
}
|
||
locationButton.disabled = true;
|
||
locationResults.hidden = true;
|
||
locationResults.replaceChildren();
|
||
locationStatus.classList.remove('warning');
|
||
locationStatus.textContent = 'Searching OpenStreetMap…';
|
||
try {
|
||
const url = new URL('https://nominatim.openstreetmap.org/search');
|
||
url.searchParams.set('format', 'jsonv2');
|
||
url.searchParams.set('q', query);
|
||
url.searchParams.set('limit', '5');
|
||
url.searchParams.set('addressdetails', '1');
|
||
const response = await fetch(url, {headers: {Accept: 'application/json'}});
|
||
if (!response.ok) throw new Error(response.status === 429
|
||
? 'Search rate limit reached. Wait a moment and try again.'
|
||
: `Search service returned HTTP ${response.status}.`);
|
||
const results = await response.json();
|
||
if (!results.length) {
|
||
locationStatus.textContent = 'No matches found. Try a more specific place or address.';
|
||
return;
|
||
}
|
||
locationStatus.textContent = `${results.length} result${results.length === 1 ? '' : 's'} found. Select one to center the map.`;
|
||
locationResults.replaceChildren(...results.map(result => {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.textContent = result.name || result.display_name.split(',')[0];
|
||
const detail = document.createElement('small');
|
||
detail.textContent = result.display_name;
|
||
button.append(detail);
|
||
button.addEventListener('click', () => selectLocation(result));
|
||
return button;
|
||
}));
|
||
locationResults.hidden = false;
|
||
} catch (error) {
|
||
locationStatus.classList.add('warning');
|
||
locationStatus.textContent = !navigator.onLine
|
||
? 'Location search is unavailable while offline. Enter coordinates below instead.'
|
||
: `Could not search locations: ${error.message}`;
|
||
} finally {
|
||
locationButton.disabled = !navigator.onLine;
|
||
}
|
||
});
|
||
updateLocationAvailability();
|
||
|
||
if (savedSettings.manualDeclination !== undefined && savedSettings.manualDeclination !== null &&
|
||
Number.isFinite(Number(savedSettings.manualDeclination))) {
|
||
manualDeclination = Number(savedSettings.manualDeclination);
|
||
$('declination').value = manualDeclination;
|
||
}
|
||
|
||
loadMapSource(initialSource);
|
||
applyPaper();
|
||
updatePrintInfo();
|
||
map.updateSize();
|
||
saveSettings();
|
||
|
||
if (navigator.geolocation) {
|
||
locationStatus.classList.remove('warning');
|
||
locationStatus.textContent = 'Trying to locate your device…';
|
||
navigator.geolocation.getCurrentPosition(position => {
|
||
const {latitude, longitude} = position.coords;
|
||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return;
|
||
$('latitude').value = latitude.toFixed(5);
|
||
$('longitude').value = longitude.toFixed(5);
|
||
view.setCenter(ol.proj.fromLonLat([longitude, latitude]));
|
||
updatePrintInfo();
|
||
rebuildGrid();
|
||
locationStatus.classList.remove('warning');
|
||
locationStatus.textContent = 'Map centered on your current location.';
|
||
saveSettings();
|
||
}, error => {
|
||
console.info('Current location unavailable:', error.message);
|
||
locationStatus.classList.toggle('warning', error.code === error.PERMISSION_DENIED);
|
||
locationStatus.textContent = error.code === error.PERMISSION_DENIED
|
||
? 'Location permission denied. Enable location access to center the map automatically.'
|
||
: 'Could not determine your location. Using the saved map view.';
|
||
}, {enableHighAccuracy: true, timeout: 10000, maximumAge: 300000});
|
||
} else {
|
||
locationStatus.textContent = 'Geolocation is not supported by this browser. Using the saved map view.';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|