agmission/server/public/sprayMapAdvanced.html

880 lines
41 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/leaflet/leaflet.css">
<link rel="stylesheet" href="/public/sprayMap.css">
<link rel="stylesheet" href="/public/map.css">
<script src="/public/js/utils.js" type="text/javascript"></script>
<script src="/leaflet/leaflet.js" type="text/javascript"></script>
<script src="/assets/js/leaflet-corridor.js" type="text/javascript"></script>
<script src="./spraydata.js"></script>
<style>
/* Advanced Report mission-overview badges (FR-2.3) — inlined on purpose:
/public/sprayMap.css may be served by a different deployment layer
(nginx) than this page's temp copy, so the variant must not depend on it */
.zone-badge {
/* border-box: border adds no extra width/height beyond iconSize, so the
circle can't drift off-square; flex centering (below) replaces the old
line-height trick for an exact center regardless of font metrics */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 2px solid rgba(20, 40, 20, .35);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, .45);
color: #1b3a1d;
font-family: Arial, sans-serif;
font-weight: bold;
text-align: center;
}
/* merged group: green circle showing the NUMBER OF ZONES at this spot */
.zone-badge.count {
background: rgba(46, 125, 50, .92);
border-color: rgba(255, 255, 255, .85);
color: #fff;
}
.zone-chip {
background: rgba(20, 24, 12, .75);
border: 0;
border-radius: 10px;
box-shadow: none;
color: #fff;
font: 12px/1.4 Arial, sans-serif;
padding: 2px 9px;
}
.zone-chip::before {
display: none;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
window.loaded = false;
// this page is dedicated to Advanced Report captures; default to the
// mission-overview variant when the caller doesn't specify one
req.variant = req.variant || 'mission';
params = req.params;
params.premium = req.premium;
// Client feedback (Matt Crabbe): show field coordinates in DMS on the report maps,
// same as the street-name request -- reuses the existing MapCenterCoord control
// (already wired below) rather than a bespoke corner-graticule.
params.coors = params.coors || 'DMS';
var map;
var missionPrefitted = false;
var missionFG = null; // the zones/lines feature group — needed again by focusZone
var missionOverlays = []; // badges/chips/leader lines, removed for zone-detail captures
var googleMapObject = null; // the raw google.maps.Map GoogleMutant wraps — set once below, on 'spawned'
var plainTileLayers = null; // Esri/OSM layer(s) whose own real 'load' event we listen to directly
// Adaptive replacement for a fixed post-fit settle delay (used below and by focusZone):
// watches the map's DOM for tile-paint activity and resolves once nothing has changed for
// `quietMs`, instead of always paying a fixed worst-case wait. Works for both basemaps —
// plain Leaflet tile layers (Esri/OSM) update their <img> src/class attributes in place,
// and the premium Google satellite basemap (Leaflet.GoogleMutant.js) clones tile <img>
// nodes directly into the map container (see its _mutantContainer/appendChild calls) —
// so a single DOM-mutation observer on the map container catches both, unlike a 'load'
// event listener (GoogleMutant only ever fires its own 'spawned' event once, at map
// creation, never again on a later refit). `maxMs` is a hard ceiling so a view that never
// quiets down can't hang the capture forever.
//
// `graceMs` is a SEPARATE, larger allowance for "nothing has mutated yet" specifically —
// confirmed on a real premium/Google-satellite job that its own internal API modules
// (common.js/util.js/map.js/...) take roughly a second to load BEFORE it issues its first
// tile request, during which the DOM is genuinely, misleadingly quiet. Without this, the
// short `quietMs` debounce (sized for the fast/common case once tiles ARE actively
// loading) fires during that startup gap and falsely declares the map settled before it
// has even started fetching imagery. The moment any real mutation arrives, the observer's
// own callback below takes over with the shorter `quietMs` debounce instead.
//
// Only mutations that actually touch an <img> element reset that clock — confirmed on a
// real job that Leaflet's own grid-layer scaffolding (creating ~100 empty placeholder
// <div class="leaflet-tile"> tiles, no image content yet) fires 50+ mutation batches, ALL
// within the first ~130ms, well before a single real tile request has even gone out. Left
// unfiltered, that scaffolding satisfies "a mutation happened" and hands control to the
// short quietMs debounce far too early, which then locks in "done" during the genuine gap
// before real image content starts arriving — bypassing graceMs entirely, since graceMs
// only guards against ZERO mutations, not irrelevant ones.
function waitForMapIdle(quietMs, graceMs, maxMs, cb) {
var done = false, settleTimer = null, maxTimer = null;
function finish() {
if (done) return;
done = true;
clearTimeout(settleTimer);
clearTimeout(maxTimer);
observer.disconnect();
cb();
}
function touchesImage(node) {
return node instanceof HTMLImageElement || (node.querySelector && !!node.querySelector('img'));
}
var observer = new MutationObserver(function (muts) {
var relevant = muts.some(function (m) {
if (m.type === 'attributes') return m.target instanceof HTMLImageElement;
for (var i = 0; i < m.addedNodes.length; i++) if (touchesImage(m.addedNodes[i])) return true;
return false;
});
if (!relevant) return;
clearTimeout(settleTimer);
settleTimer = setTimeout(finish, quietMs);
});
observer.observe(map.getContainer(), { childList: true, subtree: true, attributes: true });
settleTimer = setTimeout(finish, graceMs); // only fires if NOTHING image-related has mutated by then
maxTimer = setTimeout(finish, maxMs);
}
// When the premium Google satellite basemap is active, `googleMapObject` (captured once,
// below, from GoogleMutant's 'spawned' event) gives access to the real underlying
// google.maps.Map — which has its own authoritative 'tilesloaded' event, fired by Google
// itself whenever every tile visible in the current viewport has finished loading,
// including on every later pan/zoom (unlike 'spawned', which only ever fires once). That's
// a genuine "it's done" signal rather than waitForMapIdle's DOM-mutation inference, so
// prefer it when available; maxMs is still a backstop in case it never fires for some
// reason.
//
// For the plain Leaflet basemap (Esri/OSM), `plainTileLayers` (captured below) holds the
// actual layer object(s) — Leaflet's own GridLayer tracks each tile's success/failure
// internally and fires a real, repeatable 'load' event once the current batch is fully
// accounted for (not one-time like GoogleMutant's 'spawned' — it fires again after every
// pan/zoom/fitBounds, same as 'tilesloaded' does for Google). That's equally authoritative,
// so use it the same way instead of falling back to DOM-mutation inference. Esri needs
// BOTH its imagery layer and its street-overlay layer to report loaded (mirrors
// initMapBaseLayer's own `pending` counter in public/js/utils.js).
//
// waitForMapIdle only remains a fallback for a genuinely unexpected case — e.g. neither
// reference got captured for some reason.
function waitForBasemapReady(quietMs, graceMs, maxMs, cb) {
if (googleMapObject) {
var gDone = false, gBackstop = null;
function gFinish() {
if (gDone) return;
gDone = true;
clearTimeout(gBackstop);
cb();
}
google.maps.event.addListenerOnce(googleMapObject, 'tilesloaded', gFinish);
gBackstop = setTimeout(gFinish, maxMs);
return;
}
if (plainTileLayers && plainTileLayers.length) {
var lDone = false, lPending = plainTileLayers.length, lBackstop = null;
function onLayerLoad() { if (--lPending <= 0) lFinish(); }
function lFinish() {
if (lDone) return;
lDone = true;
clearTimeout(lBackstop);
plainTileLayers.forEach(function (l) { l.off('load', onLayerLoad); });
cb();
}
plainTileLayers.forEach(function (l) { l.on('load', onLayerLoad); });
lBackstop = setTimeout(lFinish, maxMs);
return;
}
waitForMapIdle(quietMs, graceMs, maxMs, cb);
}
initMapLib(params, loadLibs);
// Load additional libraries if needed then run the main scripts
function loadLibs() {
var libs = [];
if (params.coors) {
libs = libs.concat(['/assets/js/L.Control.MapCenterCoord.css', '/assets/js/L.Control.MapCenterCoord.js']);
if (params.coors == "UTM")
libs.push('/assets/js/utm.js');
}
loadScripts(libs, main);
}
function main() {
const mapDiv = document.getElementById("map");
if (mapDiv) {
mapDiv.style.width = params.width + 'px';
mapDiv.style.height = params.height + 'px';
}
// Advanced Report variants (req.variant 'mission') compute their own view via
// fitBounds later; give them a neutral start so legacy params stay required only for legacy calls
if (!params.center) params.center = { lat: 0, lng: 0 };
if (params.zoom == null) params.zoom = 3;
map = L.map('map', {
zoomSnap: 0.1, maxZoom: 19,
zoomControl: false,
zoomAnimation: false,
// Leaflet fades a tile's opacity in via CSS transition after it lands in the DOM —
// invisible to waitForMapIdle's MutationObserver (it's a compositor-level animation,
// not a further DOM mutation), so a capture could land mid-fade and look washed out.
// Disabling it makes tiles snap to full opacity the instant they're added, matching
// what the observer sees.
fadeAnimation: false,
attributionControl: false, attribution: ''
}).setView([params.center.lat, params.center.lng], params.zoom, { animate: false });
L.control.scale({ metric: !req.job.measureUnit, imperial: req.job.measureUnit, maxWidth: 150 }).addTo(map);
if (req.variant === 'mission' && req.job.sprayAreas && req.job.sprayAreas.length) {
// fit the final view BEFORE tiles start loading — fitting afterwards forces a
// tile reload after window.loaded already fired, which captures gray maps
var mb = null;
req.job.sprayAreas.forEach(function (a) {
a['type'] = 'Feature';
var bb = L.geoJSON(a).getBounds();
mb = mb ? mb.extend(bb) : bb;
});
if (mb && mb.isValid()) { map.fitBounds(mb, { padding: [100, 100] }); missionPrefitted = true; }
}
if (req.hideBg) {
// FR-7.4 Hide Map Background: plain dark-green base, no tile downloads
mapDiv.style.background = '#2b3220';
setTimeout(function () { window.loaded = true; }, 100);
} else {
// initMapBaseLayer (public/js/utils.js) has its own window.loaded=true assignment —
// reliable for a plain Leaflet tile layer (tied to the real 'load' event), but for the
// premium Google satellite basemap it fires on GoogleMutant's 'spawned' event instead,
// which fires once as soon as the underlying google.maps.Map object is merely
// constructed, NOT when its tiles have actually rendered (confirmed: fires within
// ~1s, while satellite tiles for a wide mission view can take 10+ real seconds to
// finish populating). Since window.loaded is a shared global, that early write always
// wins the race against waitForMapIdle below — meaning for premium/satellite jobs
// this whole adaptive mechanism was never actually the thing deciding when to
// screenshot. Gate window.loaded so only our own callback can ever set it true,
// absorbing that early write instead of racing it; handed back to a plain
// read/write property once our real signal fires so focusZone's later
// true/false toggles work normally.
var _loaded = false;
Object.defineProperty(window, 'loaded', {
get: function () { return _loaded; },
set: function (v) { if (!v) _loaded = false; } // ignore premature/incidental "true" writes from elsewhere
});
var _layers = initMapBaseLayer(map, params);
// maxMs is a last-resort backstop, not the primary signal — keep it generous: a cold
// first fetch over a wide mission-overview extent (more tiles, no warm browser cache
// yet) can legitimately take many seconds, confirmed directly against a real job.
// graceMs only matters for waitForMapIdle's own DOM-mutation fallback branch inside
// waitForBasemapReady (see its comment) — unused by the two authoritative branches.
var _startedWaiting = false;
function _startWaitingForLoad() {
if (_startedWaiting) return; // 'spawned' and the safety timeout below can both fire
_startedWaiting = true;
waitForBasemapReady(400, 3000, 20000, function () {
Object.defineProperty(window, 'loaded', { value: true, writable: true, configurable: true });
});
}
if (_layers && _layers.useGGMutant && _layers.baseLayer && _layers.baseLayer.once) {
// GoogleMutant fires 'spawned' exactly once, asynchronously, handing over the real
// google.maps.Map — googleMapObject must be captured BEFORE waitForBasemapReady
// first runs, or it'd see null on this very first call and silently miss the
// authoritative signal entirely (every later focusZone refit would still catch it,
// but this initial-load call — the one most worth fixing — would not). So wait for
// 'spawned' before starting the readiness check at all, with a safety timeout in
// case the Google API fails to load and 'spawned' never fires.
_layers.baseLayer.once('spawned', function (e) { googleMapObject = e.mapObject; _startWaitingForLoad(); });
setTimeout(_startWaitingForLoad, 8000);
} else {
// Plain Leaflet layer(s) (Esri/OSM) — no async handshake to wait for, their own real
// 'load' event (see waitForBasemapReady) can be listened for immediately.
if (_layers && _layers.baseLayer) {
plainTileLayers = _layers.streetsLayer ? [_layers.baseLayer, _layers.streetsLayer] : [_layers.baseLayer];
}
_startWaitingForLoad();
}
}
if (params.coors) {
var _iconEl = L.DomUtil.create('div', 'leaflet-control-mapcentercoord-icon leaflet-zoom-hide');
map.getPanes().overlayPane.appendChild(_iconEl);
L.control.mapCenterCoord({ position: 'bottomright', onMove: true, template: '{y}, {x}', latlngFormat: (params.coors || 'DMS') }).addTo(map);
}
var sprayAreaColor = 'blue';
var strokeWidth = 4;
var bufColor = 'orange';
var featureGroup = new L.FeatureGroup([]).addTo(map);
var showTitle = params.titleOn || false;
if (req.job.sprayAreas) {
var centroid, polygon, name, ttArea, areaTitle;
req.job.sprayAreas.forEach(function (area) {
area['type'] = 'Feature';
var layer = L.geoJSON(area, {
style: function (feature) {
return {
color: feature.properties['color'] || sprayAreaColor,
weight: strokeWidth
};
}
});
var zoneLayer = layer.getLayers()[0];
zoneLayer._isZoneBoundary = true;
featureGroup.addLayer(zoneLayer);
// mission variant labels zones via its own badge+chip (missionOverview) —
// skip the legacy on-polygon title so the name doesn't render twice
if (showTitle && req.variant !== 'mission') addAreaTitle(area, 0);
});
}
if (req.job.excludedAreas) {
req.job.excludedAreas.forEach(function (area) {
area['type'] = 'Feature';
var layer = L.geoJSON(area, {
style: function (feature) {
return {
color: 'red',
weight: strokeWidth
};
}
});
var exclLayer = layer.getLayers()[0];
exclLayer._isZoneBoundary = true;
featureGroup.addLayer(exclLayer);
});
}
if (req.job.waypoints)
addPlaces(req.job.waypoints, showTitle);
if (req.job.places)
addPlaces(req.job.places, showTitle);
var bufs = req.job.bufs;
if (bufs) {
var ops = {
fill: false,
color: this.bufColor,
opacity: 0.75,
lineCap: 'butt',
lineJoin: 'bevel',
};
var bufWidthMet;
bufs.forEach(function (buf) {
var latlng = buf.geometry.coordinates.map(function (coor) {
return [coor[1], coor[0]]; // LonLat => LatLon
});
bufWidthMet = toMeter((buf.properties["width"] || 10), req.job.measureUnit);
ops["corridor"] = toMeter((buf.properties["width"] || 10), req.job.measureUnit);
var line = L.corridor(latlng, ops);
featureGroup.addLayer(line);
});
}
if (req.data && req.data.length) {
for (var i = 0; i < req.data.length; i++) {
if (!req.data[i].fdata || !req.data[i].fdata.length) continue;
// report_util.js draw.flight entries carry their own zoneIdx ({zoneIdx, pts}), same
// as draw.spray — one polyline per segment (not one combined multi-segment polyline
// like before) so each segment can be tagged and independently hidden by focusZone
req.data[i].fdata.forEach(function (seg) {
if (!seg.pts || seg.pts.length < 2) return;
var layer = L.polyline(seg.pts,
{
lineCap: 'butt',
lineJoin: 'bevel',
color: req.colors.fpColor, weight: 2, opacity: .75,
bubblingMouseEvents: false,
smoothFactor: 2
});
layer._zoneIdx = seg.zoneIdx;
featureGroup.addLayer(layer);
});
}
var overlap = req.sprOp && req.sprOp.overlap ? req.sprOp.overlap / 100 : 1.12;
var ops = {
corridor: toMeter(req.job.swathWidth, req.job.measureUnit) * overlap,
usUnit: this.isUS,
lineCap: 'butt',
lineJoin: 'bevel',
opacity: 0.5,
color: '#ff00ff'
};
req.data.forEach(function (it) {
it.data.forEach(function (seg) {
// report_util.js draw.spray entries carry their own zoneIdx ({zoneIdx, pts});
// tag the layer so focusZone (below) can show only the focused zone's own spray
var pts = seg.pts, zoneIdx = seg.zoneIdx;
if (pts.length >= 2) {
var latLngs = pts.map(function (p) { return new L.LatLng(p[0], p[1]); });
var layer = new L.corridor(latLngs, ops);
layer._zoneIdx = zoneIdx;
featureGroup.addLayer(layer);
}
})
});
}
if (req.obs && req.obs.length) {
var ob, icon;
for (var i = 0; i < req.obs.length; i++) {
ob = req.obs[i];
icon = L.icon({
iconUrl: '/assets/images/tower_' + ob.icon,
iconSize: [20, 22],
iconAnchor: [10, 20]
});
L.marker([ob.lat, ob.lng], { icon: icon }).addTo(map);
};
}
// Advanced Report mission-overview variant (FR-2.3): fit all zones, then
// choose polygon mode or locator-badge mode by rendered pixel footprint
missionFG = featureGroup;
if (req.variant === 'mission') missionOverview(featureGroup);
}
// ==== Advanced Report mission overview (FR-2.3.1..3) ====================
function zoneCenter(area) {
var c = area.properties && area.properties.center;
if (c) return L.latLng(c[0], c[1]);
return L.geoJSON(area).getBounds().getCenter();
}
function zoneFootprintPx(area) {
var b = L.geoJSON(area).getBounds();
var p1 = map.latLngToContainerPoint(b.getNorthWest());
var p2 = map.latLngToContainerPoint(b.getSouthEast());
return Math.max(Math.abs(p2.x - p1.x), Math.abs(p2.y - p1.y));
}
// badge sizes are designed for a 1140px-wide capture; scale up for larger captures
function badgeScale() {
return Math.max(1, (params.width || 1140) / 1140);
}
function addZoneBadge(latlng, nums) {
var s = badgeScale();
var single = nums.length === 1;
var label = single ? String(nums[0]) : String(nums.length); // zone # | zone count
var d = Math.round((single ? 32 : 42) * s);
var bw = Math.max(2, Math.round(2 * s));
var m = L.marker(latlng, {
icon: L.divIcon({
className: 'zone-badge' + (single ? '' : ' count'),
html: label,
iconSize: [d, d],
iconAnchor: [d / 2, d / 2]
}),
interactive: false
}).addTo(map);
var el = m.getElement && m.getElement();
if (el) {
el.style.fontSize = Math.round((single ? 15 : 17) * s) + 'px';
el.style.borderWidth = bw + 'px'; // centering is handled by .zone-badge's flexbox now
}
missionOverlays.push(m);
return m;
}
function zoneName(area, num) {
return (area.properties && area.properties.name) || ('Zone ' + num);
}
// same formula as leaflet-draw's L.GeometryUtil.geodesicArea (what the
// map-edit page's layer.getArea() uses), so numbers match "Show info"
function ringAreaM2(ring) {
var d2r = Math.PI / 180, a = 0;
if (ring.length < 3) return 0;
for (var i = 0; i < ring.length; i++) {
var p1 = ring[i], p2 = ring[(i + 1) % ring.length]; // [lng, lat]
a += ((p2[0] - p1[0]) * d2r) * (2 + Math.sin(p1[1] * d2r) + Math.sin(p2[1] * d2r));
}
return Math.abs(a * 6378137.0 * 6378137.0 / 2.0);
}
function zoneAreaM2(area) {
if (area.properties && area.properties.area) return area.properties.area;
var g = area.geometry || {};
var polys = g.type === 'MultiPolygon' ? g.coordinates : (g.type === 'Polygon' ? [g.coordinates] : []);
var m2 = 0;
polys.forEach(function (rings) {
rings.forEach(function (ring, i) { m2 += (i === 0 ? 1 : -1) * ringAreaM2(ring); }); // outer minus holes
});
return m2 > 0 ? m2 : 0;
}
function zoneLabel(area, num) {
var name = (area.properties && area.properties.name) || ('Zone ' + num);
var ttArea = zoneAreaM2(area);
// Mission Overview badge/chip only — shortened "ac" instead of getUnit()'s "acre"
// (kept local to this label; shared getUnit() and other variants are untouched)
var unit = req.job.measureUnit ? 'ac' : getUnit(req.job.measureUnit);
return ttArea ? name + ' · ' + toArea(ttArea, req.job.measureUnit).toFixed(1) + ' ' + unit : name;
}
function addZoneChip(cx, cy, text) {
var sc = badgeScale();
var tt = new L.Tooltip({ className: 'zone-chip', direction: 'center', offset: [0, 0], permanent: true });
tt.setLatLng(map.containerPointToLatLng(L.point(cx, cy)));
tt.setContent(text);
map.addLayer(tt);
var el = tt.getElement && tt.getElement();
if (el) el.style.fontSize = Math.round(13 * sc) + 'px';
missionOverlays.push(tt);
}
function missionOverview(fg) {
var zones = req.job.sprayAreas || [];
var bounds = fg.getBounds();
if (!zones.length || !bounds.isValid()) return;
if (!missionPrefitted) map.fitBounds(bounds, { padding: [20, 20] });
// The top-level overview capture (taken before any focusZone call) shows plain zone
// polygons + number/name/area labels only, for every user/role -- spray corridors and
// flight-path segments are already shown per-zone on the Mission Coverage thumbnails
// and Zone Detail pages, so they're hidden here to avoid a redundant, cluttered
// overview. focusZone()'s own applyZoneFocusStyle call restores the right one's
// visibility for each later zone-specific capture (zone-agnostic corridors like water
// buffers carry no _zoneIdx and are left untouched).
fg.eachLayer(function (l) {
if (l._zoneIdx !== undefined && l.setStyle) l.setStyle({ opacity: 0, fillOpacity: 0 });
});
// FR-2.3.3 switching criterion: any zone below the legibility threshold => locator mode
// threshold is defined in 1140px-canvas pixels; scale with capture size so
// the polygon/locator decision is identical at any capture resolution
var thresholdPx = ((params && params.locatorThresholdPx) || 25) * badgeScale();
var dispersed = false, px;
for (var i = 0; i < zones.length; i++) {
px = zoneFootprintPx(zones[i]);
console.log('[mission] zone ' + (i + 1) + ' (' + ((zones[i].properties && zones[i].properties.name) || '') + '): ' + Math.round(px) + 'px');
if (px < thresholdPx) dispersed = true;
}
console.log('[mission] mode: ' + (dispersed ? 'locator badges (FR-2.3.2)' : 'polygons (FR-2.3.1)'));
if (!dispersed) {
// polygon mode: numbered badge + name/acreage chip per zone (FR-2.3, zone names per PO)
// — the numbered badge is skipped for a single-zone mission (nothing to number when
// there's only one), but the name/acreage chip still shows — it's real identifying
// info, not a sequence number, and stays useful even alone
zones.forEach(function (a, i) {
var c = zoneCenter(a), pt = map.latLngToContainerPoint(c), sc2 = badgeScale();
if (zones.length > 1) addZoneBadge(c, [i + 1]);
addZoneChip(pt.x, pt.y + 29 * sc2, zoneLabel(a, i + 1));
});
publishMissionInfo(zones, false);
return;
}
// locator mode: drop polygons, draw numbered badges; merge any within mergePx
map.removeLayer(fg);
var mergePx = (params && params.badgeMergePx) || 30;
var groups = [];
zones.forEach(function (a, i) {
var ll = zoneCenter(a);
var pt = map.latLngToContainerPoint(ll);
var g = null;
for (var k = 0; k < groups.length; k++) {
var d = Math.sqrt(Math.pow(groups[k].pt.x - pt.x, 2) + Math.pow(groups[k].pt.y - pt.y, 2));
if (d < mergePx) { g = groups[k]; break; }
}
if (g) g.nums.push(i + 1);
else groups.push({ pt: pt, ll: ll, nums: [i + 1] });
});
// draw badges first, then place each name chip in the first free spot
// around its badge (below, above, right, left) — cartographic candidates
var sc = badgeScale(), placedChips = [];
groups.forEach(function (g) { addZoneBadge(g.ll, g.nums); });
var hits = function (r) {
if (r.x1 < 4 || r.y1 < 4 || r.x2 > params.width - 4 || r.y2 > params.height - 4) return true; // off the map
return groups.some(function (o) {
var rad = (o.nums.length === 1 ? 16 : 21) * sc;
return o.pt.x > r.x1 - rad && o.pt.x < r.x2 + rad && o.pt.y > r.y1 - rad && o.pt.y < r.y2 + rad;
}) || placedChips.some(function (pc) {
return r.x1 < pc.x2 && pc.x1 < r.x2 && r.y1 < pc.y2 && pc.y1 < r.y2;
});
};
// try the full "name · acreage" in 8 spots around the badge; if nothing
// fits, retry with the name alone (shorter chip fits tighter gaps)
var tryPlace = function (g, label) {
var w = label.length * 6.8 * sc + 18 * sc, h = 20 * sc;
var o = 18 * sc + h / 2, os = 26 * sc + w / 2, od = 24 * sc;
var spots = [
[g.pt.x, g.pt.y + o], [g.pt.x, g.pt.y - o], // below, above
[g.pt.x + os, g.pt.y], [g.pt.x - os, g.pt.y], // right, left
[g.pt.x + od + w / 2, g.pt.y + od + h / 2], // corners
[g.pt.x - od - w / 2, g.pt.y + od + h / 2],
[g.pt.x + od + w / 2, g.pt.y - od - h / 2],
[g.pt.x - od - w / 2, g.pt.y - od - h / 2],
];
for (var c = 0; c < spots.length; c++) {
var r = { x1: spots[c][0] - w / 2, x2: spots[c][0] + w / 2, y1: spots[c][1] - h / 2, y2: spots[c][1] + h / 2 };
if (!hits(r)) {
if (c >= 2) // side/corner placements get a leader line back to their badge
missionOverlays.push(L.polyline([map.containerPointToLatLng(L.point(g.pt.x, g.pt.y)),
map.containerPointToLatLng(L.point(spots[c][0], spots[c][1]))],
{ color: '#ffffff', weight: 1.5, opacity: 0.75, interactive: false }).addTo(map));
addZoneChip(spots[c][0], spots[c][1], label);
placedChips.push(r);
return true;
}
}
return false;
};
groups.forEach(function (g) {
if (g.nums.length !== 1) return; // merged groups: count only
var z = zones[g.nums[0] - 1];
if (!tryPlace(g, zoneLabel(z, g.nums[0]))) // full: name · acreage
tryPlace(g, zoneName(z, g.nums[0])); // fallback: name only
});
console.log('[mission] badges: ' + groups.length + ' (from ' + zones.length + ' zones)');
publishMissionInfo(zones, true);
}
// Capture metadata for the report generator (controllers/advanced_report.js). Mission
// Coverage thumbnails now always reuse zone_N.jpg (focusZone's own independent per-zone
// capture) instead of cropping a rect out of this shared mission-wide view — a crop's
// native resolution and framing were both at the mercy of whatever zoom the mission
// view had to use to fit every zone at once, which is exactly what made thumbnails
// inconsistent between jobs (Job #105's tiny zones, Job #108's widely-separated ones).
// `dispersed` still matters here (badges vs. polygon shapes on the mission map itself),
// it just no longer needs to carry per-zone crop rects alongside it.
function publishMissionInfo(zones, dispersed) {
window.missionInfo = { dispersed: dispersed };
}
// Strip the mission badges/name-acreage chips/leader lines without moving the
// camera — used both by focusZone (below) and standalone by the generator so
// single-viewport thumbnail crops (plan D3.4) don't drag the overlays along.
window.hideMissionOverlays = function () {
missionOverlays.forEach(function (l) { map.removeLayer(l); });
missionOverlays = [];
};
// Styles every mission-layer for zone `idx`'s focus: this zone's own spray corridors
// and flight-path segments shown at their normal opacity, every OTHER zone's spray/
// flight-path hidden entirely, and every OTHER zone/excluded-area boundary faded down
// (same treatment focusZone always gave boundaries, now shared so thumbnails get it too
// — see below). Used by both focusZone (full Zone Detail capture, which also refits the
// viewport below) and the thumbnail pre-capture step (Mission Coverage cards, plan
// D3.4+), which crops a rect out of the shared mission viewport without refitting it.
// Without this, a thumbnail crop of the single mission view shows whatever neighboring-
// zone spray/flight-path/boundaries happen to fall inside that zone's crop rectangle —
// confusing in the same way an un-focused Zone Detail map was. Flight-path segments in
// particular NEED explicit zoneIdx-based hiding rather than the generic bounds-based
// fade below: a flight segment's own bounding box almost always spans (or contains) any
// single zone's bounds, so the bounds-overlap check used for boundaries is never false
// for it — it would otherwise always show at full opacity regardless of focus (this is
// what made Job #108's small "421" zone thumbnail look dominated by ferry-track scribble
// once the crop-widening fixes started pulling in more of the surrounding area). Buffer
// corridors (water buffers etc.) have no _zoneIdx — they're zone-agnostic, always shown.
// Every layer's style is set explicitly on every call (not just the ones changing) since
// captures move through zones/thumbnails on the same page/layers — a layer hidden/faded
// for zone 1 must be restored when zone 2's own thumbnail or detail capture comes around.
window.applyZoneFocusStyle = function (idx) {
var zones = req.job.sprayAreas || [];
if (!zones[idx] || !missionFG) return;
var target = L.geoJSON(zones[idx]).getBounds();
missionFG.eachLayer(function (l) {
if (!l.setStyle || !l.getBounds) return;
var isCorridor = l.options && l.options.corridor !== undefined;
if (isCorridor) {
var belongsHere = l._zoneIdx === undefined || l._zoneIdx === idx;
l.setStyle(belongsHere ? { opacity: 0.5, fillOpacity: 0.2 } : { opacity: 0, fillOpacity: 0 });
return;
}
if (l._zoneIdx !== undefined) {
// flight-path segment (only corridors and flight segments carry _zoneIdx)
l.setStyle({ opacity: l._zoneIdx === idx ? 0.75 : 0 });
return;
}
var inTarget = target.contains(l.getBounds()) || l.getBounds().contains(target);
l.setStyle({ opacity: inTarget ? 1 : 0.25, fillOpacity: inTarget ? 0.2 : 0.05 });
});
};
// Zone Detail capture (plan D3.3): refit to one zone, fade the neighbours,
// drop the mission badges/labels. The generator calls this between screenshots
// and waits for window.loaded to flip back to true (tiles reload on refit).
window.focusZone = function (idx) {
var zones = req.job.sprayAreas || [];
if (!zones[idx] || !missionFG) return;
window.loaded = false;
window.hideMissionOverlays();
if (!map.hasLayer(missionFG)) map.addLayer(missionFG); // locator mode removed the polygons
window.applyZoneFocusStyle(idx);
var target = L.geoJSON(zones[idx]).getBounds();
// wider padding than a plain boundary fit needs, deliberately -- the turn/ferry
// sections of the flight path loop out beyond the zone's own polygon edge, so a tight
// fit crops most of that detail out of the frame.
map.fitBounds(target, { padding: [200, 200] });
if (req.hideBg) {
// no tile layer exists in this mode, so there's nothing to observe settling — a flat window is correct here
setTimeout(function () { window.loaded = true; }, 150);
} else {
// was a flat 1500ms wait on every refit regardless of whether its tiles were already
// cached (NFR-1.1) — now resolves as soon as the map actually stops painting (or, for
// the premium Google basemap, as soon as its own authoritative 'tilesloaded' event
// fires — see waitForBasemapReady). maxMs is a last-resort backstop, not the primary
// signal — a zone's FIRST refit is a genuinely cold tile fetch (a later refit of the
// same zone, e.g. its own thumbnail capture right after, benefits from an already-warm
// cache and resolves fast), so keep this generous too.
waitForBasemapReady(250, 800, 10000, function () { window.loaded = true; });
}
};
// Mission Coverage's per-zone thumbnail cards embed this same capture at a much
// smaller physical size (~57x36mm) than the Zone Detail page does (~190x135mm) —
// a fixed screen-pixel stroke weight prints far thinner once squeezed into that
// smaller box, even though the source JPEG is byte-identical. The generator calls
// this to bump the boundary weight up only for the thumbnail-dedicated capture, so
// the printed line thickness matches the Mission Overview/Zone Detail pages despite
// the smaller embed size.
window.setZoneStrokeWeight = function (w) {
if (!missionFG) return;
missionFG.eachLayer(function (l) {
if (l._isZoneBoundary && l.setStyle) l.setStyle({ weight: w });
});
};
// Regenerates the Mission Coverage thumbnail from the Zone Detail frame just captured,
// instead of a second live focusZone()+tile-settle cycle for an identical camera position
// (the two only ever differed in boundary stroke weight — see setZoneStrokeWeight above).
// `base64Jpeg` is that already-captured screenshot, handed in as a data URI (safe from
// cross-origin canvas-taint even though the underlying map tiles came from a different
// origin, since data: URIs carry no origin of their own) — drawn as a flat base image,
// then each zone/exclusion boundary is stroked again on top at the heavier `strokeWeight`,
// at the exact pixel position Leaflet itself would use (map.latLngToContainerPoint).
// The heavier stroke fully covers the thinner one beneath it since both share the same
// centerline. The generator (captureMaps) calls this, screenshots the result directly,
// then calls clearZoneThumbOverlay — no map interaction happens here at all, so there's
// nothing to wait for.
window.renderZoneThumbOverlay = function (base64Jpeg, strokeWeight) {
window.clearZoneThumbOverlay();
var mapEl = document.getElementById('map');
var w = params.width, h = params.height;
var overlay = document.createElement('div');
overlay.id = '_thumbOverlay';
overlay.style.cssText = 'position:absolute;top:0;left:0;width:' + w + 'px;height:' + h + 'px;z-index:99999;';
var img = document.createElement('img');
img.style.cssText = 'position:absolute;top:0;left:0;width:' + w + 'px;height:' + h + 'px;';
img.src = 'data:image/jpeg;base64,' + base64Jpeg;
var svgNS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', w);
svg.setAttribute('height', h);
svg.style.cssText = 'position:absolute;top:0;left:0;';
// Polygon.getLatLngs() nests arbitrarily deep depending on ring/hole/multi-polygon shape —
// descend until we hit an actual array of LatLngs (first element has a numeric .lat).
function eachRing(latlngs, cb) {
if (!latlngs || !latlngs.length) return;
if (typeof latlngs[0].lat === 'number') { cb(latlngs); return; }
latlngs.forEach(function (item) { eachRing(item, cb); });
}
if (missionFG) {
missionFG.eachLayer(function (l) {
if (!l._isZoneBoundary || !l.getLatLngs) return;
var opts = l.options || {};
var opacity = (opts.opacity != null) ? opts.opacity : 1;
var color = opts.color || '#3388ff';
eachRing(l.getLatLngs(), function (ring) {
var pts = ring.map(function (latlng) {
var p = map.latLngToContainerPoint(latlng);
return p.x + ',' + p.y;
}).join(' ');
var poly = document.createElementNS(svgNS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', 'none');
poly.setAttribute('stroke', color);
poly.setAttribute('stroke-width', strokeWeight);
poly.setAttribute('stroke-opacity', opacity);
svg.appendChild(poly);
});
});
}
overlay.appendChild(img);
overlay.appendChild(svg);
mapEl.appendChild(overlay);
return new Promise(function (resolve, reject) {
img.onload = resolve;
img.onerror = reject;
});
};
window.clearZoneThumbOverlay = function () {
var el = document.getElementById('_thumbOverlay');
if (el && el.parentNode) el.parentNode.removeChild(el);
};
function addPlaces(geoPoints, showTitle) {
var geoCoor, name;
geoPoints.forEach(function (it) {
it['type'] = 'Feature';
if (showTitle) {
geoCoor = it.geometry.coordinates;
name = it.properties.name || '';
addTooltipLayer(name, [geoCoor[1], geoCoor[0]])
}
});
var pointOps = {
radius: 4,
fillColor: "#ff7800",
color: "#000",
weight: 1,
opacity: 1,
fillOpacity: 0.8
};
L.geoJSON(geoPoints, {
pointToLayer: function (feature, latlng) {
return L.circleMarker(latlng, pointOps);
}
}).addTo(map);
}
function addTooltipLayer(name, coor, ops) {
var ttOps = ops || {
className: "report-place",
direction: "right"
};
var popup1 = new L.Tooltip(ttOps);
popup1.setLatLng(coor);
popup1.setContent(name);
map.addLayer(popup1);
}
function addAreaTitle(geoPoly, type, ops) {
var props = geoPoly.properties,
name = props.name || '',
ttArea = props.area || 0,
title;
var center = props.center;
if (type === 0 && ttArea) {
title = '<div>${name}<span class="title-center">${ttArea} ${unit}</span></div>';
title = title.replace('${ttArea}', toArea(ttArea, req.job.measureUnit).toFixed(2)).replace('${unit}', getUnit(req.job.measureUnit));
} else
title = '<div>${name}</div>';
title = title.replace('${name}', name);
var ttOps = ops || {
className: "report-place",
direction: "top"
};
addTooltipLayer(title, center, ttOps);
}
</script>
</body>
</html>