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

Open-MSS / MSS / 19940267820

04 Dec 2025 06:48PM UTC coverage: 69.837% (-0.02%) from 69.861%
19940267820

Pull #2940

github

web-flow
Merge b6cbdf491 into 43685829d
Pull Request #2940: removed macos-13, added macos-15

14471 of 20721 relevant lines covered (69.84%)

0.7 hits per line

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

83.63
/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-2025 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, latlon_points, path_points
1✔
55
from mslib.utils.find_location import find_location
1✔
56
from mslib.utils.units import units
1✔
57
from mslib.utils.thermolib import pressure2flightlevel
1✔
58
from mslib.msui import flighttrack as ft
1✔
59
from mslib.utils.loggerdef import configure_mpl_logger
1✔
60

61

62
mpl_logger = configure_mpl_logger()
1✔
63

64

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

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

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

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

94

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

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

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

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

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

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

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

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

152
        return min_index
1✔
153

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

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

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

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

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

180

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

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

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

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

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

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

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

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

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

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

244

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

249

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

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

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

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

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

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

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

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

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

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

324

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

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

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

335
    # picking points
336

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

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

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

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

371
        # Initialize line style options
372
        self.line_style_dict = {
1✔
373
            "Solid": '-',
374
            "Dashed": '--',
375
            "Dotted": ':',
376
            "Dash-dot": '-.'
377
        }
378

379
        # Draw the line representing flight track or profile (correct
380
        # vertices handling for the line needs to be ensured in subclasses).
381
        x, y = list(zip(*self.pathpatch.get_path().vertices))
1✔
382
        self.line, = self.ax.plot(x, y, color=linecolor,
1✔
383
                                  marker=marker, linewidth=line_thickness, linestyle=self.line_style_dict[line_style],
384
                                  alpha=line_transparency,
385
                                  markerfacecolor=markerfacecolor,
386
                                  animated=True)
387

388
        # List to accommodate waypoint labels.
389
        self.wp_labels = []
1✔
390
        self.label_waypoints = label_waypoints
1✔
391

392
        # Connect mpl events to handler routines: mouse movements and picks.
393
        canvas = self.ax.figure.canvas
1✔
394
        canvas.mpl_connect('draw_event', self.draw_callback)
1✔
395
        self.canvas = canvas
1✔
396

397
    def get_line_style_dict(self):
1✔
398
        """return the line style dict so other class can access it"""
399
        return self.line_style_dict
×
400

401
    def set_line_thickness(self, thickness):
1✔
402
        """Set the line thickness of the flight track."""
403
        self.line.set_linewidth(thickness)
1✔
404
        self.canvas.draw()
1✔
405

406
    def set_line_style(self, style):
1✔
407
        """Set the line style of the flight track."""
408
        if style in self.line_style_dict:
1✔
409
            self.line.set_linestyle(self.line_style_dict[style])
1✔
410
            self.canvas.draw()
1✔
411

412
    def set_line_transparency(self, transparency):
1✔
413
        """Set the line transparency of the flight track."""
414
        self.line.set_alpha(transparency)
1✔
415
        self.canvas.draw()
1✔
416

417
    def draw_callback(self, event):
1✔
418
        """Called when the figure is redrawn. Stores background data (for later
419
           restoration) and draws artists.
420
        """
421
        self.background = self.canvas.copy_from_bbox(self.ax.bbox)
1✔
422
        try:
1✔
423
            # TODO review
424
            self.ax.draw_artist(self.pathpatch)
1✔
425
        except ValueError as ex:
×
426
            # When using Matplotlib 1.2, "ValueError: Invalid codes array."
427
            # occurs. The error occurs in Matplotlib's backend_agg.py/draw_path()
428
            # function. However, when I print the codes array in that function,
429
            # it looks fine -- correct length and correct codes. I can't figure
430
            # out why that error occurs.. (mr, 2013Feb08).
431
            logging.error("%s %s", ex, type(ex))
×
432
        self.ax.draw_artist(self.line)
1✔
433
        for t in self.wp_labels:
1✔
434
            self.ax.draw_artist(t)
1✔
435
            # The blit() method makes problems (distorted figure background). However,
436
            # I don't see why it is needed -- everything seems to work without this line.
437
            # (see infos on http://www.scipy.org/Cookbook/Matplotlib/Animations).
438
            # self.canvas.blit(self.ax.bbox)
439

440
    def set_vertices_visible(self, showverts=True):
1✔
441
        """Set the visibility of path vertices (the line plot).
442
        """
443
        self.showverts = showverts
1✔
444
        self.line.set_visible(self.showverts)
1✔
445
        for t in self.wp_labels:
1✔
446
            t.set_visible(showverts and self.label_waypoints)
1✔
447
        if not self.showverts:
1✔
448
            self._ind = None
×
449
        self.canvas.draw()
1✔
450

451
    def set_patch_visible(self, showpatch=True):
1✔
452
        """Set the visibility of path patch (the area).
453
        """
454
        self.pathpatch.set_visible(showpatch)
1✔
455
        self.canvas.draw()
1✔
456

457
    def set_labels_visible(self, visible=True):
1✔
458
        """Set the visibility of the waypoint labels.
459
        """
460
        self.label_waypoints = visible
1✔
461
        for t in self.wp_labels:
1✔
462
            t.set_visible(self.showverts and self.label_waypoints)
1✔
463
        self.canvas.draw()
1✔
464

465
    def set_path_color(self, line_color=None, marker_facecolor=None,
1✔
466
                       patch_facecolor=None):
467
        """Set the color of the path patch elements.
468
        Arguments (options):
469
        line_color -- color of the path line
470
        marker_facecolor -- color of the waypoints
471
        patch_facecolor -- color of the patch covering the path area
472
        """
473
        if line_color is not None:
1✔
474
            self.line.set_color(line_color)
1✔
475
        if marker_facecolor is not None:
1✔
476
            self.line.set_markerfacecolor(marker_facecolor)
1✔
477
        if patch_facecolor is not None:
1✔
478
            self.pathpatch.set_facecolor(patch_facecolor)
1✔
479

480
    def update_from_waypoints(self, wps):
1✔
481
        self.pathpatch.get_path().update_from_waypoints(wps)
1✔
482

483

484
class PathH_Plotter(PathPlotter):
1✔
485
    def __init__(self, mplmap, mplpath=None, facecolor='none', edgecolor='none',
1✔
486
                 linecolor='blue', markerfacecolor='red', show_marker=True,
487
                 label_waypoints=True):
488
        super().__init__(mplmap.ax, mplpath=PathH([[0, 0]], map=mplmap),
1✔
489
                         facecolor='none', edgecolor='none', linecolor=linecolor,
490
                         markerfacecolor=markerfacecolor, marker='',
491
                         label_waypoints=label_waypoints)
492
        self.map = mplmap
1✔
493
        self.wp_scatter = None
1✔
494
        self.markerfacecolor = markerfacecolor
1✔
495
        self.tangent_lines = None
1✔
496
        self.show_tangent_points = False
1✔
497
        self.solar_lines = None
1✔
498
        self.show_marker = show_marker
1✔
499
        self.show_solar_angle = None
1✔
500
        self.remote_sensing = None
1✔
501

502
    def appropriate_epsilon(self, px=5):
1✔
503
        """Determine an epsilon value appropriate for the current projection and
504
           figure size.
505
        The epsilon value gives the distance required in map projection
506
        coordinates that corresponds to approximately px Pixels in screen
507
        coordinates. The value can be used to find the line/point that is
508
        closest to a click while discarding clicks that are too far away
509
        from any geometry feature.
510
        """
511
        # (bounds = left, bottom, width, height)
512
        ax_bounds = self.ax.bbox.bounds
1✔
513
        width = int(round(ax_bounds[2]))
1✔
514
        map_delta_x = np.hypot(self.map.llcrnry - self.map.urcrnry, self.map.llcrnrx - self.map.urcrnrx)
1✔
515
        map_coords_per_px_x = map_delta_x / width
1✔
516
        return map_coords_per_px_x * px
1✔
517

518
    def redraw_path(self, wp_vertices=None, waypoints_model_data=None):
1✔
519
        """Redraw the matplotlib artists that represent the flight track
520
           (path patch, line and waypoint scatter).
521
        If waypoint vertices are specified, they will be applied to the
522
        graphics output. Otherwise the vertex array obtained from the path
523
        patch will be used.
524
        """
525
        if waypoints_model_data is None:
1✔
526
            waypoints_model_data = []
×
527
        if wp_vertices is None:
1✔
528
            wp_vertices = self.pathpatch.get_path().wp_vertices
1✔
529
            if len(wp_vertices) == 0:
1✔
530
                raise IOError("mscolab session expired")
×
531
            vertices = self.pathpatch.get_path().vertices
1✔
532
        else:
533
            # If waypoints have been provided, compute the intermediate
534
            # great circle points for the line instance.
535
            x, y = list(zip(*wp_vertices))
×
536
            lons, lats = self.map(x, y, inverse=True)
×
537
            x, y = self.map.gcpoints_path(lons, lats)
×
538
            vertices = list(zip(x, y))
×
539

540
        # Set the line to display great circle points, remove existing
541
        # waypoints scatter instance and draw a new one. This is
542
        # necessary as scatter() does not provide a set_data method.
543
        self.line.set_data(list(zip(*vertices)))
1✔
544

545
        if self.tangent_lines is not None:
1✔
546
            self.tangent_lines.remove()
1✔
547
            self.tangent_lines = None
1✔
548
        if self.solar_lines is not None:
1✔
549
            self.solar_lines.remove()
×
550
            self.solar_lines = None
×
551

552
        if len(waypoints_model_data) > 0:
1✔
553
            wp_heights = [(wpd.flightlevel * 0.03048) for wpd in waypoints_model_data]
1✔
554
            wp_times = [wpd.utc_time for wpd in waypoints_model_data]
1✔
555

556
            if self.show_tangent_points:
1✔
557
                assert self.remote_sensing is not None
1✔
558
                self.tangent_lines = self.remote_sensing.compute_tangent_lines(
1✔
559
                    self.map, wp_vertices, wp_heights)
560
                self.ax.add_collection(self.tangent_lines)
1✔
561

562
            if self.show_solar_angle is not None:
1✔
563
                assert self.remote_sensing is not None
1✔
564
                self.solar_lines = self.remote_sensing.compute_solar_lines(
1✔
565
                    self.map, wp_vertices, wp_heights, wp_times, self.show_solar_angle)
566
                self.ax.add_collection(self.solar_lines)
1✔
567

568
        if self.wp_scatter is not None:
1✔
569
            self.wp_scatter.remove()
1✔
570
            self.wp_scatter = None
1✔
571

572
        x, y = list(zip(*wp_vertices))
1✔
573

574
        if self.map.projection == "cyl":  # hack for wraparound
1✔
575
            x = np.array(x)
1✔
576
            x[x < self.map.llcrnrlon] += 360
1✔
577
            x[x > self.map.urcrnrlon] -= 360
1✔
578
        # (animated is important to remove the old scatter points from the map)
579
        self.wp_scatter = self.ax.scatter(
1✔
580
            x, y, color=self.markerfacecolor, s=20, zorder=3, animated=True, visible=self.show_marker)
581

582
        # Draw waypoint labels.
583
        label_offset = self.appropriate_epsilon(px=5)
1✔
584
        for wp_label in self.wp_labels:
1✔
585
            wp_label.remove()
1✔
586
        self.wp_labels = []  # remove doesn't seem to be necessary
1✔
587
        for i, wpd in enumerate(waypoints_model_data):
1✔
588
            textlabel = str(i)
1✔
589
            if wpd.location != "":
1✔
590
                textlabel = f"{wpd.location}"
1✔
591
            label_offset = 0
1✔
592
            text = self.ax.text(
1✔
593
                x[i] + label_offset, y[i] + label_offset, textlabel,
594
                bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.6, "edgecolor": "none"},
595
                fontweight="bold", zorder=4, animated=True, clip_on=True,
596
                visible=self.showverts and self.label_waypoints)
597
            self.wp_labels.append(text)
1✔
598

599
        # Redraw the artists.
600
        if self.background:
1✔
601
            self.canvas.restore_region(self.background)
1✔
602
        try:
1✔
603
            self.ax.draw_artist(self.pathpatch)
1✔
604
        except ValueError as error:
×
605
            logging.debug("ValueError Exception '%s'", error)
×
606
        self.ax.draw_artist(self.line)
1✔
607
        if self.wp_scatter is not None:
1✔
608
            self.ax.draw_artist(self.wp_scatter)
1✔
609

610
        for wp_label in self.wp_labels:
1✔
611
            self.ax.draw_artist(wp_label)
1✔
612
        if self.show_tangent_points:
1✔
613
            self.ax.draw_artist(self.tangent_lines)
1✔
614
        if self.show_solar_angle is not None:
1✔
615
            self.ax.draw_artist(self.solar_lines)
1✔
616
        self.canvas.blit(self.ax.bbox)
1✔
617

618
    def draw_callback(self, event):
1✔
619
        """Extends PathInteractor.draw_callback() by drawing the scatter
620
           instance.
621
        """
622
        super().draw_callback(event)
1✔
623
        if self.wp_scatter:
1✔
624
            self.ax.draw_artist(self.wp_scatter)
1✔
625
        if self.show_solar_angle:
1✔
626
            self.ax.draw_artist(self.solar_lines)
×
627
        if self.show_tangent_points:
1✔
628
            self.ax.draw_artist(self.tangent_lines)
×
629

630
    def set_path_color(self, line_color=None, marker_facecolor=None,
1✔
631
                       patch_facecolor=None):
632
        """Set the color of the path patch elements.
633
        Arguments (options):
634
        line_color -- color of the path line
635
        marker_facecolor -- color of the waypoints
636
        patch_facecolor -- color of the patch covering the path area
637
        """
638
        super().set_path_color(line_color, marker_facecolor,
1✔
639
                               patch_facecolor)
640
        if marker_facecolor is not None and self.wp_scatter is not None:
1✔
641
            self.wp_scatter.set_facecolor(marker_facecolor)
1✔
642
            self.wp_scatter.set_edgecolor(marker_facecolor)
1✔
643
            self.markerfacecolor = marker_facecolor
1✔
644

645
    def set_vertices_visible(self, showverts=True):
1✔
646
        """Set the visibility of path vertices (the line plot).
647
        """
648
        super().set_vertices_visible(showverts)
1✔
649
        if self.wp_scatter is not None:
1✔
650
            self.wp_scatter.set_visible(self.show_marker)
1✔
651

652
    def set_tangent_visible(self, visible):
1✔
653
        self.show_tangent_points = visible
1✔
654

655
    def set_solar_angle_visible(self, visible):
1✔
656
        self.show_solar_angle = visible
1✔
657

658
    def set_remote_sensing(self, ref):
1✔
659
        self.remote_sensing = ref
1✔
660

661

662
class PathV_Plotter(PathPlotter):
1✔
663
    def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
664
        """Constructor passes a PathV instance its parent.
665

666
        Arguments:
667
        ax -- matplotlib.Axes object into which the path should be drawn.
668
        waypoints -- flighttrack.WaypointsModel instance.
669
        numintpoints -- number of intermediate interpolation points. The entire
670
                        flight track will be interpolated to this number of
671
                        points.
672
        redrawXAxis -- callback function to redraw the x-axis on path changes.
673
        """
674
        super().__init__(
1✔
675
            ax=ax, mplpath=PathV([[0, 0]], numintpoints=numintpoints))
676
        self.numintpoints = numintpoints
1✔
677
        self.redraw_xaxis = redraw_xaxis
1✔
678
        self.clear_figure = clear_figure
1✔
679

680
    def get_num_interpolation_points(self):
1✔
681
        return self.numintpoints
1✔
682

683
    def redraw_path(self, vertices=None, waypoints_model_data=None):
1✔
684
        """Redraw the matplotlib artists that represent the flight track
685
           (path patch and line).
686

687
        If vertices are specified, they will be applied to the graphics
688
        output. Otherwise the vertex array obtained from the path patch
689
        will be used.
690
        """
691
        if waypoints_model_data is None:
1✔
692
            waypoints_model_data = []
×
693
        if vertices is None:
1✔
694
            vertices = self.pathpatch.get_path().vertices
1✔
695
        self.line.set_data(list(zip(*vertices)))
1✔
696
        x, y = list(zip(*vertices))
1✔
697
        # Draw waypoint labels.
698
        for wp_label in self.wp_labels:
1✔
699
            wp_label.remove()
1✔
700
        self.wp_labels = []  # remove doesn't seem to be necessary
1✔
701
        for i, wpd, in enumerate(waypoints_model_data):
1✔
702
            textlabel = f"{str(i):}   "
1✔
703
            if wpd.location != "":
1✔
704
                textlabel = f"{wpd.location:}   "
1✔
705
            text = self.ax.text(
1✔
706
                x[i], y[i],
707
                textlabel,
708
                bbox=dict(boxstyle="round",
709
                          facecolor="white",
710
                          alpha=0.5,
711
                          edgecolor="none"),
712
                fontweight="bold",
713
                zorder=4,
714
                rotation=90,
715
                animated=True,
716
                clip_on=True,
717
                visible=self.showverts and self.label_waypoints)
718
            self.wp_labels.append(text)
1✔
719

720
        if self.background:
1✔
721
            self.canvas.restore_region(self.background)
1✔
722
        try:
1✔
723
            self.ax.draw_artist(self.pathpatch)
1✔
724
        except ValueError as error:
×
725
            logging.error("ValueError Exception %s", error)
×
726
        self.ax.draw_artist(self.line)
1✔
727
        for wp_label in self.wp_labels:
1✔
728
            self.ax.draw_artist(wp_label)
1✔
729
        self.canvas.blit(self.ax.bbox)
1✔
730

731
    def get_lat_lon(self, event, wpm):
1✔
732
        x = event.xdata
1✔
733
        vertices = self.pathpatch.get_path().vertices
1✔
734
        best_index = 1
1✔
735
        # if x axis has increasing coordinates
736
        if vertices[-1, 0] > vertices[0, 0]:
1✔
737
            for index, vertex in enumerate(vertices):
1✔
738
                if x >= vertex[0]:
1✔
739
                    best_index = index + 1
1✔
740
        # if x axis has decreasing coordinates
741
        else:
742
            for index, vertex in enumerate(vertices):
×
743
                if x <= vertex[0]:
×
744
                    best_index = index + 1
×
745
        # number of subcoordinates is determined by difference in x coordinates
746
        number_of_intermediate_points = math.floor(vertices[best_index, 0] - vertices[best_index - 1, 0])
1✔
747
        vert_xs, vert_ys = latlon_points(
1✔
748
            vertices[best_index - 1, 0], vertices[best_index - 1, 1],
749
            vertices[best_index, 0], vertices[best_index, 1],
750
            number_of_intermediate_points, connection="linear")
751
        lats, lons = latlon_points(
1✔
752
            wpm[best_index - 1].lat, wpm[best_index - 1].lon,
753
            wpm[best_index].lat, wpm[best_index].lon,
754
            number_of_intermediate_points, connection="greatcircle")
755

756
        # best_index1 is the best index among the intermediate coordinates to fit the hovered point
757
        # if x axis has increasing coordinates
758
        best_index1 = np.argmin(abs(vert_xs - x))
1✔
759
        # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood
760
        return (lats[best_index1], lons[best_index1]), best_index
1✔
761

762

763
class PathL_Plotter(PathPlotter):
1✔
764
    def __init__(self, ax, redraw_xaxis=None, clear_figure=None, numintpoints=101):
1✔
765
        """Constructor passes a PathV instance its parent.
766

767
        Arguments:
768
        ax -- matplotlib.Axes object into which the path should be drawn.
769
        waypoints -- flighttrack.WaypointsModel instance.
770
        numintpoints -- number of intermediate interpolation points. The entire
771
                        flight track will be interpolated to this number of
772
                        points.
773
        redrawXAxis -- callback function to redraw the x-axis on path changes.
774
        """
775
        super().__init__(
1✔
776
            ax=ax, marker="", mplpath=PathV([[0, 0]], numintpoints=numintpoints))
777
        self.numintpoints = numintpoints
1✔
778
        self.redraw_xaxis = redraw_xaxis
1✔
779
        self.clear_figure = clear_figure
1✔
780

781
    def get_num_interpolation_points(self):
1✔
782
        return self.numintpoints
1✔
783

784
    def get_lat_lon(self, event, wpm):
1✔
785
        x = event.xdata
1✔
786
        vertices = self.pathpatch.get_path().vertices
1✔
787
        best_index = 1
1✔
788
        # if x axis has increasing coordinates
789
        if vertices[-1, 0] > vertices[0, 0]:
1✔
790
            for index, vertex in enumerate(vertices):
1✔
791
                if x >= vertex[0]:
1✔
792
                    best_index = index + 1
1✔
793
        # if x axis has decreasing coordinates
794
        else:
795
            for index, vertex in enumerate(vertices):
×
796
                if x <= vertex[0]:
×
797
                    best_index = index + 1
×
798
        # number of subcoordinates is determined by difference in x coordinates
799
        number_of_intermediate_points = int(abs(vertices[best_index, 0] - vertices[best_index - 1, 0]))
1✔
800
        vert_xs, vert_ys = latlon_points(
1✔
801
            vertices[best_index - 1, 0], vertices[best_index - 1, 1],
802
            vertices[best_index, 0], vertices[best_index, 1],
803
            number_of_intermediate_points, connection="linear")
804
        lats, lons = latlon_points(
1✔
805
            wpm.waypoint_data(best_index - 1).lat, wpm.waypoint_data(best_index - 1).lon,
806
            wpm.waypoint_data(best_index).lat, wpm.waypoint_data(best_index).lon,
807
            number_of_intermediate_points, connection="greatcircle")
808
        alts = np.linspace(wpm.waypoint_data(best_index - 1).flightlevel,
1✔
809
                           wpm.waypoint_data(best_index).flightlevel, number_of_intermediate_points)
810

811
        best_index1 = np.argmin(abs(vert_xs - x))
1✔
812
        # depends if best_index1 or best_index1 - 1 on closeness to left or right neighbourhood
813
        return (lats[best_index1], lons[best_index1], alts[best_index1]), best_index
1✔
814

815

816
class PathInteractor(QtCore.QObject):
1✔
817
    """An interactive matplotlib path editor. Allows vertices of a path patch
818
       to be interactively picked and moved around.
819
    Superclass for the path editors used by the top and side views of the
820
    Mission Support System.
821
    """
822

823
    showverts = True  # show the vertices of the path patch
1✔
824
    epsilon = 12
1✔
825

826
    # picking points
827

828
    def __init__(self, plotter, waypoints=None):
1✔
829
        """The constructor initializes the path patches, overlying line
830
           plot and connects matplotlib signals.
831
        Arguments:
832
        ax -- matplotlib.Axes object into which the path should be drawn.
833
        waypoints -- flighttrack.WaypointsModel instance.
834
        mplpath -- matplotlib.path.Path instance
835
        facecolor -- facecolor of the patch
836
        edgecolor -- edgecolor of the patch
837
        linecolor -- color of the line plotted above the patch edges
838
        markerfacecolor -- color of the markers that represent the waypoints
839
        marker -- symbol of the markers that represent the waypoints, see
840
                  matplotlib plot() or scatter() routines for more information.
841
        label_waypoints -- put labels with the waypoint numbers on the waypoints.
842
        """
843
        QtCore.QObject.__init__(self)
1✔
844
        self._ind = None  # the active vertex
1✔
845
        self.plotter = plotter
1✔
846

847
        # Set the waypoints model, connect to the change() signals of the model
848
        # and redraw the figure.
849
        self.waypoints_model = None
1✔
850
        self.set_waypoints_model(waypoints)
1✔
851

852
    def set_waypoints_model(self, waypoints):
1✔
853
        """Change the underlying waypoints data structure. Disconnect change()
854
           signals of an already existing model and connect to the new model.
855
           Redraw the map.
856
        """
857
        # If a model exists, disconnect from the old change() signals.
858
        wpm = self.waypoints_model
1✔
859
        if wpm:
1✔
860
            wpm.dataChanged.disconnect(self.qt_data_changed_listener)
1✔
861
            wpm.rowsInserted.disconnect(self.qt_insert_remove_point_listener)
1✔
862
            wpm.rowsRemoved.disconnect(self.qt_insert_remove_point_listener)
1✔
863
        # Set the new waypoints model.
864
        self.waypoints_model = waypoints
1✔
865
        # Connect to the new model's signals.
866
        wpm = self.waypoints_model
1✔
867
        wpm.dataChanged.connect(self.qt_data_changed_listener)
1✔
868
        wpm.rowsInserted.connect(self.qt_insert_remove_point_listener)
1✔
869
        wpm.rowsRemoved.connect(self.qt_insert_remove_point_listener)
1✔
870
        # Redraw.
871
        self.plotter.update_from_waypoints(wpm.all_waypoint_data())
1✔
872
        self.redraw_figure()
1✔
873

874
    def qt_insert_remove_point_listener(self, index, first, last):
1✔
875
        """Listens to rowsInserted() and rowsRemoved() signals emitted
876
           by the flight track data model. The view can thus react to
877
           data changes induced by another view (table, side view).
878
        """
879
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
880
        self.redraw_figure()
1✔
881

882
    def qt_data_changed_listener(self, index1, index2):
1✔
883
        """Listens to dataChanged() signals emitted by the flight track
884
           data model. The view can thus react to data changes induced
885
           by another view (table, top view).
886
        """
887
        # REIMPLEMENT IN SUBCLASSES.
888
        pass
×
889

890
    def get_ind_under_point(self, event):
1✔
891
        """Get the index of the waypoint vertex under the point
892
           specified by event within epsilon tolerance.
893
        Uses display coordinates.
894
        If no waypoint vertex is found, None is returned.
895
        """
896
        xy = np.asarray(self.plotter.pathpatch.get_path().vertices)
1✔
897
        xyt = self.plotter.pathpatch.get_transform().transform(xy)
1✔
898
        xt, yt = xyt[:, 0], xyt[:, 1]
1✔
899
        d = np.hypot(xt - event.x, yt - event.y)
1✔
900
        ind = d.argmin()
1✔
901
        if d[ind] >= self.epsilon:
1✔
902
            ind = None
1✔
903
        return ind
1✔
904

905
    def button_press_callback(self, event):
1✔
906
        """Called whenever a mouse button is pressed. Determines the index of
907
           the vertex closest to the click, as long as a vertex is within
908
           epsilon tolerance of the click.
909
        """
910
        if not self.plotter.showverts:
1✔
911
            return
×
912
        if event.inaxes is None:
1✔
913
            return
1✔
914
        if event.button != 1:
1✔
915
            return
×
916
        self._ind = self.get_ind_under_point(event)
1✔
917

918
    def confirm_delete_waypoint(self, row):
1✔
919
        """Open a QMessageBox and ask the user if he really wants to
920
           delete the waypoint at index <row>.
921

922
        Returns TRUE if the user confirms the deletion.
923

924
        If the flight track consists of only two points deleting a waypoint
925
        is not possible. In this case the user is informed correspondingly.
926
        """
927
        wps = self.waypoints_model.all_waypoint_data()
1✔
928
        if len(wps) < 3:
1✔
929
            QtWidgets.QMessageBox.warning(
×
930
                None, "Remove waypoint",
931
                "Cannot remove waypoint, the flight track needs to consist "
932
                "of at least two points.")
933
            return False
×
934
        else:
935
            wp = wps[row]
1✔
936
            return QtWidgets.QMessageBox.question(
1✔
937
                None, "Remove waypoint",
938
                f"Remove waypoint no.{row:d} at {wp.lat:.2f}/{wp.lon:.2f}, flightlevel {wp.flightlevel:.2f}?",
939
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
940
                QtWidgets.QMessageBox.Yes) == QtWidgets.QMessageBox.Yes
941

942

943
class VPathInteractor(PathInteractor):
1✔
944
    """Subclass of PathInteractor that implements an interactively editable
945
       vertical profile of the flight track.
946
    """
947
    signal_get_vsec = QtCore.pyqtSignal(name="get_vsec")
1✔
948

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

952
        Arguments:
953
        ax -- matplotlib.Axes object into which the path should be drawn.
954
        waypoints -- flighttrack.WaypointsModel instance.
955
        numintpoints -- number of intermediate interpolation points. The entire
956
                        flight track will be interpolated to this number of
957
                        points.
958
        redrawXAxis -- callback function to redraw the x-axis on path changes.
959
        """
960
        plotter = PathV_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints)
1✔
961
        self.redraw_xaxis = redraw_xaxis
1✔
962
        self.clear_figure = clear_figure
1✔
963
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
964

965
    def set_line_thickness(self, thickness):
1✔
966
        """Set the thickness of the line representing the flight track."""
967
        self.plotter.line.set_linewidth(thickness)
×
968
        self.redraw_figure()
×
969

970
    def set_line_style(self, style):
1✔
971
        """Set the style of the line representing the flight track."""
972
        line_style_dict = self.plotter.get_line_style_dict()
×
973
        if style in line_style_dict:
×
974
            self.plotter.set_line_style(style)
×
975
            self.redraw_figure()
×
976

977
    def set_line_transparency(self, transparency):
1✔
978
        """Set the transparency of the line representing the flight track."""
979
        self.plotter.line.set_alpha(transparency)
×
980
        self.redraw_figure()
×
981

982
    def redraw_figure(self):
1✔
983
        """For the side view, changes in the horizontal position of a waypoint
984
           (including moved waypoints, new or deleted waypoints) make a complete
985
           redraw of the figure necessary.
986

987
           Calls the callback function 'redrawXAxis()'.
988
        """
989
        self.plotter.redraw_path(waypoints_model_data=self.waypoints_model.all_waypoint_data())
1✔
990
        # emit signal to redraw map
991
        self.signal_get_vsec.emit()
1✔
992
        if self.redraw_xaxis is not None:
1✔
993
            try:
1✔
994
                self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes)
1✔
995
            except AttributeError as err:
×
996
                logging.debug("%s" % err)
×
997

998
        self.plotter.ax.figure.canvas.draw()
1✔
999

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

1006
        if self._ind is not None:
×
1007
            if self.confirm_delete_waypoint(self._ind):
×
1008
                # removeRows() will trigger a signal that will redraw the path.
1009
                self.waypoints_model.removeRows(self._ind)
×
1010
            self._ind = None
×
1011

1012
    def button_release_insert_callback(self, event):
1✔
1013
        """Called whenever a mouse button is released.
1014

1015
        From the click event's coordinates, best_index is calculated as
1016
        the index of a vertex whose x coordinate > clicked x coordinate.
1017
        This is the position where the waypoint is to be inserted.
1018

1019
        'lat' and 'lon' are calculated as an average of each of the first waypoint
1020
        in left and right neighbourhood of inserted waypoint.
1021

1022
        The coordinates are checked against "locations" defined in msui' config.
1023

1024
        A new waypoint with the coordinates, and name is inserted into the waypoints_model.
1025
        """
1026
        if not self.showverts or event.button != 1 or event.inaxes is None:
1✔
1027
            return
1✔
1028
        y = event.ydata
1✔
1029
        wpm = self.waypoints_model
1✔
1030
        flightlevel = float(pressure2flightlevel(y * units.Pa).magnitude)
1✔
1031
        # round flightlevel to the nearest multiple of five (legal level)
1032
        flightlevel = 5.0 * round(flightlevel / 5)
1✔
1033
        [lat, lon], best_index = self.plotter.get_lat_lon(event, wpm.all_waypoint_data())
1✔
1034
        loc = find_location(lat, lon)  # skipped tolerance which uses appropriate_epsilon_km
1✔
1035
        if loc is not None:
1✔
1036
            (lat, lon), location = loc
×
1037
        else:
1038
            location = ""
1✔
1039
        new_wp = ft.Waypoint(lat, lon, flightlevel, location=location)
1✔
1040
        # insertRows() will trigger a signal that will redraw the path.
1041
        wpm.insertRows(best_index, rows=1, waypoints=[new_wp])
1✔
1042

1043
        self._ind = None
1✔
1044

1045
    def get_lat_lon(self, event):
1✔
1046
        lat_lon, ind = self.plotter.get_lat_lon(event, self.waypoints_model.all_waypoint_data())
×
1047
        return lat_lon, ind
×
1048

1049
    def button_release_move_callback(self, event):
1✔
1050
        """Called whenever a mouse button is released.
1051
        """
1052
        if not self.showverts or event.button != 1:
×
1053
            return
×
1054

1055
        if self._ind is not None:
×
1056
            # Submit the new pressure (the only value that can be edited
1057
            # in the side view) to the data model.
1058
            vertices = self.plotter.pathpatch.get_path().vertices
×
1059
            pressure = vertices[self._ind, 1]
×
1060
            # http://doc.trolltech.com/4.3/qabstractitemmodel.html#createIndex
1061
            qt_index = self.waypoints_model.createIndex(self._ind, ft.PRESSURE)
×
1062
            # NOTE: QVariant cannot handle numpy.float64 types, hence convert
1063
            # to float().
1064
            self.waypoints_model.setData(qt_index, QtCore.QVariant(float(pressure / 100.)))
×
1065

1066
        self._ind = None
×
1067

1068
    def motion_notify_callback(self, event):
1✔
1069
        """Called on mouse movement. Redraws the path if a vertex has been
1070
           picked and is being dragged.
1071

1072
        In the side view, the horizontal position of a waypoint is locked.
1073
        Hence, points can only be moved in the vertical direction (y position
1074
        in this view).
1075
        """
1076
        if not self.showverts or self._ind is None or event.inaxes is None or event.button != 1:
×
1077
            return
×
1078
        vertices = self.plotter.pathpatch.get_path().vertices
×
1079
        # Set the new y position of the vertex to event.ydata. Keep the
1080
        # x coordinate.
1081
        vertices[self._ind] = vertices[self._ind, 0], event.ydata
×
1082
        self.plotter.redraw_path(vertices)
×
1083

1084
    def qt_data_changed_listener(self, index1, index2):
1✔
1085
        """Listens to dataChanged() signals emitted by the flight track
1086
           data model. The side view can thus react to data changes
1087
           induced by another view (table, top view).
1088
        """
1089
        # If the altitude of a point has changed, only the plotted flight
1090
        # profile needs to be redrawn (redraw_path()). If the horizontal
1091
        # position of a waypoint has changed, the entire figure needs to be
1092
        # redrawn, as this affects the x-position of all points.
1093
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
1094
        if index1.column() in [ft.FLIGHTLEVEL, ft.PRESSURE, ft.LOCATION]:
1✔
1095
            self.plotter.redraw_path(
×
1096
                self.plotter.pathpatch.get_path().vertices, self.waypoints_model.all_waypoint_data())
1097
        elif index1.column() in [ft.LAT, ft.LON]:
1✔
1098
            self.redraw_figure()
×
1099
        elif index1.column() in [ft.TIME_UTC]:
1✔
1100
            if self.redraw_xaxis is not None:
1✔
1101
                self.redraw_xaxis(self.plotter.path.ilats, self.plotter.path.ilons, self.plotter.path.itimes)
1✔
1102

1103

1104
class LPathInteractor(PathInteractor):
1✔
1105
    """
1106
    Subclass of PathInteractor that implements a non interactive linear profile of the flight track.
1107
    """
1108
    signal_get_lsec = QtCore.pyqtSignal(name="get_lsec")
1✔
1109

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

1113
        Arguments:
1114
        ax -- matplotlib.Axes object into which the path should be drawn.
1115
        waypoints -- flighttrack.WaypointsModel instance.
1116
        numintpoints -- number of intermediate interpolation points. The entire
1117
                        flight track will be interpolated to this number of
1118
                        points.
1119
        redrawXAxis -- callback function to redraw the x-axis on path changes.
1120
        """
1121
        plotter = PathL_Plotter(ax, redraw_xaxis=redraw_xaxis, clear_figure=clear_figure, numintpoints=numintpoints)
1✔
1122
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
1123

1124
    def redraw_figure(self):
1✔
1125
        """For the linear view, changes in the horizontal or vertical position of a waypoint
1126
           (including moved waypoints, new or deleted waypoints) make a complete
1127
           redraw of the figure necessary.
1128
        """
1129
        # emit signal to redraw map
1130
        self.plotter.redraw_xaxis()
1✔
1131
        self.signal_get_lsec.emit()
1✔
1132

1133
    def redraw_path(self, vertices=None):
1✔
1134
        """Skip redrawing paths for LSec
1135
        """
1136
        pass
×
1137

1138
    def draw_callback(self, event):
1✔
1139
        """Skip drawing paths for LSec
1140
        """
1141
        pass
×
1142

1143
    def get_lat_lon(self, event):
1✔
1144
        wpm = self.waypoints_model
1✔
1145
        lat_lon, ind = self.plotter.get_lat_lon(event, wpm)
1✔
1146
        return lat_lon, ind
1✔
1147

1148
    def qt_data_changed_listener(self, index1, index2):
1✔
1149
        """Listens to dataChanged() signals emitted by the flight track
1150
           data model. The linear view can thus react to data changes
1151
           induced by another view (table, top view, side view).
1152
        """
1153
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
×
1154
        self.redraw_figure()
×
1155

1156

1157
class HPathInteractor(PathInteractor):
1✔
1158
    """Subclass of PathInteractor that implements an interactively editable
1159
       horizontal flight track. Waypoints are connected with great circles.
1160
    """
1161

1162
    def __init__(self, mplmap, waypoints,
1✔
1163
                 linecolor='blue', markerfacecolor='red', show_marker=True,
1164
                 label_waypoints=True):
1165
        """Constructor passes a PathH_GC instance its parent (horizontal path
1166
           with waypoints connected with great circles).
1167

1168
        Arguments:
1169
        mplmap -- mpl_map.MapCanvas instance into which the path should be drawn.
1170
        waypoints -- flighttrack.WaypointsModel instance.
1171
        """
1172
        plotter = PathH_Plotter(
1✔
1173
            mplmap, mplpath=PathH([[0, 0]], map=mplmap),
1174
            linecolor=linecolor, markerfacecolor=markerfacecolor,
1175
            label_waypoints=label_waypoints)
1176
        super().__init__(plotter=plotter, waypoints=waypoints)
1✔
1177
        self.redraw_path()
1✔
1178

1179
    def set_line_thickness(self, thickness):
1✔
1180
        """Set the thickness of the line representing the flight track."""
1181
        self.plotter.line.set_linewidth(thickness)
×
1182
        self.redraw_path()
×
1183

1184
    def set_line_style(self, style):
1✔
1185
        """Set the style of the line representing the flight track."""
1186
        line_style_dict = self.plotter.get_line_style_dict()
×
1187
        if style in line_style_dict:
×
1188
            self.plotter.set_line_style(style)
×
1189
            self.redraw_path()
×
1190

1191
    def set_line_transparency(self, transparency):
1✔
1192
        """Set the transparency of the line representing the flight track."""
1193
        self.plotter.line.set_alpha(transparency)
×
1194
        self.redraw_path()
×
1195

1196
    def appropriate_epsilon(self, px=5):
1✔
1197
        """Determine an epsilon value appropriate for the current projection and
1198
           figure size.
1199

1200
        The epsilon value gives the distance required in map projection
1201
        coordinates that corresponds to approximately px Pixels in screen
1202
        coordinates. The value can be used to find the line/point that is
1203
        closest to a click while discarding clicks that are too far away
1204
        from any geometry feature.
1205
        """
1206
        return self.plotter.appropriate_epsilon(px)
1✔
1207

1208
    def appropriate_epsilon_km(self, px=5):
1✔
1209
        """Determine an epsilon value appropriate for the current projection and
1210
           figure size.
1211

1212
        The epsilon value gives the distance required in map projection
1213
        coordinates that corresponds to approximately px Pixels in screen
1214
        coordinates. The value can be used to find the line/point that is
1215
        closest to a click while discarding clicks that are too far away
1216
        from any geometry feature.
1217
        """
1218
        # (bounds = left, bottom, width, height)
1219
        ax_bounds = self.plotter.ax.bbox.bounds
1✔
1220
        diagonal = math.hypot(round(ax_bounds[2]), round(ax_bounds[3]))
1✔
1221
        plot_map = self.plotter.map
1✔
1222
        map_delta = get_distance(plot_map.llcrnrlat, plot_map.llcrnrlon, plot_map.urcrnrlat, plot_map.urcrnrlon)
1✔
1223
        km_per_px = map_delta / diagonal
1✔
1224

1225
        return km_per_px * px
1✔
1226

1227
    def get_lat_lon(self, event):
1✔
1228
        return self.plotter.map(event.xdata, event.ydata, inverse=True)[::-1]
×
1229

1230
    def button_release_insert_callback(self, event):
1✔
1231
        """Called whenever a mouse button is released.
1232

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

1236
        A vertex with same coordinates is inserted into the path in canvas.
1237

1238
        The coordinates are checked against "locations" defined in msui' config.
1239

1240
        A new waypoint with the coordinates, and name is inserted into the waypoints_model.
1241
        """
1242
        if not self.showverts or event.button != 1 or event.inaxes is None:
1✔
1243
            return
1✔
1244

1245
        # Get position for new vertex.
1246
        x, y = event.xdata, event.ydata
1✔
1247
        best_index = self.plotter.pathpatch.get_path().index_of_closest_segment(
1✔
1248
            x, y, eps=self.appropriate_epsilon())
1249
        logging.debug("TopView insert point: clicked at (%f, %f), "
1✔
1250
                      "best index: %d", x, y, best_index)
1251
        self.plotter.pathpatch.get_path().insert_vertex(best_index, [x, y], WaypointsPath.LINETO)
1✔
1252

1253
        lon, lat = self.plotter.map(x, y, inverse=True)
1✔
1254
        loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15))
1✔
1255
        if loc is not None:
1✔
1256
            (lat, lon), location = loc
×
1257
        else:
1258
            location = ""
1✔
1259
        wpm = self.waypoints_model
1✔
1260
        if len(wpm.all_waypoint_data()) > 0 and 0 < best_index <= len(wpm.all_waypoint_data()):
1✔
1261
            flightlevel = wpm.waypoint_data(best_index - 1).flightlevel
1✔
1262
        elif len(wpm.all_waypoint_data()) > 0 and best_index == 0:
×
1263
            flightlevel = wpm.waypoint_data(0).flightlevel
×
1264
        else:
1265
            logging.error("Cannot copy flightlevel. best_index: %s, len: %s",
×
1266
                          best_index, len(wpm.all_waypoint_data()))
1267
            flightlevel = 0
×
1268
        new_wp = ft.Waypoint(lat, lon, flightlevel, location=location)
1✔
1269
        # insertRows() will trigger a signal that will redraw the path.
1270
        wpm.insertRows(best_index, rows=1, waypoints=[new_wp])
1✔
1271

1272
        self._ind = None
1✔
1273

1274
    def button_release_move_callback(self, event):
1✔
1275
        """Called whenever a mouse button is released.
1276
        """
1277
        if not self.showverts or event.button != 1 or self._ind is None:
1✔
1278
            return
×
1279

1280
        # Submit the new position to the data model.
1281
        vertices = self.plotter.pathpatch.get_path().wp_vertices
1✔
1282
        lon, lat = self.plotter.map(vertices[self._ind][0], vertices[self._ind][1],
1✔
1283
                                    inverse=True)
1284
        loc = find_location(lat, lon, tolerance=self.appropriate_epsilon_km(px=15))
1✔
1285
        if loc is not None:
1✔
1286
            lat, lon = loc[0]
×
1287
        self.waypoints_model.setData(
1✔
1288
            self.waypoints_model.createIndex(self._ind, ft.LAT), QtCore.QVariant(lat), update=False)
1289
        self.waypoints_model.setData(
1✔
1290
            self.waypoints_model.createIndex(self._ind, ft.LON), QtCore.QVariant(lon))
1291

1292
        self._ind = None
1✔
1293

1294
    def button_release_delete_callback(self, event):
1✔
1295
        """Called whenever a mouse button is released.
1296
        """
1297
        if not self.showverts or event.button != 1:
1✔
1298
            return
×
1299

1300
        if self._ind is not None and self.confirm_delete_waypoint(self._ind):
1✔
1301
            # removeRows() will trigger a signal that will redraw the path.
1302
            self.waypoints_model.removeRows(self._ind)
1✔
1303

1304
        self._ind = None
1✔
1305

1306
    def motion_notify_callback(self, event):
1✔
1307
        """Called on mouse movement. Redraws the path if a vertex has been
1308
           picked and dragged.
1309
        """
1310
        if not self.showverts:
×
1311
            return
×
1312
        if self._ind is None:
×
1313
            return
×
1314
        if event.inaxes is None:
×
1315
            return
×
1316
        if event.button != 1:
×
1317
            return
×
1318
        wp_vertices = self.plotter.pathpatch.get_path().wp_vertices
×
1319
        wp_vertices[self._ind] = event.xdata, event.ydata
×
1320
        self.plotter.redraw_path(wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data())
×
1321

1322
    def qt_data_changed_listener(self, index1, index2):
1✔
1323
        """Listens to dataChanged() signals emitted by the flight track
1324
           data model. The top view can thus react to data changes
1325
           induced by another view (table, side view).
1326
        """
1327
        # Update the top view if the horizontal position of any point has been
1328
        # changed.
1329
        if index1.column() in [ft.LOCATION, ft.LAT, ft.LON, ft.FLIGHTLEVEL]:
1✔
1330
            self.update()
1✔
1331

1332
    def update(self):
1✔
1333
        """Update the path plot by updating coordinates and intermediate
1334
           great circle points from the path patch, then redrawing.
1335
        """
1336
        self.plotter.update_from_waypoints(self.waypoints_model.all_waypoint_data())
1✔
1337
        self.redraw_path()
1✔
1338

1339
    def redraw_path(self, wp_vertices=None):
1✔
1340
        """Redraw the matplotlib artists that represent the flight track
1341
           (path patch, line and waypoint scatter).
1342

1343
        If waypoint vertices are specified, they will be applied to the
1344
        graphics output. Otherwise the vertex array obtained from the path
1345
        patch will be used.
1346
        """
1347
        self.plotter.redraw_path(wp_vertices=wp_vertices, waypoints_model_data=self.waypoints_model.all_waypoint_data())
1✔
1348

1349
    # Link redraw_figure() to redraw_path().
1350
    redraw_figure = redraw_path
1✔
1351

1352
    def draw_callback(self, event):
1✔
1353
        """Extends PathInteractor.draw_callback() by drawing the scatter
1354
           instance.
1355
        """
1356
        self.plotter.draw_callback(self, event)
×
1357

1358
    def get_ind_under_point(self, event):
1✔
1359
        """Get the index of the waypoint vertex under the point
1360
           specified by event within epsilon tolerance.
1361

1362
        Uses display coordinates.
1363
        If no waypoint vertex is found, None is returned.
1364
        """
1365
        xy = np.asarray(self.plotter.pathpatch.get_path().wp_vertices)
1✔
1366
        if self.plotter.map.projection == "cyl":  # hack for wraparound
1✔
1367
            lon_min, lon_max = self.plotter.map.llcrnrlon, self.plotter.map.urcrnrlon
1✔
1368
            xy[xy[:, 0] < lon_min, 0] += 360
1✔
1369
            xy[xy[:, 0] > lon_max, 0] -= 360
1✔
1370
        xyt = self.plotter.pathpatch.get_transform().transform(xy)
1✔
1371
        xt, yt = xyt[:, 0], xyt[:, 1]
1✔
1372
        d = np.hypot(xt - event.x, yt - event.y)
1✔
1373
        ind = d.argmin()
1✔
1374
        if d[ind] >= self.epsilon:
1✔
1375
            ind = None
1✔
1376
        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