Files
mrs/client/public/map-maker.html
T

1278 lines
51 KiB
HTML
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">
<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>