880 lines
41 KiB
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>
|