diff --git a/src/libs/utils-map.ts b/src/libs/utils-map.ts index fb83df8fc..ae8c61864 100644 --- a/src/libs/utils-map.ts +++ b/src/libs/utils-map.ts @@ -1,3 +1,7 @@ +import * as turf from '@turf/turf' +import type { Feature, Polygon } from 'geojson' +import * as L from 'leaflet' + import type { WaypointCoordinates } from '@/types/mission' /** @@ -158,3 +162,124 @@ export class TargetFollower { this.onTargetChange(this.target) } } + +/** + * Generates a survey path based on the given polygon and parameters. + * @param {L.LatLng[]} polygonPoints - The points of the polygon. + * @param {number} distanceBetweenLines - The distance between survey lines in meters. + * @param {number} linesAngle - The angle of the survey lines in degrees. + * @returns {L.LatLng[]} The generated survey path. + */ +export const generateSurveyPath = ( + polygonPoints: L.LatLng[], + distanceBetweenLines: number, + linesAngle: number +): L.LatLng[] => { + if (polygonPoints.length < 4) return [] + + const polygonCoords = polygonPoints.map((p) => [p.lng, p.lat]) + if ( + polygonCoords[0][0] !== polygonCoords[polygonCoords.length - 1][0] || + polygonCoords[0][1] !== polygonCoords[polygonCoords.length - 1][1] + ) { + polygonCoords.push(polygonCoords[0]) + } + + try { + const poly = turf.polygon([polygonCoords]) + const bbox = turf.bbox(poly) + const [minX, minY, maxX, maxY] = bbox + const diagonal = Math.sqrt(Math.pow(maxX - minX, 2) + Math.pow(maxY - minY, 2)) + + const adjustedAngle = linesAngle + 90 + const angleRad = (adjustedAngle * Math.PI) / 180 + + const continuousPath: L.LatLng[] = [] + let d = -diagonal + let isReverse = false + + while (d <= diagonal * 2) { + const lineStart = [ + minX + d * Math.cos(angleRad) - diagonal * Math.sin(angleRad), + minY + d * Math.sin(angleRad) + diagonal * Math.cos(angleRad), + ] + const lineEnd = [ + minX + d * Math.cos(angleRad) + diagonal * Math.sin(angleRad), + minY + d * Math.sin(angleRad) - diagonal * Math.cos(angleRad), + ] + + const line = turf.lineString([lineStart, lineEnd]) + const clipped = turf.lineIntersect(poly, line) + + if (clipped.features.length >= 2) { + const coords = clipped.features.map((f) => f.geometry.coordinates) + if (isReverse) coords.reverse() + + const linePoints = coords.map((c) => L.latLng(c[1], c[0])) + + if (continuousPath.length > 0) { + const lastPoint = continuousPath[continuousPath.length - 1] + const edgePath = moveAlongEdge(poly, lastPoint, linePoints[0], distanceBetweenLines / 111000) + continuousPath.push(...edgePath) + } + + continuousPath.push(...linePoints) + isReverse = !isReverse + } + + d += distanceBetweenLines / 111000 + } + + return continuousPath + } catch (error) { + console.error('Error in generateSurveyPath:', error) + return [] + } +} + +/** + * Moves along the edge of a polygon from start to end point. + * @param {Feature} polygon - The polygon to move along. + * @param {L.LatLng} start - The starting point. + * @param {L.LatLng} end - The ending point. + * @param {number} maxDistance - The maximum distance to move. + * @returns {L.LatLng[]} The path along the edge. + */ +export const moveAlongEdge = ( + polygon: Feature, + start: L.LatLng, + end: L.LatLng, + maxDistance: number +): L.LatLng[] => { + const coords = polygon.geometry.coordinates[0] + const path: L.LatLng[] = [] + let remainingDistance = maxDistance + let currentPoint = turf.point([start.lng, start.lat]) + + for (let i = 0; i < coords.length; i++) { + const nextPoint = turf.point(coords[(i + 1) % coords.length]) + const edgeLine = turf.lineString([coords[i], coords[(i + 1) % coords.length]]) + + if (turf.booleanPointOnLine(currentPoint, edgeLine)) { + while (remainingDistance > 0) { + const distance = turf.distance(currentPoint, nextPoint) + if (distance <= remainingDistance) { + path.push(L.latLng(nextPoint.geometry.coordinates[1], nextPoint.geometry.coordinates[0])) + remainingDistance -= distance + currentPoint = nextPoint + break + } else { + const move = turf.along(edgeLine, remainingDistance, { units: 'kilometers' }) + path.push(L.latLng(move.geometry.coordinates[1], move.geometry.coordinates[0])) + break + } + } + } + + if (turf.booleanPointOnLine(turf.point([end.lng, end.lat]), edgeLine)) { + break + } + } + + return path +} diff --git a/src/views/MissionPlanningView.vue b/src/views/MissionPlanningView.vue index 300251898..7903282c0 100644 --- a/src/views/MissionPlanningView.vue +++ b/src/views/MissionPlanningView.vue @@ -3,6 +3,43 @@
+ +
+

Distance between lines (m)

+ +

Lines angle (degrees)

+ + + +
+

Waypoint type

+
+
+ `, + className: 'custom-div-icon', + iconSize: [24, 24], + iconAnchor: [12, 12], + }), + draggable: true, + }) + .on('drag', () => { + updatePolygon() + createSurveyPath() + }) + .on('mouseover', (event: L.LeafletEvent) => { + if (justCreated) { + justCreated = false + return + } + const target = event.target as L.Marker + const popup = target.getElement()?.querySelector('.delete-popup') as HTMLDivElement + if (popup) popup.style.display = 'block' + }) + .on('mouseout', (event: L.LeafletEvent) => { + const target = event.target as L.Marker + const popup = target.getElement()?.querySelector('.delete-popup') as HTMLDivElement + if (popup) popup.style.display = 'none' + }) + .on('click', (event: L.LeafletEvent) => { + const target = event.target as L.Marker + const index = surveyPolygonVertexesMarkers.value.indexOf(target) + if (index !== -1) { + surveyPolygonVertexesPositions.value.splice(index, 1) + surveyPolygonVertexesMarkers.value.splice(index, 1) + target.remove() + updatePolygon() + updateSurveyEdgeAddMarkers() + checkAndRemoveSurveyPath() + createSurveyPath() + } + }) + .addTo(toRaw(planningMap.value)!) + if (edgeIndex === undefined) { + surveyPolygonVertexesMarkers.value.push(newMarker) + } else { + surveyPolygonVertexesMarkers.value.splice(edgeIndex + 1, 0, newMarker) + } + updatePolygon() + updateSurveyEdgeAddMarkers() + createSurveyPath() +} + +const generateWaypointsFromSurvey = (): void => { + if (!surveyPathLayer.value) { + showDialog({ variant: 'error', message: 'No survey path to generate waypoints from.', timer: 3000 }) + return + } + + const surveyLatLngs = surveyPathLayer.value.getLatLngs() + if (!Array.isArray(surveyLatLngs) || surveyLatLngs.length === 0) { + showDialog({ variant: 'error', message: 'Invalid survey path.', timer: 3000 }) + return + } + + // Clear existing waypoints + missionStore.currentPlanningWaypoints.forEach((waypoint: Waypoint) => removeWaypoint(waypoint)) + + // Generate new waypoints from survey path + // @ts-ignore: L.LatLng is not assignable to LatLngTuple + surveyLatLngs.flat().forEach((latLng: L.LatLng) => { + addWaypoint( + [latLng.lat, latLng.lng], + currentWaypointAltitude.value, + WaypointType.PASS_BY, + currentWaypointAltitudeRefType.value + ) + }) + + // Remove survey path and polygon + clearSurveyPath() + isCreatingSurvey.value = false + + showDialog({ variant: 'success', message: 'Waypoints generated from survey path.', timer: 3000 }) +} + onMounted(async () => { const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, @@ -327,12 +628,16 @@ onMounted(async () => { await goHome() planningMap.value.on('click', (e) => { - addWaypoint( - [e.latlng.lat, e.latlng.lng], - currentWaypointAltitude.value, - currentWaypointType.value, - currentWaypointAltitudeRefType.value - ) + if (isCreatingSurvey.value) { + addSurveyPoint(e.latlng) + } else { + addWaypoint( + [e.latlng.lat, e.latlng.lng], + currentWaypointAltitude.value, + currentWaypointType.value, + currentWaypointAltitudeRefType.value + ) + } }) const layerControl = L.control.layers(baseMaps) @@ -458,4 +763,93 @@ watch([home, planningMap], async () => { .active-events-on-disabled { pointer-events: all; } +.survey-polygon { + fill-opacity: 0.2; + stroke-width: 2; + stroke: #3b82f6; + cursor: crosshair; +} +.survey-path { + stroke-width: 2; + stroke-dasharray: 16, 16; + stroke: #2563eb; +} +.survey-cursor { + cursor: crosshair; +} +.custom-div-icon { + background: none; + border: none; +} + +.custom-div-icon svg { + display: block; +} + +/* Increase clickable area */ +.custom-div-icon::after { + content: ''; + cursor: grab; + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; +} + +.edge-marker { + background: none; + border: none; +} + +.edge-marker svg { + transition: all 0.3s ease; +} + +.edge-marker:hover svg { + transform: scale(1.2); +} + +/* Add hover effect to survey point markers */ +.custom-div-icon:hover .delete-icon { + display: block; +} + +/* Add animation to survey path */ +@keyframes move { + 0% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: -100%; + } +} + +.survey-path { + animation: move 30s infinite linear; +} + +.survey-vertex-icon { + position: relative; +} + +.delete-popup { + position: absolute; + top: -20px; + left: -20px; + background-color: rgba(239, 68, 68, 0.8); + border-radius: 50%; + padding: 6px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.delete-button { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +}