# $Id: Graph2D.py,v 1.9.2.1 2006/08/16 09:30:31 marcusva Exp $
#
# Copyright (c) 2006, Marcus von Appen
# All rights reserved.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#  * Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
#  * Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions and the following disclaimer in the documentation
#    and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""A 2D graph widget."""

from pygame import K_PLUS, K_KP_PLUS, K_MINUS, K_KP_MINUS, K_DOWN, K_LEFT
from pygame import K_RIGHT, K_UP
from Diagram import Diagram
from ocempgui.draw import Draw
from Constants import *
import base

class Graph2D (Diagram):
    """Graph2D (width, height) -> Graph2D

    A widget displaying graphs on a cartesian coordinate plane.

    The Graph2D widget displays function graphs on a cartesian
    coordinate plane using two axes.

    The axes, scale units and units attributes must have at least two
    values on assignment to fit both, the horizontal and vertical axes.

    self.axes = 't', 'v'     # Set velocity related to time.
    self.set_axes ('x', 'y') # The default.

    # seconds for the time axis, mph for the velocity axis.
    self.scale_units = 's', 'mph' 
    self.set_scale_units ('-', '-') # The default.

    # Each second has a distance of 10px to its neighbours and each mph
    # has a distance of 25px to its neighbours.
    self.units = 10, 25
    self.set_units (10, 10) # The default.
    
    Setting the 'orientation' attribute of the Graph2D widget to
    ORIENTATION_VERTICAL will cause it to be rotated clockwise by 90
    degrees.

    The Graph2D can display the names of the axes and their scale units
    next to the related axes. This can be adjusted using the
    'show_names' attribute and set_show_names() method:

    self.show_names = True # Show the names and scale units.
    self.set_show_names (False) # Do not show them.

    The axes and graph colours can be set to individual values (e.g. to
    outline certain function behaviours) through the 'axis_color' and
    'graph_color' attributes and their respective methods
    set_axis_color() and set_graph_color().

    self.axis_color = (0, 255, 0)
    self.set_axiscolor ((255, 255, 0))

    self.graph_color = (0, 255, 255)
    self.set_graph_color (255, 0, 0)

    The 'zoom_factor' attribute and set_zoom_factor() method set the zoom
    factor for the graph. Values between 0 and 1 will zoom it out while
    values greater than 1 will zoom it out.
    Note: The zoom factor directly modifies the 'units' attribute values,
    which can lead to rounding errors when using floating point zoom factors.
    This usually results in a minor discrepancy, when you try to restore the
    original values.

    # Zoom the x axis out and the y axis in.
    self.zoom_factor = 0.5, 2.0

    # Zoom both axis in.
    self.set_zoom_factor (3.0, 3.0)
    
    Default action (invoked by activate()):
    See the Diagram class.
    
    Mnemonic action (invoked by activate_mnemonic()):
    See the Diagram class.

    Signals:
    SIG_KEYDOWN       - Invoked, when a key is pressed while the Graph2D has
                        the input.
    SIG_MOUSEDOWN     - Invoked, when a mouse button is pressed on the
                        Graph2D.
    SIG_DOUBLECLICKED - Invoked, when a double-click was emitted on the
                        Graph2D.
    
    Attributes:
    show_names  - Indicates, whether the axis names and scale units should
                  be shown or not.
    axis_color  - The colour for both axes.
    graph_color - The colour for the displayed graph.
    zoom_factor - The zoom factor values for both axes.
    """
    def __init__ (self, width, height):
        Diagram.__init__ (self)

        # Some defaults.
        self._zoom_factor = (1, 1)
        self._units = (20, 20)
        self._scaleunits = ("-", "-")
        self._axes = ("x", "y")

        self._shownames = True

        # Colours.
        self._axiscolor = (0, 0, 0)
        self._graphcolor = (255, 0, 0)

        # Signals.
        self._signals[SIG_KEYDOWN] = []
        self._signals[SIG_MOUSEDOWN] = []
        self._signals[SIG_DOUBLECLICKED] = []
        
        self.minsize = width, height

    def get_scale_units (self):
        """G.get_scale_units (...) -> None

        Gets the scale units of the axes.
        """
        return self._scaleunits
    
    def set_scale_units (self, units):
        """G.set_scale_units (...) -> None

        Sets the scale units of the axes.

        Raises a TypeError, if the passed argument is not a list or tuple.
        Raises a ValueError, if the passed argument values are not strings.
        """
        if type (units) not in (list, tuple):
            raise TypeError ("units must be a list or tuple")
        if len (units) < 2:
            raise ValueError ("units must at least contains 2 values")
        ov = filter (lambda x: type (x) not in (str, unicode), units)
        if len (ov) != 0:
            raise ValueError ("values in units must be strings or unicode")
        self._scaleunits = units
        self.dirty = True

    def get_units (self):
        """G.set_units (...) -> None

        Gets the pixels per unit for dimensioning.
        """
        return self._units
    
    def set_units (self, units):
        """G.set_units (...) -> None

        Sets the pixels per unit for dimensioning.

        Raises a TypeError, if the passed argument is not a list or tuple.
        Raises a ValueError, if the passed argument values are not
        integers greater than 0.
        """
        if type (units) not in (list, tuple):
            raise TypeError ("units must be a list or tuple")
        if len (units) < 2:
            raise ValueError ("units must at least contains 2 values")
        ov = filter (lambda x: (type (x) != int) or (x <= 0), units)
        if len (ov) != 0:
            raise ValueError ("values in units must be positive integers")
        self._units = units
        self.dirty = True

    def get_axes (self):
        """G.get_axes (...) -> None

        Gets the amount and names of the axes.
        """
        return self._axes

    def set_axes (self, axes):
        """G.set_axes (...) -> None

        Sets the amount and names of the axes.

        Raises a TypeError, if the passed argument is not a list or tuple.
        Raises a ValueError, if the passed argument values are not strings.
        """
        if type (axes) not in (list, tuple):
            raise TypeError ("axes must be a list or tuple")
        if len (axes) < 2:
            raise ValueError ("axes must at least contains 2 values")
        ov = filter (lambda x: type (x) not in (str, unicode), axes)
        if len (ov) != 0:
            raise ValueError ("values in axes must be strings or unicode")
        self._axes = axes
        self.dirty = True

    def set_show_names (self, var):
        """G.set_show_names (...) -> None

        Sets, whether the axis names and scale units should be shown.

        If set to True, the names and scale units of both axes will be
        displayed besides and beneath the axes in the form 'name / unit'.
        """
        self._shownames = var
        self.dirty = True

    def set_axis_color (self, var):
        """G.set_axis_color (...) -> None

        Sets the color of the axes of the coordinate plane.
        """
        self._graphcolor = var
        self.dirty = True

    def set_graph_color (self, var):
        """G.set_graph_color (...) -> None

        Sets the color of the graph to draw.
        """
        self._graphcolor = var
        self.dirty = True

    def set_data (self, data):
        """G.set_data (...) -> None

        Sets the data to evaluate.

        Raises a TypeError, if the passed argument is not a list or tuple.
        Raises a ValueError, if the passed argument values are not
        integers or floats.
        """
        if data != None:
            if type (data) not in (list, tuple):
                raise TypeError ("data must be a list, tuple or array")
            ov = filter (lambda x: type (x) not in (int, float), data)
            if len (ov) != 0:
                raise ValueError ("vales in data must be integers or float")
        Diagram.set_data (self, data)

    def set_zoom_factor (self, x, y):
        """G.set_zoom_factor (...) -> None

        Zooms the graph in or out by modifying the pixel per units values.

        Zooms the graph in or out by modifying the pixel per units
        values. Passing 0 as zoom factor will reset the 

        Raises a TypeError, if the passed arguments are not floats or
        integers.
        Raises a ValueError, if the passed arguments are smaller than or
        equal to 0.
        """
        if (type (x) not in (float, int)) or (type (y) not in (float, int)):
            raise TypeError ("x and y must floats or integers")
        if (x <= 0) or (y <= 0):
            raise ValueError ("x and y must be grater than 0")

        ux, uy = 0, 0
        oldx, oldy = self._zoom_factor

        ux = max (1, int ((self._units[0] / oldx) * x))
        uy = max (1, int ((self._units[1] / oldy) * y))
        self._zoomfactor = (x, y)
        if self.units != (ux, uy):
            self.units = ux, uy

    def zoom_in (self):
        """ G.zoom_in () -> None

        Zooms into the graph by factor 2.
        """
        x, y = self.zoom_factor
        self.set_zoom_factor (x * 2.0, y * 2.0)

    def zoom_out (self):
        """G.zoom_out () -> None

        Zoom out of the graph by factor 2.
        """
        x, y = self.zoom_factor
        x /= 2.0
        y /= 2.0
        if x == 0:
            x = self.zoom_factor[0]
        if y == 0:
            y = self.zoom_factor[1]
        self.set_zoom_factor (x, y)
        
    def draw_bg (self):
        """G.draw_bg () -> Surface

        Draws the Graph2D background surface and returns it.

        Creates the visible background surface of the Graph2D and
        returns it to the caller.
        """
        return base.GlobalStyle.engine.draw_graph2d (self)

    def notify (self, event):
        """G.notify (...) -> None

        Notifies the Graph2D about an event.
        """
        if not self.sensitive:
            return

        if event.signal in SIGNALS_MOUSE:
            eventarea = self.rect_to_client ()

            if (event.signal == SIG_MOUSEDOWN) and \
                   eventarea.collidepoint (event.data.pos):
                if event.data.button == 1:
                    if base.debug: print "Graph2D.MOUSEDOWN"
                    self.focus = True

                # Mouse wheel.
                elif event.data.button == 4:
                    self.zoom_out ()
                elif event.data.button == 5:
                    self.zoom_in ()

                self.run_signal_handlers (SIG_MOUSEDOWN, event.data)
                event.handled = True
                
        elif (event.signal == SIG_DOUBLECLICKED):
            eventarea = self.rect_to_client ()

            if eventarea.collidepoint (event.data.pos):
                if base.debug: print "Graph2D.DOUBLECLICKED"
                # The y origin starts at the bottom left, thus we have
                # to invert the y value by using the height.
                self.origin = event.data.pos[0] - eventarea.x, \
                              self.height - event.data.pos[1] + eventarea.y
                self.run_signal_handlers (SIG_DOUBLECLICKED, event.data)
                event.handled = True
                 
        elif self.focus and (event.signal == SIG_KEYDOWN):
            # Zoom in and out.
            if event.data.key in (K_PLUS, K_KP_PLUS):
                self.zoom_in ()
            elif event.data.key in (K_MINUS, K_KP_MINUS):
                self.zoom_out ()
            # Axis movement.
            elif event.data.key == K_UP:
                self.origin = (self.origin[0], self.origin[1] - 5)
            elif event.data.key == K_DOWN:
                self.origin = (self.origin[0], self.origin[1] + 5)
            elif event.data.key == K_LEFT:
                self.origin = (self.origin[0] + 5, self.origin[1])
            elif event.data.key == K_RIGHT:
                self.origin = (self.origin[0] - 5, self.origin[1])

        Diagram.notify (self, event)
    
    def _draw_axes (self, surface, rect, origin, unitx, unity):
        """G._draw_axes (...) -> None

        Draws the coordinate axes on the surface.
        """
        style = base.GlobalStyle
        cls = self.__class__

        if self.orientation == ORIENTATION_VERTICAL:
            right = (origin[0], rect.bottom)
            left = (origin[0], rect.top)
            top = (rect.right, origin[1])
            bottom = (rect.left, origin[1])
        else:
            left = (rect.left, origin[1])
            right = (rect.right, origin[1])
            top = (origin[0], rect.top)
            bottom = (origin[0], rect.bottom)
        start = None
        end = None
        scx = 1
        scy = 1

        # Draw both, positive and negative axes
        if self.negative:
            Draw.draw_line (surface, self._axiscolor, left, right, 1)
            Draw.draw_line (surface, self._axiscolor, bottom, top, 1)
        else:
            Draw.draw_line (surface, self._axiscolor, origin, right, 1)
            Draw.draw_line (surface, self._axiscolor, origin, top, 1)

        # Axis names and units.
        if self.show_names:
            st = "%s / %s " % (self.axes[0], self.scale_units[0])
            surface_x = style.engine.draw_string (st, self.state, cls,
                                                  self.style)

            st = "%s / %s " % (self.axes[1], self.scale_units[1])
            surface_y = style.engine.draw_string (st, self.state, cls,
                                                  self.style)

            rect_sx = surface_x.get_rect()
            rext_sy = surface_y.get_rect()
            if self.orientation == ORIENTATION_VERTICAL:
                surface.blit (surface_x,
                              (right[0] - 1 - 2 * scx - rect_sx.width,
                               right[1] - rect_sx.height))
                surface.blit (surface_y, (top[0] - rect_sy.width,
                                          top[1] + 1 + 2 * scy))
            else:
                surface.blit (surface_x, (right[0] - rect_sx.width,
                                          right[1] + 1 + 2 * scx))
                surface.blit (surface_y, (top[0] + 1 + 2 * scy, top[1]))
        
        # Draw the scale unit marks.
        # From the origin right and up 
        y = origin[1]
        x = origin[0]
        while y > rect.top:
            start = (origin[0] - scy, y)
            end = (origin[0] + scy, y)
            Draw.draw_line (surface, self._axiscolor, start, end, 1)
            y -= unity
        while x < rect.right:
            start = (x, origin[1] - scx)
            end = (x, origin[1] + scx)
            Draw.draw_line (surface, self._axiscolor, start, end, 1)
            x += unitx

        # From the origin down and left.
        if self.negative:
            y = origin[1]
            while y < rect.bottom:
                start = (origin[0] - scy, y)
                end = (origin[0] + scy, y)
                Draw.draw_line (surface, self._axiscolor, start, end, 1)
                y += unity
            x = origin[0]
            while x > rect.left:
                start = (x, origin[1] - scx)
                end = (x, origin[1] + scx)
                Draw.draw_line (surface, self._axiscolor, start, end, 1)
                x -= unitx

    def draw (self):
        """W.draw () -> None

        Draws the Graph2D surface.

        Creates the visible surface of the Graph2D.
        """
        Diagram.draw (self)
        surface = self.image
        rect = surface.get_rect ()

        # Orientation swapped?
        swap = self.orientation == ORIENTATION_VERTICAL
        
        # Coordinates.
        origin = (rect.left + self.origin[0],
                  rect.bottom - self.origin[1] - 1)

        unitx = 1.0
        unity = 1.0
        if self.units and (len (self.units) > 1):
            if self.orientation == ORIENTATION_VERTICAL:
                unitx = self.units[1]
                unity = self.units[0]
            else:
                unitx = self.units[0]
                unity = self.units[1]

        self._draw_axes (surface, rect, origin, unitx, unity)
        
        data = self.data
        values = self.values

        if data and values:
            # Filter negative values.
            if not self.negative:
                data = filter (lambda x: x >= 0, data)
                values = filter (lambda y: y >= 0, values)
            
            # Create the coordinate tuples and take the unit resolution into
            # account.
            coords = []
            org0 = origin[0]
            org1 = origin[1]
            if self.orientation == ORIENTATION_VERTICAL:
                coords = map (lambda x, y: (int (org1 + (x * unitx)),
                                            int (org0 + (y * unity))),
                              data, values)
            else:
                coords = map (lambda x, y: (int (org0 + (x * unitx)),
                                            int (org1 - (y * unity))),
                              data, values)

            # Filter non-visible values.
            width = self.width
            height = self.height
            coords = filter (lambda (x, y): (0 < x < width) and \
                             (0 < y < width), coords)
            
            # Draw them.
            color = self.graph_color
            for xy in coords:
                surface.set_at (xy, color)

    show_names = property (lambda self: self._shownames,
                           lambda self, var: self.set_show_names (var),
                           doc = "Indicates, whether the axis names should " \
                           "be shown.")
    axis_color = property (lambda self: self._axiscolor,
                           lambda self, var: self.set_axis_color (var),
                           doc = "The color of the axes.")
    graph_color = property (lambda self: self._graphcolor,
                            lambda self, var: self.set_graph_color (var),
                            doc = "The color of the graph.")
    zoom_factor = property (lambda self: self._zoom_factor,
                            lambda self, (x, y): self.set_zoom_factory (x, y),
                            doc = "Zoom factor for the axes.")
