• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-MSS / MSS / 10505239656

22 Aug 2024 09:12AM UTC coverage: 69.918% (+0.1%) from 69.81%
10505239656

Pull #2479

github

web-flow
Merge 2f9469316 into 4344e0052
Pull Request #2479: Fix #2478

3 of 5 new or added lines in 1 file covered. (60.0%)

23 existing lines in 2 files now uncovered.

13890 of 19866 relevant lines covered (69.92%)

0.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.3
/mslib/msui/mpl_pathinteractor.py
1
# -*- coding: utf-8 -*-
2
"""
3

4
    mslib.msui.mpl_pathinteractor
5
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6

7
    Interactive editing of Path objects on a Matplotlib canvas.
8

9
    This module provides the following classes:
10

11
    a) WaypointsPath and subclasses PathV, PathH and PathH-GC:
12
    Derivatives of matplotlib.Path, provide additional methods to
13
    insert and delete vertices and to find best insertion points for new
14
    vertices.
15

16
    b) PathInteractor and subclasses VPathInteractor and HPathInteractor:
17
    Classes that implement a path editor by binding matplotlib mouse events
18
    to a WaypointsPath object. Support for moving, inserting and deleting vertices.
19

20
    The code in this module is inspired by the matplotlib example 'path_editor.py'
21
    (http://matplotlib.sourceforge.net/examples/event_handling/path_editor.html).
22

23
    For more information on implementing animated graphics in matplotlib, see
24
    http://www.scipy.org/Cookbook/Matplotlib/Animations.
25

26

27
    This file is part of MSS.
28

29
    :copyright: Copyright 2008-2014 Deutsches Zentrum fuer Luft- und Raumfahrt e.V.
30
    :copyright: Copyright 2011-2014 Marc Rautenhaus (mr)
31
    :copyright: Copyright 2016-2024 by the MSS team, see AUTHORS.
32
    :license: APACHE-2.0, see LICENSE for details.
33

34
    Licensed under the Apache License, Version 2.0 (the "License");
35
    you may not use this file except in compliance with the License.
36
    You may obtain a copy of the License at
37

38
       http://www.apache.org/licenses/LICENSE-2.0
39

40
    Unless required by applicable law or agreed to in writing, software
41
    distributed under the License is distributed on an "AS IS" BASIS,
42
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
43
    See the License for the specific language governing permissions and
44
    limitations under the License.
45
"""
46

47
import logging
1✔
48
import math
1✔
49
import numpy as np
1✔
50
import matplotlib.path as mpath
1✔
51
import matplotlib.patches as mpatches
1✔
52
from PyQt5 import QtCore, QtWidgets
1✔
53

54
from mslib.utils.coordinate import get_distance, find_location, latlon_points, path_points
1✔
55
from mslib.utils.units import units
1✔
56
from mslib.utils.thermolib import pressure2flightlevel
1✔
57
from mslib.msui import flighttrack as ft
1✔
58
from mslib.utils.loggerdef import configure_mpl_logger
1✔
59

60

61
mpl_logger = configure_mpl_logger()
1✔
62

63

64
def distance_point_linesegment(p, l1, l2):
1✔
65
    """Computes the distance between a point p and a line segment given by its
66
       endpoints l1 and l2.
67

68
    p, l1, l2 should be numpy arrays of length 2 representing [x,y].
69

70
    Based on the dot product formulation from
71
      'Subject 1.02: How do I find the distance from a point to a line?'
72
      (from  http://www.faqs.org/faqs/graphics/algorithms-faq/).
73

74
    Special case: The point p projects to an extension of the line segment.
75
    In this case, the distance between the point and the segment equals
76
    the distance between the point and the closest segment endpoint.
77
    """
78
    # Compute the parameter r in the line formulation p* = l1 + r*(l2-l1).
79
    # p* is the point on the line at which (p-p*) and (l1-l2) form a right
80
    # angle.
81
    r = (np.dot(p - l1, l2 - l1) / np.linalg.norm(l2 - l1) ** 2)
1✔
82
    # If 0 < r < 1, we return the distance p-p*. If r > 1, p* is on the
83
    # forward extension of l1-l2, hence return the distance between
84
    # p and l2. If r < 0, return the distance p-l1.
85
    if r > 1:
1✔
86
        return np.linalg.norm(p - l2)
1✔
87
    elif r < 0:
1✔
88
        return np.linalg.norm(p - l1)
1✔
89
    else:
90
        p_on_line = l1 + r * (l2 - l1)
1✔
91
        return np.linalg.norm(p - p_on_line)
1✔
92

93

94
class WaypointsPath(mpath.Path):
1✔
95
    """Derivative of matplotlib.Path that provides methods to insert and
96
       delete vertices.
97
    """
98

99
    def delete_vertex(self, index):
1✔
100
        """Remove the vertex at the given index from the list of vertices.
101
        """
102
        # TODO: Should codes (MOVETO, LINETO) be modified here? relevant for
103
        #      inserting/deleting first/last point.
104
        # Emulate pop() for ndarray:
105
        self.vertices = np.delete(self.vertices, index, axis=0)
×
106
        self.codes = np.delete(self.codes, index, axis=0)
×
107

108
    def insert_vertex(self, index, vertex, code):
1✔
109
        """Insert a new vertex (a tuple x,y) with the given code (see
110
           matplotlib.Path) at the given index.
111
        """
112
        self.vertices = np.insert(self.vertices, index,
1✔
113
                                  np.asarray(vertex, np.float_), axis=0)
114
        self.codes = np.insert(self.codes, index, code, axis=0)
1✔
115

116
    def index_of_closest_segment(self, x, y, eps=5):
1✔
117
        """Find the index of the edge closest to the specified point at x,y.
118

119
        If the point is not within eps (in the same coordinates as x,y) of
120
        any edge in the path, the index of the closest end point is returned.
121
        """
122
        # If only one point is stored in the list the best index to insert a
123
        # new point is after this point.
124
        if len(self.vertices) == 1:
1✔
125
            return 1
×
126

127
        # Compute the distance between the first point in the path and
128
        # the given point.
129
        point = np.array([x, y])
1✔
130
        min_index = 0
1✔
131
        min_distance = np.linalg.norm(point - self.vertices[0])
1✔
132

133
        # Loop over all line segments. If the distance between the given
134
        # point and the segment is smaller than a specified threshold AND
135
        # the distance is smaller than the currently smallest distance
136
        # then remember the current index.
137
        for i in range(len(self.vertices) - 1):
1✔
138
            l1 = self.vertices[i]
1✔
139
            l2 = self.vertices[i + 1]
1✔
140
            distance = distance_point_linesegment(point, l1, l2)
1✔
141
            if distance < eps and distance < min_distance:
1✔
142
                min_index = i + 1
1✔
143
                min_distance = distance
1✔
144

145
        # Compute the distance between the given point and the end point of
146
        # the path. Is it smaller than the currently smallest distance?
147
        distance = np.linalg.norm(point - self.vertices[-1])
1✔
148
        if distance < min_distance:
1✔
149
            min_index = len(self.vertices)
1✔
150

151
        return min_index
1✔
152

153
    def transform_waypoint(self, wps_list, index):
1✔
154
        """Transform the waypoint at index <index> of wps_list to plot
155
           coordinates.
156

157
        wps_list is a list of <Waypoint> objects (obtained from
158
        WaypointsTableModel.allWaypointData()).
159

160
        NEEDS TO BE IMPLEMENTED IN DERIVED CLASSES.
161
        """
162
        return (0, 0)
×
163

164
    def update_from_waypoints(self, wps):
1✔
165
        """
166
        """
167
        Path = mpath.Path
1✔
168
        pathdata = []
1✔
169
        # on a expired mscolab server wps is an empty list
170
        if len(wps) > 0:
1✔
171
            pathdata = [(Path.MOVETO, self.transform_waypoint(wps, 0))]
1✔
172
            for i, _ in enumerate(wps[1:]):
1✔
173
                pathdata.append((Path.LINETO, self.transform_waypoint(wps, i + 1)))
1✔
174

175
        codes, vertices = list(zip(*pathdata))
1✔
176
        self.codes = np.array(codes, dtype=np.uint8)
1✔
177
        self.vertices = np.array(vertices)
1✔
178

179

180
class PathV(WaypointsPath):
1✔
181
    """Class to represent a vertical flight profile path.
182
    """
183

184
    def __init__(self, *args, **kwargs):
1✔
185
        """The constructor required the additional keyword 'numintpoints':
186

187
        numintpoints -- number of intermediate interpolation points. The entire
188
                        flight track will be interpolated to this number of
189
                        points.
190
        """
191
        self.numintpoints = kwargs.pop("numintpoints")
1✔
192
        super().__init__(*args, **kwargs)
1✔
193

194
    def update_from_waypoints(self, wps):
1✔
195
        """Extended version of the corresponding WaypointsPath method.
196

197
        The idea is to generate a field 'intermediate_indexes' that stores
198
        the indexes of the waypoints in the field of the intermediate
199
        great circle points generated by the flight track model, then
200
        to use these great circle indexes as x-coordinates for the vertical
201
        section. This means: If ngc_points are created by
202
        wps_model.intermediatePoints(), the waypoints are mapped to the
203
        range 0..ngc_points.
204

205
        NOTE: If wps_model only contains two equal waypoints,
206
        intermediate_indexes will NOT span the entire vertical section
207
        plot (this is intentional, as a flight with two equal
208
        waypoints makes no sense).
209
        """
210
        # Compute intermediate points.
211
        lats, lons, times = path_points(
1✔
212
            [wp.lat for wp in wps],
213
            [wp.lon for wp in wps],
214
            times=[wp.utc_time for wp in wps],
215
            numpoints=self.numintpoints, connection="greatcircle")
216

217
        if lats is not None:
1✔
218
            # Determine indices of waypoints in list of intermediate points.
219
            # Store these indices.
220
            waypoints = [[wp.lat, wp.lon] for wp in wps]
1✔
221
            intermediate_indexes = []
1✔
222
            ipoint = 0
1✔
223
            for i, (lat, lon) in enumerate(zip(lats, lons)):
1✔
224
                if abs(lat - waypoints[ipoint][0]) < 1E-10 and abs(lon - waypoints[ipoint][1]) < 1E-10:
1✔
225
                    intermediate_indexes.append(i)
1✔
226
                    ipoint += 1
1✔
227
                if ipoint >= len(waypoints):
1✔
228
                    break
1✔
229

230
            self.intermediate_indexes = intermediate_indexes
1✔
231
            self.ilats = lats
1✔
232
            self.ilons = lons
1✔
233
            self.itimes = times
1✔
234

235
            # Call super method.
236
            super().update_from_waypoints(wps)
1✔
237

238
    def transform_waypoint(self, wps_list, index):
1✔
239
        """Returns the x-index of the waypoint and its pressure.
240
        """
241
        return (self.intermediate_indexes[index], wps_list[index].pressure)
1✔
242

243

244
class PathH(WaypointsPath):
1✔
245
    """Class to represent a horizontal flight track path, waypoints connected
246
       by great circle segments.
247

248

249
    Provides to kinds of vertex data: (1) Waypoint vertices (wp_vertices) and
250
    (2) intermediate great circle vertices (vertices).
251
    """
252

253
    def __init__(self, *args, **kwargs):
1✔
254
        """The constructor required the additional keyword 'map' to transform
255
           vertex cooredinates between lat/lon and projection coordinates.
256
        """
257
        self.map = kwargs.pop("map")
1✔
258
        super().__init__(*args, **kwargs)
1✔
259
        self.wp_codes = np.array([], dtype=np.uint8)
1✔
260
        self.wp_vertices = np.array([])
1✔
261

262
    def transform_waypoint(self, wps_list, index):
1✔
263
        """Transform lon/lat to projection coordinates.
264
        """
265
        return self.map(wps_list[index].lon, wps_list[index].lat)
1✔
266

267
    def update_from_waypoints(self, wps):
1✔
268
        """Get waypoint coordinates from flight track model, get
269
           intermediate great circle vertices from map instance.
270
        """
271
        Path = mpath.Path
1✔
272

273
        # Waypoint coordinates.
274
        if len(wps) > 0:
1✔
275
            pathdata = [(Path.MOVETO, self.transform_waypoint(wps, 0))]
1✔
276
            for i in range(len(wps[1:])):
1✔
277
                pathdata.append((Path.LINETO, self.transform_waypoint(wps, i + 1)))
1✔
278
            wp_codes, wp_vertices = list(zip(*pathdata))
1✔
279
            self.wp_codes = np.array(wp_codes, dtype=np.uint8)
1✔
280
            self.wp_vertices = np.array(wp_vertices)
1✔
281

282
            # Coordinates of intermediate great circle points.
283
            lons, lats = list(zip(*[(wp.lon, wp.lat) for wp in wps]))
1✔
284
            x, y = self.map.gcpoints_path(lons, lats)
1✔
285

286
            if len(x) > 0:
1✔
287
                pathdata = [(Path.MOVETO, (x[0], y[0]))]
1✔
288
                for i in range(len(x[1:])):
1✔
289
                    pathdata.append((Path.LINETO, (x[i + 1], y[i + 1])))
1✔
290
            codes, vertices = list(zip(*pathdata))
1✔
291
            self.codes = np.array(codes, dtype=np.uint8)
1✔
292
            self.vertices = np.array(vertices)
1✔
293

294
    def index_of_closest_segment(self, x, y, eps=5):
1✔
295
        """Find the index of the edge closest to the specified point at x,y.
296

297
        If the point is not within eps (in the same coordinates as x,y) of
298
        any edge in the path, the index of the closest end point is returned.
299
        """
300
        # Determine the index of the great circle vertex that is closest to the
301
        # given point.
302
        gcvertex_index = super().index_of_closest_segment(x, y, eps)
1✔
303

304
        # Determine the waypoint index that corresponds to the great circle
305
        # index. If the best index is to append the waypoint to the end of
306
        # the flight track, directly return this index.
307
        if gcvertex_index == len(self.vertices):
1✔
308
            return len(self.wp_vertices)
1✔
309
        # Otherwise iterate through the list of great circle points and remember
310
        # the index of the last "real" waypoint that was encountered in this
311
        # list.
312
        i = 0  # index for great circle points
1✔
313
        j = 0  # index for waypoints
1✔
314
        wp_vertex = self.wp_vertices[j]
1✔
315
        while (i < gcvertex_index):
1✔
316
            vertex = self.vertices[i]
1✔
317
            if vertex[0] == wp_vertex[0] and vertex[1] == wp_vertex[1]:
1✔
318
                j += 1
1✔
319
                wp_vertex = self.wp_vertices[j]
1✔
320
            i += 1
1✔
321
        return j
1✔
322

323

324
class PathPlotter:
1✔
325
    """An interactive matplotlib path editor. Allows vertices of a path patch
326
       to be interactively picked and moved around.
327

328
    Superclass for the path editors used by the top and side views of the
329
    Mission Support System.
330
    """
331

332
    showverts = True  # show the vertices of the path patch
1✔
333

334
    # picking points
335

336
    def __init__(self, ax, mplpath=None,
1✔
337
                 facecolor='blue', edgecolor='yellow',
338
                 linecolor='blue', markerfacecolor='red',
339
                 marker='o', label_waypoints=True):
340
        """The constructor initializes the path patches, overlying line
341
           plot and connects matplotlib signals.
342

343
        Arguments:
344
        ax -- matplotlib.Axes object into which the path should be drawn.
345
        waypoints -- flighttrack.WaypointsModel instance.
346
        mplpath -- matplotlib.path.Path instance
347
        facecolor -- facecolor of the patch
348
        edgecolor -- edgecolor of the patch
349
        linecolor -- color of the line plotted above the patch edges
350
        markerfacecolor -- color of the markers that represent the waypoints
351
        marker -- symbol of the markers that represent the waypoints, see
352
                  matplotlib plot() or scatter() routines for more information.
353
        label_waypoints -- put labels with the waypoint numbers on the waypoints.
354
        """
355
        self.waypoints_model = None
1✔
356
        self.background = None
1✔
357

358
        # Create a PathPatch representing the interactively editable path
359
        # (vertical profile or horizontal flight track in subclasses).
360
        path = mplpath
1✔
361
        pathpatch = mpatches.PathPatch(path, facecolor=facecolor,
1✔
362
                                       edgecolor=edgecolor, alpha=0.15)
363
        ax.add_patch(pathpatch)
1✔
364

365
        self.ax = ax
1✔
366
        self.path = path
1✔
367
        self.pathpatch = pathpatch
1✔
368
        self.pathpatch.set_animated(True)  # ensure correct redrawing
1✔
369

370
        # Draw the line representing flight track or profile (correct
371
        # vertices handling for the line needs to be ensured in subclasses).
372
        x, y = list(zip(*self.pathpatch.get_path().vertices))
1✔
373
        self.line, = self.ax.plot(x, y, color=linecolor,
1✔
374
                                  marker=marker, linewidth=2,
375
                                  markerfacecolor=markerfacecolor,
376
                                  animated=True)
377

378
        # List to accommodate waypoint labels.
379
        self.wp_labels = []
1✔
380
        self.label_waypoints = label_waypoints
1✔
381

382
        # Connect mpl events to handler routines: mouse movements and picks.
383
        canvas = self.ax.figure.canvas
1✔
384
        canvas.mpl_connect('draw_event', self.draw_callback)
1✔
385
        self.canvas = canvas
1✔
386

387
    def draw_callback(self, event):
1✔
388
        """Called when the figure is redrawn. Stores background data (for later
389
           restoration) and draws artists.
390
        """
391
        self.background = self.canvas.copy_from_bbox(self.ax.bbox)
1✔
392
        try:
1✔
393
            # TODO review
394
            self.ax.draw_artist(self.pathpatch)
1✔
395
        except ValueError as ex:
×
396
            # When using Matplotlib 1.2, "ValueError: Invalid codes array."
397
            # occurs. The error occurs in Matplotlib's backend_agg.py/draw_path()
398
            # function. However, when I print the codes array in that function,
399
            # it looks fine -- correct length and correct codes. I can't figure
400
            # out why that error occurs.. (mr, 2013Feb08).
401
            logging.error("%s %s", ex, type(ex))
×
402
        self.ax.draw_artist(self.line)
1✔
403
        for t in self.wp_labels:
1✔
404
            self.ax.draw_artist(t)
1✔
405
            # The blit() method makes problems (distorted figure background). However,
406
            # I don't see why it is needed -- everything seems to work without this line.
407
            # (see infos on http://www.scipy.org/Cookbook/Matplotlib/Animations).
408
            # self.canvas.blit(self.ax.bbox)
409

410
    def set_vertices_visible(self, showverts=True):
1✔
411
        """Set the visibility of path vertices (the line plot).
412
        """
413
        self.showverts = showverts
1✔
414
        self.line.set_visible(self.showverts)
1✔
415
        for t in self.wp_labels:
1✔
416
            t.set_visible(showverts and self.label_waypoints)
1✔
417
        if not self.showverts:
1✔
418
            self._ind = None
×
419
        self.canvas.draw()
1✔
420

421
    def set_patch_visible(self, showpatch=True):
1✔
422
        """Set the visibility of path patch (the area).
423
        """
424
        self.pathpatch.set_visible(showpatch)
1✔
425
        self.canvas.draw()
1✔
426

427
    def set_labels_visible(self, visible=True):
1✔
428
        """Set the visibility of the waypoint labels.
429
        """
430
        self.label_waypoints = visible
1✔
431
        for t in self.wp_labels:
1✔
432
            t.set_visible(self.showverts and self.label_waypoints)
1✔
433
        self.canvas.draw()
1✔
434

435
    def set_path_color(self, line_color=None, marker_facecolor=None,
1✔
436
                       patch_facecolor=None):
437
        """Set the color of the path patch elements.
438
        Arguments (options):
439
        line_color -- color of the path line
440
        marker_facecolor -- color of the waypoints
441
        patch_facecolor -- color of the patch covering the path area
442
        """
443
        if line_color is not None:
1✔
444
            self.line.set_color(line_color)
1✔
445
        if marker_facecolor is not None:
1✔
446
            self.line.set_markerfacecolor(marker_facecolor)
1✔
447
        if patch_facecolor is not None:
1✔
448
            self.pathpatch.set_facecolor(patch_facecolor)
1✔
449

450
    def update_from_waypoints(self, wps):
1✔
451
        self.pathpatch.get_path().update_from_waypoints(wps)
1✔
452

453

454
class PathH_Plotter(PathPlotter):
1✔
455
    def __init__(self, mplmap, mplpath=None, facecolor='none', edgecolor='none',
1✔
456
                 linecolor='blue', markerfacecolor='red', show_marker=True,
457
                 label_waypoints=True):
458
        super().__init__(mplmap.ax, mplpath=PathH([[0, 0]], map=mplmap),
1✔
459
                         facecolor='none', edgecolor='none', linecolor=linecolor,
460
                         markerfacecolor=markerfacecolor, marker='',
461
                         label_waypoints=label_waypoints)
462
        self.map = mplmap
1✔
463
        self.wp_scatter = None
1✔
464
        self.markerfacecolor = markerfacecolor
1✔
465
        self.tangent_lines = None
1✔
466
        self.show_tangent_points = False
1✔
467
        self.solar_lines = None
1✔
468
        self.show_marker = show_marker
1✔
469
        self.show_solar_angle = None
1✔
470
        self.remote_sensing = None
1✔
471

472
    def appropriate_epsilon(self, px=5):
1✔
473
        """Determine an epsilon value appropriate for the current projection and
474
           figure size.
475
        The epsilon value gives the distance required in map projection
476
        coordinates that corresponds to approximately px Pixels in screen
477
        coordinates. The value can be used to find the line/point that is
478
        closest to a click while discarding clicks that are too far away
479
        from any geometry feature.
480
        """
481
        # (bounds = left, bottom, width, height)
482
        ax_bounds = self.ax.bbox.bounds
1✔
483
        width = int(round(ax_bounds[2]))
1✔
484
        map_delta_x = np.hypot(self.map.llcrnry - self.map.urcrnry, self.map.llcrnrx - self.map.urcrnrx)
1✔
485
        map_coords_per_px_x = map_delta_x / width
1✔
486
        return map_coords_per_px_x * px
1✔
487

488
    def redraw_path(self, wp_vertices=None, waypoints_model_data=None):
1✔
489
        """Redraw the matplotlib artists that represent the flight track
490
           (path patch, line and waypoint scatter).
491
        If waypoint vertices are specified, they will be applied to the
492
        graphics output. Otherwise the vertex array obtained from the path
493
        patch will be used.
494
        """
495
        if waypoints_model_data is None:
1✔
496
            waypoints_model_data = []
×
497
        if wp_vertices is None:
1✔
498
            wp_vertices = self.pathpatch.get_path().wp_vertices
1✔
499
            if len(wp_vertices) == 0:
1✔
500
                raise IOError("mscolab session expired")
×
501
            vertices = self.pathpatch.get_path().vertices
1✔
502
        else:
503
            # If waypoints have been provided, compute the intermediate
504
            # great circle points for the line instance.
UNCOV
505
            x, y = list(zip(*wp_vertices))
×
UNCOV
506
            lons, lats = self.map(x, y, inverse=True)
×
UNCOV
507
            x, y = self.map.gcpoints_path(lons, lats)
×
UNCOV
508
            vertices = list(zip(x, y))
×
509

510
        # Set the line to display great circle points, remove existing
511
        # waypoints scatter instance and draw a new one. This is
512
        # necessary as scatter() does not provide a set_data method.
513
        self.line.set_data(list(zip(*vertices)))
1✔
514

515
        if self.tangent_lines is not None:
1✔
516
            self.tangent_lines.remove()
1✔
517
            self.tangent_lines = None
1✔
518
        if self.solar_lines is not None:
1✔
519
            self.solar_lines.remove()
×
520
            self.solar_lines = None
×
521

522
        if len(waypoints_model_data) > 0:
1✔
523
            wp_heights = [(wpd.flightlevel * 0.03048) for wpd in waypoints_model_data]
1✔
524
            wp_times = [wpd.utc_time for wpd in waypoints_model_data]
1✔
525

526
            if self.show_tangent_points:
1✔
527
                assert self.remote_sensing is not None
1✔
528
                self.tangent_lines = self.remote_sensing.compute_tangent_lines(
1✔
529
                    self.map, wp_vertices, wp_heights)
530
                self.ax.add_collection(self.tangent_lines)
1✔
531

532
            if self.show_solar_angle is not None:
1✔
533
                assert self.remote_sensing is not None
1✔
534
                self.solar_lines = self.remote_sensing.compute_solar_lines(
1✔
535
                    self.map, wp_vertices, wp_heights, wp_times, self.show_solar_angle)
536
                self.ax.add_collection(self.solar_lines)
1✔
537

538
        if self.wp_scatter is not None:
1✔
539
            self.wp_scatter.remove()
1✔
540
            self.wp_scatter = None
1✔
541

542
        x, y = list(zip(*wp_vertices))
1✔
543

544
        if self.map.projection == "cyl":  # hack for wraparound
1✔
545
            x = np.array(x)
1✔
546
            x[x < self.map.llcrnrlon] += 360
1✔
547
            x[x > self.map.urcrnrlon] -= 360
1✔
548
        # (animated is important to remove the old scatter points from the map)
549
        self.wp_scatter = self.ax.scatter(
1✔
550
            x, y, color=self.markerfacecolor, s=20, zorder=3, animated=True, visible=self.show_marker)
551

552
        # Draw waypoint labels.
553
        label_offset = self.appropriate_epsilon(px=5)
1✔
554
        for wp_label in self.wp_labels:
1✔
555
            wp_label.remove()
1✔
556
        self.wp_labels = []  # remove doesn't seem to be necessary
1✔
557
        for i, wpd in enumerate(waypoints_model_data):
1✔
558
            textlabel = str(i)
1✔
559
            if wpd.location != "":
1✔
560
                textlabel = f"{wpd.location}"
1✔
561
            label_offset = 0
1✔
562
            text = self.ax.text(
1✔
563
                x[i] + label_offset, y[i] + label_offset, textlabel,
564
                bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.6, "edgecolor": "none"},
565
                fontweight="bold", zorder=4, animated=True, clip_on=True,
566
                visible=self.showverts and self.label_waypoints)
567
            self.wp_labels.append(text)
1✔
568

569
        # Redraw the artists.
570
        if self.background:
1✔
571
            self.canvas.restore_region(self.background)
1✔
572
        try:
1✔
573
            self.ax.draw_artist(self.pathpatch)
1✔
574
        except ValueError as error:
×
575
            logging.debug("ValueError Exception '%s'", error)
×
576
        self.ax.draw_artist(self.line)
1✔
577
        if self.wp_scatter is not None:
1✔
578
            self.ax.draw_artist(self.wp_scatter)
1✔
579

580
        for wp_label in self.wp_labels:
1✔
581
            self.ax.draw_artist(wp_label)
1✔
582
        if self.show_tangent_points:
1✔
583
            self.ax.draw_artist(self.tangent_lines)
1✔
584
        if self.show_solar_angle is not None:
1✔
585
            self.ax.draw_artist(self.solar_lines)
1✔
586
        self.canvas.blit(self.ax.bbox)
1✔
587

588
    def draw_callback(self, event):
1✔
589
        """Extends PathInteractor.draw_callback() by drawing the scatter
590
           instance.
591
        """
592
        super().draw_callback(event)
1✔
593
        if self.wp_scatter:
1✔
594
            self.ax.draw_artist(self.wp_scatter)
1✔
595
        if self.show_solar_angle:
1✔
596
            self.ax.draw_artist(self.solar_lines)
×
597
        if self.show_tangent_points:
1✔
598
            self.ax.draw_artist(self.tangent_lines)
×
599

600
    def set_path_color(self, line_color=None, marker_facecolor=None,
1✔
601
                       patch_facecolor=None):
602
        """Set the color of the path patch elements.
603
        Arguments (options):
604
        line_color -- color of the path line
605
        marker_facecolor -- color of the waypoints
606
        patch_facecolor -- color of the patch covering the path area
607
        """
608
        super().set_path_color(line_color, marker_facecolor,
1✔
609
                               patch_facecolor)
610
        if marker_facecolor is not None and self.wp_scatter is not None:
1✔
611
            self.wp_scatter.set_facecolor(marker_facecolor)
1✔
612
            self.wp_scatter.set_edgecolor(marker_facecolor)
1✔
613
            self.markerfacecolor = marker_facecolor
1✔
614

615
    def set_vertices_visible(self, showverts=True):
1✔
616
        """Set the visibility of path vertices (the line plot).
617
        """
618
        super().set_vertices_visible(showverts)
1✔
619
        if self.wp_scatter is not None:
1✔
620
            self.wp_scatter.set_visible(self.show_marker)
1✔
621

622
    def set_tangent_visible(self, visible):
1✔
623
        self.show_tangent_points = visible
1✔
624

625
    def set_solar_angle_visible(self, visible):
1✔
626
        self.show_solar_angle = visible
1✔
627

628
    def set_remote_sensing(self, ref):
1✔
629
        self.remote_sensing = ref
1✔
630

631

632
class PathV_Plotter(PathPlotter):
1✔
633
    def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
634
        """Constructor passes a PathV instance its parent.
635

636
        Arguments:
637
        ax -- matplotlib.Axes object into which the path should be drawn.
638
        waypoints -- flighttrack.WaypointsModel instance.
639
        numintpoints -- number of intermediate interpolation points. The entire
640
                        flight track will be interpolated to this number of
641
                        points.
642
        redrawXAxis -- callback function to redraw the x-axis on path changes.
643
        """
644
        super().__init__(
1✔
645
            ax=ax, mplpath=PathV([[0, 0]], numintpoints=numintpoints))
646
        self.numintpoints = numintpoints
1✔
647
        self.redraw_xaxis = redraw_xaxis
1✔
648
        self.clear_figure = clear_figure
1✔
649

650
    def get_num_interpolation_points(self):
1✔
651
        return self.numintpoints
1✔
652

653
    def redraw_path(self, vertices=None, waypoints_model_data=None):
1✔
654
        """Redraw the matplotlib artists that represent the flight track
655
           (path patch and line).
656

657
        If vertices are specified, they will be applied to the graphics
658
        output. Otherwise the vertex array obtained from the path patch
659
        will be used.
660
        """
661
        if waypoints_model_data is None:
1✔
662
            waypoints_model_data = []
×
663
        if vertices is None:
1✔
664
            vertices = self.pathpatch.get_path().vertices
1✔
665
        self.line.set_data(list(zip(*vertices)))
1✔
666
        x, y = list(zip(*vertices))
1✔
667
        # Draw waypoint labels.
668
        for wp_label in self.wp_labels:
1✔
669
            wp_label.remove()
1✔
670
        self.wp_labels = []  # remove doesn't seem to be necessary
1✔
671
        for i, wpd, in enumerate(waypoints_model_data):
1✔
672
            textlabel = f"{str(i):}   "
1✔
673
            if wpd.location != "":
1✔
674
                textlabel = f"{wpd.location:}   "
1✔
675
            text = self.ax.text(
1✔
676
                x[i], y[i],
677
                textlabel,
678
                bbox=dict(boxstyle="round",
679
                          facecolor="white",
680
                          alpha=0.5,
681
                          edgecolor="none"),
682
                fontweight="bold",
683
                zorder=4,
684
                rotation=90,
685
                animated=True,
686
                clip_on=True,
687
                visible=self.showverts and self.label_waypoints)
688
            self.wp_labels.append(text)
1✔
689

690
        if self.background:
1✔
691
            self.canvas.restore_region(self.background)
1✔
692
        try:
1✔
693
            self.ax.draw_artist(self.pathpatch)
1✔
694
        except ValueError as error:
×
695
            logging.error("ValueError Exception %s", error)
×
696
        self.ax.draw_artist(self.line)
1✔
697
        for wp_label in self.wp_labels:
1✔
698
            self.ax.draw_artist(wp_label)
1✔
699
        self.canvas.blit(self.ax.bbox)
1✔
700

701
    def get_lat_lon(self, event, wpm):
1✔
702
        x = event.xdata
1✔
703
        vertices = self.pathpatch.get_path().vertices
1✔
704
        best_index = 1
1✔
705
        # if x axis has increasing coordinates
706
        if vertices[-1, 0] > vertices[0, 0]:
1✔
707
            for index, vertex in enumerate(vertices):
1✔
708
                if x >= vertex[0]:
1✔
709
                    best_index = index + 1
1✔
710
        # if x axis has decreasing coordinates
711
        else:
712
            for index, vertex in enumerate(vertices):
×
713
                if x <= vertex[0]:
×
714
                    best_index = index + 1
×
715
        # number of subcoordinates is determined by difference in x coordinates
716
        number_of_intermediate_points = math.floor(vertices[best_index, 0] - vertices[best_index - 1, 0])
1✔
717
        vert_xs, vert_ys = latlon_points(
1✔
718
            vertices[best_index - 1, 0], vertices[best_index - 1, 1],
719
            vertices[best_index, 0], vertices[best_index, 1],
720
            number_of_intermediate_points, connection="linear")
721
        lats, lons = latlon_points(
1✔
722
            wpm[best_index - 1].lat, wpm[best_index - 1].lon,
723
            wpm[best_index].lat, wpm[best_index].lon,
724
            number_of_intermediate_points, connection="greatcircle")
725

726
        # best_index1 is the best index among the intermediate coordinates to fit the hovered point
727
        # if x axis has increasing coordinates
728
        best_index1 = np.argmin(abs(vert_xs - x))
1✔
729
        # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood
730
        return (lats[best_index1], lons[best_index1]), best_index
1✔
731

732

733
class PathL_Plotter(PathPlotter):
1✔
734
    def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
735
        """Constructor passes a PathV instance its parent.
736

737
        Arguments:
738
        ax -- matplotlib.Axes object into which the path should be drawn.
739
        waypoints -- flighttrack.WaypointsModel instance.
740
        numintpoints -- number of intermediate interpolation points. The entire
741
                        flight track will be interpolated to this number of
742
                        points.
743
        redrawXAxis -- callback function to redraw the x-axis on path changes.
744
        """
745
        super().__init__(
1✔
746
            ax=ax, marker="", mplpath=PathV([[0, 0]], numintpoints=numintpoints))
747
        self.numintpoints = numintpoints
1✔
748
        self.redraw_xaxis = redraw_xaxis
1✔
749
        self.clear_figure = clear_figure
1✔
750

751
    def get_num_interpolation_points(self):
1✔
752
        return self.numintpoints
1✔
753

754
    def get_lat_lon(self, event, wpm):
1✔
755
        x = event.xdata
×
756
        vertices = self.pathpatch.get_path().vertices
×
757
        best_index = 1
×
758
        # if x axis has increasing coordinates
759
        if vertices[-1, 0] > vertices[0, 0]:
×
760
            for index, vertex in enumerate(vertices):
×
761
                if x >= vertex[0]:
×
762
                    best_index = index + 1
×
763
        # if x axis has decreasing coordinates
764
        else:
765
            for index, vertex in enumerate(vertices):
×
766
                if x <= vertex[0]:
×
767
                    best_index = index + 1
×
768
        # number of subcoordinates is determined by difference in x coordinates
769
        number_of_intermediate_points = int(abs(vertices[best_index, 0] - vertices[best_index - 1, 0]))
×
770
        vert_xs, vert_ys = latlon_points(
×
771
            vertices[best_index - 1, 0], vertices[best_index - 1, 1],
772
            vertices[best_index, 0], vertices[best_index, 1],
773
            number_of_intermediate_points, connection="linear")
774
        lats, lons = latlon_points(
×
775
            wpm.waypoint_data(best_index - 1).lat, wpm.waypoint_data(best_index - 1).lon,
776
            wpm.waypoint_data(best_index).lat, wpm.waypoint_data(best_index).lon,
777
            number_of_intermediate_points, connection="greatcircle")
778
        alts = np.linspace(wpm.waypoint_data(best_index - 1).flightlevel,
×
779
                           wpm.waypoint_data(best_index).flightlevel, number_of_intermediate_points)
780

781
        best_index1 = np.argmin(abs(vert_xs - x))
×
782
        # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood
783
        return (lats[best_index1], lons[best_index1], alts[best_index1]), best_index
×
784

785

786
class PathInteractor(QtCore.QObject):
1✔
787
    """An interactive matplotlib path editor. Allows vertices of a path patch
788
       to be interactively picked and moved around.
789
    Superclass for the path editors used by the top and side views of the
790
    Mission Support System.
791
    """
792

793
    showverts = True  # show the vertices of the path patch
1✔
794
    epsilon = 12
1✔
795

796
    # picking points
797

798
    def __init__(self, plotter, waypoints=None):
1✔
799
        """The constructor initializes the path patches, overlying line
800
           plot and connects matplotlib signals.
801
        Arguments:
802
        ax -- matplotlib.Axes object into which the path should be drawn.
803
        waypoints -- flighttrack.WaypointsModel instance.
804
        mplpath -- matplotlib.path.Path instance
805
        facecolor -- facecolor of the patch
806
        edgecolor -- edgecolor of the patch
807
        linecolor -- color of the line plotted above the patch edges
808
        markerfacecolor -- color of the markers that represent the waypoints
809
        marker -- symbol of the markers that represent the waypoints, see
810
                  matplotlib plot() or scatter() routines for more information.
811
        label_waypoints -- put labels with the waypoint numbers on the waypoints.
812
        """
813
        QtCore.QObject.__init__(self)
1✔
814
        self._ind = None  # the active vertex
1✔
815
        self.plotter = plotter
1✔
816

817
        # Set the waypoints model, connect to the change() signals of the model
818
        # and redraw the figure.
819
        self.waypoints_model = None
1✔
820
        self.set_waypoints_model(waypoints)
1✔
821

822
    def set_waypoints_model(self, waypoints):
1✔
823
        """Change the underlying waypoints data structure. Disconnect change()
824
           signals of an already existing model and connect to the new model.
825
           Redraw the map.
826
        """
827
        # If a model exists, disconnect from the old change() signals.
828
        wpm = self.waypoints_model
1✔
829
        if wpm:
1✔
830
            wpm.dataChanged.disconnect(self.qt_data_changed_listener)
1✔
831
            wpm.rowsInserted.disconnect(self.qt_insert_remove_point_listener)
1✔
832
            wpm.rowsRemoved.disconnect(self.qt_insert_remove_point_listener)
1✔
833
        # Set the new waypoints model.
834
        self.waypoints_model = waypoints
1✔
835
        # Connect to the new model's signals.
836
        wpm = self.waypoints_model
1✔
837
        wpm.dataChanged.connect(self.qt_data_changed_listener)
1✔
838
        wpm.rowsInserted.connect(self.qt_insert_remove_point_listener)
1✔
839
        wpm.rowsRemoved.connect(self.qt_insert_remove_point_listener)
1✔
840
        # Redraw.
841
        self.plotter.update_from_waypoints(wpm.all_waypoint_data())
1✔
842
        self.redraw_figure()
1✔
843

844
    def qt_insert_remove_point_listener(self, index, first, last):
1✔
845
        """Listens to rowsInserted() and rowsRemoved() signals emitted
846
           by the flight track data model. The view can thus react to
847
           data changes induced by another view (table, side view).
848
        """
849
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
850
        self.redraw_figure()
1✔
851

852
    def qt_data_changed_listener(self, index1, index2):
1✔
853
        """Listens to dataChanged() signals emitted by the flight track
854
           data model. The view can thus react to data changes induced
855
           by another view (table, top view).
856
        """
857
        # REIMPLEMENT IN SUBCLASSES.
858
        pass
×
859

860
    def get_ind_under_point(self, event):
1✔
861
        """Get the index of the waypoint vertex under the point
862
           specified by event within epsilon tolerance.
863
        Uses display coordinates.
864
        If no waypoint vertex is found, None is returned.
865
        """
866
        xy = np.asarray(self.plotter.pathpatch.get_path().vertices)
1✔
867
        xyt = self.plotter.pathpatch.get_transform().transform(xy)
1✔
868
        xt, yt = xyt[:, 0], xyt[:, 1]
1✔
869
        d = np.hypot(xt - event.x, yt - event.y)
1✔
870
        ind = d.argmin()
1✔
871
        if d[ind] >= self.epsilon:
1✔
872
            ind = None
1✔
873
        return ind
1✔
874

875
    def button_press_callback(self, event):
1✔
876
        """Called whenever a mouse button is pressed. Determines the index of
877
           the vertex closest to the click, as long as a vertex is within
878
           epsilon tolerance of the click.
879
        """
880
        if not self.plotter.showverts:
1✔
881
            return
×
882
        if event.inaxes is None:
1✔
883
            return
1✔
884
        if event.button != 1:
1✔
885
            return
×
886
        self._ind = self.get_ind_under_point(event)
1✔
887

888
    def confirm_delete_waypoint(self, row):
1✔
889
        """Open a QMessageBox and ask the user if he really wants to
890
           delete the waypoint at index <row>.
891

892
        Returns TRUE if the user confirms the deletion.
893

894
        If the flight track consists of only two points deleting a waypoint
895
        is not possible. In this case the user is informed correspondingly.
896
        """
897
        wps = self.waypoints_model.all_waypoint_data()
1✔
898
        if len(wps) < 3:
1✔
899
            QtWidgets.QMessageBox.warning(
×
900
                None, "Remove waypoint",
901
                "Cannot remove waypoint, the flight track needs to consist "
902
                "of at least two points.")
903
            return False
×
904
        else:
905
            wp = wps[row]
1✔
906
            return QtWidgets.QMessageBox.question(
1✔
907
                None, "Remove waypoint",
908
                f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?",
909
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
910
                QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes
911

912

913
class VPathInteractor(PathInteractor):
1✔
914
    """Subclass of PathInteractor that implements an interactively editable
915
       vertical profile of the flight track.
916
    """
917
    signal_get_vsec = QtCore.pyqtSignal(name="get_vsec")
1✔
918

919
    def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
920
        """Constructor passes a PathV instance its parent.
921

922
        Arguments:
923
        ax -- matplotlib.Axes object into which the path should be drawn.
924
        waypoints -- flighttrack.WaypointsModel instance.
925
        numintpoints -- number of intermediate interpolation points. The entire
926
                        flight track will be interpolated to this number of
927
                        points.
928
        redrawXAxis -- callback function to redraw the x-axis on path changes.
929
        """
930
        plotter = PathV_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints)
1✔
931
        self.redraw_xaxis = redraw_xaxis
1✔
932
        self.clear_figure = clear_figure
1✔
933
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
934

935
    def redraw_figure(self):
1✔
936
        """For the side view, changes in the horizontal position of a waypoint
937
           (including moved waypoints, new or deleted waypoints) make a complete
938
           redraw of the figure necessary.
939

940
           Calls the callback function 'redrawXAxis()'.
941
        """
942
        self.plotter.redraw_path(waypoints_model_data=self.waypoints_model.all_waypoint_data())
1✔
943
        # emit signal to redraw map
944
        self.signal_get_vsec.emit()
1✔
945
        if self.redraw_xaxis is not None:
1✔
946
            try:
1✔
947
                self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes)
1✔
948
            except AttributeError as err:
×
949
                logging.debug("%s" % err)
×
950

951
        self.plotter.ax.figure.canvas.draw()
1✔
952

953
    def button_release_delete_callback(self, event):
1✔
954
        """Called whenever a mouse button is released.
955
        """
956
        if not self.showverts or event.button != 1:
×
957
            return
×
958

959
        if self._ind is not None:
×
960
            if self.confirm_delete_waypoint(self._ind):
×
961
                # removeRows() will trigger a signal that will redraw the path.
962
                self.waypoints_model.removeRows(self._ind)
×
963
            self._ind = None
×
964

965
    def button_release_insert_callback(self, event):
1✔
966
        """Called whenever a mouse button is released.
967

968
        From the click event's coordinates, best_index is calculated as
969
        the index of a vertex whose x coordinate > clicked x coordinate.
970
        This is the position where the waypoint is to be inserted.
971

972
        'lat' and 'lon' are calculated as an average of each of the first waypoint
973
        in left and right neighbourhood of inserted waypoint.
974

975
        The coordinates are checked against "locations" defined in msui' config.
976

977
        A new waypoint with the coordinates, and name is inserted into the waypoints_model.
978
        """
979
        if not self.showverts or event.button != 1 or event.inaxes is None:
1✔
980
            return
1✔
981
        y = event.ydata
1✔
982
        wpm = self.waypoints_model
1✔
983
        flightlevel = float(pressure2flightlevel(y * units.Pa).magnitude)
1✔
984
        # round flightlevel to the nearest multiple of five (legal level)
985
        flightlevel = 5.0 * round(flightlevel / 5)
1✔
986
        [lat, lon], best_index = self.plotter.get_lat_lon(event, wpm.all_waypoint_data())
1✔
987
        loc = find_location(lat, lon)  # skipped tolerance which uses appropriate_epsilon_km
1✔
988
        if loc is not None:
1✔
989
            (lat, lon), location = loc
×
990
        else:
991
            location = ""
1✔
992
        new_wp = ft.Waypoint(lat, lon, flightlevel, location=location)
1✔
993
        # insertRows() will trigger a signal that will redraw the path.
994
        wpm.insertRows(best_index, rows=1, waypoints=[new_wp])
1✔
995

996
        self._ind = None
1✔
997

998
    def get_lat_lon(self, event):
1✔
999
        lat_lon, ind = self.plotter.get_lat_lon(event, self.waypoints_model.all_waypoint_data())
1✔
1000
        return lat_lon, ind
1✔
1001

1002
    def button_release_move_callback(self, event):
1✔
1003
        """Called whenever a mouse button is released.
1004
        """
1005
        if not self.showverts or event.button != 1:
×
1006
            return
×
1007

1008
        if self._ind is not None:
×
1009
            # Submit the new pressure (the only value that can be edited
1010
            # in the side view) to the data model.
1011
            vertices = self.plotter.pathpatch.get_path().vertices
×
1012
            pressure = vertices[self._ind, 1]
×
1013
            # http://doc.trolltech.com/4.3/qabstractitemmodel.html#createIndex
1014
            qt_index = self.waypoints_model.createIndex(self._ind, ft.PRESSURE)
×
1015
            # NOTE: QVariant cannot handle numpy.float64 types, hence convert
1016
            # to float().
1017
            self.waypoints_model.setData(qt_index, QtCore.QVariant(float(pressure / 100.)))
×
1018

1019
        self._ind = None
×
1020

1021
    def motion_notify_callback(self, event):
1✔
1022
        """Called on mouse movement. Redraws the path if a vertex has been
1023
           picked and is being dragged.
1024

1025
        In the side view, the horizontal position of a waypoint is locked.
1026
        Hence, points can only be moved in the vertical direction (y position
1027
        in this view).
1028
        """
1029
        if not self.showverts or self._ind is None or event.inaxes is None or event.button != 1:
×
1030
            return
×
1031
        vertices = self.plotter.pathpatch.get_path().vertices
×
1032
        # Set the new y position of the vertex to event.ydata. Keep the
1033
        # x coordinate.
1034
        vertices[self._ind] = vertices[self._ind, 0], event.ydata
×
1035
        self.plotter.redraw_path(vertices)
×
1036

1037
    def qt_data_changed_listener(self, index1, index2):
1✔
1038
        """Listens to dataChanged() signals emitted by the flight track
1039
           data model. The side view can thus react to data changes
1040
           induced by another view (table, top view).
1041
        """
1042
        # If the altitude of a point has changed, only the plotted flight
1043
        # profile needs to be redrawn (redraw_path()). If the horizontal
1044
        # position of a waypoint has changed, the entire figure needs to be
1045
        # redrawn, as this affects the x-position of all points.
1046
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
1047
        if index1.column() in [ft.FLIGHTLEVEL, ft.PRESSURE, ft.LOCATION]:
1✔
1048
            self.plotter.redraw_path(
×
1049
                self.plotter.pathpatch.get_path().vertices, self.waypoints_model.all_waypoint_data())
1050
        elif index1.column() in [ft.LAT, ft.LON]:
1✔
1051
            self.redraw_figure()
×
1052
        elif index1.column() in [ft.TIME_UTC]:
1✔
1053
            if self.redraw_xaxis is not None:
1✔
1054
                self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes)
1✔
1055

1056

1057
class LPathInteractor(PathInteractor):
1✔
1058
    """
1059
    Subclass of PathInteractor that implements a non interactive linear profile of the flight track.
1060
    """
1061
    signal_get_lsec = QtCore.pyqtSignal(name="get_lsec")
1✔
1062

1063
    def __init__(self, ax, waypoints, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
1064
        """Constructor passes a PathV instance its parent.
1065

1066
        Arguments:
1067
        ax -- matplotlib.Axes object into which the path should be drawn.
1068
        waypoints -- flighttrack.WaypointsModel instance.
1069
        numintpoints -- number of intermediate interpolation points. The entire
1070
                        flight track will be interpolated to this number of
1071
                        points.
1072
        redrawXAxis -- callback function to redraw the x-axis on path changes.
1073
        """
1074
        plotter = PathL_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints)
1✔
1075
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
1076

1077
    def redraw_figure(self):
1✔
1078
        """For the linear view, changes in the horizontal or vertical position of a waypoint
1079
           (including moved waypoints, new or deleted waypoints) make a complete
1080
           redraw of the figure necessary.
1081
        """
1082
        # emit signal to redraw map
1083
        self.plotter.redraw_xaxis()
1✔
1084
        self.signal_get_lsec.emit()
1✔
1085

1086
    def redraw_path(self, vertices=None):
1✔
1087
        """Skip redrawing paths for LSec
1088
        """
1089
        pass
×
1090

1091
    def draw_callback(self, event):
1✔
1092
        """Skip drawing paths for LSec
1093
        """
1094
        pass
×
1095

1096
    def get_lat_lon(self, event):
1✔
1097
        wpm = self.waypoints_model
×
1098
        lat_lon, ind = self.plotter.get_lat_lon(event, wpm)
×
1099
        return lat_lon, ind
×
1100

1101
    def qt_data_changed_listener(self, index1, index2):
1✔
1102
        """Listens to dataChanged() signals emitted by the flight track
1103
           data model. The linear view can thus react to data changes
1104
           induced by another view (table, top view, side view).
1105
        """
1106
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
×
1107
        self.redraw_figure()
×
1108

1109

1110
class HPathInteractor(PathInteractor):
1✔
1111
    """Subclass of PathInteractor that implements an interactively editable
1112
       horizontal flight track. Waypoints are connected with great circles.
1113
    """
1114

1115
    def __init__(self, mplmap, waypoints,
1✔
1116
                 linecolor='blue', markerfacecolor='red', show_marker=True,
1117
                 label_waypoints=True):
1118
        """Constructor passes a PathH_GC instance its parent (horizontal path
1119
           with waypoints connected with great circles).
1120

1121
        Arguments:
1122
        mplmap -- mpl_map.MapCanvas instance into which the path should be drawn.
1123
        waypoints -- flighttrack.WaypointsModel instance.
1124
        """
1125
        plotter = PathH_Plotter(
1✔
1126
            mplmap, mplpath=PathH([[0, 0]], map=mplmap),
1127
            linecolor=linecolor, markerfacecolor=markerfacecolor,
1128
            label_waypoints=label_waypoints)
1129
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
1130
        self.redraw_path()
1✔
1131

1132
    def appropriate_epsilon(self, px=5):
1✔
1133
        """Determine an epsilon value appropriate for the current projection and
1134
           figure size.
1135

1136
        The epsilon value gives the distance required in map projection
1137
        coordinates that corresponds to approximately px Pixels in screen
1138
        coordinates. The value can be used to find the line/point that is
1139
        closest to a click while discarding clicks that are too far away
1140
        from any geometry feature.
1141
        """
1142
        return self.plotter.appropriate_epsilon(px)
1✔
1143

1144
    def appropriate_epsilon_km(self, px=5):
1✔
1145
        """Determine an epsilon value appropriate for the current projection and
1146
           figure size.
1147

1148
        The epsilon value gives the distance required in map projection
1149
        coordinates that corresponds to approximately px Pixels in screen
1150
        coordinates. The value can be used to find the line/point that is
1151
        closest to a click while discarding clicks that are too far away
1152
        from any geometry feature.
1153
        """
1154
        # (bounds = left, bottom, width, height)
1155
        ax_bounds = self.plotter.ax.bbox.bounds
1✔
1156
        diagonal = math.hypot(round(ax_bounds[2]), round(ax_bounds[3]))
1✔
1157
        plot_map = self.plotter.map
1✔
1158
        map_delta = get_distance(plot_map.llcrnrlat, plot_map.llcrnrlon, plot_map.urcrnrlat, plot_map.urcrnrlon)
1✔
1159
        km_per_px = map_delta / diagonal
1✔
1160

1161
        return km_per_px * px
1✔
1162

1163
    def get_lat_lon(self, event):
1✔
UNCOV
1164
        return self.plotter.map(event.xdata, event.ydata, inverse=True)[::-1]
×
1165

1166
    def button_release_insert_callback(self, event):
1✔
1167
        """Called whenever a mouse button is released.
1168

1169
        From the click event's coordinates, best_index is calculated if it can be optimally fit
1170
        as a prior waypoint in the path.
1171

1172
        A vertex with same coordinates is inserted into the path in canvas.
1173

1174
        The coordinates are checked against "locations" defined in msui' config.
1175

1176
        A new waypoint with the coordinates, and name is inserted into the waypoints_model.
1177
        """
1178
        if not self.showverts or event.button != 1 or event.inaxes is None:
1✔
1179
            return
1✔
1180

1181
        # Get position for new vertex.
1182
        x, y = event.xdata, event.ydata
1✔
1183
        best_index = self.plotter.pathpatch.get_path().index_of_closest_segment(
1✔
1184
            x, y, eps=self.appropriate_epsilon())
1185
        logging.debug("TopView insert point: clicked at (%f, %f), "
1✔
1186
                      "best index: %d", x, y, best_index)
1187
        self.plotter.pathpatch.get_path().insert_vertex(best_index, [x, y], WaypointsPath.LINETO)
1✔
1188

1189
        lon, lat = self.plotter.map(x, y, inverse=True)
1✔
1190
        loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15))
1✔
1191
        if loc is not None:
1✔
1192
            (lat, lon), location = loc
1✔
1193
        else:
1194
            location = ""
×
1195
        wpm = self.waypoints_model
1✔
1196
        if len(wpm.all_waypoint_data()) > 0 and 0 < best_index <= len(wpm.all_waypoint_data()):
1✔
1197
            flightlevel = wpm.waypoint_data(best_index - 1).flightlevel
1✔
1198
        elif len(wpm.all_waypoint_data()) > 0 and best_index == 0:
×
1199
            flightlevel = wpm.waypoint_data(0).flightlevel
×
1200
        else:
1201
            logging.error("Cannot copy flightlevel. best_index: %s, len: %s",
×
1202
                          best_index, len(wpm.all_waypoint_data()))
1203
            flightlevel = 0
×
1204
        new_wp = ft.Waypoint(lat, lon, flightlevel, location=location)
1✔
1205
        # insertRows() will trigger a signal that will redraw the path.
1206
        wpm.insertRows(best_index, rows=1, waypoints=[new_wp])
1✔
1207

1208
        self._ind = None
1✔
1209

1210
    def button_release_move_callback(self, event):
1✔
1211
        """Called whenever a mouse button is released.
1212
        """
1213
        if not self.showverts or event.button != 1 or self._ind is None:
1✔
1214
            return
×
1215

1216
        # Submit the new position to the data model.
1217
        vertices = self.plotter.pathpatch.get_path().wp_vertices
1✔
1218
        lon, lat = self.plotter.map(vertices[self._ind][0], vertices[self._ind][1],
1✔
1219
                                    inverse=True)
1220
        loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15))
1✔
1221
        if loc is not None:
1✔
1222
            lat, lon = loc[0]
1✔
1223
        self.waypoints_model.setData(
1✔
1224
            self.waypoints_model.createIndex(self._ind, ft.LAT), QtCore.QVariant(lat), update=False)
1225
        self.waypoints_model.setData(
1✔
1226
            self.waypoints_model.createIndex(self._ind, ft.LON), QtCore.QVariant(lon))
1227

1228
        self._ind = None
1✔
1229

1230
    def button_release_delete_callback(self, event):
1✔
1231
        """Called whenever a mouse button is released.
1232
        """
1233
        if not self.showverts or event.button != 1:
1✔
1234
            return
×
1235

1236
        if self._ind is not None and self.confirm_delete_waypoint(self._ind):
1✔
1237
            # removeRows() will trigger a signal that will redraw the path.
1238
            self.waypoints_model.removeRows(self._ind)
1✔
1239

1240
        self._ind = None
1✔
1241

1242
    def motion_notify_callback(self, event):
1✔
1243
        """Called on mouse movement. Redraws the path if a vertex has been
1244
           picked and dragged.
1245
        """
UNCOV
1246
        if not self.showverts:
×
1247
            return
×
UNCOV
1248
        if self._ind is None:
×
1249
            return
×
UNCOV
1250
        if event.inaxes is None:
×
1251
            return
×
UNCOV
1252
        if event.button != 1:
×
1253
            return
×
UNCOV
1254
        wp_vertices = self.plotter.pathpatch.get_path().wp_vertices
×
UNCOV
1255
        wp_vertices[self._ind] = event.xdata, event.ydata
×
UNCOV
1256
        self.plotter.redraw_path(wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data())
×
1257

1258
    def qt_data_changed_listener(self, index1, index2):
1✔
1259
        """Listens to dataChanged() signals emitted by the flight track
1260
           data model. The top view can thus react to data changes
1261
           induced by another view (table, side view).
1262
        """
1263
        # Update the top view if the horizontal position of any point has been
1264
        # changed.
1265
        if index1.column() in [ft.LOCATION, ft.LAT, ft.LON, ft.FLIGHTLEVEL]:
1✔
1266
            self.update()
1✔
1267

1268
    def update(self):
1✔
1269
        """Update the path plot by updating coordinates and intermediate
1270
           great circle points from the path patch, then redrawing.
1271
        """
1272
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
1273
        self.redraw_path()
1✔
1274

1275
    def redraw_path(self, wp_vertices=None):
1✔
1276
        """Redraw the matplotlib artists that represent the flight track
1277
           (path patch, line and waypoint scatter).
1278

1279
        If waypoint vertices are specified, they will be applied to the
1280
        graphics output. Otherwise the vertex array obtained from the path
1281
        patch will be used.
1282
        """
1283
        self.plotter.redraw_path(wp_vertices=wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data())
1✔
1284

1285
    # Link redraw_figure() to redraw_path().
1286
    redraw_figure = redraw_path
1✔
1287

1288
    def draw_callback(self, event):
1✔
1289
        """Extends PathInteractor.draw_callback() by drawing the scatter
1290
           instance.
1291
        """
1292
        self.plotter.draw_callback(self, event)
×
1293

1294
    def get_ind_under_point(self, event):
1✔
1295
        """Get the index of the waypoint vertex under the point
1296
           specified by event within epsilon tolerance.
1297

1298
        Uses display coordinates.
1299
        If no waypoint vertex is found, None is returned.
1300
        """
1301
        xy = np.asarray(self.plotter.pathpatch.get_path().wp_vertices)
1✔
1302
        if self.plotter.map.projection == "cyl":  # hack for wraparound
1✔
1303
            lon_min, lon_max = self.plotter.map.llcrnrlon, self.plotter.map.urcrnrlon
1✔
1304
            xy[xy[:, 0] < lon_min, 0] += 360
1✔
1305
            xy[xy[:, 0] > lon_max, 0] -= 360
1✔
1306
        xyt = self.plotter.pathpatch.get_transform().transform(xy)
1✔
1307
        xt, yt = xyt[:, 0], xyt[:, 1]
1✔
1308
        d = np.hypot(xt - event.x, yt - event.y)
1✔
1309
        ind = d.argmin()
1✔
1310
        if d[ind] >= self.epsilon:
1✔
1311
            ind = None
1✔
1312
        return ind
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc