diff --git a/DrawGeodesic.js b/DrawGeodesic.js index a255753e..fd9e9792 100644 --- a/DrawGeodesic.js +++ b/DrawGeodesic.js @@ -15,7 +15,7 @@ L.Geodesic.prototype.getActualLatLngs = function () { // Writing these functions as class methods will result in an error being thrown. I don't know why. function _createGeodesic (coords, opts = {}) { - let geodesic = L.geodesic(coords, {...opts, wrap: true}); + let geodesic = new L.Geodesic(coords, {...opts, wrap: true}); geodesic.geom.geodesic.ellipsoid.a = 6378137; geodesic.geom.geodesic.ellipsoid.b = 6378137; geodesic.geom.geodesic.ellipsoid.f = 0; @@ -125,7 +125,7 @@ L.Draw.Polyline = L.Draw.Feature.extend({ // also do not want to trigger any click handlers of objects we are clicking on // while drawing. if (!this._mouseMarker) { - this._mouseMarker = L.marker(this._map.getCenter(), { + this._mouseMarker = new L.Marker(this._map.getCenter(), { icon: L.divIcon({ className: 'leaflet-mouse-marker', iconAnchor: [20, 20], diff --git a/ESRIGridParser.js b/ESRIGridParser.js index bac36132..38fa8a9c 100644 --- a/ESRIGridParser.js +++ b/ESRIGridParser.js @@ -1,37 +1,34 @@ const MathTools = require("./MathTools.js"); const proj4 = require("proj4"); +const crs = L.CRS.EPSG3857; /** - * A stateful ESRI Grid parser. Calculates min and max values for selected polygons in {@link L.ALS.SynthPolygonLayer}. Can parse huge files by chunks. + * A stateful ESRI Grid parser. Calculates min and max values for selected polygons in {@link L.ALS.SynthRectangleBaseLayer}. Can parse huge files by chunks. */ class ESRIGridParser { /** * Constructs a ESRI Grid parser. * - * @param layer {L.ALS.SynthPolygonLayer} A layer to apply parsed values to. + * @param layer {L.ALS.SynthRectangleBaseLayer} A layer to apply parsed values to. * @param projectionString {string} Proj4 projection string. If not given, WGS84 assumed. */ constructor(layer = undefined, projectionString = "") { /** * Layer to apply parsed values to - * @type {L.ALS.SynthPolygonLayer} + * @type {L.ALS.SynthRectangleBaseLayer} */ - this.layer = layer + this.layer = layer; + + if (projectionString === "") + projectionString = "WGS84"; /** - * Proj4 projection string - * @type {boolean} + * Proj4 projection object. Projects coordinates from WGS84 to grids projection. + * @type {Object|undefined} */ - this.hasProj = (projectionString !== ""); - - if (this.hasProj) - /** - * Proj4 projection object. Projects coordinates from WGS84 to grids projection. - * @type {Object|undefined} - */ - this.projectionFromWGS = proj4("WGS84", projectionString); + this.projectionFromMerc = proj4("EPSG:3857", projectionString); this.clearState(); } @@ -119,15 +116,15 @@ class ESRIGridParser { this.polygonsCoordinates = ESRIGridParser.getInitialData(this.layer); } - /** * Reads a chunk * @param chunk {string} A chunk to read */ readChunk(chunk) { for (let i = 0; i < chunk.length; i++) { - let symbol = chunk[i].toLowerCase(); - let isSpace = (symbol === " "), isLineBreak = (symbol === "\n" || symbol === "\r"); + let symbol = chunk[i].toLowerCase(), + isSpace = (symbol === " "), + isLineBreak = (symbol === "\n" || symbol === "\r"); // Skip multiple spaces and line breaks let nameMap = [ @@ -151,8 +148,11 @@ class ESRIGridParser { if (!this.allDEMParamsRead) { // Read param name until we hit space if (this.readingDEMParamName) { - if (symbol === " ") + if (isSpace) { + if (this.param === "") + continue; this.readingDEMParamName = false; + } // Stop reading when we hit minus or digit else if (symbol === "-" || !isNaN(parseInt(symbol))) { this.value = symbol; @@ -199,12 +199,6 @@ class ESRIGridParser { if (!this.DEMParams.nodata_value) this.DEMParams.nodata_value = -9999; - /*let rect = L.rectangle([ - [this.y, this.x], - [this.y - this.DEMParams.nrows * this.DEMParams.cellsize, this.x + this.DEMParams.ncols * this.DEMParams.cellsize] - ], {color: "#ff7800", weight: 2}); - this.layer.addLayers(rect);*/ - this.DEMParamsCalculated = true; } @@ -219,10 +213,12 @@ class ESRIGridParser { continue; } - let oldPoint = [this.x, this.y]; - let point = (this.hasProj) ? this.projectionFromWGS.inverse(oldPoint) : oldPoint; + let point = this.projectionFromMerc.inverse([this.x, this.y]); + for (let name in this.polygonsCoordinates) { - if (!MathTools.isPointInRectangle(point, this.polygonsCoordinates[name])) + let poly = this.polygonsCoordinates[name]; + + if (!MathTools[poly.length > 2 ? "isPointInPolygon" : "isPointInRectangle"](point, poly)) continue; if (!this.polygonsStats[name]) @@ -230,6 +226,11 @@ class ESRIGridParser { let stats = this.polygonsStats[name]; ESRIGridParser.addToStats(pixelValue, stats); + + /*new L.CircleMarker( + crs.unproject(L.point(...point)), + {color: `rgb(${pixelValue},${pixelValue},${pixelValue})`, fillOpacity: 1, stroke: false} + ).addTo(map);*/ } } else if (!isSpace && !isLineBreak) this.value += symbol; @@ -260,7 +261,7 @@ class ESRIGridParser { * * This method is NOT thread-safe! Call it outside of your WebWorker and pass your layer as an argument. * - * @param layer {L.ALS.SynthPolygonLayer} If you're not using it in a WebWorker, don't pass anything. Otherwise, pass your layer. + * @param layer {L.ALS.SynthRectangleBaseLayer} If you're not using it in a WebWorker, don't pass anything. Otherwise, pass your layer. */ copyStats(layer = undefined) { let l = this.layer || layer; @@ -311,38 +312,50 @@ class ESRIGridParser { /** * Generates initial parameters for the layer. - * @param layer {L.ALS.SynthPolygonLayer} Layer to copy data from + * @param layer {L.ALS.SynthPolygonBaseLayer} Layer to copy data from */ static getInitialData(layer) { let polygonsCoordinates = {}; - for (let name in layer.polygons) { - if (!layer.polygons.hasOwnProperty(name)) - continue; + layer.forEachValidPolygon(polygon => { + polygon.tempDemName = L.ALS.Helpers.generateID(); + + let coords; + + if (polygon instanceof L.Rectangle) { + let rect = polygon.getBounds(); + coords = [rect.getNorthWest(), rect.getSouthEast()]; + } else + coords = polygon.getLatLngs()[0]; + + let coordsCopy = []; + for (let coord of coords) { + let {x, y} = crs.project(coord); + coordsCopy.push([x, y]); + } + + polygonsCoordinates[polygon.tempDemName] = coordsCopy; + }); - let rect = layer.polygons[name].getBounds(); - polygonsCoordinates[name] = [ - [rect.getWest(), rect.getNorth()], - [rect.getEast(), rect.getSouth()] - ]; - } return polygonsCoordinates; } /** * Copies stats from any ESRIGridParser to a given layer - * @param layer {L.ALS.SynthPolygonLayer} Layer to copy stats to + * @param layer {L.ALS.SynthRectangleBaseLayer} Layer to copy stats to * @param stats {Object} Stats from any ESRIGridParser */ static copyStats(layer, stats) { - for (let name in stats) { - let widgetable = layer.polygonsWidgets[name]; - let s = stats[name]; - s.mean = s.sum / s.count; - widgetable.getWidgetById("minHeight").setValue(s.min); - widgetable.getWidgetById("maxHeight").setValue(s.max); - widgetable.getWidgetById("meanHeight").setValue(s.mean); - } - layer.updateAll(); + layer.forEachValidPolygon(polygon => { + let entry = stats[polygon.tempDemName]; + if (!entry) + return; + + entry.mean = entry.sum / entry.count; + polygon.widgetable.getWidgetById("minHeight").setValue(entry.min); + polygon.widgetable.getWidgetById("maxHeight").setValue(entry.max); + polygon.widgetable.getWidgetById("meanHeight").setValue(entry.mean); + }); + layer.calculateParameters(); } static createStatsObject() { diff --git a/GeoTIFFParser.js b/GeoTIFFParser.js index 4f0d57bd..d2c28e24 100644 --- a/GeoTIFFParser.js +++ b/GeoTIFFParser.js @@ -39,26 +39,28 @@ module.exports = async function (file, projectionString, initialData) { projectionString = projInformation.proj4; } - let projectionFromWGS = proj4("WGS84", projectionString); + let projectionFromMerc = proj4("EPSG:3857", projectionString); for (let name in initialData) { - // Let's project each polygon to the image, get their intersection part and calculate statistics for it + // Project each polygon to the image, get their intersection part and calculate statistics for it let polygon = initialData[name], - oldBbox = [ - polygon[0], [polygon[1][0], polygon[0][1]], polygon[1], [polygon[0][0], polygon[1][1]], polygon[0] - ], - newBbox = []; + isRect = polygon.length === 2, + coords = isRect ? [ + polygon[0], [polygon[1][0], polygon[0][1]], + polygon[1], [polygon[0][0], polygon[1][1]] + ] : polygon, + projPolygon = []; - for (let point of oldBbox) - newBbox.push(projectionFromWGS.forward(point)); + for (let coord of coords) + projPolygon.push(projectionFromMerc.forward(coord)); - newBbox = bboxPolygon( - bbox( - turfHelpers.polygon([newBbox]) - ) - ); + projPolygon.push([...projPolygon[0]]); // Clone first coordinate to avoid floating point errors - let intersection = intersect(imagePolygon, newBbox); + let polygonBbox = bboxPolygon(bbox( + turfHelpers.polygon([projPolygon]) + )); + + let intersection = intersect(imagePolygon, polygonBbox); if (!intersection) continue; @@ -83,10 +85,11 @@ module.exports = async function (file, projectionString, initialData) { stats = ESRIGridParser.createStatsObject(); // Stats for current polygon for (currentY; currentY <= maxY; currentY++) { - let currentX = minX; - let raster = await image.readRasters({window: [minX, currentY, maxX, currentY + 1]}); - let color0 = raster[0], // Raster is a TypedArray where elements are colors and their elements are pixel values of that color. + let currentX = minX, + raster = await image.readRasters({window: [minX, currentY, maxX, currentY + 1]}), + color0 = raster[0], // Raster is a TypedArray where elements are colors and their elements are pixel values of that color. index = -1; + for (let pixel of color0) { let crsX = leftX + currentX * xSize, crsY = topY + currentY * ySize; if (projInformation) { @@ -94,11 +97,12 @@ module.exports = async function (file, projectionString, initialData) { crsY *= projInformation.coordinatesConversionParameters.y; } - let point = projectionFromWGS.inverse([crsX, crsY]); currentX++; // So we can continue without incrementing index++; - if (!MathTools.isPointInRectangle(point, polygon)) + let point = projectionFromMerc.inverse([crsX, crsY]); + + if (!MathTools[isRect ? "isPointInRectangle" : "isPointInPolygon"](point, polygon)) continue; let value = 0; @@ -108,6 +112,12 @@ module.exports = async function (file, projectionString, initialData) { let multipliedValue = value * zScale; if (value === nodata || multipliedValue === nodata) continue; + + /*new L.CircleMarker( + L.CRS.EPSG3857.unproject(L.point(...point)), + {color: `rgb(${value},${value},${value})`, fillOpacity: 1, stroke: false} + ).addTo(map);*/ + ESRIGridParser.addToStats(multipliedValue, stats); } } diff --git a/MathTools.js b/MathTools.js index 134551f7..3ec75dae 100644 --- a/MathTools.js +++ b/MathTools.js @@ -23,8 +23,7 @@ class MathTools { * @return {boolean} true, if does. False otherwise. */ static isPointOnLine(point, line) { - let p1 = line[0], p2 = line[1]; - let p1x = p1[0], p1y = p1[1], p2x = p2[0], p2y = p2[1], px = point[0], py = point[1]; + let [p1, p2] = line, [p1x, p1y] = p1, [p2x, p2y] = p2, [px, py] = point; // Determine if px and py is between line's points. If not, the point is not on the line. let minX = Math.min(p1x, p2x); @@ -99,12 +98,22 @@ class MathTools { * @return {boolean} True, if point lies in polygon or on one of its edges. */ static isPointInPolygon(point, polygon) { - let intersections = 0, ray = [point, [Infinity, point[1]]]; + let intersections = 0, ray = [point, [Infinity, point[1]]], + polygonNotClosed = !MathTools.arePointsEqual(polygon[0], polygon[polygon.length - 1]); + + // If polygon is not closed, algorithm will return wrong value, if point intersects with the last edge + if (polygonNotClosed) + polygon.push([...polygon[0]]); + for (let i = 0; i < polygon.length - 1; i++) { let edge = [polygon[i], polygon[i + 1]], isPointOnEdge = MathTools.isPointOnLine(point, edge); - if (isPointOnEdge) + if (isPointOnEdge) { + if (polygonNotClosed) + polygon.pop(); + return true; + } let intersection = MathTools.linesIntersection(edge, ray); if (!intersection) @@ -134,7 +143,10 @@ class MathTools { if (notOnVertex) intersections++; } - //console.log(point, intersections); + + if (polygonNotClosed) + polygon.pop(); + return (intersections % 2 !== 0); } @@ -180,39 +192,49 @@ class MathTools { * @return {*[]|undefined} Clipped line where points are sorted from left to right or, if x coordinates are equal, from top to bottom. Or undefined if line doesn't intersect the polygon. */ static clipLineByPolygon(line, polygon) { - if (line.length !== 2) + if (line.length < 2) return undefined; - // Find intersection of each edge with the line and put it to the array + // Find intersection of each edge with each segment of the line and put it to the array let intersections = []; for (let i = 0; i < polygon.length - 1; i++) { let edge = [polygon[i], polygon[i + 1]]; - let intersection = this.linesIntersection(line, edge); - if (intersection === undefined) - continue; - for (let point of intersection) - intersections.push(point); + for (let j = 0; j < line.length - 1; j++) { + let intersection = this.linesIntersection([line[j], line[j + 1]], edge); + + if (intersection === undefined) + continue; + + for (let point of intersection) + intersections.push(point); + } + } + if (intersections.length < 2) + return undefined; + // Find two points that will produce greatest length. It will yield the segment inside the whole polygon. - let point1, point2, previousLength; + let point1, point2, previousLength = -1; for (let i = 0; i < intersections.length; i++) { + let p1 = intersections[i]; + for (let j = i + 1; j < intersections.length; j++) { - let p1 = intersections[i], p2 = intersections[j]; - let length = this.distanceBetweenPoints(p1, p2); - if (previousLength !== undefined && this.isLessThanOrEqualTo(length, previousLength)) + let p2 = intersections[j], length = this.distanceBetweenPoints(p1, p2); + + if (length <= previousLength) continue; + point1 = p1; point2 = p2; previousLength = length; } + } - if (previousLength === undefined) - return undefined; // Sort points from left to right and, if x coordinates are equal, from top to bottom. - let x1 = point1[0], x2 = point2[0], y1 = point1[1], y2 = point2[1]; + let [x1, y1] = point1, [x2, y2] = point2; if (this.isEqual(x1, x2)) { if (this.isGreaterThanOrEqualTo(y1, y2)) return [point1, point2]; @@ -242,15 +264,13 @@ class MathTools { * @return {*[]|undefined} One of: Array of intersections or, if lines doesn't intersect, undefined */ static linesIntersection(line1, line2) { - let p11 = line1[0], p21 = line2[0]; - let x1 = p11[0], x3 = p21[0]; - - let params1 = this.getSlopeAndIntercept(line1); - let params2 = this.getSlopeAndIntercept(line2); + let x1 = line1[0][0], x3 = line2[0][0], + params1 = this.getSlopeAndIntercept(line1), + params2 = this.getSlopeAndIntercept(line2); // Case when only one of the lines is vertical - let x, verticalLine, otherLine; - let shouldIterateOverPoints = false; // In case of parallel and overlapping lines we should use another method of detection intersections + let x, verticalLine, otherLine, + shouldIterateOverPoints = false; // In case of parallel and overlapping lines we should use another method of detection intersections if (params1 === undefined && params2 !== undefined) { x = x1; verticalLine = line1; @@ -271,8 +291,8 @@ class MathTools { } if (!shouldIterateOverPoints) { - let slopeDiff = params1.slope - params2.slope; - let interceptDiff = params2.intercept - params1.intercept; + let slopeDiff = params1.slope - params2.slope, + interceptDiff = params2.intercept - params1.intercept; // Case when lines are parallel... if (this.isEqual(slopeDiff, 0)) { diff --git a/README.md b/README.md index 58d63671..f542ca8f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ -# SynthFlight Beta +# SynthFlight -SynthFlight is a fully client-side software for planning aerial photography. Run it either on the desktop or in a [browser online](https://matafokka.github.io/SynthFlight/). +SynthFlight is a fully client-side software for planning aerial photography. -This is a beta version, so bugs, huge API changes and lack of backwards compatibility are to be expected. - -Most of the planned functionality is here, however, a number of small changes will be introduced. - -A stable version will be released in May or June 2022. +Run it either on the desktop or in a [browser online](https://matafokka.github.io/SynthFlight/). # Setup @@ -26,17 +22,15 @@ There are numerous ways to set up SynthFlight, listed from most to least preferr 1. Extract the downloaded archive wherever you want. 1. Navigate to the extracted folder, open it and run `SynthFlight` executable file. -***Warning 1:** only Windows x64 builds has been tested so far.* +***Warning:** macOS builds are not signed, so you need to configure your system to run unsigned apps.* -***Warning 2:** macOS builds are not signed, thus require disabling the Gatekeeper or something.* +## System requirements -# System requirements - -## For PWA +### For PWA A browser that supports it. -## For browser +### For browser One of: @@ -55,8 +49,7 @@ One of: Of course, requirements for TLS might change in future with the new TLS versions coming out and GitHub and OSM changing their policies. You can't prevent this from happening, the only thing you can do is using an evergreen browser. - -## For desktop builds +### For desktop builds * **Operating system** - one of: * **Windows 7** or later. ARM64, x86 and x64 platforms are supported. @@ -65,6 +58,10 @@ Of course, requirements for TLS might change in future with the new TLS versions * **CPU**: One that can handle web surfing. If you can browse the internet, SynthFlight will work fine. * **RAM**: 1 GB or more. +# User guide + +Please, refer to the [Wiki page](https://github.com/matafokka/SynthFlight/wiki) for the user guide. + # Building If you want to build SynthFlight yourself, do the following: @@ -75,7 +72,7 @@ If you want to build SynthFlight yourself, do the following: 1. Build by running `node build.js`. There are additional options, to see them, run `node build.js -h`. 1. When build will be finished, in project root will be `dist` directory containing builds for different OSs and platforms. -***Warning:** To build for macOS, you may need to build on an actual macOS.* +***Warning:** To build for macOS, you may need to run everything on an actual macOS. I don't have macOS, so I can't test if builds in this repo actually work.* # Hosting @@ -90,16 +87,19 @@ There's a [development](https://github.com/matafokka/SynthFlight/tree/developmen Translating this app will be much appreciated. SynthFlight locales can be found in [`locales`](https://github.com/matafokka/SynthFlight/tree/development/locales) directory and ALS locales are available [here](https://github.com/matafokka/leaflet-advanced-layer-system/tree/master/locales). Both of these needs to be translated. Locales are plain JS objects where key is being used in the program itself and value is a string that's being added to the page. Only values needs translation. Copy one of the locales to a new file, change locale name and translate all the values. -You can also contribute by reporting bugs, requesting API changes, new functionality or something else. Please, create an issue and describe your request. +You can also contribute by reporting bugs or requesting new functionality. To do so, please, create an issue and describe your request. # FAQ ## Can a local copy work offline? Yes. -## Projects compatibility? +## Will my old projects be compatible with the new ALS version? + +Backwards compatibility is preserved unless noted otherwise in the release notes. However, this is unlikely to happen unless required for fixing a critical bug. + +## My plane crashed, images got ruined, and my dog died! Who should I blame?! -There will be no compatibility between SynthFlight versions until first stable release. +As license states, yourself :p -## When a stable release will be available? -May or June 2022 +However, if you've encountered a some kind of error, please, report it by creating an issue. diff --git a/SynthBase/Hull.js b/SynthBaseLayer/Hull.js similarity index 96% rename from SynthBase/Hull.js rename to SynthBaseLayer/Hull.js index 22e912e3..fc178c54 100644 --- a/SynthBase/Hull.js +++ b/SynthBaseLayer/Hull.js @@ -31,7 +31,7 @@ L.ALS.SynthBaseLayer.prototype.buildHull = function (path, color) { if (layers.length === 1) { const latLngs = layers[0].getLatLngs(); - connectionsGroup.addLayer(L.geodesic([latLngs[0], latLngs[latLngs.length - 1]], lineOptions)); + connectionsGroup.addLayer(new L.Geodesic([latLngs[0], latLngs[latLngs.length - 1]], lineOptions)); connectionsGroup.addLayer(hullConnection); path.hullConnections = []; return; @@ -53,6 +53,8 @@ L.ALS.SynthBaseLayer.prototype.buildHull = function (path, color) { } let {lower, upper} = this.getConvexHull(points); + lower.pop(); + upper.pop(); // Normalize path-connection order by appending other point of the first path, if it's not connected already. let [p1, p2] = lower; @@ -158,7 +160,7 @@ L.ALS.SynthBaseLayer.prototype.buildHull = function (path, color) { } for (let pair of optConnections) - connectionsGroup.addLayer(L.geodesic(pair, lineOptions)); + connectionsGroup.addLayer(new L.Geodesic(pair, lineOptions)); connectionsGroup.addLayer(hullConnection); path.hullConnections = optConnections; } @@ -189,8 +191,6 @@ L.ALS.SynthBaseLayer.prototype.getConvexHull = function (points) { upper.push(point); } - lower.pop(); - upper.pop(); return {upper, lower} } @@ -222,7 +222,7 @@ L.ALS.SynthBaseLayer.prototype.getHullOptimalConnection = function (pathP1, path } L.ALS.SynthBaseLayer.prototype.connectHullToAirport = function () { - let airportPos = this._airportMarker.getLatLng(); + let airportPos = this.airportMarker.getLatLng(); for (let i = 0; i < this.paths.length; i++) { @@ -275,12 +275,16 @@ L.ALS.SynthBaseLayer.prototype.connectHullToAirport = function () { */ L.ALS.SynthBaseLayer.prototype.connectHull = function () { for (let i = 0; i < this.paths.length; i++) { - let path = this.paths[i]; + let path = this.paths[i], selectedArea = 0; path.hullLength = 0; - let layers = path.pathGroup.getLayers(); - for (let layer of layers) + + path.pathGroup.eachLayer((layer) => { path.hullLength += this.getPathLength(layer); - this._createPathWidget(path, 1, path.toUpdateColors); + if (layer.selectedArea) + selectedArea += layer.selectedArea; + }); + + this._createPathWidget(path, 1, path.toUpdateColors, selectedArea); this.buildHull(path, this.getWidgetById(`color${i}`).getValue()); } this.connectHullToAirport(); @@ -297,7 +301,7 @@ L.ALS.SynthBaseLayer.prototype.hullToCycles = function (path) { return undefined; if (path.hullConnections.length === 0) { - let airportPos = this._airportMarker.getLatLng(); + let airportPos = this.airportMarker.getLatLng(); return [[ airportPos, ...path.pathGroup.getLayers()[0].getLatLngs(), diff --git a/SynthBase/SynthBaseLayer.js b/SynthBaseLayer/SynthBaseLayer.js similarity index 79% rename from SynthBase/SynthBaseLayer.js rename to SynthBaseLayer/SynthBaseLayer.js index 3cd40fbe..b0bc0522 100644 --- a/SynthBase/SynthBaseLayer.js +++ b/SynthBaseLayer/SynthBaseLayer.js @@ -39,7 +39,26 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot isAfterDeserialization: false, - init: function (settings, pathGroup1, connectionsGroup1, colorLabel1, path1AdditionalLayers = [], pathGroup2 = undefined, connectionsGroup2 = undefined, colorLabel2 = undefined, path2AdditionalLayers = []) { + /** + * Indicates whether this layer has Y overlay, i.e. if it has parallel paths + * @type {boolean} + */ + hasYOverlay: true, + + init: function ( + settings, + // Path 1 args + pathGroup1, + connectionsGroup1, + colorLabel1, + path1AdditionalLayers = [], + + // Path 2 args + pathGroup2 = undefined, + connectionsGroup2 = undefined, + colorLabel2 = undefined, + path2AdditionalLayers = [] + ) { /** * {@link L.ALS.Layer#writeToHistory} but debounced for use in repeated calls @@ -86,19 +105,13 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot toUpdateColors: [pathGroup2, connectionsGroup2, ...path2AdditionalLayers] } : undefined; - /** - * Indicates whether this layer has Y overlay, i.e. if it has parallel paths - * @type {boolean} - */ - this.hasYOverlay = !!this.path2; - /** * Array of paths to work with * @type {PathData[]} */ this.paths = [this.path1]; - if (this.hasYOverlay) + if (this.path2) this.paths.push(this.path2); /** @@ -114,22 +127,29 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * * @type {L.FeatureGroup[]} */ - this.toUpdateThickness = [...path1AdditionalLayers, ...path2AdditionalLayers]; + this.toUpdateThickness = []; + + for (let arr of [path1AdditionalLayers, path2AdditionalLayers]) { + for (let item of arr) { + if (item !== undefined) + this.toUpdateThickness.push(item); + } + } for (let i = 0; i < this.paths.length; i++) { let path = this.paths[i]; - path.hullConnection = L.geodesic([[0, 0], [0, 0]], this.getConnectionLineOptions(settings[`color${i}`])); + path.hullConnection = new L.Geodesic([[0, 0], [0, 0]], this.getConnectionLineOptions(settings[`color${i}`])); this.addLayers(path.pathGroup, path.connectionsGroup); this.toUpdateThickness.push(path.pathGroup, path.connectionsGroup); } - this.serializationIgnoreList.push("_airportMarker", "toUpdateThickness", "writeToHistoryDebounced"); + this.serializationIgnoreList.push("airportMarker", "toUpdateThickness", "writeToHistoryDebounced", "pathsDetailsSpoiler"); /** * Properties to copy to GeoJSON when exporting * @type {string[]} */ - this.propertiesToExport = ["cameraWidth", "cameraHeight", "pixelWidth", "focalLength", "flightHeight", "overlayBetweenPaths", "overlayBetweenImages", "imageScale", "ly", "Ly", "By", "lx", "Lx", "Bx", "GSI", "IFOV", "GIFOV", "FOV", "GFOV", "selectedArea", "timeBetweenCaptures"]; + this.propertiesToExport = ["cameraWidth", "cameraHeight", "pixelWidth", "focalLength", "flightHeight", "overlayBetweenPaths", "overlayBetweenImages", "imageScale", "ly", "Ly", "By", "lx", "Lx", "Bx", "GSI", "IFOV", "GIFOV", "FOV", "GFOV", "timeBetweenCaptures"]; // Add airport let icon = L.divIcon({ @@ -142,14 +162,14 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * Airport marker * @protected */ - this._airportMarker = L.marker(this.map.getCenter(), { + this.airportMarker = new L.Marker(this.map.getCenter(), { icon: icon, draggable: true }); // Set inputs' values to new ones on drag - this.addEventListenerTo(this._airportMarker, "drag", "onMarkerDrag"); - this.addLayers(this._airportMarker); + this.addEventListenerTo(this.airportMarker, "drag", "onMarkerDrag"); + this.addLayers(this.airportMarker); }, /** @@ -190,7 +210,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot new L.ALS.Widgets.Divider("div2"), ); - this._airportMarker.fire("drag"); // Just to set values + this.airportMarker.fire("drag"); // Just to set values }, /** @@ -250,7 +270,8 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot */ _setPathsColor: function () { for (let i = 0; i < this.paths.length; i++) { - let style = {color: this.getWidgetById(`color${i}`).getValue()}, + let color = this.getWidgetById(`color${i}`).getValue(), + style = {fillColor: color, color}, path = this.paths[i]; for (let group of path.toUpdateColors) group.setStyle(style); @@ -263,14 +284,14 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot latWidget.setValue(fixedLatLng.lat); lngWidget.setValue(fixedLatLng.lng); - this._airportMarker.setLatLng(fixedLatLng); + this.airportMarker.setLatLng(fixedLatLng); this.connectToAirport(); }, onMarkerDrag: function () { - let latLng = this._airportMarker.getLatLng(), + let latLng = this.airportMarker.getLatLng(), fixedLatLng = this._limitAirportPos(latLng.lat, latLng.lng); - this._airportMarker.setLatLng(fixedLatLng); + this.airportMarker.setLatLng(fixedLatLng); this.getWidgetById("airportLat").setValue(fixedLatLng.lat.toFixed(5)); this.getWidgetById("airportLng").setValue(fixedLatLng.lng.toFixed(5)); this.connectToAirport(); @@ -293,7 +314,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot let popup = document.createElement("div"); L.ALS.Locales.localizeElement(popup, "airportForLayer", "innerText"); popup.innerText += " " + this.getName(); - this._airportMarker.bindPopup(popup); + this.airportMarker.bindPopup(popup); }, connectToAirport: function () { @@ -316,27 +337,36 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot this.connectHull(); }, - _createPathWidget: function (layer, length, toFlash) { + _createPathWidget: function (object, length, toFlash, selectedArea = 0) { let id = L.ALS.Helpers.generateID(), button = new L.ALS.Widgets.Button("flashPath" + id, "flashPath", this, "flashPath"), lengthWidget = new L.ALS.Widgets.ValueLabel("pathLength" + id, "pathLength", "m").setFormatNumbers(true).setNumberOfDigitsAfterPoint(0), timeWidget = new L.ALS.Widgets.ValueLabel("flightTime" + id, "flightTime", "h:mm"), - warning = new L.ALS.Widgets.SimpleLabel("warning" + id, "", "center", "warning"); + warning = new L.ALS.Widgets.SimpleLabel("warning" + id, "", "left", "warning"); - layer.updateWidgets = (length) => { + object.updateWidgets = (length) => { lengthWidget.setValue(length); let time = this.getFlightTime(length); timeWidget.setValue(time.formatted); warning.setValue(time.number > 4 ? "flightTimeWarning" : ""); } + this.pathsDetailsSpoiler.addWidgets( new L.ALS.Widgets.SimpleLabel("pathLabel" + id, `${L.ALS.locale.pathTitle} ${this._pathsWidgetsNumber}`, "center", "message"), - button, lengthWidget, timeWidget, warning, + button ); + if (selectedArea) { + this.pathsDetailsSpoiler.addWidget( + new L.ALS.Widgets.ValueLabel("selectedArea" + id, "selectedArea", "sq.m.").setNumberOfDigitsAfterPoint(0).setFormatNumbers(true).setValue(selectedArea) + ); + } + + this.pathsDetailsSpoiler.addWidgets(lengthWidget, timeWidget, warning); + button.toFlash = toFlash; this._pathsWidgetsNumber++; - layer.updateWidgets(length); + object.updateWidgets(length); }, /** @@ -366,7 +396,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * @return {number} Line length */ getLineLengthMeters: function (line, useFlightHeight = true) { - let r = this._getEarthRadius(useFlightHeight), points = line instanceof Array ? line : line.getLatLngs(), distance = 0; + let r = this.getEarthRadius(useFlightHeight), points = line instanceof Array ? line : line.getLatLngs(), distance = 0; if (points.length === 0) return 0; @@ -395,7 +425,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * @return {number} By how much you should modify (add or remove to) lng or lat to get a line of given length */ getArcAngleByLength: function (startingPoint, length, isVertical, useFlightHeight = false) { - let r = this._getEarthRadius(useFlightHeight); + let r = this.getEarthRadius(useFlightHeight); // For vertical lines, we can simply use arc length since any two equal angles will form two equal arcs along any meridian. if (isVertical) @@ -411,7 +441,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot return turfHelpers.radiansToDegrees(length / newR); }, - _getEarthRadius: function (useFlightHeight = false) { + getEarthRadius: function (useFlightHeight = false) { return 6378137 + (useFlightHeight ? this.flightHeight : 0); }, @@ -428,15 +458,14 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * Called when there's one flight per each path. You should call {@link L.ALS.SynthBaseLayer#connectOnePerFlight} here. */ connectOnePerFlightToAirport: function () { - let airportPos = this._airportMarker.getLatLng(); + let airportPos = this.airportMarker.getLatLng(); for (let path of this.paths) { - let layers = path.connectionsGroup.getLayers(); - for (let layer of layers) { + path.connectionsGroup.eachLayer((layer) => { layer.getLatLngs()[1] = airportPos; L.redrawLayer(layer); layer.updateWidgets(layer.pathLength + this.getLineLengthMeters(layer)); - } + }); } }, @@ -449,24 +478,24 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot connectOnePerFlight: function () { for (let i = 0; i < this.paths.length; i++) { - let path = this.paths[i], {connectionsGroup, pathGroup} = path, layers = pathGroup.getLayers(), + let path = this.paths[i], {connectionsGroup, pathGroup} = path, lineOptions = this.getConnectionLineOptions(this.getWidgetById(`color${i}`).getValue()); connectionsGroup.clearLayers(); - for (let layer of layers) { + pathGroup.eachLayer((layer) => { layer.pathLength = this.getPathLength(layer); let latLngs = layer.getLatLngs(), - connectionLine = L.geodesic([latLngs[0], [0, 0], latLngs[latLngs.length - 1]], lineOptions); + connectionLine = new L.Geodesic([latLngs[0], [0, 0], latLngs[latLngs.length - 1]], lineOptions); connectionLine.pathLength = layer.pathLength; let toFlash = [layer, connectionLine]; if (layer.actualPaths) toFlash.push(...layer.actualPaths); - this._createPathWidget(connectionLine, 1, toFlash); + this._createPathWidget(connectionLine, 1, toFlash, layer.selectedArea); connectionsGroup.addLayer(connectionLine); - } + }); } this.connectOnePerFlightToAirport(); // So we'll end up with only one place that updates widgets }, @@ -477,18 +506,17 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot * @return {LatLng[][]} Cycles */ onePerFlightToCycles: function (path) { - let layers = path.pathGroup.getLayers(); + let cycles = [], airportPos = this.airportMarker.getLatLng(); - if (layers.length === 0) - return undefined; - - let cycles = [], airportPos = this._airportMarker.getLatLng(); - for (let layer of layers) { + path.pathGroup.eachLayer((layer) => { let latLngs = layer.getLatLngs(), toPush = [airportPos, ...latLngs, airportPos]; toPush.pathLength = layer.pathLength + this.getLineLengthMeters([latLngs[0], airportPos]) + this.getLineLengthMeters([latLngs[latLngs.length - 1], airportPos]); cycles.push(toPush); - } + }) + + if (cycles.length === 0) + return undefined; return cycles; }, @@ -513,9 +541,11 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot this.layerSystem.clickOnMenu(); for (let group of widget.toFlash) { - let layers = group instanceof L.FeatureGroup ? group.getLayers() : [group]; - for (let layer of layers) - this.flashLine(layer); + if (!group instanceof L.FeatureGroup) { + this.flashLine(group); + continue; + } + group.eachLayer(layer => this.flashLine(layer)); } }, @@ -534,7 +564,7 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot }, createCapturePoint: function (coord, color) { - return L.circleMarker(coord, { + return new L.CircleMarker(coord, { radius: this.lineThicknessValue * 2, stroke: false, fillOpacity: 1, @@ -557,9 +587,49 @@ L.ALS.SynthBaseLayer = L.ALS.Layer.extend(/** @lends L.ALS.SynthBaseLayer.protot return hide; }, - clearSerializedPathsWidgets: function (serialized) { - for (let i = 1; i <= this._pathsWidgetsNumber; i++) - delete serialized._widgets["pathWidget" + i]; + /** + * Displays a notification after layers has been edited + * @param invalidLayersMessage {string} Message to display when layers has been invalidated + * @param layersInvalidated {boolean} Whether layers has been invalidated + * @param e {Event|undefined} Received L.Draw event, if present + * @param shouldJustReturn {boolean} If this function should just return instead of displaying a notification + */ + notifyAfterEditing: function (invalidLayersMessage, layersInvalidated, e = undefined, shouldJustReturn = false) { + // The whole thing makes no sense when polygons are invalidated when user edits parameters. + // TODO: Somehow fix this? + + if (!L.ALS.generalSettings.notificationsEnabled || shouldJustReturn) + return; + + let notification = ""; + + if (layersInvalidated) + notification += invalidLayersMessage + "\n\n"; + + if (e && e.type === "draw:editstop") + notification += L.ALS.locale.afterEditingInvalidDEMValues + "\n\n"; + + if (notification !== "") + window.alert(notification + L.ALS.locale.afterEditingToDisableNotifications); + }, + + getObjectToSerializeTo: function (seenObjects) { + let object = L.ALS.Layer.prototype.getObjectToSerializeTo.call(this, seenObjects), + {lat, lng} = this.airportMarker.getLatLng(); + object.airportPos = {lat, lng}; + + delete object._widgets.pathsDetails; + + return object; + }, + + statics: { + deserialize: function (serialized, layerSystem, settings, seenObjects) { + let object = L.ALS.Layer.deserialize(serialized, layerSystem, settings, seenObjects); + object.airportMarker.setLatLng(L.latLng(serialized.airportPos)); + object.addWidget(object.pathsDetailsSpoiler); + return object; + } } }); diff --git a/SynthBase/SynthBaseSettings.js b/SynthBaseLayer/SynthBaseSettings.js similarity index 100% rename from SynthBase/SynthBaseSettings.js rename to SynthBaseLayer/SynthBaseSettings.js diff --git a/SynthBase/calculateParameters.js b/SynthBaseLayer/calculateParameters.js similarity index 87% rename from SynthBase/calculateParameters.js rename to SynthBaseLayer/calculateParameters.js index 5b9799df..889292ab 100644 --- a/SynthBase/calculateParameters.js +++ b/SynthBaseLayer/calculateParameters.js @@ -1,6 +1,6 @@ const turfHelpers = require("@turf/helpers"); -L.ALS.SynthBaseLayer.prototype.calculateParameters = function () { +L.ALS.SynthBaseLayer.prototype.calculateParameters = function (notifyIfLayersSkipped = false) { let parameters = ["cameraWidth", "cameraHeight", "pixelWidth", "focalLength", "imageScale", "overlayBetweenPaths", "overlayBetweenImages", "aircraftSpeed"]; for (let param of parameters) @@ -54,5 +54,8 @@ L.ALS.SynthBaseLayer.prototype.calculateParameters = function () { this.getWidgetById(name).setValue(value); } - this.writeToHistoryDebounced(); + if (this.onEditEndDebounced) + this.onEditEndDebounced(typeof notifyIfLayersSkipped === "boolean" ? notifyIfLayersSkipped : false); + else + this.writeToHistoryDebounced(); } \ No newline at end of file diff --git a/SynthBase/draw.js b/SynthBaseLayer/draw.js similarity index 67% rename from SynthBase/draw.js rename to SynthBaseLayer/draw.js index 5475a145..de62506c 100644 --- a/SynthBase/draw.js +++ b/SynthBaseLayer/draw.js @@ -1,3 +1,5 @@ +const debounce = require("debounce"); + /** * Enables L.Draw on this layer * @param drawControls {Object} L.Draw controls to use @@ -22,6 +24,8 @@ L.ALS.SynthBaseLayer.prototype.enableDraw = function (drawControls, drawingGroup } } + this.onEditEndDebounced = debounce((notifyIfLayersSkipped = false) => this.onEditEnd(undefined, notifyIfLayersSkipped), 300); // Math operations are too slow for immediate update + for (let control of this._drawTypes) this._drawOptions.draw[control] = false; @@ -34,10 +38,33 @@ L.ALS.SynthBaseLayer.prototype.enableDraw = function (drawControls, drawingGroup this.addEventListenerTo(this.map, "draw:created", "onDraw"); this.addEventListenerTo(this.map, "draw:drawstart draw:editstart draw:deletestart", "onEditStart"); this.addEventListenerTo(this.map, "draw:drawstop draw:editstop draw:deletestop", "onEditEnd"); - this.addControl(this.drawControl, "top", "follow-menu"); + let addDrawControl = () => this.addControl(this.drawControl, "top", "follow-menu"); + addDrawControl(); + + document.body.addEventListener("synthflight-locale-changed", () => { + this.removeControl(this.drawControl); + addDrawControl(); + }); } L.ALS.SynthBaseLayer.prototype.onDraw = function (e) { + if (!this.isSelected) + return; + + // Don't add layers of size less than 3x3 px and don't add geodesics with one point + if (e.layer instanceof L.Geodesic && e.layer.getLatLngs().length < 2) + return; + + if (e.layer.getBounds) { + let {_northEast, _southWest} = e.layer.getBounds(), + zoom = this.map.getZoom(), + min = this.map.project(_northEast, zoom), + max = this.map.project(_southWest, zoom); + + if (Math.abs(max.x - min.x) <= 3 && Math.abs(max.y - min.y) <= 3) + return; + } + this._drawingGroup.addLayer(e.layer); let borderColorId, fillColor; diff --git a/SynthBase/toGeoJSON.js b/SynthBaseLayer/toGeoJSON.js similarity index 100% rename from SynthBase/toGeoJSON.js rename to SynthBaseLayer/toGeoJSON.js diff --git a/SynthGeneralSettings.js b/SynthGeneralSettings.js new file mode 100644 index 00000000..353e0044 --- /dev/null +++ b/SynthGeneralSettings.js @@ -0,0 +1,19 @@ +/** + * Whether SynthFlight should notify user when DEM is loaded or map objects are edited + * @type {boolean} + */ +L.ALS.generalSettings.notificationsEnabled = true; + +L.ALS.SynthGeneralSettings = L.ALS.GeneralSettings.extend({ + initialize: function (defaultLocale) { + L.ALS.GeneralSettings.prototype.initialize.call(this, defaultLocale); + this.removeWidget("notify"); + this.addWidget(new L.ALS.Widgets.Checkbox("notify", "generalSettingsDisableAnnoyingNotification", this, "_changeNotifications"), false); + }, + + _changeNotifications: function (widget) { + let value = !widget.getValue(); + L.ALS.generalSettings.notifyWhenLongRunningOperationComplete = value; + L.ALS.generalSettings.notificationsEnabled = value; + } +}) \ No newline at end of file diff --git a/SynthGeometryBaseWizard.js b/SynthGeometryBaseWizard.js new file mode 100644 index 00000000..1f7e79f2 --- /dev/null +++ b/SynthGeometryBaseWizard.js @@ -0,0 +1,172 @@ +const shp = require("shpjs"); + +/** + * Wizard with file reader and Shapefile and GeoJSON parser + * @class + * @extends L.ALS.Wizard + */ +L.ALS.SynthGeometryBaseWizard = L.ALS.Wizard.extend(/** @lends L.ALS.SynthGeometryBaseWizard.prototype */{ + + fileLabel: "geometryFileLabel", + browserNotSupportedLabel: "initialFeaturesBrowserNotSupported", + + initialize: function () { + + L.ALS.Wizard.prototype.initialize.call(this); + if (!window.FileReader) { + this.addWidget(new L.ALS.Widgets.SimpleLabel("lbl", this.browserNotSupportedLabel, "center", "error")); + return; + } + + this.addWidget( + new L.ALS.Widgets.File("file", this.fileLabel) + ); + }, + + statics: { + /** + * Callback to pass to {@link L.ALS.SynthGeometryBaseWizard.getGeoJSON}. + * + * @callback getGeoJSONCallback + * @param {Object|"NoFileSelected"|"NoFeatures"|"InvalidFileType"|"ProjectionNotSupported"} geoJson GeoJSON or an error message + * @param {string|undefined} [fileName=undefined] Name of the loaded file + * @param {boolean|undefined} [isShapefile=undefined] If selected file is shapefile + */ + + /** + * Reads GeoJSON or ShapeFile and calls a callback with the content as GeoJSON and filename. + * + * If an error occurred, the first argument will be an error text, and filename will be `undefined`. + * + * @param wizardResults Wizard results + * @param callback {getGeoJSONCallback} + */ + getGeoJSON: function (wizardResults, callback) { + let file = wizardResults["file"][0], + fileReader = new FileReader(); + + if (!file) { + callback("NoFileSelected"); + return; + } + + // Try to read as shapefile + fileReader.addEventListener("load", (event) => { + shp(event.target.result).then((geoJson) => { + if (geoJson.features.length === 0) { + callback("NoFeatures"); + return; + } + + callback(geoJson, file.name, true); + + }).catch((reason) => { + // If reason is string, proj4js doesn't support file projection + if (typeof reason === "string") { + callback("ProjectionNotSupported"); + return; + } + + // If reading as shapefile fails, try to read as GeoJSON. + // We won't check bounds because we assume GeoJSON being in WGS84. + let fileReader2 = new FileReader(); + fileReader2.addEventListener("load", (event) => { + let json; + + try { + json = JSON.parse(event.target.result); + } catch (e) { + console.log(e); + callback("InvalidFileType"); + return; + } + + callback(json, file.name, false); + }); + fileReader2.readAsText(file); + }); + }); + + try {fileReader.readAsArrayBuffer(file);} + catch (e) {} + }, + + /** + * Adds initial shapefile or GeoJSON file to the {@link L.ALS.SynthPolygonLayer} or {@link L.ALS.SynthLineLayer} and updates layer parameters + * + * @param synthLayer {L.ALS.SynthPolygonLayer|L.ALS.SynthLineLayer} Pass `this` here + * @param wizardResults Wizard results + */ + initializePolygonOrPolylineLayer: function (synthLayer, wizardResults) { + let groupToAdd, layerType, CastTo, + finishLoading = () => { + synthLayer.calculateParameters(true); + synthLayer.isAfterDeserialization = false; + } + + if (!window.FileReader) { + finishLoading(); + return; + } + + if (synthLayer instanceof L.ALS.SynthPolygonLayer) { + groupToAdd = synthLayer.polygonGroup; + layerType = L.Polygon; + CastTo = L.Polygon; + } else { + groupToAdd = synthLayer.drawingGroup; + layerType = L.Polyline; + CastTo = L.Geodesic; + } + + this.getGeoJSON(wizardResults, geoJson => { + switch (geoJson) { + case "NoFileSelected": + finishLoading(); + return; + case "NoFeatures": + window.alert(L.ALS.locale.geometryNoFeatures); + finishLoading(); + return; + case "InvalidFileType": + window.alert(L.ALS.locale.geometryInvalidFile); + finishLoading(); + return; + case "ProjectionNotSupported": + window.alert(L.ALS.locale.geometryProjectionNotSupported); + finishLoading(); + return; + } + + let layersAdded = false; + L.geoJson(geoJson, { + onEachFeature: (feature, layer) => { + if (!(layer instanceof layerType)) + return; + + groupToAdd.addLayer(new CastTo(layer.getLatLngs())); + layersAdded = true; + } + }); + + if(layersAdded) + this.checkGeoJSONBounds(geoJson); + else + window.alert(L.ALS.locale.initialFeaturesNoFeatures); + + finishLoading(); + }); + }, + + checkGeoJSONBounds: function (layer) { + let {_northEast, _southWest} = layer.getBounds(); + if ( + _northEast.lng > 360 || + _northEast.lat > 90 || + _southWest.lng < -360 || + _southWest.lat < -90 + ) + window.alert(L.ALS.locale.geometryOutOfBounds); + }, + } +}) \ No newline at end of file diff --git a/SynthGeometryLayer/SynthGeometryLayer.js b/SynthGeometryLayer/SynthGeometryLayer.js index e5f87aab..9a6a5477 100644 --- a/SynthGeometryLayer/SynthGeometryLayer.js +++ b/SynthGeometryLayer/SynthGeometryLayer.js @@ -1,6 +1,5 @@ require("./SynthGeometryWizard.js"); require("./SynthGeometrySettings.js"); -const shp = require("shpjs"); /** * Layer with geometry from shapefile or GeoJSON @@ -13,7 +12,7 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay isShapeFile: false, writeToHistoryOnInit: false, - init: function (wizardResults, settings) { + init: function (wizardResults, settings, cancelCreation) { this.copySettingsToThis(settings); this.setConstructorArguments(["deserialized"]); @@ -21,64 +20,45 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay return; if (!window.FileReader) { - this._deleteInvalidLayer(L.ALS.locale.geometryBrowserNotSupported); + window.alert(L.ALS.locale.geometryBrowserNotSupported); + cancelCreation(); return; } - let file = wizardResults["geometryFileLabel"][0], fileReader = new FileReader(); + L.ALS.SynthGeometryBaseWizard.getGeoJSON(wizardResults, (geoJson, name) => this._displayFile(geoJson, name)); + }, - if (!file) { - this._deleteInvalidLayer(L.ALS.locale.geometryNoFileSelected); - return; + _displayFile: function (geoJson, fileName, isShapefile) { + if (fileName) + this.setName(fileName); + + switch (geoJson) { + case "NoFileSelected": + this._deleteInvalidLayer(L.ALS.locale.geometryNoFileSelected); + return; + case "NoFeatures": + this._deleteInvalidLayer(L.ALS.locale.geometryNoFeatures); + return; + case "InvalidFileType": + this._deleteInvalidLayer(L.ALS.locale.geometryInvalidFile); + return; + case "ProjectionNotSupported": + this._deleteInvalidLayer(L.ALS.locale.geometryProjectionNotSupported); + return; } - this.setName(file.name); + this.originalGeoJson = geoJson; - // Try to read as shapefile - fileReader.addEventListener("load", (event) => { - this.isShapefile = true; // Will hide unneeded widgets using this - shp(event.target.result).then((geoJson) => { - if (geoJson.features.length === 0) { - this._deleteInvalidLayer(L.ALS.locale.geometryNoFeatures); - return; - } - - this._displayFile(geoJson); - // Check if bounds are valid - let bounds = this._layer.getBounds(); - if (bounds._northEast.lng > 180 || bounds._northEast.lat > 90 || bounds._southWest.lng < -180 || bounds._southWest.lat < -90) - window.alert(L.ALS.locale.geometryOutOfBounds); - - }).catch((reason) => { - console.log(reason); - - // If reading as shapefile fails, try to read as GeoJSON. - // We won't check bounds because we assume GeoJSON being in WGS84. - let fileReader2 = new FileReader(); - fileReader2.addEventListener("load", (event) => { - try {this._displayFile(JSON.parse(event.target.result));} - catch (e) { - console.log(e); - this._deleteInvalidLayer(); - } - }); - fileReader2.readAsText(file); - }); - }); - - try {fileReader.readAsArrayBuffer(file);} - catch (e) {} - }, - - _displayFile: function (geoJson) { - let borderColor = new L.ALS.Widgets.Color("borderColor", "geometryBorderColor", this, "_setColor").setValue(this.borderColor), - fillColor = new L.ALS.Widgets.Color("fillColor", "geometryFillColor", this, "_setColor").setValue(this.fillColor), + let borderColor = new L.ALS.Widgets.Color("borderColor", "geometryBorderColor", this, "setColor").setValue(this.borderColor), + fillColor = new L.ALS.Widgets.Color("fillColor", "geometryFillColor", this, "setColor").setValue(this.fillColor), menu = [borderColor, fillColor], popupOptions = { maxWidth: 500, maxHeight: 500, }; + this.isShapefile = this.isShapefile || isShapefile; + if (this.isShapefile) { let type = geoJson.features[0].geometry.type; if (type === "LineString") @@ -90,21 +70,59 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay for (let widget of menu) this.addWidget(widget); - let docs = [], fields = []; // Search documents + let docs = [], fields = [], clonedLayers = []; // Search documents - this._layer = L.geoJSON(geoJson, { + this._layer = new L.GeoJSON(geoJson, { onEachFeature: (feature, layer) => { - let popup = "", doc = {}; + let popup = "", doc = {}, bbox; // Calculate bbox for zooming - if (!feature.geometry.bbox) { - if (layer.getBounds) { - let bounds = layer.getBounds(); - feature.geometry.bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; - } else { - let latLng = layer.getLatLng(), size = 0.008; - feature.geometry.bbox = [latLng.lng - size, latLng.lat - size, latLng.lng + size, latLng.lat + size]; + if (layer.getBounds) { + let bounds = layer.getBounds(), west = bounds.getWest(), east = bounds.getEast(); + bbox = [west, bounds.getSouth(), east, bounds.getNorth()]; + + // Check if layer crosses one of antimeridians + let moveBy = 0, crossesLeft = west < -180, crossesRight = east > 180; + + if (crossesLeft && crossesRight) + moveBy = 0; + else if (crossesLeft) + moveBy = 360; + else if (crossesRight) + moveBy = -360; + + // Clone layer + if (moveBy) { + // Move bbox + for (let i = 0; i <= 2; i += 2) + bbox += moveBy; + + // Clone coordinates + let latLngs = layer.getLatLngs(), clonedLatLngs = []; + + if (latLngs.length === 0 || latLngs[0] instanceof L.LatLng) + latLngs = [latLngs]; + + for (let array of latLngs) { + let clonedArray = []; + for (let coord of array) { + let clonedCoord = coord.clone(); + clonedCoord.lng += moveBy; + clonedArray.push(clonedCoord); + } + clonedLatLngs.push(clonedArray); + } + + // Create cloned layer + let clonedLayer = layer instanceof L.Polygon ? new L.Polygon(clonedLatLngs) : + new L.Polyline(clonedLatLngs); + layer.clone = clonedLayer; + clonedLayers.push(clonedLayer); } + } else { + let latLng = layer.getLatLng().wrap(), size = 0.008; + layer.setLatLng(latLng); // Wrap points + bbox = [latLng.lng - size, latLng.lat - size, latLng.lng + size, latLng.lat + size]; } // Copy properties to the popup and search doc @@ -121,15 +139,27 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay fields.push(name); } - layer.bindPopup(`
${popup}
`, popupOptions); + if (!popup) + popup = "
No data
"; + + for (let lyr of [layer, layer.clone]) { + if (lyr) + lyr.bindPopup(`
${popup}
`, popupOptions); + } doc._miniSearchId = L.ALS.Helpers.generateID(); - doc.bbox = feature.geometry.bbox; + doc.bbox = bbox; doc.properties = feature.properties; docs.push(doc); } }); + for (let layer of clonedLayers) + this._layer.addLayer(layer); + + // Check if bounds are valid + L.ALS.SynthGeometryBaseWizard.checkGeoJSONBounds(this._layer); + if (L.ALS.searchWindow) L.ALS.searchWindow.addToSearch(this.id, docs, fields); // Add GeoJSON to search this.addLayers(this._layer); @@ -137,28 +167,27 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay this.writeToHistory(); }, - _deleteInvalidLayer: function (message = L.ALS.locale.geometryInvalidFile) { + _deleteInvalidLayer: function (message) { window.alert(message); this.deleteLayer(); }, - _setColor(widget) { + setColor(widget) { this[widget.id] = widget.getValue(); this._setLayerColors(); }, _setLayerColors() { - let layers = this._layer.getLayers(); - for (let layer of layers) { + this._layer.eachLayer((layer) => { if (!layer.setStyle) - continue; + return; layer.setStyle({ color: this.borderColor, fillColor: this.fillColor, fill: layer instanceof L.Polygon }); - } + }); }, onDelete: function () { @@ -175,7 +204,7 @@ L.ALS.SynthGeometryLayer = L.ALS.Layer.extend( /** @lends L.ALS.SynthGeometryLay let json = { widgets: this.serializeWidgets(seenObjects), name: this.getName(), - geoJson: this._layer.toGeoJSON(), + geoJson: this.originalGeoJson, serializationID: this.serializationID }; diff --git a/SynthGeometryLayer/SynthGeometryWizard.js b/SynthGeometryLayer/SynthGeometryWizard.js index c2ce8faf..4734f381 100644 --- a/SynthGeometryLayer/SynthGeometryWizard.js +++ b/SynthGeometryLayer/SynthGeometryWizard.js @@ -1,20 +1,19 @@ /** - * Wizard for SynthShapefileLayer + * Wizard for SynthGeometryLayer * @class * @extends L.ALS.Wizard */ -L.ALS.SynthGeometryWizard = L.ALS.Wizard.extend( /** @lends L.ALS.SynthGeometryWizard.prototype */ { +L.ALS.SynthGeometryWizard = L.ALS.SynthGeometryBaseWizard.extend( /** @lends L.ALS.SynthGeometryWizard.prototype */ { displayName: "geometryDisplayName", + browserNotSupportedLabel: "geometryBrowserNotSupported", initialize: function () { - L.ALS.Wizard.prototype.initialize.call(this); - if (!window.FileReader) { - this.addWidget(new L.ALS.Widgets.SimpleLabel("lbl", "geometryBrowserNotSupported", "center", "error")); + L.ALS.SynthGeometryBaseWizard.prototype.initialize.call(this); + if (!window.FileReader) return; - } - this.addWidgets( - new L.ALS.Widgets.File("geometryFileLabel", "geometryFileLabel"), + + this.addWidget( new L.ALS.Widgets.SimpleLabel("geometryNotification", "geometryNotification", "center", "message") ); } diff --git a/SynthGridLayer/SynthGridLayer.js b/SynthGridLayer/SynthGridLayer.js index 3c27c390..d83cacf2 100644 --- a/SynthGridLayer/SynthGridLayer.js +++ b/SynthGridLayer/SynthGridLayer.js @@ -1,28 +1,64 @@ require("./SynthGridSettings.js"); require("./SynthGridWizard.js"); +const MathTools = require("../MathTools.js"); +const turfHelpers = require("@turf/helpers"); /** * Layer that allows users to plan aerial photography using grid * @class - * @extends L.ALS.Layer + * @extends L.ALS.SynthRectangleBaseLayer */ -L.ALS.SynthGridLayer = L.ALS.SynthPolygonLayer.extend(/** @lends L.ALS.SynthGridLayer.prototype */{ +L.ALS.SynthGridLayer = L.ALS.SynthRectangleBaseLayer.extend(/** @lends L.ALS.SynthGridLayer.prototype */{ defaultName: "Grid Layer", useZoneNumbers: true, borderColorLabel: "gridBorderColor", fillColorLabel: "gridFillColor", + writeToHistoryOnInit: false, - init: function (wizardResults, settings) { + init: function (wizardResults, settings, fn) { + // Verify that distances split map into whole number of segments. If not, ask user if they want to use corrected + // distances. + + // Distances don't divide Earth into the whole number of segments. + // Would you like to use for parallels and for meridians? + let confirmText = L.ALS.locale.gridCorrectDistancesMain1 + "\n\n" + L.ALS.locale.gridCorrectDistancesMain2, + shouldAlert = false; + + for (let name of ["Lat", "Lng"]) { + let wizardDistanceName = `grid${name}Distance`, + userDistance = parseFloat(wizardResults[wizardDistanceName]), + correctedDistance = 360 / Math.round(360 / userDistance); + + if (MathTools.isEqual((360 / userDistance) % 1, 0)) + continue; + + shouldAlert = true; + wizardResults[wizardDistanceName] = correctedDistance; + confirmText += ` ${correctedDistance}° ` + + L.ALS.locale[`gridCorrectDistances${name}`] + ` ${L.ALS.locale.gridCorrectDistancesAnd}`; + } + + if (shouldAlert) { + confirmText = confirmText.substring(0, confirmText.lastIndexOf(" " + L.ALS.locale.gridCorrectDistancesAnd)) + "?" + + "\n\n" + L.ALS.locale.gridCorrectDistancesMain3; + + if (!window.confirm(confirmText)) { + fn(); + return; + } + } /** * Contains polygons' names' IDs * @type {string[]} * @private */ - this._namesIDs = []; + this.gridLabelsIDs = []; + + this.polygons = {}; - L.ALS.SynthPolygonLayer.prototype.init.call(this, wizardResults, settings); + L.ALS.SynthRectangleBaseLayer.prototype.init.call(this, wizardResults, settings); /** * Whether or not cells above 60 lat should be merged @@ -34,26 +70,114 @@ L.ALS.SynthGridLayer = L.ALS.SynthPolygonLayer.extend(/** @lends L.ALS.SynthGrid this.addEventListenerTo(this.map, "moveend resize", "_onMapPan"); }, + calculateParameters: function () { + this._onMapZoom(); + L.ALS.SynthRectangleBaseLayer.prototype.calculateParameters.call(this); + }, + /** - * Selects or deselects polygon upon double click and redraws flight paths - * @param event + * Estimates paths count based on given cell size in degrees + * @param cellSizeDeg + * @returns {number} */ - _selectOrDeselectPolygon: function (event) { - let polygon = event.target, name = this._generatePolygonName(polygon); + estimatePathsCount: function (cellSizeDeg) { + return Math.ceil( + turfHelpers.radiansToLength(turfHelpers.degreesToRadians(cellSizeDeg), "meters") / + this.By + ); + }, + + drawPaths: function () { + this.clearPaths(); + + // Calculate estimated paths count for a polygon. Values are somewhat true for equatorial regions. + // We'll check if it's too small (in near-polar regions, there'll be only one path when value is 2) or too big. + + let errorLabel = this.getWidgetById("calculateParametersError"), + parallelsPathsCount = this.estimatePathsCount(this.lngDistance), + meridiansPathsCount = this.estimatePathsCount(this.latDistance); + + if (parallelsPathsCount === undefined) { + errorLabel.setValue("errorDistanceHasNotBeenCalculated"); + return; + } + + if (parallelsPathsCount >= 20 || meridiansPathsCount >= 20) { + errorLabel.setValue("errorPathsCountTooBig"); + return; + } + + if (parallelsPathsCount <= 2 || meridiansPathsCount <= 2) { + errorLabel.setValue("errorPathsCountTooSmall"); + return; + } + errorLabel.setValue(""); - if (this.polygons[name]) { - polygon.setStyle({fill: false}); - this.removePolygon(polygon); - } else + L.ALS.SynthRectangleBaseLayer.prototype.drawPaths.call(this); + + this.updatePathsMeta(); + }, + + forEachValidPolygon: function (cb) { + for (let id in this.polygons) + cb(this.polygons[id]); + }, + + initPolygon: function (lat, lng, lngDistance) { + let polygon = new L.Rectangle([ + [lat, lng], + [lat + this.latDistance, lng + lngDistance], + ]), + name = this.generatePolygonName(polygon); + + return this.polygons[name] ? this.polygons[name] : this.initPolygonStyleAndEvents(polygon); + }, + + initPolygonStyleAndEvents: function (polygon, isSelected = false) { + let name = this.generatePolygonName(polygon); + + polygon.setStyle({ + color: this.borderColor, + fillColor: this.fillColor, + fill: false, + weight: this.lineThicknessValue + }); + + let select = () => { + polygon.setStyle({fill: true}); + this.polygons[name] = polygon; this.addPolygon(polygon); + } + + polygon.on("dblclick contextmenu", () => { + if (this.polygons[name]) { + polygon.setStyle({fill: false}); + delete this.polygons[name]; + this.removePolygon(polygon); + } else + select(); + + this.calculateParameters(); + this.writeToHistoryDebounced(); + }); + + this.polygonGroup.addLayer(polygon); - this.updateAll(); - this.writeToHistoryDebounced(); + if (isSelected) + select(); + + return polygon; }, - updateAll: function () { - this._onMapZoom(); - L.ALS.SynthPolygonLayer.prototype.updateAll.call(this); + /** + * Generates polygon name for adding into this.polygons + * @param polygon Polygon to generate name for + * @return {string} Name for given polygon + * @protected + */ + generatePolygonName: function (polygon) { + let {lat, lng} = polygon.getBounds().getNorthWest(); + return "p_" + this.toFixed(lat) + "_" + this.toFixed(lng); }, statics: { @@ -64,4 +188,5 @@ L.ALS.SynthGridLayer = L.ALS.SynthPolygonLayer.extend(/** @lends L.ALS.SynthGrid require("./onMapPan.js"); require("./onMapZoom.js"); -require("./mergePolygons.js"); \ No newline at end of file +require("./mergePolygons.js"); +require("./serialization.js"); \ No newline at end of file diff --git a/SynthGridLayer/SynthGridSettings.js b/SynthGridLayer/SynthGridSettings.js index dfd09efc..0b358c26 100644 --- a/SynthGridLayer/SynthGridSettings.js +++ b/SynthGridLayer/SynthGridSettings.js @@ -4,10 +4,10 @@ * @class * @extends L.ALS.Settings */ -L.ALS.SynthGridSettings = L.ALS.SynthPolygonSettings.extend( /** @lends L.ALS.SynthGridSettings.prototype */ { +L.ALS.SynthGridSettings = L.ALS.SynthPolygonBaseSettings.extend( /** @lends L.ALS.SynthGridSettings.prototype */ { initialize: function () { - L.ALS.SynthPolygonSettings.prototype.initialize.call(this); + L.ALS.SynthPolygonBaseSettings.prototype.initialize.call(this); this.addColorWidgets("defaultGridBorderColor", "defaultGridFillColor"); this.addWidget(new L.ALS.Widgets.Number("gridHidingFactor", "gridHidingFactor").setMin(1).setMax(10).setValue(5), 5); } diff --git a/SynthGridLayer/mergePolygons.js b/SynthGridLayer/mergePolygons.js index a19073a0..c9915591 100644 --- a/SynthGridLayer/mergePolygons.js +++ b/SynthGridLayer/mergePolygons.js @@ -2,7 +2,7 @@ const polybool = require("polybooljs"); const MathTools = require("../MathTools.js"); L.ALS.SynthGridLayer.prototype.mergePolygons = function () { - L.ALS.SynthPolygonLayer.prototype.mergePolygons.call(this); + L.ALS.SynthRectangleBaseLayer.prototype.mergePolygons.call(this); // Until there's no adjacent polygons, compare each polygon to each and try to find adjacent one. Then merge it. while (true) { diff --git a/SynthGridLayer/onMapPan.js b/SynthGridLayer/onMapPan.js index 63911f29..1ada8371 100644 --- a/SynthGridLayer/onMapPan.js +++ b/SynthGridLayer/onMapPan.js @@ -10,48 +10,25 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { if (!this.isShown || !this.isDisplayed) return; - this.polygonGroup.clearLayers(); - - for (let id of this._namesIDs) - this.labelsGroup.deleteLabel(id); - this._namesIDs = []; - - // Get viewport bounds - let bounds = this.map.getBounds(); - let topLeft = bounds.getNorthWest(), - topRight = bounds.getNorthEast(), - bottomLeft = bounds.getSouthWest(), - bottomRight = bounds.getSouthEast(); - - // Determine the longest sides of the window - let latFrom, latTo, lngFrom, lngTo; - - if (topLeft.lat > topRight.lat) { - latFrom = bottomLeft.lat; - latTo = topLeft.lat; - } else { - latFrom = bottomRight.lat; - latTo = topRight.lat; - } + this.polygonGroup.eachLayer(polygon => { + if (!polygon.options.fill) + this.polygonGroup.removeLayer(polygon); + }); - if (topRight.lng > bottomRight.lng) { - lngFrom = topLeft.lng; - lngTo = topRight.lng; - } else { - lngFrom = bottomLeft.lng; - lngTo = bottomRight.lng; - } + this.clearLabels("gridLabelsIDs"); - // Calculate correct start and end points for given lat - latFrom = this._closestLess(latFrom, this.latDistance); - latTo = this._closestGreater(latTo, this.latDistance); + // Get viewport bounds and calculate correct start and end coords for lng and lat + let bounds = this.map.getBounds(), north = bounds.getNorth(), west = bounds.getWest(), + lngFrom = this._closestLess(west, this.lngDistance), + lngTo = this._closestGreater(bounds.getEast(), this.lngDistance), + latFrom = this._closestLess(bounds.getSouth(), this.latDistance), + latTo = this._closestGreater(north, this.latDistance); - let mapLatLng = this.map.getBounds().getNorthWest(), - isFirstIteration = true; + let isFirstIteration = true; let createLabel = (latLng, content, origin = "center", colorful = false) => { let id = L.ALS.Helpers.generateID(); - this._namesIDs.push(id); + this.gridLabelsIDs.push(id); this.labelsGroup.addLabel(id, latLng, content, {origin: origin}); if (colorful) this.labelsGroup.setLabelDisplayOptions(id, L.LabelLayer.DefaultDisplayOptions.Success); @@ -61,7 +38,7 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { for (let lat = latFrom; lat <= latTo; lat += this.latDistance) { // From bottom (South) to top (North) let absLat = this.toFixed(lat > 0 ? lat - this.latDistance : lat); - createLabel([lat, mapLatLng.lng], absLat, "leftCenter", true); + createLabel([lat, west], absLat, "leftCenter", true); // Merge sheets when lat exceeds certain value. Implemented as specified by this document: // https://docs.cntd.ru/document/456074853 @@ -75,41 +52,14 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { } let lngDistance = this.lngDistance * mergedSheetsCount; - // Calculate correct start and end points for given lng - lngFrom = this._closestLess(lngFrom, lngDistance) - lngTo = this._closestGreater(lngTo, lngDistance); - for (let lng = lngFrom; lng <= lngTo; lng += lngDistance) { // From left (West) to right (East) if (lng < -180 || lng > 180 - lngDistance) continue; if (isFirstIteration) - createLabel([mapLatLng.lat, lng], this.toFixed(lng), "topCenter", true); - - let polygon = L.polygon([ - [lat, lng], - [lat + this.latDistance, lng], - [lat + this.latDistance, lng + lngDistance], - [lat, lng + lngDistance], - ]); - - // If this polygon has been selected, we should fill it and replace it in the array. - // Because fill will be changed, we can't keep old polygon, it's easier to just replace it - let name = this._generatePolygonName(polygon); - let isSelected = this.polygons[name] !== undefined; - polygon.setStyle({ - color: this.borderColor, - fillColor: this.fillColor, - fill: isSelected, - weight: this.lineThicknessValue - }); - - // We should select or deselect polygons upon double click - this.addEventListenerTo(polygon, "dblclick contextmenu", "_selectOrDeselectPolygon"); - this.polygonGroup.addLayer(polygon); - - if (isSelected) - this.polygons[name] = polygon; + createLabel([north, lng], this.toFixed(lng), "topCenter", true); + + let polygon = this.initPolygon(lat, lng, lngDistance); // Generate current polygon's name if grid uses one of standard scales if (this._currentStandardScale === Infinity) { @@ -150,7 +100,7 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { let fixedLatScale = this.toFixed(sheetLat); // Truncate sheet sizes to avoid floating point errors. let fixedLngScale = this.toFixed(sheetLng); - // Ok, imagine a ruler. It looks like |...|...|...|. In our case, | is sheet's border. Our point lies between these borders. + // Ok, imagine a ruler which looks like this: |...|...|...|. In our case, | is sheet's border. Our point lies between these borders. // We need to find how much borders we need to reach our point. We do that for both lat and lng. // Here we're finding coordinates of these borders let bottomLat = this.toFixed(this._closestLess(fixedLat, fixedLatScale)); @@ -187,8 +137,6 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { } return toReturn; - //return " | Row: " + row + " Col: " + col; - } if (this._currentStandardScale === 500000) // 1:500 000 @@ -207,9 +155,7 @@ L.ALS.SynthGridLayer.prototype._onMapPan = function () { if (this._currentStandardScale <= 10000) polygonName += "-" + sheetNumber(2, "numbers", 5 / 60, 7.5 / 60); } else if (this._currentStandardScale <= 5000) { - polygonName += "(" - if (this._currentStandardScale <= 5000) - polygonName += sheetNumber(16, this._currentStandardScale === 5000 ? "numbers" : "none", 2 / 6, 3 / 6); + polygonName += " (" + sheetNumber(16, this._currentStandardScale === 5000 ? "numbers" : "none", 2 / 6, 3 / 6); if (this._currentStandardScale === 2000) polygonName += "-" + sheetNumber(3, "alphabet", (1 + 15 / 60) / 60, (1 + 52.5 / 60) / 60).toLowerCase(); polygonName += ")"; diff --git a/SynthGridLayer/serialization.js b/SynthGridLayer/serialization.js new file mode 100644 index 00000000..9ebb97c8 --- /dev/null +++ b/SynthGridLayer/serialization.js @@ -0,0 +1,39 @@ +L.ALS.SynthGridLayer.prototype.serialize = function (seenObjects) { + let serialized = this.getObjectToSerializeTo(seenObjects); + + serialized.polygons = []; + + // Gather selected polygons' coordinates + + for (let id in this.polygons) { + let poly = this.polygons[id]; + + serialized.polygons.push({ + polygon: this.serializeRect(poly), + widget: poly.widgetable.serialize(seenObjects), + }); + } + + return serialized; +} + +L.ALS.SynthGridLayer.deserialize = function (serialized, layerSystem, settings, seenObjects) { + let object = L.ALS.SynthBaseLayer.deserialize(serialized, layerSystem, settings, seenObjects); + object.isAfterDeserialization = true; + + for (let struct of serialized.polygons) { + let {polygon, widget} = struct, + newPoly = new L.Rectangle(polygon), + newWidget = L.ALS.LeafletLayers.WidgetLayer.deserialize(widget, seenObjects); + + newPoly.widgetable = newWidget; + newWidget.polygon = newPoly; + + object.initPolygonStyleAndEvents(newPoly, true); + object.widgetsGroup.addLayer(newWidget); + } + + this.afterDeserialization(object); + object._onMapPan(); + return object; +} \ No newline at end of file diff --git a/SynthLineLayer/SynthLineLayer.js b/SynthLineLayer/SynthLineLayer.js index 98fc0873..e71c606d 100644 --- a/SynthLineLayer/SynthLineLayer.js +++ b/SynthLineLayer/SynthLineLayer.js @@ -15,11 +15,14 @@ L.ALS.SynthLineLayer = L.ALS.SynthBaseLayer.extend(/** @lends L.ALS.SynthLineLay hasYOverlay: false, init: function (wizardResults, settings) { - this.pathsGroup = L.featureGroup(); - this.drawingGroup = L.featureGroup(); - this.connectionsGroup = L.featureGroup(); + this.pathsGroup = new L.FeatureGroup(); + this.drawingGroup = new L.FeatureGroup(); + this.connectionsGroup = new L.FeatureGroup(); + this.errorGroup = new L.FeatureGroup(); + this.pointsGroup = new L.FeatureGroup(); L.ALS.SynthBaseLayer.prototype.init.call(this, settings, this.pathsGroup, this.connectionsGroup, "lineLayerColor"); + this.addLayers(this.errorGroup, this.pointsGroup); this.enableDraw({ polyline: { @@ -38,9 +41,7 @@ L.ALS.SynthLineLayer = L.ALS.SynthBaseLayer.extend(/** @lends L.ALS.SynthLineLay this.addBaseParametersInputSection(); this.addBaseParametersOutputSection(); - - this.pointsGroup = L.featureGroup(); - this.calculateParameters(); + L.ALS.SynthGeometryBaseWizard.initializePolygonOrPolylineLayer(this, wizardResults); }, _hideCapturePoints: function (widget) { @@ -52,58 +53,81 @@ L.ALS.SynthLineLayer = L.ALS.SynthBaseLayer.extend(/** @lends L.ALS.SynthLineLay }, onEditStart: function () { + if (!this.isSelected) + return; + this.map.removeLayer(this.pathsGroup); this.map.removeLayer(this.connectionsGroup); this.map.removeLayer(this.pointsGroup); this.map.addLayer(this.drawingGroup); }, - onEditEnd: function () { + onEditEnd: function (event, notifyIfLayersSkipped = true) { + if (!this.isSelected) + return; + this.pathsGroup.clearLayers(); this.pointsGroup.clearLayers(); + this.errorGroup.clearLayers(); + + let color = this.getWidgetById("color0").getValue(), + lineOptions = {color, thickness: this.lineThicknessValue}, + linesWereInvalidated = false; - let layers = this.drawingGroup.getLayers(), color = this.getWidgetById("color0").getValue(), lineOptions = { - color, thickness: this.lineThicknessValue, segmentsNumber: L.GEODESIC_SEGMENTS, - }; + notifyIfLayersSkipped = typeof notifyIfLayersSkipped === "boolean" ? notifyIfLayersSkipped : true; - for (let layer of layers) { + this.drawingGroup.eachLayer((layer) => { let latLngs = layer.getLatLngs(); for (let i = 1; i < latLngs.length; i++) { - let extendedGeodesic = new L.Geodesic([latLngs[i - 1], latLngs[i]], lineOptions), + let extendedGeodesic = new L.Geodesic([latLngs[i - 1], latLngs[i]], {segmentsNumber: 2}), length = extendedGeodesic.statistics.sphericalLengthMeters, numberOfImages = Math.ceil(length / this.Bx) + 4, - extendBy = (this.Bx * numberOfImages - length) / 2 / length; - extendedGeodesic.changeLength("both", extendBy); - this.pathsGroup.addLayer(extendedGeodesic); + shouldInvalidateLine = numberOfImages > 10000; // Line is way too long for calculated Bx - // Capture points made by constructing a line with segments number equal to the number of images - let points = new L.Geodesic(extendedGeodesic.getLatLngs(), { + // This will throw an error when new length exceeds 180 degrees + try { + extendedGeodesic.changeLength("both", (this.Bx * numberOfImages - length) / 2 / length); + } catch (e) { + shouldInvalidateLine = true; + } + + let displayGeodesic = new L.Geodesic(extendedGeodesic.getLatLngs(), { ...lineOptions, - segmentsNumber: numberOfImages - }).getActualLatLngs()[0]; + segmentsNumber: Math.max(numberOfImages, L.GEODESIC_SEGMENTS) + }); + + if (shouldInvalidateLine) { + displayGeodesic.setStyle({color: "red"}); + this.errorGroup.addLayer(displayGeodesic); + linesWereInvalidated = true; + continue; + } - for (let point of points) - this.pointsGroup.addLayer(this.createCapturePoint([point.lat, point.lng], color)); + this.pathsGroup.addLayer(displayGeodesic); + + // Capture points made by constructing a line with segments number equal to the number of images + let pointsArrays = new L.Geodesic(displayGeodesic.getLatLngs(), { + ...lineOptions, segmentsNumber: numberOfImages + }).getActualLatLngs(); + + for (let array of pointsArrays) { + for (let point of array) + this.pointsGroup.addLayer(this.createCapturePoint(point, color)); + } } - } + }); this.updatePathsMeta(); - if (!this.getWidgetById("hidePathsConnections").getValue()) - this.map.addLayer(this.connectionsGroup); - - if (!this.getWidgetById("hideCapturePoints").getValue()) - this.map.addLayer(this.pointsGroup); + this.hideOrShowLayer(this.getWidgetById("hidePathsConnections").getValue(), this.connectionsGroup); + this.hideOrShowLayer(this.getWidgetById("hideCapturePoints").getValue(), this.pointsGroup); this.map.removeLayer(this.drawingGroup); this.map.addLayer(this.pathsGroup); - this.writeToHistory(); - }, + this.notifyAfterEditing(L.ALS.locale.lineLayersSkipped, linesWereInvalidated, undefined, !notifyIfLayersSkipped); - calculateParameters: function () { - L.ALS.SynthBaseLayer.prototype.calculateParameters.call(this); - this.onEditEnd(); + this.writeToHistoryDebounced(); }, toGeoJSON: function () { @@ -120,12 +144,10 @@ L.ALS.SynthLineLayer = L.ALS.SynthBaseLayer.extend(/** @lends L.ALS.SynthLineLay }, serialize: function (seenObjects) { - let layers = this.drawingGroup.getLayers(), lines = []; - - for (let layer of layers) - lines.push(layer.getLatLngs()); + let serialized = this.getObjectToSerializeTo(seenObjects), + lines = []; - let serialized = this.getObjectToSerializeTo(seenObjects); + this.drawingGroup.eachLayer(layer => lines.push(layer.getLatLngs())); serialized.lines = L.ALS.Serializable.serializeAnyObject(lines, seenObjects); return serialized; }, @@ -135,12 +157,13 @@ L.ALS.SynthLineLayer = L.ALS.SynthBaseLayer.extend(/** @lends L.ALS.SynthLineLay settings: new L.ALS.SynthLineSettings(), deserialize: function (serialized, layerSystem, settings, seenObjects) { - let object = L.ALS.Layer.deserialize(serialized, layerSystem, settings, seenObjects), + let object = L.ALS.SynthBaseLayer.deserialize(serialized, layerSystem, settings, seenObjects), lines = L.ALS.Serializable.deserialize(serialized.lines, seenObjects); for (let line of lines) object.drawingGroup.addLayer(new L.Geodesic(line, object.drawControls.polyline.shapeOptions)); + object.isAfterDeserialization = true; object.onEditEnd(); delete object.lines; diff --git a/SynthLineLayer/SynthLineSettings.js b/SynthLineLayer/SynthLineSettings.js index 9d12dd44..09bee9b7 100644 --- a/SynthLineLayer/SynthLineSettings.js +++ b/SynthLineLayer/SynthLineSettings.js @@ -2,7 +2,7 @@ L.ALS.SynthLineSettings = L.ALS.SynthBaseSettings.extend({ initialize: function () { L.ALS.SynthBaseSettings.prototype.initialize.call(this); - const color = "#ff0000"; + const color = "#d900ff"; this.addWidget(new L.ALS.Widgets.Color("color0", "settingsLineLayerColor").setValue(color), color); } diff --git a/SynthLineLayer/SynthLineWizard.js b/SynthLineLayer/SynthLineWizard.js index 35e42a24..6fdc1ccb 100644 --- a/SynthLineLayer/SynthLineWizard.js +++ b/SynthLineLayer/SynthLineWizard.js @@ -1,3 +1,4 @@ -L.ALS.SynthLineWizard = L.ALS.EmptyWizard.extend({ +L.ALS.SynthLineWizard = L.ALS.SynthGeometryBaseWizard.extend({ displayName: "lineLayerName", + fileLabel: "initialFeaturesFileLabelLine", }); \ No newline at end of file diff --git a/SynthPolygonBaseLayer/DEM.js b/SynthPolygonBaseLayer/DEM.js new file mode 100644 index 00000000..2959d547 --- /dev/null +++ b/SynthPolygonBaseLayer/DEM.js @@ -0,0 +1,185 @@ +// Methods related to DEM loading + +const ESRIGridParser = require("../ESRIGridParser.js"); +const ESRIGridParserWorker = require("../ESRIGridParserWorker.js"); +const proj4 = require("proj4"); +let GeoTIFFParser; +try { + GeoTIFFParser = require("../GeoTIFFParser.js"); +} catch (e) {} +const work = require("webworkify"); + +L.ALS.SynthPolygonBaseLayer.prototype.onDEMLoad = async function (widget) { + let clear = () => { + L.ALS.operationsWindow.removeOperation("dem"); + widget.clearFileArea(); + } + + if (!window.confirm(L.ALS.locale.confirmDEMLoading)) { + clear(); + return; + } + + L.ALS.operationsWindow.addOperation("dem", "loadingDEM"); + await new Promise(resolve => setTimeout(resolve, 0)); + + // For old browsers that doesn't support FileReader + if (!window.FileReader) { + L.ALS.Helpers.readTextFile(widget.input, L.ALS.locale.notGridNotSupported, (grid) => { + let parser = new ESRIGridParser(this); + try { + parser.readChunk(grid); + } catch (e) { + console.log(e); + this.showDEMError([widget.input.files[0].name]); + return; + } + parser.copyStats(); + clear(); + }); + return; + } + + // For normal browsers + let {invalidFiles, invalidProjectionFiles} = await this.onDEMLoadWorker(widget); + clear(); + + if (invalidFiles.length !== 0) + this.showDEMError(invalidFiles, invalidProjectionFiles); +}; + +L.ALS.SynthPolygonBaseLayer.prototype.showDEMError = function (invalidFiles, invalidProjectionFiles = []) { + let errorMessage = L.ALS.locale.DEMError + " " + invalidFiles.join(", "); + + if (invalidProjectionFiles.length !== 0) + errorMessage += "\n\n" + L.ALS.locale.DEMErrorProjFiles + " " + invalidProjectionFiles.join(", "); + + window.alert(errorMessage + "."); +} + +L.ALS.SynthPolygonBaseLayer.prototype._tryProjectionString = function (string) { + try { + proj4(string, "WGS84"); + return true; + } catch (e) { + return false; + } +} + +/** + * Being called upon DEM load + * @param widget {L.ALS.Widgets.File} + */ +L.ALS.SynthPolygonBaseLayer.prototype.onDEMLoadWorker = async function (widget) { + let files = widget.getValue(), + parser = new ESRIGridParser(this), + fileReader = new FileReader(), + supportsWorker = (window.Worker && process.browser), // We're using webworkify which relies on browserify-specific stuff which isn't available in dev environment + invalidFiles = [], invalidProjectionFiles = []; + + for (let file of files) { + let ext = L.ALS.Helpers.getFileExtension(file.name).toLowerCase(); + + let isTiff = (ext === "tif" || ext === "tiff" || ext === "geotif" || ext === "geotiff"), + isGrid = (ext === "asc" || ext === "grd"); + + if (!isTiff && !isGrid) { + if (ext !== "prj" && ext !== "xml") + invalidFiles.push(file.name); + continue; + } + + // Try to find aux or prj file for current file and get projection string from it + let baseName = "", projectionString = ""; + + for (let symbol of file.name) { + if (symbol === ".") + break; + baseName += symbol; + } + + for (let file2 of files) { + let ext2 = L.ALS.Helpers.getFileExtension(file2.name).toLowerCase(), + isPrj = (ext2 === "prj"); + + if ((ext2 !== "xml" && !isPrj) || !file2.name.startsWith(baseName)) + continue; + + // Read file + let text = await new Promise((resolve => { + let fileReader2 = new FileReader(); + fileReader2.addEventListener("loadend", (e) => { + resolve(e.target.result); + }); + fileReader2.readAsText(file2); + })); + + // prj contains only projection string + if (isPrj) { + projectionString = text; + + if (!this._tryProjectionString(projectionString)) { + invalidProjectionFiles.push(file2.name); + projectionString = ""; + } + + break; + } + + // Parse XML + let start = "", end = "", + startIndex = text.indexOf(start) + start.length, + endIndex = text.indexOf(end); + + projectionString = text.substring(startIndex, endIndex); + + if (projectionString.length !== 0 && this._tryProjectionString(projectionString)) + break; + + invalidProjectionFiles.push(file2.name); + projectionString = ""; + } + + if (isTiff) { + if (!GeoTIFFParser) + continue; + try { + let stats = await GeoTIFFParser(file, projectionString, ESRIGridParser.getInitialData(this)); + ESRIGridParser.copyStats(this, stats); + } catch (e) { + console.log(e); + invalidFiles.push(file.name); + } + continue; + } + + if (!supportsWorker) { + await new Promise((resolve) => { + ESRIGridParser.parseFile(file, parser, fileReader, () => resolve()) + }).catch(e => { + console.log(e); + invalidFiles.push(file.name); + }); + continue; + } + + let worker = work(ESRIGridParserWorker); + await new Promise(resolve => { + worker.addEventListener("message", (e) => { + ESRIGridParser.copyStats(this, e.data); + resolve(); + worker.terminate(); + }); + worker.postMessage({ + parserData: ESRIGridParser.getInitialData(this), + projectionString: projectionString, + file: file, + }); + }).catch(e => { + console.log(e); + invalidFiles.push(file.name); + }); + } + + return {invalidFiles, invalidProjectionFiles}; +}; \ No newline at end of file diff --git a/SynthPolygonBaseLayer/SynthPolygonBaseLayer.js b/SynthPolygonBaseLayer/SynthPolygonBaseLayer.js new file mode 100644 index 00000000..3aac58cc --- /dev/null +++ b/SynthPolygonBaseLayer/SynthPolygonBaseLayer.js @@ -0,0 +1,256 @@ +let GeoTIFFParser; +try { + GeoTIFFParser = require("../GeoTIFFParser.js"); +} catch (e) { +} + +/** + * Contains common logic for rectangle and polygon layers + * + * @class + * @extends L.ALS.SynthBaseLayer + */ +L.ALS.SynthPolygonBaseLayer = L.ALS.SynthBaseLayer.extend( /** @lends L.ALS.SynthPolygonBaseLayer.prototype */ { + useZoneNumbers: false, + calculateCellSizeForPolygons: true, + + /** + * Indicates whether the grid is displayed or not. + * @type {boolean} + */ + isDisplayed: true, + + _doHidePolygonWidgets: false, + _doHidePathsNumbers: false, + + init: function ( + settings, + // Path 1 args + path1InternalConnections, + path1ExternalConnections, + path1ActualPathGroup, + pointsGroup1 = undefined, + colorLabel1 = "parallelsColor", + hidePaths1WidgetId = "hidePathsByParallels", + // Path 2 args + path2InternalConnections = undefined, + path2ExternalConnections = undefined, + path2ActualPathGroup = undefined, + pointsGroup2 = undefined, + colorLabel2 = "meridiansColor", + hidePaths2WidgetId = "hidePathsByMeridians", + ) { + this.serializationIgnoreList.push("polygons", "invalidPolygons", "lngDistance", "latDistance", "_currentStandardScale"); + + this.polygonGroup = new L.FeatureGroup(); + this.widgetsGroup = new L.FeatureGroup(); + this.bordersGroup = new L.FeatureGroup(); + this.bordersGroup.thicknessMultiplier = 4; + this.labelsGroup = new L.LabelLayer(false); + + L.ALS.SynthBaseLayer.prototype.init.call(this, settings, + path1InternalConnections, path1ExternalConnections, colorLabel1, [path1ActualPathGroup], + path2InternalConnections, path2ExternalConnections, colorLabel2, [path2ActualPathGroup] + ); + + this.path1.pointsGroup = pointsGroup1; + this.path1.hidePathsWidgetId = hidePaths1WidgetId; + this.path1.actualPathGroup = path1ActualPathGroup; + this.path1.toUpdateColors.push(pointsGroup1); + + if (this.path2) { + this.path2.pointsGroup = pointsGroup2; + this.path2.hidePathsWidgetId = hidePaths2WidgetId; + this.path2.actualPathGroup = path2ActualPathGroup; + this.path2.toUpdateColors.push(pointsGroup2); + } + + this.groupsToHideOnEditStart = [ + path1InternalConnections, path1ExternalConnections, path1ActualPathGroup, pointsGroup1, + path2InternalConnections, path2ExternalConnections, path2ActualPathGroup, pointsGroup2, + this.widgetsGroup, this.labelsGroup, + ]; + + this.addLayers(this.polygonGroup, this.widgetsGroup, this.bordersGroup, pointsGroup1, path1ActualPathGroup, this.labelsGroup); + this.toUpdateThickness.push(this.polygonGroup, this.bordersGroup, pointsGroup1, path1ActualPathGroup); + + if (this.path2) { + this.addLayers(pointsGroup2, path2ActualPathGroup); + this.toUpdateThickness.push(pointsGroup2, path2ActualPathGroup); + } + + this.addWidgets( + new L.ALS.Widgets.Checkbox("hidePolygonWidgets", "hidePolygonWidgets", this, "updateLayersVisibility"), + new L.ALS.Widgets.Checkbox("hideNumbers", "hideNumbers", this, "updateLayersVisibility"), + new L.ALS.Widgets.Checkbox("hideCapturePoints", "hideCapturePoints", this, "updateLayersVisibility").setValue(true), + new L.ALS.Widgets.Checkbox("hidePathsConnections", "hidePathsConnections", this, "updateLayersVisibility"), + new L.ALS.Widgets.Checkbox(this.path1.hidePathsWidgetId, this.path1.hidePathsWidgetId, this, "updateLayersVisibility"), + ); + + if (this.path2) { + this.addWidget( + new L.ALS.Widgets.Checkbox(this.path2.hidePathsWidgetId, this.path2.hidePathsWidgetId, this, "updateLayersVisibility")); + } + + this.addWidgets( + new L.ALS.Widgets.Color("borderColor", this.borderColorLabel, this, "setColor").setValue(this.borderColor), + new L.ALS.Widgets.Color("fillColor", this.fillColorLabel, this, "setColor").setValue(this.fillColor), + ); + + this.addBaseParametersInputSection(); + + let DEMFilesLabel = "DEMFiles"; + if (!GeoTIFFParser) + DEMFilesLabel = "DEMFilesWhenGeoTIFFNotSupported"; + if (L.ALS.Helpers.isIElte9) + DEMFilesLabel = "DEMFilesIE9"; + + this.addWidgets( + new L.ALS.Widgets.File("DEMFiles", DEMFilesLabel, this, "onDEMLoad").setMultiple(true), + new L.ALS.Widgets.Divider("div3"), + ); + + this.addBaseParametersOutputSection(); + }, + + /** + * Calculates grid hiding threshold + * @param settings {SettingsObject} Settings to calculate threshold from + */ + calculateThreshold: function (settings) { + let multiplier = (settings.gridHidingFactor - 5) / 5; // Factor is in range [1..10]. Let's make it [-1...1] + this.minThreshold = 15 + 10 * multiplier; + this.maxThreshold = 60 + 60 * multiplier; + + // If grid will have labels, on lower zoom levels map will become both messy and unusably slow. So we have to set higher hiding threshold. + this.hidingThreshold = this._currentStandardScale === Infinity ? this.minThreshold : this.maxThreshold; + }, + + applyNewSettings: function (settings) { + this.calculateThreshold(settings); + this.calculateParameters(); + }, + + onHide: function () { + for (let path of this.paths) + path.pathGroup.remove(); + }, + + onShow: function () { + this.updateLayersVisibility(); + }, + + onDelete: function () { + if (!this.creationCancelled) + this.onHide(); + }, + + updateLayersVisibility: function () { + let hideCapturePoints = this.getWidgetById("hideCapturePoints").getValue(), + hidePathsConnections = this.getWidgetById("hidePathsConnections").getValue(); + + for (let path of this.paths) { + let hidePaths = this.getWidgetById(path.hidePathsWidgetId).getValue(); + + if (hidePathsConnections) { + path.pathGroup.remove(); + path.connectionsGroup.remove(); + } else { + this.hideOrShowLayer(hidePaths, path.pathGroup); + this.hideOrShowLayer(hidePaths, path.actualPathGroup); + this.hideOrShowLayer(hidePaths, path.connectionsGroup); + } + + if (hideCapturePoints) + path.pointsGroup.remove(); + else + this.hideOrShowLayer(hidePaths, path.pointsGroup); + + this.hideOrShowLayer(hidePaths, path.actualPathGroup); + } + + this._doHidePolygonWidgets = this.getWidgetById("hidePolygonWidgets").getValue(); + this.hideOrShowLayer(this._doHidePolygonWidgets || this._shouldHideEverything, this.widgetsGroup); + this._doHidePathsNumbers = this.getWidgetById("hideNumbers").getValue(); + }, + + setColor: function (widget) { + this[widget.id] = widget.getValue(); + this.calculateParameters(); + this.updatePolygonsColors(); + }, + + updatePolygonsColors: function () { + let color = this.getWidgetById("borderColor").getValue(), + fillColor = this.getWidgetById("fillColor").getValue(); + this.forEachValidPolygon(polygon => polygon.setStyle({color, fillColor})); + }, + + clearPaths: function () { + for (let path of this.paths) { + let groups = [path.pathGroup, path.connectionsGroup, path.pointsGroup, path.actualPathGroup]; + for (let group of groups) + group.clearLayers(); + } + }, + + onEditStart: function () { + if (!this.isSelected) + return; + + for (let group of this.groupsToHideOnEditStart) { + if (group) + this.hideOrShowLayer(true, group); + } + }, + + /** + * Clears labels which IDs are contained in `arrayProperty`. + * @param arrayProperty {string} A property of `this` that contains labels' IDs to clear + */ + clearLabels: function (arrayProperty) { + for (let id of this[arrayProperty]) + this.labelsGroup.deleteLabel(id); + this[arrayProperty] = []; + }, + + /** + * Copies polygons and airport to the array for merging it to collection later + * @returns {Object[]} Array of features to merge + */ + baseFeaturesToGeoJSON: function () { + let jsons = []; + + this.forEachValidPolygon(polygon => { + let polygonJson = polygon.toGeoJSON(), + props = ["polygonName", "minHeight", "maxHeight", "meanHeight", "absoluteHeight", "reliefType", "elevationDifference", "latCellSizeInMeters", "lngCellSizeInMeters"]; + for (let prop of props) { + let value = polygon[prop]; + if (value !== undefined) + polygonJson.properties[prop] = value; + } + polygonJson.properties.name = "Selected cell"; + jsons.push(polygonJson); + }); + + let airport = this.airportMarker.toGeoJSON(); + airport.name = "Airport"; + jsons.push(airport); + + return jsons; + }, + + /** + * Truncates argument to fifth number after point. + * @param n {number} Number to truncate + * @return {number} Truncated number + */ + toFixed: function (n) { + return parseFloat(n.toFixed(5)); + }, + +}); + +require("./DEM.js"); +require("./polygons.js"); +require("./serialization.js"); \ No newline at end of file diff --git a/SynthPolygonBaseLayer/SynthPolygonBaseSettings.js b/SynthPolygonBaseLayer/SynthPolygonBaseSettings.js new file mode 100644 index 00000000..4762a880 --- /dev/null +++ b/SynthPolygonBaseLayer/SynthPolygonBaseSettings.js @@ -0,0 +1,39 @@ +/** + * Settings for SynthRectangleBaseLayer + * + * @class + * @extends L.ALS.Settings + */ +L.ALS.SynthPolygonBaseSettings = L.ALS.SynthBaseSettings.extend( /** @lends L.ALS.SynthPolygonBaseSettings.prototype */ { + + borderColor: "#6495ed", + fillColor: "#6495ed", + meridiansColor: "#ad0000", + parallelsColor: "#007800", + + addColorWidgets: function (borderLabel, fillLabel, useTwoPaths = true) { + this.addWidget( + new L.ALS.Widgets.Color("borderColor", borderLabel).setValue(this.borderColor), + this.borderColor + ); + + this.addWidget( + new L.ALS.Widgets.Color("fillColor", fillLabel).setValue(this.fillColor), + this.fillColor + ); + + this.addWidget( + new L.ALS.Widgets.Color("color0", "defaultParallelsColor").setValue(this.parallelsColor), + this.parallelsColor + ); + + if (!useTwoPaths) + return; + + this.addWidget( + new L.ALS.Widgets.Color("color1", "defaultMeridiansColor").setValue(this.meridiansColor), + this.meridiansColor + ); + } + +}); \ No newline at end of file diff --git a/SynthPolygonBaseLayer/polygons.js b/SynthPolygonBaseLayer/polygons.js new file mode 100644 index 00000000..a5c3d9fb --- /dev/null +++ b/SynthPolygonBaseLayer/polygons.js @@ -0,0 +1,245 @@ +/** + * Serializable button handler for getting mean height from min and max heights + * + * @class + * @extends L.ALS.Serializable + */ +L.ALS.MeanHeightButtonHandler = L.ALS.Serializable.extend( /**@lends L.ALS.MeanHeightButtonHandler.prototype */ { + + initialize: function (widgetable) { + this._widgetable = widgetable; + }, + + handle: function () { + this._widgetable.getWidgetById("meanHeight").setValue( + (this._widgetable.getWidgetById("minHeight").getValue() + this._widgetable.getWidgetById("maxHeight").getValue()) / 2 + ); + } +}) + +L.ALS.SynthPolygonBaseLayer.prototype.addPolygon = function (polygon, alignToCenter = false) { + // Make polygon valid + polygon.setStyle({fill: true}); + polygon.isValid = true; + + // Get anchor and anchor coordinates + let anchorPoint, anchor; + if (alignToCenter) { + anchorPoint = polygon.getBounds().getCenter(); + anchor = "center"; + } else { + anchorPoint = polygon.getLatLngs()[0][1]; + anchor = "topLeft"; + } + + if (polygon.widgetable) { + polygon.widgetable.setLatLng(anchorPoint); + return; + } + + polygon.widgetable = new L.WidgetLayer(anchorPoint, anchor); + polygon.widgetable.polygon = polygon; + + let handler = new L.ALS.MeanHeightButtonHandler(polygon.widgetable); + + if (this.useZoneNumbers) + polygon.widgetable.addWidget(new L.ALS.Widgets.Number("zoneNumber", "zoneNumber", this, "calculatePolygonParameters").setMin(1).setValue(1)); + + polygon.widgetable.addWidgets( + new L.ALS.Widgets.Number("minHeight", "minHeight", this, "calculatePolygonParameters").setMin(1).setValue(1), + new L.ALS.Widgets.Number("maxHeight", "maxHeight", this, "calculatePolygonParameters").setMin(1).setValue(1), + new L.ALS.Widgets.Number("meanHeight", "meanHeight", this, "calculatePolygonParameters").setMin(1).setValue(1), + new L.ALS.Widgets.Button("meanFromMinMax", "meanFromMinMax", handler, "handle"), + new L.ALS.Widgets.ValueLabel("absoluteHeight", "absoluteHeight", "m"), + new L.ALS.Widgets.ValueLabel("elevationDifference", "elevationDifference"), + new L.ALS.Widgets.ValueLabel("reliefType", "reliefType"), + new L.ALS.Widgets.SimpleLabel("error").setStyle("error"), + ); + + if (this.calculateCellSizeForPolygons) { + polygon.widgetable.addWidgets( + new L.ALS.Widgets.ValueLabel("lngCellSizeInMeters", "lngCellSizeInMeters", "m").setNumberOfDigitsAfterPoint(0), + new L.ALS.Widgets.ValueLabel("latCellSizeInMeters", "latCellSizeInMeters", "m").setNumberOfDigitsAfterPoint(0), + ) + } + + let toFormatNumbers = ["absoluteHeight", "elevationDifference", "lngCellSizeInMeters", "latCellSizeInMeters"]; + for (let id of toFormatNumbers) { + let widget = polygon.widgetable.getWidgetById(id); + if (widget) + widget.setFormatNumbers(true); + } + + this.widgetsGroup.addLayer(polygon.widgetable); + + if (polygon.linkedLayer) + polygon.linkedLayer.setStyle(polygon.options); +} + +/** + * Removes polygon, its widget and linked polygon from the map + * @param polygon {L.Layer} Polygon to remove + */ +L.ALS.SynthPolygonBaseLayer.prototype.removePolygon = function (polygon) { + for (let layer of [polygon, polygon.linkedLayer]) { + if (!layer) + continue; + + this.polygonGroup.removeLayer(layer); + if (!layer.widgetable) + continue; + + this._removePolygonWidget(layer); + } +} + +L.ALS.SynthPolygonBaseLayer.prototype.invalidatePolygon = function (polygon) { + for (let layer of [polygon, polygon.linkedLayer]) { + if (!layer) + continue; + + layer.setStyle({color: "red", fillColor: "red"}); + layer.isValid = false; + + this._removePolygonWidget(layer); + } +} + +L.ALS.SynthPolygonBaseLayer.prototype._removePolygonWidget = function (polygon) { + if (!polygon.widgetable) + return; + + this.widgetsGroup.removeLayer(polygon.widgetable); + polygon.widgetable.remove(); + delete polygon.widgetable; +} + +/** + * Removes widgets that are hanging on the map after polygons have been removed + */ +L.ALS.SynthPolygonBaseLayer.prototype.removeLeftoverWidgets = function () { + this.widgetsGroup.eachLayer(layer => { + if (layer.polygon && !this.polygonGroup.hasLayer(layer.polygon)) + this.widgetsGroup.removeLayer(layer); + }); +} + +L.ALS.SynthPolygonBaseLayer.prototype.afterEditEnd = function (invalidLayersMessage, layersInvalidated, e = undefined, shouldJustReturn = false) { + this.notifyAfterEditing(invalidLayersMessage, layersInvalidated, e, shouldJustReturn); + + this.removeLeftoverWidgets(); + this.map.addLayer(this.labelsGroup); // Nothing in the base layer hides or shows it, so it's only hidden in code above + this.updatePolygonsColors(); + this.calculatePolygonParameters(); + this.updatePathsMeta(); + this.updateLayersVisibility(); + this.writeToHistoryDebounced(); +} + +L.ALS.SynthPolygonBaseLayer.prototype.calculatePolygonParameters = function () { + this.forEachValidPolygon(layer => { + + let latLngs = layer.getLatLngs()[0]; + + if (this.calculateCellSizeForPolygons) { + layer.lngCellSizeInMeters = this.getParallelOrMeridianLineLength(latLngs[0], latLngs[1], false); + layer.latCellSizeInMeters = this.getParallelOrMeridianLineLength(latLngs[1], latLngs[2], false); + + layer.widgetable.getWidgetById("lngCellSizeInMeters").setValue(layer.lngCellSizeInMeters); + layer.widgetable.getWidgetById("latCellSizeInMeters").setValue(layer.latCellSizeInMeters); + } + + layer.minHeight = layer.widgetable.getWidgetById("minHeight").getValue(); + layer.maxHeight = layer.widgetable.getWidgetById("maxHeight").getValue(); + + let errorLabel = layer.widgetable.getWidgetById("error"); + if (layer.minHeight > layer.maxHeight) { + errorLabel.setValue("errorMinHeightBiggerThanMaxHeight"); + return; + } + errorLabel.setValue(""); + + layer.meanHeight = layer.widgetable.getWidgetById("meanHeight").getValue(); + layer.absoluteHeight = this["flightHeight"] + layer.meanHeight; + + layer.elevationDifference = (layer.maxHeight - layer.minHeight) / this["flightHeight"]; + layer.reliefType = (layer.elevationDifference >= 0.2) ? "Variable" : "Plain"; + + let names = ["meanHeight", "absoluteHeight", "elevationDifference", "reliefType"]; + for (let name of names) { + let value; + try { + value = this.toFixed(layer[name]); + } catch (e) { + value = layer[name]; + } + layer.widgetable.getWidgetById(name).setValue(value); + } + }) +} + +L.ALS.SynthPolygonBaseLayer.prototype.forEachValidPolygon = function (cb) { + this.polygonGroup.eachLayer(poly => { + if (!poly.isCloned && poly.isValid) + cb(poly); + }) +} + +L.ALS.SynthPolygonBaseLayer.prototype._getRectOrPolyCoords = function (layer) { + if (layer instanceof L.Rectangle) { + let {_northEast, _southWest} = layer.getBounds(); + return [_northEast, _southWest]; + } + return layer.getLatLngs()[0]; +} + +L.ALS.SynthPolygonBaseLayer.prototype._setRectOrPolyCoords = function (layer, coords) { + if (layer instanceof L.Rectangle) + layer.setBounds(coords); + else + layer.setLatLngs(coords); +} + +L.ALS.SynthPolygonBaseLayer.prototype.cloneLayerIfNeeded = function (layer) { + // Clone layers that crosses antimeridians + let bounds = layer.getBounds(), + crossingWest = bounds.getWest() < -180, + crossingEast = bounds.getEast() > 180, + crossingOne = crossingWest || crossingEast, + crossingBoth = crossingEast && crossingWest + + if (!layer.linkedLayer && crossingOne && !crossingBoth) { + let clonedLayer = layer instanceof L.Rectangle ? L.rectangle(bounds) : new L.Polygon([]), + moveTo = crossingWest ? 1 : -1, + setLinkedLatLngs = (editedLayer) => { + let latlngs = this._getRectOrPolyCoords(editedLayer), newLatLngs = []; + + for (let coord of latlngs) + newLatLngs.push([coord.lat, coord.lng + (editedLayer.linkedLayer.isCloned ? 1 : -1) * moveTo * 360]); + + this._setRectOrPolyCoords(editedLayer.linkedLayer, newLatLngs); + } + + clonedLayer.isCloned = true; + clonedLayer.linkedLayer = layer; + layer.linkedLayer = clonedLayer; + this.polygonGroup.addLayer(clonedLayer); + + for (let lyr of [layer, clonedLayer]) { + let editHandler = () => { + setLinkedLatLngs(lyr); + lyr.linkedLayer.editing.updateMarkers(); + } + + lyr.on("editdrag", editHandler); + lyr.editHandler = editHandler; + } + + setLinkedLatLngs(layer); + } else if (layer.linkedLayer && (!crossingOne || crossingBoth)) { + layer.off("editdrag", layer.editHandler); + this.polygonGroup.removeLayer(layer.linkedLayer); + delete layer.editHandler; + delete layer.linkedLayer; + } +} \ No newline at end of file diff --git a/SynthPolygonBaseLayer/serialization.js b/SynthPolygonBaseLayer/serialization.js new file mode 100644 index 00000000..50d66d8d --- /dev/null +++ b/SynthPolygonBaseLayer/serialization.js @@ -0,0 +1,66 @@ +L.ALS.SynthPolygonBaseLayer.prototype.serialize = function (seenObjects) { + let serialized = this.getObjectToSerializeTo(seenObjects); + + serialized.polygons = []; + + // Gather selected polygons' coordinates + + this.polygonGroup.eachLayer(poly => { + if (poly.isCloned) + return; + + let serializedPoly = poly instanceof L.Rectangle ? this.serializeRect(poly) : poly.getLatLngs(), + serializedStruct = {polygon: serializedPoly} + serialized.polygons.push(serializedStruct); + + if (!poly.widgetable) + return; + + serializedStruct.widget = poly.widgetable.serialize(seenObjects); + }); + + return serialized; +} + +L.ALS.SynthPolygonBaseLayer.prototype.serializeRect = function (rect) { + let {_northEast, _southWest} = rect.getBounds(); + return [[_northEast.lat, _northEast.lng], [_southWest.lat, _southWest.lng]]; +} + +L.ALS.SynthPolygonBaseLayer._toUpdateColors = ["borderColor", "fillColor", "color0", "color1"]; + +L.ALS.SynthPolygonBaseLayer.deserialize = function (serialized, layerSystem, settings, seenObjects) { + let object = L.ALS.SynthBaseLayer.deserialize(serialized, layerSystem, settings, seenObjects); + object.isAfterDeserialization = true; + + for (let struct of serialized.polygons) { + let {polygon, widget} = struct, + newPoly = new L[polygon.length === 2 ? "Rectangle" : "Polygon"](polygon); + object.polygonGroup.addLayer(newPoly); + + if (!widget) + continue; + + let newWidget = L.ALS.LeafletLayers.WidgetLayer.deserialize(widget, seenObjects); + + newPoly.widgetable = newWidget; + newWidget.polygon = newPoly; + + object.widgetsGroup.addLayer(newWidget); + } + + this.afterDeserialization(object); + return object; +} + +L.ALS.SynthPolygonBaseLayer.afterDeserialization = function (deserialized) { + for (let color of this._toUpdateColors) { + let widget = deserialized.getWidgetById(color); + if (widget) + deserialized.setColor(widget); + } + + deserialized.setAirportLatLng(); + deserialized.calculateParameters(); + deserialized.updatePolygonsColors(); +} \ No newline at end of file diff --git a/SynthPolygonLayer.js b/SynthPolygonLayer.js deleted file mode 100644 index 749a2823..00000000 --- a/SynthPolygonLayer.js +++ /dev/null @@ -1,10 +0,0 @@ -L.ALS.SynthPolygonLayer = L.ALS.SynthBaseDrawLayer.extend({ - defaultName: "Polygon Layer", - drawControls: { - polygon: { - shapeOptions: { - color: "#ff0000" - } - } - }, -}); \ No newline at end of file diff --git a/SynthPolygonLayer/DEM.js b/SynthPolygonLayer/DEM.js deleted file mode 100644 index b1980aa4..00000000 --- a/SynthPolygonLayer/DEM.js +++ /dev/null @@ -1,142 +0,0 @@ -// Methods related to DEM loading - -const ESRIGridParser = require("../ESRIGridParser.js"); -const ESRIGridParserWorker = require("../ESRIGridParserWorker.js"); -let GeoTIFFParser; -try { - GeoTIFFParser = require("../GeoTIFFParser.js"); -} catch (e) {} -const work = require("webworkify"); - -L.ALS.SynthPolygonLayer.prototype.onDEMLoad = async function (widget) { - let clear = () => { - L.ALS.operationsWindow.removeOperation("dem"); - widget.clearFileArea(); - } - - if (!window.confirm(L.ALS.locale.confirmDEMLoading)) { - clear(); - return; - } - - L.ALS.operationsWindow.addOperation("dem", "loadingDEM"); - await new Promise(resolve => setTimeout(resolve, 0)); - - // For old browsers that doesn't support FileReader - if (!window.FileReader) { - L.ALS.Helpers.readTextFile(widget.input, L.ALS.locale.notGridNotSupported, (grid) => { - let parser = new ESRIGridParser(this); - parser.readChunk(grid); - parser.copyStats(); - clear(); - }); - return; - } - - // For normal browsers - try { - await this.onDEMLoadWorker(widget); - } catch (e) { - console.error(e); - window.alert(L.ALS.locale.DEMError); - } - clear(); - -}; - -/** - * Being called upon DEM load - * @param widget {L.ALS.Widgets.File} - */ -L.ALS.SynthPolygonLayer.prototype.onDEMLoadWorker = async function (widget) { - let files = widget.getValue(); - let parser = new ESRIGridParser(this); - let fileReader = new FileReader(); - // noinspection JSUnresolvedVariable - let supportsWorker = (window.Worker && process.browser); // We're using webworkify which relies on browserify-specific stuff which isn't available in dev environment - - for (let file of files) { - let ext = L.ALS.Helpers.getFileExtension(file.name).toLowerCase(); - - let isTiff = (ext === "tif" || ext === "tiff" || ext === "geotif" || ext === "geotiff"); - let isGrid = (ext === "asc" || ext === "grd"); - - if (!isTiff && !isGrid) - continue; - - // Try to find aux or prj file for current file and get projection string from it - let baseName = this.getFileBaseName(file.name), projectionString = ""; - for (let file2 of files) { - let ext2 = L.ALS.Helpers.getFileExtension(file2.name).toLowerCase(); - let isPrj = (ext2 === "prj"); - if ((ext2 !== "xml" && !isPrj) || !file2.name.startsWith(baseName)) - continue; - - // Read file - let text = await new Promise((resolve => { - let fileReader2 = new FileReader(); - fileReader2.addEventListener("loadend", (e) => { - resolve(e.target.result); - }); - fileReader2.readAsText(file2); - })); - - // prj contains only projection string - if (isPrj) { - projectionString = text; - break; - } - - // Parse XML - let start = "", end = ""; - let startIndex = text.indexOf(start) + start.length; - let endIndex = text.indexOf(end); - if (startIndex === start.length - 1 || endIndex === -1) - continue; // Continue in hope of finding not broken xml or prj file. - projectionString = text.substring(startIndex, endIndex); - break; - } - - if (isTiff) { - if (!GeoTIFFParser) - continue; - let stats = await GeoTIFFParser(file, projectionString, ESRIGridParser.getInitialData(this)); - ESRIGridParser.copyStats(this, stats); - continue; - } - - if (!supportsWorker) { - await new Promise((resolve) => { - ESRIGridParser.parseFile(file, parser, fileReader, () => { - resolve(); - }) - }); - continue; - } - - //let workerFn = isTiff ? GeoTIFFParserWorker : ESRIGridParserWorker; // In case we'll define another parser - let worker = work(ESRIGridParserWorker); - await new Promise(resolve => { - worker.addEventListener("message", (e) => { - ESRIGridParser.copyStats(this, e.data); - resolve(); - worker.terminate(); - }); - worker.postMessage({ - parserData: ESRIGridParser.getInitialData(this), - projectionString: projectionString, - file: file, - }); - }); - } -}; - -L.ALS.SynthPolygonLayer.prototype.getFileBaseName = function (filename) { - let baseName = ""; - for (let symbol of filename) { - if (symbol === ".") - return baseName; - baseName += symbol; - } - return baseName; -}; \ No newline at end of file diff --git a/SynthPolygonLayer/SynthPolygonLayer.js b/SynthPolygonLayer/SynthPolygonLayer.js index d3ce5353..ff8023df 100644 --- a/SynthPolygonLayer/SynthPolygonLayer.js +++ b/SynthPolygonLayer/SynthPolygonLayer.js @@ -1,170 +1,393 @@ -// This file contains class definitions and menu. For other stuff, see other files in this directory. - -const MathTools = require("../MathTools.js"); -const turfHelpers = require("@turf/helpers"); +require("./SynthPolygonWizard.js"); require("./SynthPolygonSettings.js"); -let GeoTIFFParser; -try { - GeoTIFFParser = require("../GeoTIFFParser.js"); -} catch (e) { -} +const MathTools = require("../MathTools.js"); +const proj4 = require("proj4"); +const geojsonMerge = require("@mapbox/geojson-merge"); // Using this since turfHelpers.featureCollection() discards previously defined properties. /** - * Base layer for rectangle-based planning + * Polygon layer * * @class - * @extends L.ALS.SynthBaseLayer + * @extends L.ALS.SynthPolygonBaseLayer */ -L.ALS.SynthPolygonLayer = L.ALS.SynthBaseLayer.extend( /** @lends L.ALS.SynthPolygonLayer.prototype */ { +L.ALS.SynthPolygonLayer = L.ALS.SynthPolygonBaseLayer.extend(/** @lends L.ALS.SynthPolygonLayer.prototype */{ - _currentStandardScale: -1, + calculateCellSizeForPolygons: false, + defaultName: "Polygon Layer", + borderColorLabel: "rectangleBorderColor", + fillColorLabel: "rectangleFillColor", - borderColorLabel: "", - fillColorLabel: "", + init: function (wizardResults, settings) { + this.copySettingsToThis(settings); - useZoneNumbers: false, + /** + * 89 degrees geodesic line length in meters. Gnomonic projection can't display points starting from 90 deg from the center. + * @type {number} + */ + this.maxGnomonicPointDistance = this.getEarthRadius() * 89 * Math.PI / 180; + + this.internalConnections = new L.FeatureGroup(); + this.externalConnections = new L.FeatureGroup(); + this.pathGroup = new L.FeatureGroup(); + this.pointsGroup = new L.FeatureGroup(); + + L.ALS.SynthPolygonBaseLayer.prototype.init.call(this, settings, + this.internalConnections, + this.externalConnections, + this.pathGroup, + this.pointsGroup, + "polygonPathsColor", + "polygonHidePaths", + ); - /** - * Indicates whether the grid is displayed or not. - * @type {boolean} - */ - isDisplayed: true, + this.enableDraw({ + polygon: { + shapeOptions: { + color: "#ff0000", + weight: this.lineThicknessValue + } + }, + + rectangle: { + shapeOptions: { + color: "#ff0000", + weight: this.lineThicknessValue + } + } + }, this.polygonGroup); - _doHidePolygonWidgets: false, - _doHidePathsNumbers: false, + this.calculateThreshold(settings); // Update hiding threshold - init: function (wizardResults, settings) { - this.copySettingsToThis(settings); + L.ALS.SynthGeometryBaseWizard.initializePolygonOrPolylineLayer(this, wizardResults); + }, - this.polygons = {}; - this.polygonsWidgets = {}; - this.serializationIgnoreList.push("polygons", "lngDistance", "latDistance", "_currentStandardScale"); + onEditEnd: function (e, notifyIfLayersSkipped = true) { + if (!this.isSelected) + return; + this.labelsGroup.deleteAllLabels(); + this.clearPaths(); - // To optimize the grid and reduce visual clutter, let's: - // 1. Display only visible polygons. If we'll render the whole thing, user will need from couple of MBs to TBs of RAM. - // 2. Hide grid when it'll contain a lot of polygons and becomes messy - // Additional redrawing actually won't introduce any noticeable delay. + let color = this.getWidgetById("color0").getValue(), + lineOptions = {color, thickness: this.lineThicknessValue, segmentsNumber: L.GEODESIC_SEGMENTS}, + calculationsLineOptions = {segmentsNumber: 2}, + layersWereInvalidated = false; - // Create empty groups containing our stuff. Yeah, I hate copying too, but I want code completion :D + // Build paths for each polygon. - this.polygonGroup = L.featureGroup(); - this.widgetsGroup = L.featureGroup(); - this.bordersGroup = L.featureGroup(); - this.bordersGroup.thicknessMultiplier = 4; - this.latPointsGroup = L.featureGroup(); - this.lngPointsGroup = L.featureGroup(); - this.labelsGroup = new L.LabelLayer(false); + // The heuristics follows an assumption that the shortest path will always be parallel + // to the edge of the polygon. - this.pathsByParallels = L.featureGroup(); - this.parallelsInternalConnections = L.featureGroup(); - this.parallelsExternalConnections = L.featureGroup(); + // To build parallel paths, first, we build a line that is perpendicular to the edge (let's call it directional). + // Then, for each intermediate point (distances between points are equal to By) of the directional line, + // we build a line that is perpendicular to the directional line - a path. - this.pathsByMeridians = L.featureGroup(); - this.meridiansInternalConnections = L.featureGroup(); - this.meridiansExternalConnections = L.featureGroup(); + // Sometimes we can't use an edge (i.e. when polygon is star-shaped) because directional line won't cover the + // whole polygon. To fix that, we'll build paths using convex hull which'll allow us to get rid of two problems: - this.addLayers(this.polygonGroup, this.widgetsGroup, this.bordersGroup, this.latPointsGroup, this.lngPointsGroup, this.labelsGroup, this.pathsByParallels, this.pathsByMeridians); + // 1. Star-shaped polygons. + // 2. Crop the line by polygon to determine correct perpendicular direction. To do so, we'll draw a directional + // line from the center of the edge. I haven't found any other way of determining direction when drawing from + // the first point of the edge, there's just no common properties of polygon's configurations I've tested. - L.ALS.SynthBaseLayer.prototype.init.call(this, settings, - this.parallelsInternalConnections, this.parallelsExternalConnections, "parallelsColor", [this.pathsByParallels], - this.meridiansInternalConnections, this.meridiansExternalConnections, "meridiansColor", [this.pathsByMeridians] - ); + // To work with geodesics as vectors and lines, we'll use gnomonic projection. + // We'll also crop the paths by the hull, so there won't be empty space along the paths. - this.toUpdateThickness.push(this.polygonGroup, this.bordersGroup, this.latPointsGroup, this.lngPointsGroup); + this.polygonGroup.eachLayer(layer => { + // Remove a linked layer when a layer either original or cloned has been removed + if (layer.linkedLayer && !this.polygonGroup.hasLayer(layer.linkedLayer)) { + this.removePolygon(layer); + return; + } - /** - * Contains paths' labels' IDs - * @type {string[]} - * @private - */ - this._pathsLabelsIDs = []; - - let DEMFilesLabel = "DEMFiles"; - if (!GeoTIFFParser) - DEMFilesLabel = "DEMFilesWhenGeoTIFFNotSupported"; - if (L.ALS.Helpers.isIElte9) - DEMFilesLabel = "DEMFilesIE9"; - - this.addWidgets( - new L.ALS.Widgets.Checkbox("hidePolygonWidgets", "hidePolygonWidgets", this, "_updateLayersVisibility"), - new L.ALS.Widgets.Checkbox("hideNumbers", "hideNumbers", this, "_updateLayersVisibility"), - new L.ALS.Widgets.Checkbox("hideCapturePoints", "hideCapturePoints", this, "_updateLayersVisibility").setValue(true), - new L.ALS.Widgets.Checkbox("hidePathsConnections", "hidePathsConnections", this, "_updateLayersVisibility"), - new L.ALS.Widgets.Checkbox("hidePathsByMeridians", "hidePathsByMeridians", this, "_updateLayersVisibility"), - new L.ALS.Widgets.Checkbox("hidePathsByParallels", "hidePathsByParallels", this, "_updateLayersVisibility"), - new L.ALS.Widgets.Color("borderColor", this.borderColorLabel, this, "_setColor").setValue(this.borderColor), - new L.ALS.Widgets.Color("fillColor", this.fillColorLabel, this, "_setColor").setValue(this.fillColor), - ); + // Skip cloned layers + if (layer.isCloned) + return; + + this.cloneLayerIfNeeded(layer); + + // Build projection. The center will be the center of the polygon. It doesn't matter that much, but center + // Allows us to expand polygon size a bit when it's close to the projection's coordinates limits. + let center = layer.getBounds().getCenter(), + proj = proj4("+proj=longlat +ellps=sphere +no_defs", `+proj=gnom +lat_0=${center.lat} +lon_0=${center.lng} +x_0=0 +y_0=0 +ellps=sphere +R=${this.getEarthRadius()} +units=m +no_defs`), + + // Get convex hull and initialize variables for the shortest path + {upper, lower} = this.getConvexHull(L.LatLngUtil.cloneLatLngs(layer.getLatLngs()[0])), + projectedPolygon = [], + minLength = Infinity, shortestPath, shortestPathConnections, shortestPathPoints; + + // Remove same points from the hull + upper.pop(); + lower.pop(); + + // Project the hull + for (let part of [lower, upper]) { + for (let coord of part) { + let point = proj.forward([coord.lng, coord.lat]); + if (!this.isPointValid(point)) { + layersWereInvalidated = true; + this.invalidatePolygon(layer); + return; + } + projectedPolygon.push(point); + } + } - this.addBaseParametersInputSection(); + projectedPolygon.push(projectedPolygon[0]); // Close the polygon + + // For each edge + for (let i = 0; i < projectedPolygon.length - 1; i++) { + // Build a directional line + let edgeP1 = projectedPolygon[i], edgeP2 = projectedPolygon[i + 1], + directionalLine = this.perpendicularLine(edgeP1, edgeP2, projectedPolygon, true), + // Initialize variables for the current path + currentPath = [], currentConnections = [], currentLength = 0, currentPoints = [], + shouldSwapPoints = false, lineAfterPolygonAdded = false; + + // Precalculate paths' count and invalidate layer if this count is too high + if (MathTools.distanceBetweenPoints(...directionalLine) / this.By > 50) { + layersWereInvalidated = true; + this.invalidatePolygon(layer); + return; + } + + // Move along the line by By until we reach the end of the polygon and add an additional line + // or exceed the limit of 300 paths + let deltaBy = 0; + while (true) { + if (lineAfterPolygonAdded) + break; + + // Scale the directional line, so endpoints of scaled line will be the start and end points of the + // line that we'll build a perpendicular (a path) to. The distance between these points is By. + let p1 = this.scaleLine(directionalLine, deltaBy)[1], + p2 = this.scaleLine(directionalLine, deltaBy + this.By)[1], + line = this.perpendicularLine(p1, p2, projectedPolygon); + + if (!line) { + // If it's the first point, use an edge as an intersection. This shouldn't happen but I like to + // be extra careful. + if (deltaBy === 0) + line = [edgeP1, edgeP2]; + else { + // Otherwise, we've passed the polygon and we have to add another line, i.e. move it along + // directional line. To do so, get x and y differences between current path and previous + // path. Then move cloned path by these differences. + let dx = p2[0] - p1[0], + dy = p2[1] - p1[1], + [prevPathP1, prevPathP2] = currentPath[currentPath.length - 1].getLatLngs(), + [p1x, p1y] = proj.forward([prevPathP1.lng, prevPathP1.lat]), + [p2x, p2y] = proj.forward([prevPathP2.lng, prevPathP2.lat]); + p1x += dx; + p2x += dx; + p1y += dy; + p2y += dy; + + line = [[p1x, p1y], [p2x, p2y]]; + lineAfterPolygonAdded = true; + } + } + + // Swap points of each odd path + if (shouldSwapPoints) + line.reverse(); + + shouldSwapPoints = !shouldSwapPoints; + + // Build a path + let path = new L.Geodesic([ + proj.inverse(line[0]).reverse(), + proj.inverse(line[1]).reverse(), + ], calculationsLineOptions), + length = path.statistics.sphericalLengthMeters, + numberOfImages = Math.ceil(length / this.Bx), extendBy; + + if (lineAfterPolygonAdded) { + extendBy = 0; // Don't extend copied line + } else { + numberOfImages += 4; + extendBy = (this.Bx * numberOfImages - length) / 2 / length; + } + + if (numberOfImages > 100) { + layersWereInvalidated = true; + this.invalidatePolygon(layer); + return; + } + + // If current length is already greater than previous, break loop and save some time + if (currentLength + length + length * extendBy >= minLength) { + currentLength = Infinity; + break; + } + + // Try change length to fit new basis. GeodesicLine will throw when new length exceeds 180 degrees. + // In this case, invalidate current polygon. + if (!lineAfterPolygonAdded) { + try { + path.changeLength("both", extendBy); + } catch (e) { + layersWereInvalidated = true; + this.invalidatePolygon(layer); + return; + } + } + + // Push the stuff related to the current paths to the arrays + let pathEndPoints = path.getLatLngs(); + + currentPath.push(path); + currentLength += path.statistics.sphericalLengthMeters; + currentConnections.push(...pathEndPoints); + + // Fill in capture points. + let pointsArrays = new L.Geodesic(pathEndPoints, {segmentsNumber: numberOfImages}).getActualLatLngs(); + + for (let array of pointsArrays) { + for (let point of array) + currentPoints.push(this.createCapturePoint(point, color)); + } + + deltaBy += this.By; + } + + if (currentLength >= minLength) + continue; - this.addWidgets( - new L.ALS.Widgets.File("DEMFiles", DEMFilesLabel, this, "onDEMLoad").setMultiple(true), - new L.ALS.Widgets.Divider("div3"), - new L.ALS.Widgets.ValueLabel("selectedArea", "selectedArea", "sq.m.").setNumberOfDigitsAfterPoint(0).setFormatNumbers(true), - ); + minLength = currentLength; + shortestPath = currentPath; + shortestPathConnections = currentConnections; + shortestPathPoints = currentPoints; + } - this.addBaseParametersOutputSection(); + this.addPolygon(layer, center); - this.lngDistance = parseFloat(wizardResults["gridLngDistance"]); - this.latDistance = parseFloat(wizardResults["gridLatDistance"]); + this.internalConnections.addLayer(new L.Geodesic(shortestPathConnections, { + ...lineOptions, + dashArray: this.dashedLine, + })); - // Determine whether this grid uses standard scale or not - let scale = wizardResults.gridStandardScales; - if (scale && scale !== "Custom") { - let scaleWithoutSpaces = ""; - for (let i = 2; i < scale.length; i++) { - let char = scale[i]; - if (char === " ") - continue; - scaleWithoutSpaces += char; + let number = 1; + for (let path of shortestPath) { + this.pathGroup.addLayer(new L.Geodesic(path.getLatLngs(), lineOptions)); + + // Add numbers + let latLngs = path.getLatLngs(); + + for (let point of latLngs) { + let {lat, lng} = point.wrap(); + this.labelsGroup.addLabel("", [lat, lng], number, L.LabelLayer.DefaultDisplayOptions.Message); + number++; + } } - this._currentStandardScale = parseInt(scaleWithoutSpaces); - this.setName(`${this.defaultName}, ${scale}`); - } else - this._currentStandardScale = Infinity; - this.calculateThreshold(settings); // Update hiding threshold - this.updateAll(); - this.getWidgetById("hideCapturePoints").callCallback(); + for (let marker of shortestPathPoints) + this.pointsGroup.addLayer(marker); + }); + + this.afterEditEnd(L.ALS.locale.polygonLayersSkipped, layersWereInvalidated, e, !notifyIfLayersSkipped); }, - // It overrides parent method, my IDE can't see it - getPathLength: function (layer) { - // Basically, inverse of L.ALS.SynthBaseLayer#getArcAngleByLength - let latLngs = layer instanceof Array ? layer : layer.getLatLngs(), length = 0; + updateLayersVisibility: function () { + L.ALS.SynthPolygonBaseLayer.prototype.updateLayersVisibility.call(this); + this.hideOrShowLayer(this._doHidePathsNumbers || this.getWidgetById("polygonHidePaths").getValue(), this.labelsGroup); + }, - for (let i = 0; i < latLngs.length - 1; i += 2) { - // Path length - let p1 = latLngs[i], p2 = latLngs[i + 1], connP = latLngs[i + 2]; - length += this.getParallelOrMeridianLineLength(p1, p2); + /** + * Builds an "infinite" perpendicular line to the given line defined by p1 and p2 (let's call it reference line). + * Then crops the perpendicular by the given polygon and returns it. + * + * First point of the perpendicular always lies on the reference line. + * + * @param p1 {number[]} First point of the reference line + * @param p2 {number[]} Second point of the reference line + * @param polygon {number[][]} Polygon to crop perpendicular with. + * @param moveToCenter {boolean} If true, perpendicular will be drawn from the center of the reference line. Otherwise, perpendicular will be drawn from p1. + * @returns {number[][]|undefined} Perpendicular or undefined, if line doesn't intersect the polygon. + */ + perpendicularLine: function (p1, p2, polygon, moveToCenter = false) { + let [p1x, p1y] = p1, [p2x, p2y] = p2, + x = p2x - p1x, y = p2y - p1y, + perpX, perpY; + + // Find an orthogonal vector + if (y === 0) { + // Build vertical line for horizontal reference lines + perpX = 0; + perpY = 1000; + } else { + // Build orthogonal vectors for other lines + perpX = 1000; + perpY = -perpX * x / y; + } + + // For each negative and positive directions + let line = []; + for (let sign of [1, -1]) { + // Scale the perpendicular to the maximum distance in gnomonic projection + let [px, py] = this.scaleLine([[0, 0], [sign * perpX, sign * perpY]], this.maxGnomonicPointDistance)[1]; + + // Move perpendicular back + px += p1x; + py += p1y; - // Connection length - if (connP) - length += this.getParallelOrMeridianLineLength(p2, connP); + if (moveToCenter) { + // Move perpendicular by the half of the original vector + px += x / 2; + py += y / 2; + } + + line.push([px, py]); } - return length; + + let clippedLine = MathTools.clipLineByPolygon(line, polygon); + + if (!clippedLine) + return; + + return MathTools.isPointOnLine(clippedLine[0], [p1, p2]) ? clippedLine : clippedLine.reverse(); + }, + + /** + * Scales the line from the first point to the target length + * + * @param line {number[][]} Line to scale + * @param targetLength {number} Target line length in meters + * @returns {number[][]} Scaled line + */ + scaleLine: function (line, targetLength) { + let [p1, p2] = line, [p1x, p1y] = p1, [p2x, p2y] = p2, + dx = p2x - p1x, dy = p2y - p1y, + lengthModifier = targetLength / Math.sqrt(dx ** 2 + dy ** 2); + dx *= lengthModifier; + dy *= lengthModifier; + return [[p1x, p1y], [dx + p1x, dy + p1y]]; }, - getParallelOrMeridianLineLength: function (p1, p2, useFlightHeight = true) { - let r = this._getEarthRadius(useFlightHeight), {x, y} = MathTools.getXYPropertiesForPoint(p1), - p1Y = p1[y], lngDiff = Math.abs(p1[x] - p2[x]); + isPointValid: function (point) { + return Math.sqrt(point[0] ** 2 + point[1] ** 2) <= this.maxGnomonicPointDistance; + }, - // By meridians - if (lngDiff <= MathTools.precision) - return r * turfHelpers.degreesToRadians(Math.abs(p1Y - p2[y])); + toGeoJSON: function () { + let jsons = this.baseFeaturesToGeoJSON(); - // By parallels - let angle = turfHelpers.degreesToRadians(90 - Math.abs(p1Y)); - return turfHelpers.degreesToRadians(lngDiff) * Math.sin(angle) * r; - } + this.pointsGroup.eachLayer(layer => { + let pointsJson = layer.toGeoJSON(); + pointsJson.name = "capturePoint"; + jsons.push(pointsJson); + }); + + let props = {} + for (let param of this.propertiesToExport) + props[param] = this[param]; -}); + jsons.push(L.ALS.SynthBaseLayer.prototype.toGeoJSON.call(this, props)); -require("./DEM.js"); -require("./drawPaths.js"); -require("./misc.js"); -require("./polygons.js"); -require("./serialization.js"); -require("./toGeoJSON.js"); \ No newline at end of file + return geojsonMerge.merge(jsons); + }, + + statics: { + wizard: L.ALS.SynthPolygonWizard, + settings: new L.ALS.SynthPolygonSettings(), + } +}); \ No newline at end of file diff --git a/SynthPolygonLayer/SynthPolygonSettings.js b/SynthPolygonLayer/SynthPolygonSettings.js index c3f0812d..0aa015ce 100644 --- a/SynthPolygonLayer/SynthPolygonSettings.js +++ b/SynthPolygonLayer/SynthPolygonSettings.js @@ -1,36 +1,14 @@ /** - * Settings for SynthPolygonLayer + * Settings for SynthGridLayer * * @class * @extends L.ALS.Settings */ -L.ALS.SynthPolygonSettings = L.ALS.SynthBaseSettings.extend( /** @lends L.ALS.SynthPolygonSettings.prototype */ { +L.ALS.SynthPolygonSettings = L.ALS.SynthPolygonBaseSettings.extend( /** @lends L.ALS.SynthRectangleSettings.prototype */ { - borderColor: "#6495ed", - fillColor: "#6495ed", - meridiansColor: "#ad0000", - parallelsColor: "#007800", - - addColorWidgets: function (borderLabel, fillLabel) { - this.addWidget( - new L.ALS.Widgets.Color("borderColor", borderLabel).setValue(this.borderColor), - this.borderColor - ); - - this.addWidget( - new L.ALS.Widgets.Color("fillColor", fillLabel).setValue(this.fillColor), - this.fillColor - ); - - this.addWidget( - new L.ALS.Widgets.Color("color0", "defaultParallelsColor").setValue(this.parallelsColor), - this.parallelsColor - ); - - this.addWidget( - new L.ALS.Widgets.Color("color1", "defaultMeridiansColor").setValue(this.meridiansColor), - this.meridiansColor - ); + initialize: function () { + L.ALS.SynthPolygonBaseSettings.prototype.initialize.call(this); + this.addColorWidgets("defaultRectangleBorderColor", "defaultRectangleFillColor", false); } }); \ No newline at end of file diff --git a/SynthPolygonLayer/SynthPolygonWizard.js b/SynthPolygonLayer/SynthPolygonWizard.js new file mode 100644 index 00000000..29f1657f --- /dev/null +++ b/SynthPolygonLayer/SynthPolygonWizard.js @@ -0,0 +1,4 @@ +L.ALS.SynthPolygonWizard = L.ALS.SynthGeometryBaseWizard.extend({ + displayName: "polygonLayerName", + fileLabel: "initialFeaturesFileLabelPolygon", +}); \ No newline at end of file diff --git a/SynthPolygonLayer/misc.js b/SynthPolygonLayer/misc.js deleted file mode 100644 index 6e07428f..00000000 --- a/SynthPolygonLayer/misc.js +++ /dev/null @@ -1,151 +0,0 @@ -// Misc methods, event handlers, etc which most likely won't change in future - -const turfHelpers = require("@turf/helpers"); - -L.ALS.SynthPolygonLayer.prototype._setColor = function (widget) { - this[widget.id] = widget.getValue(); - this.updateAll(); -} - -L.ALS.SynthPolygonLayer.prototype.calculateParameters = function () { - L.ALS.SynthBaseLayer.prototype.calculateParameters.call(this); - - // Calculate estimated paths count for a polygon. Values are somewhat true for equatorial regions. - // We'll check if it's too small (in near-polar regions, there'll be only one path when value is 2) or too big. - let latLngs = ["lat", "lng"]; - for (let name of latLngs) { - let cellSize = Math.round(turfHelpers.radiansToLength(turfHelpers.degreesToRadians(this[name + "Distance"]), "meters")); - this[name + "FakePathsCount"] = Math.ceil(cellSize / this.By); - } - - this._calculatePolygonParameters(); -} - -L.ALS.SynthPolygonLayer.prototype._updateLayersVisibility = function () { - let hidePathsByMeridians = this.getWidgetById("hidePathsByMeridians").getValue(), - hidePathsByParallels = this.getWidgetById("hidePathsByParallels").getValue(); - - if (this.getWidgetById("hidePathsConnections").getValue()) { - this.parallelsInternalConnections.remove(); - this.parallelsExternalConnections.remove(); - this.meridiansInternalConnections.remove(); - this.meridiansExternalConnections.remove(); - } else { - this.hideOrShowLayer(hidePathsByParallels, this.parallelsInternalConnections); - this.hideOrShowLayer(hidePathsByParallels, this.parallelsExternalConnections); - this.hideOrShowLayer(hidePathsByMeridians, this.meridiansInternalConnections); - this.hideOrShowLayer(hidePathsByMeridians, this.meridiansExternalConnections); - } - - if (this.getWidgetById("hideCapturePoints").getValue()) { - this.latPointsGroup.remove(); - this.lngPointsGroup.remove(); - } else { - this.hideOrShowLayer(hidePathsByParallels, this.lngPointsGroup); - this.hideOrShowLayer(hidePathsByMeridians, this.latPointsGroup); - } - - this.hideOrShowLayer(hidePathsByParallels, this.pathsByParallels); - this.hideOrShowLayer(hidePathsByMeridians, this.pathsByMeridians); - - this._doHidePolygonWidgets = this.getWidgetById("hidePolygonWidgets").getValue(); - this.hideOrShowLayer(this._doHidePolygonWidgets || this._shouldHideEverything, this.widgetsGroup); - this._doHidePathsNumbers = this.getWidgetById("hideNumbers").getValue(); - this._drawPaths(); // We have to redraw paths both for hiding one of the paths and hiding numbers -} - -/** - * Updates grid by redrawing all polygons, recalculating stuff, etc - */ -L.ALS.SynthPolygonLayer.prototype.updateAll = function () { - // Legacy code, though, this might be used for something else later - this.calculateParameters(); -} - -/** - * Generates polygon name for adding into this.polygons - * @param polygon Polygon to generate name for - * @return {string} Name for given polygon - * @protected - */ -L.ALS.SynthPolygonLayer.prototype._generatePolygonName = function (polygon) { - let firstPoint = polygon.getLatLngs()[0][0]; - return "p_" + this.toFixed(firstPoint.lat) + "_" + this.toFixed(firstPoint.lng); -} - -/** - * Loops over pathsByParallels and pathsByMeridians and calls callback - * @param callback {function(Polyline)} Callback function that accepts polyline (path) - */ -L.ALS.SynthPolygonLayer.prototype.forEachPath = function (callback) { - let groups = ["pathsByParallels", "pathsByMeridians"]; - for (let group of groups) - callback(this[group]); -} - -L.ALS.SynthPolygonLayer.prototype.onHide = function () { - this.forEachPath((path) => { - path.remove(); - }); -} - -L.ALS.SynthPolygonLayer.prototype.onShow = function () { - this._updateLayersVisibility(); -} - -L.ALS.SynthPolygonLayer.prototype.onDelete = function () { - this.onHide(); -} - -/** - * Truncates argument to fifth number after point. - * @param n Number to truncate - * @return {number} Truncated number - */ -L.ALS.SynthPolygonLayer.prototype.toFixed = function (n) { - return parseFloat(n.toFixed(5)); -} - -L.ALS.SynthPolygonLayer.prototype._closestGreater = function (current, divider) { - return Math.ceil(current / divider) * divider; -} - -L.ALS.SynthPolygonLayer.prototype._closestLess = function (current, divider) { - return Math.floor(current / divider) * divider; -} - -L.ALS.SynthPolygonLayer.prototype.applyNewSettings = function (settings) { - this.calculateThreshold(settings); - this.updateAll(); -} - -/** - * Calculates grid hiding threshold - * @param settings {SettingsObject} Settings to calculate threshold from - */ -L.ALS.SynthPolygonLayer.prototype.calculateThreshold = function (settings) { - let multiplier = (settings.gridHidingFactor - 5) / 5; // Factor is in range [1..10]. Let's make it [-1...1] - this.minThreshold = 15 + 10 * multiplier; - this.maxThreshold = 60 + 60 * multiplier; - - // If grid will have labels, on lower zoom levels map will become both messy and unusably slow. So we have to set higher hiding threshold. - this.hidingThreshold = this._currentStandardScale === Infinity ? this.minThreshold : this.maxThreshold; -} - -/** - * Merges selected polygon into one GeoJSON feature. - * @return number[][][] Merged feature - */ -L.ALS.SynthPolygonLayer.prototype.mergePolygons = function () { - // Convert object with polygons to an array and push edges instead of points here - this.mergedPolygons = []; - for (let id in this.polygons) { - let latLngs = this.polygons[id].getLatLngs()[0], poly = []; - for (let p of latLngs) - poly.push([p.lng, p.lat]); - poly.push(poly[0]); // We need to close the polygons to use MathTools stuff - if (this.useZoneNumbers) - poly.zoneNumber = this.polygonsWidgets[id].getWidgetById("zoneNumber").getValue(); - this.mergedPolygons.push(poly); - } -} \ No newline at end of file diff --git a/SynthPolygonLayer/polygons.js b/SynthPolygonLayer/polygons.js deleted file mode 100644 index 1791435e..00000000 --- a/SynthPolygonLayer/polygons.js +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Serializable button handler for getting mean height from min and max heights - * - * @class - * @extends L.ALS.Serializable - */ -L.ALS.MeanHeightButtonHandler = L.ALS.Serializable.extend( /**@lends L.ALS.MeanHeightButtonHandler.prototype */ { - - initialize: function (controlsContainer) { - this._widgetable = controlsContainer; - }, - - handle: function () { - this._widgetable.getWidgetById("meanHeight").setValue( - (this._widgetable.getWidgetById("minHeight").getValue() + this._widgetable.getWidgetById("maxHeight").getValue()) / 2 - ); - } -}) - -L.ALS.SynthPolygonLayer.prototype.addPolygon = function (polygon) { - polygon._intName = this._generatePolygonName(polygon); - - polygon.setStyle({fill: true}); - this.polygons[polygon._intName] = polygon; - - let controlsContainer = new L.WidgetLayer(polygon.getLatLngs()[0][1], "topLeft"), handler = new L.ALS.MeanHeightButtonHandler(controlsContainer); - - if (this.useZoneNumbers) - controlsContainer.addWidget(new L.ALS.Widgets.Number("zoneNumber", "zoneNumber", this, "_calculatePolygonParameters").setMin(1).setValue(1)); - - controlsContainer.addWidgets( - new L.ALS.Widgets.Number("minHeight", "minHeight", this, "_calculatePolygonParameters").setMin(1).setValue(1), - new L.ALS.Widgets.Number("maxHeight", "maxHeight", this, "_calculatePolygonParameters").setMin(1).setValue(1), - new L.ALS.Widgets.Number("meanHeight", "meanHeight", this, "_calculatePolygonParameters").setMin(1).setValue(1), - new L.ALS.Widgets.Button("meanFromMinMax", "meanFromMinMax", handler, "handle"), - new L.ALS.Widgets.ValueLabel("absoluteHeight", "absoluteHeight", "m"), - new L.ALS.Widgets.ValueLabel("elevationDifference", "elevationDifference"), - new L.ALS.Widgets.ValueLabel("reliefType", "reliefType"), - new L.ALS.Widgets.SimpleLabel("error").setStyle("error"), - new L.ALS.Widgets.ValueLabel("lngCellSizeInMeters", "lngCellSizeInMeters", "m").setNumberOfDigitsAfterPoint(0), - new L.ALS.Widgets.ValueLabel("latCellSizeInMeters", "latCellSizeInMeters", "m").setNumberOfDigitsAfterPoint(0), - ); - - let toFormatNumbers = ["absoluteHeight", "elevationDifference", "lngCellSizeInMeters", "latCellSizeInMeters"]; - for (let id of toFormatNumbers) - controlsContainer.getWidgetById(id).setFormatNumbers(true); - - this.polygonsWidgets[polygon._intName] = controlsContainer; - this.widgetsGroup.addLayer(controlsContainer); -} - -L.ALS.SynthPolygonLayer.prototype.removePolygon = function (polygon, removeFromObject = true) { - let name = polygon._intName || this._generatePolygonName(polygon); - if (removeFromObject) - delete this.polygons[name]; - this.widgetsGroup.removeLayer(this.polygonsWidgets[name]); - delete this.polygonsWidgets[name]; -} - -L.ALS.SynthPolygonLayer.prototype._calculatePolygonParameters = function (widget) { - this.selectedArea = 0; - for (let name in this.polygons) { - if (!this.polygons.hasOwnProperty(name)) - continue; - - let layer = this.polygons[name], latLngs = layer.getLatLngs()[0]; - let widgetContainer = this.polygonsWidgets[name]; - - layer.lngCellSizeInMeters = this.getParallelOrMeridianLineLength(latLngs[0], latLngs[1], false); - layer.latCellSizeInMeters = this.getParallelOrMeridianLineLength(latLngs[1], latLngs[2], false); - - this.selectedArea += layer.lngCellSizeInMeters * layer.latCellSizeInMeters; - - widgetContainer.getWidgetById("lngCellSizeInMeters").setValue(layer.lngCellSizeInMeters); - widgetContainer.getWidgetById("latCellSizeInMeters").setValue(layer.latCellSizeInMeters); - - layer.minHeight = widgetContainer.getWidgetById("minHeight").getValue(); - layer.maxHeight = widgetContainer.getWidgetById("maxHeight").getValue(); - - let errorLabel = widgetContainer.getWidgetById("error"); - if (layer.minHeight > layer.maxHeight) { - errorLabel.setValue("errorMinHeightBiggerThanMaxHeight"); - continue; - } - errorLabel.setValue(""); - - layer.meanHeight = widgetContainer.getWidgetById("meanHeight").getValue(); - layer.absoluteHeight = this["flightHeight"] + layer.meanHeight; - - layer.elevationDifference = (layer.maxHeight - layer.minHeight) / this["flightHeight"]; - layer.reliefType = (layer.elevationDifference >= 0.2) ? "Variable" : "Plain"; - - let names = ["meanHeight", "absoluteHeight", "elevationDifference", "reliefType"]; - for (let name of names) { - let value; - try { - value = this.toFixed(layer[name]); - } catch (e) { - value = layer[name]; - } - widgetContainer.getWidgetById(name).setValue(value); - } - } - this.getWidgetById("selectedArea").setValue(this.selectedArea); - - // Draw thick borders around selected polygons - this.mergePolygons(); - this.bordersGroup.clearLayers(); - if (this.mergedPolygons.length === 0) { - this._clearPaths(); - return; - } - - for (let polygon of this.mergedPolygons) { - let latLngs = []; - for (let p of polygon) - latLngs.push([p[1], p[0]]); - - if (!this.useZoneNumbers) - continue; - - this.bordersGroup.addLayer(L.polyline(latLngs, { - weight: this.lineThicknessValue * this.bordersGroup.thicknessMultiplier, - color: this.getWidgetById("borderColor").getValue() - } - )); - } - this._drawPaths(); - - this.writeToHistoryDebounced(); -} \ No newline at end of file diff --git a/SynthPolygonLayer/serialization.js b/SynthPolygonLayer/serialization.js deleted file mode 100644 index 9edcd047..00000000 --- a/SynthPolygonLayer/serialization.js +++ /dev/null @@ -1,43 +0,0 @@ -L.ALS.SynthPolygonLayer.prototype.serialize = function (seenObjects) { - let serialized = this.getObjectToSerializeTo(seenObjects); - - serialized.polygonsWidgets = L.ALS.Serializable.serializeAnyObject(this.polygonsWidgets, seenObjects); - serialized.polygons = {}; - - // Gather selected polygons' coordinates - for (let name in this.polygons) { - if (!this.polygons.hasOwnProperty(name)) - continue; - let poly = this.polygons[name]; - serialized.polygons[name] = poly[poly instanceof L.Rectangle ? "getBounds" : "getLatLngs"](); - } - - this.clearSerializedPathsWidgets(serialized); - return serialized; -} - -L.ALS.SynthPolygonLayer._toUpdateColors = ["borderColor", "fillColor", "color0", "color1"]; - -L.ALS.SynthPolygonLayer.deserialize = function (serialized, layerSystem, settings, seenObjects) { - let object = L.ALS.Layer.deserialize(serialized, layerSystem, settings, seenObjects); - object.isAfterDeserialization = true; - - for (let prop in serialized.polygons) { - let value = serialized.polygons[prop]; - object.polygons[prop] = L[value.serializableClassName === "L.LatLngBounds" ? "rectangle" : "polygon"](value); - } - - for (let prop in object.polygonsWidgets) { - let widget = object.polygonsWidgets[prop]; - if (widget.addTo) - object.widgetsGroup.addLayer(widget); - } - - for (let color of this._toUpdateColors) - object._setColor(object.getWidgetById(color)); - - object.setAirportLatLng(); - object.updateAll(); - - return object; -} \ No newline at end of file diff --git a/SynthPolygonLayer/toGeoJSON.js b/SynthPolygonLayer/toGeoJSON.js deleted file mode 100644 index 7aac2549..00000000 --- a/SynthPolygonLayer/toGeoJSON.js +++ /dev/null @@ -1,47 +0,0 @@ -const geojsonMerge = require("@mapbox/geojson-merge"); // Using this since turfHelpers.featureCollection() discards previously defined properties. - -L.ALS.SynthPolygonLayer.prototype.toGeoJSON = function () { - let jsons = []; - - for (let name in this.polygons) { - if (!this.polygons.hasOwnProperty(name)) - continue; - let polygon = this.polygons[name], - polygonJson = polygon.toGeoJSON(), - props = ["polygonName", "minHeight", "maxHeight", "meanHeight", "absoluteHeight", "reliefType", "elevationDifference", "latCellSizeInMeters", "lngCellSizeInMeters"]; - for (let prop of props) - polygonJson.properties[prop] = polygon[prop]; - polygonJson.properties.name = "Selected cell"; - jsons.push(polygonJson); - } - - let airport = this._airportMarker.toGeoJSON(); - airport.name = "Airport"; - jsons.push(airport); - - if (this.pathsByMeridians.getLayers().length === 0 || this.pathsByParallels.getLayers().length === 0) { - window.alert(`No paths has been drawn in layer "${this.getName()}"! You'll get only selected gird cells and airport position.`); - return geojsonMerge.merge(jsons); - } - - // See _calculateParameters - let parallelsProps = {name: "Flight paths by parallels"}, meridiansProps = {name: "Flight paths by meridians"}; - - for (let prop of [parallelsProps, meridiansProps]) { - for (let param of this.propertiesToExport) - prop[param] = this[param]; - } - - jsons.push(L.ALS.SynthBaseLayer.prototype.toGeoJSON.call(this, parallelsProps, meridiansProps)); - - let pointsParams = [["capturePointsByMeridians", this.latPointsGroup.getLayers()], ["capturePointsByParallels", this.lngPointsGroup.getLayers()]]; - for (let param of pointsParams) { - for (let layer of param[1]) { - let pointsJson = layer.toGeoJSON(); - pointsJson.name = param[0]; - jsons.push(pointsJson); - } - } - - return geojsonMerge.merge(jsons); -} \ No newline at end of file diff --git a/SynthRectangleBaseLayer/SynthRectangleBaseLayer.js b/SynthRectangleBaseLayer/SynthRectangleBaseLayer.js new file mode 100644 index 00000000..a8ffc515 --- /dev/null +++ b/SynthRectangleBaseLayer/SynthRectangleBaseLayer.js @@ -0,0 +1,122 @@ +// This file contains class definitions and menu. For other stuff, see other files in this directory. + +const MathTools = require("../MathTools.js"); +const turfHelpers = require("@turf/helpers"); +require("../SynthPolygonBaseLayer/SynthPolygonBaseSettings.js"); + +/** + * Base layer for rectangle-based planning + * + * @class + * @extends L.ALS.SynthPolygonBaseLayer + */ +L.ALS.SynthRectangleBaseLayer = L.ALS.SynthPolygonBaseLayer.extend( /** @lends L.ALS.SynthRectangleBaseLayer.prototype */ { + + _currentStandardScale: -1, + borderColorLabel: "", + fillColorLabel: "", + + init: function (wizardResults, settings) { + this.copySettingsToThis(settings); + + + // To optimize the grid and reduce visual clutter, let's: + // 1. Display only visible polygons. If we'll render the whole thing, user will need from couple of MBs to TBs of RAM. + // 2. Hide grid when it'll contain a lot of polygons and becomes messy + // Additional redrawing actually won't introduce any noticeable delay. + + // Create empty groups containing our stuff. Yeah, I hate copying too, but I want code completion :D + this.latPointsGroup = new L.FeatureGroup(); + this.lngPointsGroup = new L.FeatureGroup(); + + this.pathsByParallels = new L.FeatureGroup(); + this.parallelsInternalConnections = new L.FeatureGroup(); + this.parallelsExternalConnections = new L.FeatureGroup(); + + this.pathsByMeridians = new L.FeatureGroup(); + this.meridiansInternalConnections = new L.FeatureGroup(); + this.meridiansExternalConnections = new L.FeatureGroup(); + + L.ALS.SynthPolygonBaseLayer.prototype.init.call(this, settings, + // Parallels args + this.parallelsInternalConnections, + this.parallelsExternalConnections, + this.pathsByParallels, + this.lngPointsGroup, + "parallelsColor", + "hidePathsByParallels", + + // Meridians args + this.meridiansInternalConnections, + this.meridiansExternalConnections, + this.pathsByMeridians, + this.latPointsGroup, + "meridiansColor", + "hidePathsByMeridians", + ); + + /** + * Contains paths' labels' IDs + * @type {string[]} + * @private + */ + this.pathsLabelsIds = []; + + this.lngDistance = parseFloat(wizardResults.gridLngDistance); + this.latDistance = parseFloat(wizardResults.gridLatDistance); + + // Determine whether this grid uses standard scale or not + let scale = wizardResults.gridStandardScales; + if (scale && scale !== "Custom") { + let scaleWithoutSpaces = ""; + for (let i = 2; i < scale.length; i++) { + let char = scale[i]; + if (char === " ") + continue; + scaleWithoutSpaces += char; + } + this._currentStandardScale = parseInt(scaleWithoutSpaces); + this.setName(`${this.defaultName}, ${scale}`); + } else + this._currentStandardScale = Infinity; + this.calculateThreshold(settings); // Update hiding threshold + + this.calculateParameters(); + this.getWidgetById("hideCapturePoints").callCallback(); + }, + + // It overrides parent method, my IDE can't see it + getPathLength: function (layer) { + // Basically, inverse of L.ALS.SynthBaseLayer#getArcAngleByLength + let latLngs = layer instanceof Array ? layer : layer.getLatLngs(), length = 0; + + for (let i = 0; i < latLngs.length - 1; i += 2) { + // Path length + let p1 = latLngs[i], p2 = latLngs[i + 1], connP = latLngs[i + 2]; + length += this.getParallelOrMeridianLineLength(p1, p2); + + // Connection length + if (connP) + length += this.getParallelOrMeridianLineLength(p2, connP); + } + return length; + }, + + getParallelOrMeridianLineLength: function (p1, p2, useFlightHeight = true) { + let r = this.getEarthRadius(useFlightHeight), {x, y} = MathTools.getXYPropertiesForPoint(p1), + p1Y = p1[y], lngDiff = Math.abs(p1[x] - p2[x]); + + // By meridians + if (lngDiff <= MathTools.precision) + return r * turfHelpers.degreesToRadians(Math.abs(p1Y - p2[y])); + + // By parallels + let angle = turfHelpers.degreesToRadians(90 - Math.abs(p1Y)); + return turfHelpers.degreesToRadians(lngDiff) * Math.sin(angle) * r; + } + +}); + +require("./drawPaths.js"); +require("./misc.js"); +require("./toGeoJSON.js"); \ No newline at end of file diff --git a/SynthPolygonLayer/drawPaths.js b/SynthRectangleBaseLayer/drawPaths.js similarity index 71% rename from SynthPolygonLayer/drawPaths.js rename to SynthRectangleBaseLayer/drawPaths.js index 69da725a..b31f1597 100644 --- a/SynthPolygonLayer/drawPaths.js +++ b/SynthRectangleBaseLayer/drawPaths.js @@ -1,75 +1,45 @@ const bbox = require("@turf/bbox").default; +const turfArea = require("@turf/area").default; const MathTools = require("../MathTools.js"); const turfHelpers = require("@turf/helpers"); -L.ALS.SynthPolygonLayer.prototype._clearPaths = function () { - let groupsToClear = [this.pathsByParallels, this.pathsByMeridians, this.meridiansExternalConnections, this.meridiansInternalConnections, this.parallelsExternalConnections, this.parallelsInternalConnections, this.latPointsGroup, this.lngPointsGroup]; - for (let group of groupsToClear) - group.clearLayers(); - - for (let id of this._pathsLabelsIDs) - this.labelsGroup.deleteLabel(id); - this._pathsLabelsIDs = []; +L.ALS.SynthRectangleBaseLayer.prototype.clearPaths = function () { + L.ALS.SynthPolygonBaseLayer.prototype.clearPaths.call(this); + this.clearLabels("pathsLabelsIds"); } -L.ALS.SynthPolygonLayer.prototype._drawPaths = function () { - this._clearPaths(); - - // Validate estimated paths count - - let errorLabel = this.getWidgetById("calculateParametersError"), - parallelsPathsCount = this["lngFakePathsCount"], - meridiansPathsCount = this["latFakePathsCount"]; - - if (parallelsPathsCount === undefined) { - errorLabel.setValue("errorDistanceHasNotBeenCalculated"); - return; - } - - if (parallelsPathsCount >= 20 || meridiansPathsCount >= 20) { - errorLabel.setValue("errorPathsCountTooBig"); - return; - } - - if (parallelsPathsCount <= 2 || meridiansPathsCount <= 2) { - errorLabel.setValue("errorPathsCountTooSmall"); - return; - } - errorLabel.setValue(""); - +L.ALS.SynthRectangleBaseLayer.prototype.drawPaths = function () { if (this.mergedPolygons.length === 0) return; - this._drawPathsWorker(true); - this._drawPathsWorker(false); - this.updatePathsMeta(); + this.drawPathsWorker(true); + this.drawPathsWorker(false); this.labelsGroup.redraw(); } /** - * Draws flight paths. Use _drawPaths wrapper to draw paths instead of this. + * Draws flight paths. Use drawPaths wrapper to draw paths instead of this. * @private */ -L.ALS.SynthPolygonLayer.prototype._drawPathsWorker = function (isParallels) { +L.ALS.SynthRectangleBaseLayer.prototype.drawPathsWorker = function (isParallels) { - let pathName, nameForOutput, color, connectionsGroup, widgetId, extensionIndex; + let pathGroup, nameForOutput, color, connectionsGroup, widgetId, extensionIndex; if (isParallels) { - pathName = "pathsByParallels"; + pathGroup = this.pathsByParallels; connectionsGroup = this.parallelsInternalConnections; nameForOutput = "lng"; color = this["color0"]; widgetId = "hidePathsByParallels"; extensionIndex = 0; } else { - pathName = "pathsByMeridians"; + pathGroup = this.pathsByMeridians; connectionsGroup = this.meridiansInternalConnections; nameForOutput = "lat"; color = this["color1"]; widgetId = "hidePathsByMeridians"; extensionIndex = 1; } - let pathGroup = this[pathName], - pointsName = nameForOutput + "PointsGroup", + let pointsName = nameForOutput + "PointsGroup", lineOptions = { color, weight: this.lineThicknessValue @@ -89,10 +59,11 @@ L.ALS.SynthPolygonLayer.prototype._drawPathsWorker = function (isParallels) { lat = startLat, lng = startLng, turfPolygonCoordinates = turfPolygon.geometry.coordinates[0], // MathTools accepts coordinates of the polygon, not polygon itself - number = 1, connectionLine = L.polyline([], connLineOptions), + number = 1, connectionLine = new L.WrappedPolyline([], connLineOptions), prevLine, shouldDraw = true; connectionLine.actualPaths = []; + connectionLine.selectedArea = turfArea(turfPolygon); while (shouldDraw) { let lineCoordinates; @@ -159,7 +130,7 @@ L.ALS.SynthPolygonLayer.prototype._drawPathsWorker = function (isParallels) { secondPoint = endPoint; } - let line = L.polyline([], lineOptions); // Contains paths with turns, i.e. internal connections + let line = new L.WrappedPolyline([], lineOptions); // Contains paths with turns, i.e. internal connections for (let point of [firstPoint, secondPoint]) { // Add points to the path @@ -172,8 +143,8 @@ L.ALS.SynthPolygonLayer.prototype._drawPathsWorker = function (isParallels) { continue; let labelId = L.ALS.Helpers.generateID(); - this._pathsLabelsIDs.push(labelId); - this.labelsGroup.addLabel(labelId, coord, number, L.LabelLayer.DefaultDisplayOptions[isParallels ? "Message" : "Error"]); + this.pathsLabelsIds.push(labelId); + this.labelsGroup.addLabel(labelId, L.latLng(coord).wrap(), number, L.LabelLayer.DefaultDisplayOptions[isParallels ? "Message" : "Error"]); number++; } diff --git a/SynthRectangleBaseLayer/misc.js b/SynthRectangleBaseLayer/misc.js new file mode 100644 index 00000000..c471f4e9 --- /dev/null +++ b/SynthRectangleBaseLayer/misc.js @@ -0,0 +1,73 @@ +// Misc methods, event handlers, etc which most likely won't change in future + +L.ALS.SynthRectangleBaseLayer.prototype.calculateParameters = function (notifyIfLayersSkipped = false) { + L.ALS.SynthBaseLayer.prototype.calculateParameters.call(this, notifyIfLayersSkipped); + + if (!this.onEditEndDebounced) + this.calculatePolygonParameters(); +} + +L.ALS.SynthRectangleBaseLayer.prototype.updateLayersVisibility = function () { + L.ALS.SynthPolygonBaseLayer.prototype.updateLayersVisibility.call(this); + this.drawPaths(); // We have to redraw paths both for hiding one of the paths and hiding numbers +} + +L.ALS.SynthRectangleBaseLayer.prototype._closestGreater = function (current, divider) { + return Math.ceil(current / divider) * divider; +} + +L.ALS.SynthRectangleBaseLayer.prototype._closestLess = function (current, divider) { + return Math.floor(current / divider) * divider; +} + +L.ALS.SynthRectangleBaseLayer.prototype.calculatePolygonParameters = function (widget) { + L.ALS.SynthPolygonBaseLayer.prototype.calculatePolygonParameters.call(this, widget); + + // Draw thick borders around selected polygons + this.mergePolygons(); + this.bordersGroup.clearLayers(); + if (this.mergedPolygons.length === 0) { + this.clearPaths(); + return; + } + + for (let polygon of this.mergedPolygons) { + let latLngs = []; + for (let p of polygon) + latLngs.push([p[1], p[0]]); + + if (!this.useZoneNumbers) + continue; + + this.bordersGroup.addLayer(new L.Polyline(latLngs, { + weight: this.lineThicknessValue * this.bordersGroup.thicknessMultiplier, + color: this.getWidgetById("borderColor").getValue() + } + )); + } + this.drawPaths(); + + this.writeToHistoryDebounced(); +} + +/** + * Merges selected polygon into one GeoJSON feature. + * @return {number[][][]} Merged feature + */ +L.ALS.SynthRectangleBaseLayer.prototype.mergePolygons = function () { + // Convert object with polygons to an array and push edges instead of points here + this.mergedPolygons = []; + + this.forEachValidPolygon(polygon => { + let latLngs = polygon.getLatLngs()[0], poly = []; + for (let p of latLngs) + poly.push([p.lng, p.lat]); + + poly.push(poly[0]); // We need to close the polygons to use MathTools stuff + + if (this.useZoneNumbers) + poly.zoneNumber = polygon.widgetable.getWidgetById("zoneNumber").getValue(); + + this.mergedPolygons.push(poly); + }); +} \ No newline at end of file diff --git a/SynthRectangleBaseLayer/toGeoJSON.js b/SynthRectangleBaseLayer/toGeoJSON.js new file mode 100644 index 00000000..a90c583b --- /dev/null +++ b/SynthRectangleBaseLayer/toGeoJSON.js @@ -0,0 +1,31 @@ +const geojsonMerge = require("@mapbox/geojson-merge"); // Using this since turfHelpers.featureCollection() discards previously defined properties. + +L.ALS.SynthRectangleBaseLayer.prototype.toGeoJSON = function () { + let jsons = this.baseFeaturesToGeoJSON(); + + if (this.pathsByMeridians.getLayers().length === 0 || this.pathsByParallels.getLayers().length === 0) { + window.alert(`${L.ALS.locale.jsonNoPaths1} "${this.getName()}"! ${L.ALS.locale.jsonNoPaths2}`); + return geojsonMerge.merge(jsons); + } + + // See _calculateParameters + let parallelsProps = {name: "Flight paths by parallels"}, meridiansProps = {name: "Flight paths by meridians"}; + + for (let prop of [parallelsProps, meridiansProps]) { + for (let param of this.propertiesToExport) + prop[param] = this[param]; + } + + jsons.push(L.ALS.SynthBaseLayer.prototype.toGeoJSON.call(this, parallelsProps, meridiansProps)); + + let pointsParams = [["capturePointsByMeridians", this.latPointsGroup], ["capturePointsByParallels", this.lngPointsGroup]]; + for (let param of pointsParams) { + param[1].eachLayer((layer) => { + let pointsJson = layer.toGeoJSON(); + pointsJson.name = param[0]; + jsons.push(pointsJson); + }); + } + + return geojsonMerge.merge(jsons); +} \ No newline at end of file diff --git a/SynthRectangleLayer/SynthRectangleLayer.js b/SynthRectangleLayer/SynthRectangleLayer.js index af362cd7..a0199d33 100644 --- a/SynthRectangleLayer/SynthRectangleLayer.js +++ b/SynthRectangleLayer/SynthRectangleLayer.js @@ -1,14 +1,20 @@ require("./SynthRectangleWizard.js"); require("./SynthRectangleSettings.js"); -L.ALS.SynthRectangleLayer = L.ALS.SynthPolygonLayer.extend({ +/** + * Rectangle layer + * + * @class + * @extends L.ALS.SynthRectangleBaseLayer + */ +L.ALS.SynthRectangleLayer = L.ALS.SynthRectangleBaseLayer.extend(/** @lends L.ALS.SynthRectangleLayer.prototype */{ defaultName: "Rectangle Layer", borderColorLabel: "rectangleBorderColor", fillColorLabel: "rectangleFillColor", init: function (wizardResults, settings) { - L.ALS.SynthPolygonLayer.prototype.init.call(this, wizardResults, settings); + L.ALS.SynthRectangleBaseLayer.prototype.init.call(this, wizardResults, settings); this.enableDraw({ rectangle: { @@ -18,70 +24,49 @@ L.ALS.SynthRectangleLayer = L.ALS.SynthPolygonLayer.extend({ } } }, this.polygonGroup); + this.isAfterDeserialization = false; }, - onEditStart: function () { - let groups = ["labelsGroup", "widgetsGroup", "pathsByParallels", "pathsByMeridians", "parallelsInternalConnections", "parallelsExternalConnections", "meridiansInternalConnections", "meridiansExternalConnections", "latPointsGroup", "lngPointsGroup"]; - for (let group of groups) - this.hideOrShowLayer(true, this[group]); - }, - - onEditEnd: function () { - for (let name in this.polygons) - this.removePolygon(this.polygons[name], false); - this.polygons = {} + onEditEnd: function (e, notifyIfLayersSkipped = true) { + if (!this.isSelected) + return; - let layers = this.polygonGroup.getLayers(), layersWereRemoved = false; + this.clearPaths(); - for (let i = 0; i < layers.length; i++) { - let layer = layers[i], bounds = layer.getBounds(), topLeft = bounds.getNorthWest(), - arrTopLeft = [topLeft.lng, topLeft.lat], - lngDiff = Math.abs(bounds.getWest() - bounds.getEast()), - latDiff = Math.abs(bounds.getNorth() - bounds.getSouth()), - lngLength = this.getArcAngleByLength(arrTopLeft, this.By, false), - latLength = this.getArcAngleByLength(arrTopLeft, this.By, true), - lngPathsCount = Math.round(lngDiff / lngLength), latPathsCount = Math.round(latDiff / latLength); + let layersWereInvalidated = false; - // Limit polygon size by limiting total approximate paths count. This is not 100% accurate but close enough. - if (lngPathsCount + latPathsCount > 150) { - layersWereRemoved = true; - this.polygonGroup.removeLayer(layer); - continue; + this.polygonGroup.eachLayer(layer => { + // Remove a linked layer when a layer either original or cloned has been removed + if (layer.linkedLayer && !this.polygonGroup.hasLayer(layer.linkedLayer)) { + this.removePolygon(layer); + return; } - this.addPolygon(layer); - } + // Skip cloned layers + if (layer.isCloned) + return; - if (layersWereRemoved) - window.alert(L.ALS.locale.rectangleLayersRemoved); + this.cloneLayerIfNeeded(layer); - this.map.addLayer(this.labelsGroup); // Nothing in the base layer hides or shows it, so it's only hidden in code above - this._updateLayersVisibility(); - this.updateAll(); - this.writeToHistory(); - }, + // Limit polygon size by limiting total paths count + let bounds = layer.getBounds(), topLeft = bounds.getNorthWest(), + parallelsPathsCount = Math.ceil(this.getParallelOrMeridianLineLength(topLeft, bounds.getSouthWest()) / this.By) + 1, + meridiansPathsCount = Math.ceil(this.getParallelOrMeridianLineLength(topLeft, bounds.getNorthEast()) / this.By) + 1; - _setColor: function (widget) { - L.ALS.SynthPolygonLayer.prototype._setColor.call(this, widget); - this.updateRectanglesColors(); - }, + if (meridiansPathsCount + parallelsPathsCount > 150) { + layersWereInvalidated = true; + this.invalidatePolygon(layer); + return; + } + + this.addPolygon(layer); + }); - updateRectanglesColors: function () { - let color = this.getWidgetById("borderColor").getValue(), - fillColor = this.getWidgetById("fillColor").getValue(); - for (let id in this.polygons) - this.polygons[id].setStyle({color, fillColor}); + this.afterEditEnd(L.ALS.locale.rectangleLayersSkipped, layersWereInvalidated, e, !notifyIfLayersSkipped); }, statics: { wizard: L.ALS.SynthRectangleWizard, settings: new L.ALS.SynthRectangleSettings(), - deserialize: function (serialized, layerSystem, settings, seenObjects) { - let deserialized = L.ALS.SynthPolygonLayer.deserialize(serialized, layerSystem, settings, seenObjects); - for (let id in deserialized.polygons) - deserialized.polygonGroup.addLayer(deserialized.polygons[id]); - deserialized.updateRectanglesColors(); - return deserialized; - } } }); \ No newline at end of file diff --git a/SynthRectangleLayer/SynthRectangleSettings.js b/SynthRectangleLayer/SynthRectangleSettings.js index 26198012..8812a6f6 100644 --- a/SynthRectangleLayer/SynthRectangleSettings.js +++ b/SynthRectangleLayer/SynthRectangleSettings.js @@ -4,10 +4,10 @@ * @class * @extends L.ALS.Settings */ -L.ALS.SynthRectangleSettings = L.ALS.SynthPolygonSettings.extend( /** @lends L.ALS.SynthRectangleSettings.prototype */ { +L.ALS.SynthRectangleSettings = L.ALS.SynthPolygonBaseSettings.extend( /** @lends L.ALS.SynthRectangleSettings.prototype */ { initialize: function () { - L.ALS.SynthPolygonSettings.prototype.initialize.call(this); + L.ALS.SynthPolygonBaseSettings.prototype.initialize.call(this); this.addColorWidgets("defaultRectangleBorderColor", "defaultRectangleFillColor"); } diff --git a/WrappedPolyline.js b/WrappedPolyline.js new file mode 100644 index 00000000..2e99a59a --- /dev/null +++ b/WrappedPolyline.js @@ -0,0 +1,55 @@ +/** + * A class for displaying paths by parallels and meridians. Shouldn't be using for anything else. + * + * @class + * @extends L.Polyline + */ +L.WrappedPolyline = L.Polyline.extend(/** @lends L.WrappedPolyline.prototype */{ + initialize: function (latlngs, options) { + L.Util.setOptions(this, options); + this.setLatLngs(latlngs); + }, + + setLatLngs: function (latlngs) { + this._originalLatLngs = []; + + if (latlngs.length === 0) { + L.Polyline.prototype.setLatLngs.call(this, this._originalLatLngs); + return this; + } + + let segments = [this._originalLatLngs, []], moveBy = 0, isFirstIteration = true; + + for (let segment of segments) { + for (let i = 0; i < latlngs.length; i++) { + let point = L.latLng(latlngs[i]).clone(); + + if (isFirstIteration && !moveBy) { + if (point.lng < -180) + moveBy = 360; + else if (point.lng > 180) + moveBy = -360; + } else if (!isFirstIteration) + point.lng += moveBy; + + segment.push(point); + } + isFirstIteration = false; + } + + L.Polyline.prototype.setLatLngs.call(this, moveBy === 0 ? this._originalLatLngs : segments); + return this; + }, + + addLatLng: function (latLng) { + return this.setLatLngs([...this._originalLatLngs, latLng]); + }, + + getLatLngs: function () { + return this._originalLatLngs; + }, + + toGeoJSON: function (precision) { + return new L.Polyline(this._originalLatLngs).toGeoJSON(precision); + } +}); \ No newline at end of file diff --git a/about.js b/about.js index 54f18dff..70319894 100644 --- a/about.js +++ b/about.js @@ -46,13 +46,15 @@ module.exports = `

LogoSynthFlight ${version}

-

+

-

+

-

+

-

+

+ +

-

Nominatim API

+

Nominatim API

`; \ No newline at end of file diff --git a/css/styles.css b/css/styles.css index 90cf8b2b..f8e8ffc1 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,3 +1,20 @@ +#map { + background: black; +} + +.leaflet-pane.leaflet-mapLabels-pane { + z-index: 600; +} + +.leaflet-pane.leaflet-blackOverlay-pane { + z-index: 500; +} + +.ie-lte-9 .leaflet-pane.leaflet-mapLabels-pane, +.ie-lte-9 .leaflet-pane.leaflet-blackOverlay-pane { + z-index: 400; +} + .leaflet-control-coordinates .uiElement { font-size: 0.75rem !important; margin-top: 0; diff --git a/locales/English.js b/locales/English.js index aae3c7c8..5bfa2bda 100644 --- a/locales/English.js +++ b/locales/English.js @@ -20,21 +20,31 @@ L.ALS.Locales.addLocaleProperties("English", { // SynthLineLayer lineLayerColor: "Line color:", settingsLineLayerColor: "Default line color:", + lineLayersSkipped: "One or more lines has been skipped because they're too long. These lines have red color.", // SynthGridWizard - gridWizardDisplayName: "Grid Layer", gridWizardNotification: `If map scale is too low, grid will be hidden. Please, zoom in to see it. - To select a polygon, either click right mouse button (or tap and hold) or double-click (or double-tap) on it.`, + To select a polygon, either click right mouse button (or tap and hold on sensor display) or double-click (or double-tap) on it.`, gridStandardScales: "Grid scale:", gridLngDistance: "Distance between parallels:", gridLatDistance: "Distance between meridians:", - gridShouldMergeCells: "Merge couples of adjacent cells when latitude exceeds 60° and merge again when it exceeds 76° (except 1:1 000 000 and 1:2 000 scales when cells above 76° triple-merged instead of quadruple-merged)", + gridShouldMergeCells: "Merge adjacent cells when latitude exceeds 60°", // SynthGridLayer + // Confirmation text when distances don't divide map into the whole number of cells + gridCorrectDistancesMain1: "Distances don't divide Earth into the whole number of segments.", + gridCorrectDistancesMain2: "Would you like to use distance in", + gridCorrectDistancesLat: "for parallels", + gridCorrectDistancesAnd: "and", + gridCorrectDistancesLng: "for meridians", + gridCorrectDistancesMain3: "Click \"OK\" to use the suggested distances. Click \"Cancel\" to cancel adding this layer.", + + // Main stuff + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", // Don't add it, if you don't need different symbols in cells' names gridLayerDefaultName: "Grid Layer", hidePolygonWidgets: "Hide widgets on the map", @@ -100,7 +110,11 @@ L.ALS.Locales.addLocaleProperties("English", { confirmDEMLoading: "Are you sure you want to load DEMs? It will override current statistics and take some time.", loadingDEM: "Loading selected DEM files, it might take a while...", notGridNotSupported: "Sorry, your browser doesn't support anything other than ASCII Grid. Please, select a valid ASCII Grid file.", - DEMError: "Sorry, an error occurred while loading one of your files", //TODO: Add file name + DEMError: "Following files are not valid DEM files:", + DEMErrorProjFiles: "Following files are not valid projection files:", + + jsonNoPaths1: "No paths has been drawn in layer", + jsonNoPaths2: "only selected geometry and airport position will be exported.", // SynthGridSettings @@ -117,29 +131,47 @@ L.ALS.Locales.addLocaleProperties("English", { defaultRectangleFillColor: "Default rectangle fill color:", rectangleBorderColor: "Rectangle border color:", rectangleFillColor: "Rectangle fill color:", - rectangleLayersRemoved: "One or more rectangles has been removed because they're too big", + rectangleLayersSkipped: "One or more rectangles has been skipped because they're too big. These rectangles have red color.", // SynthGeometryWizard geometryDisplayName: "Geometry Layer", geometryFileLabel: "Zipped shapefile or GeoJSON:", - geometryNotification: "Click/tap on features to see the semantics", // TODO: Add tip for searching by semantics when search will be added + geometryNotification: "Click/tap on features to see the semantics. You cam search by semantics later by clicking \"Search\" button on the program's panel.", // SynthGeometryLayer geometryOutOfBounds: "Features in selected file are out of visible area. Please, check projection and/or add .prj file to the archive.", - geometryInvalidFile: "This file is not valid zipped shapefile or GeoJSON file", - geometryNoFeatures: "This file doesn't contain any features, so it won't be added", + geometryInvalidFile: "Selected file is not valid zipped shapefile or GeoJSON file", + geometryNoFeatures: "Selected file doesn't contain any features, so it won't be added", geometryBorderColor: "Border color:", geometryFillColor: "Fill color:", - geometryBrowserNotSupported: "Your browser doesn't support adding this layer. You still can open projects with this layer though.", + geometryBrowserNotSupported: "Sorry, your browser doesn't support adding this layer. You still can open projects with this layer though.", geometryNoFileSelected: "No file has been selected. Please, select a file that you want to add and try again.", + geometryProjectionNotSupported: "Sorry, projection of selected file is not supported. Please, convert your file to another projection, preferably, WebMercator.", // SynthGeometrySettings geometryDefaultFillColor: "Default fill color:", geometryDefaultBorderColor: "Default border color:", + // SynthPolygonLayer + polygonLayerName: "Polygon Layer", + polygonPathsColor: "Paths color:", + polygonHidePaths: "Hide paths", + polygonLayersSkipped: "One or more polygons has been skipped because they're too big. These polygons have red color.", + + // Notifications after editing + afterEditingInvalidDEMValues: "Height values might be invalid because map objects has been edited. Please, reload DEM or edit height values manually.", + afterEditingToDisableNotifications: "To disable these notification, go to Settings - General Settings - Disable all annoying notifications after editing and DEM loading.", + generalSettingsDisableAnnoyingNotification: "Disable all annoying notifications after editing and DEM loading", + + // GeoJSON initial features + initialFeaturesBrowserNotSupported: "Sorry, your browser doesn't support loading initial geometry for this layer. You still can draw geometry yourself though.", + initialFeaturesFileLabelPolygon: "Load initial polygons from zipped shapefile or GeoJSON (non-polygon features will be skipped):", + initialFeaturesFileLabelLine: "Load initial polylines from zipped shapefile or GeoJSON (non-polyline features will be skipped):", + initialFeaturesNoFeatures: "Selected file doesn't contain any features supported by the added layer", + // Search searchButtonTitle: "Search Geometry Layers or OSM", searchPlaceholder: "Type to search...", @@ -160,16 +192,17 @@ L.ALS.Locales.addLocaleProperties("English", { // About - firstParagraph: "SynthFlight is a fully client-side software for planning aerial photography. This is a beta version so bugs, huge API changes and lack of backwards compatibility are to be expected.", - - secondParagraphPart1: "Visit project's", - secondParagraphPart2: "GitHub page", - secondParagraphPart3: "for more information.", + about1: "SynthFlight is a fully client-side software for planning aerial photography.", - thirdParagraph: "Developing SynthFlight is possible thanks to various open-source software.", + about2Part1: "Visit project's", + about2Part2: "GitHub page", + about2Part3: "for more information.", - fourthParagraph: "Using maps is possible thanks to following geoservices:", + about3Part1: "User guide is located in", + about3Part2: "SynthFlight Wiki.", - fifthParagraph: "Web search is powered by OpenStreetMaps and", // ... Nominatim API + about4: "Developing SynthFlight is possible thanks to various open-source software.", + about5: "Using maps is possible thanks to following geoservices:", + about6: "Web search is powered by OpenStreetMaps and", // ... Nominatim API }); \ No newline at end of file diff --git a/locales/Russian.js b/locales/Russian.js index b578edaa..3289145e 100644 --- a/locales/Russian.js +++ b/locales/Russian.js @@ -20,21 +20,32 @@ L.ALS.Locales.addLocaleProperties("Русский", { // SynthLineLayer lineLayerColor: "Цвет линий:", settingsLineLayerColor: "Цвет линий по умолчанию:", + lineLayersSkipped: "Одна или несколько линий были пропущены, так как они слишком длинные. Данные линии имеют красный цвет.", // SynthGridWizard gridWizardDisplayName: "Слой Сетки", gridWizardNotification: `Если масштаб карты слишком мелкий, сетка будет скрыта. Пожалуйста, увеличьте масштаб карты, чтобы ее увидеть. - Чтобы выделить трапецию, либо нажмите на него правой кнопкой мыши (или тапните и задержите палец) или два раза кликните (тапните) на него.`, + Чтобы выделить трапецию, или нажмите на него правой кнопкой мыши (или задержите палец на сенсорном экране), или два раза кликните (тапните) на него.`, gridStandardScales: "Масштаб сетки:", gridLngDistance: "Расстояние между параллелями:", gridLatDistance: "Расстояние между меридианами:", - gridShouldMergeCells: "Объединять пары соседних трапеций при широте выше 60° и снова объединять при широте выше 76° (кроме масштабов 1:1 000 000 и 1:2 000, при которых трапеции с широтой больше 76° объединяются по 3, а не по 4)", + gridShouldMergeCells: "Объединять соседние трапеции при широте выше 60°", // SynthGridLayer + // Confirmation text when distances don't divide map into the whole number of cells + gridCorrectDistancesMain1: "Расстояния не разделяют Землю на равное число сегментов.", + gridCorrectDistancesMain2: "Хотите ли вы использовать расстояние в", + gridCorrectDistancesLat: "для параллелей", + gridCorrectDistancesAnd: "и", + gridCorrectDistancesLng: "для меридианов", + gridCorrectDistancesMain3: "Нажмите \"Ок\", чтобы использовать предложенные расстояния. Нажмите \"Отмена\", чтобы отменить создание данного слоя.", + + // Main stuff + alphabet: "АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", // Don't add it, if you don't need different symbols in cells' names gridLayerDefaultName: "Слой Сетки", hidePolygonWidgets: "Скрыть виджеты на карте", @@ -95,7 +106,11 @@ L.ALS.Locales.addLocaleProperties("Русский", { confirmDEMLoading: "Вы уверены, что хотите загрузить файлы ЦМР? Это перезапишет текущую статистику и займет некоторое время.", loadingDEM: "Выбранные вами файлы ЦМР загружаются, это может занять некоторое время...", notGridNotSupported: "Извините, ваш браузер не поддерживает ничего, кроме ASCII Grid. Пожалуйста, выберете файл ASCII Grid.", - DEMError: "Извините, во время загрузки одного из ваших файлов произошла ошибка", + DEMError: "Следующие файлы не являются файлами ЦМР:", + DEMErrorProjFiles: "Следующие файлы не являются файлами проекции:", + + jsonNoPaths1: "Маршруты не были добавлены в слое", + jsonNoPaths2: "будет экспортирована только геометрия и положение аэропорта.", // SynthGridSettings @@ -112,29 +127,47 @@ L.ALS.Locales.addLocaleProperties("Русский", { defaultRectangleFillColor: "Цвет заливки прямоугольников по умолчанию:", rectangleBorderColor: "Цвет обводки прямоугольников:", rectangleFillColor: "Цвет заливки прямоугольников:", - rectangleLayersRemoved: "Один или несколько прямоугольников были удалены, так как они слишком большие", + rectangleLayersSkipped: "Один или несколько прямоугольников были пропущены, так как они слишком большие. Данные прямоугольники имеют красный цвет.", - // SynthShapefileWizard + // SynthGeometryWizard geometryDisplayName: "Слой Геометрии", geometryFileLabel: "Сжатый shapefile (zip-архив) или GeoJSON:", - geometryNotification: "Чтобы просмотреть семантику объекта, нажмите на него", // TODO: Add tip for searching by semantics when search will be added + geometryNotification: "Чтобы просмотреть семантику объекта, нажмите на него. Позже вы можете выполнить поиск по семантике, нажав кнопку поиска на панели программы.", - // SynthShapefileLayer + // SynthGeometryLayer geometryOutOfBounds: "Объекты в выбранном файле выходят за границы видимой области. Пожалуйста, проверьте проекцию и/или добавьте в архив файл .prj", - geometryInvalidFile: "Этот файл не является shapefile-ом или файлом GeoJSON", - geometryNoFeatures: "Этот файл не содержит объектов, поэтому не будет добавлен", + geometryInvalidFile: "Выбранный файл не является shapefile-ом или файлом GeoJSON", + geometryNoFeatures: "Выбранный файл не содержит объектов, поэтому не будет добавлен", geometryBorderColor: "Цвет обводки:", geometryFillColor: "Цвет заливки:", - geometryBrowserNotSupported: "Ваш браузер не поддерживает добавление данного слоя, но вы можете открывать проекты, использующие этот слой.", + geometryBrowserNotSupported: "Извините, ваш браузер не поддерживает добавление данного слоя, но вы можете открывать проекты, использующие этот слой.", geometryNoFileSelected: "Файл не был выбран. Пожалуйста, выберете файл, который хотите добавить, и попробуйте снова.", + geometryProjectionNotSupported: "Извините проекция выбранного файла не поддерживается. Пожалуйста, переконвертируйте файл в другую проекцию, предпочтительно, в WebMercator.", - // Shapefile settings + // SynthGeometrySettings geometryDefaultFillColor: "Цвет заливки по умолчанию:", geometryDefaultBorderColor: "Цвет обводки по умолчанию:", + // SynthPolygonLayer + polygonLayerName: "Слой Полигонов", + polygonPathsColor: "Цвет маршрутов:", + polygonHidePaths: "Скрыть маршруты", + + // Notifications after editing + polygonLayersSkipped: "Один или несколько полигонов были пропущены, так как они слишком большие. Данные полигоны имеют красный цвет.", + afterEditingInvalidDEMValues: "Значения высот могут быть неправильными, поскольку объекты были отредактированы. Пожалуйста, заново загрузите ЦМР или вручную отредактируйте значения высот.", + afterEditingToDisableNotifications: "Чтобы убрать данные уведомления, перейдите в Настройки - Общие настройки - Отключить все надоедливые уведомления после редактирования и загрузки ЦМР.", + generalSettingsDisableAnnoyingNotification: "Отключить все надоедливые уведомления после редактирования и загрузки ЦМР", + + // GeoJSON initial features + initialFeaturesBrowserNotSupported: "Извините, ваш браузер не поддерживает загрузку исходной геометрии для данного слоя, но вы все равно можете нарисовать геомтрию вручную.", + initialFeaturesFileLabelPolygon: "Загрузить исходные полигоны из сжатого shapefile (zip-архива) или GeoJSON (типы, отличные от полигона, будут пропущены):", + initialFeaturesFileLabelLine: "Загрузить исходные полилинии из сжатого shapefile (zip-архива) или GeoJSON (типы, отличные от пололинии, будут пропущены):", + initialFeaturesNoFeatures: "Выбранный файл не содержит ни одного объекта, поддерживаемого добавляемым слоем", + // Search searchButtonTitle: "Поиск в Слоях Геометрии и OSM", searchPlaceholder: "Начните вводить для поиска...", @@ -155,16 +188,17 @@ L.ALS.Locales.addLocaleProperties("Русский", { // About - firstParagraph: "SynthFlight – это полностью клиентское программное обеспечение для проектирования аэрофотосъемочных работ. Это beta-версия, поэтому ожидаемы баги, большие изменения API, отсутствие обратной совместимости и т.д.", - - secondParagraphPart1: "Посетите", - secondParagraphPart2: "страницу проекта на GitHub", - secondParagraphPart3: "для дополнительной информации (на английском языке).", + about1: "SynthFlight – это полностью клиентское программное обеспечение для проектирования аэрофотосъемочных работ.", - thirdParagraph: "Разработка SynthFlight возможна, благодаря различному свободному ПО.", + about2Part1: "Посетите", + about2Part2: "страницу проекта на GitHub", + about2Part3: "для дополнительной информации (на английском языке).", - fourthParagraph: "Использование карт возможно, благодаря следующим геосервисам:", + about3Part1: "Руководство пользователя на английском языке находится на", + about3Part2: "странице SynthFlight Wiki.", - fifthParagraph: "Поиск по Интернету осуществляется через OpenStreetMaps при помощи", // ... Nominatim API + about4: "Разработка SynthFlight возможна, благодаря различному свободному ПО.", + about5: "Использование карт возможно, благодаря следующим геосервисам:", + about6: "Поиск по Интернету осуществляется через OpenStreetMaps при помощи", // ... Nominatim API }); \ No newline at end of file diff --git a/main.js b/main.js index 2b55cd9d..14299228 100644 --- a/main.js +++ b/main.js @@ -14,9 +14,10 @@ window.L = require("leaflet"); * Segments number to use when displaying L.Geodesic * @type {number} */ -L.GEODESIC_SEGMENTS = 1000; +L.GEODESIC_SEGMENTS = 500; L.Geodesic = require("leaflet.geodesic").GeodesicLine; +require("./WrappedPolyline.js"); require("leaflet-draw"); require("./DrawGeodesic.js"); require("leaflet-advanced-layer-system"); @@ -24,13 +25,27 @@ L.ALS.Locales.AdditionalLocales.Russian(); require("./node_modules/leaflet.coordinates/dist/Leaflet.Coordinates-0.1.5.min.js"); require("./locales/English.js"); require("./locales/Russian.js"); +require("./SynthGeneralSettings.js"); +require("./SynthGeometryBaseWizard.js"); require("./SynthGeometryLayer/SynthGeometryLayer.js"); -require("./SynthBase/SynthBaseLayer.js"); -require("./SynthPolygonLayer/SynthPolygonLayer.js"); +require("./SynthBaseLayer/SynthBaseLayer.js"); +require("./SynthPolygonBaseLayer/SynthPolygonBaseLayer.js"); +require("./SynthRectangleBaseLayer/SynthRectangleBaseLayer.js"); require("./SynthGridLayer/SynthGridLayer.js"); require("./SynthRectangleLayer/SynthRectangleLayer.js"); require("./SynthLineLayer/SynthLineLayer.js"); +require("./SynthPolygonLayer/SynthPolygonLayer.js"); require("./SearchControl.js"); +const drawLocales = require("leaflet-draw-locales").default; + +// Update L.Draw locale on ALS locale change +let oldChangeLocale = L.ALS.Locales.changeLocale; + +L.ALS.Locales.changeLocale = function (locale) { + oldChangeLocale.call(this, locale); + L.drawLocal = drawLocales(L.ALS.locale.language); + L.ALS.Helpers.dispatchEvent(document.body, "synthflight-locale-changed"); +} L.ALS.System.initializeSystem(); @@ -47,18 +62,24 @@ let map = L.map("map", { preferCanvas: true, // Canvas is faster than SVG renderer keyboard: false, worldCopyJump: true, + fadeAnimation: false }).setView([51.505, -0.09], 13); map.doubleClickZoom.disable(); // Display a notification that users can move the map farther to jump to the other side of the world -let labelLayer = new L.ALS.LeafletLayers.LabelLayer(false), maxLabelWidth = 3, + +let labelLayer = new L.ALS.LeafletLayers.LabelLayer(false), labelOpts = { maxWidth: 10, breakWords: false, }, westOpts = {origin: "rightCenter", ...labelOpts}, eastOpts = {origin: "leftCenter", ...labelOpts}; + +let labelsPaneElement = map.createPane("mapLabelsPane"); + +labelLayer.options.pane = "mapLabelsPane"; labelLayer.addTo(map); map.on("moveend zoomend resize", () => { @@ -79,7 +100,61 @@ map.on("moveend zoomend resize", () => { labelLayer.redraw(); }); +// Add black overlay to hide polygons when editing is not active + +L.BlackOverlayLayer = L.GridLayer.extend({ + createTile: function(coords) { + let tile = L.DomUtil.create("canvas", "leaflet-tile"), + {_northEast, _southWest} = this._tileCoordsToBounds(coords); + + if (_southWest.lng >= -180 && _northEast.lng <= 180) + return tile; + + let ctx = tile.getContext("2d"), + {x, y} = this.getTileSize(); + tile.width = x; + tile.height = y; + + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, x, y); + + return tile; + } +}); + +map.createPane("blackOverlayPane"); + +let overlayLayer = new L.BlackOverlayLayer({ + noWrap: true, + pane: "blackOverlayPane", + updateWhenIdle: false, + updateWhenZooming: false, +}).addTo(map); + +// When drawing starts, hide notifications and black overlay, but add red datelines + +let datelines = new L.FeatureGroup(); +for (let lng of [180, -180]) { + datelines.addLayer(new L.Polyline([[90, lng], [-90, lng]], { + color: "red", + weight: 1, + })); +} + +map.on("draw:drawstart draw:editstart draw:deletestart", () => { + overlayLayer.setOpacity(0); + labelsPaneElement.style.opacity = "0"; + datelines.addTo(map); +}); + +map.on("draw:drawstop draw:editstop draw:deletestop", () => { + overlayLayer.setOpacity(1); + labelsPaneElement.style.opacity = "1"; + datelines.remove(); +}); + // Initialize layer system. Create and add base layers. + let layerSystem = new L.ALS.System(map, { aboutHTML: require("./about.js"), filePrefix: "SynthFlight", @@ -88,18 +163,17 @@ let layerSystem = new L.ALS.System(map, { makeMapFullscreen: true, historySize: L.ALS.Helpers.supportsFlexbox ? 40 : 20, // Old browsers might have lower RAM limits toolbarZoomControl: new L.ALS.ControlZoom({vertical: true}), + generalSettings: L.ALS.SynthGeneralSettings }); // CartoDB layerSystem.addBaseLayer(L.tileLayer("http://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", { maxZoom: 19, - noWrap: true, }), "CartoDB"); // OSM layerSystem.addBaseLayer(L.tileLayer("http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, - noWrap: true, }), "Open Street Maps"); // Google maps @@ -107,7 +181,6 @@ let letters = [["m", "Streets"], ["s", "Satellite"], ["p", "Terrain"], ["s,h", " for (let letter of letters) { layerSystem.addBaseLayer(L.tileLayer("http://{s}.google.com/vt/lyrs=" + letter[0] + "&x={x}&y={y}&z={z}", { maxZoom: 20, - noWrap: true, subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] }), "Google " + letter[1]); } @@ -119,7 +192,6 @@ for (let country of countries) { subdomains: ['01', '02', '03', '04'], reuseTiles: true, updateWhenIdle: false, - noWrap: true, }), "Yandex " + country[3] + country[4]); } @@ -129,6 +201,7 @@ layerSystem.addBaseLayer(L.tileLayer(""), "Empty"); // Add layer types layerSystem.addLayerType(L.ALS.SynthGridLayer); layerSystem.addLayerType(L.ALS.SynthRectangleLayer); +layerSystem.addLayerType(L.ALS.SynthPolygonLayer); layerSystem.addLayerType(L.ALS.SynthLineLayer); layerSystem.addLayerType(L.ALS.SynthGeometryLayer); diff --git a/package-lock.json b/package-lock.json index 27738ee7..6ba7f47b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "synthflight", - "version": "0.1.0-beta", + "version": "0.2.0-beta", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "synthflight", - "version": "0.1.0-beta", + "version": "0.2.0-beta", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { "@electron/remote": "^2.0.1", - "leaflet-advanced-layer-system": "^2.2.1" + "leaflet-advanced-layer-system": "^2.2.6" }, "devDependencies": { "@babel/core": "^7.12.3", @@ -20,6 +20,7 @@ "@babel/preset-env": "^7.12.1", "@babel/runtime-corejs3": "^7.12.5", "@mapbox/geojson-merge": "^1.1.1", + "@turf/area": "^6.5.0", "@turf/bbox": "^6.0.1", "@turf/bbox-polygon": "^6.4.0", "@turf/helpers": "^6.1.4", @@ -33,7 +34,6 @@ "debounce": "^1.2.1", "electron": "^16.0.3", "electron-packager": "^15.4.0", - "fastestsmallesttextencoderdecoder": "^1.0.22", "fs-extra": "^9.0.1", "geotiff": "^1.0.4", "geotiff-geokeys-to-proj4": "^2021.10.31", @@ -41,6 +41,7 @@ "keyboardevent-key-polyfill": "^1.1.0", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", + "leaflet-draw-locales": "^1.2.1", "leaflet.coordinates": "~0.1.5", "leaflet.geodesic": "github:matafokka/Leaflet.Geodesic", "minisearch": "^4.0.3", @@ -1543,6 +1544,19 @@ "node": ">=6" } }, + "node_modules/@turf/area": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", + "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", + "dev": true, + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@turf/bbox": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.0.1.tgz", @@ -1562,21 +1576,15 @@ "@turf/helpers": "^6.4.0" } }, - "node_modules/@turf/bbox/node_modules/@turf/meta": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.0.2.tgz", - "integrity": "sha512-VA7HJkx7qF1l3+GNGkDVn2oXy4+QoLP6LktXAaZKjuT1JI0YESat7quUkbCMy4zP9lAUuvS4YMslLyTtr919FA==", + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", "dev": true, - "dependencies": { - "@turf/helpers": "6.x" + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/helpers": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.4.0.tgz", - "integrity": "sha512-7vVpWZwHP0Qn8DDSlM++nhs3/6zfPt+GODjvLVZ+sWIG4S3vOtUUOfO5eIjRzxsUHHqhgiIL0QA17u79uLM+mQ==", - "dev": true - }, "node_modules/@turf/intersect": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/@turf/intersect/-/intersect-6.4.0.tgz", @@ -1597,6 +1605,18 @@ "@turf/helpers": "^6.4.0" } }, + "node_modules/@turf/meta": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", + "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "dev": true, + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -4560,12 +4580,6 @@ "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", "dev": true }, - "node_modules/fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", - "dev": true - }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -6040,9 +6054,9 @@ "dev": true }, "node_modules/leaflet-advanced-layer-system": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/leaflet-advanced-layer-system/-/leaflet-advanced-layer-system-2.2.1.tgz", - "integrity": "sha512-F010VJWIlrqoYZjXH5BtpKr5zVHA8sxchYnxGgN84xa/gCPrJ5RZx/6qHpsa7A6eLqsqXWFpezOvU5vY3Nw4ZQ==" + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/leaflet-advanced-layer-system/-/leaflet-advanced-layer-system-2.2.6.tgz", + "integrity": "sha512-QG5VcQJmP9NLZ5PhBbXBPXJ5gRpdlg2c44b8BORRhTxVJWQ//wSo4l1sjjGvYk+oZAvmJA26FyGBAqXNpz/0HQ==" }, "node_modules/leaflet-draw": { "version": "1.0.4", @@ -6050,6 +6064,16 @@ "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==", "dev": true }, + "node_modules/leaflet-draw-locales": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/leaflet-draw-locales/-/leaflet-draw-locales-1.2.1.tgz", + "integrity": "sha512-pT7qoUF9cod4B5q9RO4jKF6tLH+sVXhq2Xw34NAWV83w+ne2+ZqWARR5Kis9bREunznQRXNIq4UR9prib1waBw==", + "dev": true, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, "node_modules/leaflet.coordinates": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/leaflet.coordinates/-/leaflet.coordinates-0.1.5.tgz", @@ -6058,7 +6082,7 @@ }, "node_modules/leaflet.geodesic": { "version": "2.6.1", - "resolved": "git+ssh://git@github.com/matafokka/Leaflet.Geodesic.git#ff58fda7783f91c9c4b2595903b73c5359f66703", + "resolved": "git+ssh://git@github.com/matafokka/Leaflet.Geodesic.git#fa63ecc51ce77256d1ca2cb96b139e0040b6a6da", "dev": true, "license": "GPL-3.0", "peerDependencies": { @@ -13935,6 +13959,16 @@ "defer-to-connect": "^1.0.1" } }, + "@turf/area": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-6.5.0.tgz", + "integrity": "sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg==", + "dev": true, + "requires": { + "@turf/helpers": "^6.5.0", + "@turf/meta": "^6.5.0" + } + }, "@turf/bbox": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-6.0.1.tgz", @@ -13943,17 +13977,6 @@ "requires": { "@turf/helpers": "6.x", "@turf/meta": "6.x" - }, - "dependencies": { - "@turf/meta": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.0.2.tgz", - "integrity": "sha512-VA7HJkx7qF1l3+GNGkDVn2oXy4+QoLP6LktXAaZKjuT1JI0YESat7quUkbCMy4zP9lAUuvS4YMslLyTtr919FA==", - "dev": true, - "requires": { - "@turf/helpers": "6.x" - } - } } }, "@turf/bbox-polygon": { @@ -13966,9 +13989,9 @@ } }, "@turf/helpers": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.4.0.tgz", - "integrity": "sha512-7vVpWZwHP0Qn8DDSlM++nhs3/6zfPt+GODjvLVZ+sWIG4S3vOtUUOfO5eIjRzxsUHHqhgiIL0QA17u79uLM+mQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", "dev": true }, "@turf/intersect": { @@ -13991,6 +14014,15 @@ "@turf/helpers": "^6.4.0" } }, + "@turf/meta": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz", + "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==", + "dev": true, + "requires": { + "@turf/helpers": "^6.5.0" + } + }, "@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -16413,12 +16445,6 @@ "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==", "dev": true }, - "fastestsmallesttextencoderdecoder": { - "version": "1.0.22", - "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", - "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", - "dev": true - }, "fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -17572,9 +17598,9 @@ "dev": true }, "leaflet-advanced-layer-system": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/leaflet-advanced-layer-system/-/leaflet-advanced-layer-system-2.2.1.tgz", - "integrity": "sha512-F010VJWIlrqoYZjXH5BtpKr5zVHA8sxchYnxGgN84xa/gCPrJ5RZx/6qHpsa7A6eLqsqXWFpezOvU5vY3Nw4ZQ==" + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/leaflet-advanced-layer-system/-/leaflet-advanced-layer-system-2.2.6.tgz", + "integrity": "sha512-QG5VcQJmP9NLZ5PhBbXBPXJ5gRpdlg2c44b8BORRhTxVJWQ//wSo4l1sjjGvYk+oZAvmJA26FyGBAqXNpz/0HQ==" }, "leaflet-draw": { "version": "1.0.4", @@ -17582,6 +17608,12 @@ "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==", "dev": true }, + "leaflet-draw-locales": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/leaflet-draw-locales/-/leaflet-draw-locales-1.2.1.tgz", + "integrity": "sha512-pT7qoUF9cod4B5q9RO4jKF6tLH+sVXhq2Xw34NAWV83w+ne2+ZqWARR5Kis9bREunznQRXNIq4UR9prib1waBw==", + "dev": true + }, "leaflet.coordinates": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/leaflet.coordinates/-/leaflet.coordinates-0.1.5.tgz", @@ -17589,7 +17621,7 @@ "dev": true }, "leaflet.geodesic": { - "version": "git+ssh://git@github.com/matafokka/Leaflet.Geodesic.git#ff58fda7783f91c9c4b2595903b73c5359f66703", + "version": "git+ssh://git@github.com/matafokka/Leaflet.Geodesic.git#fa63ecc51ce77256d1ca2cb96b139e0040b6a6da", "dev": true, "from": "leaflet.geodesic@github:matafokka/Leaflet.Geodesic", "requires": {} diff --git a/package.json b/package.json index aa740616..e8d20061 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "synthflight", "productName": "SynthFlight", - "version": "0.2.0-beta", + "version": "1.0.0", "description": "A fully client-side software for planning aerial photography", "main": "electronApp.js", "browser": "index.html", @@ -61,6 +61,7 @@ "@babel/preset-env": "^7.12.1", "@babel/runtime-corejs3": "^7.12.5", "@mapbox/geojson-merge": "^1.1.1", + "@turf/area": "^6.5.0", "@turf/bbox": "^6.0.1", "@turf/bbox-polygon": "^6.4.0", "@turf/helpers": "^6.1.4", @@ -74,7 +75,6 @@ "debounce": "^1.2.1", "electron": "^16.0.3", "electron-packager": "^15.4.0", - "fastestsmallesttextencoderdecoder": "^1.0.22", "fs-extra": "^9.0.1", "geotiff": "^1.0.4", "geotiff-geokeys-to-proj4": "^2021.10.31", @@ -82,6 +82,7 @@ "keyboardevent-key-polyfill": "^1.1.0", "leaflet": "^1.7.1", "leaflet-draw": "^1.0.4", + "leaflet-draw-locales": "^1.2.1", "leaflet.coordinates": "~0.1.5", "leaflet.geodesic": "github:matafokka/Leaflet.Geodesic", "minisearch": "^4.0.3", @@ -100,6 +101,6 @@ }, "dependencies": { "@electron/remote": "^2.0.1", - "leaflet-advanced-layer-system": "^2.2.1" + "leaflet-advanced-layer-system": "^2.2.6" } }