'use strict'; module.exports = function (app) { const debug = require('debug')('agm:export'), ObjectId = require('mongodb').ObjectId, moment = require('moment'), Job = require('../model/job'), JobAssign = require('../model/job_assign'), jobUtil = require('../helpers/job_util'), Obstacle = require('../model/obstacles'), async = require('async'), path = require('path'), fs = require('fs-extra'), shpWrite = require('shp-write'), util = require('util'), archiver = require('archiver'), xml2js = require('xml2js'), uniqid = require('uniqid'), turf = require('@turf/turf'), fileHelper = require('../helpers/file_helper'), urlHelper = require('../helpers/url_helper'), utils = require('../helpers/utils'), bufUtil = require('../helpers/line_buffer'), polyUtil = require('../helpers/poly_util'), geoUtil = require('../helpers/geo_util'), webUtil = require('../helpers/web_util'), { Errors, UserTypes, AssignStatus, RateUnits } = require('../helpers/constants'), { AppParamError, AppError } = require('../helpers/app_error'), lineUtil = require('../helpers/gridline_util'), { version } = require('../package.json'), env = require('../helpers/env'), cloneDeep = require('clone-deep'), _ = require('lodash'); require('string-format-js'); const parserXMLAsync = util.promisify(xml2js.Parser().parseString); const writeWorldFileAync = util.promisify(writeWorldFile); const makeDownloadItemsAsync = util.promisify(makeDownloadItems); const sendArchiveAsync = util.promisify(sendArchive); const removeTempFilesAsync = util.promisify(removeTempFiles); // Line identification number for NO1 file const NO1_TITLE = 0; // title const NO1_CENTRAL_MERIDIAN = 1; // central meridian const NO1_CORNER = 2; // area corner points const NO1_WAYPOINT = 3; // way points line const NO1_NUMBER_OF_LINES = 4; // number of lines const NO1_MAX_CROSS_TRACK = 8; // max cross track const NO1_DELTA_X_Y_Z = 9; // delta x, y, z (LL to UTM conversion) const NO1_K0_XY_SHIFT = 11; // K0, x/y shift (LL to UTM conversion) // const NO1_1ST_LINE = 16; // 1st line number const NO1_MASTER_POINT_HEADING = 17; // master point, heading const NO1_ELLIPSOID = 20; // ellipsoid const NO1_EQUATORIAL_CROSSING = 21; // equatorial crossing // const NO1_DIR_RAMDISK_SPRAYSIDE = 35; // project directory, ramdisk, and spray side const NO1_SWATHWIDTH_AC_INSIDE_AREA = 36; // swath width, number of A/C, inside area const NO1_HALF_SWATH_OFFFSET_1ST_TIME = 37; // half swath offset on 1st line(1=YES, 0=NO) const NO1_DISPLAY_UNIT = 38; // display units (1=US, 2=metric) // const NO1_RACE_SKIP = 39; // race and skip track const NO1_MAGNETIC_VARIATION = 40; // magnetic variation (degrees) const NO1_SYSETEM_LAG = 41; // system lag (seconds) const NO1_RELAY_ON_OFF = 42; // Relay ON/OFF (seconds), turn Relay ON/OFF early const NO1_PATTERN = 43; // flight pattern const NO1_EXPAND_LEFT_RIGHT = 44; // for expand spray pattern, indicates expand direction (1:left, 0: right) const PRJ_ZONE_NAME = 62; // Zone name const AGN_APPLICATION_RATE = 63; // Application rate for flow controller const NO1_SWATHWIDTH_SEGMENTS = 202; // 4 swath widths for each segment const NO1_AREATYPE = 203; // Area Type 1: ABLINE, 65536: SPLIT BDY const MAX_MAP_DIMEN = 4 * 1024; // Maximum map dimention (in pixels) async function anyJob_post(req, res) { const userId = req.uid; if (!utils.isObjectId(userId)) return res.json({ total: 0 }); const data = await JobAssign.countDocuments({ user: userId, status: 0 }); res.json({ total: data || 0 }); } async function newJobs_post(req, res) { const userId = req.uid; if (!utils.isObjectId(userId)) return res.json([]); const data = await JobAssign.find({ user: ObjectId(userId), status: 0 }) .populate('job', 'name startDate endDate') .select('-_id date') .lean(); res.json(data ? data : []); } async function downloadJob_post(req, res) { if (!req.body.jobId) AppParamError.throw(); // Get job's download options to go const job = await Job.findById(req.body.jobId, 'dlOp', { lean: true }); if (!job) AppError.throw(Errors.JOB_NOT_FOUND); if (job.dlOp && job.dlOp.mapOp) { const newReq = cloneDeep(req); newReq.body['mapOps'] = job.dlOp.mapOp; await downloadJobwMap(newReq, res); } else { await downloadJob(req, res); } } async function downloadJob(req, res) { const params = req.body; await jobUtil.isJobAssignedToVehicle(req.uid, req.ut, params.jobId); const items = await makeDownloadItemsAsync(req); await sendArchiveAsync(items, res); await writeJobLog(params.jobId, 2, req.uid, req.ut); } function sendArchive(items, res, cb) { // Add items to archive then pipe to response, end response when done const archive = archiver('zip', { zlib: { level: 9 } }); archive.on('end', () => { res.end(); cb && cb(); }); archive.on('error', function (err) { debug(err.stack); cb && cb(err); }); archive.pipe(res); if (!utils.isEmptyArray(items)) { for (const item of items) { if (item.content) archive.append(item.content, item.meta); if (item.file) archive.file(item.file, item.meta); } } archive.finalize(); } function makeDownloadItems(req, cb) { const params = req.body; // params = { jobId: 15, type: 1 }; // 1:'AGNAV', 2: AgNav Prj, 3:'SHP' (,4:'KML', 5:'KMZ' not yet) // debug('Request params: ', util.inspect(job, false, null)); let dlType = params.type || 1; // Default is AGNAV type let outFileName = '', sprayAreas = [], xclAreas = [], xclItems = [], _job, xyzFile, xclContent, archiveItems = [], numOfLines = -1; async.series([ function (callback) { Job.findById(params.jobId) .populate({ path: 'client', model: UserTypes.CLIENT, select: { '_id': 0, 'name': 1 } }) .populate({ path: 'operator', model: UserTypes.PILOT, select: { '_id': 0, 'name': 1 } }) .populate({ path: 'vehicle', model: UserTypes.DEVICE, select: { '_id': 0, 'name': 1 } }) .populate('products.product', 'name') .then(job => { if (!job) { callback(AppError.create(Errors.JOB_NOT_FOUND)); return; } else { return Job.populate(job, { path: 'crop', select: 'name', skipInvalidIds: true }); } }) .then(job => { _job = job.toObject(); // Override with selected download option if (req.ut === UserTypes.DEVICE) { dlType = _job.dlOp.type || 1; } outFileName = _job.name; callback(); }) .catch(err => { callback(err); }); }, function (callback) { const pipeline = [ { "$match": { _id: params.jobId } }, { $unwind: "$sprayAreas" }, { $lookup: { from: "area_lines", localField: "sprayAreas._id", foreignField: "areaId", as: "area_lines" } }, { $unwind: { "path": "$area_lines", "preserveNullAndEmptyArrays": true } }, { $project: { "_id": "$_id", "sprayAreas": { "_id": "$sprayAreas._id", "geometry": "$sprayAreas.geometry", "properties": "$sprayAreas.properties", 'masterPoint': '$area_lines.masterPoint', "latlngHeading": '$area_lines.latlngHeading', "heading": '$area_lines.heading', "lines": { $cond: { if: '$area_lines.lines', then: "$area_lines.lines", else: [] } }, "mems": { $cond: { if: '$area_lines.mems', then: "$area_lines.mems", else: [] } }, } } }, { "$group": { "_id": "$_id", "sprayAreas": { $push: "$sprayAreas" } } } ]; Job.aggregate(pipeline, (err, result) => { if (err) return callback(err); if (result && result.length) _job.sprayAreas = result[0].sprayAreas; // Replace the sprayAreas with the ones combines with grid lines related info callback(); }); }, function (callback) { sprayAreas = _job.sprayAreas; if (utils.isEmptyArray(sprayAreas)) return callback(AppError.create(Errors.NO_SPRAY_AREA)); // Use the reusable helper function to process buffers to XCL areas xclAreas = jobUtil.processBuffersToXclAreas(_job); // Make sure spray areas always in clockwise order for (const area of sprayAreas) { let coors = area.geometry.coordinates[0]; if (!polyUtil.isClockwise(coors)) area.geometry.coordinates[0] = coors.reverse(); } callback(); }, function (callback) { if (dlType > 1) { // Generate xyz file for all combined areas options: PRJ or SHAPE numOfLines = 0; const rootArea = findRootArea(sprayAreas, true); if (!rootArea) return callback(); // Heading to generate is in UTM, write to file in LL const heading = utils.isNumber(rootArea.heading) ? rootArea.heading : 0.0; lineUtil.getLinesLatLng(_job, null, true, 0, 0, heading, null, { useGroup: false, regenerate: true }) .then(linesRes => { if (!utils.isEmptyArray(linesRes.lines) && !utils.isEmptyArray(linesRes.lines[0].lines)) { numOfLines = linesRes.lines[0].lines.length; const xyzContent = linesToXYZ(linesRes.lines[0].lines); if (xyzContent) archiveItems.push({ content: xyzContent, meta: { name: outFileName + '.xyz' } }); } callback(); }) .catch(err => callback(err)); } else callback(); }, function (callback) { if (dlType === 1) { // AgNav if (sprayAreas.length === 1) { if (sprayAreas[0].properties && sprayAreas[0].properties.name) outFileName = sprayAreas[0].properties.name; const no1Content = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 1); archiveItems.push({ content: no1Content, meta: { name: outFileName + '.no1' } }); xclContent = writeDSPorXCL(xclAreas); if (xclContent) archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } }); xyzFile = writeAreasXYZ(sprayAreas, outFileName + '.xyz'); if (!utils.isEmptyArray(xyzFile)) archiveItems = archiveItems.concat(xyzFile); } else { // Export No1 files for each spray zone, xcl not releated to any spray zone will be added to the first no1's xcl // split spray area into seperated items a long with its xcls, then write these as no1+dsp+xcl files const processXclAreas = xclAreas.slice(0); const entries = []; let sprArea, xclArea; for (let i = 0; i < sprayAreas.length; i++) { const xcls = []; sprArea = sprayAreas[i]; let idx = processXclAreas.length - 1; while (processXclAreas.length && idx >= 0) { xclArea = processXclAreas[idx]; if (polyUtil.polygonWithin(xclArea.geometry.coordinates, sprArea.geometry.coordinates) || polyUtil.polygonIntersect(sprArea.geometry.coordinates, xclArea.geometry.coordinates)) { xcls.push(xclArea); processXclAreas.splice(idx, 1); } idx--; } entries.push({ area: sprArea, xcls: xcls }); } // The rest of the unrelated xcls (if any) assign to the first spray zone if (entries.length && processXclAreas && processXclAreas.length) { if (entries[0].xcls) entries[0].xcls = entries[0].xcls.concat(processXclAreas); else entries[0].xcls = processXclAreas; } // Now, create no1 file archive entries const entriesByName = _.groupBy(entries, it => (it.area.properties && it.area.properties.name) ? it.area.properties.name.toLowerCase() : ''); let nameIdx = 1; for (let entryName in entriesByName) { xclItems.length = 0; const groupEntry = entriesByName[entryName]; outFileName = groupEntry[0].area.properties.name; if (!outFileName) outFileName = `Spray_${nameIdx++}`; if (groupEntry.length === 1) { // No1 const no1Content = writeAGNorN01orPRJ(_job, outFileName, [groupEntry[0].area], numOfLines, 1); if (no1Content) { archiveItems.push({ content: no1Content, meta: { name: outFileName + '.no1' } }); if (!utils.isEmptyArray(groupEntry[0].xcls)) xclItems = xclItems.concat(groupEntry[0].xcls); xyzFile = writeAreasXYZ([groupEntry[0].area], outFileName + '.xyz'); if (!utils.isEmptyArray(xyzFile)) archiveItems = archiveItems.concat(xyzFile); } } else { // Prj let sprayItems = []; xclItems = []; for (const it of groupEntry) { sprayItems.push(it.area); if (!utils.isEmptyArray(it.xcls)) xclItems = xclItems.concat(it.xcls); } const prjContent = writeAGNorN01orPRJ(_job, outFileName, sprayItems, numOfLines, 2, true); const dspContent = writeDSPorXCL(sprayItems); const vfrContent = writeVFR(sprayItems, _job.appRate); archiveItems = archiveItems.concat([ { content: prjContent, meta: { name: outFileName + '.prj' } }, { content: dspContent, meta: { name: outFileName + '.dsp' } }, { content: vfrContent, meta: { name: outFileName + '.vfr' } }]); xyzFile = writeAreasXYZ(sprayItems, outFileName + '.xyz'); if (!utils.isEmptyArray(xyzFile)) archiveItems = archiveItems.concat(xyzFile); } const xclContent = writeDSPorXCL(xclItems); if (xclContent) archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } }); } } callback(); } else if (dlType === 2) { // AgNav Prj const prjContent = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 2, true, true); const dspContent = writeDSPorXCL(sprayAreas); const vfrContent = writeVFR(sprayAreas, _job.appRate); archiveItems = archiveItems.concat([ { content: prjContent, meta: { name: outFileName + '.prj' } }, { content: dspContent, meta: { name: outFileName + '.dsp' } }, { content: vfrContent, meta: { name: outFileName + '.vfr' } }]); // XCL for the case of not combined, No1 or Prj xclContent = writeDSPorXCL(xclAreas); if (xclContent) archiveItems.push({ content: xclContent, meta: { name: outFileName + '.xcl' } }); callback(); } else if (dlType === 3) { const items = []; items.push(sprayAreas); if (xclAreas.length > 0) items.push(xclAreas); const agnContent = writeAGNorN01orPRJ(_job, outFileName, sprayAreas, numOfLines, 3); if (agnContent) archiveItems.push({ content: agnContent, meta: { name: outFileName + '.agn' } }); writeShapeFile(_job, items, 1, outFileName, (err, items) => { if (err) return callback(err); if (!utils.isEmptyArray(items)) archiveItems = archiveItems.concat(items); callback(); }); } else callback(); }, function (callback) { if (dlType === 3 && _job.waypoints.length > 0) { writeShapeFile(_job, _job.waypoints, 2, outFileName + 'wpt', (err, items) => { if (err) return callback(err); if (!utils.isEmptyArray(items)) archiveItems = archiveItems.concat(items); callback(); }); } else callback(); }, function (callback) { const info = writeJobInfo(_job); if (info) archiveItems.push({ content: info, meta: { name: 'job.json' } }); callback(); } ], function (err) { if (err) { debug(params); return cb(err); } return cb(null, archiveItems); }); } /** * Write Job items to a shape file and return a list of archive entries contain the shp file items * * @param {*} job the job object for additional info only (must not be modified) * @param {*} items items list * @param {*} type 1: polygons, 2: waypoints * @param {*} outFileName The base file name * @param {*} cb Callback function */ function writeShapeFile(job, items, type, outFileName, cb) { if (utils.isEmptyArray(items)) return cb(); const points = []; const featureData = []; let archiveItems = []; if (type === 1) { // Area shape file let sprayAreas = [], xclAreas = [], coors = [], name; if (items.length > 1) xclAreas = items[1] || []; sprayAreas = items[0] || []; for (let i = 0; i < sprayAreas.length; i++) { const sprArea = sprayAreas[i]; const sprayWithchildCoors = []; let rate = Number(sprArea.properties.appRate) || 0; if (!rate) rate = job.appRate; name = sprArea.properties.name || `${i + 1}`; coors = sprArea.geometry.coordinates[0]; sprayWithchildCoors.push(coors); featureData.push({ BLOCKID: name, XCL: 'N', RATE: rate.toFixed(2), Version: version }); // Dbf record if (!utils.isEmptyArray(xclAreas)) { let j = xclAreas.length - 1; while (xclAreas.length && j >= 0) { const xclArea = xclAreas[j]; if (polyUtil.polygonWithin(xclArea.geometry.coordinates, sprArea.geometry.coordinates) || polyUtil.polygonIntersect(sprArea.geometry.coordinates, xclArea.geometry.coordinates)) { coors = xclArea.geometry.coordinates[0]; if (polyUtil.isClockwise(coors)) coors = coors.reverse(); sprayWithchildCoors.push(coors); xclAreas.splice(j, 1); } j--; } } // Same-name Spray areas and related xcls (holes be written to the same shape record (Multipolygon)) points.push([sprayWithchildCoors]); } // Process the rest of standalone xcl items as poly with XCL = 'Y' if (!utils.isEmptyArray(xclAreas)) { for (let m = 0; m < xclAreas.length; m++) { const xclArea = xclAreas[m]; featureData.push({ BLOCKID: xclArea.properties.name || '', XCL: 'Y', RATE: 0, VERSION: version, }); coors = xclArea.geometry.coordinates[0]; if (!polyUtil.isClockwise(coors)) { coors = coors.reverse(); } points.push([coors]); } } } else if (type === 2) { // WayPoint shape file // Process points for waypoint items for (const item of items) { featureData.push({ NAME: item.properties.name || '', VERSION: version }); if (item.geometry && item.geometry.coordinates) points.push(item.geometry.coordinates); } } if (utils.isEmptyArray(points)) return cb(); shpWrite.write( // feature data featureData.length ? featureData : [{ Version: version, Name: '' }], // geometry type type === 1 ? 'POLYGON' : 'POINT', // geometries points, function (err, files) { if (err) return cb(err); archiveItems = [ { content: utils.ArrayBuffertoBuffer(files.shp.buffer), meta: { name: outFileName + '.shp' } }, { content: utils.ArrayBuffertoBuffer(files.shx.buffer), meta: { name: outFileName + '.shx' } }, { content: utils.ArrayBuffertoBuffer(files.dbf.buffer), meta: { name: outFileName + '.dbf' } }, { content: writeShapePRJ(), meta: { name: outFileName + '.prj' } }]; return cb(null, archiveItems); }); } function writeShapePRJ() { return 'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]'; } function writeJobInfo(job) { if (!job) return null; const _job = { _id: job._id, name: job.name, orderNumber: job.orderNumber, measureUnit: job.measureUnit, swathWidth: job.swathWidth, appRate: job.appRate, appRateUnit: job.appRateUnit, startDate: job.startDate, endDate: job.endDate, flightNumber: job.flightNumber, crop: (job.crop && job.crop['_id']) ? job.crop['name'] : job.crop, farm: job.farm, remark: job.remark }; if (!utils.isEmptyArray(job.products)) { _job.product = { name: job.products.map(p => p.product.name).join("/") }; } if (job.operator) _job.operator = { name: job.operator.name }; if (job.client) _job.client = { name: job.client.name, address: job.client.address }; if (job.vehicle) _job.vehicle = { name: job.vehicle.name }; try { return JSON.stringify(_job, null, 4); } catch (error) { debug(error); return null; } } function writeAreasXYZ(sprayAreas, fileName) { if (utils.isEmptyArray(sprayAreas)) return null; const items = []; if (sprayAreas.length === 1) { if (sprayAreas[0].lines.length) items.push({ content: linesToXYZ(sprayAreas[0].lines), meta: { name: fileName } }); } else { const selLines = sprayAreas.map(a => a.lines); const allLines = [].concat.apply([], selLines); items.push({ content: linesToXYZ(allLines), meta: { name: fileName } }); } return items; } function linesToXYZ(lines) { const content = []; let llUtm = new app.locals.LatLonUTM(0, 0), line, utm; for (let i = 0; i < lines.length; i++) { line = lines[i]; if (!line.length) continue; content.push("LINE " + ((i + 1) * 10).toString()); for (let m = 1; m < line.length; m += 2) { // Validate coordinates before processing if (!line[m - 1] || line[m - 1].length < 2 || !line[m] || line[m].length < 2) { continue; } const lat1 = Number(line[m - 1][0]); const lon1 = Number(line[m - 1][1]); const lat2 = Number(line[m][0]); const lon2 = Number(line[m][1]); if (!isFinite(lat1) || !isFinite(lon1) || !isFinite(lat2) || !isFinite(lon2)) { continue; } llUtm.lat = lat1; llUtm.lon = lon1; utm = llUtm.toUtm(); const easting1 = utils.fixedTo(utm.easting, 3); const northing1 = utils.fixedTo(utm.northing, 3); if (!isFinite(easting1) || !isFinite(northing1)) { continue; } content.push('%-.3f %-.3f 1 0'.format(easting1, northing1)); llUtm.lat = lat2; llUtm.lon = lon2; utm = llUtm.toUtm(); const easting2 = utils.fixedTo(utm.easting, 3); const northing2 = utils.fixedTo(utm.northing, 3); if (!isFinite(easting2) || !isFinite(northing2)) { continue; } content.push('%-.3f %-.3f 2 0'.format(easting2, northing2)); } } return content.join('\r\n'); } function writeDSPorXCL(areas) { const content = []; if (utils.isEmptyArray(areas)) return null; let areaNum = 1; for (const area of areas) { if (area.geometry.coordinates && area.geometry.coordinates[0].length >= 3) { let coors = area.geometry.coordinates[0]; if (!polyUtil.isClockwise(coors)) coors = coors.reverse(); for (const vertex of coors) { // Validate vertex coordinates const lat = Number(vertex[1]); const lon = Number(vertex[0]); if (!isFinite(lat) || !isFinite(lon)) { continue; // Skip invalid coordinates } content.push('%-3d %.6f %.6f'.format(areaNum, lat, lon)); } areaNum = areaNum + 1; } } return content.join('\r\n'); } function writeVFR(areas, jobAppRate) { let content = []; if (utils.isEmptyArray(areas)) return null; let areaNum = 1; for (const area of areas) { if (area.geometry.coordinates && area.geometry.coordinates[0].length >= 3) { for (const vertex of area.geometry.coordinates[0]) { if (vertex && vertex.length >= 2) { // Validate vertex coordinates const lat = Number(vertex[1]); const lon = Number(vertex[0]); if (!isFinite(lat) || !isFinite(lon)) { continue; // Skip invalid coordinates } // Validate application rate const areaAppRate = (area.properties && area.properties.appRate) ? area.properties.appRate : jobAppRate; const appRate = Number(areaAppRate) || 0; if (!isFinite(appRate)) { continue; // Skip invalid app rate } const fixedAppRate = utils.fixedTo(appRate, 2); if (!isFinite(fixedAppRate)) { continue; // Skip if fixedTo returns invalid value } content.push('%-3d %.6f %.6f %.2f'.format(areaNum, lat, lon, fixedAppRate)); } } areaNum = areaNum + 1; } } return content.join('\r\n'); } /** * Write job items to an AGNAV file (No1, Prj, AGN) * * @param {*} job the job object * @param {*} title title for line 0 (NO1_TITLE) * @param {*} sprayAreas area list to write to the file * @param {*} type: output file formats. 1: No1, 2: Prj, 3: AGN */ function writeAGNorN01orPRJ(job, name, sprayAreas, numOfLines, type, findRoot = false, firstRoot = false) { let content = []; if (!job || utils.isEmptyArray(sprayAreas)) return null; let itemNum = 0; // First vertex of the first area [long, lat] const firstVertex = sprayAreas[0].geometry.coordinates[0][0]; // Validate firstVertex before computing areaCM if (!firstVertex || firstVertex.length < 2 || !isFinite(Number(firstVertex[0]))) { return null; // Cannot proceed without valid coordinates } const areaCM = geoUtil.computeCMfromLon(firstVertex[0]); // Validate areaCM before using it if (!isFinite(areaCM)) { return null; // Cannot proceed without valid central meridian } const title = name ? name : 'Untitled Area'; content.push('%-2d NEW AREA FILE %-61s'.format(NO1_TITLE, title)); content.push('%-2d %-2s %d'.format(NO1_CENTRAL_MERIDIAN, 'L', areaCM)); let zoneNames = []; if (type === 1) { const no1Area = sprayAreas[0]; zoneNames.push(no1Area.properties.name); itemNum = 1; for (const vertex of no1Area.geometry.coordinates[0]) { // Validate vertex coordinates const lat = Number(vertex[1]); const lon = Number(vertex[0]); if (!isFinite(lat) || !isFinite(lon)) { continue; // Skip invalid coordinates } content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, lat, lon, itemNum)); itemNum = itemNum + 1; } } else if (type === 2) { let features = []; for (const area of sprayAreas) { area['type'] = 'Feature'; // Add the required property for GeoJSON object // area = turf.truncate(area, { precision: 6}); features.push(area); } const featureCollection = { 'type': 'FeatureCollection', 'features': features }; let areasBbox; try { areasBbox = turf.bbox(featureCollection); // bbox extent in [ minX, minY, maxX, maxY ] order } catch (error) { } if (areasBbox) { // Validate bbox coordinates before using them const validBbox = areasBbox.every(coord => isFinite(Number(coord))); if (!validBbox) { // Skip bbox processing if any coordinate is invalid } else { // Corners of the bounds content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[1], areasBbox[0], 1)); content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[3], areasBbox[0], 2)); content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[3], areasBbox[2], 3)); content.push('%-2d %-11.6f %-11.6f AREA CORNER %d'.format(NO1_CORNER, areasBbox[1], areasBbox[2], 4)); } } } if (type <= 2 && job.waypoints) { itemNum = 1; for (const wpt of job.waypoints) { // Validate waypoint coordinates if (!wpt.geometry || !wpt.geometry.coordinates || wpt.geometry.coordinates.length < 2) { continue; } const lat = Number(wpt.geometry.coordinates[1]); const lon = Number(wpt.geometry.coordinates[0]); if (!isFinite(lat) || !isFinite(lon)) { continue; // Skip invalid coordinates } content.push('%-2d %-11.6f %-11.6f%-5.5s WAYPOINT %d'.format(NO1_WAYPOINT, lat, lon, (!utils.isBlank(wpt.properties.name) ? wpt.properties.name.replace(' ', '_') : ''), itemNum)); itemNum = itemNum + 1; } } let _numOfLines = 0; if (numOfLines < 0) { for (let i = 0; i < sprayAreas.length; i++) { if (!utils.isEmptyArray(sprayAreas[i].lines)) _numOfLines += sprayAreas[i].lines.length; } } else { _numOfLines = numOfLines; } if (_numOfLines) content.push('%-2d %11d NUMBER OF LINES'.format(NO1_NUMBER_OF_LINES, _numOfLines)); content.push('%-2d %11d MAX CROSS TRACK'.format(NO1_MAX_CROSS_TRACK, 200)); let wgs84Default = { delx: 0, dely: 0, delz: 0, k0: 0.9996000000, // UTM scale on the central meridian, fixed constant xshift: 0.0, yShift: 0.0 }; content.push('%-2d %5d %5d %5d DELTA X/Y/Z'.format(NO1_DELTA_X_Y_Z, wgs84Default.delx, wgs84Default.dely, wgs84Default.delz)); content.push('%-2d %12f %9.1f %9.1f K0, X/Y SHIFT'.format(NO1_K0_XY_SHIFT, wgs84Default.k0, wgs84Default.xshift, wgs84Default.yShift)); let masterPoint, heading = 0, rootArea = sprayAreas[0]; if (findRoot && sprayAreas.length > 1) { rootArea = findRootArea(sprayAreas, firstRoot); } if (!utils.isEmptyArray(rootArea.masterPoint) && rootArea.masterPoint.length >= 2) masterPoint = rootArea.masterPoint; else masterPoint = [firstVertex[1], firstVertex[0]]; heading = geoUtil.to180Range(rootArea.latlngHeading); // Validate master point and heading before using them const validMasterPoint = masterPoint && masterPoint.length >= 2 && isFinite(Number(masterPoint[0])) && isFinite(Number(masterPoint[1])); const validHeading = isFinite(Number(heading)); const validFixedHeading = validHeading ? utils.fixedTo(heading) : 0; if (validMasterPoint && isFinite(validFixedHeading)) { content.push('%-2d %-11.6f %11.6f %6.2f MASTER POINT HEADING'.format(NO1_MASTER_POINT_HEADING, Number(masterPoint[0]), Number(masterPoint[1]), validFixedHeading, 1)); } content.push('%-2d %-11s %11.1f %13f %8d ELLIPSOID'.format(NO1_ELLIPSOID, 'WGS-84', 6378137.0, 298.257223123, 22)); // Validate firstVertex for equatorial crossing calculation const validLatForCrossing = isFinite(Number(firstVertex[1])) ? Number(firstVertex[1]) : 0; let equatorialCrossing = validLatForCrossing > 0 ? 1 : 2; content.push('%-2d %10d NO EQUATORIAL CROSSING, %s HEMISPHERE'.format(NO1_EQUATORIAL_CROSSING, equatorialCrossing, equatorialCrossing === 1 ? 'N' : 'S')); // Validate job properties before formatting const validSwathWidth = isFinite(Number(job.swathWidth)) ? Number(job.swathWidth) : 0; content.push('%-2d %10.1f %9d %2d SWATH WIDTH, NO A/C, SPRAY ON CLOSURE OF CONTACT'.format(NO1_SWATHWIDTH_AC_INSIDE_AREA, validSwathWidth, 1, 1)); content.push('%-2d %10d HALF-SWATH OFFSET OF MASTER LINE'.format(NO1_HALF_SWATH_OFFFSET_1ST_TIME, 1)); content.push('%-2d %10d %s'.format(NO1_DISPLAY_UNIT, job.measureUnit ? 1 : 2, job.measureUnit ? 'U.S. SYSTEM' : 'MET SYSTEM')); // content.push('%-2d %5d %7d RACE TRACK, SKIP TRACK'.format(NO1_RACE_SKIP, 5, 1)); content.push('%-2d %10d MAGNETIC VARIATION, Deg.'.format(NO1_MAGNETIC_VARIATION, 0)); // System params, default for now let systemLag = 0.80; let relayOn = 0.65, relayOff = 0.5; content.push('%-2d %-10.2f SYSTEM LAG, sec.'.format(NO1_SYSETEM_LAG, systemLag)); content.push('%-2d %-5.2f %7.2f RELAY ON/OFF, sec.'.format(NO1_RELAY_ON_OFF, relayOn, relayOff)); // Nov.08, 2021: Task#298 - Not writing these 2 => Let the console unit decide later on. // content.push('%-2d %-27d SPRAY PATTERN 0-B&F, 1-RT 2-SQZ 3-SKP 4-Split 5-Expand'.format(NO1_PATTERN, 0)); // content.push('%-2d %-27d EXPAND PATTERN SIDE 0-Right, 1-Left'.format(NO1_EXPAND_LEFT_RIGHT, 1)); if (type === 2) { // only PRJ write area names for (let i = 0; i < sprayAreas.length; i++) { const area = sprayAreas[i]; const areaName = (area.properties && area.properties.name) ? area.properties.name.replace(' ', '_') : ''; content.push('%-2d %s'.format(PRJ_ZONE_NAME, areaName)); } } for (let i = 0; i < sprayAreas.length; i++) { const area = sprayAreas[i]; const appRate = (area.properties && area.properties.appRate) ? area.properties.appRate : job.appRate; // Validate application rate const validAppRate = isFinite(Number(appRate)) ? Number(appRate) : 0; content.push('%-2d %-27.2f AGNAV FLOW RATE %s'.format(AGN_APPLICATION_RATE, validAppRate, codeToAppRateUnit(job.appRateUnit, job.measureUnit))); } // Validate swath width for segment const validSegmentSwathWidth = isFinite(Number(job.swathWidth)) ? Number(job.swathWidth) : 0; content.push('%-3d %-6.1f %-6.1f %-6.1f %-6.1f SEGMENT SWATH'.format(NO1_SWATHWIDTH_SEGMENTS, validSegmentSwathWidth, 0.0, 0.0, 0.0)); content.push('%-3d %-26d AREA TYPE'.format(NO1_AREATYPE, 0)); // POLYGON FILL COLOR // content.push('%-3d %-25s POLYGON FILL COLOR'.format(NO1_POLYGON_FILL_COLOR, 'colorforwhich??'); return content.join('\r\n'); } function codeToAppRateUnit(unitCode, isUS) { let rateUnit = isUS ? "LPH" : "GPA"; switch (unitCode) { case RateUnits.OZ_PER_ACRE: rateUnit = "OZPA"; break; case RateUnits.GAL_PER_ACRE: rateUnit = "GPA"; break; case RateUnits.LBS_PER_ACRE: rateUnit = "LBPA"; break; case RateUnits.LIT_PER_HA: rateUnit = "LPH"; break; case RateUnits.KG_PER_HA: rateUnit = "KGPH"; break; } return rateUnit; } async function downloadMap_post(req, res) { await downloadJobwMap(req, res); } async function downloadJobwMap(req, res) { const dlReq = req.body; // debug('Request params: ', util.inspect(JSON.stringify(_dlReq), false, null)); const _mapOps = dlReq.mapOps; let targetFileName, outFileName; if (!dlReq.jobId || !_mapOps.width || !_mapOps.height || !_mapOps.center || !_mapOps.zoom) AppParamError.throw(); try { const job = await Job.findById(dlReq.jobId, 'name'); if (!job) AppError.throw(Errors.JOB_NOT_FOUND); const dlType = dlReq.type || 0, userInfo = req.userInfo; targetFileName = path.join(env.TEMP_DIR, job._id + '_' + uniqid()); let imgFilePath = targetFileName + '.jpg', imgTifFileName, imgUTMFileName, realName = job.name, pageWidth = Math.trunc(_mapOps.width), pageHeight = Math.trunc(_mapOps.height); const renderParams = urlHelper.encodeQueryData({ base: _mapOps.base, zoom: _mapOps.zoom, clat: _mapOps.center.lat, clon: _mapOps.center.lng, width: pageWidth, height: pageHeight, premium: (userInfo ? userInfo.premium : 0) }); const reportUrl = `${req.protocol}://${req.hostname}/public/downloadMap.html?${renderParams}`; await fs.ensureDir(env.TEMP_DIR); await webUtil.webShot({ url: reportUrl, type: 'jpeg', quality: 100, width: pageWidth, height: pageHeight, path: imgFilePath }); // 1. Re-project the image from Web Mercater to UTM at the zone based on the center of the bounds let northWest = _mapOps.bounds.NW, southEast = _mapOps.bounds.SE, center_UTM = new app.locals.LatLonUTM(_mapOps.center.lat, _mapOps.center.lng).toUtm(); outFileName = `${targetFileName}-out`; imgTifFileName = outFileName + '.tiff'; imgUTMFileName = `${outFileName}-utm.tiff`; const ullr = ` -a_ullr ${northWest.x} ${northWest.y} ${southEast.x} ${southEast.y}`; const cmd1 = `gdal_translate ${ullr} -a_srs EPSG:3857 ${imgFilePath} ${imgTifFileName} -co COMPRESS=JPEG`; await utils.execAsync(cmd1); // 1.1. Check and rescale if the image size constraints over MAX_MAP_DIMEN pixels on the largest dimension let maxDimen = Math.max(pageWidth, pageHeight); if (maxDimen >= MAX_MAP_DIMEN) { let newWidth, newHeight; if (pageWidth > pageHeight) { newWidth = MAX_MAP_DIMEN; newHeight = (pageHeight / pageWidth) * MAX_MAP_DIMEN; } else { newHeight = MAX_MAP_DIMEN; newWidth = (pageWidth / pageHeight) * MAX_MAP_DIMEN; } pageWidth = Math.trunc(newWidth); pageHeight = Math.trunc(newHeight); // console.log('New WxH:', pageWidth, pageHeight); const outSize = `-outsize ${pageWidth} ${pageHeight}`, croppedTif = outFileName + '_crop.tiff'; imgTifFileName = croppedTif; const cmd11 = `gdal_translate ${imgTifFileName} ${croppedTif} ${outSize} -co COMPRESS=JPEG`; await utils.execAsync(cmd11); } // 2. Reproject Image from Web Mecarter to UTM projection let zoneDef = `+zone=${center_UTM.zone}`; if (center_UTM.hemisphere === 'S') zoneDef = zoneDef + ' +south'; const cmd2 = `gdalwarp ${imgTifFileName} ${imgUTMFileName} -s_srs EPSG:3857 -t_srs "+proj=utm ${zoneDef} +ellps=WGS84 +datum=WGS84 +units=m +no_defs" -overwrite -co COMPRESS=JPEG`; await utils.execAsync(cmd2); // 3. Crop 4% on all edges toward center to remove black edges if any let cropX = Math.trunc(pageWidth * 0.04), cropY = Math.trunc(pageHeight * 0.04); const srcWin = `-srcwin ${cropX} ${cropY} ${pageWidth - (2 * cropX)} ${pageHeight - (2 * cropY)}`; const cmd3 = `gdal_translate -of jpeg ${srcWin} ${imgUTMFileName} ${outFileName}.jpg`; await utils.execAsync(cmd3); // 4. Build and response a .zip file contains the image + geo-referenced meta-data file // Read .aux.xml for aux meta-data info const meta = await fs.readFile(outFileName + '.jpg.aux.xml'); const parseResult = await parserXMLAsync(meta); const geotf = parseResult.PAMDataset.GeoTransform[0].split(','); const origin = { x: parseFloat(geotf[0]), y: parseFloat(geotf[3]) }; await writeWorldFileAync(outFileName, { origin: origin, scaleX: parseFloat(geotf[1]), scaleY: parseFloat(geotf[5]), rx: 0.0, ry: 0.0 }); // 5. Create zip file to response let archiveItems = []; if (dlType > 0) // dlType with 0: 'Map Only', 1:AgNav, 2:Agnav Prj, 3:Shape archiveItems = await makeDownloadItemsAsync(req); archiveItems.push({ file: outFileName + '.jpg', meta: { name: realName + '.jpg' } }, { file: outFileName + '.jgw', meta: { name: realName + '.jgw' } }); await sendArchiveAsync(archiveItems, res); if (dlType) await writeJobLog(job._id, 2, req.uid, req.ut); } catch (err) { if (targetFileName) debug("input:", req.body); debug(err); throw err; } finally { // Clean up temp files if any await removeTempFilesAsync(targetFileName, outFileName); } } function writeWorldFile(fileName, meta, cb) { try { const lineEnd = '\r\n'; // Line 1 X pixel scale let content = utils.truncR(meta.scaleX, 6) + lineEnd; // Line 2 X rotation angle content = content.concat(meta.rx + lineEnd); // Line 3 Y rotation angle content = content.concat(meta.ry + lineEnd); // Line 4 Y pixel scale content = content.concat(utils.truncR(meta.scaleY, 6) + lineEnd); // Line 5 X origin (Easting) content = content.concat(utils.truncR(meta.origin.x, 6) + lineEnd); // Line 6 Y origin (Northing) content = content.concat(utils.truncR(meta.origin.y, 6) + lineEnd); fs.writeFile(fileName + '.jgw', content, { encoding: 'utf8', flag: 'w' }, (err) => { if (err) return cb(err, false); return cb(null, true); }); } catch (err) { // debug('Error writing World File:' + fileName + '.jgw', error); return cb(err, false); } } /** * Record Download log entry and update Job status to DOWNLOADED * @param {*} jobId Job Id * @param {*} type 0: New, 1: Ready, 2: Download, 3: Sprayed * @param {*} userId the userId that performed the event * @param {*} userType the user type */ async function writeJobLog(jobId, type = AssignStatus.DOWNLOADED, userId, userType) { if (!jobId || !userId) return; // Use the reusable job log utility await jobUtil.writeJobLog(jobId, type, userId); // Update assignment status for device users using the reusable utility if (userType === UserTypes.DEVICE) { await jobUtil.updateAssignStatus(userId, jobId, AssignStatus.DOWNLOADED); } } /** * Find the root area in a list which has lines info (latlngHeading, lines, etc.) * @param {*} areas */ function findRootArea(sprayAreas, firstIsRoot = false) { let rootArea = null; if (utils.isEmptyArray(sprayAreas)) return rootArea; rootArea = sprayAreas[0]; const firstId = sprayAreas[0]._id.toHexString(); for (let i = 0; i < sprayAreas.length; i++) { if (firstIsRoot) { if (!utils.isEmptyArray(sprayAreas[i].mems) && sprayAreas[i].mems.includes(firstId)) { rootArea = sprayAreas[i]; break; } } else { if (utils.getProp(sprayAreas[i], "latlngHeading", null)) { rootArea = sprayAreas[i]; break; } } } return rootArea; } function removeTempFiles(targetFileName, outFileName, cb) { let files = []; if (outFileName) files = [ outFileName + '.jpg', outFileName + '.jgw', outFileName + '.jpg.aux.xml', outFileName + '.tiff', outFileName + '-utm.tiff', outFileName + '.zip' ]; if (targetFileName) files.push(targetFileName + '.jpg'); fileHelper.removeFiles(files, cb); } /** * Detect partner code from file extension * @param {string} fileName - The filename to check * @returns {string|null} Partner code or null if not a partner file */ function detectPartnerFromExtension(fileName) { const ext = path.extname(fileName).toLowerCase(); // Map file extensions to partner codes const { PartnerFileExtensions } = require('../helpers/constants'); return PartnerFileExtensions[ext] || null; } /** * Resolve file path based on partner code * @param {string} fileName - The filename to resolve * @param {string} partnerCode - Partner code (e.g., 'SATLOC') * @returns {string} Full file path */ function resolvePartnerFilePath(fileName, partnerCode) { if (partnerCode) { const { PartnerCodes } = require('../helpers/constants'); const codeUpper = partnerCode.toUpperCase(); // Handle partner-specific storage paths if (codeUpper === PartnerCodes.SATLOC) { const SatlocService = require('../services/satloc_service'); const satlocService = new SatlocService(); return satlocService.resolveLogFilePath(fileName); } // Add more partner codes here as needed } // Default: use UPLOAD_DIR for regular application files return path.join(env.UPLOAD_DIR, fileName); } async function downloadAppfile_get(req, res) { const fileName = decodeURIComponent(req.query.file); if (!fileName) return writeNotFound(res); let fileLoc; try { // Fast path: Check file extension first to avoid database query for known partner files const partnerCodeFromExt = detectPartnerFromExtension(fileName); if (partnerCodeFromExt) { // Known partner special/unique file extension - resolve directly without DB lookup fileLoc = resolvePartnerFilePath(fileName, partnerCodeFromExt); } else { // Unknown extension - check database for partner association // This handles edge cases like renamed files or custom partner formats const { PartnerLogTracker } = require('../model'); const jobId = req.query.jobId; let query; if (jobId && !isNaN(jobId)) { // Efficient lookup using jobId and filename query = { 'matchedJobs.jobId': Number(jobId), $or: [ { logFileName: fileName }, { savedLocalFile: fileName } ] }; } else { // Fallback to filename-only lookup query = { $or: [ { logFileName: fileName }, { savedLocalFile: fileName } ] }; } const tracker = await PartnerLogTracker.findOne(query, { partnerCode: 1 }).lean(); if (tracker && tracker.partnerCode) { // Partner file found in database - resolve using partner service fileLoc = resolvePartnerFilePath(fileName, tracker.partnerCode); } else { // Regular application file - use default upload directory fileLoc = path.join(env.UPLOAD_DIR, fileName); } } } catch (err) { // On error, fall back to default upload directory debug('Error looking up file source:', err); fileLoc = path.join(env.UPLOAD_DIR, fileName); } const stream = fs.createReadStream(fileLoc); // This will wait until we know the readable stream is actually valid before piping stream.on('open', () => { let length = fileName.lastIndexOf('_'); length = length != -1 ? length : fileName.length; // File exists, stream it to user res.writeHead(200, { "Content-Disposition": "attachment;filename=" + `${fileName.substring(0, length)}`, }); // This just pipes the read stream to the response object (which goes to the client) stream.pipe(res); }); stream.on('error', () => { writeNotFound(res); }); } function writeNotFound(res) { res.writeHead(404, 'Not Found'); res.write('404: File Not Found!'); res.end(); } async function downloadObs_post(req, res) { let obs = []; const userInfo = req.userInfo; if (userInfo) { obs = await Obstacle.find({ $or: [{ byUser: ObjectId(req.uid) }, { byUser: ObjectId(userInfo.puid) }], "properties.type": "USER" }).lean(); } res.setHeader('Content-disposition', 'attachment; filename=obstacles.dat'); res.setHeader('Content-type', 'text/plain'); res.charset = 'UTF-8'; res.write(writeFAAUserObs(obs)); res.end(); } /** * Write obstacles to FAA text content * @param {*} obstacles collection */ function writeFAAUserObs(obs) { if (!obs || !obs.length) obs = []; const Dms = app.locals.Dms; Dms.separator = ''; let content = []; const currencyDate = moment.utc().format('MM/DD/Y'); content.push(' CURRENCY DATE = %s'.format(currencyDate)); content = content.concat([ ' LATITUDE LONGITUDE OBSTACLE AGL AMSL LT ACC MAR FAA ACTION', 'OAS# V CO ST CITY DEG MIN SEC DEG MIN SEC TYPE HT HT H V IND STUDY JDATE', '-------------------------------------------------------------------------------------------------------------------------------' ]); let ob, dataObj; for (let i = 0; i < obs.length; i++) { ob = obs[i]; let prop = ob.properties, coor = ob.geometry.coordinates; // Validate coordinates before processing if (!coor || coor.length < 2 || !isFinite(Number(coor[0])) || !isFinite(Number(coor[1]))) { continue; // Skip invalid coordinates } const agl = (Number(prop.agl) || 0).toFixed(0); // Validate agl and index before using in format strings if (!isFinite(Number(agl)) || !isFinite(i)) { continue; // Skip if agl or index is invalid } dataObj = { osa: '%-9s'.format(utils.normalizeName(prop.name || `TOWER_${i + 1}`).substring(0, 9)), ver: 'U', country2: '--', state2: '--', city16: '%-16s'.format('UNKNOWN'), latDMS: Dms.toLat(Number(coor[1]), 'dms', 2).replace('°', ' ').replace('′', ' ').replace('″', ''), lonDM: Dms.toLon(Number(coor[0]), 'dms', 2).replace('°', ' ').replace('′', ' ').replace('″', ''), type: '%-18s'.format('TOWER'), qty: '1', agl: '%s'.format(utils.padZero(agl, 5)), // pad left 0 - feet amsl: '%s'.format(utils.padZero(utils.isNumber(Number(prop.amsl)) ? Number(prop.amsl).toFixed(0) : agl, 5)) // pad left 0 - feet }; let line = Object.values(dataObj).join(' '); line = '%-128s'.format(line + ' U 9 I U %-14s A %-7s'.format('', utils.julianDate(new Date().toUTCString(), true).dateStr)); content.push(line); } return content.join('\n'); } return ({ anyJob_post, newJobs_post, downloadJob_post, downloadMap_post, downloadAppfile_get, downloadObs_post }); }