SVGHMI: detachable and discardable elements sets, Reworked geometric intersection, toward more accurate page content detection.
Moved page's widget/element dependency crawling functions so that it is possible to compute a global detachable and discardable elements sets.
Reworked geometric intersection detection logic to distinguish ovelapping and inclusion.
Goal is to include englobing and overlapping graphical elements, but not groups (would then include everything around...). Intermediate commit, to be continued.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This file is part of Beremiz, a Integrated Development Environment for
# programming IEC 61131-3 automates supporting plcopen standard and CanFestival.
#
# Copyright (C) 2012: Edouard TISSERANT and Laurent BESSARD
#
# See COPYING file for copyrights details.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
from __future__ import absolute_import
from __future__ import division
from time import time as gettime
from cycler import cycler
import numpy
import wx
import matplotlib
import matplotlib.pyplot
from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
from matplotlib.backends.backend_wxagg import _convert_agg_to_wx_bitmap
from matplotlib.backends.backend_agg import FigureCanvasAgg
from mpl_toolkits.mplot3d import Axes3D
from six.moves import xrange
from editors.DebugViewer import REFRESH_PERIOD
from controls.DebugVariablePanel.DebugVariableViewer import *
from controls.DebugVariablePanel.GraphButton import GraphButton
# Graph variable display type
GRAPH_PARALLEL, GRAPH_ORTHOGONAL = range(2)
# Canvas height
[SIZE_MINI, SIZE_MIDDLE, SIZE_MAXI] = [0, 100, 200]
CANVAS_BORDER = (30., 20.) # Border height on at bottom and top of graph
CANVAS_PADDING = 8.5 # Border inside graph where no label is drawn
VALUE_LABEL_HEIGHT = 17. # Height of variable label in graph
AXES_LABEL_HEIGHT = 12.75 # Height of variable value in graph
# Colors used cyclically for graph curves
COLOR_CYCLE = ['r', 'b', 'g', 'm', 'y', 'k']
# Color for graph cursor
CURSOR_COLOR = '#800080'
# -------------------------------------------------------------------------------
# Debug Variable Graphic Viewer Helpers
# -------------------------------------------------------------------------------
def merge_ranges(ranges):
"""
Merge variables data range in a list to return a range of minimal min range
value and maximal max range value extended of 10% for keeping a padding
around graph in canvas
@param ranges: [(range_min_value, range_max_value),...]
@return: merged_range_min_value, merged_range_max_value
"""
# Get minimal and maximal range value
min_value = max_value = None
for range_min, range_max in ranges:
# Update minimal range value
if min_value is None:
min_value = range_min
elif range_min is not None:
min_value = min(min_value, range_min)
# Update maximal range value
if max_value is None:
max_value = range_max
elif range_min is not None:
max_value = max(max_value, range_max)
# Calculate range center and width if at least one valid range is defined
if min_value is not None and max_value is not None:
center = (min_value + max_value) / 2.
range_size = max(1.0, max_value - min_value)
# Set default center and with if no valid range is defined
else:
center = 0.5
range_size = 1.0
# Return range expended from 10 %
return center - range_size * 0.55, center + range_size * 0.55
# -------------------------------------------------------------------------------
# Debug Variable Graphic Viewer Drop Target
# -------------------------------------------------------------------------------
class DebugVariableGraphicDropTarget(wx.TextDropTarget):
"""
Class that implements a custom drop target class for Debug Variable Graphic
Viewer
"""
def __init__(self, parent, window):
"""
Constructor
@param parent: Reference to Debug Variable Graphic Viewer
@param window: Reference to the Debug Variable Panel
"""
wx.TextDropTarget.__init__(self)
self.ParentControl = parent
self.ParentWindow = window
def __del__(self):
"""
Destructor
"""
# Remove reference to Debug Variable Graphic Viewer and Debug Variable
# Panel
self.ParentControl = None
self.ParentWindow = None
def OnDragOver(self, x, y, d):
"""
Function called when mouse is dragged over Drop Target
@param x: X coordinate of mouse pointer
@param y: Y coordinate of mouse pointer
@param d: Suggested default for return value
"""
# Signal parent that mouse is dragged over
self.ParentControl.OnMouseDragging(x, y)
return wx.TextDropTarget.OnDragOver(self, x, y, d)
def OnDropText(self, x, y, data):
"""
Function called when mouse is released in Drop Target
@param x: X coordinate of mouse pointer
@param y: Y coordinate of mouse pointer
@param data: Text associated to drag'n drop
"""
# Signal Debug Variable Panel to reset highlight
self.ParentWindow.ResetHighlight()
message = None
# Check that data is valid regarding DebugVariablePanel
try:
values = eval(data)
if not isinstance(values, tuple):
raise ValueError
except Exception:
message = _("Invalid value \"%s\" for debug variable") % data
values = None
# Display message if data is invalid
if message is not None:
wx.CallAfter(self.ShowMessage, message)
# Data contain a reference to a variable to debug
elif values[1] == "debug":
target_idx = self.ParentControl.GetIndex()
# If mouse is dropped in graph canvas bounding box and graph is
# not 3D canvas, graphs will be merged
rect = self.ParentControl.GetAxesBoundingBox()
if not self.ParentControl.Is3DCanvas() and rect.InsideXY(x, y):
# Default merge type is parallel
merge_type = GRAPH_PARALLEL
# If mouse is dropped in left part of graph canvas, graph
# wall be merged orthogonally
merge_rect = wx.Rect(rect.x, rect.y,
rect.width / 2., rect.height)
if merge_rect.InsideXY(x, y):
merge_type = GRAPH_ORTHOGONAL
# Merge graphs
wx.CallAfter(self.ParentWindow.MergeGraphs,
values[0], target_idx,
merge_type, force=True)
else:
_width, height = self.ParentControl.GetSize()
# Get Before which Viewer the variable has to be moved or added
# according to the position of mouse in Viewer.
if y > height // 2:
target_idx += 1
# Drag'n Drop is an internal is an internal move inside Debug
# Variable Panel
if len(values) > 2 and values[2] == "move":
self.ParentWindow.MoveValue(values[0],
target_idx)
# Drag'n Drop was initiated by another control of Beremiz
else:
self.ParentWindow.InsertValue(values[0],
target_idx,
force=True)
def OnLeave(self):
"""
Function called when mouse is leave Drop Target
"""
# Signal Debug Variable Panel to reset highlight
self.ParentWindow.ResetHighlight()
return wx.TextDropTarget.OnLeave(self)
def ShowMessage(self, message):
"""
Show error message in Error Dialog
@param message: Error message to display
"""
dialog = wx.MessageDialog(self.ParentWindow,
message,
_("Error"),
wx.OK | wx.ICON_ERROR)
dialog.ShowModal()
dialog.Destroy()
# -------------------------------------------------------------------------------
# Debug Variable Graphic Viewer Class
# -------------------------------------------------------------------------------
class DebugVariableGraphicViewer(DebugVariableViewer, FigureCanvas):
"""
Class that implements a Viewer that display variable values as a graphs
"""
def __init__(self, parent, window, items, graph_type):
"""
Constructor
@param parent: Parent wx.Window of DebugVariableText
@param window: Reference to the Debug Variable Panel
@param items: List of DebugVariableItem displayed by Viewer
@param graph_type: Graph display type (Parallel or orthogonal)
"""
DebugVariableViewer.__init__(self, window, items)
self.GraphType = graph_type # Graph type display
self.CursorTick = None # Tick of the graph cursor
# Mouse position when start dragging
self.MouseStartPos = None
# Tick when moving tick start
self.StartCursorTick = None
# Canvas size when starting to resize canvas
self.CanvasStartSize = None
# List of current displayed contextual buttons
self.ContextualButtons = []
# Reference to item for which contextual buttons was displayed
self.ContextualButtonsItem = None
# Flag indicating that zoom fit current displayed data range or whole
# data range if False
self.ZoomFit = False
# Create figure for drawing graphs
self.Figure = matplotlib.figure.Figure(facecolor='w')
# Defined border around figure in canvas
self.Figure.subplotpars.update(top=0.95, left=0.1,
bottom=0.1, right=0.95)
FigureCanvas.__init__(self, parent, -1, self.Figure)
self.SetWindowStyle(wx.WANTS_CHARS)
self.SetBackgroundColour(wx.WHITE)
# Bind wx events
self.Bind(wx.EVT_LEFT_DCLICK, self.OnLeftDClick)
self.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)
self.Bind(wx.EVT_ENTER_WINDOW, self.OnEnter)
self.Bind(wx.EVT_LEAVE_WINDOW, self.OnLeave)
self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground)
self.Bind(wx.EVT_SIZE, self.OnResize)
# Set canvas min size
canvas_size = self.GetCanvasMinSize()
self.SetMinSize(canvas_size)
# Define Viewer drop target
self.SetDropTarget(DebugVariableGraphicDropTarget(self, window))
# Connect matplotlib events
self.mpl_connect('button_press_event', self.OnCanvasButtonPressed)
self.mpl_connect('motion_notify_event', self.OnCanvasMotion)
self.mpl_connect('button_release_event', self.OnCanvasButtonReleased)
self.mpl_connect('scroll_event', self.OnCanvasScroll)
# Add buttons for zooming on current displayed data range
self.Buttons.append(
GraphButton(0, 0, "fit_graph", self.OnZoomFitButton))
# Add buttons for changing canvas size with predefined height
for size, bitmap in zip(
[SIZE_MINI, SIZE_MIDDLE, SIZE_MAXI],
["minimize_graph", "middle_graph", "maximize_graph"]):
self.Buttons.append(GraphButton(0, 0, bitmap,
self.GetOnChangeSizeButton(size)))
# Add buttons for exporting graph values to clipboard and close graph
for bitmap, callback in [
("export_graph_mini", self.OnExportGraphButton),
("delete_graph", self.OnCloseButton)]:
self.Buttons.append(GraphButton(0, 0, bitmap, callback))
# Update graphs elements
self.ResetGraphics()
self.RefreshLabelsPosition(canvas_size.height)
def AddItem(self, item):
"""
Add an item to the list of items displayed by Viewer
@param item: Item to add to the list
"""
DebugVariableViewer.AddItem(self, item)
self.ResetGraphics()
def RemoveItem(self, item):
"""
Remove an item from the list of items displayed by Viewer
@param item: Item to remove from the list
"""
DebugVariableViewer.RemoveItem(self, item)
# If list of items is not empty
if not self.ItemsIsEmpty():
# Return to parallel graph if there is only one item
# especially if it's actually orthogonal
if len(self.Items) == 1:
self.GraphType = GRAPH_PARALLEL
self.ResetGraphics()
def SetCursorTick(self, cursor_tick):
"""
Set cursor tick
@param cursor_tick: Cursor tick
"""
self.CursorTick = cursor_tick
def SetZoomFit(self, zoom_fit):
"""
Set flag indicating that zoom fit current displayed data range
@param zoom_fit: Flag for zoom fit (False: zoom fit whole data range)
"""
# Flag is different from the actual one
if zoom_fit != self.ZoomFit:
# Save new flag value
self.ZoomFit = zoom_fit
# Update button for zoom fit bitmap
self.Buttons[0].SetBitmap("full_graph" if zoom_fit else "fit_graph")
# Refresh canvas
self.RefreshViewer()
def SubscribeAllDataConsumers(self):
"""
Function that unsubscribe and remove every item that store values of
a variable that doesn't exist in PLC anymore
"""
DebugVariableViewer.SubscribeAllDataConsumers(self)
# Graph still have data to display
if not self.ItemsIsEmpty():
# Reset flag indicating that zoom fit current displayed data range
self.SetZoomFit(False)
self.ResetGraphics()
def Is3DCanvas(self):
"""
Return if Viewer is a 3D canvas
@return: True if Viewer is a 3D canvas
"""
return self.GraphType == GRAPH_ORTHOGONAL and len(self.Items) == 3
def GetButtons(self):
"""
Return list of buttons defined in Viewer
@return: List of buttons
"""
# Add contextual buttons to default buttons
return self.Buttons + self.ContextualButtons
def PopupContextualButtons(self, item, rect, direction=wx.RIGHT):
"""
Show contextual menu for item aside a label of this item defined
by the bounding box of label in figure
@param item: Item for which contextual is shown
@param rect: Bounding box of label aside which drawing menu
@param direction: Direction in which buttons must be drawn
"""
# Return immediately if contextual menu for item is already shown
if self.ContextualButtonsItem == item:
return
# Close already shown contextual menu
self.DismissContextualButtons()
# Save item for which contextual menu is shown
self.ContextualButtonsItem = item
# If item variable is forced, add button for release variable to
# contextual menu
if self.ContextualButtonsItem.IsForced():
self.ContextualButtons.append(
GraphButton(0, 0, "release", self.OnReleaseItemButton))
# Add other buttons to contextual menu
for bitmap, callback in [
("force", self.OnForceItemButton),
("export_graph_mini", self.OnExportItemGraphButton),
("delete_graph", self.OnRemoveItemButton)]:
self.ContextualButtons.append(GraphButton(0, 0, bitmap, callback))
# If buttons are shown at left side or upper side of rect, positions
# will be set in reverse order
buttons = self.ContextualButtons[:]
if direction in [wx.TOP, wx.LEFT]:
buttons.reverse()
# Set contextual menu buttons position aside rect depending on
# direction given
offset = 0
for button in buttons:
w, h = button.GetSize()
if direction in [wx.LEFT, wx.RIGHT]:
x = rect.x + (- w - offset
if direction == wx.LEFT
else rect.width + offset)
y = rect.y + (rect.height - h) // 2
offset += w
else:
x = rect.x + (rect.width - w) // 2
y = rect.y + (- h - offset
if direction == wx.TOP
else rect.height + offset)
offset += h
button.SetPosition(x, y)
button.Show()
# Refresh canvas
self.ParentWindow.ForceRefresh()
def DismissContextualButtons(self):
"""
Close current shown contextual menu
"""
# Return immediately if no contextual menu is shown
if self.ContextualButtonsItem is None:
return
# Reset variables corresponding to contextual menu
self.ContextualButtonsItem = None
self.ContextualButtons = []
# Refresh canvas
self.ParentWindow.ForceRefresh()
def IsOverContextualButton(self, x, y):
"""
Return if point is over one contextual button of Viewer
@param x: X coordinate of point
@param y: Y coordinate of point
@return: contextual button where point is over
"""
for button in self.ContextualButtons:
if button.HitTest(x, y):
return button
return None
def ExportGraph(self, item=None):
"""
Export item(s) data to clipboard in CSV format
@param item: Item from which data to export, all items if None
(default None)
"""
self.ParentWindow.CopyDataToClipboard(
[(item, [entry for entry in item.GetData()])
for item in (self.Items
if item is None
else [item])])
def OnZoomFitButton(self):
"""
Function called when Viewer Zoom Fit button is pressed
"""
# Toggle zoom fit flag value
self.SetZoomFit(not self.ZoomFit)
def GetOnChangeSizeButton(self, height):
"""
Function that generate callback function for change Viewer height to
pre-defined height button
@param height: Height that change Viewer to
@return: callback function
"""
def OnChangeSizeButton():
self.SetCanvasHeight(height)
return OnChangeSizeButton
def OnExportGraphButton(self):
"""
Function called when Viewer Export button is pressed
"""
# Export data of every item in Viewer
self.ExportGraph()
def OnForceItemButton(self):
"""
Function called when contextual menu Force button is pressed
"""
# Open dialog for forcing item variable value
self.ForceValue(self.ContextualButtonsItem)
# Close contextual menu
self.DismissContextualButtons()
def OnReleaseItemButton(self):
"""
Function called when contextual menu Release button is pressed
"""
# Release item variable value
self.ReleaseValue(self.ContextualButtonsItem)
# Close contextual menu
self.DismissContextualButtons()
def OnExportItemGraphButton(self):
"""
Function called when contextual menu Export button is pressed
"""
# Export data of item variable
self.ExportGraph(self.ContextualButtonsItem)
# Close contextual menu
self.DismissContextualButtons()
def OnRemoveItemButton(self):
"""
Function called when contextual menu Remove button is pressed
"""
# Remove item from Viewer
wx.CallAfter(self.ParentWindow.DeleteValue, self,
self.ContextualButtonsItem)
# Close contextual menu
self.DismissContextualButtons()
def HandleCursorMove(self, event):
"""
Update Cursor position according to mouse position and graph type
@param event: Mouse event
"""
start_tick, end_tick = self.ParentWindow.GetRange()
cursor_tick = None
items = self.ItemsDict.values()
# Graph is orthogonal
if self.GraphType == GRAPH_ORTHOGONAL:
# Extract items data displayed in canvas figure
start_tick = max(start_tick, self.GetItemsMinCommonTick())
end_tick = max(end_tick, start_tick)
x_data = items[0].GetData(start_tick, end_tick)
y_data = items[1].GetData(start_tick, end_tick)
# Search for the nearest point from mouse position
if len(x_data) > 0 and len(y_data) > 0:
length = min(len(x_data), len(y_data))
d = numpy.sqrt((x_data[:length, 1]-event.xdata) ** 2 +
(y_data[:length, 1]-event.ydata) ** 2)
# Set cursor tick to the tick of this point
cursor_tick = x_data[numpy.argmin(d), 0]
# Graph is parallel
else:
# Extract items tick
data = items[0].GetData(start_tick, end_tick)
# Search for point that tick is the nearest from mouse X position
# and set cursor tick to the tick of this point
if len(data) > 0:
cursor_tick = data[numpy.argmin(
numpy.abs(data[:, 0] - event.xdata)), 0]
# Update cursor tick
if cursor_tick is not None:
self.ParentWindow.SetCursorTick(cursor_tick)
def OnCanvasButtonPressed(self, event):
"""
Function called when a button of mouse is pressed
@param event: Mouse event
"""
# Get mouse position, graph Y coordinate is inverted in matplotlib
# comparing to wx
_width, height = self.GetSize()
x, y = event.x, height - event.y
# Return immediately if mouse is over a button
if self.IsOverButton(x, y):
return
# Mouse was clicked inside graph figure
if event.inaxes == self.Axes:
# Find if it was on an item label
item_idx = None
# Check every label paired with corresponding item
for i, t in ([pair for pair in enumerate(self.AxesLabels)] +
[pair for pair in enumerate(self.Labels)]):
# Get label bounding box
(x0, y0), (x1, y1) = t.get_window_extent().get_points()
rect = wx.Rect(x0, height - y1, x1 - x0, y1 - y0)
# Check if mouse was over label
if rect.InsideXY(x, y):
item_idx = i
break
# If an item label have been clicked
if item_idx is not None:
# Hide buttons and contextual buttons
self.ShowButtons(False)
self.DismissContextualButtons()
# Start a drag'n drop from mouse position in wx coordinate of
# parent
xw, yw = self.GetPosition()
self.ParentWindow.StartDragNDrop(
self, self.ItemsDict.values()[item_idx],
x + xw, y + yw, # Current mouse position
x + xw, y + yw) # Mouse position when button was clicked
# Don't handle mouse button if canvas is 3D and let matplotlib do
# the default behavior (rotate 3D axes)
elif not self.Is3DCanvas():
# Save mouse position when clicked
self.MouseStartPos = wx.Point(x, y)
# Mouse button was left button, start moving cursor
if event.button == 1:
# Save current tick in case a drag'n drop is initiate to
# restore it
self.StartCursorTick = self.CursorTick
self.HandleCursorMove(event)
# Mouse button is middle button and graph is parallel, start
# moving graph along X coordinate (tick)
elif event.button == 2 and self.GraphType == GRAPH_PARALLEL:
self.StartCursorTick = self.ParentWindow.GetRange()[0]
# Mouse was clicked outside graph figure and over resize highlight with
# left button, start resizing Viewer
elif event.button == 1 and event.y <= 5:
self.MouseStartPos = wx.Point(x, y)
self.CanvasStartSize = height
def OnCanvasButtonReleased(self, event):
"""
Function called when a button of mouse is released
@param event: Mouse event
"""
# If a drag'n drop is in progress, stop it
if self.ParentWindow.IsDragging():
_width, height = self.GetSize()
xw, yw = self.GetPosition()
item = self.ParentWindow.DraggingAxesPanel.ItemsDict.values()[0]
# Give mouse position in wx coordinate of parent
self.ParentWindow.StopDragNDrop(item.GetVariable(),
xw + event.x, yw + height - event.y)
else:
# Reset any move in progress
self.MouseStartPos = None
self.CanvasStartSize = None
# Handle button under mouse if it exist
_width, height = self.GetSize()
self.HandleButton(event.x, height - event.y)
def OnCanvasMotion(self, event):
"""
Function called when a button of mouse is moved over Viewer
@param event: Mouse event
"""
_width, height = self.GetSize()
# If a drag'n drop is in progress, move canvas dragged
if self.ParentWindow.IsDragging():
xw, yw = self.GetPosition()
# Give mouse position in wx coordinate of parent
self.ParentWindow.MoveDragNDrop(
xw + event.x,
yw + height - event.y)
# If a Viewer resize is in progress, change Viewer size
elif event.button == 1 and self.CanvasStartSize is not None:
_width, height = self.GetSize()
self.SetCanvasHeight(
self.CanvasStartSize + height - event.y - self.MouseStartPos.y)
# If no button is pressed, show or hide contextual buttons or resize
# highlight
elif event.button is None:
# Compute direction for items label according graph type
if self.GraphType == GRAPH_PARALLEL: # Graph is parallel
directions = [wx.RIGHT] * len(self.AxesLabels) + \
[wx.LEFT] * len(self.Labels)
elif len(self.AxesLabels) > 0: # Graph is orthogonal in 2D
directions = [wx.RIGHT, wx.TOP, # Directions for AxesLabels
wx.LEFT, wx.BOTTOM] # Directions for Labels
else: # Graph is orthogonal in 3D
directions = [wx.LEFT] * len(self.Labels)
# Find if mouse is over an item label
item_idx = None
menu_direction = None
for (i, t), dir in zip(
[pair for pair in enumerate(self.AxesLabels)] +
[pair for pair in enumerate(self.Labels)],
directions):
# Check every label paired with corresponding item
(x0, y0), (x1, y1) = t.get_window_extent().get_points()
rect = wx.Rect(x0, height - y1, x1 - x0, y1 - y0)
# Check if mouse was over label
if rect.InsideXY(event.x, height - event.y):
item_idx = i
menu_direction = dir
break
# If mouse is over an item label,
if item_idx is not None:
self.PopupContextualButtons(
self.ItemsDict.values()[item_idx],
rect, menu_direction)
return
# If mouse isn't over a contextual menu, hide the current shown one
# if it exists
if self.IsOverContextualButton(event.x, height - event.y) is None:
self.DismissContextualButtons()
# Update resize highlight
if event.y <= 5:
if self.SetHighlight(HIGHLIGHT_RESIZE):
self.SetCursor(wx.StockCursor(wx.CURSOR_SIZENS))
self.ParentWindow.ForceRefresh()
else:
if self.SetHighlight(HIGHLIGHT_NONE):
self.SetCursor(wx.NullCursor)
self.ParentWindow.ForceRefresh()
# Handle buttons if canvas is not 3D
elif not self.Is3DCanvas():
# If left button is pressed
if event.button == 1:
# Mouse is inside graph figure
if event.inaxes == self.Axes:
# If a cursor move is in progress, update cursor position
if self.MouseStartPos is not None:
self.HandleCursorMove(event)
# Mouse is outside graph figure, cursor move is in progress and
# there is only one item in Viewer, start a drag'n drop
elif self.MouseStartPos is not None and len(self.Items) == 1:
xw, yw = self.GetPosition()
self.ParentWindow.SetCursorTick(self.StartCursorTick)
self.ParentWindow.StartDragNDrop(
self, self.ItemsDict.values()[0],
# Current mouse position
event.x + xw, height - event.y + yw,
# Mouse position when button was clicked
self.MouseStartPos.x + xw,
self.MouseStartPos.y + yw)
# If middle button is pressed and moving graph along X coordinate
# is in progress
elif (event.button == 2 and
self.GraphType == GRAPH_PARALLEL and
self.MouseStartPos is not None):
start_tick, end_tick = self.ParentWindow.GetRange()
rect = self.GetAxesBoundingBox()
# Move graph along X coordinate
self.ParentWindow.SetCanvasPosition(
self.StartCursorTick +
(self.MouseStartPos.x - event.x) *
(end_tick - start_tick) // rect.width)
def OnCanvasScroll(self, event):
"""
Function called when a wheel mouse is use in Viewer
@param event: Mouse event
"""
# Change X range of graphs if mouse is in canvas figure and ctrl is
# pressed
if event.inaxes is not None and event.guiEvent.ControlDown():
# Calculate position of fixed tick point according to graph type
# and mouse position
if self.GraphType == GRAPH_ORTHOGONAL:
start_tick, end_tick = self.ParentWindow.GetRange()
tick = (start_tick + end_tick) / 2.
else:
tick = event.xdata
self.ParentWindow.ChangeRange(int(-event.step) // 3, tick)
# Vetoing event to prevent parent panel to be scrolled
self.ParentWindow.VetoScrollEvent = True
def OnLeftDClick(self, event):
"""
Function called when a left mouse button is double clicked
@param event: Mouse event
"""
# Check that double click was done inside figure
pos = event.GetPosition()
rect = self.GetAxesBoundingBox()
if rect.InsideXY(pos.x, pos.y):
# Reset Cursor tick to value before starting clicking
self.ParentWindow.SetCursorTick(self.StartCursorTick)
# Toggle to text Viewer(s)
self.ParentWindow.ToggleViewerType(self)
else:
event.Skip()
# Cursor tick move for each arrow key
KEY_CURSOR_INCREMENT = {
wx.WXK_LEFT: -1,
wx.WXK_RIGHT: 1,
wx.WXK_UP: 10,
wx.WXK_DOWN: -10}
def OnKeyDown(self, event):
"""
Function called when key is pressed
@param event: wx.KeyEvent
"""
# If cursor is shown and arrow key is pressed, move cursor tick
if self.CursorTick is not None:
move = self.KEY_CURSOR_INCREMENT.get(event.GetKeyCode(), None)
if move is not None:
self.ParentWindow.MoveCursorTick(move)
event.Skip()
def OnLeave(self, event):
"""
Function called when mouse leave Viewer
@param event: wx.MouseEvent
"""
# If Viewer is not resizing, reset resize highlight
if self.CanvasStartSize is None:
self.SetHighlight(HIGHLIGHT_NONE)
self.SetCursor(wx.NullCursor)
DebugVariableViewer.OnLeave(self, event)
else:
event.Skip()
def GetCanvasMinSize(self):
"""
Return the minimum size of Viewer so that all items label can be
displayed
@return: wx.Size containing Viewer minimum size
"""
# The minimum height take in account the height of all items, padding
# inside figure and border around figure
return wx.Size(200,
CANVAS_BORDER[0] + CANVAS_BORDER[1] +
2 * CANVAS_PADDING + VALUE_LABEL_HEIGHT * len(self.Items))
def SetCanvasHeight(self, height):
"""
Set Viewer size checking that it respects Viewer minimum size
@param height: Viewer height
"""
min_width, min_height = self.GetCanvasMinSize()
height = max(height, min_height)
self.SetMinSize(wx.Size(min_width, height))
self.RefreshLabelsPosition(height)
self.ParentWindow.RefreshGraphicsSizer()
def GetAxesBoundingBox(self, parent_coordinate=False):
"""
Return figure bounding box in wx coordinate
@param parent_coordinate: True if use parent coordinate (default False)
"""
# Calculate figure bounding box. Y coordinate is inverted in matplotlib
# figure comparing to wx panel
width, height = self.GetSize()
ax, ay, aw, ah = self.figure.gca().get_position().bounds
bbox = wx.Rect(ax * width, height - (ay + ah) * height - 1,
aw * width + 2, ah * height + 1)
# If parent_coordinate, add Viewer position in parent
if parent_coordinate:
xw, yw = self.GetPosition()
bbox.x += xw
bbox.y += yw
return bbox
def RefreshHighlight(self, x, y):
"""
Refresh Viewer highlight according to mouse position
@param x: X coordinate of mouse pointer
@param y: Y coordinate of mouse pointer
"""
_width, height = self.GetSize()
# Mouse is over Viewer figure and graph is not 3D
bbox = self.GetAxesBoundingBox()
if bbox.InsideXY(x, y) and not self.Is3DCanvas():
rect = wx.Rect(bbox.x, bbox.y, bbox.width // 2, bbox.height)
# Mouse is over Viewer left part of figure
if rect.InsideXY(x, y):
self.SetHighlight(HIGHLIGHT_LEFT)
# Mouse is over Viewer right part of figure
else:
self.SetHighlight(HIGHLIGHT_RIGHT)
# Mouse is over upper part of Viewer
elif y < height // 2:
# Viewer is upper one in Debug Variable Panel, show highlight
if self.ParentWindow.IsViewerFirst(self):
self.SetHighlight(HIGHLIGHT_BEFORE)
# Viewer is not the upper one, show highlight in previous one
# It prevents highlight to move when mouse leave one Viewer to
# another
else:
self.SetHighlight(HIGHLIGHT_NONE)
self.ParentWindow.HighlightPreviousViewer(self)
# Mouse is over lower part of Viewer
else:
self.SetHighlight(HIGHLIGHT_AFTER)
def OnAxesMotion(self, event):
"""
Function overriding default function called when mouse is dragged for
rotating graph preventing refresh to be called too quickly
@param event: Mouse event
"""
if self.Is3DCanvas():
# Call default function at most 10 times per second
current_time = gettime()
if current_time - self.LastMotionTime > REFRESH_PERIOD:
self.LastMotionTime = current_time
Axes3D._on_move(self.Axes, event)
def GetAddTextFunction(self):
"""
Return function for adding text in figure according to graph type
@return: Function adding text to figure
"""
text_func = (self.Axes.text2D if self.Is3DCanvas() else self.Axes.text)
def AddText(*args, **kwargs):
args = [0, 0, ""]
kwargs["transform"] = self.Axes.transAxes
return text_func(*args, **kwargs)
return AddText
def SetAxesColor(self, color):
self.Axes.set_prop_cycle(cycler('color', color))
def ResetGraphics(self):
"""
Reset figure and graphical elements displayed in it
Called any time list of items or graph type change
"""
# Clear figure from any axes defined
self.Figure.clear()
# Add 3D projection if graph is in 3D
if self.Is3DCanvas():
self.Axes = self.Figure.gca(projection='3d')
self.SetAxesColor(['b'])
# Override function to prevent too much refresh when graph is
# rotated
self.LastMotionTime = gettime()
setattr(self.Axes, "_on_move", self.OnAxesMotion)
# Init graph mouse event so that graph can be rotated
self.Axes.mouse_init()
# Set size of Z axis labels
self.Axes.tick_params(axis='z', labelsize='small')
else:
self.Axes = self.Figure.gca()
self.SetAxesColor(COLOR_CYCLE)
# Set size of X and Y axis labels
self.Axes.tick_params(axis='x', labelsize='small')
self.Axes.tick_params(axis='y', labelsize='small')
# Init variables storing graphical elements added to figure
self.Plots = [] # List of curves
self.VLine = None # Vertical line for cursor
self.HLine = None # Horizontal line for cursor (only orthogonal 2D)
self.AxesLabels = [] # List of items variable path text label
self.Labels = [] # List of items text label
# Get function to add a text in figure according to graph type
add_text_func = self.GetAddTextFunction()
# Graph type is parallel or orthogonal in 3D
if self.GraphType == GRAPH_PARALLEL or self.Is3DCanvas():
num_item = len(self.Items)
for idx in xrange(num_item):
# Get color from color cycle (black if only one item)
color = ('k' if num_item == 1 else
COLOR_CYCLE[idx % len(COLOR_CYCLE)])
# In 3D graph items variable label are not displayed as text
# in figure, but as axis title
if not self.Is3DCanvas():
# Items variable labels are in figure upper left corner
self.AxesLabels.append(
add_text_func(size='small', color=color,
verticalalignment='top'))
# Items variable labels are in figure lower right corner
self.Labels.append(
add_text_func(size='large', color=color,
horizontalalignment='right'))
# Graph type is orthogonal in 2D
else:
# X coordinate labels are in figure lower side
self.AxesLabels.append(add_text_func(size='small'))
self.Labels.append(
add_text_func(size='large',
horizontalalignment='right'))
# Y coordinate labels are vertical and in figure left side
self.AxesLabels.append(
add_text_func(size='small', rotation='vertical',
verticalalignment='bottom'))
self.Labels.append(
add_text_func(size='large', rotation='vertical',
verticalalignment='top'))
# Refresh position of labels according to Viewer size
_width, height = self.GetSize()
self.RefreshLabelsPosition(height)
def RefreshLabelsPosition(self, height):
"""
Function called when mouse leave Viewer
@param event: wx.MouseEvent
"""
# Figure position like text position in figure are expressed is ratio
# canvas size and figure size. As we want that border around figure and
# text position in figure don't change when canvas size change, we
# expressed border and text position in pixel on screen and apply the
# ratio calculated hereafter to get border and text position in
# matplotlib coordinate
canvas_ratio = 1. / height # Divide by canvas height in pixel
graph_ratio = 1. / (
(1.0 - (CANVAS_BORDER[0] + CANVAS_BORDER[1]) * canvas_ratio) *
height) # Divide by figure height in pixel
# Update position of figure (keeping up and bottom border the same
# size)
self.Figure.subplotpars.update(
top=1.0 - CANVAS_BORDER[1] * canvas_ratio,
bottom=CANVAS_BORDER[0] * canvas_ratio)
# Update position of items labels
if self.GraphType == GRAPH_PARALLEL or self.Is3DCanvas():
num_item = len(self.Items)
for idx in xrange(num_item):
# In 3D graph items variable label are not displayed
if not self.Is3DCanvas():
# Items variable labels are in figure upper left corner
self.AxesLabels[idx].set_position(
(0.05,
1.0 - (CANVAS_PADDING +
AXES_LABEL_HEIGHT * idx) * graph_ratio))
# Items variable labels are in figure lower right corner
self.Labels[idx].set_position(
(0.95,
CANVAS_PADDING * graph_ratio +
(num_item - idx - 1) * VALUE_LABEL_HEIGHT * graph_ratio))
else:
# X coordinate labels are in figure lower side
self.AxesLabels[0].set_position(
(0.1, CANVAS_PADDING * graph_ratio))
self.Labels[0].set_position(
(0.95, CANVAS_PADDING * graph_ratio))
# Y coordinate labels are vertical and in figure left side
self.AxesLabels[1].set_position(
(0.05, 2 * CANVAS_PADDING * graph_ratio))
self.Labels[1].set_position(
(0.05, 1.0 - CANVAS_PADDING * graph_ratio))
# Update subplots
self.Figure.subplots_adjust()
def RefreshViewer(self, refresh_graphics=True):
"""
Function called to refresh displayed by matplotlib canvas
@param refresh_graphics: Flag indicating that graphs have to be
refreshed (False: only label values have to be refreshed)
"""
# Refresh graphs if needed
if refresh_graphics:
# Get tick range of values to display
start_tick, end_tick = self.ParentWindow.GetRange()
# Graph is parallel
if self.GraphType == GRAPH_PARALLEL:
# Init list of data range for each variable displayed
ranges = []
# Get data and range for each variable displayed
for idx, item in enumerate(self.Items):
data, min_value, max_value = item.GetDataAndValueRange(
start_tick, end_tick, not self.ZoomFit)
# Check that data is not empty
if data is not None:
# Add variable range to list of variable data range
ranges.append((min_value, max_value))
# Add plot to canvas if not yet created
if len(self.Plots) <= idx:
self.Plots.append(
self.Axes.plot(data[:, 0], data[:, 1])[0])
# Set data to already created plot in canvas
else:
self.Plots[idx].set_data(data[:, 0], data[:, 1])
# Get X and Y axis ranges
x_min, x_max = start_tick, end_tick
y_min, y_max = merge_ranges(ranges)
# Display cursor in canvas if a cursor tick is defined and it is
# include in values tick range
if self.CursorTick is not None and \
start_tick <= self.CursorTick <= end_tick:
# Define a vertical line to display cursor position if no
# line is already defined
if self.VLine is None:
self.VLine = self.Axes.axvline(self.CursorTick,
color=CURSOR_COLOR)
# Set value of vertical line if already defined
else:
self.VLine.set_xdata((self.CursorTick, self.CursorTick))
self.VLine.set_visible(True)
# Hide vertical line if cursor tick is not defined or reset
elif self.VLine is not None:
self.VLine.set_visible(False)
# Graph is orthogonal
else:
# Update tick range, removing ticks that don't have a value for
# each variable
start_tick = max(start_tick, self.GetItemsMinCommonTick())
end_tick = max(end_tick, start_tick)
items = self.ItemsDict.values()
# Get data and range for first variable (X coordinate)
x_data, x_min, x_max = items[0].GetDataAndValueRange(
start_tick, end_tick, not self.ZoomFit)
# Get data and range for second variable (Y coordinate)
y_data, y_min, y_max = items[1].GetDataAndValueRange(
start_tick, end_tick, not self.ZoomFit)
# Normalize X and Y coordinates value range
x_min, x_max = merge_ranges([(x_min, x_max)])
y_min, y_max = merge_ranges([(y_min, y_max)])
# Get X and Y coordinates for cursor if cursor tick is defined
if self.CursorTick is not None:
x_cursor, _x_forced = items[0].GetValue(
self.CursorTick, raw=True)
y_cursor, _y_forced = items[1].GetValue(
self.CursorTick, raw=True)
# Get common data length so that each value has an x and y
# coordinate
length = (min(len(x_data), len(y_data))
if x_data is not None and y_data is not None
else 0)
# Graph is orthogonal 2D
if len(self.Items) < 3:
# Check that x and y data are not empty
if x_data is not None and y_data is not None:
# Add plot to canvas if not yet created
if len(self.Plots) == 0:
self.Plots.append(
self.Axes.plot(x_data[:, 1][:length],
y_data[:, 1][:length])[0])
# Set data to already created plot in canvas
else:
self.Plots[0].set_data(
x_data[:, 1][:length],
y_data[:, 1][:length])
# Display cursor in canvas if a cursor tick is defined and it is
# include in values tick range
if self.CursorTick is not None and \
start_tick <= self.CursorTick <= end_tick:
# Define a vertical line to display cursor x coordinate
# if no line is already defined
if self.VLine is None:
self.VLine = self.Axes.axvline(x_cursor,
color=CURSOR_COLOR)
# Set value of vertical line if already defined
else:
self.VLine.set_xdata((x_cursor, x_cursor))
# Define a horizontal line to display cursor y
# coordinate if no line is already defined
if self.HLine is None:
self.HLine = self.Axes.axhline(y_cursor,
color=CURSOR_COLOR)
# Set value of horizontal line if already defined
else:
self.HLine.set_ydata((y_cursor, y_cursor))
self.VLine.set_visible(True)
self.HLine.set_visible(True)
# Hide vertical and horizontal line if cursor tick is not
# defined or reset
else:
if self.VLine is not None:
self.VLine.set_visible(False)
if self.HLine is not None:
self.HLine.set_visible(False)
# Graph is orthogonal 3D
else:
# Remove all plots already defined in 3D canvas
while len(self.Axes.lines) > 0:
self.Axes.lines.pop()
# Get data and range for third variable (Z coordinate)
z_data, z_min, z_max = items[2].GetDataAndValueRange(
start_tick, end_tick, not self.ZoomFit)
# Normalize Z coordinate value range
z_min, z_max = merge_ranges([(z_min, z_max)])
# Check that x, y and z data are not empty
if x_data is not None and \
y_data is not None and \
z_data is not None:
# Get common data length so that each value has an x, y
# and z coordinate
length = min(length, len(z_data))
# Add plot to canvas
self.Axes.plot(x_data[:, 1][:length],
y_data[:, 1][:length],
zs=z_data[:, 1][:length])
# Display cursor in canvas if a cursor tick is defined and
# it is include in values tick range
if self.CursorTick is not None and \
start_tick <= self.CursorTick <= end_tick:
# Get Z coordinate for cursor
z_cursor, _z_forced = items[2].GetValue(
self.CursorTick, raw=True)
# Add 3 lines parallel to x, y and z axis to display
# cursor position in 3D
for kwargs in [{"xs": numpy.array([x_min, x_max])},
{"ys": numpy.array([y_min, y_max])},
{"zs": numpy.array([z_min, z_max])}]:
for param, value in [
("xs", numpy.array([x_cursor, x_cursor])),
("ys", numpy.array([y_cursor, y_cursor])),
("zs", numpy.array([z_cursor, z_cursor]))]:
kwargs.setdefault(param, value)
kwargs["color"] = CURSOR_COLOR
self.Axes.plot(**kwargs)
# Set Z axis limits
self.Axes.set_zlim(z_min, z_max)
# Set X and Y axis limits
self.Axes.set_xlim(x_min, x_max)
self.Axes.set_ylim(y_min, y_max)
# Get value and forced flag for each variable displayed in graph
# If cursor tick is not defined get value and flag of last received
# or get value and flag of variable at cursor tick
args = [(
item.GetValue(self.CursorTick)
if self.CursorTick is not None
else (item.GetValue(), item.IsForced())) for item in self.Items]
values, forced = zip(*args)
# Get path of each variable displayed simplified using panel variable
# name mask
labels = [item.GetVariable(self.ParentWindow.GetVariableNameMask())
for item in self.Items]
# Get style for each variable according to
styles = map(lambda x: {True: 'italic', False: 'normal'}[x], forced)
# Graph is orthogonal 3D, set variables path as 3D axis label
if self.Is3DCanvas():
for idx, label_func in enumerate([self.Axes.set_xlabel,
self.Axes.set_ylabel,
self.Axes.set_zlabel]):
label_func(labels[idx], fontdict={'size': 'small',
'color': COLOR_CYCLE[idx]})
# Graph is not orthogonal 3D, set variables path in axes labels
else:
for label, text in zip(self.AxesLabels, labels):
label.set_text(text)
# Set value label text and style according to value and forced flag for
# each variable displayed
for label, value, style in zip(self.Labels, values, styles):
label.set_text(value)
label.set_style(style)
# Refresh figure
self.draw()
def draw(self, drawDC=None):
"""
Render the figure.
"""
# Render figure using agg
FigureCanvasAgg.draw(self)
# Get bitmap of figure rendered
self.bitmap = _convert_agg_to_wx_bitmap(self.get_renderer(), None)
if wx.VERSION < (3, 0, 0):
self.bitmap.UseAlpha()
# Create DC for rendering graphics in bitmap
destDC = wx.MemoryDC()
destDC.SelectObject(self.bitmap)
# Get Graphics Context for DC, for anti-aliased and transparent
# rendering
destGC = wx.GCDC(destDC)
destGC.BeginDrawing()
# Get canvas size and figure bounding box in canvas
width, height = self.GetSize()
bbox = self.GetAxesBoundingBox()
# If highlight to display is resize, draw thick grey line at bottom
# side of canvas
if self.Highlight == HIGHLIGHT_RESIZE:
destGC.SetPen(HIGHLIGHT['RESIZE_PEN'])
destGC.SetBrush(HIGHLIGHT['RESIZE_BRUSH'])
destGC.DrawRectangle(0, height - 5, width, 5)
# If highlight to display is merging graph, draw 50% transparent blue
# rectangle on left or right part of figure depending on highlight type
elif self.Highlight in [HIGHLIGHT_LEFT, HIGHLIGHT_RIGHT]:
destGC.SetPen(HIGHLIGHT['DROP_PEN'])
destGC.SetBrush(HIGHLIGHT['DROP_BRUSH'])
x_offset = (bbox.width // 2
if self.Highlight == HIGHLIGHT_RIGHT
else 0)
destGC.DrawRectangle(bbox.x + x_offset, bbox.y,
bbox.width // 2, bbox.height)
# Draw other Viewer common elements
self.DrawCommonElements(destGC, self.GetButtons())
destGC.EndDrawing()
self._isDrawn = True
self.gui_repaint(drawDC=drawDC)